This commit is contained in:
Bastian Wagner
2026-06-29 16:26:29 +02:00
parent 47d61e6427
commit e4d4e78d74
4 changed files with 237 additions and 2 deletions

View File

@@ -31,6 +31,67 @@
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
} @else if (dashboard()) { } @else if (dashboard()) {
<section class="dashboard-section" aria-labelledby="current-tasks-title">
<div class="section-heading">
<div>
<h2 id="current-tasks-title">Aktuelle Tasks</h2>
<p>Heute faellige und ueberfaellige Aufgaben.</p>
</div>
<a mat-stroked-button routerLink="/tasks">
<mat-icon aria-hidden="true">task_alt</mat-icon>
Alle Tasks
</a>
</div>
@if (tasksLoading()) {
<mat-card class="state-card compact-state" appearance="outlined">
<mat-card-content>
<mat-progress-spinner mode="indeterminate" diameter="28" />
<h2>Tasks werden geladen</h2>
</mat-card-content>
</mat-card>
} @else if (hasCurrentTasks()) {
<mat-card class="current-tasks-card" appearance="outlined">
<mat-card-content>
<div class="current-task-list">
@for (task of currentTasks(); track task.id) {
<div class="current-task-row" [class.overdue]="task.dueDate < dashboard()!.dateKey">
<mat-checkbox
[checked]="task.completed"
[disabled]="updatingTaskId() === task.id"
(change)="completeTask(task)"
aria-label="Task erledigen"
/>
<div class="current-task-main">
<strong>{{ task.title }}</strong>
@if (task.notes) {
<span>{{ task.notes }}</span>
}
</div>
<span class="task-date-chip">
<mat-icon aria-hidden="true">
{{ task.dueDate < dashboard()!.dateKey ? 'priority_high' : 'today' }}
</mat-icon>
{{ taskDateLabel(task) }}
</span>
</div>
}
</div>
</mat-card-content>
</mat-card>
} @else {
<mat-card class="state-card compact-state" appearance="outlined">
<mat-card-content>
<mat-icon aria-hidden="true">task_alt</mat-icon>
<h2>Keine aktuellen Tasks</h2>
<p>Heute ist nichts offen oder ueberfaellig.</p>
</mat-card-content>
</mat-card>
}
</section>
<section class="dashboard-section" aria-labelledby="important-lists-title"> <section class="dashboard-section" aria-labelledby="important-lists-title">
<div class="section-heading"> <div class="section-heading">
<div> <div>

View File

@@ -37,7 +37,8 @@
} }
.important-list-card, .important-list-card,
.suggestion-card { .suggestion-card,
.current-tasks-card {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
@@ -46,6 +47,10 @@
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
} }
.compact-state mat-card-content {
padding: 1rem;
}
.important-list-card mat-card-title, .important-list-card mat-card-title,
.important-list-card mat-card-subtitle, .important-list-card mat-card-subtitle,
.suggestion-card mat-card-title, .suggestion-card mat-card-title,
@@ -53,6 +58,73 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.current-task-list {
display: grid;
gap: 0.45rem;
}
.current-task-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 0.35rem 0.65rem;
min-width: 0;
padding: 0.55rem 0;
border-bottom: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 64%, transparent);
}
.current-task-row:last-child {
border-bottom: 0;
}
.current-task-main {
display: grid;
gap: 0.15rem;
min-width: 0;
}
.current-task-main strong,
.current-task-main span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.current-task-main strong {
font-weight: 600;
}
.current-task-main span {
color: var(--mat-sys-on-surface-variant);
font-size: 0.85rem;
}
.task-date-chip {
display: inline-flex;
grid-column: 2;
align-items: center;
width: fit-content;
max-width: 100%;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--mat-sys-primary-container) 65%, transparent);
color: var(--mat-sys-on-primary-container);
font-size: 0.8rem;
line-height: 1.2;
}
.current-task-row.overdue .task-date-chip {
background: color-mix(in srgb, var(--mat-sys-error-container) 78%, transparent);
color: var(--mat-sys-on-error-container);
}
.task-date-chip mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
.dashboard-meta { .dashboard-meta {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -129,6 +201,14 @@ a mat-progress-spinner {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem; gap: 1rem;
} }
.current-task-row {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.task-date-chip {
grid-column: auto;
}
} }
@media (min-width: 1040px) { @media (min-width: 1040px) {

View File

@@ -4,11 +4,14 @@ import { RouterLink } from '@angular/router';
import { finalize } from 'rxjs'; import { finalize } from 'rxjs';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { getAuthErrorMessage } from '../auth/error-message'; import { getAuthErrorMessage } from '../auth/error-message';
import { TasksService } from '../tasks/tasks.service';
import { UserTask } from '../tasks/tasks.models';
import { DashboardResponse, DashboardWeeklySuggestion } from './dashboard.models'; import { DashboardResponse, DashboardWeeklySuggestion } from './dashboard.models';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
@@ -19,6 +22,7 @@ import { DashboardService } from './dashboard.service';
RouterLink, RouterLink,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
MatCheckboxModule,
MatIconModule, MatIconModule,
MatProgressBarModule, MatProgressBarModule,
MatProgressSpinnerModule, MatProgressSpinnerModule,
@@ -29,21 +33,43 @@ import { DashboardService } from './dashboard.service';
}) })
export class DashboardComponent implements OnInit { export class DashboardComponent implements OnInit {
private readonly dashboardService = inject(DashboardService); private readonly dashboardService = inject(DashboardService);
private readonly tasksService = inject(TasksService);
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
protected readonly dashboard = signal<DashboardResponse | null>(null); protected readonly dashboard = signal<DashboardResponse | null>(null);
protected readonly tasks = signal<UserTask[]>([]);
protected readonly loading = signal(true); protected readonly loading = signal(true);
protected readonly tasksLoading = signal(true);
protected readonly errorMessage = signal<string | null>(null); protected readonly errorMessage = signal<string | null>(null);
protected readonly creatingSuggestionId = signal<string | null>(null); protected readonly creatingSuggestionId = signal<string | null>(null);
protected readonly updatingTaskId = signal<string | null>(null);
protected readonly hasImportantLists = computed( protected readonly hasImportantLists = computed(
() => (this.dashboard()?.importantLists.length ?? 0) > 0, () => (this.dashboard()?.importantLists.length ?? 0) > 0,
); );
protected readonly currentTasks = computed(() => {
const today = this.dashboard()?.dateKey ?? this.todayKey();
return this.tasks()
.filter((task) => !task.completed && task.dueDate <= today)
.sort((left, right) => {
const dueDateComparison = left.dueDate.localeCompare(right.dueDate);
if (dueDateComparison !== 0) {
return dueDateComparison;
}
return new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime();
})
.slice(0, 6);
});
protected readonly hasCurrentTasks = computed(() => this.currentTasks().length > 0);
protected readonly suggestions = computed( protected readonly suggestions = computed(
() => this.dashboard()?.weeklySuggestions.suggestions ?? [], () => this.dashboard()?.weeklySuggestions.suggestions ?? [],
); );
ngOnInit(): void { ngOnInit(): void {
this.loadDashboard(); this.loadDashboard();
this.loadTasks();
} }
protected loadDashboard(): void { protected loadDashboard(): void {
@@ -62,6 +88,48 @@ export class DashboardComponent implements OnInit {
}); });
} }
protected loadTasks(): void {
this.tasksLoading.set(true);
this.tasksService.listTasks().subscribe({
next: (tasks) => {
this.tasks.set(tasks);
this.tasksLoading.set(false);
},
error: (error: unknown) => {
this.tasksLoading.set(false);
this.snackBar.open(getAuthErrorMessage(error), 'OK', {
duration: 5000,
});
},
});
}
protected completeTask(task: UserTask): void {
if (this.updatingTaskId()) {
return;
}
this.updatingTaskId.set(task.id);
this.tasksService
.updateTask(task.id, { completed: true })
.pipe(finalize(() => this.updatingTaskId.set(null)))
.subscribe({
next: (updatedTask) => {
this.tasks.update((tasks) =>
tasks.map((existingTask) =>
existingTask.id === updatedTask.id ? updatedTask : existingTask,
),
);
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', {
duration: 5000,
});
},
});
}
protected createSuggestion(suggestion: DashboardWeeklySuggestion): void { protected createSuggestion(suggestion: DashboardWeeklySuggestion): void {
if (suggestion.createdListId || this.creatingSuggestionId()) { if (suggestion.createdListId || this.creatingSuggestionId()) {
return; return;
@@ -102,4 +170,29 @@ export class DashboardComponent implements OnInit {
protected progressPercent(value: number): number { protected progressPercent(value: number): number {
return Math.round(value * 100); return Math.round(value * 100);
} }
protected taskDateLabel(task: UserTask): string {
const today = this.dashboard()?.dateKey ?? this.todayKey();
if (task.dueDate === today) {
return 'Heute';
}
return `Ueberfaellig seit ${this.formatDay(task.dueDate)}`;
}
private formatDay(value: string): string {
const [year, month, day] = value.split('-');
return year && month && day ? `${day}.${month}.${year}` : value;
}
private todayKey(): string {
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
} }

View File

@@ -6,7 +6,8 @@
<base href="/" /> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#6750a4" /> <meta name="theme-color" content="#6750a4" />
<link rel="icon" type="image/x-icon" href="favicon.ico" /> <link rel="icon" type="image/svg+xml" href="pwa-icon.svg" />
<link rel="alternate icon" type="image/x-icon" href="favicon.ico" />
<link rel="manifest" href="manifest.webmanifest" /> <link rel="manifest" href="manifest.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />