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
@@ -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