feat: "Heute" start page with one next step, clear task states and motion levels
Start page (AuDHD-oriented, per the agreed "Heute zuerst" layout): - One highlighted next step: started work first (easy re-entry after an interruption), then overdue, due today, then urgent undated tasks. Primary action "Erledigt", secondary "Beginnen", link to the related colony/apiary/queen. - "Außerdem heute": at most five further tasks with a large honey-cell checkbox; undated automatic suggestions are included so a queenless colony does not sit unseen in the full list. - Completing is the one authored motion moment: the cell fills, the tick draws, the row folds away and the next step arrives with a short focus pull. Every completion can be undone from the toast (10 s), restoring the previous status. - Status is shown as icon plus word (never colour alone); tasks created by automation carry a "Vorschlag" badge, recurring and inspection tasks their own - a suggestion never looks like work already done. - Friendly empty, loading and error states; key figures moved into a compact overview below; overdue tasks are no longer listed twice. Motion: per-device setting Voll / Dezent / Aus in the profile (default Voll); the OS reduce-motion setting always lowers Voll to Dezent. New 44 px "touch" button size for gloves at the apiary; themed text selection and caret. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
5c2ea7bf1e
commit
f9374a135a
14 files changed
+1092
-100
No files matched your search
@@ -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>
|
||||
</div>
|
||||
<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>
|
||||
</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) => {
|
||||
<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 (
|
||||
<div
|
||||
<li
|
||||
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"}`}
|
||||
className={`flex items-center gap-3 rounded-xl border p-3 text-sm ${meta?.className ?? "border-border bg-card"}`}
|
||||
>
|
||||
<AlertIcon className="size-4.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1">{alert.message}</span>
|
||||
<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 font-medium text-primary underline-offset-4 hover:underline"
|
||||
className="shrink-0 rounded-lg px-2 py-2 font-medium text-primary underline-offset-4 hover:underline"
|
||||
href={href}
|
||||
>
|
||||
Ansehen
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</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 & 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>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user