From 5319e8af2f5d538b4af15234ef95b45242b9e016 Mon Sep 17 00:00:00 2001 From: Bonna Moon Date: Mon, 6 Jul 2026 16:06:58 -0500 Subject: [PATCH] =?UTF-8?q?v0.26.0=20=E2=80=94=20Calendar:=20month=20view?= =?UTF-8?q?=20of=20all=20tasks=20+=20bloom/harvest=20windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /calendar page merges every Task (garden, home, equipment, animals) into one month grid, color-coded by category, with a click-a-day detail panel and inline Done (reuses /api/tasks/[id]/complete). Recurring tasks project every occurrence in view via the new occurrencesInRange in src/lib/tasks/schedule.ts (first tests for that module — 12, including month-end clamping). Season chips per month come from PlantType bloom/harvest windows via src/lib/garden/season.ts (monthInWindow, year-wrap aware). Nav gains a Calendar item. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 5 +- src/app/(dashboard)/calendar/page.tsx | 67 +++++++ src/components/calendar/calendar-view.tsx | 225 ++++++++++++++++++++++ src/components/layout/nav-items.ts | 4 +- src/lib/changelog.ts | 9 + src/lib/garden/season.test.ts | 25 +++ src/lib/garden/season.ts | 10 + src/lib/tasks/schedule.test.ts | 75 ++++++++ src/lib/tasks/schedule.ts | 87 +++++++++ src/lib/version.ts | 2 +- 10 files changed, 506 insertions(+), 3 deletions(-) create mode 100644 src/app/(dashboard)/calendar/page.tsx create mode 100644 src/components/calendar/calendar-view.tsx create mode 100644 src/lib/garden/season.test.ts create mode 100644 src/lib/garden/season.ts create mode 100644 src/lib/tasks/schedule.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 20ea39b..e6391e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,10 @@ user-facing overview. - **Animals** — `Animal` + `AnimalLog` (pets/livestock); placed on the Location tree, and `Task`s can link to an animal (`Task.animalId`). - **Locations** — `Location` tree (self-ref `parentId`) shared by plants, items, animals. -- **Reminders** — `Task` + `TaskCompletion`; recurrence in `src/lib/tasks/schedule.ts`. +- **Reminders** — `Task` + `TaskCompletion`; recurrence in `src/lib/tasks/schedule.ts` + (incl. `occurrencesInRange` projection). **/calendar** renders a month view of + all tasks (garden + house) plus PlantType bloom/harvest windows + (`src/lib/garden/season.ts`). - **REST API** — `/api/v1/*` authed by `ApiToken` (per-scope; logic in `src/lib/api/`). - **Suggestions** — `@otm/account-panel` (Gitea npm registry) → files issues to the repo. - **Backup & Restore** — `/api/admin/backup` + `/api/admin/restore` (admin only). diff --git a/src/app/(dashboard)/calendar/page.tsx b/src/app/(dashboard)/calendar/page.tsx new file mode 100644 index 0000000..59fe6c4 --- /dev/null +++ b/src/app/(dashboard)/calendar/page.tsx @@ -0,0 +1,67 @@ +import { db } from "@/lib/db"; +import { CalendarView, type CalendarTask, type SeasonType } from "@/components/calendar/calendar-view"; + +export const metadata = { title: "Calendar" }; +export const dynamic = "force-dynamic"; + +export default async function CalendarPage() { + const [tasks, types] = await Promise.all([ + db.task.findMany({ + where: { active: true }, + orderBy: { nextDue: "asc" }, + include: { + plant: { select: { commonName: true, variety: true } }, + animal: { select: { name: true } }, + item: { select: { name: true } }, + location: { select: { name: true } }, + }, + }), + db.plantType.findMany({ + where: { + active: true, + plants: { some: { active: true } }, + OR: [ + { bloomStart: { not: null } }, + { bloomEnd: { not: null } }, + { harvestStart: { not: null } }, + { harvestEnd: { not: null } }, + ], + }, + orderBy: { commonName: "asc" }, + select: { + id: true, commonName: true, + bloomStart: true, bloomEnd: true, harvestStart: true, harvestEnd: true, + }, + }), + ]); + + const calendarTasks: CalendarTask[] = tasks.map((t) => ({ + id: t.id, + title: t.title, + category: t.category, + nextDue: t.nextDue.toISOString(), + recurrence: t.recurrence, + intervalDays: t.intervalDays, + month: t.month, + linkedTo: + (t.plant && `${t.plant.commonName}${t.plant.variety ? ` (${t.plant.variety})` : ""}`) || + t.animal?.name || + t.item?.name || + t.location?.name || + null, + })); + + const seasonTypes: SeasonType[] = types; + + return ( +
+
+

Calendar

+

+ Everything due around the homestead — garden and house — plus what's blooming and ready to pick. +

+
+ +
+ ); +} diff --git a/src/components/calendar/calendar-view.tsx b/src/components/calendar/calendar-view.tsx new file mode 100644 index 0000000..83ab820 --- /dev/null +++ b/src/components/calendar/calendar-view.tsx @@ -0,0 +1,225 @@ +"use client"; + +// Month calendar merging every kind of task (garden, home, equipment) with +// the garden's bloom/harvest windows. Tasks project forward from their +// nextDue via occurrencesInRange; windows come from PlantType months. +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { AlertTriangle, Apple, ChevronLeft, ChevronRight, Flower2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { occurrencesInRange, describeRecurrence, type RecurrenceType } from "@/lib/tasks/schedule"; +import { monthInWindow } from "@/lib/garden/season"; +import { CompleteTaskButton } from "@/components/tasks/complete-task-button"; +import { cn } from "@/lib/utils"; + +export type CalendarTask = { + id: string; + title: string; + category: "GARDEN" | "HOME" | "EQUIPMENT" | "OTHER"; + nextDue: string; // ISO + recurrence: RecurrenceType; + intervalDays: number | null; + month: number | null; + linkedTo: string | null; // plant/animal/item/location name +}; + +export type SeasonType = { + id: string; + commonName: string; + bloomStart: number | null; + bloomEnd: number | null; + harvestStart: number | null; + harvestEnd: number | null; +}; + +const CATEGORY_STYLES: Record = { + GARDEN: "bg-[hsl(var(--leaf)/.12)] text-[hsl(var(--leaf))]", + HOME: "bg-amber-100 text-amber-800", + EQUIPMENT: "bg-sky-100 text-sky-800", + OTHER: "bg-muted text-muted-foreground", +}; + +const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +function iso(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +export function CalendarView({ tasks, seasonTypes }: { + tasks: CalendarTask[]; + seasonTypes: SeasonType[]; +}) { + const today = new Date(); + const todayIso = iso(today); + const [year, setYear] = useState(today.getFullYear()); + const [month, setMonth] = useState(today.getMonth()); // 0-indexed + const [selected, setSelected] = useState(todayIso); + + const monthStart = new Date(year, month, 1); + const monthEnd = new Date(year, month + 1, 0); + + const byDay = useMemo(() => { + const map = new Map(); + for (const t of tasks) { + const occ = occurrencesInRange( + { nextDue: new Date(t.nextDue), recurrence: t.recurrence, intervalDays: t.intervalDays, month: t.month }, + monthStart, + monthEnd, + ); + for (const d of occ) { + const key = iso(d); + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(t); + } + } + return map; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tasks, year, month]); + + const blooming = seasonTypes.filter((t) => monthInWindow(month + 1, t.bloomStart, t.bloomEnd)); + const harvesting = seasonTypes.filter((t) => monthInWindow(month + 1, t.harvestStart, t.harvestEnd)); + const overdue = tasks.filter((t) => new Date(t.nextDue) < new Date(todayIso)); + + function nav(delta: number) { + const d = new Date(year, month + delta, 1); + setYear(d.getFullYear()); + setMonth(d.getMonth()); + setSelected(null); + } + + const leadingBlanks = monthStart.getDay(); + const daysInMonth = monthEnd.getDate(); + const selectedTasks = selected ? byDay.get(selected) ?? [] : []; + + return ( +
+
+

+ {monthStart.toLocaleDateString(undefined, { month: "long", year: "numeric" })} +

+ + + +
+ + {(blooming.length > 0 || harvesting.length > 0) && ( +
+ {harvesting.map((t) => ( + + {t.commonName} ready to harvest + + ))} + {blooming.map((t) => ( + + {t.commonName} in bloom + + ))} +
+ )} + + {overdue.length > 0 && ( + + + {overdue.length} {overdue.length === 1 ? "task is" : "tasks are"} overdue — see Reminders + + )} + +
+
+ {WEEKDAYS.map((w) => ( +
{w}
+ ))} +
+
+ {Array.from({ length: leadingBlanks }).map((_, i) => ( +
+ ))} + {Array.from({ length: daysInMonth }).map((_, i) => { + const day = i + 1; + const key = iso(new Date(year, month, day)); + const dayTasks = byDay.get(key) ?? []; + const isToday = key === todayIso; + const isSelected = key === selected; + return ( + + ); + })} +
+
+ + {selected && ( +
+

+ {new Date(`${selected}T00:00:00`).toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" })} +

+ {selectedTasks.length === 0 ? ( +

Nothing scheduled.

+ ) : ( +
+ {selectedTasks.map((t) => { + const isCurrentDue = iso(new Date(t.nextDue)) === selected; + return ( +
+ + {t.category.toLowerCase()} + +
+

{t.title}

+

+ {describeRecurrence(t.recurrence, t.intervalDays, t.month)} + {t.linkedTo && ` · ${t.linkedTo}`} +

+
+ {isCurrentDue && } +
+ ); + })} +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/layout/nav-items.ts b/src/components/layout/nav-items.ts index 2bc98c8..9c0d31c 100644 --- a/src/components/layout/nav-items.ts +++ b/src/components/layout/nav-items.ts @@ -1,6 +1,7 @@ import { LayoutDashboard, Leaf, BookOpen, Package, PawPrint, MapPin, Bell, - Lightbulb, KeyRound, HardDrive, RefreshCw, UtensilsCrossed, type LucideIcon, + Lightbulb, KeyRound, HardDrive, RefreshCw, UtensilsCrossed, CalendarDays, + type LucideIcon, } from "lucide-react"; export type NavItem = { label: string; href: string; icon: LucideIcon; section?: string }; @@ -13,6 +14,7 @@ export const navItems: NavItem[] = [ { label: "Animals", href: "/animals", icon: PawPrint }, { label: "Locations", href: "/locations", icon: MapPin }, { label: "Reminders", href: "/tasks", icon: Bell }, + { label: "Calendar", href: "/calendar", icon: CalendarDays }, ]; export const kitchenItems: NavItem[] = [ diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index b109ce1..6be537e 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -8,6 +8,15 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.26.0", + date: "2026-07-06", + changes: [ + "New Calendar page (in the menu, next to Reminders) — a month view of everything due around the homestead. Garden chores, furnace filter, vacuum servicing: one calendar. Repeating reminders show every time they'll come up, not just the next one.", + "The calendar also knows your garden's seasons: chips at the top of each month show what's in bloom and what's ready to harvest, based on the bloom/harvest months saved on your plant types.", + "Click any day to see its tasks and mark them done right there; anything overdue is flagged at the top with a link to Reminders.", + ], + }, { version: "0.25.0", date: "2026-07-06", diff --git a/src/lib/garden/season.test.ts b/src/lib/garden/season.test.ts new file mode 100644 index 0000000..12cea59 --- /dev/null +++ b/src/lib/garden/season.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { monthInWindow } from "./season"; + +describe("monthInWindow", () => { + it("plain range", () => { + expect(monthInWindow(7, 6, 9)).toBe(true); + expect(monthInWindow(5, 6, 9)).toBe(false); + }); + + it("wraps the year end", () => { + expect(monthInWindow(11, 10, 3)).toBe(true); + expect(monthInWindow(2, 10, 3)).toBe(true); + expect(monthInWindow(7, 10, 3)).toBe(false); + }); + + it("single-ended windows collapse to one month", () => { + expect(monthInWindow(6, 6, null)).toBe(true); + expect(monthInWindow(7, 6, null)).toBe(false); + expect(monthInWindow(9, null, 9)).toBe(true); + }); + + it("no window at all", () => { + expect(monthInWindow(6, null, null)).toBe(false); + }); +}); diff --git a/src/lib/garden/season.ts b/src/lib/garden/season.ts new file mode 100644 index 0000000..62d5c96 --- /dev/null +++ b/src/lib/garden/season.ts @@ -0,0 +1,10 @@ +// Season-window math for PlantType bloom/harvest month ranges (1–12). +// Windows may wrap the year end (e.g. Oct–Mar). + +/** Is `month` (1–12) inside the inclusive window start..end, wrapping year end? */ +export function monthInWindow(month: number, start: number | null, end: number | null): boolean { + if (start == null && end == null) return false; + const s = start ?? end!; + const e = end ?? start!; + return s <= e ? month >= s && month <= e : month >= s || month <= e; +} diff --git a/src/lib/tasks/schedule.test.ts b/src/lib/tasks/schedule.test.ts new file mode 100644 index 0000000..51fe080 --- /dev/null +++ b/src/lib/tasks/schedule.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { nextDueDate, occurrencesInRange, type ProjectableTask } from "./schedule"; + +const d = (s: string) => new Date(`${s}T00:00:00`); + +describe("nextDueDate", () => { + it("ONCE never recurs", () => { + expect(nextDueDate("ONCE", d("2026-07-06"), null, null)).toBeNull(); + }); + + it("CUSTOM adds the interval (default 30)", () => { + expect(nextDueDate("CUSTOM", d("2026-07-06"), 14, null)).toEqual(d("2026-07-20")); + expect(nextDueDate("CUSTOM", d("2026-07-06"), null, null)).toEqual(d("2026-08-05")); + }); + + it("MONTHLY moves to next month", () => { + expect(nextDueDate("MONTHLY", d("2026-07-06"), null, null)).toEqual(d("2026-08-06")); + }); + + it("YEARLY lands on the 1st of the target month next year", () => { + expect(nextDueDate("YEARLY", d("2026-07-06"), null, 4)).toEqual(d("2027-04-01")); + }); +}); + +function task(overrides: Partial): ProjectableTask { + return { nextDue: d("2026-07-10"), recurrence: "ONCE", intervalDays: null, month: null, ...overrides }; +} + +describe("occurrencesInRange", () => { + it("ONCE: shows only on its due date", () => { + const t = task({}); + expect(occurrencesInRange(t, d("2026-07-01"), d("2026-07-31"))).toEqual([d("2026-07-10")]); + expect(occurrencesInRange(t, d("2026-08-01"), d("2026-08-31"))).toEqual([]); + }); + + it("CUSTOM: repeats by interval within the range", () => { + const t = task({ recurrence: "CUSTOM", intervalDays: 7 }); + expect(occurrencesInRange(t, d("2026-07-01"), d("2026-07-31"))).toEqual([ + d("2026-07-10"), d("2026-07-17"), d("2026-07-24"), d("2026-07-31"), + ]); + }); + + it("CUSTOM: projects into a later month without drifting", () => { + const t = task({ recurrence: "CUSTOM", intervalDays: 14 }); + expect(occurrencesInRange(t, d("2026-09-01"), d("2026-09-30"))).toEqual([ + d("2026-09-04"), d("2026-09-18"), + ]); + }); + + it("an overdue occurrence stays visible in its own (past) month", () => { + const t = task({ recurrence: "CUSTOM", intervalDays: 30, nextDue: d("2026-06-20") }); + expect(occurrencesInRange(t, d("2026-06-01"), d("2026-06-30"))).toEqual([d("2026-06-20")]); + }); + + it("MONTHLY: same day each month, clamped at short months", () => { + const t = task({ recurrence: "MONTHLY", nextDue: d("2026-01-31") }); + expect(occurrencesInRange(t, d("2026-02-01"), d("2026-02-28"))).toEqual([d("2026-02-28")]); + expect(occurrencesInRange(t, d("2026-03-01"), d("2026-03-31"))).toEqual([d("2026-03-31")]); + }); + + it("MONTHLY: never projects before the stored due date", () => { + const t = task({ recurrence: "MONTHLY", nextDue: d("2026-07-15") }); + expect(occurrencesInRange(t, d("2026-06-01"), d("2026-06-30"))).toEqual([]); + }); + + it("YEARLY: shows the stored due and the same date next year", () => { + const t = task({ recurrence: "YEARLY", nextDue: d("2026-10-01"), month: 10 }); + expect(occurrencesInRange(t, d("2026-10-01"), d("2026-10-31"))).toEqual([d("2026-10-01")]); + expect(occurrencesInRange(t, d("2027-10-01"), d("2027-10-31"))).toEqual([d("2027-10-01")]); + }); + + it("returns nothing for an inverted range", () => { + expect(occurrencesInRange(task({}), d("2026-07-31"), d("2026-07-01"))).toEqual([]); + }); +}); diff --git a/src/lib/tasks/schedule.ts b/src/lib/tasks/schedule.ts index c5ea08f..4d4fc47 100644 --- a/src/lib/tasks/schedule.ts +++ b/src/lib/tasks/schedule.ts @@ -74,3 +74,90 @@ export const MONTH_NAMES = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; + +// --------------------------------------------------------------------------- +// Occurrence projection — which days does a task land on within a date range? +// The DB stores only the next due date; recurring tasks repeat from there. +// Used by the calendar to paint future (and the one overdue) occurrence(s). +// --------------------------------------------------------------------------- + +export type ProjectableTask = { + nextDue: Date; + recurrence: RecurrenceType; + intervalDays: number | null; + month: number | null; // 1–12, for YEARLY +}; + +/** Same day-of-month in the given month, clamped to its last day (Jan 31 → Feb 28). */ +function onDayOfMonth(year: number, monthIdx: number, day: number): Date { + const lastDay = new Date(year, monthIdx + 1, 0).getDate(); + return new Date(year, monthIdx, Math.min(day, lastDay)); +} + +function startOfDay(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); +} + +/** + * All days in [rangeStart, rangeEnd] (inclusive) the task lands on. + * The stored nextDue is always an occurrence (even if overdue/in the past); + * recurring tasks project forward from it, never backward. + */ +export function occurrencesInRange( + task: ProjectableTask, + rangeStart: Date, + rangeEnd: Date, +): Date[] { + const due = startOfDay(new Date(task.nextDue)); + const start = startOfDay(rangeStart); + const end = startOfDay(rangeEnd); + if (start > end) return []; + + const inRange = (d: Date) => d >= start && d <= end; + const out: Date[] = []; + const push = (d: Date) => { + if (inRange(d) && !out.some((o) => o.getTime() === d.getTime())) out.push(d); + }; + + switch (task.recurrence) { + case "ONCE": { + push(due); + break; + } + case "CUSTOM": { + const step = Math.max(1, task.intervalDays ?? 30); + push(due); // the stored (possibly overdue) occurrence stays visible + let d = due; + if (d < start) { + // Jump close to the range start instead of stepping day by day. + const behind = Math.ceil((start.getTime() - d.getTime()) / (step * 86_400_000)); + d = new Date(d.getTime() + behind * step * 86_400_000); + } + for (; d <= end; d = new Date(d.getTime() + step * 86_400_000)) push(d); + break; + } + case "MONTHLY": { + const day = due.getDate(); + push(due); + let y = start.getFullYear(); + let m = start.getMonth(); + for (; new Date(y, m, 1) <= end; m === 11 ? (m = 0, y++) : m++) { + const d = onDayOfMonth(y, m, day); + if (d >= due) push(d); + } + break; + } + case "YEARLY": { + push(due); + const targetMonth = (task.month ?? due.getMonth() + 1) - 1; + const day = due.getDate(); + for (let y = start.getFullYear(); y <= end.getFullYear(); y++) { + const d = onDayOfMonth(y, targetMonth, day); + if (d > due) push(d); + } + break; + } + } + + return out.sort((a, b) => a.getTime() - b.getTime()); +} diff --git a/src/lib/version.ts b/src/lib/version.ts index bd3409a..2bdb10a 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,2 +1,2 @@ // Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`. -export const APP_VERSION = "0.25.0"; +export const APP_VERSION = "0.26.0";