feat: Uebersicht ueberfaelliger Aufgaben
Some checks are pending
Playwright Tests / test (push) Waiting to run
Test and SonarQube / quality (push) Waiting to run

Aufgaben, die in der Vergangenheit geplant und nicht erledigt wurden, waren bislang
praktisch unsichtbar: nur Aufgaben mit aktiviertem Rolling-Schalter (oder bei global
aktiviertem Auto-Rolling) wandern automatisch auf heute (rollOverdueTasks in
WeeklyView.tsx) -- alles andere blieb fuer immer auf seinem urspruenglichen Datum
stehen, sobald man in der Wochenansicht weitergeklickt hat.

- src/lib/weekly/overdue.ts: reine Funktionen getOverdueTasks/groupOverdueByDate
  (unerledigt, kein Someday, scheduledDate vor heute -- Datumsvergleich als String,
  damit Uhrzeit/Zeitzone keine Rolle spielen). 10 Unit-Tests.
- src/components/OverdueTasksManager.tsx: Dialog, gruppiert nach dem urspruenglich
  geplanten Tag (aelteste zuerst), Checkbox zum Erledigen, Knopf zum Verschieben auf
  heute pro Aufgabe, "Alle auf heute verschieben". Nutzt LinkifiedText (v1.128.0) --
  Titel mit URLs bleiben auch hier anklickbar. Verschieben/Erledigen laeuft ueber die
  bestehenden moveTaskToSlot/toggleTask-Pfade, keine neue API.
- Neuer Menuepunkt "Ueberfaellig" mit Anzahl-Badge neben "Wiederkehrend" in allen
  bestehenden Menue-Varianten (linke/obere Randleiste, mobile Leiste, Kopfzeile).

Verifiziert nicht nur mit Unit-Tests: lokale Instanz mit Wegwerf-Postgres (Docker)
aufgesetzt, drei ueberfaellige Aufgaben geseedet (eine mit URL im Titel), per Playwright
eingeloggt -- Badge, Gruppierung und Link-Rendering per Screenshot geprueft. Danach real
geklickt: eine Aufgabe per Checkbox erledigt, eine per Knopf auf heute verschoben, die
restlichen zwei per Sammelknopf -- alle drei Aenderungen per Datenbankabfrage bestaetigt.

Ausserdem: Typecheck, Lint (0 Fehler), 118 Unit-Tests (10 neu), Produktions-Build.

v1.129.0

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-14 18:01:05 +02:00
parent 7f850bb4e9
commit 64f0eb73f8
6 changed files with 340 additions and 1 deletions

View File

@ -2,6 +2,33 @@
All notable changes to this project are documented in this file. 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 ## [1.128.0] - 2026-09-14
### Fixed ### Fixed

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "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", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -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 (
<div className="fixed inset-0 bg-black/50 z-[3000] flex items-center justify-center p-4 animate-fadeIn" onClick={onClose}>
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[600px] max-w-full overflow-hidden flex flex-col animate-scaleIn" onClick={(e) => e.stopPropagation()} style={{ maxHeight: "80vh" }}>
<div className="p-5 flex items-center justify-between border-b border-gray-100 dark:border-gray-800">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg text-red-600">
<AlertCircle size={20} />
</div>
<h2 className="text-lg font-bold text-gray-800 dark:text-white">
{de ? "Überfällig" : "Overdue"}
{overdue.length > 0 && (
<span className="ml-2 text-sm font-medium text-gray-400">({overdue.length})</span>
)}
</h2>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full text-gray-400 transition-colors">
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
{groups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-16 h-16 bg-gray-50 dark:bg-gray-800 rounded-full flex items-center justify-center text-gray-300 mb-4">
<AlertCircle size={32} />
</div>
<p className="text-gray-500 dark:text-gray-400 max-w-xs">
{de ? "Nichts Überfälliges — alles erledigt oder rechtzeitig verschoben." : "Nothing overdue — all caught up."}
</p>
</div>
) : (
<div className="space-y-5">
{groups.map(({ dateStr, tasks: dayTasks }) => (
<div key={dateStr}>
<div className="text-xs font-bold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-2">
{dateLabel(dateStr)}
</div>
<div className="space-y-2">
{dayTasks.map((task) => (
<div
key={task.id}
className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-100 dark:border-gray-700/50 hover:border-red-200 dark:hover:border-red-900/50 transition-all"
>
<input
type="checkbox"
checked={false}
onChange={() => onToggleComplete(task.id)}
className="w-4 h-4 flex-shrink-0 cursor-pointer"
aria-label={de ? `"${task.title}" erledigt` : `Mark "${task.title}" complete`}
/>
<div className="flex-1 min-w-0 text-sm text-gray-800 dark:text-gray-100 truncate" title={task.title}>
<LinkifiedText text={task.title} />
</div>
<button
onClick={() => onMoveToToday(task)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 hover:bg-blue-100 dark:bg-blue-900/10 dark:hover:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg text-xs font-bold transition-colors flex-shrink-0"
title={de ? "Auf heute verschieben" : "Move to today"}
>
<ArrowRight size={12} />
{de ? "Heute" : "Today"}
</button>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
{overdue.length > 0 && (
<div className="p-3 bg-gray-50 dark:bg-gray-900 border-t border-gray-100 dark:border-gray-800 flex justify-center">
<button
onClick={() => onMoveAllToToday(overdue)}
className="flex items-center gap-2 px-4 py-2 bg-teal-500 hover:bg-teal-600 text-white rounded-lg text-sm font-bold transition-colors"
>
<ArrowRightCircle size={16} />
{de ? "Alle auf heute verschieben" : "Move all to today"}
</button>
</div>
)}
</div>
</div>
);
}

View File

@ -116,6 +116,8 @@ import UserMenu from "./UserMenu";
import SearchModal from "./SearchModal"; import SearchModal from "./SearchModal";
import SimpleDatePicker from "./SimpleDatePicker"; import SimpleDatePicker from "./SimpleDatePicker";
import RecurringTasksManager from "./RecurringTasksManager"; import RecurringTasksManager from "./RecurringTasksManager";
import OverdueTasksManager from "./OverdueTasksManager";
import { getOverdueTasks } from "@/lib/weekly/overdue";
import WeekPrintModal from "./WeekPrintModal"; import WeekPrintModal from "./WeekPrintModal";
import { ProjectIcon } from "./weekly/ProjectIcon"; import { ProjectIcon } from "./weekly/ProjectIcon";
import { ProjectsSidebar } from "./weekly/ProjectsSidebar"; import { ProjectsSidebar } from "./weekly/ProjectsSidebar";
@ -936,6 +938,8 @@ export default function WeeklyView() {
// New UI State // New UI State
const [isSearchOpen, setIsSearchOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isRecurringTasksOpen, setIsRecurringTasksOpen] = 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 [showDatePicker, setShowDatePicker] = useState(false);
const datePickerBtnRef = useRef<HTMLDivElement>(null); const datePickerBtnRef = useRef<HTMLDivElement>(null);
const [showNavDatePicker, setShowNavDatePicker] = useState(false); const [showNavDatePicker, setShowNavDatePicker] = useState(false);
@ -5494,6 +5498,7 @@ export default function WeeklyView() {
<button onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Plus size={16} /> <span>{profile.language === "de" ? "Kalendereintrag" : "Calendar Event"}</span></button> <button onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Plus size={16} /> <span>{profile.language === "de" ? "Kalendereintrag" : "Calendar Event"}</span></button>
<button onClick={() => { setShowProjectsSidebar(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><FolderPlus size={16} /> <span>{profile.language === "de" ? "Projekte" : "Projects"}</span></button> <button onClick={() => { setShowProjectsSidebar(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><FolderPlus size={16} /> <span>{profile.language === "de" ? "Projekte" : "Projects"}</span></button>
<button onClick={() => { setIsRecurringTasksOpen(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Repeat size={16} /> <span>{profile.language === "de" ? "Wiederkehrend" : "Recurring Tasks"}</span></button> <button onClick={() => { setIsRecurringTasksOpen(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Repeat size={16} /> <span>{profile.language === "de" ? "Wiederkehrend" : "Recurring Tasks"}</span></button>
<button onClick={() => { setIsOverdueTasksOpen(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><AlertCircle size={16} /> <span>{profile.language === "de" ? "Überfällig" : "Overdue"}{overdueCount > 0 ? ` (${overdueCount})` : ""}</span></button>
<button onClick={() => { const nv = !profile.showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); }} style={qsActionStyle(darkMode)}>{profile.showNextTask ? <Play size={16} /> : <Target size={16} />}<span>{profile.showNextTask ? (profile.language === "de" ? "Nächste Aufgabe" : "Next Task") : (profile.language === "de" ? "Ziel" : "Goal")}</span></button> <button onClick={() => { const nv = !profile.showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); }} style={qsActionStyle(darkMode)}>{profile.showNextTask ? <Play size={16} /> : <Target size={16} />}<span>{profile.showNextTask ? (profile.language === "de" ? "Nächste Aufgabe" : "Next Task") : (profile.language === "de" ? "Ziel" : "Goal")}</span></button>
<button onClick={() => { setShowFocusMode(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Zap size={16} /> <span>{profile.language === "de" ? "Fokus" : "Focus"}</span></button> <button onClick={() => { setShowFocusMode(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Zap size={16} /> <span>{profile.language === "de" ? "Fokus" : "Focus"}</span></button>
</div> </div>
@ -5558,6 +5563,7 @@ export default function WeeklyView() {
<button onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Plus size={16} /> <span>{profile.language === "de" ? "Kalendereintrag" : "Calendar Event"}</span></button> <button onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Plus size={16} /> <span>{profile.language === "de" ? "Kalendereintrag" : "Calendar Event"}</span></button>
<button onClick={() => { setShowProjectsSidebar(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><FolderPlus size={16} /> <span>{profile.language === "de" ? "Projekte" : "Projects"}</span></button> <button onClick={() => { setShowProjectsSidebar(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><FolderPlus size={16} /> <span>{profile.language === "de" ? "Projekte" : "Projects"}</span></button>
<button onClick={() => { setIsRecurringTasksOpen(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Repeat size={16} /> <span>{profile.language === "de" ? "Wiederkehrend" : "Recurring Tasks"}</span></button> <button onClick={() => { setIsRecurringTasksOpen(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Repeat size={16} /> <span>{profile.language === "de" ? "Wiederkehrend" : "Recurring Tasks"}</span></button>
<button onClick={() => { setIsOverdueTasksOpen(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><AlertCircle size={16} /> <span>{profile.language === "de" ? "Überfällig" : "Overdue"}{overdueCount > 0 ? ` (${overdueCount})` : ""}</span></button>
<button onClick={() => { const nv = !profile.showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); }} style={qsActionStyle(darkMode)}>{profile.showNextTask ? <Play size={16} /> : <Target size={16} />}<span>{profile.showNextTask ? (profile.language === "de" ? "Nächste Aufgabe" : "Next Task") : (profile.language === "de" ? "Ziel" : "Goal")}</span></button> <button onClick={() => { const nv = !profile.showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); }} style={qsActionStyle(darkMode)}>{profile.showNextTask ? <Play size={16} /> : <Target size={16} />}<span>{profile.showNextTask ? (profile.language === "de" ? "Nächste Aufgabe" : "Next Task") : (profile.language === "de" ? "Ziel" : "Goal")}</span></button>
<button onClick={() => { setShowFocusMode(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Zap size={16} /> <span>{profile.language === "de" ? "Fokus" : "Focus"}</span></button> <button onClick={() => { setShowFocusMode(true); setShowQuickSettings(false); }} style={qsActionStyle(darkMode)}><Zap size={16} /> <span>{profile.language === "de" ? "Fokus" : "Focus"}</span></button>
</div> </div>
@ -5623,6 +5629,7 @@ export default function WeeklyView() {
<button onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); }} style={qsActionStyle(darkMode)}><Plus size={16} /> <span>{profile.language === "de" ? "Kalendereintrag" : "Calendar Event"}</span></button> <button onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); }} style={qsActionStyle(darkMode)}><Plus size={16} /> <span>{profile.language === "de" ? "Kalendereintrag" : "Calendar Event"}</span></button>
<button onClick={() => setShowProjectsSidebar(true)} style={qsActionStyle(darkMode)}><FolderPlus size={16} /> <span>{profile.language === "de" ? "Projekte" : "Projects"}</span></button> <button onClick={() => setShowProjectsSidebar(true)} style={qsActionStyle(darkMode)}><FolderPlus size={16} /> <span>{profile.language === "de" ? "Projekte" : "Projects"}</span></button>
<button onClick={() => setIsRecurringTasksOpen(true)} style={qsActionStyle(darkMode)}><Repeat size={16} /> <span>{profile.language === "de" ? "Wiederkehrend" : "Recurring Tasks"}</span></button> <button onClick={() => setIsRecurringTasksOpen(true)} style={qsActionStyle(darkMode)}><Repeat size={16} /> <span>{profile.language === "de" ? "Wiederkehrend" : "Recurring Tasks"}</span></button>
<button onClick={() => setIsOverdueTasksOpen(true)} style={qsActionStyle(darkMode)}><AlertCircle size={16} /> <span>{profile.language === "de" ? "Überfällig" : "Overdue"}{overdueCount > 0 ? ` (${overdueCount})` : ""}</span></button>
<button onClick={() => { const nv = !profile.showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); }} style={qsActionStyle(darkMode)}>{profile.showNextTask ? <Play size={16} /> : <Target size={16} />}<span>{profile.showNextTask ? (profile.language === "de" ? "Nächste Aufgabe" : "Next Task") : (profile.language === "de" ? "Ziel" : "Goal")}</span></button> <button onClick={() => { const nv = !profile.showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); }} style={qsActionStyle(darkMode)}>{profile.showNextTask ? <Play size={16} /> : <Target size={16} />}<span>{profile.showNextTask ? (profile.language === "de" ? "Nächste Aufgabe" : "Next Task") : (profile.language === "de" ? "Ziel" : "Goal")}</span></button>
<button onClick={() => setShowFocusMode(true)} style={qsActionStyle(darkMode)}><Zap size={16} /> <span>{profile.language === "de" ? "Fokus" : "Focus"}</span></button> <button onClick={() => setShowFocusMode(true)} style={qsActionStyle(darkMode)}><Zap size={16} /> <span>{profile.language === "de" ? "Fokus" : "Focus"}</span></button>
</div> </div>
@ -5683,6 +5690,7 @@ export default function WeeklyView() {
<button onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Kalendereintrag" : "Calendar Event"}><Plus size={15} /></button> <button onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Kalendereintrag" : "Calendar Event"}><Plus size={15} /></button>
<button onClick={() => setShowProjectsSidebar(true)} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Projekte" : "Projects"}><FolderOpen size={15} /></button> <button onClick={() => setShowProjectsSidebar(true)} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Projekte" : "Projects"}><FolderOpen size={15} /></button>
<button onClick={() => setIsRecurringTasksOpen(true)} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Wiederkehrend" : "Recurring"}><Repeat size={15} /></button> <button onClick={() => setIsRecurringTasksOpen(true)} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Wiederkehrend" : "Recurring"}><Repeat size={15} /></button>
<button onClick={() => setIsOverdueTasksOpen(true)} style={{ ...railIconBtnStyle }} title={`${profile.language === "de" ? "Überfällig" : "Overdue"}${overdueCount > 0 ? ` (${overdueCount})` : ""}`}><AlertCircle size={15} /></button>
<button onClick={() => { const nv = !profile.showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Ziel" : "Goal"}>{profile.showNextTask ? <Play size={15} /> : <Target size={15} />}</button> <button onClick={() => { const nv = !profile.showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Ziel" : "Goal"}>{profile.showNextTask ? <Play size={15} /> : <Target size={15} />}</button>
<button onClick={() => setShowFocusMode(true)} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Fokus" : "Focus"}><Zap size={15} /></button> <button onClick={() => setShowFocusMode(true)} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Fokus" : "Focus"}><Zap size={15} /></button>
{railSep} {railSep}
@ -5922,6 +5930,7 @@ export default function WeeklyView() {
<button className="mobile-rail-btn" onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Kalendereintrag" : "Calendar Event"}><Plus size={15} /></button> <button className="mobile-rail-btn" onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Kalendereintrag" : "Calendar Event"}><Plus size={15} /></button>
<button className="mobile-rail-btn" onClick={() => { setShowProjectsSidebar(true); setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Projekte" : "Projects"}><FolderOpen size={15} /></button> <button className="mobile-rail-btn" onClick={() => { setShowProjectsSidebar(true); setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Projekte" : "Projects"}><FolderOpen size={15} /></button>
<button className="mobile-rail-btn" onClick={() => { setIsRecurringTasksOpen(true); setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Wiederkehrend" : "Recurring"}><Repeat size={15} /></button> <button className="mobile-rail-btn" onClick={() => { setIsRecurringTasksOpen(true); setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Wiederkehrend" : "Recurring"}><Repeat size={15} /></button>
<button className="mobile-rail-btn" onClick={() => { setIsOverdueTasksOpen(true); setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title={`${profile.language === "de" ? "Überfällig" : "Overdue"}${overdueCount > 0 ? ` (${overdueCount})` : ""}`}><AlertCircle size={15} /></button>
<button className="mobile-rail-btn" onClick={() => { const nv = !profile.showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Ziel" : "Goal"}>{profile.showNextTask ? <Play size={15} /> : <Target size={15} />}</button> <button className="mobile-rail-btn" onClick={() => { const nv = !profile.showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Ziel" : "Goal"}>{profile.showNextTask ? <Play size={15} /> : <Target size={15} />}</button>
<button className="mobile-rail-btn" onClick={() => { setShowFocusMode(true); setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Fokus" : "Focus"}><Zap size={15} /></button> <button className="mobile-rail-btn" onClick={() => { setShowFocusMode(true); setShowMobileRail(false); setFlyoutSection(null); }} style={{ ...railIconBtnStyle }} title={profile.language === "de" ? "Fokus" : "Focus"}><Zap size={15} /></button>
{railSep} {railSep}
@ -6331,6 +6340,7 @@ export default function WeeklyView() {
<button onClick={() => { handleRedo(); setShowHeaderMore(false); }} disabled={redoCount === 0}><Redo2 size={16} /> <span>Redo</span></button> <button onClick={() => { handleRedo(); setShowHeaderMore(false); }} disabled={redoCount === 0}><Redo2 size={16} /> <span>Redo</span></button>
<button onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); setShowHeaderMore(false); }}><Plus size={16} /> <span>{profile.language === "de" ? "Termin erstellen" : "Add Event"}</span></button> <button onClick={() => { const now = new Date(); setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` }); setShowHeaderMore(false); }}><Plus size={16} /> <span>{profile.language === "de" ? "Termin erstellen" : "Add Event"}</span></button>
<button onClick={() => { setIsRecurringTasksOpen(true); setShowHeaderMore(false); }}><Repeat size={16} /> <span>{profile.language === "de" ? "Wiederkehrend" : "Recurring"}</span></button> <button onClick={() => { setIsRecurringTasksOpen(true); setShowHeaderMore(false); }}><Repeat size={16} /> <span>{profile.language === "de" ? "Wiederkehrend" : "Recurring"}</span></button>
<button onClick={() => { setIsOverdueTasksOpen(true); setShowHeaderMore(false); }}><AlertCircle size={16} /> <span>{profile.language === "de" ? "Überfällig" : "Overdue"}{overdueCount > 0 ? ` (${overdueCount})` : ""}</span></button>
<button onClick={() => { const nv = !showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); setShowHeaderMore(false); }}>{showNextTask ? <Play size={16} /> : <Target size={16} />} <span>{showNextTask ? "Next Task" : "Goal"}</span></button> <button onClick={() => { const nv = !showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); setShowHeaderMore(false); }}>{showNextTask ? <Play size={16} /> : <Target size={16} />} <span>{showNextTask ? "Next Task" : "Goal"}</span></button>
<button onClick={() => { setShowFocusMode(true); setShowHeaderMore(false); }}><Zap size={16} /> <span>{profile.language === "de" ? "Fokus" : "Focus"}</span></button> <button onClick={() => { setShowFocusMode(true); setShowHeaderMore(false); }}><Zap size={16} /> <span>{profile.language === "de" ? "Fokus" : "Focus"}</span></button>
<button onClick={() => { setDarkMode(!darkMode); setShowHeaderMore(false); }}>{darkMode ? <Sun size={16} /> : <Moon size={16} />} <span>{darkMode ? "Light" : "Dark"}</span></button> <button onClick={() => { setDarkMode(!darkMode); setShowHeaderMore(false); }}>{darkMode ? <Sun size={16} /> : <Moon size={16} />} <span>{darkMode ? "Light" : "Dark"}</span></button>
@ -6363,6 +6373,7 @@ export default function WeeklyView() {
{!useLeftRail && ( {!useLeftRail && (
<> <>
<button className="weekly-btn-icon header-desktop-only" onClick={() => setIsRecurringTasksOpen(true)} title="Recurring Tasks"><Repeat size={17} /></button> <button className="weekly-btn-icon header-desktop-only" onClick={() => setIsRecurringTasksOpen(true)} title="Recurring Tasks"><Repeat size={17} /></button>
<button className="weekly-btn-icon header-desktop-only" onClick={() => setIsOverdueTasksOpen(true)} title={overdueCount > 0 ? `Overdue (${overdueCount})` : "Overdue"}><AlertCircle size={17} /></button>
<button className="weekly-btn-icon header-desktop-only" onClick={() => setShowProjectsSidebar(true)} title="New Project"><FolderPlus size={17} /></button> <button className="weekly-btn-icon header-desktop-only" onClick={() => setShowProjectsSidebar(true)} title="New Project"><FolderPlus size={17} /></button>
<button className="weekly-btn-icon header-desktop-only" onClick={() => setShowWeekPrintModal(true)} title={profile.language === "de" ? "Woche drucken / exportieren" : "Print / export week"}><Printer size={17} /></button> <button className="weekly-btn-icon header-desktop-only" onClick={() => setShowWeekPrintModal(true)} title={profile.language === "de" ? "Woche drucken / exportieren" : "Print / export week"}><Printer size={17} /></button>
</> </>
@ -8910,6 +8921,24 @@ export default function WeeklyView() {
onSeriesUpdated={() => fetchTasks()} onSeriesUpdated={() => fetchTasks()}
/> />
<OverdueTasksManager
isOpen={isOverdueTasksOpen}
onClose={() => 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 */} {/* Recurring Task Delete Confirmation Modal */}
{ {
recurringDeleteModal.isOpen && ( recurringDeleteModal.isOpen && (

54
src/lib/weekly/overdue.ts Normal file
View File

@ -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<string, Task[]>();
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 }));
}

View File

@ -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([]), []);
});
});