Skip to content
UI Kit
Accent color

Task list component

Task screens with priorities, subtasks, filters and a satisfying tick. Grouped by due date, a sortable table, an urgent and important matrix, My Day and a task page.

5 variantsOne of 4 components in Tasks and planning

From overdue to done in one view

A task list answers what is next, for one person or for a whole team. This one brings priorities, subtasks, filters and an animated tick, and every check can be undone right after, so a slip of the finger costs nothing.

Grouped by due date sorts tasks into overdue, today, the next seven days and later, with a quick add, filters for status, priority and person and subtasks that fold out. Table has sortable columns, a status menu in every row, selection with bulk actions and a card list on phones. Priority matrix splits urgent and important into four fields, tasks move by drag, by menu or with Space and the arrow keys. My Day is a focus list for today with a progress ring, the next task in front and suggestions that are due soon. Task page opens one task with person, due date, priority, project, estimate, subtasks to tick and reorder and comments.

Let people add a task in one line and fill in the rest later, a quick add keeps the list alive. Show overdue tasks first but without alarm colors everywhere. Use My Day as the start screen for people who work alone and the table for leads who plan for others.

Variants

Grouped by due dateNew in 1.2

Overdue, today, the next seven days and later, with a quick add, filters for status, priority and person, subtasks that fold out and undo after ticking.

TableNew in 1.2

Sortable columns, a status menu in every row, filters, selection with bulk actions and a card list on phones.

Priority matrixNew in 1.2

Urgent and important as four fields. Drag a task, use its menu, or lift it with Space and move it with the arrow keys.

My DayNew in 1.2

A focus list for today with a progress ring, the next task in front, reordering and suggestions that are due soon.

Task pageNew in 1.2

One task with status, fields for person, due date, priority, project and estimate, subtasks you tick and reorder, and comments.

Details

What Task list brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/task-list.tsx
npm packages
None
Languages
English and German, 112 texts of its own
Studio
Adjust in code
In collections
SaaS app screens

Props

The TaskListProps interface from components/ui/task-list.tsx, with the comments from the code.

  • variantoptional
    TaskListVariant

    groupedtablepriority-matrixmy-daydetail

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

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

  • titlerequired
    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.

Built-in texts

Task list writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 112 texts
searchEnglish Search tasksGerman Aufgaben suchen
openEnglish OpenGerman Offen
doneFEnglish DoneGerman Erledigt
allEnglish AllGerman Alle
showEnglish ShowGerman Anzeigen
anyPriorityEnglish Any priorityGerman Jede Priorität
anyoneEnglish AnyoneGerman Alle Personen
priorityFEnglish PriorityGerman Priorität
personEnglish PersonGerman Person
newTaskEnglish New taskGerman Neue Aufgabe
newPlaceholderEnglish Add a task, press Enter to saveGerman Aufgabe eingeben, Enter speichert
addEnglish Add taskGerman Aufgabe anlegen
whenEnglish DueGerman Fällig
noDateEnglish No dateGerman Ohne Datum
todayEnglish TodayGerman Heute
tomorrowEnglish TomorrowGerman Morgen
nextWeekEnglish Next weekGerman Nächste Woche
overdueEnglish OverdueGerman Überfällig
upcomingEnglish Next 7 daysGerman Nächste 7 Tage
laterEnglish LaterGerman Später
completedEnglish CompletedGerman Erledigt
groupToggleEnglish {group}, {n} tasksGerman {group}, {n} Aufgaben
completeEnglish Mark {task} as doneGerman {task} als erledigt markieren
reopenEnglish Mark {task} as openGerman {task} wieder öffnen
doneSayEnglish {task} done. {n} open tasks left.German {task} erledigt. Noch {n} offene Aufgaben.
reopenSayEnglish {task} is open again.German {task} ist wieder offen.
addedSayEnglish {task} added.German {task} angelegt.
undoEnglish UndoGerman Rückgängig
subtasksEnglish SubtasksGerman Unteraufgaben
showSubtasksEnglish Show subtasks of {task}, {done} of {n} doneGerman Unteraufgaben von {task} zeigen, {done} von {n} erledigt
addSubtaskEnglish Add subtaskGerman Unteraufgabe hinzufügen
subPlaceholderEnglish New subtaskGerman Neue Unteraufgabe
urgentEnglish UrgentGerman Dringend
highEnglish HighGerman Hoch
mediumEnglish MediumGerman Mittel
lowEnglish LowGerman Niedrig
noneEnglish NoneGerman Keine
noPriorityEnglish No priorityGerman Ohne Priorität
priorityEnglish Priority {p}German Priorität {p}
todoEnglish To doGerman Offen
doingEnglish In progressGerman In Arbeit
doneEnglish DoneGerman Erledigt
statusEnglish StatusGerman Status
assigneeEnglish AssigneeGerman Zuständig
unassignedEnglish Not assignedGerman Nicht zugewiesen
progressEnglish ProgressGerman Fortschritt
projectEnglish ProjectGerman Projekt
titleColEnglish TaskGerman Aufgabe
dueOnEnglish Due {date}German Fällig {date}
sortByEnglish Sort by {col}German Nach {col} sortieren
sortEnglish SortGerman Sortieren
selectAllEnglish Select all tasksGerman Alle Aufgaben auswählen
selectEnglish Select {task}German {task} auswählen
selectedEnglish {n} selectedGerman {n} ausgewählt
markDoneEnglish Mark doneGerman Erledigt
setPriorityEnglish Set priorityGerman Priorität setzen
removeEnglish DeleteGerman Löschen
removedEnglish {n} tasks deleted.German {n} Aufgaben gelöscht.
nothingEnglish No task matches. Try another filter.German Keine Aufgabe passt. Probiere einen anderen Filter.
clearEnglish Show allGerman Alle zeigen
rowsEnglish {n} tasksGerman {n} Aufgaben
doFirstEnglish Do firstGerman Sofort erledigen
doFirstHintEnglish Urgent and importantGerman Dringend und wichtig
planEnglish ScheduleGerman Einplanen
planHintEnglish Important, not urgentGerman Wichtig, nicht dringend
delegateEnglish DelegateGerman Abgeben
delegateHintEnglish Urgent, not importantGerman Dringend, nicht wichtig
dropEnglish Let goGerman Loslassen
dropHintEnglish Neither, revisit or dropGerman Weder noch, später prüfen
isUrgentEnglish UrgentGerman Dringend
notUrgentEnglish Not urgentGerman Nicht dringend
isImportantEnglish ImportantGerman Wichtig
notImportantEnglish Not importantGerman Nicht wichtig
moveToEnglish Move toGerman Verschieben nach
taskMenuEnglish Actions for {task}German Aktionen für {task}
matrixHintEnglish Press Space to pick up a task. The arrow keys move it between the four fields, Space drops it, Escape puts it back.German Die Leertaste nimmt eine Aufgabe auf. Die Pfeiltasten bewegen sie zwischen den vier Feldern, die Leertaste legt sie ab, Escape legt sie zurück.
grabbedEnglish {task} picked up in {quad}.German {task} in {quad} aufgenommen.
overEnglish {quad}.German {quad}.
droppedEnglish {task} moved to {quad}.German {task} nach {quad} verschoben.
cancelledEnglish {task} is back in {quad}.German {task} liegt wieder in {quad}.
draggableEnglish movable taskGerman verschiebbare Aufgabe
addToEnglish Add to {quad}German Zu {quad} hinzufügen
myDayEnglish My DayGerman Mein Tag
progressDayEnglish {done} of {n} doneGerman {done} von {n} erledigt
leftEnglish {t} leftGerman noch {t}
upNextEnglish Up nextGerman Als Nächstes
finishEnglish Mark as doneGerman Als erledigt markieren
later2English Later todayGerman Später heute
reorderUpEnglish Move {task} upGerman {task} nach oben
reorderDownEnglish Move {task} downGerman {task} nach unten
removeDayEnglish Take {task} off My DayGerman {task} von Mein Tag nehmen
suggestionsEnglish SuggestionsGerman Vorschläge
suggestTextEnglish Due soon or overdue and not planned yet.German Bald fällig oder überfällig und noch nicht eingeplant.
addDayEnglish Add {task} to My DayGerman {task} zu Mein Tag hinzufügen
allDoneEnglish All done for todayGerman Für heute alles erledigt
allDoneTextEnglish Every task on your list is done. Pick one from the suggestions or call it a day.German Jede Aufgabe auf deiner Liste ist erledigt. Nimm dir einen Vorschlag oder mach Feierabend.
movedUpEnglish {task} moved to position {pos}.German {task} steht jetzt an Position {pos}.
hEnglish {h} hGerman {h} Std.
hmEnglish {h} h {m} minGerman {h} Std. {m} Min.
mEnglish {m} minGerman {m} Min.
detailsEnglish DetailsGerman Details
descriptionEnglish DescriptionGerman Beschreibung
descPlaceholderEnglish What does done look like?German Woran erkennt man, dass es fertig ist?
estimateEnglish EstimateGerman Schätzung
commentsEnglish CommentsGerman Kommentare
commentPlaceholderEnglish Write a commentGerman Kommentar schreiben
postEnglish Post commentGerman Kommentar senden
subDoneEnglish {done} of {n} subtasks doneGerman {done} von {n} Unteraufgaben erledigt
removeSubEnglish Remove {task}German {task} entfernen
titleLabelEnglish Task titleGerman Titel der Aufgabe
justEnglish just nowGerman gerade eben
savedEnglish SavedGerman Gespeichert

Accessibility

Read from the source of the component.

  • Task list handles the keys Escape, Enter, Arrow up, Arrow down, Arrow left, Arrow right, Tab, Delete and Space itself.
  • Sets aria-checked, aria-controls, aria-describedby, aria-expanded, aria-label, aria-labelledby, aria-live, aria-pressed, aria-roledescription and aria-sort, so screen readers announce purpose and state.
  • Uses the roles button, checkbox, group, radio, radiogroup and status.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,827 lines
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>
  );
}

Frequent questions

How do I install Task list?

With aiw add @ai-website/task-list. The command puts components/ui/task-list.tsx into your project, the code is yours from then on. It brings along avatar, button, dropdown-menu, icon, radial-progress and sidebar. It needs no further npm packages.

Which variants does Task list have?

5 variants, Grouped by due date, Table, Priority matrix, My Day and Task page. You pick one with the variant prop, for example variant="table".

Can I translate the texts of Task list?

Yes. Task list carries 112 texts of its own in English and German, such as “Search tasks”, “Open” and “Done”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Task list?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Task list with aiw_components and install it with aiw_add @ai-website/task-list. Name the variant in your prompt, for example variant="grouped", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard