tia/src/app/(app)/activity/page.tsx
Mannu 2a09c027fa feat(marketing): public homepage replacing / → /login redirect
- Add (marketing) route group: /, /pricing, /privacy, /terms
- Add (app) route group: moves all authenticated pages, app home → /home
- Root / is now a static marketing page (zero DB imports, zero auth)
- NavAuthButton client component: shows "Open Tia →" if logged in, else "Continue with Google"
- Plausible analytics hook in marketing layout
- Auto-generated OG image via opengraph-image.tsx
- Middleware updated to allowlist marketing routes
- All /-redirects updated to /home (login, onboarding, invite, circle join)
- BottomNav home tab updated: / → /home

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

586 lines
26 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 "@/app/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<{ date: string; type: LogType } | 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 row independently tappable */}
<div className="grid grid-cols-4 gap-2 px-4 mb-3">
{[...last4Days].reverse().map(d => {
const isToday = d.label === "Today";
const rows: { type: LogType; icon: string; count: number }[] = [
{ type: "feed", icon: "🍼", count: d.feed },
{ type: "sleep", icon: "😴", count: d.sleep },
{ type: "diaper", icon: "🚼", count: d.diaper },
];
return (
<div
key={d.label}
className={`rounded-xl shadow-sm overflow-hidden ${
isToday ? "bg-rose-400" : "bg-white dark:bg-gray-800"
}`}
>
{/* Day label — non-interactive header */}
<div className={`text-xs font-semibold text-center py-1.5 ${
isToday ? "text-rose-100" : "text-gray-500 dark:text-gray-400"
}`}>
{d.label}
</div>
{/* Each row is its own tap target */}
{rows.map((row, idx) => (
<button
key={row.type}
onClick={() => setDaySheetDate({ date: d.date, type: row.type })}
className={`w-full flex items-center justify-center py-1.5 text-xs transition-all active:opacity-60 ${
isToday ? "border-t border-rose-300" : "border-t border-gray-100 dark:border-gray-700"
} ${
isToday
? "text-white hover:bg-white/20"
: row.count === 0
? "text-gray-300 dark:text-gray-600 hover:bg-rose-50 dark:hover:bg-gray-700"
: "text-gray-700 dark:text-gray-200 hover:bg-rose-50 dark:hover:bg-gray-700"
}`}
>
{row.icon}×{row.count}
</button>
))}
</div>
);
})}
</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="group w-full flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-xl text-left transition-all active:scale-[0.98] hover:bg-rose-50/70 dark:hover:bg-gray-700/80 hover:shadow-sm"
>
<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-1.5 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 dark:text-gray-600 group-hover:text-rose-400 transition-colors text-base leading-none"></span>
</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-type detail sheet — tap a specific row (feed/sleep/diaper) on a day chip */}
{daySheetDate && (() => {
const { date: sheetDateStr, type: sheetType } = daySheetDate;
const sheetLogs = logs
.filter(l =>
new Date(l.loggedAt).toDateString() === sheetDateStr &&
l.type === sheetType
)
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime());
const sheetDayLabel = formatDayLabel(sheetDateStr);
const sheetTypeIcon = getIcon(sheetType);
const sheetTypeLabel = sheetType.charAt(0).toUpperCase() + sheetType.slice(1);
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 className="flex items-center gap-2">
<span className="text-2xl">{sheetTypeIcon}</span>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">
{sheetTypeLabel} · {sheetDayLabel}
</h3>
<p className="text-xs text-gray-400 mt-0.5">
{sheetLogs.length === 0
? "Nothing logged — tap + to add"
: `${sheetLogs.length} entr${sheetLogs.length === 1 ? "y" : "ies"} · tap to edit or delete`}
</p>
</div>
</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-4xl mb-2">{sheetTypeIcon}</p>
<p className="text-sm font-medium">No {sheetType} logged for {sheetDayLabel}</p>
<p className="text-xs mt-1 text-gray-300">
Tap + below to add one, or use Generate sample history
</p>
</div>
) : (
sheetLogs.map(log => (
<button
key={log.id}
onClick={() => { setSelectedLog(log); setDeleteConfirm(false); setDaySheetDate(null); }}
className="group w-full flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-xl text-left transition-all active:scale-[0.98] hover:bg-rose-50 dark:hover:bg-gray-700 hover:shadow-sm"
>
<span className="text-2xl">{sheetTypeIcon}</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 dark:text-gray-600 group-hover:text-rose-400 transition-colors text-lg"></span>
</div>
</button>
))
)}
</div>
{/* Single add button for this specific type */}
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-800">
<button
onClick={() => { setDaySheetDate(null); setModalType(sheetType as ModalLogType); }}
className="w-full flex items-center justify-center gap-2 py-3 bg-rose-400 text-white rounded-xl font-medium"
>
<span>{sheetTypeIcon}</span>
<span>+ Add {sheetTypeLabel}</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>
);
}