feat: "Heute" start page with one next step, clear task states and motion levels

Start page (AuDHD-oriented, per the agreed "Heute zuerst" layout):
- One highlighted next step: started work first (easy re-entry after an
  interruption), then overdue, due today, then urgent undated tasks.
  Primary action "Erledigt", secondary "Beginnen", link to the related
  colony/apiary/queen.
- "Außerdem heute": at most five further tasks with a large honey-cell
  checkbox; undated automatic suggestions are included so a queenless
  colony does not sit unseen in the full list.
- Completing is the one authored motion moment: the cell fills, the tick
  draws, the row folds away and the next step arrives with a short focus
  pull. Every completion can be undone from the toast (10 s), restoring
  the previous status.
- Status is shown as icon plus word (never colour alone); tasks created by
  automation carry a "Vorschlag" badge, recurring and inspection tasks
  their own - a suggestion never looks like work already done.
- Friendly empty, loading and error states; key figures moved into a
  compact overview below; overdue tasks are no longer listed twice.

Motion: per-device setting Voll / Dezent / Aus in the profile (default
Voll); the OS reduce-motion setting always lowers Voll to Dezent.
New 44 px "touch" button size for gloves at the apiary; themed text
selection and caret.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-27 13:00:44 +02:00
1 parent 5c2ea7bf1e
commit f9374a135a
14 files changed
+1104 -112

No files matched your search

@@ -0,0 +1,68 @@
"use client";
import { useCallback, useState } from "react";
/**
* How much the interface moves. A per-device preference (localStorage), not
* an account setting: the same person may want calm motion on the phone at
* the apiary and full feedback on the desktop.
*
* - full: feedback, transitions and the task-completion moment.
* - subtle: no spatial movement, only short fades and colour changes.
* - off: state changes happen instantly.
*
* The operating system's "reduce motion" setting always wins over "full"
* (see globals.css), so this only ever lowers motion further.
*/
export type MotionLevel = "full" | "subtle" | "off";
export const MOTION_LEVELS: MotionLevel[] = ["full", "subtle", "off"];
export const DEFAULT_MOTION_LEVEL: MotionLevel = "full";
const STORAGE_KEY = "apicore.motion";
function isMotionLevel(value: unknown): value is MotionLevel {
return value === "full" || value === "subtle" || value === "off";
}
export function readMotionLevel(): MotionLevel {
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return isMotionLevel(stored) ? stored : DEFAULT_MOTION_LEVEL;
} catch {
// Private mode / blocked storage: fall back to the default.
return DEFAULT_MOTION_LEVEL;
}
}
export function applyMotionLevel(level: MotionLevel): void {
document.documentElement.dataset.motion = level;
}
/** Effective level, including the operating system's reduce-motion setting. */
export function effectiveMotionLevel(level: MotionLevel = readMotionLevel()): MotionLevel {
const systemReduces =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
return systemReduces && level === "full" ? "subtle" : level;
}
export function useMotionPreference(): [MotionLevel, (level: MotionLevel) => void] {
// Lazy read: the app shell renders only on the client after login, and the
// document attribute itself is applied once in Providers.
const [level, setLevelState] = useState<MotionLevel>(() =>
typeof window === "undefined" ? DEFAULT_MOTION_LEVEL : readMotionLevel(),
);
const setLevel = useCallback((next: MotionLevel) => {
setLevelState(next);
applyMotionLevel(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Not persisted - still applies for this visit.
}
}, []);
return [level, setLevel];
}
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { Task } from "./types";
import { dueState, pickNextStep, relatedHref, tasksForToday } from "./today";
const NOW = new Date(2026, 8, 27, 9, 30); // 27.09.2026, 09:30 local
function task(overrides: Partial<Task>): Task {
return {
id: overrides.id ?? "t",
title: "Aufgabe",
description: null,
priority: "NORMAL",
dueDate: null,
status: "OPEN",
assignedUserId: null,
createdByUserId: null,
relatedType: "GENERAL",
relatedId: null,
sourceInspectionId: null,
sourceTemplateId: null,
sourceAutomationTrigger: null,
createdAt: "2026-09-01T00:00:00.000Z",
completedAt: null,
...overrides,
};
}
describe("dueState", () => {
it("treats a task due on today's calendar day as today, not overdue", () => {
expect(dueState({ dueDate: "2026-09-27T00:00:00.000Z" }, NOW)).toBe("today");
});
it("marks earlier days as overdue and later days as upcoming", () => {
expect(dueState({ dueDate: "2026-09-26T00:00:00.000Z" }, NOW)).toBe("overdue");
expect(dueState({ dueDate: "2026-09-28T00:00:00.000Z" }, NOW)).toBe("upcoming");
expect(dueState({ dueDate: null }, NOW)).toBe("none");
});
});
describe("tasksForToday", () => {
it("lists started, overdue and due-today tasks - started first, then overdue", () => {
const tasks = [
task({ id: "today", dueDate: "2026-09-27T00:00:00.000Z" }),
task({ id: "overdue", dueDate: "2026-09-20T00:00:00.000Z" }),
task({ id: "started", status: "IN_PROGRESS" }),
task({ id: "later", dueDate: "2026-10-01T00:00:00.000Z" }),
task({ id: "done", status: "DONE", dueDate: "2026-09-27T00:00:00.000Z" }),
];
expect(tasksForToday(tasks, NOW).map((t) => t.id)).toEqual(["started", "overdue", "today"]);
});
it("includes an undated automatic suggestion but not an undated planned task", () => {
const tasks = [
task({ id: "suggestion", sourceAutomationTrigger: "QUEENLESS_NO_EGGS", priority: "HIGH" }),
task({ id: "someday" }),
];
expect(tasksForToday(tasks, NOW).map((t) => t.id)).toEqual(["suggestion"]);
});
it("orders equally due tasks by priority", () => {
const tasks = [
task({ id: "normal", dueDate: "2026-09-27T00:00:00.000Z" }),
task({ id: "urgent", priority: "URGENT", dueDate: "2026-09-27T00:00:00.000Z" }),
];
expect(tasksForToday(tasks, NOW).map((t) => t.id)).toEqual(["urgent", "normal"]);
});
});
describe("pickNextStep", () => {
it("falls back to an undated high-priority task when nothing is due today", () => {
const tasks = [
task({ id: "later", dueDate: "2026-10-05T00:00:00.000Z" }),
task({ id: "undated-high", priority: "HIGH" }),
];
expect(pickNextStep(tasks, NOW)?.id).toBe("undated-high");
});
it("returns null when nothing is open", () => {
expect(pickNextStep([task({ status: "DONE" })], NOW)).toBeNull();
});
});
describe("relatedHref", () => {
it("links a colony task to the colony", () => {
expect(relatedHref({ relatedType: "COLONY", relatedId: "c1" })).toBe("/colonies/c1");
});
it("has no link for general tasks", () => {
expect(relatedHref({ relatedType: "GENERAL", relatedId: null })).toBeNull();
});
});
+103
View File
@@ -0,0 +1,103 @@
import { Task, TaskPriority, TaskRelatedType } from "./types";
export type DueState = "overdue" | "today" | "upcoming" | "none";
const ACTIVE_STATUSES = new Set<Task["status"]>(["OPEN", "IN_PROGRESS"]);
const PRIORITY_RANK: Record<TaskPriority, number> = {
URGENT: 0,
HIGH: 1,
NORMAL: 2,
LOW: 3,
};
function startOfLocalDay(date: Date): number {
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
}
/**
* Due dates are stored as calendar days (midnight UTC). Compared by local
* calendar day, so a task due "today" does not turn overdue at 01:00.
*/
export function dueState(task: Pick<Task, "dueDate">, now: Date): DueState {
if (!task.dueDate) return "none";
const due = new Date(task.dueDate);
const dueDay = new Date(due.getUTCFullYear(), due.getUTCMonth(), due.getUTCDate()).getTime();
const today = startOfLocalDay(now);
if (dueDay < today) return "overdue";
if (dueDay === today) return "today";
return "upcoming";
}
export function isActiveTask(task: Pick<Task, "status">): boolean {
return ACTIVE_STATUSES.has(task.status);
}
function byUrgency(now: Date) {
const rank = (task: Task): number[] => {
const state = dueState(task, now);
return [
// Something already started comes first: picking it up again is the
// cheapest way back into the work after an interruption.
task.status === "IN_PROGRESS" ? 0 : 1,
state === "overdue" ? 0 : state === "today" ? 1 : state === "none" ? 2 : 3,
PRIORITY_RANK[task.priority],
task.dueDate ? new Date(task.dueDate).getTime() : Number.MAX_SAFE_INTEGER,
new Date(task.createdAt).getTime(),
];
};
return (a: Task, b: Task): number => {
const ra = rank(a);
const rb = rank(b);
for (let i = 0; i < ra.length; i++) {
if (ra[i] !== rb[i]) return ra[i] - rb[i];
}
return 0;
};
}
/**
* Active tasks due today or earlier, anything already started, and automatic
* suggestions without a date (a queenless colony, a low stock item): those
* ask for a look now and would otherwise sit unseen in the full list.
*/
export function tasksForToday(tasks: Task[], now: Date): Task[] {
return tasks
.filter(isActiveTask)
.filter((task) => {
const state = dueState(task, now);
return (
task.status === "IN_PROGRESS" ||
state === "overdue" ||
state === "today" ||
(state === "none" && task.sourceAutomationTrigger !== null)
);
})
.sort(byUrgency(now));
}
/**
* The one task the start page puts forward. Falls back from today's list to
* urgent/high tasks without a date and then to the next upcoming one, so there
* is a concrete suggestion whenever anything is open at all.
*/
export function pickNextStep(tasks: Task[], now: Date): Task | null {
const today = tasksForToday(tasks, now);
if (today.length > 0) return today[0];
const rest = tasks.filter(isActiveTask).sort(byUrgency(now));
return rest[0] ?? null;
}
const RELATED_PATHS: Partial<Record<TaskRelatedType, string>> = {
COLONY: "/colonies",
APIARY: "/apiaries",
QUEEN: "/queens",
HONEY_BATCH: "/honey-batches",
};
/** Where the object a task is about lives in the app, if anywhere. */
export function relatedHref(task: Pick<Task, "relatedType" | "relatedId">): string | null {
if (task.relatedType === "STORAGE") return "/storage";
const base = RELATED_PATHS[task.relatedType];
return base && task.relatedId ? `${base}/${task.relatedId}` : null;
}
+5
View File
@@ -20,6 +20,11 @@ export interface Task {
relatedType: TaskRelatedType;
relatedId: string | null;
sourceInspectionId: string | null;
// Set when a recurring template created the task.
sourceTemplateId: string | null;
// Set when an automation rule suggested the task (not a person) - e.g.
// "STOCK_ITEM_LOW". Shown as a suggestion, never as work already done.
sourceAutomationTrigger: string | null;
createdAt: string;
completedAt: string | null;
}