diff --git a/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx b/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx index 7d74e3d..ce5dcb6 100644 --- a/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx @@ -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 ( -
-
+

- {greeting()} - {user ? `, ${user.email.split("@")[0]}` : ""} + {greeting(now)} + {firstName ? `, ${firstName}` : ""}

-

Überblick über deine Imkerei

+

+ {now.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long" })} + {todayCount !== null ? ` · ${todaySummary(todayCount)}` : ""} +

-
+ - {summaryLoading ?

Lädt…

: null} -
- {tiles.map((tile) => { - const Icon = tile.icon; - return ( - - - -
- -
-
-

{tile.value}

-

{tile.label}

-
-
-
- - ); - })} -
+ void tasksQuery.refetch()} + now={now} + /> -
-

Hinweise

-
+ {alertsLoading || otherAlerts.length > 0 ? ( +
+

+ Im Blick behalten +

{alertsLoading ?

Lädt…

: null} - {!alertsLoading && (!alerts || alerts.length === 0) ? ( - - - Keine offenen Hinweise. - - - ) : null} - {alerts?.map((alert) => { - const href = alertHref(alert); - const meta = ALERT_META[alert.type]; - const AlertIcon = meta?.icon ?? AlertTriangle; - return ( -
- - {alert.message} - {href ? ( +
    + {otherAlerts.map((alert) => { + const href = alertHref(alert); + const meta = ALERT_META[alert.type]; + const AlertIcon = meta?.icon ?? AlertTriangle; + return ( +
  • + + + {meta?.label}: + {alert.message} + + {href ? ( + + Ansehen + + ) : null} +
  • + ); + })} +
+
+ ) : null} + + {tiles.length > 0 ? ( +
+

+ Überblick +

+
    + {tiles.map((tile) => { + const Icon = tile.icon; + return ( +
  • - Ansehen + + + {tile.value} + + + {tile.label} + - ) : null} -
- ); - })} -
- + + ); + })} + + + ) : null}
); } diff --git a/ApiCore-Frontend/src/app/(app)/profile/motion-section.tsx b/ApiCore-Frontend/src/app/(app)/profile/motion-section.tsx new file mode 100644 index 0000000..f4c6732 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/profile/motion-section.tsx @@ -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 = { + 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 ( + + + Bewegung & Effekte + + Gilt für dieses Gerät. Ist am Gerät „Bewegung reduzieren“ eingeschaltet, wird „Voll“ + automatisch zu „Dezent“. + + + +
+ Bewegung und Effekte + {MOTION_LEVELS.map((option) => ( + + ))} +
+
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/profile/page.tsx b/ApiCore-Frontend/src/app/(app)/profile/page.tsx index 2c918c1..d33835d 100644 --- a/ApiCore-Frontend/src/app/(app)/profile/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/profile/page.tsx @@ -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() { void refreshUser()} /> + diff --git a/ApiCore-Frontend/src/app/(app)/tasks/page.tsx b/ApiCore-Frontend/src/app/(app)/tasks/page.tsx index 16b335e..f8d0887 100644 --- a/ApiCore-Frontend/src/app/(app)/tasks/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/tasks/page.tsx @@ -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={ - - {TASK_STATUS_LABELS[task.status]} - + + + + } fields={[ { label: "Priorität", value: TASK_PRIORITY_LABELS[task.priority] }, @@ -152,12 +155,13 @@ export default function TasksPage() { {task.status !== "DONE" && task.status !== "CANCELLED" ? ( ) : null} diff --git a/ApiCore-Frontend/src/app/globals.css b/ApiCore-Frontend/src/app/globals.css index 1e6fa62..464c28c 100644 --- a/ApiCore-Frontend/src/app/globals.css +++ b/ApiCore-Frontend/src/app/globals.css @@ -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; + } } \ No newline at end of file diff --git a/ApiCore-Frontend/src/app/providers.tsx b/ApiCore-Frontend/src/app/providers.tsx index 1f56b8a..a241d06 100644 --- a/ApiCore-Frontend/src/app/providers.tsx +++ b/ApiCore-Frontend/src/app/providers.tsx @@ -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 ( {children} diff --git a/ApiCore-Frontend/src/components/task-status.tsx b/ApiCore-Frontend/src/components/task-status.tsx new file mode 100644 index 0000000..2d5a335 --- /dev/null +++ b/ApiCore-Frontend/src/components/task-status.tsx @@ -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 = { + 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 ( + + + {TASK_STATUS_LABELS[status]} + + ); +} + +/** + * 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; +}) { + 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 ( + + + {source.label} + + ); +} + +const DUE_LABEL: Record, string> = { + overdue: "Überfällig", + today: "Heute", + upcoming: "Geplant", +}; + +export function DueChip({ state, dueDate }: { state: DueState; dueDate: string | null }) { + if (state === "none" || !dueDate) { + return ohne Termin; + } + const date = new Date(dueDate).toLocaleDateString("de-DE", { + day: "numeric", + month: "short", + timeZone: "UTC", + }); + return ( + + {DUE_LABEL[state]} + {state !== "today" ? · {date} : null} + + ); +} diff --git a/ApiCore-Frontend/src/components/today/today-panel.tsx b/ApiCore-Frontend/src/components/today/today-panel.tsx new file mode 100644 index 0000000..643feb8 --- /dev/null +++ b/ApiCore-Frontend/src/components/today/today-panel.tsx @@ -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 = { + 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 ( +
+
+
+
+
+ ); + } + + if (isError || !tasks) { + return ( +
+

+ Die Aufgaben konnten gerade nicht geladen werden. Deine Daten sind nicht betroffen. +

+ +
+ ); + } + + const nextStep = pickNextStep(tasks, now); + if (!nextStep) { + return ; + } + + 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 ( +
+ actions.complete(nextStep)} + onStart={() => actions.start(nextStep)} + isStarting={actions.isStarting} + /> + +
+
+

+ Außerdem heute +

+ + Alle Aufgaben + +
+ {visible.length === 0 ? ( +

+ Sonst ist für heute nichts fällig. Ein Schritt reicht. +

+ ) : ( +
    + {visible.map((task) => ( + actions.complete(task)} + /> + ))} +
+ )} + {hidden > 0 ? ( + + + {hidden} weitere für heute + + ) : null} +
+
+ ); +} + +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 ( +
+

+ Als Nächstes +

+ {/* Keyed by task: a new next step arrives with its own entrance. */} +
+
+

+ {task.title} +

+
+ {task.status === "IN_PROGRESS" ? : null} + + +
+ {task.description ? ( +

{task.description}

+ ) : null} +
+
+ + {task.status === "OPEN" ? ( + + ) : null} + {href ? ( + + ) : null} +
+
+
+ ); +} + +interface TodayRowProps { + task: Task; + now: Date; + leaving: boolean; + onComplete: () => void; +} + +function TodayRow({ task, now, leaving, onComplete }: TodayRowProps) { + const href = relatedHref(task); + return ( +
  • +
    +
    + +
    + {task.title} + + {task.status === "IN_PROGRESS" ? : null} + + + +
    + {href ? ( + + + + ) : null} +
    +
    +
  • + ); +} + +function NothingDue() { + return ( +
    + + + +
    +

    + Heute ist nichts fällig. +

    +

    + Keine offenen Aufgaben. Wenn du am Stand bist, halte fest, was du siehst – Folgeaufgaben + schlägt ApiCore dann selbst vor. +

    +
    +
    + + +
    +
    + ); +} diff --git a/ApiCore-Frontend/src/components/today/use-task-actions.ts b/ApiCore-Frontend/src/components/today/use-task-actions.ts new file mode 100644 index 0000000..a201ccb --- /dev/null +++ b/ApiCore-Frontend/src/components/today/use-task-actions.ts @@ -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 { + 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>(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, + }; +} diff --git a/ApiCore-Frontend/src/components/ui/button.tsx b/ApiCore-Frontend/src/components/ui/button.tsx index ee4a78d..0ffcaee 100644 --- a/ApiCore-Frontend/src/components/ui/button.tsx +++ b/ApiCore-Frontend/src/components/ui/button.tsx @@ -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", diff --git a/ApiCore-Frontend/src/lib/motion/motion-preference.ts b/ApiCore-Frontend/src/lib/motion/motion-preference.ts new file mode 100644 index 0000000..e93e71a --- /dev/null +++ b/ApiCore-Frontend/src/lib/motion/motion-preference.ts @@ -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(() => + 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]; +} diff --git a/ApiCore-Frontend/src/lib/tasks/today.spec.ts b/ApiCore-Frontend/src/lib/tasks/today.spec.ts new file mode 100644 index 0000000..85f52ab --- /dev/null +++ b/ApiCore-Frontend/src/lib/tasks/today.spec.ts @@ -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 { + 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(); + }); +}); diff --git a/ApiCore-Frontend/src/lib/tasks/today.ts b/ApiCore-Frontend/src/lib/tasks/today.ts new file mode 100644 index 0000000..c7adec1 --- /dev/null +++ b/ApiCore-Frontend/src/lib/tasks/today.ts @@ -0,0 +1,103 @@ +import { Task, TaskPriority, TaskRelatedType } from "./types"; + +export type DueState = "overdue" | "today" | "upcoming" | "none"; + +const ACTIVE_STATUSES = new Set(["OPEN", "IN_PROGRESS"]); + +const PRIORITY_RANK: Record = { + 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, 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): 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> = { + 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): string | null { + if (task.relatedType === "STORAGE") return "/storage"; + const base = RELATED_PATHS[task.relatedType]; + return base && task.relatedId ? `${base}/${task.relatedId}` : null; +} diff --git a/ApiCore-Frontend/src/lib/tasks/types.ts b/ApiCore-Frontend/src/lib/tasks/types.ts index 4fae51b..bad5fe8 100644 --- a/ApiCore-Frontend/src/lib/tasks/types.ts +++ b/ApiCore-Frontend/src/lib/tasks/types.ts @@ -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; }