feat: "Heute" start page with one next step, clear task states and motion levels
Start page (AuDHD-oriented, per the agreed "Heute zuerst" layout): - One highlighted next step: started work first (easy re-entry after an interruption), then overdue, due today, then urgent undated tasks. Primary action "Erledigt", secondary "Beginnen", link to the related colony/apiary/queen. - "Außerdem heute": at most five further tasks with a large honey-cell checkbox; undated automatic suggestions are included so a queenless colony does not sit unseen in the full list. - Completing is the one authored motion moment: the cell fills, the tick draws, the row folds away and the next step arrives with a short focus pull. Every completion can be undone from the toast (10 s), restoring the previous status. - Status is shown as icon plus word (never colour alone); tasks created by automation carry a "Vorschlag" badge, recurring and inspection tasks their own - a suggestion never looks like work already done. - Friendly empty, loading and error states; key figures moved into a compact overview below; overdue tasks are no longer listed twice. Motion: per-device setting Voll / Dezent / Aus in the profile (default Voll); the OS reduce-motion setting always lowers Voll to Dezent. New 44 px "touch" button size for gloves at the apiary; themed text selection and caret. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
5c2ea7bf1e
commit
f9374a135a
14 files changed
+1104
-112
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -17,8 +18,10 @@ import {
|
|||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
|
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
|
||||||
import { DashboardAlert, DashboardAlertType } from "@/lib/dashboard/types";
|
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 { HoneycombPattern } from "@/components/honeycomb-pattern";
|
||||||
|
import { TodayPanel } from "@/components/today/today-panel";
|
||||||
|
|
||||||
function alertHref(alert: DashboardAlert): string | null {
|
function alertHref(alert: DashboardAlert): string | null {
|
||||||
if (alert.relatedType === "Colony") return `/colonies/${alert.relatedId}`;
|
if (alert.relatedType === "Colony") return `/colonies/${alert.relatedId}`;
|
||||||
@@ -27,19 +30,25 @@ function alertHref(alert: DashboardAlert): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function greeting(): string {
|
function greeting(now: Date): string {
|
||||||
const hour = new Date().getHours();
|
const hour = now.getHours();
|
||||||
if (hour < 11) return "Guten Morgen";
|
if (hour < 11) return "Guten Morgen";
|
||||||
if (hour < 18) return "Guten Tag";
|
if (hour < 18) return "Guten Tag";
|
||||||
return "Guten Abend";
|
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 {
|
interface StatTile {
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
gradient: string;
|
|
||||||
href: string;
|
href: string;
|
||||||
|
attention?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALERT_META: Record<
|
const ALERT_META: Record<
|
||||||
@@ -75,7 +84,8 @@ const ALERT_META: Record<
|
|||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { data: summary, isLoading: summaryLoading } = useQuery({
|
const [now] = useState(() => new Date());
|
||||||
|
const { data: summary } = useQuery({
|
||||||
queryKey: ["dashboard", "summary"],
|
queryKey: ["dashboard", "summary"],
|
||||||
queryFn: fetchDashboardSummary,
|
queryFn: fetchDashboardSummary,
|
||||||
});
|
});
|
||||||
@@ -83,133 +93,133 @@ export default function DashboardPage() {
|
|||||||
queryKey: ["dashboard", "alerts"],
|
queryKey: ["dashboard", "alerts"],
|
||||||
queryFn: fetchDashboardAlerts,
|
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
|
const tiles: StatTile[] = summary
|
||||||
? [
|
? [
|
||||||
{
|
{ label: "Bienenstände", value: summary.apiariesActive, icon: MapPin, href: "/apiaries" },
|
||||||
label: "Aktive Bienenstände",
|
{ label: "Völker", value: summary.coloniesActive, icon: Layers, href: "/colonies" },
|
||||||
value: summary.apiariesActive,
|
{ label: "Königinnen", value: summary.queensActive, icon: Crown, href: "/queens" },
|
||||||
icon: MapPin,
|
{ label: "Offene Aufgaben", value: summary.openTasks, icon: ListTodo, href: "/tasks" },
|
||||||
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: "Honigchargen",
|
label: "Honigchargen",
|
||||||
value: summary.honeyBatchesTotal,
|
value: summary.honeyBatchesTotal,
|
||||||
icon: Droplet,
|
icon: Droplet,
|
||||||
gradient: "var(--gradient-honey)",
|
|
||||||
href: "/honey-batches",
|
href: "/honey-batches",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Artikel unter Mindestbestand",
|
label: "unter Minimum",
|
||||||
value: summary.stockItemsLowCount,
|
value: summary.stockItemsLowCount,
|
||||||
icon: AlertTriangle,
|
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",
|
href: "/storage",
|
||||||
|
attention: summary.stockItemsLowCount > 0,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-6 p-4 sm:p-6">
|
<main className="mx-auto flex w-full max-w-3xl flex-col gap-8 p-4 sm:p-6">
|
||||||
<div
|
<header
|
||||||
className="relative overflow-hidden rounded-2xl px-5 py-6 text-white shadow-[var(--shadow-elevated)] sm:px-8 sm:py-8"
|
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)" }}
|
style={{ background: "var(--gradient-honey)" }}
|
||||||
>
|
>
|
||||||
<HoneycombPattern className="pointer-events-none absolute inset-0 h-full w-full text-white/[0.08]" />
|
<HoneycombPattern className="pointer-events-none absolute inset-0 h-full w-full text-white/[0.08]" />
|
||||||
<div className="relative flex flex-col gap-1">
|
<div className="relative flex flex-col gap-1">
|
||||||
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||||
{greeting()}
|
{greeting(now)}
|
||||||
{user ? `, ${user.email.split("@")[0]}` : ""}
|
{firstName ? `, ${firstName}` : ""}
|
||||||
</h1>
|
</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>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
{summaryLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
<TodayPanel
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
tasks={tasksQuery.data}
|
||||||
{tiles.map((tile) => {
|
isLoading={tasksQuery.isLoading}
|
||||||
const Icon = tile.icon;
|
isError={tasksQuery.isError}
|
||||||
return (
|
onRetry={() => void tasksQuery.refetch()}
|
||||||
<Link key={tile.label} href={tile.href}>
|
now={now}
|
||||||
<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>
|
|
||||||
|
|
||||||
<div>
|
{alertsLoading || otherAlerts.length > 0 ? (
|
||||||
<h2 className="mb-3 font-heading text-lg font-semibold tracking-tight">Hinweise</h2>
|
<section aria-labelledby="alerts-heading" className="flex flex-col gap-2">
|
||||||
<div 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 ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
{!alertsLoading && (!alerts || alerts.length === 0) ? (
|
<ul className="flex flex-col gap-2">
|
||||||
<Card>
|
{otherAlerts.map((alert) => {
|
||||||
<CardContent className="text-sm text-muted-foreground">
|
const href = alertHref(alert);
|
||||||
Keine offenen Hinweise.
|
const meta = ALERT_META[alert.type];
|
||||||
</CardContent>
|
const AlertIcon = meta?.icon ?? AlertTriangle;
|
||||||
</Card>
|
return (
|
||||||
) : null}
|
<li
|
||||||
{alerts?.map((alert) => {
|
key={`${alert.type}-${alert.relatedId}`}
|
||||||
const href = alertHref(alert);
|
className={`flex items-center gap-3 rounded-xl border p-3 text-sm ${meta?.className ?? "border-border bg-card"}`}
|
||||||
const meta = ALERT_META[alert.type];
|
>
|
||||||
const AlertIcon = meta?.icon ?? AlertTriangle;
|
<AlertIcon className="size-5 shrink-0 text-foreground/70" aria-hidden />
|
||||||
return (
|
<span className="flex-1">
|
||||||
<div
|
<span className="sr-only">{meta?.label}: </span>
|
||||||
key={`${alert.type}-${alert.relatedId}`}
|
{alert.message}
|
||||||
className={`flex items-center gap-3 rounded-xl border p-3 text-sm shadow-[var(--shadow-soft)] ${meta?.className ?? "border-border bg-card"}`}
|
</span>
|
||||||
>
|
{href ? (
|
||||||
<AlertIcon className="size-4.5 shrink-0 text-muted-foreground" />
|
<Link
|
||||||
<span className="flex-1">{alert.message}</span>
|
className="shrink-0 rounded-lg px-2 py-2 font-medium text-primary underline-offset-4 hover:underline"
|
||||||
{href ? (
|
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
|
<Link
|
||||||
className="shrink-0 font-medium text-primary underline-offset-4 hover:underline"
|
href={tile.href}
|
||||||
href={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>
|
</Link>
|
||||||
) : null}
|
</li>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</ul>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
) : null}
|
||||||
</main>
|
</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";
|
} from "@/components/ui/card";
|
||||||
import { MfaSection } from "./mfa-section";
|
import { MfaSection } from "./mfa-section";
|
||||||
import { DangerZoneSection } from "./danger-zone-section";
|
import { DangerZoneSection } from "./danger-zone-section";
|
||||||
|
import { MotionSection } from "./motion-section";
|
||||||
|
|
||||||
function splitName(fullName: string): { firstName: string; lastName: string } {
|
function splitName(fullName: string): { firstName: string; lastName: string } {
|
||||||
const [firstName, ...rest] = fullName.trim().split(/\s+/);
|
const [firstName, ...rest] = fullName.trim().split(/\s+/);
|
||||||
@@ -297,6 +298,7 @@ export default function ProfilePage() {
|
|||||||
<ProfileInfoSection />
|
<ProfileInfoSection />
|
||||||
<PasswordSection />
|
<PasswordSection />
|
||||||
<MfaSection mfaEnabled={user.mfaEnabled} onChanged={() => void refreshUser()} />
|
<MfaSection mfaEnabled={user.mfaEnabled} onChanged={() => void refreshUser()} />
|
||||||
|
<MotionSection />
|
||||||
<DangerZoneSection />
|
<DangerZoneSection />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { completeTask, fetchTasks } from "@/lib/tasks/api";
|
import { completeTask, fetchTasks } from "@/lib/tasks/api";
|
||||||
import { TaskPriority, TaskStatus } from "@/lib/tasks/types";
|
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 { 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 { PageHeader } from "@/components/page-header";
|
||||||
import { EmptyState } from "@/components/empty-state";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { EntityCard } from "@/components/entity-card";
|
import { EntityCard } from "@/components/entity-card";
|
||||||
@@ -38,10 +38,12 @@ export default function TasksPage() {
|
|||||||
|
|
||||||
const completeMutation = useMutation({
|
const completeMutation = useMutation({
|
||||||
mutationFn: completeTask,
|
mutationFn: completeTask,
|
||||||
onSuccess: () => {
|
onSuccess: (task) => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
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());
|
const [now] = useState(() => Date.now());
|
||||||
@@ -136,9 +138,10 @@ export default function TasksPage() {
|
|||||||
gradient="var(--gradient-sky)"
|
gradient="var(--gradient-sky)"
|
||||||
title={task.title}
|
title={task.title}
|
||||||
badge={
|
badge={
|
||||||
<Badge variant={task.status === "DONE" ? "secondary" : "default"}>
|
<span className="flex flex-wrap items-center gap-1.5">
|
||||||
{TASK_STATUS_LABELS[task.status]}
|
<TaskStatusPill status={task.status} />
|
||||||
</Badge>
|
<TaskSourceBadge task={task} />
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
fields={[
|
fields={[
|
||||||
{ label: "Priorität", value: TASK_PRIORITY_LABELS[task.priority] },
|
{ label: "Priorität", value: TASK_PRIORITY_LABELS[task.priority] },
|
||||||
@@ -152,12 +155,13 @@ export default function TasksPage() {
|
|||||||
<TaskEditDialog task={task} />
|
<TaskEditDialog task={task} />
|
||||||
{task.status !== "DONE" && task.status !== "CANCELLED" ? (
|
{task.status !== "DONE" && task.status !== "CANCELLED" ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="touch"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={completeMutation.isPending}
|
disabled={completeMutation.isPending}
|
||||||
onClick={() => completeMutation.mutate(task.id)}
|
onClick={() => completeMutation.mutate(task.id)}
|
||||||
>
|
>
|
||||||
Erledigen
|
<Check aria-hidden />
|
||||||
|
Erledigt
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -198,4 +198,179 @@
|
|||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
letter-spacing: -0.01em;
|
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";
|
"use client";
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AuthProvider } from "@/lib/auth/auth-context";
|
import { AuthProvider } from "@/lib/auth/auth-context";
|
||||||
|
import { applyMotionLevel, readMotionLevel } from "@/lib/motion/motion-preference";
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
const [queryClient] = useState(() => new QueryClient());
|
const [queryClient] = useState(() => new QueryClient());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyMotionLevel(readMotionLevel());
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<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",
|
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",
|
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",
|
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: "size-8",
|
||||||
"icon-xs":
|
"icon-xs":
|
||||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
"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;
|
relatedType: TaskRelatedType;
|
||||||
relatedId: string | null;
|
relatedId: string | null;
|
||||||
sourceInspectionId: 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;
|
createdAt: string;
|
||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user