- getCareDueWidgetRows query: schedule rows due within N days, filtered by optional container list, ordered by next_due_at - getGardenOverviewStats query: plant/container/overdue counts + next care - CareDueWidget: compact list sorted by urgency, inline log-care button, truncates at 10 rows with View all link - GardenOverviewWidget: stat row (plants, containers, overdue) + next care line + garden link - plant-widget.tsx server component: InlineLogButton uses form action to call logCare without client JS or navigation - manifest: garden.care-due and garden.overview widgets registered; resolveConfigOptions returns container list for care-due config picker - quickAdds: icons updated (leaf/box/droplets), log-care URL -> /garden?logCare=1
125 lines
4.4 KiB
TypeScript
125 lines
4.4 KiB
TypeScript
import Link from "next/link";
|
|
import { logCare } from "../server/actions";
|
|
import type { CareDueWidgetRow, GardenOverviewStats } from "../server/queries";
|
|
|
|
const CARE_ICONS: Record<string, string> = {
|
|
watering: "💧",
|
|
fertilizing: "🌱",
|
|
repotting: "🪴",
|
|
pruning: "✂️",
|
|
"pest-control": "🐛",
|
|
other: "📋",
|
|
};
|
|
|
|
function urgencyLabel(days: number): { text: string; cls: string } {
|
|
if (days < 0)
|
|
return { text: `${Math.abs(days)}d overdue`, cls: "text-red-600 dark:text-red-400" };
|
|
if (days === 0) return { text: "due today", cls: "text-amber-600 dark:text-amber-400" };
|
|
return { text: `in ${days}d`, cls: "text-[var(--ink-mute)]" };
|
|
}
|
|
|
|
// ─── Care-due widget ──────────────────────────────────────────────────────────
|
|
|
|
export function CareDueWidget({ rows }: { rows: CareDueWidgetRow[] }) {
|
|
if (rows.length === 0) {
|
|
return <p className="text-sm text-[var(--ink-mute)]">All plants are on schedule.</p>;
|
|
}
|
|
|
|
const displayed = rows.slice(0, 10);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
{displayed.map((row) => {
|
|
const { text, cls } = urgencyLabel(row.daysUntilDue);
|
|
const icon = CARE_ICONS[row.careType] ?? "📋";
|
|
return (
|
|
<div key={row.scheduleId} className="flex items-center gap-2 py-1">
|
|
<span className="text-base leading-none" aria-hidden>
|
|
{icon}
|
|
</span>
|
|
<Link
|
|
href={`/garden/plants/${row.plantId}`}
|
|
className="text-sm flex-1 truncate hover:underline"
|
|
>
|
|
{row.plantName}
|
|
</Link>
|
|
<span className={`text-xs shrink-0 ${cls}`}>{text}</span>
|
|
<InlineLogButton plantId={row.plantId} careType={row.careType} />
|
|
</div>
|
|
);
|
|
})}
|
|
{rows.length > 10 && (
|
|
<Link href="/garden" className="text-xs text-[var(--ink-mute)] hover:underline mt-1">
|
|
View all ({rows.length}) →
|
|
</Link>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InlineLogButton({ plantId, careType }: { plantId: string; careType: string }) {
|
|
async function action() {
|
|
"use server";
|
|
await logCare({ plantId, careType });
|
|
}
|
|
return (
|
|
<form action={action}>
|
|
<button
|
|
type="submit"
|
|
title={`Log ${careType}`}
|
|
className="text-xs text-[var(--ink-mute)] hover:text-[var(--ink)] px-1"
|
|
>
|
|
✓
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
// ─── Overview widget ──────────────────────────────────────────────────────────
|
|
|
|
export function GardenOverviewWidget({ stats }: { stats: GardenOverviewStats }) {
|
|
function nextCareLabel(): string {
|
|
const n = stats.nextCare;
|
|
if (!n) return "No upcoming care scheduled.";
|
|
const dayText =
|
|
n.daysUntilDue < 0
|
|
? "overdue"
|
|
: n.daysUntilDue === 0
|
|
? "today"
|
|
: `in ${n.daysUntilDue} day${n.daysUntilDue !== 1 ? "s" : ""}`;
|
|
const careLabel = n.careType.charAt(0).toUpperCase() + n.careType.slice(1);
|
|
return `${careLabel} ${n.plantName} — ${dayText}`;
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex gap-4">
|
|
<div className="flex flex-col items-center">
|
|
<span className="text-2xl font-bold leading-none">{stats.plantCount}</span>
|
|
<span className="text-xs text-[var(--ink-mute)] mt-0.5">
|
|
{stats.plantCount === 1 ? "plant" : "plants"}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col items-center">
|
|
<span className="text-2xl font-bold leading-none">{stats.containerCount}</span>
|
|
<span className="text-xs text-[var(--ink-mute)] mt-0.5">
|
|
{stats.containerCount === 1 ? "container" : "containers"}
|
|
</span>
|
|
</div>
|
|
{stats.overdueCount > 0 && (
|
|
<div className="flex flex-col items-center">
|
|
<span className="text-2xl font-bold leading-none text-red-500">
|
|
{stats.overdueCount}
|
|
</span>
|
|
<span className="text-xs text-[var(--ink-mute)] mt-0.5">overdue</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-[var(--ink-mute)]">{nextCareLabel()}</p>
|
|
<Link href="/garden" className="text-xs text-[var(--ink-mute)] hover:underline self-start">
|
|
View garden →
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|