feat: "Heute" start page with one next step, clear task states and motion levels

Start page (AuDHD-oriented, per the agreed "Heute zuerst" layout):
- One highlighted next step: started work first (easy re-entry after an
  interruption), then overdue, due today, then urgent undated tasks.
  Primary action "Erledigt", secondary "Beginnen", link to the related
  colony/apiary/queen.
- "Außerdem heute": at most five further tasks with a large honey-cell
  checkbox; undated automatic suggestions are included so a queenless
  colony does not sit unseen in the full list.
- Completing is the one authored motion moment: the cell fills, the tick
  draws, the row folds away and the next step arrives with a short focus
  pull. Every completion can be undone from the toast (10 s), restoring
  the previous status.
- Status is shown as icon plus word (never colour alone); tasks created by
  automation carry a "Vorschlag" badge, recurring and inspection tasks
  their own - a suggestion never looks like work already done.
- Friendly empty, loading and error states; key figures moved into a
  compact overview below; overdue tasks are no longer listed twice.

Motion: per-device setting Voll / Dezent / Aus in the profile (default
Voll); the OS reduce-motion setting always lowers Voll to Dezent.
New 44 px "touch" button size for gloves at the apiary; themed text
selection and caret.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-27 13:00:44 +02:00
1 parent 5c2ea7bf1e
commit f9374a135a
14 files changed
+1104 -112

No files matched your search

@@ -0,0 +1,92 @@
import { CheckCircle2, Circle, ClipboardList, PlayCircle, Repeat, Sparkles, XCircle, type LucideIcon } from "lucide-react";
import { Task, TaskStatus } from "@/lib/tasks/types";
import { TASK_STATUS_LABELS } from "@/lib/tasks/labels";
import { DueState } from "@/lib/tasks/today";
import { cn } from "@/lib/utils";
const STATUS_META: Record<TaskStatus, { icon: LucideIcon; className: string }> = {
OPEN: { icon: Circle, className: "bg-muted text-foreground/80" },
IN_PROGRESS: {
icon: PlayCircle,
className: "bg-[color-mix(in_oklch,var(--chart-5)_16%,var(--card))] text-foreground",
},
DONE: { icon: CheckCircle2, className: "bg-success/15 text-foreground" },
CANCELLED: { icon: XCircle, className: "bg-muted text-muted-foreground line-through" },
};
/** Status as icon AND word - never colour alone. */
export function TaskStatusPill({ status, className }: { status: TaskStatus; className?: string }) {
const meta = STATUS_META[status];
const Icon = meta.icon;
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
meta.className,
className,
)}
>
<Icon className="size-3.5" aria-hidden />
{TASK_STATUS_LABELS[status]}
</span>
);
}
/**
* Where a task came from. An automatic task is a suggestion to look at
* something - it says nothing about work having been done.
*/
export function TaskSourceBadge({
task,
}: {
task: Pick<Task, "sourceAutomationTrigger" | "sourceTemplateId" | "sourceInspectionId">;
}) {
const source = task.sourceAutomationTrigger
? { icon: Sparkles, label: "Vorschlag", title: "Automatisch vorgeschlagen – noch nichts erledigt" }
: task.sourceTemplateId
? { icon: Repeat, label: "Wiederkehrend", title: "Aus einer wiederkehrenden Vorlage" }
: task.sourceInspectionId
? { icon: ClipboardList, label: "Aus Durchsicht", title: "Bei einer Durchsicht angelegt" }
: null;
if (!source) return null;
const Icon = source.icon;
return (
<span
title={source.title}
className="inline-flex items-center gap-1 rounded-full border border-dashed border-primary/45 px-2 py-0.5 text-xs text-foreground/80"
>
<Icon className="size-3.5 text-primary" aria-hidden />
{source.label}
</span>
);
}
const DUE_LABEL: Record<Exclude<DueState, "none">, string> = {
overdue: "Überfällig",
today: "Heute",
upcoming: "Geplant",
};
export function DueChip({ state, dueDate }: { state: DueState; dueDate: string | null }) {
if (state === "none" || !dueDate) {
return <span className="text-xs text-muted-foreground">ohne Termin</span>;
}
const date = new Date(dueDate).toLocaleDateString("de-DE", {
day: "numeric",
month: "short",
timeZone: "UTC",
});
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
state === "overdue" && "bg-warning/20 text-foreground",
state === "today" && "bg-primary/12 text-foreground",
state === "upcoming" && "bg-muted text-muted-foreground",
)}
>
{DUE_LABEL[state]}
{state !== "today" ? <span className="font-normal">· {date}</span> : null}
</span>
);
}
@@ -0,0 +1,275 @@
"use client";
import Link from "next/link";
import { ArrowRight, Check, ClipboardPlus, ListPlus, Play, Sun } from "lucide-react";
import { Task, TaskRelatedType } from "@/lib/tasks/types";
import { dueState, pickNextStep, relatedHref, tasksForToday } from "@/lib/tasks/today";
import { Button } from "@/components/ui/button";
import { DueChip, TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
import { useTaskActions } from "./use-task-actions";
const TODAY_LIST_LIMIT = 5;
const OPEN_RELATED_LABEL: Record<TaskRelatedType, string> = {
COLONY: "Zum Volk",
APIARY: "Zum Bienenstand",
QUEEN: "Zur Königin",
HONEY_BATCH: "Zur Honigcharge",
STORAGE: "Zum Lager",
GENERAL: "Öffnen",
};
interface TodayPanelProps {
tasks: Task[] | undefined;
isLoading: boolean;
isError: boolean;
onRetry: () => void;
now: Date;
}
export function TodayPanel({ tasks, isLoading, isError, onRetry, now }: TodayPanelProps) {
const actions = useTaskActions();
if (isLoading) {
return (
<div aria-busy="true" aria-label="Aufgaben werden geladen" className="flex flex-col gap-3">
<div className="h-40 animate-pulse rounded-2xl bg-muted/70" />
<div className="h-14 animate-pulse rounded-xl bg-muted/50" />
<div className="h-14 animate-pulse rounded-xl bg-muted/40" />
</div>
);
}
if (isError || !tasks) {
return (
<div
role="alert"
className="flex flex-col items-start gap-3 rounded-2xl border border-destructive/30 bg-destructive/5 p-4"
>
<p className="text-sm">
Die Aufgaben konnten gerade nicht geladen werden. Deine Daten sind nicht betroffen.
</p>
<Button size="touch" variant="outline" onClick={onRetry}>
Erneut laden
</Button>
</div>
);
}
const nextStep = pickNextStep(tasks, now);
if (!nextStep) {
return <NothingDue />;
}
const today = tasksForToday(tasks, now).filter((task) => task.id !== nextStep.id);
const visible = today.slice(0, TODAY_LIST_LIMIT);
const hidden = today.length - visible.length;
return (
<div className="flex flex-col gap-6">
<NextStepCard
task={nextStep}
now={now}
leaving={actions.leavingIds.has(nextStep.id)}
onComplete={() => actions.complete(nextStep)}
onStart={() => actions.start(nextStep)}
isStarting={actions.isStarting}
/>
<section aria-labelledby="today-heading" className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-3">
<h2 id="today-heading" className="font-heading text-lg font-semibold tracking-tight">
Außerdem heute
</h2>
<Link
href="/tasks"
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
>
Alle Aufgaben
</Link>
</div>
{visible.length === 0 ? (
<p className="rounded-xl bg-card/60 px-4 py-3 text-sm text-muted-foreground">
Sonst ist für heute nichts fällig. Ein Schritt reicht.
</p>
) : (
<ul className="flex flex-col gap-2">
{visible.map((task) => (
<TodayRow
key={task.id}
task={task}
now={now}
leaving={actions.leavingIds.has(task.id)}
onComplete={() => actions.complete(task)}
/>
))}
</ul>
)}
{hidden > 0 ? (
<Link
href="/tasks"
className="self-start text-sm text-muted-foreground underline-offset-4 hover:underline"
>
+ {hidden} weitere für heute
</Link>
) : null}
</section>
</div>
);
}
interface NextStepCardProps {
task: Task;
now: Date;
leaving: boolean;
onComplete: () => void;
onStart: () => void;
isStarting: boolean;
}
function NextStepCard({ task, now, leaving, onComplete, onStart, isStarting }: NextStepCardProps) {
const href = relatedHref(task);
return (
<section
aria-labelledby="next-step-heading"
className="relative overflow-hidden rounded-2xl border border-primary/25 bg-card p-5 shadow-[var(--shadow-elevated)] sm:p-6"
>
<h2
id="next-step-heading"
className="font-heading text-base font-semibold text-muted-foreground"
>
Als Nächstes
</h2>
{/* Keyed by task: a new next step arrives with its own entrance. */}
<div key={task.id} className="next-step-enter mt-2 flex flex-col gap-4" aria-live="polite">
<div className="flex flex-col gap-2">
<p className="font-heading text-2xl font-semibold leading-tight tracking-tight text-balance sm:text-3xl">
{task.title}
</p>
<div className="flex flex-wrap items-center gap-2">
{task.status === "IN_PROGRESS" ? <TaskStatusPill status={task.status} /> : null}
<DueChip state={dueState(task, now)} dueDate={task.dueDate} />
<TaskSourceBadge task={task} />
</div>
{task.description ? (
<p className="max-w-prose text-sm text-muted-foreground">{task.description}</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button size="touch" onClick={onComplete} disabled={leaving}>
<Check aria-hidden />
{leaving ? "Wird gespeichert…" : "Erledigt"}
</Button>
{task.status === "OPEN" ? (
<Button
size="touch"
variant="outline"
onClick={onStart}
disabled={isStarting || leaving}
>
<Play aria-hidden />
Beginnen
</Button>
) : null}
{href ? (
<Button size="touch" variant="ghost" nativeButton={false} render={<Link href={href} />}>
{OPEN_RELATED_LABEL[task.relatedType]}
<ArrowRight aria-hidden />
</Button>
) : null}
</div>
</div>
</section>
);
}
interface TodayRowProps {
task: Task;
now: Date;
leaving: boolean;
onComplete: () => void;
}
function TodayRow({ task, now, leaving, onComplete }: TodayRowProps) {
const href = relatedHref(task);
return (
<li className="today-row" data-leaving={leaving}>
<div>
<div className="flex items-center gap-2 rounded-xl border border-border/60 bg-card py-1.5 pr-2 pl-1 shadow-[var(--shadow-soft)]">
<button
type="button"
role="checkbox"
aria-checked={leaving}
aria-label={`„${task.title}“ als erledigt markieren`}
className="honey-check"
disabled={leaving}
onClick={onComplete}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
</button>
<div className="flex min-w-0 flex-1 flex-col gap-1 py-1">
<span className="line-clamp-2 font-medium">{task.title}</span>
<span className="flex flex-wrap items-center gap-1.5">
{task.status === "IN_PROGRESS" ? <TaskStatusPill status={task.status} /> : null}
<DueChip state={dueState(task, now)} dueDate={task.dueDate} />
<TaskSourceBadge task={task} />
</span>
</div>
{href ? (
<Link
href={href}
aria-label={OPEN_RELATED_LABEL[task.relatedType]}
className="grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
>
<ArrowRight className="size-5" aria-hidden />
</Link>
) : null}
</div>
</div>
</li>
);
}
function NothingDue() {
return (
<section className="flex flex-col items-start gap-4 rounded-2xl border border-border/60 bg-card p-5 shadow-[var(--shadow-soft)] sm:p-6">
<span className="grid size-11 place-items-center rounded-full bg-primary/12 text-primary">
<Sun className="size-6" aria-hidden />
</span>
<div className="flex flex-col gap-1">
<h2 className="font-heading text-xl font-semibold tracking-tight">
Heute ist nichts fällig.
</h2>
<p className="max-w-prose text-sm text-muted-foreground">
Keine offenen Aufgaben. Wenn du am Stand bist, halte fest, was du siehst – Folgeaufgaben
schlägt ApiCore dann selbst vor.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button size="touch" nativeButton={false} render={<Link href="/inspections/new" />}>
<ClipboardPlus aria-hidden />
Durchsicht erfassen
</Button>
<Button
size="touch"
variant="outline"
nativeButton={false}
render={<Link href="/tasks/new" />}
>
<ListPlus aria-hidden />
Aufgabe planen
</Button>
</div>
</section>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { completeTask, updateTask } from "@/lib/tasks/api";
import { Task } from "@/lib/tasks/types";
import { effectiveMotionLevel } from "@/lib/motion/motion-preference";
// How long the honey cell fill + row fold take per motion level (globals.css).
const EXIT_DELAY_MS = { full: 720, subtle: 200, off: 0 } as const;
const UNDO_WINDOW_MS = 10_000;
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Complete / start / undo for tasks on the start page. Completing shows the
* tick immediately, lets the row fold away and only then refreshes the list,
* so the finished task visibly leaves instead of blinking out. Every
* completion can be taken back from the toast.
*/
export function useTaskActions() {
const queryClient = useQueryClient();
const [leavingIds, setLeavingIds] = useState<ReadonlySet<string>>(new Set());
const refresh = () =>
Promise.all([
queryClient.invalidateQueries({ queryKey: ["tasks"] }),
queryClient.invalidateQueries({ queryKey: ["dashboard"] }),
]);
const setLeaving = (id: string, leaving: boolean) =>
setLeavingIds((current) => {
const next = new Set(current);
if (leaving) next.add(id);
else next.delete(id);
return next;
});
const undo = useMutation({
mutationFn: (task: Task) => updateTask(task.id, { status: task.status }),
onSuccess: async (_result, task) => {
await refresh();
setLeaving(task.id, false);
toast.success("Wieder offen");
},
onError: () =>
toast.error("Rückgängig hat nicht geklappt – die Aufgabe steht unter Aufgaben › Historie."),
});
const complete = useMutation({
mutationFn: async (task: Task) => {
const [done] = await Promise.all([
completeTask(task.id),
wait(EXIT_DELAY_MS[effectiveMotionLevel()]),
]);
return done;
},
onMutate: (task) => setLeaving(task.id, true),
onSuccess: async (_done, task) => {
await refresh();
toast.success(`Erledigt: ${task.title}`, {
// Long enough to notice a slip and take it back without hurrying.
duration: UNDO_WINDOW_MS,
action: { label: "Rückgängig", onClick: () => undo.mutate(task) },
});
},
onError: (_error, task) => {
setLeaving(task.id, false);
toast.error("Konnte nicht als erledigt gespeichert werden. Bitte noch einmal tippen.");
},
});
const start = useMutation({
mutationFn: (task: Task) => updateTask(task.id, { status: "IN_PROGRESS" }),
onSuccess: async () => {
await refresh();
toast.success("Begonnen – du findest sie ganz oben wieder.");
},
onError: () => toast.error("Konnte nicht gestartet werden. Bitte noch einmal versuchen."),
});
return {
leavingIds,
complete: (task: Task) => complete.mutate(task),
start: (task: Task) => start.mutate(task),
isStarting: start.isPending,
};
}
@@ -26,6 +26,8 @@ const buttonVariants = cva(
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
// 44 px: reliable with gloves or sticky fingers at the apiary.
touch: "h-11 gap-2 px-4 text-[0.95rem] [&_svg:not([class*='size-'])]:size-5",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",