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:
parent
515e93aae1
commit
5863c8c2e6
1 changed files with 264 additions and 105 deletions
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useFamily } from "../FamilyProvider";
|
||||
import { getGuideline, getAgeInMonths } from "@/lib/guidelines";
|
||||
|
||||
type ViewMode = "timeline" | "calendar";
|
||||
type LogType = "feed" | "sleep" | "diaper";
|
||||
|
|
@ -16,39 +17,218 @@ interface Log {
|
|||
loggedAt: string;
|
||||
}
|
||||
|
||||
interface Child {
|
||||
id: string;
|
||||
name: string;
|
||||
birthDate: string;
|
||||
}
|
||||
|
||||
import { getGuideline, getAgeInMonths, guidelines } from "@/lib/guidelines";
|
||||
|
||||
interface DayLogs {
|
||||
date: string;
|
||||
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() {
|
||||
const { child, childId: providerChildId, familyId } = useFamily();
|
||||
const { child, childId: providerChildId } = useFamily();
|
||||
const [view, setView] = useState<ViewMode>("timeline");
|
||||
const [logs, setLogs] = useState<Log[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<LogType | "all">("all");
|
||||
const [showSuggested, setShowSuggested] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const childId = providerChildId || "default";
|
||||
const childId = providerChildId || "";
|
||||
|
||||
useEffect(() => {
|
||||
if (providerChildId) {
|
||||
fetchLogs();
|
||||
}
|
||||
if (providerChildId) fetchLogs();
|
||||
}, [providerChildId]);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
if (!childId) return;
|
||||
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();
|
||||
setLogs(data.entries || []);
|
||||
} catch (err) {
|
||||
|
|
@ -67,81 +247,69 @@ export default function ActivityPage() {
|
|||
body: JSON.stringify({ childId: child.id, birthDate: child.birthDate }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
fetchLogs();
|
||||
}
|
||||
if (data.success) fetchLogs();
|
||||
} catch (err) {
|
||||
console.error("Failed to generate:", err);
|
||||
}
|
||||
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 date = new Date(log.loggedAt).toDateString();
|
||||
const existing = acc.find((d) => d.date === date);
|
||||
if (existing) {
|
||||
existing.logs.push(log);
|
||||
} else {
|
||||
acc.push({ date, logs: [log] });
|
||||
}
|
||||
const existing = acc.find(d => d.date === date);
|
||||
if (existing) existing.logs.push(log);
|
||||
else acc.push({ date, logs: [log] });
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const getIcon = (type: LogType) => {
|
||||
switch (type) {
|
||||
case "feed": return "🍼";
|
||||
case "sleep": return "😴";
|
||||
case "diaper": return "👶";
|
||||
default: 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">
|
||||
{/* Header */}
|
||||
<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>
|
||||
<h1 className="text-xl font-bold">Activity</h1>
|
||||
{child && logs.length === 0 && (
|
||||
{child && logs.length === 0 && !loading && (
|
||||
<button
|
||||
onClick={generateHistory}
|
||||
disabled={generating}
|
||||
className="text-xs px-2 py-1 bg-rose-400 text-white rounded-full"
|
||||
>
|
||||
{generating ? "..." : "Generate History"}
|
||||
{generating ? "…" : "Generate History"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* View Toggle */}
|
||||
{/* View toggle */}
|
||||
<div className="flex bg-white dark:bg-gray-800 rounded-lg p-1">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
{/* Filter pills */}
|
||||
<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
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-4 py-2 rounded-full text-sm whitespace-nowrap ${
|
||||
filter === f
|
||||
? "bg-rose-400 text-white"
|
||||
: "bg-white dark:bg-gray-800"
|
||||
className={`px-4 py-2 rounded-full text-sm whitespace-nowrap transition-colors ${
|
||||
filter === f ? "bg-rose-400 text-white" : "bg-white dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "All" : f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
|
|
@ -149,7 +317,7 @@ export default function ActivityPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Guidelines Card */}
|
||||
{/* Guidelines card */}
|
||||
{child && showSuggested && (
|
||||
<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">
|
||||
{child.name} · {ageMonths} months old
|
||||
</div>
|
||||
<button onClick={() => setShowSuggested(false)} className="text-gray-400">✕</button>
|
||||
<button onClick={() => setShowSuggested(false)} className="text-gray-400 text-sm">✕</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<div className="text-center">
|
||||
|
|
@ -186,40 +354,40 @@ export default function ActivityPage() {
|
|||
{/* Content */}
|
||||
<div className="px-4 pb-20">
|
||||
{loading ? (
|
||||
<div className="text-center py-20 text-gray-400">Loading...</div>
|
||||
) : view === "timeline" ? (
|
||||
/* Timeline View */
|
||||
groupedByDay.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-400">Loading…</div>
|
||||
) : view === "calendar" ? (
|
||||
<CalendarView logs={logs} filter={filter} />
|
||||
) : groupedByDay.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-400">
|
||||
<div className="text-6xl mb-4">📊</div>
|
||||
<p>No activity yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{groupedByDay.map((day) => (
|
||||
{groupedByDay.map(day => (
|
||||
<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", {
|
||||
weekday: "long",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
weekday: "long", month: "short", day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{day.logs
|
||||
.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">
|
||||
<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="text-sm text-gray-500">
|
||||
{log.subType && `${log.subType} · `}
|
||||
{log.amount && `${log.amount}ml`}
|
||||
{log.notes && ` · ${log.notes}`}
|
||||
<div className="text-sm 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-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" })}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -228,15 +396,6 @@ export default function ActivityPage() {
|
|||
</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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue