From ecb902565df1bde73023183c12947f60e0e2df08 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 29 Jun 2026 14:17:52 +0200 Subject: [PATCH] task --- .../src/app/tasks/tasks.component.html | 293 +++++++++++------- .../src/app/tasks/tasks.component.scss | 161 +++++++--- .../src/app/tasks/tasks.component.ts | 175 ++++++++--- 3 files changed, 443 insertions(+), 186 deletions(-) diff --git a/listify-client/src/app/tasks/tasks.component.html b/listify-client/src/app/tasks/tasks.component.html index 9b95303..558d819 100644 --- a/listify-client/src/app/tasks/tasks.component.html +++ b/listify-client/src/app/tasks/tasks.component.html @@ -4,25 +4,6 @@

Tasks

Einfache Tagesaufgaben ohne Listenbezug.

- -
- - - Tag - - - - -
@@ -39,6 +20,16 @@ /> + + Faellig am + + + Notiz +
+ + Tasks suchen + + + @if (searchTerm()) { + + } + + + + Zeitraum + + @for (option of dateFilterOptions; track option.value) { + {{ option.label }} + } + + + +
+ + Offen + Alle + Erledigt + + + @if (activeFilterCount() > 0) { + + } +
+
+ @if (loading()) { @@ -97,105 +140,135 @@ -
- @for (task of tasks(); track task.id) { - - - + @if (hasVisibleTasks()) { +

{{ visibleTasks().length }} von {{ tasks().length }} Tasks

- @if (editingTaskId() === task.id) { -
- - Titel - - - - Notiz - - - - Tag - - -
- - -
-
- } @else { -
-

{{ task.title }}

- @if (task.notes) { -

{{ task.notes }}

- } - - - {{ formatDay(task.dueDate) }} - +
+ @for (group of taskGroups(); track group.date) { +
+
+
+

{{ group.label }}

+

{{ formatDay(group.date) }}

+ {{ group.tasks.length }} +
-
- - -
- } - - - } -
+ + + + @if (editingTaskId() === task.id) { +
+ + Titel + + + + Notiz + + + + Tag + + +
+ + +
+
+ } @else { +
+

{{ task.title }}

+ @if (task.notes) { +

{{ task.notes }}

+ } +
+ +
+ + +
+ } +
+ + } +
+ + } +
+ } @else { + + + +

Keine Treffer

+

Mit den aktuellen Filtern wurden keine Tasks gefunden.

+ +
+
+ } } @else { -

Keine Tasks fuer diesen Tag

-

Lege oben eine Aufgabe an, die an diesem Tag erledigt werden soll.

+

Noch keine Tasks

+

Lege oben eine Aufgabe an, die an einem bestimmten Tag erledigt werden soll.

} diff --git a/listify-client/src/app/tasks/tasks.component.scss b/listify-client/src/app/tasks/tasks.component.scss index 758ac7d..b762265 100644 --- a/listify-client/src/app/tasks/tasks.component.scss +++ b/listify-client/src/app/tasks/tasks.component.scss @@ -1,17 +1,3 @@ -.day-controls { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto auto; - align-items: center; - gap: 0.35rem; - width: 100%; - min-width: 0; -} - -.day-controls mat-form-field { - min-width: 0; - width: 100%; -} - .task-create-card { margin-bottom: 1rem; overflow: hidden; @@ -38,6 +24,48 @@ justify-self: start; } +.task-controls { + display: grid; + gap: 0.75rem; + min-width: 0; + margin: 0 0 1rem; + padding: 0.75rem; + border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--mat-sys-surface) 88%, transparent); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06); +} + +.task-controls mat-form-field { + min-width: 0; + width: 100%; +} + +.status-filter-row { + display: grid; + gap: 0.65rem; + min-width: 0; +} + +.status-filter-row mat-button-toggle-group { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + min-width: 0; + width: 100%; + overflow: hidden; + border-color: color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); + border-radius: 8px; +} + +.status-filter-row mat-button-toggle, +.status-filter-row button { + max-width: 100%; +} + +.status-filter-row button { + justify-self: start; +} + .task-summary { display: flex; flex-wrap: wrap; @@ -46,20 +74,78 @@ color: var(--mat-sys-on-surface-variant); } -.task-summary span, -.task-content span { +.result-count { + margin: 0 0 0.85rem; + color: var(--mat-sys-on-surface-variant); + font-size: 0.9rem; +} + +.task-summary span { display: inline-flex; align-items: center; gap: 0.35rem; } -.task-summary mat-icon, -.task-content mat-icon { +.task-summary mat-icon { width: 18px; height: 18px; font-size: 18px; } +.task-groups { + display: grid; + gap: 1rem; + min-width: 0; +} + +.task-day-group { + display: grid; + gap: 0.65rem; + min-width: 0; +} + +.task-day-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + min-width: 0; + padding: 0.15rem 0.15rem 0; +} + +.task-day-header h2, +.task-day-header p { + margin: 0; +} + +.task-day-header h2 { + overflow-wrap: anywhere; + font-size: 1.1rem; + font-weight: 650; +} + +.task-day-header p { + color: var(--mat-sys-on-surface-variant); + font-size: 0.85rem; +} + +.task-day-header span { + display: inline-grid; + place-items: center; + flex: 0 0 auto; + min-width: 32px; + height: 32px; + padding-inline: 0.45rem; + border-radius: 999px; + background: color-mix(in srgb, var(--mat-sys-primary) 12%, transparent); + color: var(--mat-sys-primary); + font-weight: 650; +} + +.today-group .task-day-header h2 { + color: var(--mat-sys-primary); +} + .task-list { display: grid; gap: 0.75rem; @@ -90,14 +176,14 @@ min-width: 0; } -.task-content h2 { +.task-content h3 { margin: 0; overflow-wrap: anywhere; font-size: 1rem; font-weight: 600; } -.task-card.completed .task-content h2 { +.task-card.completed .task-content h3 { color: var(--mat-sys-on-surface-variant); text-decoration: line-through; } @@ -108,12 +194,6 @@ overflow-wrap: anywhere; } -.task-content span { - margin-top: 0.45rem; - color: var(--mat-sys-on-surface-variant); - font-size: 0.85rem; -} - .task-actions { display: flex; flex: 0 0 auto; @@ -134,15 +214,6 @@ } @media (max-width: 520px) { - .day-controls { - grid-template-columns: auto minmax(0, 1fr) auto; - } - - .day-controls button[mat-stroked-button] { - grid-column: 1 / -1; - justify-self: start; - } - .task-card mat-card-content { grid-template-columns: auto minmax(0, 1fr); } @@ -153,12 +224,22 @@ } @media (min-width: 701px) { - .day-controls { - width: min(100%, 440px); - } - .task-create-card mat-card-content { - grid-template-columns: minmax(220px, 1.2fr) minmax(180px, 1fr) auto; + grid-template-columns: minmax(220px, 1.2fr) minmax(170px, 0.7fr) minmax(180px, 1fr) auto; align-items: start; } + + .task-controls { + grid-template-columns: minmax(260px, 1.2fr) minmax(180px, 0.55fr); + align-items: start; + gap: 0.85rem 1rem; + margin-bottom: 1.35rem; + padding: 1rem; + } + + .status-filter-row { + grid-column: 1 / -1; + grid-template-columns: minmax(300px, 1fr) auto; + align-items: center; + } } diff --git a/listify-client/src/app/tasks/tasks.component.ts b/listify-client/src/app/tasks/tasks.component.ts index 8b05319..a6a72ae 100644 --- a/listify-client/src/app/tasks/tasks.component.ts +++ b/listify-client/src/app/tasks/tasks.component.ts @@ -2,28 +2,41 @@ import { Component, OnInit, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { finalize } from 'rxjs'; import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatCardModule } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { getAuthErrorMessage } from '../auth/error-message'; import { TasksService } from './tasks.service'; import { UserTask } from './tasks.models'; +type TaskStatusFilter = 'open' | 'all' | 'completed'; +type TaskDateFilter = 'all' | 'today' | 'future' | 'overdue'; + +interface TaskDayGroup { + date: string; + label: string; + tasks: UserTask[]; +} + @Component({ selector: 'app-tasks', imports: [ FormsModule, MatButtonModule, + MatButtonToggleModule, MatCardModule, MatCheckboxModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, + MatSelectModule, MatSnackBarModule, ], templateUrl: './tasks.component.html', @@ -39,16 +52,58 @@ export class TasksComponent implements OnInit { protected readonly updatingTaskId = signal(null); protected readonly deletingTaskId = signal(null); protected readonly errorMessage = signal(null); - protected readonly selectedDate = signal(this.todayKey()); + protected readonly newDueDate = signal(this.todayKey()); protected readonly newTitle = signal(''); protected readonly newNotes = signal(''); protected readonly editingTaskId = signal(null); protected readonly editTitle = signal(''); protected readonly editNotes = signal(''); protected readonly editDueDate = signal(this.todayKey()); + protected readonly searchTerm = signal(''); + protected readonly statusFilter = signal('open'); + protected readonly dateFilter = signal('all'); + protected readonly today = signal(this.todayKey()); protected readonly openTasks = computed(() => this.tasks().filter((task) => !task.completed)); protected readonly completedTasks = computed(() => this.tasks().filter((task) => task.completed)); protected readonly hasTasks = computed(() => this.tasks().length > 0); + protected readonly visibleTasks = computed(() => { + const term = this.searchTerm().trim().toLowerCase(); + const today = this.today(); + + return this.tasks().filter((task) => { + const matchesStatus = + this.statusFilter() === 'all' || + (this.statusFilter() === 'open' && !task.completed) || + (this.statusFilter() === 'completed' && task.completed); + const matchesDate = + this.dateFilter() === 'all' || + (this.dateFilter() === 'today' && task.dueDate === today) || + (this.dateFilter() === 'future' && task.dueDate > today) || + (this.dateFilter() === 'overdue' && task.dueDate < today); + const matchesSearch = + !term || + [task.title, task.notes ?? '', task.dueDate].join(' ').toLowerCase().includes(term); + + return matchesStatus && matchesDate && matchesSearch; + }); + }); + protected readonly taskGroups = computed(() => this.groupTasksByDay(this.visibleTasks())); + protected readonly hasVisibleTasks = computed(() => this.visibleTasks().length > 0); + protected readonly activeFilterCount = computed( + () => + Number(this.searchTerm().trim().length > 0) + + Number(this.statusFilter() !== 'open') + + Number(this.dateFilter() !== 'all'), + ); + protected readonly dateFilterOptions: ReadonlyArray<{ + value: TaskDateFilter; + label: string; + }> = [ + { value: 'all', label: 'Alle Tage' }, + { value: 'today', label: 'Heute' }, + { value: 'future', label: 'Zukunft' }, + { value: 'overdue', label: 'Ueberfaellig' }, + ]; ngOnInit(): void { this.loadTasks(); @@ -58,7 +113,9 @@ export class TasksComponent implements OnInit { this.loading.set(true); this.errorMessage.set(null); - this.tasksService.listTasks(this.selectedDate()).subscribe({ + this.today.set(this.todayKey()); + + this.tasksService.listTasks().subscribe({ next: (tasks) => { this.tasks.set(this.sortTasks(tasks)); this.loading.set(false); @@ -82,7 +139,7 @@ export class TasksComponent implements OnInit { .createTask({ title, notes: this.optionalText(this.newNotes()), - dueDate: this.selectedDate(), + dueDate: this.newDueDate(), }) .pipe(finalize(() => this.saving.set(false))) .subscribe({ @@ -129,7 +186,7 @@ export class TasksComponent implements OnInit { this.editingTaskId.set(null); this.editTitle.set(''); this.editNotes.set(''); - this.editDueDate.set(this.selectedDate()); + this.editDueDate.set(this.newDueDate()); } protected saveEdit(task: UserTask): void { @@ -149,13 +206,7 @@ export class TasksComponent implements OnInit { .pipe(finalize(() => this.updatingTaskId.set(null))) .subscribe({ next: (updatedTask) => { - if (updatedTask.dueDate !== this.selectedDate()) { - this.tasks.update((tasks) => - tasks.filter((existingTask) => existingTask.id !== updatedTask.id), - ); - } else { - this.replaceTask(updatedTask); - } + this.replaceTask(updatedTask); this.cancelEdit(); }, error: (error: unknown) => { @@ -187,28 +238,12 @@ export class TasksComponent implements OnInit { }); } - protected previousDay(): void { - this.shiftSelectedDate(-1); - } - - protected nextDay(): void { - this.shiftSelectedDate(1); - } - - protected selectToday(): void { - this.selectedDate.set(this.todayKey()); - this.cancelEdit(); - this.loadTasks(); - } - - protected selectDate(value: string): void { - if (!value || value === this.selectedDate()) { + protected selectNewDueDate(value: string): void { + if (!value || value === this.newDueDate()) { return; } - this.selectedDate.set(value); - this.cancelEdit(); - this.loadTasks(); + this.newDueDate.set(value); } protected formatDay(value: string): string { @@ -217,13 +252,26 @@ export class TasksComponent implements OnInit { return year && month && day ? `${day}.${month}.${year}` : value; } - private shiftSelectedDate(offsetDays: number): void { - const date = new Date(`${this.selectedDate()}T00:00:00`); + protected dayLabel(value: string): string { + if (value === this.today()) { + return 'Heute'; + } - date.setDate(date.getDate() + offsetDays); - this.selectedDate.set(this.toDateInputValue(date)); - this.cancelEdit(); - this.loadTasks(); + if (value === this.shiftDayKey(this.today(), 1)) { + return 'Morgen'; + } + + if (value === this.shiftDayKey(this.today(), -1)) { + return 'Gestern'; + } + + return this.formatDay(value); + } + + protected resetFilters(): void { + this.searchTerm.set(''); + this.statusFilter.set('open'); + this.dateFilter.set('all'); } private replaceTask(task: UserTask): void { @@ -240,10 +288,65 @@ export class TasksComponent implements OnInit { return left.completed ? 1 : -1; } + const dueDateComparison = this.compareDayKeys(left.dueDate, right.dueDate); + + if (dueDateComparison !== 0) { + return dueDateComparison; + } + return new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime(); }); } + private groupTasksByDay(tasks: UserTask[]): TaskDayGroup[] { + const groupedTasks = new Map(); + + for (const task of this.sortTasks(tasks)) { + groupedTasks.set(task.dueDate, [...(groupedTasks.get(task.dueDate) ?? []), task]); + } + + return [...groupedTasks.entries()] + .sort(([leftDate], [rightDate]) => this.compareDayKeys(leftDate, rightDate)) + .map(([date, dayTasks]) => ({ + date, + label: this.dayLabel(date), + tasks: dayTasks, + })); + } + + private compareDayKeys(left: string, right: string): number { + return this.dayRank(left) - this.dayRank(right) || left.localeCompare(right); + } + + private dayRank(value: string): number { + const today = this.today(); + + if (value === today) { + return 0; + } + + if (value > today) { + return 100000 + this.daysBetween(today, value); + } + + return 200000 + this.daysBetween(value, today); + } + + private daysBetween(startDate: string, endDate: string): number { + const start = new Date(`${startDate}T00:00:00`).getTime(); + const end = new Date(`${endDate}T00:00:00`).getTime(); + + return Math.round((end - start) / 86_400_000); + } + + private shiftDayKey(value: string, offsetDays: number): string { + const date = new Date(`${value}T00:00:00`); + + date.setDate(date.getDate() + offsetDays); + + return this.toDateInputValue(date); + } + private optionalText(value: string): string | undefined { const trimmed = value.trim(); return trimmed ? trimmed : undefined;