tia/src/app/activity/page.tsx
Mannu 3db6fb2710 Activity: guidelines above strip, interactive day chips
- Moved guidelines row ABOVE the 4-day strip (correct order)
- 4-day strip: each chip is now tappable
  → opens a day-detail bottom sheet showing all logs for that day
  → each log row has ‹ › arrow; tap opens edit/delete action sheet
  → empty state tells mama to use Generate sample history to pre-fill
  → quick-add row at bottom (+ Feed / + Sleep / + Diaper) for fast logging

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 23:47:20 +05:30

548 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useFamily } from "../FamilyProvider";
import { getGuideline, getAgeInMonths } from "@/lib/guidelines";
import { api } from "@/lib/api";
import { CalendarView } from "@/components/CalendarView";
import { LogModal, type LogType as ModalLogType, type SmartDefault } from "@/components/LogModal";
import type { Log, LogType } from "@/types";
type ViewMode = "timeline" | "calendar";
interface DayLogs {
date: string;
logs: Log[];
}
function getIcon(type: LogType) {
if (type === "feed") return "🍼";
if (type === "sleep") return "😴";
if (type === "diaper") return "🚼";
return "📝";
}
function formatDayLabel(dateStr: string): string {
const d = new Date(dateStr);
const today = new Date().toDateString();
const yesterday = new Date(Date.now() - 86_400_000).toDateString();
if (d.toDateString() === today) return "Today";
if (d.toDateString() === yesterday) return "Yesterday";
return d.toLocaleDateString("en-IN", { weekday: "short", month: "short", day: "numeric" });
}
export default function ActivityPage() {
const router = useRouter();
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 [guideExpanded, setGuideExpanded] = useState(false);
const [generating, setGenerating] = useState(false);
const [fabOpen, setFabOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [modalType, setModalType] = useState<ModalLogType | null>(null);
const [selectedLog, setSelectedLog] = useState<Log | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [smartDefault, setSmartDefault] = useState<SmartDefault | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<{ id: string; type: string } | null>(null);
const [daySheetDate, setDaySheetDate] = useState<string | null>(null);
const childId = providerChildId ?? "";
useEffect(() => {
if (providerChildId) fetchLogs();
}, [providerChildId]);
const fetchLogs = async () => {
if (!childId) return;
try {
const data = await api.get<{ entries: Log[] }>(`/api/logs?childId=${childId}&limit=200`);
setLogs(data.entries || []);
} catch (err) {
console.error("Failed to fetch logs:", err);
}
setLoading(false);
};
const generateHistory = async () => {
if (!child) return;
setMenuOpen(false);
setGenerating(true);
try {
const data = await api.post<{ success: boolean }>("/api/history", { childId: child.id, birthDate: child.birthDate });
if (data.success) fetchLogs();
} catch (err) {
console.error("Failed to generate history:", err);
}
setGenerating(false);
};
const deleteLog = async (log: Log) => {
try {
await fetch(`/api/logs/${log.id}?type=${log.type}`, { method: "DELETE" });
setSelectedLog(null);
setDeleteConfirm(false);
fetchLogs();
} catch (err) {
console.error("Failed to delete log:", err);
}
};
const handleEdit = (log: Log) => {
// Store the old log to delete after the new one is saved
setPendingDeleteId({ id: log.id, type: log.type });
setSmartDefault({ subType: log.subType ?? "", amountMl: log.amount ?? undefined } as SmartDefault);
setModalType(log.type as ModalLogType);
setSelectedLog(null);
};
// Today's stats (computed from loaded logs — no extra fetch)
const todayStr = new Date().toDateString();
const todayLogs = logs.filter(l => new Date(l.loggedAt).toDateString() === todayStr);
const todayCounts = {
feed: todayLogs.filter(l => l.type === "feed").length,
sleep: todayLogs.filter(l => l.type === "sleep").length,
diaper: todayLogs.filter(l => l.type === "diaper").length,
};
// Last 4 calendar days (computed from loaded logs — no extra fetch)
const last4Days = Array.from({ length: 4 }, (_, i) => {
const d = new Date(Date.now() - i * 86_400_000);
const dateStr = d.toDateString();
const dayLogs = logs.filter(l => new Date(l.loggedAt).toDateString() === dateStr);
return {
date: dateStr,
label: i === 0 ? "Today" : i === 1 ? "Yest." : d.toLocaleDateString("en-IN", { weekday: "short" }),
feed: dayLogs.filter(l => l.type === "feed").length,
sleep: dayLogs.filter(l => l.type === "sleep").length,
diaper: dayLogs.filter(l => l.type === "diaper").length,
};
});
const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter);
const groupedByDay = filteredLogs.reduce<DayLogs[]>((acc, 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] });
return acc;
}, []);
const guide = child ? getGuideline(child.birthDate) : null;
const ageMonths = child ? getAgeInMonths(child.birthDate) : 0;
const guideItems = guide ? [
{ icon: "🍼", label: "Feeds/day", count: todayCounts.feed, target: guide.feeds.times, barClass: "bg-rose-400", textClass: "text-rose-600 dark:text-rose-400" },
{ icon: "😴", label: "Sleep (hrs)", count: todayCounts.sleep, target: guide.sleep.totalHours, barClass: "bg-amber-400", textClass: "text-amber-600 dark:text-amber-400" },
{ icon: "🚼", label: "Diapers", count: todayCounts.diaper, target: guide.diapers.count, barClass: "bg-blue-400", textClass: "text-blue-600 dark:text-blue-400" },
] : [];
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-3">
<button
onClick={() => router.back()}
className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"
></button>
<h1 className="text-xl font-bold">Activity</h1>
</div>
<div className="flex items-center gap-2">
{/* ⋯ overflow menu */}
<div className="relative">
<button
onClick={() => setMenuOpen(o => !o)}
className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-lg leading-none"
></button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 top-10 z-50 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700 min-w-[200px]">
<button
onClick={generateHistory}
disabled={generating}
className="w-full text-left px-4 py-3 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 rounded-xl disabled:opacity-50"
>
{generating ? "Generating…" : "📋 Generate sample history"}
</button>
</div>
</>
)}
</div>
{/* View toggle */}
<div className="flex bg-white dark:bg-gray-800 rounded-lg p-1">
{(["timeline", "calendar"] as const).map(v => (
<button
key={v}
onClick={() => setView(v)}
className={`px-3 py-1 rounded-md text-sm transition-colors ${
view === v ? "bg-rose-400 text-white" : "text-gray-600 dark:text-gray-400"
}`}
>
{v === "timeline" ? "📋" : "📅"}
</button>
))}
</div>
</div>
</div>
{/* Filter pills */}
<div className="px-4 mb-3 flex gap-2 overflow-x-auto scrollbar-hide pb-1">
{([
{ value: "all", label: "All" },
{ value: "feed", label: "🍼 Feed" },
{ value: "sleep", label: "😴 Sleep" },
{ value: "diaper", label: "🚼 Diaper" },
] as { value: LogType | "all"; label: string }[]).map(f => (
<button
key={f.value}
onClick={() => setFilter(f.value)}
className={`px-4 py-2 rounded-full text-sm whitespace-nowrap flex-shrink-0 transition-colors ${
filter === f.value ? "bg-rose-400 text-white" : "bg-white dark:bg-gray-800"
}`}
>
{f.label}
</button>
))}
</div>
{!loading && (
<>
{/* Collapsible guidelines card — above strip */}
{child && guide && showSuggested && (
<div className="mx-4 mb-3">
<button
onClick={() => setGuideExpanded(v => !v)}
className="w-full flex items-center justify-between px-4 py-2.5 bg-white dark:bg-gray-800 rounded-2xl shadow-sm"
>
<span className="text-sm text-gray-500 dark:text-gray-400 text-left">
📋 {child.name} · {ageMonths}mo &nbsp;·&nbsp;
🍼 {todayCounts.feed}/{guide.feeds.times} &nbsp;·&nbsp;
😴 {todayCounts.sleep}/{guide.sleep.totalHours}h &nbsp;·&nbsp;
🚼 {todayCounts.diaper}/{guide.diapers.count}
</span>
<span className={`text-gray-400 text-xs ml-2 flex-shrink-0 transition-transform ${guideExpanded ? "rotate-180" : ""}`}></span>
</button>
{guideExpanded && (
<div className="mt-2 p-4 bg-gradient-to-r from-rose-100 to-amber-100 dark:from-rose-900/40 dark:to-amber-900/40 rounded-2xl">
<div className="flex justify-end mb-2">
<button onClick={() => setShowSuggested(false)} className="text-gray-400 text-sm"> Hide</button>
</div>
<div className="grid grid-cols-3 gap-3">
{guideItems.map(item => (
<div key={item.label} className="text-center">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">{item.label}</div>
<div className={`text-lg font-bold ${item.textClass}`}>
{item.count}
<span className="text-sm font-normal text-gray-400">/{item.target}</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 mt-1">
<div
className={`${item.barClass} h-1.5 rounded-full transition-all`}
style={{ width: `${Math.min(100, item.target > 0 ? (item.count / item.target) * 100 : 0)}%` }}
/>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* 4-day overview strip — oldest → newest, each chip tappable */}
<div className="grid grid-cols-4 gap-2 px-4 mb-3">
{[...last4Days].reverse().map(d => {
const isToday = d.label === "Today";
return (
<button
key={d.label}
onClick={() => setDaySheetDate(d.date)}
className={`rounded-xl px-2 py-2 shadow-sm text-center active:scale-95 transition-transform ${
isToday ? "bg-rose-400 text-white" : "bg-white dark:bg-gray-800"
}`}
>
<p className={`text-xs font-semibold mb-1 ${isToday ? "text-rose-100" : "text-gray-500 dark:text-gray-400"}`}>
{d.label}
</p>
<p className={`text-xs ${isToday ? "text-white" : "text-gray-600 dark:text-gray-300"}`}>🍼×{d.feed}</p>
<p className={`text-xs ${isToday ? "text-white" : "text-gray-600 dark:text-gray-300"}`}>😴×{d.sleep}</p>
<p className={`text-xs ${isToday ? "text-white" : "text-gray-600 dark:text-gray-300"}`}>🚼×{d.diaper}</p>
</button>
);
})}
</div>
</>
)}
{/* Content */}
<div className="px-4 pb-24">
{loading ? (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<div className="flex gap-3 text-3xl">
{["🍼", "😴", "🚼"].map((e, i) => (
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
))}
</div>
<p className="text-sm text-gray-400">Loading activity</p>
</div>
) : view === "calendar" ? (
<CalendarView logs={logs} filter={filter} />
) : groupedByDay.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center px-8">
<span className="text-5xl mb-4">📋</span>
<p className="font-semibold text-gray-600 dark:text-gray-300">
No {filter !== "all" ? filter : ""} logs yet
</p>
<p className="text-sm text-gray-400 mt-1">Tap + to start logging</p>
</div>
) : (
<div className="space-y-6">
{groupedByDay.map(day => {
const label = formatDayLabel(day.date);
const isToday = label === "Today";
return (
<div key={day.date}>
<div className={`text-sm mb-2 ${isToday ? "text-rose-500 font-semibold" : "font-medium text-gray-500 dark:text-gray-400"}`}>
{label}
</div>
<div className="space-y-2">
{day.logs
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime())
.map(log => (
<button
key={log.id}
onClick={() => { setSelectedLog(log); setDeleteConfirm(false); }}
className="w-full flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-xl text-left active:scale-[0.98] transition-transform"
>
<span className="text-2xl">{getIcon(log.type)}</span>
<div className="flex-1 min-w-0">
<div className="font-medium capitalize">{log.type}</div>
<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 flex-shrink-0">
{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</div>
</button>
))}
</div>
</div>
);
})}
</div>
)}
</div>
{/* FAB */}
<div className="fixed bottom-20 right-5 flex flex-col items-end gap-2 z-40">
{fabOpen && (["feed", "sleep", "diaper"] as ModalLogType[]).map(t => (
<button
key={t}
onClick={() => { setFabOpen(false); setModalType(t); }}
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 rounded-full shadow-lg text-sm font-medium capitalize"
>
<span>{t === "feed" ? "🍼" : t === "sleep" ? "😴" : "🚼"}</span>
{t}
</button>
))}
<button
onClick={() => setFabOpen(o => !o)}
className="w-14 h-14 bg-rose-400 text-white rounded-full shadow-lg flex items-center justify-center text-2xl transition-transform"
style={{ transform: fabOpen ? "rotate(45deg)" : "rotate(0deg)" }}
>
+
</button>
</div>
{fabOpen && <div className="fixed inset-0 z-30" onClick={() => setFabOpen(false)} />}
{/* Log action sheet */}
{selectedLog && (
<>
<div
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
onClick={() => { setSelectedLog(null); setDeleteConfirm(false); }}
/>
<div className="fixed bottom-0 inset-x-0 z-50 bg-white dark:bg-gray-900 rounded-t-2xl p-4 pb-10 shadow-xl">
{/* Summary row */}
<div className="flex items-center gap-3 mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-xl">
<span className="text-2xl">{getIcon(selectedLog.type)}</span>
<div className="flex-1">
<div className="font-medium capitalize">{selectedLog.type}</div>
<div className="text-sm text-gray-500">
{[
selectedLog.subType?.replace(/_/g, " "),
selectedLog.amount ? `${selectedLog.amount}ml` : null,
new Date(selectedLog.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
].filter(Boolean).join(" · ")}
</div>
</div>
</div>
{!deleteConfirm ? (
<div className="space-y-2">
<button
onClick={() => handleEdit(selectedLog)}
className="w-full flex items-center gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-800 rounded-xl text-left"
>
<span className="text-lg"></span>
<div>
<div className="font-medium">Edit</div>
<div className="text-xs text-gray-400">Pre-fills a new log with same values</div>
</div>
</button>
<button
onClick={() => setDeleteConfirm(true)}
className="w-full flex items-center gap-3 px-4 py-3 bg-red-50 dark:bg-red-900/20 text-red-500 rounded-xl text-left"
>
<span className="text-lg">🗑</span>
<span className="font-medium">Delete</span>
</button>
<button
onClick={() => setSelectedLog(null)}
className="w-full py-3 text-gray-400 text-sm text-center"
>
Cancel
</button>
</div>
) : (
<div className="space-y-2">
<p className="text-center text-sm text-gray-600 dark:text-gray-300 py-2">
Delete this {selectedLog.type} log?
</p>
<button
onClick={() => deleteLog(selectedLog)}
className="w-full py-3 bg-red-500 text-white rounded-xl font-medium"
>
Yes, delete
</button>
<button
onClick={() => setDeleteConfirm(false)}
className="w-full py-3 text-gray-400 text-sm text-center"
>
Cancel
</button>
</div>
)}
</div>
</>
)}
{/* Day detail sheet — tap a day chip to view & edit that day's logs */}
{daySheetDate && (() => {
const sheetLogs = logs
.filter(l => new Date(l.loggedAt).toDateString() === daySheetDate)
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime());
const sheetLabel = formatDayLabel(daySheetDate);
return (
<>
<div
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
onClick={() => setDaySheetDate(null)}
/>
<div className="fixed bottom-0 inset-x-0 z-50 bg-white dark:bg-gray-900 rounded-t-2xl shadow-xl flex flex-col max-h-[75vh]">
{/* Sheet header */}
<div className="flex items-center justify-between px-4 pt-4 pb-3 border-b border-gray-100 dark:border-gray-800">
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">{sheetLabel}</h3>
<p className="text-xs text-gray-400 mt-0.5">
{sheetLogs.length} log{sheetLogs.length !== 1 ? "s" : ""} · tap any entry to edit or delete
</p>
</div>
<button
onClick={() => setDaySheetDate(null)}
className="p-2 text-gray-400 text-lg"
></button>
</div>
{/* Log list */}
<div className="overflow-y-auto flex-1 p-4 space-y-2">
{sheetLogs.length === 0 ? (
<div className="text-center py-10 text-gray-400">
<p className="text-3xl mb-2">📋</p>
<p className="text-sm font-medium">No logs for {sheetLabel}</p>
<p className="text-xs mt-1 text-gray-300">Use Generate sample history to pre-fill your schedule</p>
</div>
) : (
sheetLogs.map(log => (
<button
key={log.id}
onClick={() => { setSelectedLog(log); setDeleteConfirm(false); setDaySheetDate(null); }}
className="w-full flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-xl text-left active:scale-[0.98] transition-transform"
>
<span className="text-2xl">{getIcon(log.type)}</span>
<div className="flex-1 min-w-0">
<div className="font-medium capitalize">{log.type}</div>
<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="flex items-center gap-2 flex-shrink-0">
<span className="text-sm text-gray-400">
{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</span>
<span className="text-gray-300 text-lg"></span>
</div>
</button>
))
)}
</div>
{/* Quick-add row */}
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-800 flex gap-2">
{(["feed", "sleep", "diaper"] as ModalLogType[]).map(t => (
<button
key={t}
onClick={() => { setDaySheetDate(null); setModalType(t); }}
className="flex-1 flex flex-col items-center gap-0.5 py-2 bg-gray-50 dark:bg-gray-800 rounded-xl text-sm"
>
<span className="text-lg">{t === "feed" ? "🍼" : t === "sleep" ? "😴" : "🚼"}</span>
<span className="text-xs text-gray-500 capitalize">+ {t}</span>
</button>
))}
</div>
</div>
</>
);
})()}
<LogModal
type={modalType}
childId={childId}
onClose={() => { setModalType(null); setSmartDefault(null); setPendingDeleteId(null); }}
onSaved={async () => {
// If editing, delete the old log after the new one is saved
if (pendingDeleteId) {
try {
await fetch(`/api/logs/${pendingDeleteId.id}?type=${pendingDeleteId.type}`, { method: "DELETE" });
} catch (err) {
console.error("Failed to delete old log during edit:", err);
}
setPendingDeleteId(null);
}
setSmartDefault(null);
fetchLogs();
}}
smartDefault={smartDefault}
/>
</div>
);
}