Zum Inhalt springen
UI Kit
Accent

Aufgabenliste für Apps

Aufgaben-Screens mit Prioritäten, Unteraufgaben, Filtern und schönem Abhaken. Nach Fälligkeit, als sortierbare Tabelle, Eisenhower-Matrix, Mein Tag und Aufgabenseite.

5 VariantsEine von 4 Components in Aufgaben und Planung

Von überfällig bis erledigt in einer Ansicht

Eine Aufgabenliste beantwortet, was als Nächstes dran ist, für eine Person oder ein ganzes Team. Diese bringt Prioritäten, Unteraufgaben, Filter und einen animierten Haken, und jedes Abhaken lässt sich gleich danach zurücknehmen, ein falscher Tipp kostet also nichts.

„Nach Fälligkeit“ ordnet in überfällig, heute, die nächsten sieben Tage und später, mit Schnelleingabe, Filtern nach Status, Priorität und Person und aufklappbaren Unteraufgaben. Die „Tabelle“ hat sortierbare Spalten, ein Statusmenü in jeder Zeile, Auswahl mit Sammelaktionen und eine Kartenliste am Telefon. Die „Prioritätenmatrix“ teilt dringend und wichtig in vier Felder, Aufgaben wandern per Ziehen, Menü oder Leertaste und Pfeiltasten. „Mein Tag“ ist eine Fokusliste für heute mit Fortschrittsring, der nächsten Aufgabe vorn und Vorschlägen, die bald fällig sind. Die „Aufgabenseite“ öffnet eine Aufgabe mit Person, Fälligkeit, Priorität, Projekt, Schätzung, Unteraufgaben zum Abhaken und Sortieren und Kommentaren.

Lass Menschen eine Aufgabe in einer Zeile anlegen und den Rest später ergänzen, die Schnelleingabe hält die Liste lebendig. Zeig Überfälliges zuerst, aber ohne Alarmfarben überall. Nimm „Mein Tag“ als Startseite für alle, die allein arbeiten, und die Tabelle für Leitungen, die für andere planen.

Variants

Nach FälligkeitNeu in 1.2

Überfällig, heute, die nächsten sieben Tage und später, mit Schnelleingabe, Filtern nach Status, Priorität und Person, aufklappbaren Unteraufgaben und Rückgängig.

TabelleNeu in 1.2

Sortierbare Spalten, ein Statusmenü in jeder Zeile, Filter, Auswahl mit Sammelaktionen und eine Kartenliste am Telefon.

PrioritätenmatrixNeu in 1.2

Dringend und wichtig als vier Felder. Aufgaben ziehen, über ihr Menü verschieben oder mit der Leertaste aufnehmen und mit den Pfeiltasten bewegen.

Mein TagNeu in 1.2

Eine Fokusliste für heute mit Fortschrittsring, der nächsten Aufgabe vorn, Umsortieren und Vorschlägen, die bald fällig sind.

AufgabenseiteNeu in 1.2

Eine Aufgabe mit Status, Feldern für Person, Fälligkeit, Priorität, Projekt und Schätzung, Unteraufgaben zum Abhaken und Sortieren und Kommentaren.

Details

Was Aufgabenliste mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.0 · seit 1.2.0
Datei
components/ui/task-list.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 112 eigene Texte
Studio
Im Code anpassen
In Sammlungen
SaaS-App-Screens

Props

Die Schnittstelle TaskListProps aus components/ui/task-list.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    TaskListVariant

    groupedtablepriority-matrixmy-daydetail

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titlePflicht
    string
  • subtitleoptional
    string
  • tasksoptional
    Task[]
  • peopleoptional
    TaskPerson[]
  • projectsoptional
    string[]

    Project names to pick from

  • meoptional
    string

    Person id of the signed in user

  • todayoptional
    string

    YYYY-MM-DD that counts as today

  • taskoptional
    string

    detail: the task on screen, default the first

  • onActionoptional
    (action: string, data: Record<string, unknown>) => void | Promise<void>

    Called on add, complete, update, move, delete and comment. Without it the block simulates the request.

Eingebaute Texte

Diese Texte schreibt Aufgabenliste selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 112 Texte anzeigen
searchEnglisch Search tasksDeutsch Aufgaben suchen
openEnglisch OpenDeutsch Offen
doneFEnglisch DoneDeutsch Erledigt
allEnglisch AllDeutsch Alle
showEnglisch ShowDeutsch Anzeigen
anyPriorityEnglisch Any priorityDeutsch Jede Priorität
anyoneEnglisch AnyoneDeutsch Alle Personen
priorityFEnglisch PriorityDeutsch Priorität
personEnglisch PersonDeutsch Person
newTaskEnglisch New taskDeutsch Neue Aufgabe
newPlaceholderEnglisch Add a task, press Enter to saveDeutsch Aufgabe eingeben, Enter speichert
addEnglisch Add taskDeutsch Aufgabe anlegen
whenEnglisch DueDeutsch Fällig
noDateEnglisch No dateDeutsch Ohne Datum
todayEnglisch TodayDeutsch Heute
tomorrowEnglisch TomorrowDeutsch Morgen
nextWeekEnglisch Next weekDeutsch Nächste Woche
overdueEnglisch OverdueDeutsch Überfällig
upcomingEnglisch Next 7 daysDeutsch Nächste 7 Tage
laterEnglisch LaterDeutsch Später
completedEnglisch CompletedDeutsch Erledigt
groupToggleEnglisch {group}, {n} tasksDeutsch {group}, {n} Aufgaben
completeEnglisch Mark {task} as doneDeutsch {task} als erledigt markieren
reopenEnglisch Mark {task} as openDeutsch {task} wieder öffnen
doneSayEnglisch {task} done. {n} open tasks left.Deutsch {task} erledigt. Noch {n} offene Aufgaben.
reopenSayEnglisch {task} is open again.Deutsch {task} ist wieder offen.
addedSayEnglisch {task} added.Deutsch {task} angelegt.
undoEnglisch UndoDeutsch Rückgängig
subtasksEnglisch SubtasksDeutsch Unteraufgaben
showSubtasksEnglisch Show subtasks of {task}, {done} of {n} doneDeutsch Unteraufgaben von {task} zeigen, {done} von {n} erledigt
addSubtaskEnglisch Add subtaskDeutsch Unteraufgabe hinzufügen
subPlaceholderEnglisch New subtaskDeutsch Neue Unteraufgabe
urgentEnglisch UrgentDeutsch Dringend
highEnglisch HighDeutsch Hoch
mediumEnglisch MediumDeutsch Mittel
lowEnglisch LowDeutsch Niedrig
noneEnglisch NoneDeutsch Keine
noPriorityEnglisch No priorityDeutsch Ohne Priorität
priorityEnglisch Priority {p}Deutsch Priorität {p}
todoEnglisch To doDeutsch Offen
doingEnglisch In progressDeutsch In Arbeit
doneEnglisch DoneDeutsch Erledigt
statusEnglisch StatusDeutsch Status
assigneeEnglisch AssigneeDeutsch Zuständig
unassignedEnglisch Not assignedDeutsch Nicht zugewiesen
progressEnglisch ProgressDeutsch Fortschritt
projectEnglisch ProjectDeutsch Projekt
titleColEnglisch TaskDeutsch Aufgabe
dueOnEnglisch Due {date}Deutsch Fällig {date}
sortByEnglisch Sort by {col}Deutsch Nach {col} sortieren
sortEnglisch SortDeutsch Sortieren
selectAllEnglisch Select all tasksDeutsch Alle Aufgaben auswählen
selectEnglisch Select {task}Deutsch {task} auswählen
selectedEnglisch {n} selectedDeutsch {n} ausgewählt
markDoneEnglisch Mark doneDeutsch Erledigt
setPriorityEnglisch Set priorityDeutsch Priorität setzen
removeEnglisch DeleteDeutsch Löschen
removedEnglisch {n} tasks deleted.Deutsch {n} Aufgaben gelöscht.
nothingEnglisch No task matches. Try another filter.Deutsch Keine Aufgabe passt. Probiere einen anderen Filter.
clearEnglisch Show allDeutsch Alle zeigen
rowsEnglisch {n} tasksDeutsch {n} Aufgaben
doFirstEnglisch Do firstDeutsch Sofort erledigen
doFirstHintEnglisch Urgent and importantDeutsch Dringend und wichtig
planEnglisch ScheduleDeutsch Einplanen
planHintEnglisch Important, not urgentDeutsch Wichtig, nicht dringend
delegateEnglisch DelegateDeutsch Abgeben
delegateHintEnglisch Urgent, not importantDeutsch Dringend, nicht wichtig
dropEnglisch Let goDeutsch Loslassen
dropHintEnglisch Neither, revisit or dropDeutsch Weder noch, später prüfen
isUrgentEnglisch UrgentDeutsch Dringend
notUrgentEnglisch Not urgentDeutsch Nicht dringend
isImportantEnglisch ImportantDeutsch Wichtig
notImportantEnglisch Not importantDeutsch Nicht wichtig
moveToEnglisch Move toDeutsch Verschieben nach
taskMenuEnglisch Actions for {task}Deutsch Aktionen für {task}
matrixHintEnglisch Press Space to pick up a task. The arrow keys move it between the four fields, Space drops it, Escape puts it back.Deutsch Die Leertaste nimmt eine Aufgabe auf. Die Pfeiltasten bewegen sie zwischen den vier Feldern, die Leertaste legt sie ab, Escape legt sie zurück.
grabbedEnglisch {task} picked up in {quad}.Deutsch {task} in {quad} aufgenommen.
overEnglisch {quad}.Deutsch {quad}.
droppedEnglisch {task} moved to {quad}.Deutsch {task} nach {quad} verschoben.
cancelledEnglisch {task} is back in {quad}.Deutsch {task} liegt wieder in {quad}.
draggableEnglisch movable taskDeutsch verschiebbare Aufgabe
addToEnglisch Add to {quad}Deutsch Zu {quad} hinzufügen
myDayEnglisch My DayDeutsch Mein Tag
progressDayEnglisch {done} of {n} doneDeutsch {done} von {n} erledigt
leftEnglisch {t} leftDeutsch noch {t}
upNextEnglisch Up nextDeutsch Als Nächstes
finishEnglisch Mark as doneDeutsch Als erledigt markieren
later2Englisch Later todayDeutsch Später heute
reorderUpEnglisch Move {task} upDeutsch {task} nach oben
reorderDownEnglisch Move {task} downDeutsch {task} nach unten
removeDayEnglisch Take {task} off My DayDeutsch {task} von Mein Tag nehmen
suggestionsEnglisch SuggestionsDeutsch Vorschläge
suggestTextEnglisch Due soon or overdue and not planned yet.Deutsch Bald fällig oder überfällig und noch nicht eingeplant.
addDayEnglisch Add {task} to My DayDeutsch {task} zu Mein Tag hinzufügen
allDoneEnglisch All done for todayDeutsch Für heute alles erledigt
allDoneTextEnglisch Every task on your list is done. Pick one from the suggestions or call it a day.Deutsch Jede Aufgabe auf deiner Liste ist erledigt. Nimm dir einen Vorschlag oder mach Feierabend.
movedUpEnglisch {task} moved to position {pos}.Deutsch {task} steht jetzt an Position {pos}.
hEnglisch {h} hDeutsch {h} Std.
hmEnglisch {h} h {m} minDeutsch {h} Std. {m} Min.
mEnglisch {m} minDeutsch {m} Min.
detailsEnglisch DetailsDeutsch Details
descriptionEnglisch DescriptionDeutsch Beschreibung
descPlaceholderEnglisch What does done look like?Deutsch Woran erkennt man, dass es fertig ist?
estimateEnglisch EstimateDeutsch Schätzung
commentsEnglisch CommentsDeutsch Kommentare
commentPlaceholderEnglisch Write a commentDeutsch Kommentar schreiben
postEnglisch Post commentDeutsch Kommentar senden
subDoneEnglisch {done} of {n} subtasks doneDeutsch {done} von {n} Unteraufgaben erledigt
removeSubEnglisch Remove {task}Deutsch {task} entfernen
titleLabelEnglisch Task titleDeutsch Titel der Aufgabe
justEnglisch just nowDeutsch gerade eben
savedEnglisch SavedDeutsch Gespeichert

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Aufgabenliste reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Tab, Entf und Leertaste.
  • Setzt aria-checked, aria-controls, aria-describedby, aria-expanded, aria-label, aria-labelledby, aria-live, aria-pressed, aria-roledescription und aria-sort, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen button, checkbox, group, radio, radiogroup und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 1.827 Zeilen
components/ui/task-list.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn, formatPercent, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { DropdownMenu, type MenuItem } from '@/components/ui/dropdown-menu';
import { Icon, type IconName } from '@/components/ui/icon';
import { RadialProgress } from '@/components/ui/radial-progress';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';

export type TaskListVariant = 'grouped' | 'table' | 'priority-matrix' | 'my-day' | 'detail';
export type TaskPriority = 'urgent' | 'high' | 'medium' | 'low' | 'none';
export type TaskStatus = 'todo' | 'doing' | 'done';
export type TaskQuadrant = 'do' | 'plan' | 'delegate' | 'drop';

export interface TaskPerson {
  id: string;
  name: string;
}

export interface Subtask {
  id: string;
  title: string;
  done: boolean;
}

export interface TaskComment {
  id: string;
  /** Person id */
  author: string;
  text: string;
  /** ISO date and time */
  time: string;
}

export interface Task {
  id: string;
  title: string;
  status: TaskStatus;
  priority?: TaskPriority;
  /** YYYY-MM-DD */
  due?: string;
  /** Person id */
  assignee?: string;
  project?: string;
  subtasks?: Subtask[];
  /** Minutes */
  estimate?: number;
  /** priority-matrix: the quadrant */
  quadrant?: TaskQuadrant;
  /** my-day: on today's list */
  myDay?: boolean;
  description?: string;
  comments?: TaskComment[];
}

export interface TaskListProps {
  variant?: TaskListVariant;
  /** The app frame. Leave it out (or null) for a standalone screen. */
  sidebar?: Omit<SidebarProps, 'children'> | null;
  title: string;
  subtitle?: string;
  tasks?: Task[];
  people?: TaskPerson[];
  /** Project names to pick from */
  projects?: string[];
  /** Person id of the signed in user */
  me?: string;
  /** YYYY-MM-DD that counts as today */
  today?: string;
  /** detail: the task on screen, default the first */
  task?: string;
  /** Called on add, complete, update, move, delete and comment. Without it the block simulates the request. */
  onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    search: 'Search tasks',
    open: 'Open',
    doneF: 'Done',
    all: 'All',
    show: 'Show',
    anyPriority: 'Any priority',
    anyone: 'Anyone',
    priorityF: 'Priority',
    person: 'Person',
    newTask: 'New task',
    newPlaceholder: 'Add a task, press Enter to save',
    add: 'Add task',
    when: 'Due',
    noDate: 'No date',
    today: 'Today',
    tomorrow: 'Tomorrow',
    nextWeek: 'Next week',
    overdue: 'Overdue',
    upcoming: 'Next 7 days',
    later: 'Later',
    completed: 'Completed',
    groupToggle: '{group}, {n} tasks',
    complete: 'Mark {task} as done',
    reopen: 'Mark {task} as open',
    doneSay: '{task} done. {n} open tasks left.',
    reopenSay: '{task} is open again.',
    addedSay: '{task} added.',
    undo: 'Undo',
    subtasks: 'Subtasks',
    showSubtasks: 'Show subtasks of {task}, {done} of {n} done',
    addSubtask: 'Add subtask',
    subPlaceholder: 'New subtask',
    urgent: 'Urgent',
    high: 'High',
    medium: 'Medium',
    low: 'Low',
    none: 'None',
    noPriority: 'No priority',
    priority: 'Priority {p}',
    todo: 'To do',
    doing: 'In progress',
    done: 'Done',
    status: 'Status',
    assignee: 'Assignee',
    unassigned: 'Not assigned',
    progress: 'Progress',
    project: 'Project',
    titleCol: 'Task',
    dueOn: 'Due {date}',
    sortBy: 'Sort by {col}',
    sort: 'Sort',
    selectAll: 'Select all tasks',
    select: 'Select {task}',
    selected: '{n} selected',
    markDone: 'Mark done',
    setPriority: 'Set priority',
    remove: 'Delete',
    removed: '{n} tasks deleted.',
    nothing: 'No task matches. Try another filter.',
    clear: 'Show all',
    rows: '{n} tasks',
    doFirst: 'Do first',
    doFirstHint: 'Urgent and important',
    plan: 'Schedule',
    planHint: 'Important, not urgent',
    delegate: 'Delegate',
    delegateHint: 'Urgent, not important',
    drop: 'Let go',
    dropHint: 'Neither, revisit or drop',
    isUrgent: 'Urgent',
    notUrgent: 'Not urgent',
    isImportant: 'Important',
    notImportant: 'Not important',
    moveTo: 'Move to',
    taskMenu: 'Actions for {task}',
    matrixHint: 'Press Space to pick up a task. The arrow keys move it between the four fields, Space drops it, Escape puts it back.',
    grabbed: '{task} picked up in {quad}.',
    over: '{quad}.',
    dropped: '{task} moved to {quad}.',
    cancelled: '{task} is back in {quad}.',
    draggable: 'movable task',
    addTo: 'Add to {quad}',
    myDay: 'My Day',
    progressDay: '{done} of {n} done',
    left: '{t} left',
    upNext: 'Up next',
    finish: 'Mark as done',
    later2: 'Later today',
    reorderUp: 'Move {task} up',
    reorderDown: 'Move {task} down',
    removeDay: 'Take {task} off My Day',
    suggestions: 'Suggestions',
    suggestText: 'Due soon or overdue and not planned yet.',
    addDay: 'Add {task} to My Day',
    allDone: 'All done for today',
    allDoneText: 'Every task on your list is done. Pick one from the suggestions or call it a day.',
    movedUp: '{task} moved to position {pos}.',
    h: '{h} h',
    hm: '{h} h {m} min',
    m: '{m} min',
    details: 'Details',
    description: 'Description',
    descPlaceholder: 'What does done look like?',
    estimate: 'Estimate',
    comments: 'Comments',
    commentPlaceholder: 'Write a comment',
    post: 'Post comment',
    subDone: '{done} of {n} subtasks done',
    removeSub: 'Remove {task}',
    titleLabel: 'Task title',
    just: 'just now',
    saved: 'Saved',
  },
  de: {
    search: 'Aufgaben suchen',
    open: 'Offen',
    doneF: 'Erledigt',
    all: 'Alle',
    show: 'Anzeigen',
    anyPriority: 'Jede Priorität',
    anyone: 'Alle Personen',
    priorityF: 'Priorität',
    person: 'Person',
    newTask: 'Neue Aufgabe',
    newPlaceholder: 'Aufgabe eingeben, Enter speichert',
    add: 'Aufgabe anlegen',
    when: 'Fällig',
    noDate: 'Ohne Datum',
    today: 'Heute',
    tomorrow: 'Morgen',
    nextWeek: 'Nächste Woche',
    overdue: 'Überfällig',
    upcoming: 'Nächste 7 Tage',
    later: 'Später',
    completed: 'Erledigt',
    groupToggle: '{group}, {n} Aufgaben',
    complete: '{task} als erledigt markieren',
    reopen: '{task} wieder öffnen',
    doneSay: '{task} erledigt. Noch {n} offene Aufgaben.',
    reopenSay: '{task} ist wieder offen.',
    addedSay: '{task} angelegt.',
    undo: 'Rückgängig',
    subtasks: 'Unteraufgaben',
    showSubtasks: 'Unteraufgaben von {task} zeigen, {done} von {n} erledigt',
    addSubtask: 'Unteraufgabe hinzufügen',
    subPlaceholder: 'Neue Unteraufgabe',
    urgent: 'Dringend',
    high: 'Hoch',
    medium: 'Mittel',
    low: 'Niedrig',
    none: 'Keine',
    noPriority: 'Ohne Priorität',
    priority: 'Priorität {p}',
    todo: 'Offen',
    doing: 'In Arbeit',
    done: 'Erledigt',
    status: 'Status',
    assignee: 'Zuständig',
    unassigned: 'Nicht zugewiesen',
    progress: 'Fortschritt',
    project: 'Projekt',
    titleCol: 'Aufgabe',
    dueOn: 'Fällig {date}',
    sortBy: 'Nach {col} sortieren',
    sort: 'Sortieren',
    selectAll: 'Alle Aufgaben auswählen',
    select: '{task} auswählen',
    selected: '{n} ausgewählt',
    markDone: 'Erledigt',
    setPriority: 'Priorität setzen',
    remove: 'Löschen',
    removed: '{n} Aufgaben gelöscht.',
    nothing: 'Keine Aufgabe passt. Probiere einen anderen Filter.',
    clear: 'Alle zeigen',
    rows: '{n} Aufgaben',
    doFirst: 'Sofort erledigen',
    doFirstHint: 'Dringend und wichtig',
    plan: 'Einplanen',
    planHint: 'Wichtig, nicht dringend',
    delegate: 'Abgeben',
    delegateHint: 'Dringend, nicht wichtig',
    drop: 'Loslassen',
    dropHint: 'Weder noch, später prüfen',
    isUrgent: 'Dringend',
    notUrgent: 'Nicht dringend',
    isImportant: 'Wichtig',
    notImportant: 'Nicht wichtig',
    moveTo: 'Verschieben nach',
    taskMenu: 'Aktionen für {task}',
    matrixHint: 'Die Leertaste nimmt eine Aufgabe auf. Die Pfeiltasten bewegen sie zwischen den vier Feldern, die Leertaste legt sie ab, Escape legt sie zurück.',
    grabbed: '{task} in {quad} aufgenommen.',
    over: '{quad}.',
    dropped: '{task} nach {quad} verschoben.',
    cancelled: '{task} liegt wieder in {quad}.',
    draggable: 'verschiebbare Aufgabe',
    addTo: 'Zu {quad} hinzufügen',
    myDay: 'Mein Tag',
    progressDay: '{done} von {n} erledigt',
    left: 'noch {t}',
    upNext: 'Als Nächstes',
    finish: 'Als erledigt markieren',
    later2: 'Später heute',
    reorderUp: '{task} nach oben',
    reorderDown: '{task} nach unten',
    removeDay: '{task} von Mein Tag nehmen',
    suggestions: 'Vorschläge',
    suggestText: 'Bald fällig oder überfällig und noch nicht eingeplant.',
    addDay: '{task} zu Mein Tag hinzufügen',
    allDone: 'Für heute alles erledigt',
    allDoneText: 'Jede Aufgabe auf deiner Liste ist erledigt. Nimm dir einen Vorschlag oder mach Feierabend.',
    movedUp: '{task} steht jetzt an Position {pos}.',
    h: '{h} Std.',
    hm: '{h} Std. {m} Min.',
    m: '{m} Min.',
    details: 'Details',
    description: 'Beschreibung',
    descPlaceholder: 'Woran erkennt man, dass es fertig ist?',
    estimate: 'Schätzung',
    comments: 'Kommentare',
    commentPlaceholder: 'Kommentar schreiben',
    post: 'Kommentar senden',
    subDone: '{done} von {n} Unteraufgaben erledigt',
    removeSub: '{task} entfernen',
    titleLabel: 'Titel der Aufgabe',
    just: 'gerade eben',
    saved: 'Gespeichert',
  },
};

type Texts = (typeof TEXTS)['en'];

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;

/* ------------------------------------------------------------------ */
/* Motion                                                              */
/* ------------------------------------------------------------------ */

const CSS =
  '@keyframes tkl-up{from{opacity:0;transform:translateY(10px)}}@keyframes tkl-tick{from{stroke-dashoffset:16}}@keyframes tkl-burst{0%{transform:scale(.4);opacity:.7}100%{transform:scale(1.9);opacity:0}}@keyframes tkl-grow{from{transform:scaleX(0)}}@keyframes tkl-flash{from{background-color:color-mix(in oklab,var(--primary) 12%,transparent)}}' +
  '.tkl-grow{transform-origin:left}' +
  '@media (prefers-reduced-motion:no-preference){[data-tkl=play] .tkl-up{animation:tkl-up .55s var(--ease-kit) var(--tkl-d,0ms) both}[data-tkl=wait] .tkl-up{opacity:0}[data-tkl=play] .tkl-grow{animation:tkl-grow .9s var(--ease-kit) .15s both}.tkl-in{animation:tkl-up .4s var(--ease-kit) both}.tkl-tick{stroke-dasharray:16;animation:tkl-tick .35s var(--ease-kit) .05s both}.tkl-burst{animation:tkl-burst .5s var(--ease-kit) both}.tkl-flash{animation:tkl-flash 1.4s ease-out}}';

function Style() {
  return (
    <style href="kitb5-task-list" precedence="default">
      {CSS}
    </style>
  );
}

function useEnter<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
  useEffect(() => {
    const el = ref.current;
    if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
    setState('wait');
    const io = new IntersectionObserver(([e]) => {
      if (!e?.isIntersecting) return;
      setState('play');
      io.disconnect();
    });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return [ref, { 'data-tkl': state }] as const;
}

/* ------------------------------------------------------------------ */
/* Parts                                                               */
/* ------------------------------------------------------------------ */

const dayMs = 86400000;
const dayNum = (iso: string) => Math.round(Date.parse(iso.slice(0, 10) + 'T00:00:00Z') / dayMs);
const isoOf = (n: number) => new Date(n * dayMs).toISOString().slice(0, 10);
const tagOf = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const fmtDay = (iso: string, l: Locale, o: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short' }) => new Intl.DateTimeFormat(tagOf(l), { ...o, timeZone: 'UTC' }).format(new Date(dayNum(iso) * dayMs));

const field =
  'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-9 sm:text-[13px]';
const ring = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const iconBtn = cn('grid size-11 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-30 sm:size-8', ring);

const PRIOS: TaskPriority[] = ['urgent', 'high', 'medium', 'low', 'none'];
const PRIO_TONE: Record<TaskPriority, string> = {
  urgent: 'text-destructive',
  high: 'text-warning-foreground',
  medium: 'text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]',
  low: 'text-muted-foreground',
  none: 'text-subtle-foreground',
};
const STATUS_TONE: Record<TaskStatus, string> = {
  todo: 'bg-[color-mix(in_oklab,var(--foreground)_7%,var(--card))] text-muted-foreground',
  doing: 'bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]',
  done: 'bg-[color-mix(in_oklab,var(--success)_14%,var(--card))] text-success-foreground',
};

function Flag({ p, t, label }: { p?: TaskPriority; t: Texts; label?: boolean }) {
  if (!p || p === 'none') return null;
  return (
    <span className={cn('inline-flex shrink-0 items-center gap-1 text-[12px] font-medium', PRIO_TONE[p])} title={fill(t.priority, { p: t[p] })}>
      <Icon name={p === 'urgent' ? 'alert-circle' : 'flag'} size={12} />
      {label ? t[p] : <span className="sr-only">{fill(t.priority, { p: t[p] })}</span>}
    </span>
  );
}

function dueOf(due: string, today: string, l: Locale, t: Texts) {
  const d = dayNum(due) - dayNum(today);
  const text = d === 0 ? t.today : d === 1 ? t.tomorrow : fmtDay(due, l, d > 1 && d < 6 ? { weekday: 'short' } : { day: 'numeric', month: 'short' });
  return { text, tone: d < 0 ? 'text-destructive font-medium' : d <= 1 ? 'text-warning-foreground font-medium' : 'text-muted-foreground', sr: d < 0 ? `${t.overdue}, ${text}` : fill(t.dueOn, { date: text }) };
}

function Due({ due, today, locale, t }: { due?: string; today: string; locale: Locale; t: Texts }) {
  if (!due) return null;
  const d = dueOf(due, today, locale, t);
  return (
    <span className={cn('inline-flex shrink-0 items-center gap-1 text-[12px] tabular-nums', d.tone)}>
      <Icon name="calendar" size={12} />
      <span aria-hidden>{d.text}</span>
      <span className="sr-only">{d.sr}</span>
    </span>
  );
}

function dur(min: number, t: Texts) {
  const h = Math.floor(min / 60);
  const m = Math.round(min % 60);
  return h && m ? fill(t.hm, { h, m }) : h ? fill(t.h, { h }) : fill(t.m, { m });
}

function ago(l: Locale, iso: string, now: string, t: Texts) {
  const rtf = new Intl.RelativeTimeFormat(tagOf(l), { numeric: 'auto' });
  const m = Math.round((Date.parse(iso + 'Z') - Date.parse(now + 'Z')) / 60000);
  if (Math.abs(m) < 1) return t.just;
  if (Math.abs(m) < 60) return rtf.format(m, 'minute');
  if (Math.abs(m) < 1440) return rtf.format(Math.round(m / 60), 'hour');
  return rtf.format(Math.round(m / 1440), 'day');
}

/** A round check with a drawn tick and a small burst when it is ticked */
function Tick({ on, onChange, label, size = 'md' }: { on: boolean; onChange: () => void; label: string; size?: 'sm' | 'md' }) {
  const [burst, setBurst] = useState(0);
  return (
    <button
      type="button"
      role="checkbox"
      aria-checked={on}
      aria-label={label}
      onClick={() => {
        if (!on) setBurst((b) => b + 1);
        onChange();
      }}
      className={cn('group/tick relative grid size-11 shrink-0 cursor-pointer place-items-center rounded-full sm:size-8', ring)}
    >
      {burst > 0 && on && <span key={burst} aria-hidden className="tkl-burst absolute size-5 rounded-full bg-[color-mix(in_oklab,var(--success)_45%,transparent)]" />}
      <span className={cn('relative grid place-items-center rounded-full border-[1.5px] transition-[background-color,border-color,transform] duration-200 group-active/tick:scale-90', size === 'sm' ? 'size-4' : 'size-5', on ? 'border-transparent bg-success text-white' : 'border-border-strong bg-background group-hover/tick:border-[color-mix(in_oklab,var(--success)_70%,var(--border-strong))]')}>
        <svg viewBox="0 0 16 16" className={cn(size === 'sm' ? 'size-2.5' : 'size-3', !on && 'opacity-0 group-hover/tick:opacity-40')} aria-hidden>
          <path d="M3.5 8.5l3 3 6-7" fill="none" stroke={on ? 'currentColor' : 'var(--success)'} strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" className={on ? 'tkl-tick' : ''} />
        </svg>
      </span>
    </button>
  );
}

function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
  return (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
      <div className="min-w-0">
        {subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
        <h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
      </div>
      {children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
    </div>
  );
}

function Main({ children, className }: { children: React.ReactNode; className?: string }) {
  return <main className={cn('mx-auto w-full min-w-0 max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}

function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const onKey = (e: React.KeyboardEvent, i: number) => {
    const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    const next = (i + dir + options.length) % options.length;
    onChange(options[next].value);
    ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
  };
  return (
    <div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_6%,var(--background))] p-1', className)}>
      {options.map((o, i) => (
        <button
          key={o.value}
          type="button"
          role="radio"
          aria-checked={value === o.value}
          tabIndex={value === o.value ? 0 : -1}
          onClick={() => onChange(o.value)}
          onKeyDown={(e) => onKey(e, i)}
          className={cn('relative inline-flex h-11 flex-1 shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium transition-[color,background-color,box-shadow] duration-200 sm:h-8', ring, value === o.value ? 'bg-background text-foreground shadow-[var(--shadow-sm)] ring-1 ring-border' : 'text-muted-foreground hover:text-foreground')}
        >
          {o.label}
        </button>
      ))}
    </div>
  );
}

function Pick({ label, value, onChange, children, className, hideLabel = true }: { label: string; value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string; hideLabel?: boolean }) {
  const id = useId();
  return (
    <span className={cn('relative block min-w-0', className)}>
      <label htmlFor={id} className={hideLabel ? 'sr-only' : 'mb-1.5 block text-[12px] font-medium text-muted-foreground'}>
        {label}
      </label>
      <span className="relative block">
        <select id={id} value={value} onChange={(e) => onChange(e.target.value)} className={cn(field, 'cursor-pointer appearance-none truncate pe-8')}>
          {children}
        </select>
        <Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
      </span>
    </span>
  );
}

function Toast({ text, onUndo, t }: { text: string; onUndo?: () => void; t: Texts }) {
  if (!text) return null;
  return (
    <div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
      <div key={text} className="tkl-in pointer-events-auto flex max-w-md items-center gap-3 rounded-xl border border-border bg-popover py-2 ps-4 pe-2 text-[13.5px] text-popover-foreground shadow-[var(--shadow-lg)]">
        <Icon name="check-circle" size={16} className="shrink-0 text-success-foreground" />
        <span className="min-w-0 flex-1 text-pretty">{text}</span>
        {onUndo && (
          <button type="button" onClick={onUndo} className={cn('h-11 shrink-0 cursor-pointer rounded-md px-3 text-[13px] font-semibold text-foreground hover:bg-accent sm:h-8', ring)}>
            {t.undo}
          </button>
        )}
      </div>
    </div>
  );
}

function useToast() {
  const [toast, setToast] = useState<{ text: string; undo?: () => void } | null>(null);
  const timer = useRef(0);
  const show = (text: string, undo?: () => void) => {
    setToast({ text, undo });
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setToast(null), 5000);
  };
  return [toast, show, () => setToast(null)] as const;
}

function Live({ text }: { text: string }) {
  return (
    <p role="status" aria-live="polite" className="sr-only">
      {text}
    </p>
  );
}

function Bar({ value, className, tone = 'bg-primary' }: { value: number; className?: string; tone?: string }) {
  return (
    <span aria-hidden className={cn('block h-1.5 overflow-hidden rounded-full bg-track', className)}>
      <span className={cn('block h-full rounded-full transition-[width] duration-500 ease-[var(--ease-kit)]', value >= 100 ? 'bg-success' : tone)} style={{ width: `${round(Math.max(0, Math.min(100, value)), 1)}%` }} />
    </span>
  );
}

type Ctx = { t: Texts; locale: Locale; today: string; person: (id?: string) => TaskPerson | undefined };

/** Subtasks under a row, with progress, ticks and a quick add */
function SubList({ task, ctx, update }: { task: Task; ctx: Ctx; update: (patch: Partial<Task>) => void }) {
  const { t } = ctx;
  const [draft, setDraft] = useState('');
  const subs = task.subtasks ?? [];
  return (
    <div className="pb-3 ps-11 pe-3 sm:ps-12">
      <ul className="relative grid">
        <span aria-hidden className="absolute bottom-5 start-[-14px] top-0 w-px bg-border sm:start-[-18px]" />
        {subs.map((s) => (
          <li key={s.id} className="tkl-in flex items-center gap-1.5">
            <Tick size="sm" on={s.done} label={fill(s.done ? t.reopen : t.complete, { task: s.title })} onChange={() => update({ subtasks: subs.map((x) => (x.id === s.id ? { ...x, done: !x.done } : x)) })} />
            <span className={cn('min-w-0 flex-1 text-[13px] transition-colors', s.done ? 'text-subtle-foreground line-through' : 'text-foreground')}>{s.title}</span>
          </li>
        ))}
      </ul>
      <form
        className="mt-1 flex gap-2"
        onSubmit={(e) => {
          e.preventDefault();
          if (!draft.trim()) return;
          update({ subtasks: [...subs, { id: `s${Date.now()}`, title: draft.trim(), done: false }] });
          setDraft('');
        }}
      >
        <label className="relative block min-w-0 flex-1">
          <span className="sr-only">{t.addSubtask}</span>
          <Icon name="plus" size={14} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
          <input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder={t.subPlaceholder} className={cn(field, 'h-11 border-transparent bg-transparent ps-8 shadow-none hover:border-border sm:h-8')} />
        </label>
      </form>
    </div>
  );
}

/** One task as a row: tick, title, subtasks toggle, due, priority, person */
function Row({ task, ctx, update, toggle, leaving, className, extra }: { task: Task; ctx: Ctx; update: (patch: Partial<Task>) => void; toggle: () => void; leaving?: boolean; className?: string; extra?: React.ReactNode }) {
  const { t, locale, today } = ctx;
  const id = useId();
  const [open, setOpen] = useState(false);
  const subs = task.subtasks ?? [];
  const sd = subs.filter((s) => s.done).length;
  const p = ctx.person(task.assignee);
  const done = task.status === 'done';
  return (
    <li className={cn('tkl-up border-b border-border transition-[opacity,transform] duration-500 ease-[var(--ease-kit)] last:border-b-0', leaving && 'translate-x-2 opacity-50', className)}>
      <div className="flex items-start gap-1 px-1.5 py-1.5 sm:px-2">
        <Tick on={done} onChange={toggle} label={fill(done ? t.reopen : t.complete, { task: task.title })} />
        <div className="min-w-0 flex-1 py-1.5 sm:py-1">
          <p className={cn('text-pretty text-[14px] leading-snug transition-colors duration-300', done ? 'text-subtle-foreground line-through decoration-[color-mix(in_oklab,var(--foreground)_30%,transparent)]' : 'text-foreground')}>{task.title}</p>
          <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-muted-foreground">
            {task.project && (
              <span className="inline-flex items-center gap-1">
                <Icon name="folder" size={12} />
                {task.project}
              </span>
            )}
            {subs.length > 0 && (
              <button type="button" aria-expanded={open} aria-controls={`${id}-subs`} aria-label={fill(t.showSubtasks, { task: task.title, done: sd, n: subs.length })} onClick={() => setOpen(!open)} className={cn('-my-2 inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-md px-1 tabular-nums hover:text-foreground sm:-my-1 sm:h-7', ring)}>
                <Icon name="chevron-right" size={12} className={cn('transition-transform duration-300', open && 'rotate-90')} />
                <span aria-hidden className="relative h-1 w-8 overflow-hidden rounded-full bg-track">
                  <span className={cn('absolute inset-y-0 start-0 rounded-full transition-[width] duration-500', sd === subs.length ? 'bg-success' : 'bg-primary')} style={{ width: `${round((sd / subs.length) * 100, 1)}%` }} />
                </span>
                <span aria-hidden>
                  {sd}/{subs.length}
                </span>
              </button>
            )}
            <Due due={task.due} today={today} locale={locale} t={t} />
            <Flag p={task.priority} t={t} />
          </div>
        </div>
        {extra}
        {p && (
          <span className="mt-2 shrink-0" title={p.name}>
            <Avatar name={p.name} size="xs" />
            <span className="sr-only">{p.name}</span>
          </span>
        )}
      </div>
      {subs.length > 0 && (
        <div id={`${id}-subs`} className={cn('grid transition-[grid-template-rows] duration-400 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')} inert={!open}>
          <div className={cn('min-h-0 overflow-hidden', !open && 'invisible')}>
            <SubList task={task} ctx={ctx} update={update} />
          </div>
        </div>
      )}
    </li>
  );
}

/** Shared state: the list, completing with a short delay, undo, announcements */
function useTasks(initial: Task[], t: Texts, onAction: TaskListProps['onAction']) {
  const [tasks, setTasks] = useState(initial);
  const [leaving, setLeaving] = useState<string[]>([]);
  const [say, setSay] = useState('');
  const [toast, show, hide] = useToast();
  const update = (id: string, patch: Partial<Task>, action = 'update') => {
    setTasks((ts) => ts.map((x) => (x.id === id ? { ...x, ...patch } : x)));
    void onAction?.(action, { id, ...patch });
  };
  const toggle = (task: Task, delay = 650) => {
    const before = tasks;
    if (task.status === 'done') {
      update(task.id, { status: 'todo' }, 'reopen');
      setSay(fill(t.reopenSay, { task: task.title }));
      return;
    }
    // The tick plays, then the row moves on
    setTasks((ts) => ts.map((x) => (x.id === task.id ? { ...x, status: 'done' } : x)));
    setLeaving((l) => [...l, task.id]);
    window.setTimeout(() => setLeaving((l) => l.filter((x) => x !== task.id)), prefersReducedMotion() ? 0 : delay);
    const left = tasks.filter((x) => x.status !== 'done' && x.id !== task.id).length;
    const text = fill(t.doneSay, { task: task.title, n: left });
    setSay(text);
    show(text, () => {
      setTasks(before);
      hide();
    });
    void onAction?.('complete', { id: task.id });
  };
  return { tasks, setTasks, leaving, say, setSay, toast, show, hide, update, toggle };
}

/* ------------------------------------------------------------------ */
/* grouped                                                             */
/* ------------------------------------------------------------------ */

function QuickAdd({ ctx, onAdd, projects }: { ctx: Ctx; onAdd: (t: Task) => void; projects?: string[] }) {
  const { t, today } = ctx;
  const id = useId();
  const [title, setTitle] = useState('');
  const [when, setWhen] = useState('today');
  const [prio, setPrio] = useState<TaskPriority>('none');
  const submit = () => {
    if (!title.trim()) return;
    const d = dayNum(today);
    const due = when === 'today' ? today : when === 'tomorrow' ? isoOf(d + 1) : when === 'week' ? isoOf(d + 7) : undefined;
    onAdd({ id: `t${Date.now()}`, title: title.trim(), status: 'todo', priority: prio, due, project: projects?.[0] });
    setTitle('');
  };
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        submit();
      }}
      className="tkl-up flex flex-col gap-2 rounded-xl border border-border bg-card p-2 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow] focus-within:border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_12%,transparent),var(--shadow-sm)] sm:flex-row sm:items-center"
    >
      <label htmlFor={`${id}-t`} className="sr-only">
        {t.newTask}
      </label>
      <span className="flex min-w-0 flex-1 items-center gap-2 ps-2">
        <Icon name="plus-circle" size={18} className="shrink-0 text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]" />
        <input id={`${id}-t`} value={title} onChange={(e) => setTitle(e.target.value)} placeholder={t.newPlaceholder} className="h-11 w-0 min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-subtle-foreground sm:h-9 sm:text-[14px]" />
      </span>
      <span className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2 sm:flex">
        <Pick label={t.when} value={when} onChange={setWhen} className="sm:w-36">
          <option value="today">{t.today}</option>
          <option value="tomorrow">{t.tomorrow}</option>
          <option value="week">{t.nextWeek}</option>
          <option value="none">{t.noDate}</option>
        </Pick>
        <Pick label={t.priorityF} value={prio} onChange={(v) => setPrio(v as TaskPriority)} className="sm:w-32">
          {PRIOS.map((p) => (
            <option key={p} value={p}>
              {p === 'none' ? t.noPriority : t[p]}
            </option>
          ))}
        </Pick>
        <Button type="submit" size="sm" disabled={!title.trim()} className="max-sm:h-11">
          {t.add}
        </Button>
      </span>
    </form>
  );
}

function Grouped({ ctx, tasks: initial, people = [], projects, onAction, title, subtitle }: VP) {
  const { t, today } = ctx;
  const id = useId();
  const s = useTasks(initial, t, onAction);
  const [q, setQ] = useState('');
  const [status, setStatus] = useState<'open' | 'done' | 'all'>('open');
  const [prio, setPrio] = useState('all');
  const [who, setWho] = useState('all');
  const [shut, setShut] = useState<string[]>(['completed']);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const ql = q.trim().toLowerCase();
  const match = (x: Task) => (!ql || x.title.toLowerCase().includes(ql) || (x.project ?? '').toLowerCase().includes(ql)) && (prio === 'all' || (x.priority ?? 'none') === prio) && (who === 'all' || x.assignee === who);
  const td = dayNum(today);
  const bucket = (x: Task) => {
    if (x.status === 'done' && !s.leaving.includes(x.id)) return 'completed';
    if (!x.due) return 'later';
    const d = dayNum(x.due) - td;
    return d < 0 ? 'overdue' : d === 0 ? 'today' : d <= 7 ? 'upcoming' : 'later';
  };
  const groups: { id: string; label: string; dot: string }[] = [
    { id: 'overdue', label: t.overdue, dot: 'bg-destructive' },
    { id: 'today', label: t.today, dot: 'bg-primary' },
    { id: 'upcoming', label: t.upcoming, dot: 'bg-warning' },
    { id: 'later', label: t.later, dot: 'bg-subtle-foreground' },
    { id: 'completed', label: t.completed, dot: 'bg-success' },
  ];
  const shown = s.tasks.filter(match).filter((x) => status === 'all' || (status === 'done' ? x.status === 'done' : x.status !== 'done' || s.leaving.includes(x.id)));
  const filtered = !!ql || prio !== 'all' || who !== 'all';
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <span className="text-[13px] tabular-nums text-muted-foreground">{fill(t.rows, { n: s.tasks.filter((x) => x.status !== 'done').length })}</span>
      </Header>
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4">
        <QuickAdd
          ctx={ctx}
          projects={projects}
          onAdd={(task) => {
            s.setTasks((ts) => [task, ...ts]);
            s.setSay(fill(t.addedSay, { task: task.title }));
            void onAction?.('add', { task });
          }}
        />
        <div className="tkl-up flex flex-col gap-2 [--tkl-d:60ms] lg:flex-row lg:items-center">
          <Segmented
            label={t.show}
            value={status}
            onChange={setStatus}
            className="w-full lg:w-auto"
            options={[
              { value: 'open', label: t.open },
              { value: 'done', label: t.doneF },
              { value: 'all', label: t.all },
            ]}
          />
          <label className="relative block min-w-0 lg:ms-auto lg:w-56">
            <span className="sr-only">{t.search}</span>
            <Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
            <input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.search} className={cn(field, 'ps-9')} />
          </label>
          <span className="grid grid-cols-2 gap-2 lg:flex">
            <Pick label={t.priorityF} value={prio} onChange={setPrio} className="lg:w-36">
              <option value="all">{t.anyPriority}</option>
              {PRIOS.map((p) => (
                <option key={p} value={p}>
                  {t[p]}
                </option>
              ))}
            </Pick>
            <Pick label={t.person} value={who} onChange={setWho} className="lg:w-40">
              <option value="all">{t.anyone}</option>
              {people.map((p) => (
                <option key={p.id} value={p.id}>
                  {p.name}
                </option>
              ))}
            </Pick>
          </span>
        </div>
        {groups.map((g, gi) => {
          const items = shown.filter((x) => bucket(x) === g.id);
          if (!items.length) return null;
          const closed = shut.includes(g.id);
          return (
            <section key={g.id} aria-labelledby={`${id}-${g.id}`} className="tkl-up" style={{ '--tkl-d': `${100 + gi * 60}ms` } as React.CSSProperties}>
              <h2 id={`${id}-${g.id}`}>
                <button type="button" aria-expanded={!closed} aria-controls={`${id}-${g.id}-l`} onClick={() => setShut((x) => (closed ? x.filter((y) => y !== g.id) : [...x, g.id]))} className={cn('flex h-11 w-full cursor-pointer items-center gap-2.5 rounded-lg px-1 text-left sm:h-9', ring)} aria-label={fill(t.groupToggle, { group: g.label, n: items.length })}>
                  <Icon name="chevron-down" size={15} className={cn('text-muted-foreground transition-transform duration-300', closed && '-rotate-90')} />
                  <span aria-hidden className={cn('size-2 rounded-full', g.dot)} />
                  <span className="text-[13.5px] font-semibold text-foreground">{g.label}</span>
                  <span key={items.length} className="tkl-in text-[12.5px] tabular-nums text-subtle-foreground">
                    {items.length}
                  </span>
                </button>
              </h2>
              <div id={`${id}-${g.id}-l`} className={cn('grid transition-[grid-template-rows] duration-400 ease-[var(--ease-kit)] motion-reduce:transition-none', closed ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]')} inert={closed}>
                <div className={cn('min-h-0 overflow-hidden', closed && 'invisible')}>
                  <ul className={cn('mt-1 rounded-xl border bg-card shadow-[var(--highlight),var(--shadow-sm)]', g.id === 'overdue' ? 'border-[color-mix(in_oklab,var(--destructive)_30%,var(--border))]' : 'border-border')}>
                    {items.map((x) => (
                      <Row key={x.id} task={x} ctx={ctx} leaving={s.leaving.includes(x.id)} update={(p) => s.update(x.id, p)} toggle={() => s.toggle(x)} />
                    ))}
                  </ul>
                </div>
              </div>
            </section>
          );
        })}
        {!shown.length && (
          <div className="tkl-in grid place-items-center gap-3 rounded-xl border border-dashed border-border px-4 py-12 text-center">
            <p className="text-[13.5px] text-muted-foreground">{t.nothing}</p>
            {filtered && (
              <Button
                size="sm"
                variant="outline"
                onClick={() => {
                  setQ('');
                  setPrio('all');
                  setWho('all');
                }}
              >
                {t.clear}
              </Button>
            )}
          </div>
        )}
      </div>
      <Live text={s.say} />
      <Toast t={t} text={s.toast?.text ?? ''} onUndo={s.toast?.undo} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* table                                                               */
/* ------------------------------------------------------------------ */

type SortKey = 'title' | 'status' | 'priority' | 'due' | 'assignee' | 'progress';

function Table({ ctx, tasks: initial, people = [], onAction, title, subtitle }: VP) {
  const { t, locale, today } = ctx;
  const id = useId();
  const s = useTasks(initial, t, onAction);
  const [q, setQ] = useState('');
  const [status, setStatus] = useState<'all' | TaskStatus>('all');
  const [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: 'due', dir: 1 });
  const [sel, setSel] = useState<string[]>([]);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const ql = q.trim().toLowerCase();
  const prog = (x: Task) => (x.subtasks?.length ? x.subtasks.filter((y) => y.done).length / x.subtasks.length : x.status === 'done' ? 1 : 0);
  const val = (x: Task, k: SortKey): string | number => {
    if (k === 'title') return x.title.toLowerCase();
    if (k === 'status') return ['doing', 'todo', 'done'].indexOf(x.status);
    if (k === 'priority') return PRIOS.indexOf(x.priority ?? 'none');
    if (k === 'due') return x.due ? dayNum(x.due) : 1e9;
    if (k === 'assignee') return ctx.person(x.assignee)?.name ?? '~';
    return prog(x);
  };
  const rows = s.tasks
    .filter((x) => (!ql || x.title.toLowerCase().includes(ql) || (x.project ?? '').toLowerCase().includes(ql)) && (status === 'all' || x.status === status))
    .sort((a, b) => {
      const va = val(a, sort.key);
      const vb = val(b, sort.key);
      return (va < vb ? -1 : va > vb ? 1 : 0) * sort.dir;
    });
  const allSel = rows.length > 0 && rows.every((r) => sel.includes(r.id));
  const someSel = rows.some((r) => sel.includes(r.id));
  const cols: { key: SortKey; label: string; className?: string }[] = [
    { key: 'title', label: t.titleCol },
    { key: 'status', label: t.status, className: 'w-32' },
    { key: 'priority', label: t.priorityF, className: 'w-28' },
    { key: 'due', label: t.when, className: 'w-28' },
    { key: 'assignee', label: t.assignee, className: 'w-40 max-xl:hidden' },
    { key: 'progress', label: t.progress, className: 'w-32 max-xl:hidden' },
  ];
  const statusMenu = (x: Task): MenuItem[] => (['todo', 'doing', 'done'] as TaskStatus[]).map((st) => ({ type: 'radio', group: `st-${x.id}`, value: st, label: t[st], checked: x.status === st, onSelect: () => (st === 'done' && x.status !== 'done' ? s.toggle(x, 0) : s.update(x.id, { status: st })) }));
  const bulk = (fn: (x: Task) => Partial<Task> | null, say: string) => {
    const before = s.tasks;
    s.setTasks((ts) => ts.flatMap((x) => (sel.includes(x.id) ? (fn(x) ? [{ ...x, ...fn(x) }] : []) : [x])));
    s.show(say, () => {
      s.setTasks(before);
      s.hide();
    });
    s.setSay(say);
    void onAction?.('bulk', { ids: sel });
    setSel([]);
  };
  const statusChip = (x: Task) => (
    <DropdownMenu
      items={statusMenu(x)}
      trigger={
        <button type="button" aria-label={`${t.status}: ${t[x.status]}, ${x.title}`} className={cn('inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-md px-2 text-[12px] font-semibold transition-[filter] hover:brightness-95 sm:h-7', ring, STATUS_TONE[x.status])}>
          <Icon name={x.status === 'done' ? 'check-circle' : x.status === 'doing' ? 'loader' : 'circle'} size={12} />
          {t[x.status]}
          <Icon name="chevron-down" size={11} className="opacity-60" />
        </button>
      }
    />
  );
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <span className="text-[13px] tabular-nums text-muted-foreground" aria-live="polite">
          {fill(t.rows, { n: rows.length })}
        </span>
      </Header>
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4">
        <div className="tkl-up flex flex-col gap-2 md:flex-row md:items-center">
          <Segmented
            label={t.status}
            value={status}
            onChange={setStatus}
            className="grid w-full grid-cols-2 gap-1 sm:flex md:w-auto"
            options={[
              { value: 'all', label: t.all },
              { value: 'todo', label: t.todo },
              { value: 'doing', label: t.doing },
              { value: 'done', label: t.done },
            ]}
          />
          <label className="relative block min-w-0 md:ms-auto md:w-60">
            <span className="sr-only">{t.search}</span>
            <Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
            <input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.search} className={cn(field, 'ps-9')} />
          </label>
          <Pick label={t.sort} value={`${sort.key}:${sort.dir}`} onChange={(v) => setSort({ key: v.split(':')[0] as SortKey, dir: Number(v.split(':')[1]) as 1 | -1 })} className="md:hidden">
            {cols.flatMap((c) => [
              <option key={c.key + '1'} value={`${c.key}:1`}>
                {c.label} ↑
              </option>,
              <option key={c.key + '-1'} value={`${c.key}:-1`}>
                {c.label} ↓
              </option>,
            ])}
          </Pick>
        </div>
        {sel.length > 0 && (
          <div role="group" aria-label={fill(t.selected, { n: sel.length })} className="tkl-in sticky top-16 z-10 flex flex-wrap items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--primary)_35%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))] p-2 ps-4 shadow-[var(--shadow-md)] md:top-4">
            <span className="me-auto text-[13px] font-semibold tabular-nums text-foreground">{fill(t.selected, { n: sel.length })}</span>
            <Button size="sm" variant="outline" iconLeft="check" onClick={() => bulk(() => ({ status: 'done' }), fill(t.doneSay, { task: fill(t.selected, { n: sel.length }), n: s.tasks.filter((x) => x.status !== 'done' && !sel.includes(x.id)).length }))}>
              {t.markDone}
            </Button>
            <DropdownMenu
              items={PRIOS.map((p) => ({ label: t[p], icon: p === 'none' ? 'minus' : 'flag', onSelect: () => bulk(() => ({ priority: p }), `${t.setPriority}: ${t[p]}`) }))}
              trigger={
                <Button size="sm" variant="outline" iconLeft="flag">
                  {t.setPriority}
                </Button>
              }
            />
            <Button size="sm" variant="destructive" iconLeft="trash" onClick={() => bulk(() => null, fill(t.removed, { n: sel.length }))}>
              {t.remove}
            </Button>
          </div>
        )}
        {/* Tablet and desktop: a sortable table */}
        <div className="tkl-up overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] [--tkl-d:80ms] max-md:hidden">
          <table className="w-full table-fixed border-collapse text-left">
            <thead>
              <tr className="border-b border-border bg-surface">
                <th scope="col" className="w-12 py-1 ps-3">
                  <input type="checkbox" aria-label={t.selectAll} checked={allSel} ref={(el) => void (el && (el.indeterminate = someSel && !allSel))} onChange={() => setSel(allSel ? [] : rows.map((r) => r.id))} className="size-4 cursor-pointer accent-[var(--primary)]" />
                </th>
                {cols.map((c) => (
                  <th key={c.key} scope="col" aria-sort={sort.key === c.key ? (sort.dir === 1 ? 'ascending' : 'descending') : 'none'} className={cn('px-2 py-1 text-[12px] font-medium text-muted-foreground', c.className)}>
                    <button type="button" onClick={() => setSort((x) => ({ key: c.key, dir: x.key === c.key ? ((-x.dir) as 1 | -1) : 1 }))} className={cn('-mx-1.5 inline-flex h-9 cursor-pointer items-center gap-1 rounded-md px-1.5 hover:text-foreground', ring, sort.key === c.key && 'text-foreground')}>
                      {c.label}
                      <Icon name={sort.key === c.key ? (sort.dir === 1 ? 'arrow-up' : 'arrow-down') : 'chevrons-up-down'} size={12} className={sort.key === c.key ? '' : 'opacity-50'} />
                    </button>
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {rows.map((x, i) => {
                const p = ctx.person(x.assignee);
                const on = sel.includes(x.id);
                const pr = prog(x) * 100;
                return (
                  <tr key={x.id} className={cn('tkl-up border-b border-border transition-colors last:border-b-0 hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]', on && 'bg-[color-mix(in_oklab,var(--primary)_5%,transparent)]')} style={{ '--tkl-d': `${120 + Math.min(i, 8) * 35}ms` } as React.CSSProperties}>
                    <td className="py-2 ps-3">
                      <input type="checkbox" aria-label={fill(t.select, { task: x.title })} checked={on} onChange={() => setSel((y) => (on ? y.filter((z) => z !== x.id) : [...y, x.id]))} className="size-4 cursor-pointer accent-[var(--primary)]" />
                    </td>
                    <td className="min-w-0 px-2 py-2.5">
                      <p className={cn('truncate text-[13.5px] font-medium', x.status === 'done' ? 'text-subtle-foreground line-through' : 'text-foreground')}>{x.title}</p>
                      {x.project && <p className="truncate text-[12px] text-muted-foreground">{x.project}</p>}
                    </td>
                    <td className="px-2 py-2">{statusChip(x)}</td>
                    <td className="px-2 py-2">{x.priority && x.priority !== 'none' ? <Flag p={x.priority} t={t} label /> : <span className="text-[12px] text-subtle-foreground">{t.none}</span>}</td>
                    <td className="px-2 py-2">{x.due ? <Due due={x.due} today={today} locale={locale} t={t} /> : <span className="text-[12px] text-subtle-foreground">{t.noDate}</span>}</td>
                    <td className="px-2 py-2 max-xl:hidden">
                      {p ? (
                        <span className="flex min-w-0 items-center gap-2 text-[13px] text-foreground">
                          <Avatar name={p.name} size="xs" />
                          <span className="truncate">{p.name}</span>
                        </span>
                      ) : (
                        <span className="text-[12px] text-subtle-foreground">{t.unassigned}</span>
                      )}
                    </td>
                    <td className="px-2 py-2 pe-4 max-xl:hidden">
                      <span className="flex items-center gap-2">
                        <Bar value={pr} className="flex-1" />
                        <span className="w-9 text-right text-[12px] tabular-nums text-muted-foreground">{formatPercent(Math.round(pr), locale)}</span>
                      </span>
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
          {!rows.length && <p className="px-4 py-12 text-center text-[13.5px] text-muted-foreground">{t.nothing}</p>}
        </div>
        {/* Phone: one card per task, sorted with the select above */}
        <ul className="grid gap-2 md:hidden">
          {rows.map((x) => {
            const p = ctx.person(x.assignee);
            const on = sel.includes(x.id);
            return (
              <li key={x.id} className={cn('tkl-up rounded-xl border bg-card p-3 shadow-[var(--highlight),var(--shadow-sm)]', on ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))]' : 'border-border')}>
                <div className="flex items-start gap-2">
                  <label className="grid size-11 shrink-0 cursor-pointer place-items-center">
                    <input type="checkbox" aria-label={fill(t.select, { task: x.title })} checked={on} onChange={() => setSel((y) => (on ? y.filter((z) => z !== x.id) : [...y, x.id]))} className="size-4 cursor-pointer accent-[var(--primary)]" />
                  </label>
                  <div className="min-w-0 flex-1 pt-2.5">
                    <p className={cn('text-pretty text-[14px] font-medium leading-snug', x.status === 'done' ? 'text-subtle-foreground line-through' : 'text-foreground')}>{x.title}</p>
                    {x.project && <p className="text-[12px] text-muted-foreground">{x.project}</p>}
                  </div>
                  {p && (
                    <span className="mt-2" title={p.name}>
                      <Avatar name={p.name} size="sm" />
                    </span>
                  )}
                </div>
                <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 ps-[52px]">
                  {statusChip(x)}
                  <Flag p={x.priority} t={t} label />
                  <Due due={x.due} today={today} locale={locale} t={t} />
                </div>
              </li>
            );
          })}
          {!rows.length && <li className="rounded-xl border border-dashed border-border px-4 py-10 text-center text-[13.5px] text-muted-foreground">{t.nothing}</li>}
        </ul>
        <p className="sr-only" id={`${id}-sorted`} aria-live="polite">
          {fill(t.sortBy, { col: cols.find((c) => c.key === sort.key)?.label ?? '' })}
        </p>
      </div>
      <Live text={s.say} />
      <Toast t={t} text={s.toast?.text ?? ''} onUndo={s.toast?.undo} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* priority-matrix                                                     */
/* ------------------------------------------------------------------ */

const QUADS: TaskQuadrant[] = ['do', 'plan', 'delegate', 'drop'];
// Grid position: column 0 urgent, 1 not urgent; row 0 important, 1 not important
const POS: Record<TaskQuadrant, [number, number]> = { do: [0, 0], plan: [1, 0], delegate: [0, 1], drop: [1, 1] };
const AT = (c: number, r: number) => QUADS.find((q) => POS[q][0] === c && POS[q][1] === r)!;
const QUAD_ICON: Record<TaskQuadrant, IconName> = { do: 'bolt', plan: 'calendar', delegate: 'users', drop: 'archive' };
const QUAD_TONE: Record<TaskQuadrant, string> = {
  do: 'bg-[color-mix(in_oklab,var(--destructive)_12%,var(--card))] text-destructive',
  plan: 'bg-[color-mix(in_oklab,var(--primary)_13%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]',
  delegate: 'bg-[color-mix(in_oklab,var(--warning)_20%,var(--card))] text-warning-foreground',
  drop: 'bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] text-muted-foreground',
};

function Matrix({ ctx, tasks: initial, onAction, title, subtitle }: VP) {
  const { t, locale, today } = ctx;
  const id = useId();
  const s = useTasks(initial.map((x) => ({ ...x, quadrant: x.quadrant ?? 'plan' })), t, onAction);
  const [kb, setKb] = useState<{ id: string; from: TaskQuadrant } | null>(null);
  const [drag, setDrag] = useState<{ id: string; over: TaskQuadrant | null; w: number } | null>(null);
  const [adding, setAdding] = useState<TaskQuadrant | null>(null);
  const [draft, setDraft] = useState('');
  const [fresh, setFresh] = useState<string | null>(null);
  const ghost = useRef<HTMLDivElement>(null);
  const grid = useRef<HTMLDivElement>(null);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const qn: Record<TaskQuadrant, { name: string; hint: string }> = { do: { name: t.doFirst, hint: t.doFirstHint }, plan: { name: t.plan, hint: t.planHint }, delegate: { name: t.delegate, hint: t.delegateHint }, drop: { name: t.drop, hint: t.dropHint } };
  const domId = (tid: string) => `${id}-t-${tid}`;
  const moveTo = (task: Task, q: TaskQuadrant, announce = true) => {
    if (task.quadrant === q) return;
    const before = s.tasks;
    s.setTasks((ts) => [...ts.filter((x) => x.id !== task.id), { ...task, quadrant: q }]);
    setFresh(task.id);
    window.setTimeout(() => setFresh((f) => (f === task.id ? null : f)), 1500);
    if (announce) {
      const text = fill(t.dropped, { task: task.title, quad: qn[q].name });
      s.setSay(text);
      s.show(text, () => {
        s.setTasks(before);
        s.hide();
      });
    }
    void onAction?.('move', { id: task.id, quadrant: q });
  };
  useIso(() => {
    if (!kb) return;
    const el = document.getElementById(domId(kb.id));
    if (el && document.activeElement !== el) el.focus({ preventScroll: true });
    el?.scrollIntoView({ block: 'nearest' });
  }, [kb, s.tasks]);
  const onKey = (e: React.KeyboardEvent, task: Task) => {
    // Keys inside the task menu belong to the menu (it bubbles here through the portal)
    if (e.target !== e.currentTarget) return;
    const q = task.quadrant ?? 'plan';
    if (kb?.id !== task.id) {
      if (kb) return;
      if (e.key === ' ' || e.key === 'Enter') {
        e.preventDefault();
        setKb({ id: task.id, from: q });
        s.setSay(fill(t.grabbed, { task: task.title, quad: qn[q].name }));
      }
      return;
    }
    const [c, r] = POS[q];
    const map: Record<string, [number, number]> = { ArrowLeft: [0, r], ArrowRight: [1, r], ArrowUp: [c, 0], ArrowDown: [c, 1] };
    if (map[e.key]) {
      e.preventDefault();
      const nq = AT(...map[e.key]);
      if (nq !== q) {
        s.setTasks((ts) => [...ts.filter((x) => x.id !== task.id), { ...task, quadrant: nq }]);
        s.setSay(fill(t.over, { quad: `${qn[nq].name}, ${qn[nq].hint}` }));
      }
    } else if (e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') {
      if (e.key !== 'Tab') e.preventDefault();
      setKb(null);
      setFresh(task.id);
      s.setSay(fill(t.dropped, { task: task.title, quad: qn[q].name }));
      void onAction?.('move', { id: task.id, quadrant: q });
    } else if (e.key === 'Escape') {
      e.preventDefault();
      s.setTasks((ts) => ts.map((x) => (x.id === task.id ? { ...x, quadrant: kb.from } : x)));
      setKb(null);
      s.setSay(fill(t.cancelled, { task: task.title, quad: qn[kb.from].name }));
    }
  };
  const onPointerDown = (e: React.PointerEvent<HTMLElement>, task: Task) => {
    if (e.button !== 0 || kb || e.pointerType === 'touch') return;
    if ((e.target as HTMLElement).closest('button,input,a,select')) return;
    const r = e.currentTarget.getBoundingClientRect();
    const st = { sx: e.clientX, sy: e.clientY, ox: e.clientX - r.left, oy: e.clientY - r.top, began: false };
    let over: TaskQuadrant | null = null;
    const move = (ev: PointerEvent) => {
      if (!st.began) {
        if (Math.hypot(ev.clientX - st.sx, ev.clientY - st.sy) < 5) return;
        st.began = true;
        document.body.style.userSelect = 'none';
        setDrag({ id: task.id, over: null, w: r.width });
      }
      if (ghost.current) ghost.current.style.transform = `translate3d(${round(ev.clientX - st.ox, 1)}px, ${round(ev.clientY - st.oy, 1)}px, 0)`;
      const q = ((document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null)?.closest<HTMLElement>('[data-quad]')?.dataset.quad ?? null) as TaskQuadrant | null;
      if (q !== over) {
        over = q;
        setDrag((d) => (d ? { ...d, over: q } : d));
      }
    };
    const end = (ok: boolean) => {
      document.removeEventListener('pointermove', move);
      document.removeEventListener('pointerup', up);
      document.removeEventListener('pointercancel', cancel);
      document.body.style.userSelect = '';
      if (!st.began) return;
      setDrag(null);
      if (ok && over) moveTo(task, over);
    };
    const up = () => end(true);
    const cancel = () => end(false);
    document.addEventListener('pointermove', move);
    document.addEventListener('pointerup', up);
    document.addEventListener('pointercancel', cancel);
  };
  const dragTask = drag ? s.tasks.find((x) => x.id === drag.id) : undefined;
  const chip = (x: Task, ghostly = false) => {
    const p = ctx.person(x.assignee);
    return (
      <>
        {!ghostly && <Tick on={x.status === 'done'} onChange={() => s.toggle(x, 0)} label={fill(x.status === 'done' ? t.reopen : t.complete, { task: x.title })} />}
        <span className={cn('min-w-0 flex-1 py-2', ghostly && 'ps-2')}>
          <span className={cn('block text-pretty text-[13.5px] font-medium leading-snug', x.status === 'done' ? 'text-subtle-foreground line-through' : 'text-foreground')}>{x.title}</span>
          <span className="mt-0.5 flex flex-wrap items-center gap-x-2.5 text-[12px] text-muted-foreground">
            <Due due={x.due} today={today} locale={locale} t={t} />
            {x.estimate ? <span className="tabular-nums">{dur(x.estimate, t)}</span> : null}
            {p && <span className="truncate">{p.name}</span>}
          </span>
        </span>
      </>
    );
  };
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <p id={`${id}-hint`} className="sr-only">
        {t.matrixHint}
      </p>
      <div ref={scope} {...enter} className="mt-6">
        {/* Axis labels, desktop and tablet */}
        <div aria-hidden className="mb-2 grid grid-cols-[1.75rem_minmax(0,1fr)_minmax(0,1fr)] gap-3 max-md:hidden">
          <span />
          <span className="tkl-up flex items-center justify-center gap-1.5 text-[12px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
            <Icon name="bolt" size={12} />
            {t.isUrgent}
          </span>
          <span className="tkl-up flex items-center justify-center gap-1.5 text-[12px] font-semibold uppercase tracking-[0.08em] text-subtle-foreground">{t.notUrgent}</span>
        </div>
        <div className="grid grid-cols-[minmax(0,1fr)] gap-3 md:grid-cols-[1.75rem_minmax(0,1fr)_minmax(0,1fr)]">
          <div aria-hidden className="grid grid-rows-2 gap-3 max-md:hidden">
            <span className="flex items-center justify-center text-[12px] font-semibold uppercase tracking-[0.08em] text-muted-foreground [writing-mode:vertical-rl] rotate-180">{t.isImportant}</span>
            <span className="flex items-center justify-center text-[12px] font-semibold uppercase tracking-[0.08em] text-subtle-foreground [writing-mode:vertical-rl] rotate-180">{t.notImportant}</span>
          </div>
          <div ref={grid} className="grid grid-cols-[minmax(0,1fr)] gap-3 md:col-span-2 md:grid-cols-2">
            {QUADS.map((q, qi) => {
              const items = s.tasks.filter((x) => x.quadrant === q);
              const open = items.filter((x) => x.status !== 'done').length;
              const over = drag?.over === q && dragTask?.quadrant !== q;
              const mins = items.filter((x) => x.status !== 'done').reduce((a, x) => a + (x.estimate ?? 0), 0);
              return (
                <section
                  key={q}
                  data-quad={q}
                  aria-labelledby={`${id}-${q}`}
                  className={cn('tkl-up flex min-h-56 flex-col rounded-xl border bg-card p-3 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow,background-color] duration-200 sm:p-4', over ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_4%,var(--card))] shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_14%,transparent)]' : 'border-border')}
                  style={{ '--tkl-d': `${qi * 70}ms` } as React.CSSProperties}
                >
                  <div className="flex items-start gap-3">
                    <span className={cn('grid size-9 shrink-0 place-items-center rounded-lg', QUAD_TONE[q])}>
                      <Icon name={QUAD_ICON[q]} size={16} />
                    </span>
                    <div className="min-w-0 flex-1">
                      <h2 id={`${id}-${q}`} className="flex items-baseline gap-2 text-[14.5px] font-semibold text-foreground">
                        {qn[q].name}
                        <span key={open} className="tkl-in text-[12.5px] font-medium tabular-nums text-subtle-foreground">
                          {open}
                        </span>
                      </h2>
                      <p className="text-[12.5px] text-muted-foreground">
                        {qn[q].hint}
                        {mins > 0 && <span className="tabular-nums"> · {dur(mins, t)}</span>}
                      </p>
                    </div>
                    <button type="button" aria-label={fill(t.addTo, { quad: qn[q].name })} onClick={() => (setAdding(q), setDraft(''))} className={iconBtn}>
                      <Icon name="plus" size={16} />
                    </button>
                  </div>
                  <ul className="mt-3 grid gap-1.5">
                    {adding === q && (
                      <li className="tkl-in">
                        <form
                          onSubmit={(e) => {
                            e.preventDefault();
                            if (draft.trim()) {
                              const task: Task = { id: `t${Date.now()}`, title: draft.trim(), status: 'todo', quadrant: q };
                              s.setTasks((ts) => [...ts, task]);
                              s.setSay(fill(t.addedSay, { task: task.title }));
                              void onAction?.('add', { task });
                            }
                            setAdding(null);
                          }}
                        >
                          <label className="sr-only" htmlFor={`${id}-add-${q}`}>
                            {fill(t.addTo, { quad: qn[q].name })}
                          </label>
                          <input id={`${id}-add-${q}`} autoFocus value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => e.key === 'Escape' && setAdding(null)} onBlur={() => !draft.trim() && setAdding(null)} placeholder={t.newPlaceholder} className={field} />
                        </form>
                      </li>
                    )}
                    {items.map((x) => (
                      <li key={x.id} className={cn(drag?.id === x.id && 'opacity-35')}>
                        <div
                          id={domId(x.id)}
                          role="button"
                          tabIndex={0}
                          aria-roledescription={t.draggable}
                          aria-describedby={`${id}-hint`}
                          aria-pressed={kb?.id === x.id || undefined}
                          aria-label={x.title}
                          onKeyDown={(e) => onKey(e, x)}
                          onPointerDown={(e) => onPointerDown(e, x)}
                          onBlur={() => kb?.id === x.id && requestAnimationFrame(() => document.activeElement?.id !== domId(x.id) && setKb(null))}
                          className={cn(
                            'group flex cursor-grab items-start gap-1 rounded-lg border bg-surface pe-1 transition-[border-color,box-shadow,transform] duration-200 hover:border-border-strong active:cursor-grabbing',
                            ring,
                            kb?.id === x.id ? 'scale-[1.02] border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_30%,transparent),var(--shadow-md)]' : 'border-border',
                            fresh === x.id && 'tkl-flash',
                          )}
                        >
                          {chip(x)}
                          <DropdownMenu
                            align="end"
                            items={[{ type: 'label', label: t.moveTo }, ...QUADS.filter((y) => y !== q).map((y) => ({ label: qn[y].name, icon: QUAD_ICON[y], description: qn[y].hint, onSelect: () => moveTo(x, y) }))]}
                            trigger={
                              <button type="button" aria-label={fill(t.taskMenu, { task: x.title })} className={cn(iconBtn, 'mt-0.5 text-subtle-foreground')}>
                                <Icon name="more-horizontal" size={15} />
                              </button>
                            }
                          />
                        </div>
                      </li>
                    ))}
                  </ul>
                  {!items.length && adding !== q && <p className="mt-3 grid flex-1 place-items-center rounded-lg border border-dashed border-border px-3 py-6 text-center text-[12.5px] text-subtle-foreground">{qn[q].hint}</p>}
                </section>
              );
            })}
          </div>
        </div>
      </div>
      {dragTask &&
        drag &&
        createPortal(
          <div ref={ghost} aria-hidden className="pointer-events-none fixed left-0 top-0 z-[100]" style={{ width: drag.w }}>
            <div className="flex rotate-[1.5deg] items-start gap-1 rounded-lg border border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-card pe-1 shadow-[var(--shadow-lg)]">{chip(dragTask, true)}</div>
          </div>,
          document.body,
        )}
      <Live text={s.say} />
      <Toast t={t} text={s.toast?.text ?? ''} onUndo={s.toast?.undo} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* my-day                                                              */
/* ------------------------------------------------------------------ */

function MyDay({ ctx, tasks: initial, onAction, title, subtitle }: VP) {
  const { t, locale, today } = ctx;
  const id = useId();
  const s = useTasks(initial, t, onAction);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const [focusId, setFocusId] = useState<string | null>(null);
  const day = s.tasks.filter((x) => x.myDay);
  const open = day.filter((x) => x.status !== 'done' || s.leaving.includes(x.id));
  const done = day.filter((x) => x.status === 'done');
  const next = day.find((x) => x.status !== 'done');
  const left = day.filter((x) => x.status !== 'done').reduce((a, x) => a + (x.estimate ?? 0), 0);
  const td = dayNum(today);
  const sugg = s.tasks.filter((x) => !x.myDay && x.status !== 'done' && x.due && dayNum(x.due) - td <= 2).sort((a, b) => dayNum(a.due!) - dayNum(b.due!));
  useEffect(() => {
    if (!focusId) return;
    document.getElementById(`${id}-r-${focusId}`)?.focus({ preventScroll: true });
    setFocusId(null);
  }, [focusId, id, s.tasks]);
  const reorder = (task: Task, dir: -1 | 1) => {
    const ids = day.map((x) => x.id);
    const i = ids.indexOf(task.id);
    const j = i + dir;
    if (j < 0 || j >= ids.length) return;
    const other = ids[j];
    s.setTasks((ts) => {
      const a = ts.findIndex((x) => x.id === task.id);
      const b = ts.findIndex((x) => x.id === other);
      const out = [...ts];
      [out[a], out[b]] = [out[b], out[a]];
      return out;
    });
    s.setSay(fill(t.movedUp, { task: task.title, pos: j + 1 }));
    setFocusId(task.id);
    void onAction?.('reorder', { id: task.id, position: j + 1 });
  };
  const dateLine = new Intl.DateTimeFormat(tagOf(locale), { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }).format(new Date(td * dayMs));
  return (
    <Main className="max-w-[72rem]">
      <Header title={title} subtitle={subtitle ?? dateLine} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
        <div className="grid min-w-0 gap-4">
          {/* Progress and the next task */}
          <section aria-label={t.myDay} className="tkl-up kit-card relative grid grid-cols-[minmax(0,1fr)] overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] sm:grid-cols-[auto_minmax(0,1fr)]">
            <div className="flex items-center gap-4 border-b border-border p-4 sm:flex-col sm:justify-center sm:border-b-0 sm:border-e sm:px-6 sm:py-5">
              <RadialProgress label={t.myDay} size={112} thickness={10} data={[{ label: t.myDay, value: done.length, max: Math.max(1, day.length) }]} centerValue={`${done.length}/${day.length}`} centerLabel={t.doneF} />
              <div className="min-w-0 sm:text-center">
                <p className="text-[13px] font-medium text-foreground" aria-live="polite">
                  {fill(t.progressDay, { done: done.length, n: day.length })}
                </p>
                {left > 0 && <p className="text-[12.5px] tabular-nums text-muted-foreground">{fill(t.left, { t: dur(left, t) })}</p>}
              </div>
            </div>
            <div className="flex min-w-0 flex-col justify-center gap-3 p-4 sm:p-5">
              {next ? (
                <div key={next.id} className="tkl-in">
                  <p className="text-[12px] font-semibold uppercase tracking-[0.08em] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">{t.upNext}</p>
                  <p className="mt-1.5 text-balance text-[19px] font-semibold leading-snug tracking-[-0.015em] text-foreground">{next.title}</p>
                  <p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12.5px] text-muted-foreground">
                    {next.project && (
                      <span className="inline-flex items-center gap-1">
                        <Icon name="folder" size={12} />
                        {next.project}
                      </span>
                    )}
                    {next.estimate ? (
                      <span className="inline-flex items-center gap-1 tabular-nums">
                        <Icon name="clock" size={12} />
                        {dur(next.estimate, t)}
                      </span>
                    ) : null}
                    <Flag p={next.priority} t={t} label />
                  </p>
                  <div className="mt-4 flex flex-wrap gap-2">
                    <Button size="sm" iconLeft="check" magnetic className="max-sm:flex-1" onClick={() => s.toggle(next, 450)}>
                      {t.finish}
                    </Button>
                    <Button
                      size="sm"
                      variant="outline"
                      iconLeft="arrow-down"
                      className="max-sm:flex-1"
                      onClick={() => {
                        s.setTasks((ts) => [...ts.filter((x) => x.id !== next.id), next]);
                        s.setSay(fill(t.movedUp, { task: next.title, pos: day.length }));
                      }}
                    >
                      {t.later2}
                    </Button>
                  </div>
                </div>
              ) : (
                <div className="tkl-in flex items-start gap-3">
                  <span className="grid size-10 shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_15%,var(--card))] text-success-foreground">
                    <Icon name="sun" size={18} />
                  </span>
                  <div>
                    <p className="text-[16px] font-semibold text-foreground">{t.allDone}</p>
                    <p className="mt-1 text-pretty text-[13.5px] text-muted-foreground">{t.allDoneText}</p>
                  </div>
                </div>
              )}
            </div>
          </section>
          <ol className="tkl-up overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] [--tkl-d:80ms]">
            {[...open, ...done.filter((x) => !s.leaving.includes(x.id))].map((x) => {
              const i = day.findIndex((y) => y.id === x.id);
              const isDone = x.status === 'done' && !s.leaving.includes(x.id);
              return (
                <li key={x.id} id={`${id}-r-${x.id}`} tabIndex={-1} onKeyDown={(e) => e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && (e.preventDefault(), reorder(x, e.key === 'ArrowUp' ? -1 : 1))} className={cn('tkl-in group flex items-center gap-1 border-b border-border px-1.5 py-1 outline-none last:border-b-0 focus-visible:bg-[color-mix(in_oklab,var(--primary)_5%,transparent)] sm:px-2', s.leaving.includes(x.id) && 'opacity-60')}>
                  <Tick on={x.status === 'done'} onChange={() => s.toggle(x)} label={fill(x.status === 'done' ? t.reopen : t.complete, { task: x.title })} />
                  <span className="min-w-0 flex-1 py-2">
                    <span className={cn('block text-[14px] leading-snug transition-colors duration-300', x.status === 'done' ? 'text-subtle-foreground line-through' : 'text-foreground')}>{x.title}</span>
                    <span className="mt-0.5 flex flex-wrap items-center gap-x-3 text-[12px] text-muted-foreground">
                      {x.project && <span>{x.project}</span>}
                      {x.estimate ? <span className="tabular-nums">{dur(x.estimate, t)}</span> : null}
                      <Flag p={x.priority} t={t} />
                    </span>
                  </span>
                  {!isDone && (
                    <span className="flex items-center sm:opacity-60 sm:transition-opacity sm:group-focus-within:opacity-100 sm:group-hover:opacity-100">
                      <button type="button" aria-label={fill(t.reorderUp, { task: x.title })} disabled={i <= 0} onClick={() => reorder(x, -1)} className={iconBtn}>
                        <Icon name="arrow-up" size={15} />
                      </button>
                      <button type="button" aria-label={fill(t.reorderDown, { task: x.title })} disabled={i >= open.length - 1} onClick={() => reorder(x, 1)} className={cn(iconBtn, 'max-sm:hidden')}>
                        <Icon name="arrow-down" size={15} />
                      </button>
                    </span>
                  )}
                  <button
                    type="button"
                    aria-label={fill(t.removeDay, { task: x.title })}
                    onClick={() => {
                      s.update(x.id, { myDay: false }, 'unplan');
                      s.setSay(fill(t.removeDay, { task: x.title }));
                    }}
                    className={cn(iconBtn, 'text-subtle-foreground hover:text-destructive')}
                  >
                    <Icon name="x" size={15} />
                  </button>
                </li>
              );
            })}
          </ol>
        </div>
        <aside aria-labelledby={`${id}-sug`} className="tkl-up rounded-xl border border-border bg-surface p-4 [--tkl-d:140ms] sm:p-5 lg:sticky lg:top-6">
          <h2 id={`${id}-sug`} className="flex items-center gap-2 text-[14px] font-semibold text-foreground">
            <Icon name="lightbulb" size={15} className="text-warning-foreground" />
            {t.suggestions}
          </h2>
          <p className="mt-1 text-pretty text-[12.5px] text-muted-foreground">{t.suggestText}</p>
          <ul className="mt-3 grid gap-2">
            {sugg.map((x) => (
              <li key={x.id} className="tkl-in flex items-center gap-2 rounded-lg border border-border bg-card py-1 ps-3 pe-1 transition-transform duration-200 hover:-translate-y-0.5">
                <span className="min-w-0 flex-1 py-1.5">
                  <span className="block text-[13px] font-medium leading-snug text-foreground">{x.title}</span>
                  <span className="mt-0.5 flex items-center gap-2">
                    <Due due={x.due} today={today} locale={locale} t={t} />
                    <Flag p={x.priority} t={t} />
                  </span>
                </span>
                <button
                  type="button"
                  aria-label={fill(t.addDay, { task: x.title })}
                  onClick={() => {
                    s.setTasks((ts) => [...ts.filter((y) => y.id !== x.id), { ...x, myDay: true }]);
                    s.setSay(fill(t.addDay, { task: x.title }));
                    void onAction?.('plan', { id: x.id });
                  }}
                  className={cn(iconBtn, 'text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]')}
                >
                  <Icon name="plus-circle" size={17} />
                </button>
              </li>
            ))}
            {!sugg.length && <li className="py-4 text-center text-[12.5px] text-subtle-foreground">{t.allDone}</li>}
          </ul>
        </aside>
      </div>
      <Live text={s.say} />
      <Toast t={t} text={s.toast?.text ?? ''} onUndo={s.toast?.undo} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* detail                                                              */
/* ------------------------------------------------------------------ */

function Detail({ ctx, tasks: initial, people = [], projects = [], me, task: taskId, onAction, title, subtitle, now }: VP) {
  const { t, locale } = ctx;
  const id = useId();
  const s = useTasks(initial, t, onAction);
  const [sub, setSub] = useState('');
  const [comment, setComment] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const task = s.tasks.find((x) => x.id === taskId) ?? s.tasks[0];
  if (!task) return null;
  const subs = task.subtasks ?? [];
  const sd = subs.filter((x) => x.done).length;
  const up = (patch: Partial<Task>, action?: string) => s.update(task.id, patch, action);
  const moveSub = (i: number, d: number) => {
    const j = i + d;
    if (j < 0 || j >= subs.length) return;
    const out = [...subs];
    [out[i], out[j]] = [out[j], out[i]];
    up({ subtasks: out });
    s.setSay(fill(t.movedUp, { task: subs[i].title, pos: j + 1 }));
    requestAnimationFrame(() => document.getElementById(`${id}-mv-${subs[i].id}-${d}`)?.focus());
  };
  const lbl = 'text-[12px] font-medium text-muted-foreground';
  const fields = (
    <dl className="grid grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-2 lg:grid-cols-1">
      <div className="grid gap-1.5">
        <dt className={lbl}>{t.assignee}</dt>
        <dd>
          <Pick label={t.assignee} value={task.assignee ?? ''} onChange={(v) => up({ assignee: v || undefined })}>
            <option value="">{t.unassigned}</option>
            {people.map((p) => (
              <option key={p.id} value={p.id}>
                {p.name}
              </option>
            ))}
          </Pick>
        </dd>
      </div>
      <div className="grid gap-1.5">
        <dt>
          <label htmlFor={`${id}-due`} className={lbl}>
            {t.when}
          </label>
        </dt>
        <dd>
          <input id={`${id}-due`} type="date" value={task.due ?? ''} onChange={(e) => up({ due: e.target.value || undefined })} className={cn(field, 'tabular-nums')} />
        </dd>
      </div>
      <div className="grid gap-1.5">
        <dt className={lbl}>{t.priorityF}</dt>
        <dd>
          <Pick label={t.priorityF} value={task.priority ?? 'none'} onChange={(v) => up({ priority: v as TaskPriority })}>
            {PRIOS.map((p) => (
              <option key={p} value={p}>
                {t[p]}
              </option>
            ))}
          </Pick>
        </dd>
      </div>
      <div className="grid gap-1.5">
        <dt className={lbl}>{t.project}</dt>
        <dd>
          <Pick label={t.project} value={task.project ?? ''} onChange={(v) => up({ project: v || undefined })}>
            {[...new Set([...(task.project ? [task.project] : []), ...projects])].map((p) => (
              <option key={p} value={p}>
                {p}
              </option>
            ))}
          </Pick>
        </dd>
      </div>
      <div className="grid gap-1.5">
        <dt>
          <label htmlFor={`${id}-est`} className={lbl}>
            {t.estimate}
          </label>
        </dt>
        <dd className="relative">
          <input id={`${id}-est`} type="number" min={0} step={15} value={task.estimate ?? 0} onChange={(e) => up({ estimate: Math.max(0, Number(e.target.value) || 0) })} className={cn(field, 'pe-14 tabular-nums')} />
          <span className="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-[12.5px] text-subtle-foreground">{fill(t.m, { m: '' }).trim()}</span>
        </dd>
      </div>
    </dl>
  );
  return (
    <Main className="max-w-[72rem]">
      <div ref={scope} {...enter}>
        <p className="tkl-up flex items-center gap-1.5 text-[13px] text-muted-foreground">
          {subtitle}
          {task.project && (
            <>
              <Icon name="chevron-right" size={13} className="text-subtle-foreground" />
              <span>{task.project}</span>
            </>
          )}
        </p>
        <h1 className="sr-only">{title || task.title}</h1>
        <div className="tkl-up mt-2 flex items-start gap-2">
          <Tick on={task.status === 'done'} onChange={() => (task.status === 'done' ? s.toggle(task) : (up({ status: 'done' }, 'complete'), s.setSay(fill(t.doneSay, { task: task.title, n: 0 }))))} label={fill(task.status === 'done' ? t.reopen : t.complete, { task: task.title })} />
          <label htmlFor={`${id}-title`} className="sr-only">
            {t.titleLabel}
          </label>
          <textarea id={`${id}-title`} rows={1} value={task.title} onChange={(e) => up({ title: e.target.value })} className={cn('field-sizing-content mt-0.5 block min-w-0 flex-1 resize-none rounded-md bg-transparent px-1 text-balance text-[clamp(1.375rem,1.15rem+0.8vw,1.75rem)] font-semibold leading-tight tracking-[-0.03em] text-foreground hover:bg-[color-mix(in_oklab,var(--foreground)_4%,transparent)]', ring, task.status === 'done' && 'text-muted-foreground line-through')} />
        </div>
        <div className="tkl-up mt-4 [--tkl-d:60ms]">
          <Segmented
            label={t.status}
            value={task.status}
            onChange={(v) => up({ status: v }, 'status')}
            className="w-full sm:w-auto"
            options={[
              { value: 'todo', label: t.todo },
              { value: 'doing', label: t.doing },
              { value: 'done', label: t.done },
            ]}
          />
        </div>
        <div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_18rem] lg:items-start">
          {/* Fields: a card beside the content on desktop, before it on phones and tablets */}
          <aside aria-label={t.details} className="tkl-up rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] [--tkl-d:100ms] sm:p-5 lg:sticky lg:top-6 lg:order-last">
            <h2 className="mb-4 text-[13.5px] font-semibold text-foreground">{t.details}</h2>
            {fields}
          </aside>
          <div className="grid min-w-0 gap-6">
            <section className="tkl-up [--tkl-d:140ms]">
              <label htmlFor={`${id}-desc`} className="text-[13.5px] font-semibold text-foreground">
                {t.description}
              </label>
              <textarea id={`${id}-desc`} rows={4} value={task.description ?? ''} placeholder={t.descPlaceholder} onChange={(e) => up({ description: e.target.value })} className={cn(field, 'mt-2 h-auto min-h-24 resize-y py-2.5 leading-relaxed sm:h-auto sm:text-[14px]')} />
            </section>
            <section aria-labelledby={`${id}-subs`} className="tkl-up rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] [--tkl-d:180ms]">
              <div className="flex items-center gap-3 border-b border-border px-4 py-3.5 sm:px-5">
                <h2 id={`${id}-subs`} className="text-[13.5px] font-semibold text-foreground">
                  {t.subtasks}
                </h2>
                <span className="text-[12.5px] tabular-nums text-muted-foreground" aria-live="polite">
                  {fill(t.subDone, { done: sd, n: subs.length })}
                </span>
                <Bar value={subs.length ? (sd / subs.length) * 100 : 0} className="ms-auto w-24 sm:w-32" />
              </div>
              <ol>
                {subs.map((x, i) => (
                  <li key={x.id} className="tkl-in group flex items-center gap-1 border-b border-border px-1.5 py-0.5 last:border-b-0 sm:px-2">
                    <Tick on={x.done} onChange={() => up({ subtasks: subs.map((y) => (y.id === x.id ? { ...y, done: !y.done } : y)) })} label={fill(x.done ? t.reopen : t.complete, { task: x.title })} />
                    <span className={cn('min-w-0 flex-1 py-2 text-[14px] transition-colors', x.done ? 'text-subtle-foreground line-through' : 'text-foreground')}>{x.title}</span>
                    <span className="flex sm:opacity-60 sm:transition-opacity sm:group-focus-within:opacity-100 sm:group-hover:opacity-100">
                      <button id={`${id}-mv-${x.id}--1`} type="button" aria-label={fill(t.reorderUp, { task: x.title })} disabled={i === 0} onClick={() => moveSub(i, -1)} className={cn(iconBtn, 'max-sm:hidden')}>
                        <Icon name="arrow-up" size={14} />
                      </button>
                      <button id={`${id}-mv-${x.id}-1`} type="button" aria-label={fill(t.reorderDown, { task: x.title })} disabled={i === subs.length - 1} onClick={() => moveSub(i, 1)} className={cn(iconBtn, 'max-sm:hidden')}>
                        <Icon name="arrow-down" size={14} />
                      </button>
                      <button type="button" aria-label={fill(t.removeSub, { task: x.title })} onClick={() => up({ subtasks: subs.filter((y) => y.id !== x.id) })} className={cn(iconBtn, 'text-subtle-foreground hover:text-destructive')}>
                        <Icon name="trash" size={14} />
                      </button>
                    </span>
                  </li>
                ))}
              </ol>
              <form
                className="flex gap-2 border-t border-border p-2 sm:p-3"
                onSubmit={(e) => {
                  e.preventDefault();
                  if (!sub.trim()) return;
                  up({ subtasks: [...subs, { id: `s${Date.now()}`, title: sub.trim(), done: false }] });
                  setSub('');
                }}
              >
                <label htmlFor={`${id}-sub`} className="sr-only">
                  {t.addSubtask}
                </label>
                <input id={`${id}-sub`} value={sub} onChange={(e) => setSub(e.target.value)} placeholder={t.subPlaceholder} className={cn(field, 'w-0 flex-1')} />
                <Button type="submit" size="sm" variant="outline" iconLeft="plus" disabled={!sub.trim()} className="max-sm:h-11">
                  <span className="max-sm:sr-only">{t.addSubtask}</span>
                </Button>
              </form>
            </section>
            <section aria-labelledby={`${id}-cm`} className="tkl-up [--tkl-d:220ms]">
              <h2 id={`${id}-cm`} className="text-[13.5px] font-semibold text-foreground">
                {t.comments}
              </h2>
              <ol className="relative mt-4 grid gap-5">
                <span aria-hidden className="absolute bottom-3 start-4 top-3 w-px bg-border" />
                {(task.comments ?? []).map((c) => {
                  const p = ctx.person(c.author);
                  return (
                    <li key={c.id} className="tkl-in relative flex gap-3">
                      <span className="relative rounded-full ring-4 ring-background">
                        <Avatar name={p?.name ?? '?'} size="sm" />
                      </span>
                      <div className="min-w-0 flex-1 rounded-xl border border-border bg-card px-3.5 py-2.5">
                        <p className="flex flex-wrap items-baseline gap-x-2 text-[13px]">
                          <span className="font-semibold text-foreground">{p?.name}</span>
                          <time dateTime={c.time} className="text-[12px] text-subtle-foreground">
                            {ago(locale, c.time, now, t)}
                          </time>
                        </p>
                        <p className="mt-0.5 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{c.text}</p>
                      </div>
                    </li>
                  );
                })}
              </ol>
              <form
                className="mt-4 grid gap-2"
                onSubmit={(e) => {
                  e.preventDefault();
                  if (!comment.trim()) return;
                  up({ comments: [...(task.comments ?? []), { id: `c${Date.now()}`, author: me ?? people[0]?.id ?? '', text: comment.trim(), time: now }] }, 'comment');
                  setComment('');
                }}
              >
                <label htmlFor={`${id}-c`} className="sr-only">
                  {t.commentPlaceholder}
                </label>
                <textarea id={`${id}-c`} rows={2} value={comment} onChange={(e) => setComment(e.target.value)} placeholder={t.commentPlaceholder} className={cn(field, 'h-auto resize-none py-2.5 sm:h-auto')} />
                <Button type="submit" size="sm" iconLeft="send" disabled={!comment.trim()} className="justify-self-end max-sm:h-11 max-sm:w-full">
                  {t.post}
                </Button>
              </form>
            </section>
          </div>
        </div>
      </div>
      <Live text={s.say} />
      <Toast t={t} text={s.toast?.text ?? ''} onUndo={s.toast?.undo} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* TaskList                                                            */
/* ------------------------------------------------------------------ */

type VP = Omit<TaskListProps, 'variant' | 'sidebar' | 'labels' | 'className' | 'tasks' | 'today'> & { ctx: Ctx; tasks: Task[]; now: string };

/**
 * Task screens for apps: grouped by due date with subtasks and filters, a
 * sortable table with bulk actions, an urgent and important matrix you move
 * tasks across, a focus list for today and a full task page.
 *   <TaskList variant="grouped" title="My tasks" tasks={…} people={…} today="2026-10-21" />
 */
export function TaskList({ variant = 'grouped', sidebar, labels, className, today = '2026-10-21', tasks = [], ...props }: TaskListProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const people = props.people;
  const ctx: Ctx = useMemo(() => ({ t, locale, today, person: (i?: string) => (i ? people?.find((p) => p.id === i) : undefined) }), [t, locale, today, people]);
  const p: VP = { ...props, ctx, tasks, now: `${today}T15:30` };
  const body = (() => {
    switch (variant) {
      case 'table':
        return <Table {...p} />;
      case 'priority-matrix':
        return <Matrix {...p} />;
      case 'my-day':
        return <MyDay {...p} />;
      case 'detail':
        return <Detail {...p} />;
      default:
        return <Grouped {...p} />;
    }
  })();
  if (!sidebar)
    return (
      <div className={cn('min-h-dvh bg-background', className)}>
        <Style />
        {body}
      </div>
    );
  return (
    <Sidebar mobile="drawer" {...sidebar} className={className}>
      <Style />
      {body}
    </Sidebar>
  );
}

Häufige Fragen

Wie installiere ich Aufgabenliste?

Mit aiw add @ai-website/task-list. Der Befehl legt die Datei components/ui/task-list.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind avatar, button, dropdown-menu, icon, radial-progress und sidebar. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Aufgabenliste?

5 Varianten, Nach Fälligkeit, Tabelle, Prioritätenmatrix, Mein Tag und Aufgabenseite. Du wählst sie mit der Prop variant, zum Beispiel variant="table".

Kann ich die Texte von Aufgabenliste übersetzen?

Ja. Aufgabenliste bringt 112 eigene Texte auf Deutsch und Englisch mit, etwa „Aufgaben suchen“, „Offen“ und „Erledigt“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Aufgabenliste einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Aufgabenliste mit aiw_components und installieren sie mit aiw_add @ai-website/task-list. Nenn im Prompt die Variante, etwa variant="grouped", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert