v0.26.0 — Calendar: month view of all tasks + bloom/harvest windows

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 <noreply@anthropic.com>
This commit is contained in:
Bonna Moon
2026-07-06 16:06:58 -05:00
parent cc212c87c7
commit 5319e8af2f
10 changed files with 506 additions and 3 deletions

View File

@@ -32,7 +32,10 @@ user-facing overview.
- **Animals** — `Animal` + `AnimalLog` (pets/livestock); placed on the Location - **Animals** — `Animal` + `AnimalLog` (pets/livestock); placed on the Location
tree, and `Task`s can link to an animal (`Task.animalId`). tree, and `Task`s can link to an animal (`Task.animalId`).
- **Locations** — `Location` tree (self-ref `parentId`) shared by plants, items, animals. - **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/`). - **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. - **Suggestions** — `@otm/account-panel` (Gitea npm registry) → files issues to the repo.
- **Backup & Restore** — `/api/admin/backup` + `/api/admin/restore` (admin only). - **Backup & Restore** — `/api/admin/backup` + `/api/admin/restore` (admin only).

View File

@@ -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 (
<div className="space-y-6 max-w-4xl">
<div>
<h1 className="font-display text-2xl font-semibold">Calendar</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Everything due around the homestead garden and house plus what&apos;s blooming and ready to pick.
</p>
</div>
<CalendarView tasks={calendarTasks} seasonTypes={seasonTypes} />
</div>
);
}

View File

@@ -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<CalendarTask["category"], string> = {
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<string | null>(todayIso);
const monthStart = new Date(year, month, 1);
const monthEnd = new Date(year, month + 1, 0);
const byDay = useMemo(() => {
const map = new Map<string, CalendarTask[]>();
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 (
<div className="space-y-4">
<div className="flex items-center gap-2">
<h2 className="font-display text-xl font-semibold flex-1">
{monthStart.toLocaleDateString(undefined, { month: "long", year: "numeric" })}
</h2>
<Button variant="outline" size="sm" onClick={() => nav(-1)} aria-label="Previous month">
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline" size="sm"
onClick={() => { setYear(today.getFullYear()); setMonth(today.getMonth()); setSelected(todayIso); }}
>
Today
</Button>
<Button variant="outline" size="sm" onClick={() => nav(1)} aria-label="Next month">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{(blooming.length > 0 || harvesting.length > 0) && (
<div className="flex flex-wrap gap-1.5 text-xs">
{harvesting.map((t) => (
<Badge key={`h-${t.id}`} variant="outline" className="gap-1 border-[hsl(var(--leaf)/.5)] text-[hsl(var(--leaf))]">
<Apple className="h-3 w-3" /> {t.commonName} ready to harvest
</Badge>
))}
{blooming.map((t) => (
<Badge key={`b-${t.id}`} variant="outline" className="gap-1 border-pink-300 text-pink-700">
<Flower2 className="h-3 w-3" /> {t.commonName} in bloom
</Badge>
))}
</div>
)}
{overdue.length > 0 && (
<Link
href="/tasks"
className="flex items-center gap-2 text-sm rounded-lg border border-[hsl(var(--ember)/.4)] bg-[hsl(var(--ember)/.06)] px-3 py-2 text-[hsl(var(--ember))] hover:bg-[hsl(var(--ember)/.1)] transition-colors"
>
<AlertTriangle className="h-4 w-4 shrink-0" />
{overdue.length} {overdue.length === 1 ? "task is" : "tasks are"} overdue see Reminders
</Link>
)}
<div className="rounded-lg border bg-card overflow-hidden">
<div className="grid grid-cols-7 border-b bg-muted/40">
{WEEKDAYS.map((w) => (
<div key={w} className="px-2 py-1.5 text-xs font-medium text-muted-foreground text-center">{w}</div>
))}
</div>
<div className="grid grid-cols-7">
{Array.from({ length: leadingBlanks }).map((_, i) => (
<div key={`blank-${i}`} className="min-h-[72px] border-b border-r bg-muted/20" />
))}
{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 (
<button
key={key}
onClick={() => setSelected(key)}
className={cn(
"min-h-[72px] border-b border-r p-1 text-left align-top transition-colors hover:bg-accent/50",
isSelected && "bg-accent/60",
)}
>
<span
className={cn(
"inline-flex h-5 w-5 items-center justify-center rounded-full text-xs",
isToday ? "bg-[hsl(var(--leaf))] text-white font-semibold" : "text-muted-foreground",
)}
>
{day}
</span>
<div className="mt-0.5 space-y-0.5">
{dayTasks.slice(0, 3).map((t) => (
<div
key={`${t.id}-${key}`}
className={cn("truncate rounded px-1 py-px text-[10px] leading-4", CATEGORY_STYLES[t.category])}
>
{t.title}
</div>
))}
{dayTasks.length > 3 && (
<div className="text-[10px] text-muted-foreground px-1">+{dayTasks.length - 3} more</div>
)}
</div>
</button>
);
})}
</div>
</div>
{selected && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
{new Date(`${selected}T00:00:00`).toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" })}
</h3>
{selectedTasks.length === 0 ? (
<p className="text-sm text-muted-foreground border rounded-lg px-4 py-3">Nothing scheduled.</p>
) : (
<div className="rounded-lg border bg-card divide-y">
{selectedTasks.map((t) => {
const isCurrentDue = iso(new Date(t.nextDue)) === selected;
return (
<div key={t.id} className="flex items-center gap-3 px-4 py-2.5">
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", CATEGORY_STYLES[t.category])}>
{t.category.toLowerCase()}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{t.title}</p>
<p className="text-xs text-muted-foreground truncate">
{describeRecurrence(t.recurrence, t.intervalDays, t.month)}
{t.linkedTo && ` · ${t.linkedTo}`}
</p>
</div>
{isCurrentDue && <CompleteTaskButton taskId={t.id} taskTitle={t.title} />}
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { import {
LayoutDashboard, Leaf, BookOpen, Package, PawPrint, MapPin, Bell, 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"; } from "lucide-react";
export type NavItem = { label: string; href: string; icon: LucideIcon; section?: string }; 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: "Animals", href: "/animals", icon: PawPrint },
{ label: "Locations", href: "/locations", icon: MapPin }, { label: "Locations", href: "/locations", icon: MapPin },
{ label: "Reminders", href: "/tasks", icon: Bell }, { label: "Reminders", href: "/tasks", icon: Bell },
{ label: "Calendar", href: "/calendar", icon: CalendarDays },
]; ];
export const kitchenItems: NavItem[] = [ export const kitchenItems: NavItem[] = [

View File

@@ -8,6 +8,15 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: 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", version: "0.25.0",
date: "2026-07-06", date: "2026-07-06",

View File

@@ -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);
});
});

10
src/lib/garden/season.ts Normal file
View File

@@ -0,0 +1,10 @@
// Season-window math for PlantType bloom/harvest month ranges (112).
// Windows may wrap the year end (e.g. OctMar).
/** Is `month` (112) 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;
}

View File

@@ -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>): 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([]);
});
});

View File

@@ -74,3 +74,90 @@ export const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June", "January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December", "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; // 112, 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());
}

View File

@@ -1,2 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`. // 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";