This commit is contained in:
Bastian Wagner
2026-06-29 14:17:52 +02:00
parent 41a37ecd6a
commit ecb902565d
3 changed files with 443 additions and 186 deletions

View File

@@ -4,25 +4,6 @@
<h1>Tasks</h1>
<p>Einfache Tagesaufgaben ohne Listenbezug.</p>
</div>
<div class="day-controls" aria-label="Tag auswaehlen">
<button mat-icon-button type="button" aria-label="Vorheriger Tag" (click)="previousDay()">
<mat-icon aria-hidden="true">chevron_left</mat-icon>
</button>
<mat-form-field appearance="outline">
<mat-label>Tag</mat-label>
<input
matInput
type="date"
[value]="selectedDate()"
(change)="selectDate($any($event.target).value)"
/>
</mat-form-field>
<button mat-icon-button type="button" aria-label="Naechster Tag" (click)="nextDay()">
<mat-icon aria-hidden="true">chevron_right</mat-icon>
</button>
<button mat-stroked-button type="button" (click)="selectToday()">Heute</button>
</div>
</header>
<mat-card class="task-create-card" appearance="outlined">
@@ -39,6 +20,16 @@
/>
</mat-form-field>
<mat-form-field appearance="outline" class="task-date-field">
<mat-label>Faellig am</mat-label>
<input
matInput
type="date"
[value]="newDueDate()"
(change)="selectNewDueDate($any($event.target).value)"
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Notiz</mat-label>
<input
@@ -66,6 +57,58 @@
</mat-card-content>
</mat-card>
<section class="task-controls" aria-label="Tasks filtern">
<mat-form-field appearance="outline" class="search-field">
<mat-label>Tasks suchen</mat-label>
<mat-icon matPrefix aria-hidden="true">search</mat-icon>
<input
matInput
type="search"
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
/>
@if (searchTerm()) {
<button
mat-icon-button
matSuffix
type="button"
aria-label="Suche loeschen"
(click)="searchTerm.set('')"
>
<mat-icon aria-hidden="true">close</mat-icon>
</button>
}
</mat-form-field>
<mat-form-field appearance="outline" class="date-filter-field">
<mat-label>Zeitraum</mat-label>
<mat-select [value]="dateFilter()" (selectionChange)="dateFilter.set($event.value)">
@for (option of dateFilterOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<div class="status-filter-row">
<mat-button-toggle-group
[value]="statusFilter()"
(change)="statusFilter.set($event.value)"
aria-label="Task Status"
>
<mat-button-toggle value="open">Offen</mat-button-toggle>
<mat-button-toggle value="all">Alle</mat-button-toggle>
<mat-button-toggle value="completed">Erledigt</mat-button-toggle>
</mat-button-toggle-group>
@if (activeFilterCount() > 0) {
<button mat-button type="button" (click)="resetFilters()">
<mat-icon aria-hidden="true">restart_alt</mat-icon>
Zuruecksetzen
</button>
}
</div>
</section>
@if (loading()) {
<mat-card class="state-card" appearance="outlined">
<mat-card-content>
@@ -97,105 +140,135 @@
</span>
</div>
<div class="task-list">
@for (task of tasks(); track task.id) {
<mat-card class="task-card" [class.completed]="task.completed" appearance="outlined">
<mat-card-content>
<mat-checkbox
[checked]="task.completed"
[disabled]="updatingTaskId() === task.id"
(change)="toggleTask(task, $event.checked)"
[attr.aria-label]="task.title + ' erledigt'"
/>
@if (hasVisibleTasks()) {
<p class="result-count">{{ visibleTasks().length }} von {{ tasks().length }} Tasks</p>
@if (editingTaskId() === task.id) {
<div class="task-edit-form">
<mat-form-field appearance="outline">
<mat-label>Titel</mat-label>
<input
matInput
type="text"
[value]="editTitle()"
(input)="editTitle.set($any($event.target).value)"
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Notiz</mat-label>
<input
matInput
type="text"
[value]="editNotes()"
(input)="editNotes.set($any($event.target).value)"
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Tag</mat-label>
<input
matInput
type="date"
[value]="editDueDate()"
(change)="editDueDate.set($any($event.target).value)"
/>
</mat-form-field>
<div class="task-edit-actions">
<button
mat-flat-button
type="button"
[disabled]="!editTitle().trim() || updatingTaskId() === task.id"
(click)="saveEdit(task)"
>
<mat-icon aria-hidden="true">save</mat-icon>
Speichern
</button>
<button mat-button type="button" (click)="cancelEdit()">Abbrechen</button>
</div>
</div>
} @else {
<div class="task-content">
<h2>{{ task.title }}</h2>
@if (task.notes) {
<p>{{ task.notes }}</p>
}
<span>
<mat-icon aria-hidden="true">event</mat-icon>
{{ formatDay(task.dueDate) }}
</span>
<div class="task-groups">
@for (group of taskGroups(); track group.date) {
<section class="task-day-group" [class.today-group]="group.date === today()">
<header class="task-day-header">
<div>
<h2>{{ group.label }}</h2>
<p>{{ formatDay(group.date) }}</p>
</div>
<span>{{ group.tasks.length }}</span>
</header>
<div class="task-actions">
<button
mat-icon-button
type="button"
aria-label="Task bearbeiten"
(click)="startEdit(task)"
<div class="task-list">
@for (task of group.tasks; track task.id) {
<mat-card
class="task-card"
[class.completed]="task.completed"
appearance="outlined"
>
<mat-icon aria-hidden="true">edit</mat-icon>
</button>
<button
mat-icon-button
type="button"
aria-label="Task loeschen"
[disabled]="deletingTaskId() === task.id"
(click)="deleteTask(task)"
>
@if (deletingTaskId() === task.id) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">delete</mat-icon>
}
</button>
</div>
}
</mat-card-content>
</mat-card>
}
</div>
<mat-card-content>
<mat-checkbox
[checked]="task.completed"
[disabled]="updatingTaskId() === task.id"
(change)="toggleTask(task, $event.checked)"
[attr.aria-label]="task.title + ' erledigt'"
/>
@if (editingTaskId() === task.id) {
<div class="task-edit-form">
<mat-form-field appearance="outline">
<mat-label>Titel</mat-label>
<input
matInput
type="text"
[value]="editTitle()"
(input)="editTitle.set($any($event.target).value)"
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Notiz</mat-label>
<input
matInput
type="text"
[value]="editNotes()"
(input)="editNotes.set($any($event.target).value)"
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Tag</mat-label>
<input
matInput
type="date"
[value]="editDueDate()"
(change)="editDueDate.set($any($event.target).value)"
/>
</mat-form-field>
<div class="task-edit-actions">
<button
mat-flat-button
type="button"
[disabled]="!editTitle().trim() || updatingTaskId() === task.id"
(click)="saveEdit(task)"
>
<mat-icon aria-hidden="true">save</mat-icon>
Speichern
</button>
<button mat-button type="button" (click)="cancelEdit()">Abbrechen</button>
</div>
</div>
} @else {
<div class="task-content">
<h3>{{ task.title }}</h3>
@if (task.notes) {
<p>{{ task.notes }}</p>
}
</div>
<div class="task-actions">
<button
mat-icon-button
type="button"
aria-label="Task bearbeiten"
(click)="startEdit(task)"
>
<mat-icon aria-hidden="true">edit</mat-icon>
</button>
<button
mat-icon-button
type="button"
aria-label="Task loeschen"
[disabled]="deletingTaskId() === task.id"
(click)="deleteTask(task)"
>
@if (deletingTaskId() === task.id) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">delete</mat-icon>
}
</button>
</div>
}
</mat-card-content>
</mat-card>
}
</div>
</section>
}
</div>
} @else {
<mat-card class="state-card" appearance="outlined">
<mat-card-content>
<mat-icon aria-hidden="true">filter_alt_off</mat-icon>
<h2>Keine Treffer</h2>
<p>Mit den aktuellen Filtern wurden keine Tasks gefunden.</p>
<button mat-stroked-button type="button" (click)="resetFilters()">
<mat-icon aria-hidden="true">restart_alt</mat-icon>
Filter zuruecksetzen
</button>
</mat-card-content>
</mat-card>
}
} @else {
<mat-card class="state-card" appearance="outlined">
<mat-card-content>
<mat-icon aria-hidden="true">task_alt</mat-icon>
<h2>Keine Tasks fuer diesen Tag</h2>
<p>Lege oben eine Aufgabe an, die an diesem Tag erledigt werden soll.</p>
<h2>Noch keine Tasks</h2>
<p>Lege oben eine Aufgabe an, die an einem bestimmten Tag erledigt werden soll.</p>
</mat-card-content>
</mat-card>
}

View File

@@ -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;
}
}

View File

@@ -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<string | null>(null);
protected readonly deletingTaskId = signal<string | null>(null);
protected readonly errorMessage = signal<string | null>(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<string | null>(null);
protected readonly editTitle = signal('');
protected readonly editNotes = signal('');
protected readonly editDueDate = signal(this.todayKey());
protected readonly searchTerm = signal('');
protected readonly statusFilter = signal<TaskStatusFilter>('open');
protected readonly dateFilter = signal<TaskDateFilter>('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<string, UserTask[]>();
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;