diff --git a/CHANGELOG.md b/CHANGELOG.md index dc79eda..cc9e1df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ All notable changes to this project are documented in this file. +## [1.129.0] - 2026-09-14 + +### Added +- **Neue "Überfällig"-Übersicht** für Aufgaben, die in der Vergangenheit geplant und + nicht erledigt wurden. Bislang wandern nur Aufgaben mit aktiviertem Rolling-Schalter + (oder bei global aktiviertem Auto-Rolling) automatisch auf heute — alles andere blieb + für immer auf seinem ursprünglichen Datum stehen und verschwand aus dem Blick, sobald + man in der Wochenansicht weitergeklickt hat. + - Neuer Menüpunkt "Überfällig" mit Anzahl-Badge, direkt neben "Wiederkehrend" in allen + bestehenden Menü-Varianten (linke/obere Randleiste, mobile Leiste, Kopfzeile). + - Dialog gruppiert nach dem ursprünglich geplanten Tag (älteste zuerst), pro Aufgabe + Checkbox zum Erledigen und ein Knopf zum Verschieben auf heute, dazu "Alle auf + heute verschieben". + - Reine Erkennungs-/Gruppierungslogik in `src/lib/weekly/overdue.ts` (10 Unit-Tests), + Darstellung in `src/components/OverdueTasksManager.tsx` — nutzt `LinkifiedText` + (v1.128.0), Titel mit URLs bleiben also auch hier anklickbar. + - Verschieben/Erledigen läuft über die bestehenden `moveTaskToSlot`/`toggleTask`-Pfade, + keine neue API, keine neue Persistenzlogik. + +Verifiziert nicht nur mit Unit-Tests: lokale Instanz mit Wegwerf-Postgres (Docker) +aufgesetzt, drei überfällige Aufgaben geseedet (eine davon mit URL im Titel), per +Playwright eingeloggt und die Übersicht geöffnet — Badge, Gruppierung und Link-Rendering +per Screenshot geprüft. Danach real geklickt: eine Aufgabe per Checkbox erledigt, eine +per Knopf auf heute verschoben, die restlichen zwei per Sammelknopf — alle drei Änderungen +per Datenbankabfrage bestätigt. Zusätzlich: Typecheck, Lint (0 Fehler), 118 Unit-Tests +(10 neu), Produktions-Build. + ## [1.128.0] - 2026-09-14 ### Fixed diff --git a/package.json b/package.json index fcf187b..5750e7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.128.0", + "version": "1.129.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/components/OverdueTasksManager.tsx b/src/components/OverdueTasksManager.tsx new file mode 100644 index 0000000..bae1353 --- /dev/null +++ b/src/components/OverdueTasksManager.tsx @@ -0,0 +1,139 @@ +import React, { useMemo } from "react"; +import { AlertCircle, X, ArrowRight, ArrowRightCircle } from "lucide-react"; +import type { Task } from "@/lib/weekly/types"; +import { getOverdueTasks, groupOverdueByDate } from "@/lib/weekly/overdue"; +import { LinkifiedText } from "./weekly/LinkifiedText"; + +interface OverdueTasksManagerProps { + isOpen: boolean; + onClose: () => void; + tasks: Task[]; + language?: string; + onToggleComplete: (taskId: string) => void; + onMoveToToday: (task: Task) => void; + onMoveAllToToday: (tasks: Task[]) => void; +} + +/** + * Aufgaben, die in der Vergangenheit geplant und nicht erledigt wurden. + * + * Nur Aufgaben mit "isRolling" oder bei global aktiviertem "autoRolling" wandern + * automatisch auf heute (siehe rollOverdueTasks in WeeklyView.tsx) -- alles andere + * bleibt sonst für immer auf seinem ursprünglichen Datum stehen und verschwindet aus + * dem Blick, sobald man in der Wochenansicht weitergeklickt hat. Dieser Dialog macht + * genau diese Aufgaben sichtbar, unabhängig vom Rolling-Status, gruppiert nach dem + * Datum, an dem sie eigentlich fällig waren. + * + * Die Erkennungs-/Gruppierungslogik selbst liegt als reine Funktion in + * src/lib/weekly/overdue.ts -- diese Datei enthält bewusst nur JSX/Rendering. + */ + +export default function OverdueTasksManager({ + isOpen, + onClose, + tasks, + language, + onToggleComplete, + onMoveToToday, + onMoveAllToToday, +}: OverdueTasksManagerProps) { + const de = language === "de"; + const overdue = useMemo(() => getOverdueTasks(tasks), [tasks]); + const groups = useMemo(() => groupOverdueByDate(overdue), [overdue]); + + if (!isOpen) return null; + + const dateLabel = (dateStr: string) => { + const date = new Date(dateStr + "T00:00:00"); + return date.toLocaleDateString(de ? "de-DE" : "en-US", { + weekday: "short", + day: "numeric", + month: "short", + }); + }; + + return ( +
+
e.stopPropagation()} style={{ maxHeight: "80vh" }}> +
+
+
+ +
+

+ {de ? "Überfällig" : "Overdue"} + {overdue.length > 0 && ( + ({overdue.length}) + )} +

+
+ +
+ +
+ {groups.length === 0 ? ( +
+
+ +
+

+ {de ? "Nichts Überfälliges — alles erledigt oder rechtzeitig verschoben." : "Nothing overdue — all caught up."} +

+
+ ) : ( +
+ {groups.map(({ dateStr, tasks: dayTasks }) => ( +
+
+ {dateLabel(dateStr)} +
+
+ {dayTasks.map((task) => ( +
+ onToggleComplete(task.id)} + className="w-4 h-4 flex-shrink-0 cursor-pointer" + aria-label={de ? `"${task.title}" erledigt` : `Mark "${task.title}" complete`} + /> +
+ +
+ +
+ ))} +
+
+ ))} +
+ )} +
+ + {overdue.length > 0 && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 7e4a3c6..738462e 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -116,6 +116,8 @@ import UserMenu from "./UserMenu"; import SearchModal from "./SearchModal"; import SimpleDatePicker from "./SimpleDatePicker"; import RecurringTasksManager from "./RecurringTasksManager"; +import OverdueTasksManager from "./OverdueTasksManager"; +import { getOverdueTasks } from "@/lib/weekly/overdue"; import WeekPrintModal from "./WeekPrintModal"; import { ProjectIcon } from "./weekly/ProjectIcon"; import { ProjectsSidebar } from "./weekly/ProjectsSidebar"; @@ -936,6 +938,8 @@ export default function WeeklyView() { // New UI State const [isSearchOpen, setIsSearchOpen] = useState(false); const [isRecurringTasksOpen, setIsRecurringTasksOpen] = useState(false); + const [isOverdueTasksOpen, setIsOverdueTasksOpen] = useState(false); + const overdueCount = useMemo(() => getOverdueTasks(tasks).length, [tasks]); const [showDatePicker, setShowDatePicker] = useState(false); const datePickerBtnRef = useRef(null); const [showNavDatePicker, setShowNavDatePicker] = useState(false); @@ -5494,6 +5498,7 @@ export default function WeeklyView() { + @@ -5558,6 +5563,7 @@ export default function WeeklyView() { + @@ -5623,6 +5629,7 @@ export default function WeeklyView() { + @@ -5683,6 +5690,7 @@ export default function WeeklyView() { + {railSep} @@ -5922,6 +5930,7 @@ export default function WeeklyView() { + {railSep} @@ -6331,6 +6340,7 @@ export default function WeeklyView() { + @@ -6363,6 +6373,7 @@ export default function WeeklyView() { {!useLeftRail && ( <> + @@ -8910,6 +8921,24 @@ export default function WeeklyView() { onSeriesUpdated={() => fetchTasks()} /> + setIsOverdueTasksOpen(false)} + tasks={tasks} + language={profile.language} + onToggleComplete={toggleTask} + onMoveToToday={(task) => { + const today = new Date(); + moveTaskToSlot(task.id, today.getDay(), task.startTime || "09:00", today); + }} + onMoveAllToToday={(tasksToMove) => { + const today = new Date(); + for (const task of tasksToMove) { + moveTaskToSlot(task.id, today.getDay(), task.startTime || "09:00", today); + } + }} + /> + {/* Recurring Task Delete Confirmation Modal */} { recurringDeleteModal.isOpen && ( diff --git a/src/lib/weekly/overdue.ts b/src/lib/weekly/overdue.ts new file mode 100644 index 0000000..86bae30 --- /dev/null +++ b/src/lib/weekly/overdue.ts @@ -0,0 +1,54 @@ +import type { Task } from "@/lib/weekly/types"; + +/** + * Überfällige Aufgaben: in der Vergangenheit geplant, nicht erledigt. + * + * Reine Funktionen, damit sie ohne Browser/DOM testbar sind -- siehe + * src/components/OverdueTasksManager.tsx, das sie zum Rendern verwendet. Datum-Vergleich + * als String (YYYY-MM-DD), damit Uhrzeiten und Zeitzonen keine Rolle spielen: ein Task von + * "heute Nachmittag, noch nicht erledigt" ist nicht überfällig, "gestern" schon. + */ + +function localDayString(date: Date): string { + 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}`; +} + +/** + * Nur Aufgaben mit `isRolling` oder bei global aktiviertem `autoRolling` wandern + * automatisch auf heute (siehe rollOverdueTasks in WeeklyView.tsx) -- diese Funktion + * erfasst absichtlich ALLE unerledigten überfälligen Aufgaben, unabhängig vom + * Rolling-Status, damit nichts unbemerkt auf einem vergangenen Datum liegen bleibt. + * Someday-Aufgaben zählen nicht mit: die stehen bewusst ohne festes Datum. + */ +export function getOverdueTasks(tasks: Task[], now: Date = new Date()): Task[] { + const todayStr = localDayString(now); + return tasks.filter( + (t) => + !t.completed && + !t.somedayListId && + !!t.scheduledDate && + localDayString(new Date(t.scheduledDate)) < todayStr, + ); +} + +export interface OverdueGroup { + dateStr: string; + tasks: Task[]; +} + +/** Gruppiert nach dem ursprünglich geplanten Tag, älteste Gruppe zuerst. */ +export function groupOverdueByDate(tasks: Task[]): OverdueGroup[] { + const groups = new Map(); + for (const task of tasks) { + const dateStr = localDayString(new Date(task.scheduledDate as string)); + const bucket = groups.get(dateStr); + if (bucket) bucket.push(task); + else groups.set(dateStr, [task]); + } + return Array.from(groups.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([dateStr, tasks]) => ({ dateStr, tasks })); +} diff --git a/tests/unit/overdue.test.ts b/tests/unit/overdue.test.ts new file mode 100644 index 0000000..a1b19c9 --- /dev/null +++ b/tests/unit/overdue.test.ts @@ -0,0 +1,90 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { getOverdueTasks, groupOverdueByDate } from "../../src/lib/weekly/overdue.ts"; +import type { Task } from "../../src/lib/weekly/types.ts"; + +/** + * Erkennung und Gruppierung überfälliger Aufgaben. Beobachtbares Verhalten: eine + * unerledigte Aufgabe mit einem Datum vor heute gilt als überfällig, unabhängig vom + * Rolling-Status (siehe OverdueTasksManager.tsx für den fachlichen Hintergrund) -- + * Someday-Aufgaben und heute fällige Aufgaben zählen bewusst nicht mit. + */ + +const baseTask: Task = { + id: "t1", + title: "Test task", + completed: false, + order: 0, + updatedAt: new Date(), + userId: "u1", +}; + +const now = new Date(2026, 8, 14, 10, 0, 0); // Montag, 14.09.2026, 10 Uhr + +describe("getOverdueTasks", () => { + test("unerledigte Aufgabe mit vergangenem Datum ist überfällig", () => { + const task = { ...baseTask, scheduledDate: "2026-09-10" }; + assert.deepEqual(getOverdueTasks([task], now), [task]); + }); + + test("erledigte Aufgabe mit vergangenem Datum ist nicht überfällig", () => { + const task = { ...baseTask, scheduledDate: "2026-09-10", completed: true }; + assert.deepEqual(getOverdueTasks([task], now), []); + }); + + test("heute fällige Aufgabe ist nicht überfällig, auch nachmittags nicht", () => { + const task = { ...baseTask, scheduledDate: "2026-09-14" }; + assert.deepEqual(getOverdueTasks([task], now), []); + }); + + test("zukünftige Aufgabe ist nicht überfällig", () => { + const task = { ...baseTask, scheduledDate: "2026-09-20" }; + assert.deepEqual(getOverdueTasks([task], now), []); + }); + + test("Aufgabe ohne Datum ist nicht überfällig", () => { + const task = { ...baseTask, scheduledDate: null }; + assert.deepEqual(getOverdueTasks([task], now), []); + }); + + test("Someday-Aufgabe zählt nicht mit, selbst mit vergangenem Datum", () => { + const task = { ...baseTask, scheduledDate: "2026-09-10", somedayListId: "list1" }; + assert.deepEqual(getOverdueTasks([task], now), []); + }); + + test("virtuelle wiederkehrende Instanz mit vergangenem Datum zählt mit", () => { + // Der Server materialisiert virtuelle Ids bei jedem PATCH (siehe + // VIRTUAL_ID_REGEX in api/tasks/route.ts) -- toggleTask/moveTaskToSlot + // funktionieren darauf bereits unverändert. + const task = { ...baseTask, id: "virtual-t1-2026-09-10", scheduledDate: "2026-09-10" }; + assert.deepEqual(getOverdueTasks([task], now), [task]); + }); + + test("mehrere Aufgaben werden korrekt gefiltert", () => { + const overdueTask = { ...baseTask, id: "a", scheduledDate: "2026-09-10" }; + const futureTask = { ...baseTask, id: "b", scheduledDate: "2026-09-20" }; + const doneTask = { ...baseTask, id: "c", scheduledDate: "2026-09-01", completed: true }; + assert.deepEqual(getOverdueTasks([overdueTask, futureTask, doneTask], now), [overdueTask]); + }); +}); + +describe("groupOverdueByDate", () => { + test("gruppiert nach Datum, älteste Gruppe zuerst", () => { + const a = { ...baseTask, id: "a", scheduledDate: "2026-09-12" }; + const b = { ...baseTask, id: "b", scheduledDate: "2026-09-10" }; + const c = { ...baseTask, id: "c", scheduledDate: "2026-09-12" }; + + const groups = groupOverdueByDate([a, b, c]); + + assert.deepEqual( + groups.map((g) => g.dateStr), + ["2026-09-10", "2026-09-12"], + ); + assert.deepEqual(groups[0].tasks, [b]); + assert.deepEqual(groups[1].tasks, [a, c]); + }); + + test("leere Liste ergibt keine Gruppen", () => { + assert.deepEqual(groupOverdueByDate([]), []); + }); +});