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

+111 -101
View File
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import {
@@ -17,8 +18,10 @@ import {
import { useAuth } from "@/lib/auth/auth-context";
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
import { DashboardAlert, DashboardAlertType } from "@/lib/dashboard/types";
import { Card, CardContent } from "@/components/ui/card";
import { fetchTasks } from "@/lib/tasks/api";
import { tasksForToday } from "@/lib/tasks/today";
import { HoneycombPattern } from "@/components/honeycomb-pattern";
import { TodayPanel } from "@/components/today/today-panel";
function alertHref(alert: DashboardAlert): string | null {
if (alert.relatedType === "Colony") return `/colonies/${alert.relatedId}`;
@@ -27,19 +30,25 @@ function alertHref(alert: DashboardAlert): string | null {
return null;
}
function greeting(): string {
const hour = new Date().getHours();
function greeting(now: Date): string {
const hour = now.getHours();
if (hour < 11) return "Guten Morgen";
if (hour < 18) return "Guten Tag";
return "Guten Abend";
}
function todaySummary(count: number): string {
if (count === 0) return "Für heute steht nichts Festes an.";
if (count === 1) return "Heute steht eine Sache an.";
return `Heute stehen ${count} Sachen an – eine nach der anderen.`;
}
interface StatTile {
label: string;
value: number;
icon: LucideIcon;
gradient: string;
href: string;
attention?: boolean;
}
const ALERT_META: Record<
@@ -75,7 +84,8 @@ const ALERT_META: Record<
export default function DashboardPage() {
const { user } = useAuth();
const { data: summary, isLoading: summaryLoading } = useQuery({
const [now] = useState(() => new Date());
const { data: summary } = useQuery({
queryKey: ["dashboard", "summary"],
queryFn: fetchDashboardSummary,
});
@@ -83,133 +93,133 @@ export default function DashboardPage() {
queryKey: ["dashboard", "alerts"],
queryFn: fetchDashboardAlerts,
});
const tasksQuery = useQuery({
queryKey: ["tasks", "dashboard"],
queryFn: () => fetchTasks({}),
});
// Overdue tasks already appear in "Heute"; listing them again as alerts
// would announce the same thing twice.
const otherAlerts = (alerts ?? []).filter((alert) => alert.type !== "OVERDUE_TASK");
const todayCount = tasksQuery.data ? tasksForToday(tasksQuery.data, now).length : null;
const firstName = user?.name.trim().split(/\s+/)[0];
const tiles: StatTile[] = summary
? [
{
label: "Aktive Bienenstände",
value: summary.apiariesActive,
icon: MapPin,
gradient: "var(--gradient-honey)",
href: "/apiaries",
},
{
label: "Aktive Völker",
value: summary.coloniesActive,
icon: Layers,
gradient: "var(--gradient-honey)",
href: "/colonies",
},
{
label: "Aktive Königinnen",
value: summary.queensActive,
icon: Crown,
gradient: "var(--gradient-sage)",
href: "/queens",
},
{
label: "Offene Aufgaben",
value: summary.openTasks,
icon: ListTodo,
gradient: "var(--gradient-sky)",
href: "/tasks",
},
{ label: "Bienenstände", value: summary.apiariesActive, icon: MapPin, href: "/apiaries" },
{ label: "Völker", value: summary.coloniesActive, icon: Layers, href: "/colonies" },
{ label: "Königinnen", value: summary.queensActive, icon: Crown, href: "/queens" },
{ label: "Offene Aufgaben", value: summary.openTasks, icon: ListTodo, href: "/tasks" },
{
label: "Honigchargen",
value: summary.honeyBatchesTotal,
icon: Droplet,
gradient: "var(--gradient-honey)",
href: "/honey-batches",
},
{
label: "Artikel unter Mindestbestand",
label: "unter Minimum",
value: summary.stockItemsLowCount,
icon: AlertTriangle,
gradient:
summary.stockItemsLowCount > 0
? "linear-gradient(135deg, oklch(0.72 0.15 70) 0%, oklch(0.56 0.15 50) 100%)"
: "var(--gradient-sage)",
href: "/storage",
attention: summary.stockItemsLowCount > 0,
},
]
: [];
return (
<main className="flex flex-col gap-6 p-4 sm:p-6">
<div
className="relative overflow-hidden rounded-2xl px-5 py-6 text-white shadow-[var(--shadow-elevated)] sm:px-8 sm:py-8"
<main className="mx-auto flex w-full max-w-3xl flex-col gap-8 p-4 sm:p-6">
<header
className="relative overflow-hidden rounded-2xl px-5 py-5 text-white shadow-[var(--shadow-elevated)] sm:px-7 sm:py-6"
style={{ background: "var(--gradient-honey)" }}
>
<HoneycombPattern className="pointer-events-none absolute inset-0 h-full w-full text-white/[0.08]" />
<div className="relative flex flex-col gap-1">
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
{greeting()}
{user ? `, ${user.email.split("@")[0]}` : ""}
{greeting(now)}
{firstName ? `, ${firstName}` : ""}
</h1>
<p className="text-sm text-white/85 sm:text-base">Überblick über deine Imkerei</p>
<p className="text-sm text-white/90 sm:text-base">
{now.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long" })}
{todayCount !== null ? ` · ${todaySummary(todayCount)}` : ""}
</p>
</div>
</div>
</header>
{summaryLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{tiles.map((tile) => {
const Icon = tile.icon;
return (
<Link key={tile.label} href={tile.href}>
<Card interactive>
<CardContent className="flex flex-col gap-3">
<div
className="flex size-10 items-center justify-center rounded-xl text-white shadow-[var(--shadow-soft)]"
style={{ background: tile.gradient }}
>
<Icon className="size-5" />
</div>
<div>
<p className="text-2xl font-semibold tabular-nums">{tile.value}</p>
<p className="text-xs text-muted-foreground">{tile.label}</p>
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
<TodayPanel
tasks={tasksQuery.data}
isLoading={tasksQuery.isLoading}
isError={tasksQuery.isError}
onRetry={() => void tasksQuery.refetch()}
now={now}
/>
<div>
<h2 className="mb-3 font-heading text-lg font-semibold tracking-tight">Hinweise</h2>
<div className="flex flex-col gap-2">
{alertsLoading || otherAlerts.length > 0 ? (
<section aria-labelledby="alerts-heading" className="flex flex-col gap-2">
<h2 id="alerts-heading" className="font-heading text-lg font-semibold tracking-tight">
Im Blick behalten
</h2>
{alertsLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{!alertsLoading && (!alerts || alerts.length === 0) ? (
<Card>
<CardContent className="text-sm text-muted-foreground">
Keine offenen Hinweise.
</CardContent>
</Card>
) : null}
{alerts?.map((alert) => {
const href = alertHref(alert);
const meta = ALERT_META[alert.type];
const AlertIcon = meta?.icon ?? AlertTriangle;
return (
<div
key={`${alert.type}-${alert.relatedId}`}
className={`flex items-center gap-3 rounded-xl border p-3 text-sm shadow-[var(--shadow-soft)] ${meta?.className ?? "border-border bg-card"}`}
>
<AlertIcon className="size-4.5 shrink-0 text-muted-foreground" />
<span className="flex-1">{alert.message}</span>
{href ? (
<ul className="flex flex-col gap-2">
{otherAlerts.map((alert) => {
const href = alertHref(alert);
const meta = ALERT_META[alert.type];
const AlertIcon = meta?.icon ?? AlertTriangle;
return (
<li
key={`${alert.type}-${alert.relatedId}`}
className={`flex items-center gap-3 rounded-xl border p-3 text-sm ${meta?.className ?? "border-border bg-card"}`}
>
<AlertIcon className="size-5 shrink-0 text-foreground/70" aria-hidden />
<span className="flex-1">
<span className="sr-only">{meta?.label}: </span>
{alert.message}
</span>
{href ? (
<Link
className="shrink-0 rounded-lg px-2 py-2 font-medium text-primary underline-offset-4 hover:underline"
href={href}
>
Ansehen
</Link>
) : null}
</li>
);
})}
</ul>
</section>
) : null}
{tiles.length > 0 ? (
<section aria-labelledby="overview-heading" className="flex flex-col gap-2">
<h2 id="overview-heading" className="font-heading text-lg font-semibold tracking-tight">
Überblick
</h2>
<ul className="grid grid-cols-2 gap-x-4 gap-y-1 rounded-2xl border border-border/60 bg-card p-2 sm:grid-cols-3">
{tiles.map((tile) => {
const Icon = tile.icon;
return (
<li key={tile.label}>
<Link
className="shrink-0 font-medium text-primary underline-offset-4 hover:underline"
href={href}
href={tile.href}
className="flex min-h-11 items-center gap-3 rounded-xl px-3 py-2 transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring"
>
Ansehen
<Icon
className={`size-5 shrink-0 ${tile.attention ? "text-warning" : "text-primary"}`}
aria-hidden
/>
<span className="font-heading text-lg font-semibold tabular-nums">
{tile.value}
</span>
<span className="min-w-0 truncate text-sm text-muted-foreground">
{tile.label}
</span>
</Link>
) : null}
</div>
);
})}
</div>
</div>
</li>
);
})}
</ul>
</section>
) : null}
</main>
);
}
@@ -0,0 +1,65 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { MOTION_LEVELS, MotionLevel, useMotionPreference } from "@/lib/motion/motion-preference";
import { cn } from "@/lib/utils";
const OPTIONS: Record<MotionLevel, { label: string; description: string }> = {
full: {
label: "Voll",
description: "Rückmeldung beim Abhaken und Speichern, weiche Übergänge.",
},
subtle: {
label: "Dezent",
description: "Nur kurzes Ein- und Ausblenden, nichts bewegt sich über den Bildschirm.",
},
off: {
label: "Aus",
description: "Alles ändert sich sofort. Zustände bleiben an Symbol und Text erkennbar.",
},
};
export function MotionSection() {
const [level, setLevel] = useMotionPreference();
return (
<Card>
<CardHeader>
<CardTitle>Bewegung &amp; Effekte</CardTitle>
<CardDescription>
Gilt für dieses Gerät. Ist am Gerät „Bewegung reduzieren“ eingeschaltet, wird „Voll“
automatisch zu „Dezent“.
</CardDescription>
</CardHeader>
<CardContent>
<fieldset className="flex flex-col gap-2">
<legend className="sr-only">Bewegung und Effekte</legend>
{MOTION_LEVELS.map((option) => (
<label
key={option}
className={cn(
"flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border p-3 transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring",
level === option
? "border-primary/60 bg-primary/8"
: "border-border hover:bg-muted",
)}
>
<input
type="radio"
name="motion-level"
value={option}
checked={level === option}
onChange={() => setLevel(option)}
className="mt-1 size-4 accent-[var(--primary)]"
/>
<span className="flex flex-col">
<span className="font-medium">{OPTIONS[option].label}</span>
<span className="text-sm text-muted-foreground">{OPTIONS[option].description}</span>
</span>
</label>
))}
</fieldset>
</CardContent>
</Card>
);
}
@@ -26,6 +26,7 @@ import {
} from "@/components/ui/card";
import { MfaSection } from "./mfa-section";
import { DangerZoneSection } from "./danger-zone-section";
import { MotionSection } from "./motion-section";
function splitName(fullName: string): { firstName: string; lastName: string } {
const [firstName, ...rest] = fullName.trim().split(/\s+/);
@@ -297,6 +298,7 @@ export default function ProfilePage() {
<ProfileInfoSection />
<PasswordSection />
<MfaSection mfaEnabled={user.mfaEnabled} onChanged={() => void refreshUser()} />
<MotionSection />
<DangerZoneSection />
</div>
</main>
+14 -10
View File
@@ -2,14 +2,14 @@
import { useState } from "react";
import Link from "next/link";
import { ListTodo } from "lucide-react";
import { Check, ListTodo } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { completeTask, fetchTasks } from "@/lib/tasks/api";
import { TaskPriority, TaskStatus } from "@/lib/tasks/types";
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS, TASK_STATUS_LABELS } from "@/lib/tasks/labels";
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
@@ -38,10 +38,12 @@ export default function TasksPage() {
const completeMutation = useMutation({
mutationFn: completeTask,
onSuccess: () => {
onSuccess: (task) => {
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
void queryClient.invalidateQueries({ queryKey: ["dashboard"] });
toast.success(`Erledigt: ${task.title}`);
},
onError: () => toast.error("Aufgabe konnte nicht erledigt werden"),
onError: () => toast.error("Konnte nicht als erledigt gespeichert werden. Bitte noch einmal tippen."),
});
const [now] = useState(() => Date.now());
@@ -136,9 +138,10 @@ export default function TasksPage() {
gradient="var(--gradient-sky)"
title={task.title}
badge={
<Badge variant={task.status === "DONE" ? "secondary" : "default"}>
{TASK_STATUS_LABELS[task.status]}
</Badge>
<span className="flex flex-wrap items-center gap-1.5">
<TaskStatusPill status={task.status} />
<TaskSourceBadge task={task} />
</span>
}
fields={[
{ label: "Priorität", value: TASK_PRIORITY_LABELS[task.priority] },
@@ -152,12 +155,13 @@ export default function TasksPage() {
<TaskEditDialog task={task} />
{task.status !== "DONE" && task.status !== "CANCELLED" ? (
<Button
size="sm"
size="touch"
variant="outline"
disabled={completeMutation.isPending}
onClick={() => completeMutation.mutate(task.id)}
>
Erledigen
<Check aria-hidden />
Erledigt
</Button>
) : null}
</>
+175
View File
@@ -198,4 +198,179 @@
font-family: var(--font-heading);
letter-spacing: -0.01em;
}
::selection {
background: color-mix(in oklch, var(--primary) 28%, transparent);
color: var(--foreground);
}
input,
textarea {
caret-color: var(--primary);
}
}
/* ---------------------------------------------------------------------------
Motion levels (see lib/motion/motion-preference.ts)
html[data-motion] = full | subtle | off, chosen per device in the profile.
The OS "reduce motion" setting always wins over "full".
`motion-full:` = spatial movement allowed (level full AND system allows).
------------------------------------------------------------------------ */
@custom-variant motion-full {
@media (prefers-reduced-motion: no-preference) {
&:where(:root:not([data-motion="subtle"]):not([data-motion="off"]) *) {
@slot;
}
}
}
:root {
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
}
/* Off: every state change is instant. Colour and icon still carry the state. */
:root[data-motion="off"] *,
:root[data-motion="off"] *::before,
:root[data-motion="off"] *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
transition-delay: 0ms !important;
}
/* Subtle / system reduce: keep fades, drop movement. */
:root[data-motion="subtle"] .motion-safe\:animate-page-in {
animation-name: fade-in;
}
@media (prefers-reduced-motion: reduce) {
.motion-safe\:animate-page-in {
animation: fade-in 160ms ease-out;
}
}
:root[data-motion="subtle"] [data-slot="card"] {
translate: none !important;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* --- Task completion: the one authored moment ------------------------------
The honey cell fills, the tick draws itself, then the row folds away. */
.honey-check {
position: relative;
display: inline-grid;
place-items: center;
flex-shrink: 0;
width: 2.75rem;
height: 2.75rem;
border-radius: 9999px;
color: var(--primary-foreground);
outline: none;
}
.honey-check::before {
content: "";
position: absolute;
inset: 0.375rem;
border-radius: 0.6rem;
border: 2px solid color-mix(in oklch, var(--primary) 65%, var(--border));
background: var(--card);
transition:
border-color 150ms ease-out,
background-color 150ms ease-out;
}
.honey-check::after {
content: "";
position: absolute;
inset: 0.375rem;
border-radius: 0.6rem;
background: var(--gradient-honey);
clip-path: circle(0% at 50% 60%);
transition: clip-path 420ms var(--ease-out-expo);
}
.honey-check:hover::before {
border-color: var(--primary);
background: color-mix(in oklch, var(--primary) 8%, var(--card));
}
.honey-check:focus-visible::before {
box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 45%, transparent);
}
.honey-check svg {
position: relative;
z-index: 1;
width: 1.1rem;
height: 1.1rem;
stroke-dasharray: 24;
stroke-dashoffset: 24;
transition: stroke-dashoffset 260ms var(--ease-out-expo) 160ms;
}
.honey-check[aria-checked="true"]::after {
clip-path: circle(75% at 50% 60%);
}
.honey-check[aria-checked="true"] svg {
stroke-dashoffset: 0;
}
.today-row {
display: grid;
grid-template-rows: 1fr;
opacity: 1;
transition:
grid-template-rows 320ms var(--ease-out-expo) 380ms,
opacity 220ms ease-out 360ms;
}
.today-row > * {
min-height: 0;
overflow: hidden;
}
.today-row[data-leaving="true"] {
grid-template-rows: 0fr;
opacity: 0;
}
/* The next step changes: bring the new one in with a short focus pull. */
@keyframes next-step-in {
from {
opacity: 0;
filter: blur(6px);
transform: translateY(8px);
}
to {
opacity: 1;
filter: blur(0);
transform: none;
}
}
@keyframes next-step-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.next-step-enter {
animation: next-step-in 480ms var(--ease-out-expo);
}
:root[data-motion="subtle"] .next-step-enter {
animation: next-step-fade 200ms ease-out;
}
:root[data-motion="subtle"] .today-row,
:root[data-motion="subtle"] .honey-check::after {
transition-duration: 120ms;
transition-delay: 0ms;
}
@media (prefers-reduced-motion: reduce) {
.next-step-enter {
animation: next-step-fade 200ms ease-out;
}
.today-row,
.honey-check::after,
.honey-check svg {
transition-duration: 120ms;
transition-delay: 0ms;
}
}
+6 -1
View File
@@ -1,12 +1,17 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { useEffect, useState } from "react";
import { AuthProvider } from "@/lib/auth/auth-context";
import { applyMotionLevel, readMotionLevel } from "@/lib/motion/motion-preference";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
useEffect(() => {
applyMotionLevel(readMotionLevel());
}, []);
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
@@ -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",
@@ -0,0 +1,68 @@
"use client";
import { useCallback, useState } from "react";
/**
* How much the interface moves. A per-device preference (localStorage), not
* an account setting: the same person may want calm motion on the phone at
* the apiary and full feedback on the desktop.
*
* - full: feedback, transitions and the task-completion moment.
* - subtle: no spatial movement, only short fades and colour changes.
* - off: state changes happen instantly.
*
* The operating system's "reduce motion" setting always wins over "full"
* (see globals.css), so this only ever lowers motion further.
*/
export type MotionLevel = "full" | "subtle" | "off";
export const MOTION_LEVELS: MotionLevel[] = ["full", "subtle", "off"];
export const DEFAULT_MOTION_LEVEL: MotionLevel = "full";
const STORAGE_KEY = "apicore.motion";
function isMotionLevel(value: unknown): value is MotionLevel {
return value === "full" || value === "subtle" || value === "off";
}
export function readMotionLevel(): MotionLevel {
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return isMotionLevel(stored) ? stored : DEFAULT_MOTION_LEVEL;
} catch {
// Private mode / blocked storage: fall back to the default.
return DEFAULT_MOTION_LEVEL;
}
}
export function applyMotionLevel(level: MotionLevel): void {
document.documentElement.dataset.motion = level;
}
/** Effective level, including the operating system's reduce-motion setting. */
export function effectiveMotionLevel(level: MotionLevel = readMotionLevel()): MotionLevel {
const systemReduces =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
return systemReduces && level === "full" ? "subtle" : level;
}
export function useMotionPreference(): [MotionLevel, (level: MotionLevel) => void] {
// Lazy read: the app shell renders only on the client after login, and the
// document attribute itself is applied once in Providers.
const [level, setLevelState] = useState<MotionLevel>(() =>
typeof window === "undefined" ? DEFAULT_MOTION_LEVEL : readMotionLevel(),
);
const setLevel = useCallback((next: MotionLevel) => {
setLevelState(next);
applyMotionLevel(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Not persisted - still applies for this visit.
}
}, []);
return [level, setLevel];
}
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { Task } from "./types";
import { dueState, pickNextStep, relatedHref, tasksForToday } from "./today";
const NOW = new Date(2026, 8, 27, 9, 30); // 27.09.2026, 09:30 local
function task(overrides: Partial<Task>): Task {
return {
id: overrides.id ?? "t",
title: "Aufgabe",
description: null,
priority: "NORMAL",
dueDate: null,
status: "OPEN",
assignedUserId: null,
createdByUserId: null,
relatedType: "GENERAL",
relatedId: null,
sourceInspectionId: null,
sourceTemplateId: null,
sourceAutomationTrigger: null,
createdAt: "2026-09-01T00:00:00.000Z",
completedAt: null,
...overrides,
};
}
describe("dueState", () => {
it("treats a task due on today's calendar day as today, not overdue", () => {
expect(dueState({ dueDate: "2026-09-27T00:00:00.000Z" }, NOW)).toBe("today");
});
it("marks earlier days as overdue and later days as upcoming", () => {
expect(dueState({ dueDate: "2026-09-26T00:00:00.000Z" }, NOW)).toBe("overdue");
expect(dueState({ dueDate: "2026-09-28T00:00:00.000Z" }, NOW)).toBe("upcoming");
expect(dueState({ dueDate: null }, NOW)).toBe("none");
});
});
describe("tasksForToday", () => {
it("lists started, overdue and due-today tasks - started first, then overdue", () => {
const tasks = [
task({ id: "today", dueDate: "2026-09-27T00:00:00.000Z" }),
task({ id: "overdue", dueDate: "2026-09-20T00:00:00.000Z" }),
task({ id: "started", status: "IN_PROGRESS" }),
task({ id: "later", dueDate: "2026-10-01T00:00:00.000Z" }),
task({ id: "done", status: "DONE", dueDate: "2026-09-27T00:00:00.000Z" }),
];
expect(tasksForToday(tasks, NOW).map((t) => t.id)).toEqual(["started", "overdue", "today"]);
});
it("includes an undated automatic suggestion but not an undated planned task", () => {
const tasks = [
task({ id: "suggestion", sourceAutomationTrigger: "QUEENLESS_NO_EGGS", priority: "HIGH" }),
task({ id: "someday" }),
];
expect(tasksForToday(tasks, NOW).map((t) => t.id)).toEqual(["suggestion"]);
});
it("orders equally due tasks by priority", () => {
const tasks = [
task({ id: "normal", dueDate: "2026-09-27T00:00:00.000Z" }),
task({ id: "urgent", priority: "URGENT", dueDate: "2026-09-27T00:00:00.000Z" }),
];
expect(tasksForToday(tasks, NOW).map((t) => t.id)).toEqual(["urgent", "normal"]);
});
});
describe("pickNextStep", () => {
it("falls back to an undated high-priority task when nothing is due today", () => {
const tasks = [
task({ id: "later", dueDate: "2026-10-05T00:00:00.000Z" }),
task({ id: "undated-high", priority: "HIGH" }),
];
expect(pickNextStep(tasks, NOW)?.id).toBe("undated-high");
});
it("returns null when nothing is open", () => {
expect(pickNextStep([task({ status: "DONE" })], NOW)).toBeNull();
});
});
describe("relatedHref", () => {
it("links a colony task to the colony", () => {
expect(relatedHref({ relatedType: "COLONY", relatedId: "c1" })).toBe("/colonies/c1");
});
it("has no link for general tasks", () => {
expect(relatedHref({ relatedType: "GENERAL", relatedId: null })).toBeNull();
});
});
+103
View File
@@ -0,0 +1,103 @@
import { Task, TaskPriority, TaskRelatedType } from "./types";
export type DueState = "overdue" | "today" | "upcoming" | "none";
const ACTIVE_STATUSES = new Set<Task["status"]>(["OPEN", "IN_PROGRESS"]);
const PRIORITY_RANK: Record<TaskPriority, number> = {
URGENT: 0,
HIGH: 1,
NORMAL: 2,
LOW: 3,
};
function startOfLocalDay(date: Date): number {
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
}
/**
* Due dates are stored as calendar days (midnight UTC). Compared by local
* calendar day, so a task due "today" does not turn overdue at 01:00.
*/
export function dueState(task: Pick<Task, "dueDate">, now: Date): DueState {
if (!task.dueDate) return "none";
const due = new Date(task.dueDate);
const dueDay = new Date(due.getUTCFullYear(), due.getUTCMonth(), due.getUTCDate()).getTime();
const today = startOfLocalDay(now);
if (dueDay < today) return "overdue";
if (dueDay === today) return "today";
return "upcoming";
}
export function isActiveTask(task: Pick<Task, "status">): boolean {
return ACTIVE_STATUSES.has(task.status);
}
function byUrgency(now: Date) {
const rank = (task: Task): number[] => {
const state = dueState(task, now);
return [
// Something already started comes first: picking it up again is the
// cheapest way back into the work after an interruption.
task.status === "IN_PROGRESS" ? 0 : 1,
state === "overdue" ? 0 : state === "today" ? 1 : state === "none" ? 2 : 3,
PRIORITY_RANK[task.priority],
task.dueDate ? new Date(task.dueDate).getTime() : Number.MAX_SAFE_INTEGER,
new Date(task.createdAt).getTime(),
];
};
return (a: Task, b: Task): number => {
const ra = rank(a);
const rb = rank(b);
for (let i = 0; i < ra.length; i++) {
if (ra[i] !== rb[i]) return ra[i] - rb[i];
}
return 0;
};
}
/**
* Active tasks due today or earlier, anything already started, and automatic
* suggestions without a date (a queenless colony, a low stock item): those
* ask for a look now and would otherwise sit unseen in the full list.
*/
export function tasksForToday(tasks: Task[], now: Date): Task[] {
return tasks
.filter(isActiveTask)
.filter((task) => {
const state = dueState(task, now);
return (
task.status === "IN_PROGRESS" ||
state === "overdue" ||
state === "today" ||
(state === "none" && task.sourceAutomationTrigger !== null)
);
})
.sort(byUrgency(now));
}
/**
* The one task the start page puts forward. Falls back from today's list to
* urgent/high tasks without a date and then to the next upcoming one, so there
* is a concrete suggestion whenever anything is open at all.
*/
export function pickNextStep(tasks: Task[], now: Date): Task | null {
const today = tasksForToday(tasks, now);
if (today.length > 0) return today[0];
const rest = tasks.filter(isActiveTask).sort(byUrgency(now));
return rest[0] ?? null;
}
const RELATED_PATHS: Partial<Record<TaskRelatedType, string>> = {
COLONY: "/colonies",
APIARY: "/apiaries",
QUEEN: "/queens",
HONEY_BATCH: "/honey-batches",
};
/** Where the object a task is about lives in the app, if anywhere. */
export function relatedHref(task: Pick<Task, "relatedType" | "relatedId">): string | null {
if (task.relatedType === "STORAGE") return "/storage";
const base = RELATED_PATHS[task.relatedType];
return base && task.relatedId ? `${base}/${task.relatedId}` : null;
}
+5
View File
@@ -20,6 +20,11 @@ export interface Task {
relatedType: TaskRelatedType;
relatedId: string | null;
sourceInspectionId: string | null;
// Set when a recurring template created the task.
sourceTemplateId: string | null;
// Set when an automation rule suggested the task (not a person) - e.g.
// "STOCK_ITEM_LOW". Shown as a suggestion, never as work already done.
sourceAutomationTrigger: string | null;
createdAt: string;
completedAt: string | null;
}