feat(activity): implement calendar view

Month grid with Su–Sa columns, colored activity dots per day (rose=feed,
blue=sleep, amber=diaper). Tapping a day opens a detail panel below the
grid with the full log list for that day. Month navigation with prev/next
arrows; future months are disabled. Filter pills apply to both views.
Limit raised to 200 entries so calendar data spans multiple months.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-18 11:04:42 +05:30
parent 515e93aae1
commit 5863c8c2e6

View file

@ -1,8 +1,9 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import { useFamily } from "../FamilyProvider"; import { useFamily } from "../FamilyProvider";
import { getGuideline, getAgeInMonths } from "@/lib/guidelines";
type ViewMode = "timeline" | "calendar"; type ViewMode = "timeline" | "calendar";
type LogType = "feed" | "sleep" | "diaper"; type LogType = "feed" | "sleep" | "diaper";
@ -16,39 +17,218 @@ interface Log {
loggedAt: string; loggedAt: string;
} }
interface Child {
id: string;
name: string;
birthDate: string;
}
import { getGuideline, getAgeInMonths, guidelines } from "@/lib/guidelines";
interface DayLogs { interface DayLogs {
date: string; date: string;
logs: Log[]; logs: Log[];
} }
const TYPE_COLORS: Record<LogType, string> = {
feed: "bg-rose-400",
sleep: "bg-blue-400",
diaper: "bg-amber-400",
};
function getIcon(type: LogType) {
if (type === "feed") return "🍼";
if (type === "sleep") return "😴";
if (type === "diaper") return "👶";
return "📝";
}
// ─── Calendar view ────────────────────────────────────────────────────────────
function CalendarView({ logs, filter }: { logs: Log[]; filter: LogType | "all" }) {
const now = new Date();
const [calMonth, setCalMonth] = useState(new Date(now.getFullYear(), now.getMonth(), 1));
const [selectedDay, setSelectedDay] = useState<string | null>(
now.toISOString().slice(0, 10)
);
const year = calMonth.getFullYear();
const month = calMonth.getMonth();
const today = now.toISOString().slice(0, 10);
const isCurrentMonth = year === now.getFullYear() && month === now.getMonth();
const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter);
// Group logs by YYYY-MM-DD
const logsByDate = useMemo(() => {
const map: Record<string, Log[]> = {};
filteredLogs.forEach(log => {
const key = new Date(log.loggedAt).toISOString().slice(0, 10);
if (!map[key]) map[key] = [];
map[key].push(log);
});
return map;
}, [filteredLogs]);
// Build grid: leading nulls for DOW offset, then day numbers
const firstDow = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells: (number | null)[] = [
...Array(firstDow).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
];
const dayKey = (d: number) =>
`${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const selectedLogs = useMemo(
() => (selectedDay ? (logsByDate[selectedDay] || [])
.slice()
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime())
: []),
[selectedDay, logsByDate]
);
return (
<div className="space-y-3">
{/* Month grid */}
<div className="bg-white dark:bg-gray-800 rounded-xl p-4">
{/* Month nav */}
<div className="flex items-center justify-between mb-4">
<button
onClick={() => setCalMonth(new Date(year, month - 1, 1))}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300 text-lg"
>
</button>
<span className="font-semibold text-gray-800 dark:text-white">
{calMonth.toLocaleDateString("en-US", { month: "long", year: "numeric" })}
</span>
<button
onClick={() => setCalMonth(new Date(year, month + 1, 1))}
disabled={isCurrentMonth}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300 text-lg disabled:opacity-30"
>
</button>
</div>
{/* DOW headers */}
<div className="grid grid-cols-7 mb-1">
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(d => (
<div key={d} className="text-center text-xs text-gray-400 font-medium py-1">{d}</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-y-1">
{cells.map((day, i) => {
if (!day) return <div key={i} />;
const key = dayKey(day);
const dayLogs = logsByDate[key] || [];
const isToday = key === today;
const isSel = key === selectedDay;
const types = [...new Set(dayLogs.map(l => l.type))] as LogType[];
return (
<button
key={i}
onClick={() => setSelectedDay(isSel ? null : key)}
className={`flex flex-col items-center justify-center py-1.5 rounded-lg transition-colors ${
isSel
? "bg-rose-400"
: isToday
? "bg-rose-50 dark:bg-rose-900/30"
: "hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
<span className={`text-sm leading-none ${
isSel ? "text-white font-semibold" :
isToday ? "text-rose-500 font-bold" :
"text-gray-700 dark:text-gray-300"
}`}>
{day}
</span>
<div className="flex gap-0.5 mt-1 h-2 items-center">
{types.slice(0, 3).map(t => (
<span
key={t}
className={`w-1.5 h-1.5 rounded-full ${isSel ? "bg-white/80" : TYPE_COLORS[t]}`}
/>
))}
</div>
</button>
);
})}
</div>
{/* Legend */}
<div className="flex justify-center gap-5 mt-4 pt-3 border-t border-gray-100 dark:border-gray-700">
{(["feed", "sleep", "diaper"] as LogType[]).map(t => (
<div key={t} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className={`w-2 h-2 rounded-full ${TYPE_COLORS[t]}`} />
<span className="capitalize">{t}</span>
</div>
))}
</div>
</div>
{/* Selected day detail */}
{selectedDay && (
<div className="bg-white dark:bg-gray-800 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-gray-800 dark:text-white">
{new Date(selectedDay + "T12:00:00").toLocaleDateString("en-US", {
weekday: "long", month: "short", day: "numeric",
})}
</h3>
<span className="text-sm text-gray-400 dark:text-gray-500">
{selectedLogs.length} {selectedLogs.length === 1 ? "entry" : "entries"}
</span>
</div>
{selectedLogs.length === 0 ? (
<p className="text-center text-gray-400 text-sm py-6">No activity logged</p>
) : (
<div className="space-y-2">
{selectedLogs.map(log => (
<div key={log.id} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-xl">
<span className="text-xl">{getIcon(log.type)}</span>
<div className="flex-1 min-w-0">
<div className="font-medium capitalize text-sm dark:text-white">{log.type}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{[
log.subType?.replace(/_/g, " "),
log.amount ? `${log.amount}ml` : null,
log.notes,
].filter(Boolean).join(" · ")}
</div>
</div>
<div className="text-xs text-gray-400 flex-shrink-0">
{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function ActivityPage() { export default function ActivityPage() {
const { child, childId: providerChildId, familyId } = useFamily(); const { child, childId: providerChildId } = useFamily();
const [view, setView] = useState<ViewMode>("timeline"); const [view, setView] = useState<ViewMode>("timeline");
const [logs, setLogs] = useState<Log[]>([]); const [logs, setLogs] = useState<Log[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<LogType | "all">("all"); const [filter, setFilter] = useState<LogType | "all">("all");
const [showSuggested, setShowSuggested] = useState(true); const [showSuggested, setShowSuggested] = useState(true);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const childId = providerChildId || "default"; const childId = providerChildId || "";
useEffect(() => { useEffect(() => {
if (providerChildId) { if (providerChildId) fetchLogs();
fetchLogs();
}
}, [providerChildId]); }, [providerChildId]);
const fetchLogs = async () => { const fetchLogs = async () => {
if (!childId) return; if (!childId) return;
try { try {
const res = await fetch(`/api/logs?childId=${childId}&limit=100`); const res = await fetch(`/api/logs?childId=${childId}&limit=200`);
const data = await res.json(); const data = await res.json();
setLogs(data.entries || []); setLogs(data.entries || []);
} catch (err) { } catch (err) {
@ -67,81 +247,69 @@ export default function ActivityPage() {
body: JSON.stringify({ childId: child.id, birthDate: child.birthDate }), body: JSON.stringify({ childId: child.id, birthDate: child.birthDate }),
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) fetchLogs();
fetchLogs();
}
} catch (err) { } catch (err) {
console.error("Failed to generate:", err); console.error("Failed to generate:", err);
} }
setGenerating(false); setGenerating(false);
}; };
const filteredLogs = filter === "all" ? logs : logs.filter((l) => l.type === filter); const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter);
const groupedByDay = filteredLogs.reduce((acc: DayLogs[], log) => { const groupedByDay = filteredLogs.reduce((acc: DayLogs[], log) => {
const date = new Date(log.loggedAt).toDateString(); const date = new Date(log.loggedAt).toDateString();
const existing = acc.find((d) => d.date === date); const existing = acc.find(d => d.date === date);
if (existing) { if (existing) existing.logs.push(log);
existing.logs.push(log); else acc.push({ date, logs: [log] });
} else {
acc.push({ date, logs: [log] });
}
return acc; return acc;
}, []); }, []);
const getIcon = (type: LogType) => {
switch (type) {
case "feed": return "🍼";
case "sleep": return "😴";
case "diaper": return "👶";
default: return "📝";
}
};
return ( return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800"> <div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
{/* Header */} {/* Header */}
<div className="p-4 flex items-center justify-between"> <div className="p-4 flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<Link href="/menu" className="p-2"></Link> <Link href="/menu" className="p-2"></Link>
<h1 className="text-xl font-bold">Activity</h1> <h1 className="text-xl font-bold">Activity</h1>
{child && logs.length === 0 && ( {child && logs.length === 0 && !loading && (
<button <button
onClick={generateHistory} onClick={generateHistory}
disabled={generating} disabled={generating}
className="text-xs px-2 py-1 bg-rose-400 text-white rounded-full" className="text-xs px-2 py-1 bg-rose-400 text-white rounded-full"
> >
{generating ? "..." : "Generate History"} {generating ? "" : "Generate History"}
</button> </button>
)} )}
</div> </div>
{/* View Toggle */} {/* View toggle */}
<div className="flex bg-white dark:bg-gray-800 rounded-lg p-1"> <div className="flex bg-white dark:bg-gray-800 rounded-lg p-1">
<button <button
onClick={() => setView("timeline")} onClick={() => setView("timeline")}
className={`px-3 py-1 rounded-md text-sm ${view === "timeline" ? "bg-rose-400 text-white" : ""}`} className={`px-3 py-1 rounded-md text-sm transition-colors ${
view === "timeline" ? "bg-rose-400 text-white" : "text-gray-600 dark:text-gray-400"
}`}
> >
Timeline Timeline
</button> </button>
<button <button
onClick={() => setView("calendar")} onClick={() => setView("calendar")}
className={`px-3 py-1 rounded-md text-sm ${view === "calendar" ? "bg-rose-400 text-white" : ""}`} className={`px-3 py-1 rounded-md text-sm transition-colors ${
view === "calendar" ? "bg-rose-400 text-white" : "text-gray-600 dark:text-gray-400"
}`}
> >
Calendar Calendar
</button> </button>
</div> </div>
</div> </div>
{/* Filter */} {/* Filter pills */}
<div className="px-4 mb-4 flex gap-2 overflow-x-auto"> <div className="px-4 mb-4 flex gap-2 overflow-x-auto">
{(["all", "feed", "sleep", "diaper"] as const).map((f) => ( {(["all", "feed", "sleep", "diaper"] as const).map(f => (
<button <button
key={f} key={f}
onClick={() => setFilter(f)} onClick={() => setFilter(f)}
className={`px-4 py-2 rounded-full text-sm whitespace-nowrap ${ className={`px-4 py-2 rounded-full text-sm whitespace-nowrap transition-colors ${
filter === f filter === f ? "bg-rose-400 text-white" : "bg-white dark:bg-gray-800"
? "bg-rose-400 text-white"
: "bg-white dark:bg-gray-800"
}`} }`}
> >
{f === "all" ? "All" : f.charAt(0).toUpperCase() + f.slice(1)} {f === "all" ? "All" : f.charAt(0).toUpperCase() + f.slice(1)}
@ -149,7 +317,7 @@ export default function ActivityPage() {
))} ))}
</div> </div>
{/* Guidelines Card */} {/* Guidelines card */}
{child && showSuggested && ( {child && showSuggested && (
<div className="px-4 mb-4"> <div className="px-4 mb-4">
{(() => { {(() => {
@ -161,7 +329,7 @@ export default function ActivityPage() {
<div className="font-medium text-rose-800 dark:text-rose-200"> <div className="font-medium text-rose-800 dark:text-rose-200">
{child.name} · {ageMonths} months old {child.name} · {ageMonths} months old
</div> </div>
<button onClick={() => setShowSuggested(false)} className="text-gray-400"></button> <button onClick={() => setShowSuggested(false)} className="text-gray-400 text-sm"></button>
</div> </div>
<div className="grid grid-cols-3 gap-2 text-sm"> <div className="grid grid-cols-3 gap-2 text-sm">
<div className="text-center"> <div className="text-center">
@ -186,40 +354,40 @@ export default function ActivityPage() {
{/* Content */} {/* Content */}
<div className="px-4 pb-20"> <div className="px-4 pb-20">
{loading ? ( {loading ? (
<div className="text-center py-20 text-gray-400">Loading...</div> <div className="text-center py-20 text-gray-400">Loading</div>
) : view === "timeline" ? ( ) : view === "calendar" ? (
/* Timeline View */ <CalendarView logs={logs} filter={filter} />
groupedByDay.length === 0 ? ( ) : groupedByDay.length === 0 ? (
<div className="text-center py-20 text-gray-400"> <div className="text-center py-20 text-gray-400">
<div className="text-6xl mb-4">📊</div> <div className="text-6xl mb-4">📊</div>
<p>No activity yet</p> <p>No activity yet</p>
</div> </div>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
{groupedByDay.map((day) => ( {groupedByDay.map(day => (
<div key={day.date}> <div key={day.date}>
<div className="text-sm font-medium text-gray-500 mb-2"> <div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
{new Date(day.date).toLocaleDateString("en-US", { {new Date(day.date).toLocaleDateString("en-US", {
weekday: "long", weekday: "long", month: "short", day: "numeric",
month: "short",
day: "numeric",
})} })}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{day.logs {day.logs
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime()) .sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime())
.map((log) => ( .map(log => (
<div key={log.id} className="flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-xl"> <div key={log.id} className="flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-xl">
<span className="text-2xl">{getIcon(log.type)}</span> <span className="text-2xl">{getIcon(log.type)}</span>
<div className="flex-1"> <div className="flex-1 min-w-0">
<div className="font-medium capitalize">{log.type}</div> <div className="font-medium capitalize">{log.type}</div>
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500 dark:text-gray-400 truncate">
{log.subType && `${log.subType} · `} {[
{log.amount && `${log.amount}ml`} log.subType?.replace(/_/g, " "),
{log.notes && ` · ${log.notes}`} log.amount ? `${log.amount}ml` : null,
log.notes,
].filter(Boolean).join(" · ")}
</div> </div>
</div> </div>
<div className="text-sm text-gray-400"> <div className="text-sm text-gray-400 flex-shrink-0">
{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} {new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</div> </div>
</div> </div>
@ -228,15 +396,6 @@ export default function ActivityPage() {
</div> </div>
))} ))}
</div> </div>
)
) : (
/* Calendar View */
<div className="bg-white dark:bg-gray-800 rounded-xl p-4">
<div className="text-center text-gray-400 py-20">
<div className="text-6xl mb-4">📅</div>
<p>Calendar view coming soon</p>
</div>
</div>
)} )}
</div> </div>
</div> </div>