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:
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",
|
||||
|
||||
Reference in new issue
Block a user