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>