Activity page overhaul — 9 UX improvements
- FAB raised to bottom-20 to clear fixed bottom nav - Branded loading: bouncing 🍼 😴 🚼 emojis - Back button: white pill with shadow (matches other pages) - Generate History moved to ⋯ overflow menu (keeps header clean) - Filter pills: emoji labels (🍼 Feed / 😴 Sleep / 🚼 Diaper) + scrollbar-hide - Daily summary bar: today's feed/diaper/sleep counts at a glance - 4-day overview strip: quick multi-day snapshot above timeline - Collapsible guidelines card: collapsed shows x/target fractions, expands to progress bars with actual/target display - Today/Yesterday/weekday labels in timeline; Today styled in rose - Better empty state with emoji and "Tap + to start logging" CTA - Tap any log row → action sheet with Edit and Delete Edit: pre-fills LogModal with same values, deletes old log on save Delete: inline confirmation (no browser confirm()), then refresh - New DELETE + PATCH handlers at /api/logs/[id] with family ownership check Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
94b80fd862
commit
164206c023
2 changed files with 456 additions and 106 deletions
|
|
@ -1,12 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import { useRouter } from "next/navigation";
|
||||||
import { useFamily } from "../FamilyProvider";
|
import { useFamily } from "../FamilyProvider";
|
||||||
import { getGuideline, getAgeInMonths } from "@/lib/guidelines";
|
import { getGuideline, getAgeInMonths } from "@/lib/guidelines";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { CalendarView } from "@/components/CalendarView";
|
import { CalendarView } from "@/components/CalendarView";
|
||||||
import { LogModal, type LogType as ModalLogType } from "@/components/LogModal";
|
import { LogModal, type LogType as ModalLogType, type SmartDefault } from "@/components/LogModal";
|
||||||
import type { Log, LogType } from "@/types";
|
import type { Log, LogType } from "@/types";
|
||||||
|
|
||||||
type ViewMode = "timeline" | "calendar";
|
type ViewMode = "timeline" | "calendar";
|
||||||
|
|
@ -23,16 +23,32 @@ function getIcon(type: LogType) {
|
||||||
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() {
|
export default function ActivityPage() {
|
||||||
|
const router = useRouter();
|
||||||
const { child, childId: providerChildId } = 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 [guideExpanded, setGuideExpanded] = useState(false);
|
||||||
const [fabOpen, setFabOpen] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [modalType, setModalType] = useState<ModalLogType | null>(null);
|
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 childId = providerChildId ?? "";
|
const childId = providerChildId ?? "";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -52,6 +68,7 @@ export default function ActivityPage() {
|
||||||
|
|
||||||
const generateHistory = async () => {
|
const generateHistory = async () => {
|
||||||
if (!child) return;
|
if (!child) return;
|
||||||
|
setMenuOpen(false);
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.post<{ success: boolean }>("/api/history", { childId: child.id, birthDate: child.birthDate });
|
const data = await api.post<{ success: boolean }>("/api/history", { childId: child.id, birthDate: child.birthDate });
|
||||||
|
|
@ -62,6 +79,47 @@ export default function ActivityPage() {
|
||||||
setGenerating(false);
|
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 {
|
||||||
|
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 filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter);
|
||||||
|
|
||||||
const groupedByDay = filteredLogs.reduce<DayLogs[]>((acc, log) => {
|
const groupedByDay = filteredLogs.reduce<DayLogs[]>((acc, log) => {
|
||||||
|
|
@ -72,138 +130,233 @@ export default function ActivityPage() {
|
||||||
return acc;
|
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 (
|
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-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/menu" className="p-2">←</Link>
|
<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>
|
<h1 className="text-xl font-bold">Activity</h1>
|
||||||
{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"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* View toggle */}
|
|
||||||
<div className="flex bg-white dark:bg-gray-800 rounded-lg p-1">
|
<div className="flex items-center gap-2">
|
||||||
{(["timeline", "calendar"] as const).map(v => (
|
{/* ⋯ overflow menu */}
|
||||||
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
key={v}
|
onClick={() => setMenuOpen(o => !o)}
|
||||||
onClick={() => setView(v)}
|
className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-lg leading-none"
|
||||||
className={`px-3 py-1 rounded-md text-sm transition-colors capitalize ${
|
>⋯</button>
|
||||||
view === v ? "bg-rose-400 text-white" : "text-gray-600 dark:text-gray-400"
|
{menuOpen && (
|
||||||
}`}
|
<>
|
||||||
>
|
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||||
{v}
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter pills */}
|
{/* Filter pills */}
|
||||||
<div className="px-4 mb-4 flex gap-2 overflow-x-auto">
|
<div className="px-4 mb-3 flex gap-2 overflow-x-auto scrollbar-hide pb-1">
|
||||||
{(["all", "feed", "sleep", "diaper"] as const).map(f => (
|
{([
|
||||||
|
{ 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
|
<button
|
||||||
key={f}
|
key={f.value}
|
||||||
onClick={() => setFilter(f)}
|
onClick={() => setFilter(f.value)}
|
||||||
className={`px-4 py-2 rounded-full text-sm whitespace-nowrap transition-colors ${
|
className={`px-4 py-2 rounded-full text-sm whitespace-nowrap flex-shrink-0 transition-colors ${
|
||||||
filter === f ? "bg-rose-400 text-white" : "bg-white dark:bg-gray-800"
|
filter === f.value ? "bg-rose-400 text-white" : "bg-white dark:bg-gray-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{f === "all" ? "All" : f.charAt(0).toUpperCase() + f.slice(1)}
|
{f.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Guidelines card */}
|
{!loading && (
|
||||||
{child && showSuggested && (() => {
|
<>
|
||||||
const guide = getGuideline(child.birthDate);
|
{/* Daily summary bar */}
|
||||||
const ageMonths = getAgeInMonths(child.birthDate);
|
<div className="mx-4 mb-3 bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
|
||||||
return (
|
<div className="grid grid-cols-3 divide-x divide-gray-100 dark:divide-gray-700">
|
||||||
<div className="px-4 mb-4">
|
{[
|
||||||
<div className="p-4 bg-gradient-to-r from-rose-100 to-amber-100 dark:from-rose-900 dark:to-amber-900 rounded-xl">
|
{ icon: "🍼", label: "Feeds", count: todayCounts.feed },
|
||||||
<div className="flex items-center justify-between mb-2">
|
{ icon: "🚼", label: "Diapers", count: todayCounts.diaper },
|
||||||
<div className="font-medium text-rose-800 dark:text-rose-200">
|
{ icon: "😴", label: "Sleep", count: todayCounts.sleep },
|
||||||
{child.name} · {ageMonths} months old
|
].map(item => (
|
||||||
|
<div key={item.label} className="flex flex-col items-center py-2 px-1">
|
||||||
|
<span className="text-lg">{item.icon}</span>
|
||||||
|
<span className="text-lg font-bold">{item.count}</span>
|
||||||
|
<span className="text-xs text-gray-400">today</span>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
|
||||||
<div className="text-lg font-bold text-rose-600 dark:text-rose-400">{guide.feeds.times}</div>
|
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400">feeds/day</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-lg font-bold text-amber-600 dark:text-amber-400">{guide.sleep.totalHours}h</div>
|
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400">sleep/day</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{guide.diapers.count}</div>
|
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400">diapers/day</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})()}
|
{/* 4-day overview strip */}
|
||||||
|
<div className="flex gap-2 px-4 mb-3 overflow-x-auto scrollbar-hide">
|
||||||
|
{last4Days.map(d => (
|
||||||
|
<div key={d.label} className="flex-shrink-0 bg-white dark:bg-gray-800 rounded-xl px-3 py-2 shadow-sm min-w-[68px] text-center">
|
||||||
|
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{d.label}</p>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-300">🍼×{d.feed}</p>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-300">😴×{d.sleep}</p>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-300">🚼×{d.diaper}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsible guidelines card */}
|
||||||
|
{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 ·
|
||||||
|
🍼 {todayCounts.feed}/{guide.feeds.times} ·
|
||||||
|
😴 {todayCounts.sleep}/{guide.sleep.totalHours}h ·
|
||||||
|
🚼 {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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-4 pb-24">
|
<div className="px-4 pb-24">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-20 text-gray-400">Loading…</div>
|
<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" ? (
|
) : view === "calendar" ? (
|
||||||
<CalendarView logs={logs} filter={filter} />
|
<CalendarView logs={logs} filter={filter} />
|
||||||
) : groupedByDay.length === 0 ? (
|
) : groupedByDay.length === 0 ? (
|
||||||
<div className="text-center py-20 text-gray-400">
|
<div className="flex flex-col items-center justify-center py-20 text-center px-8">
|
||||||
<div className="text-6xl mb-4">📊</div>
|
<span className="text-5xl mb-4">📋</span>
|
||||||
<p>No activity yet</p>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{groupedByDay.map(day => (
|
{groupedByDay.map(day => {
|
||||||
<div key={day.date}>
|
const label = formatDayLabel(day.date);
|
||||||
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
const isToday = label === "Today";
|
||||||
{new Date(day.date).toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" })}
|
return (
|
||||||
</div>
|
<div key={day.date}>
|
||||||
<div className="space-y-2">
|
<div className={`text-sm mb-2 ${isToday ? "text-rose-500 font-semibold" : "font-medium text-gray-500 dark:text-gray-400"}`}>
|
||||||
{day.logs
|
{label}
|
||||||
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime())
|
</div>
|
||||||
.map(log => (
|
<div className="space-y-2">
|
||||||
<div key={log.id} className="flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-xl">
|
{day.logs
|
||||||
<span className="text-2xl">{getIcon(log.type)}</span>
|
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime())
|
||||||
<div className="flex-1 min-w-0">
|
.map(log => (
|
||||||
<div className="font-medium capitalize">{log.type}</div>
|
<button
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 truncate">
|
key={log.id}
|
||||||
{[
|
onClick={() => { setSelectedLog(log); setDeleteConfirm(false); }}
|
||||||
log.subType?.replace(/_/g, " "),
|
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"
|
||||||
log.amount ? `${log.amount}ml` : null,
|
>
|
||||||
log.notes,
|
<span className="text-2xl">{getIcon(log.type)}</span>
|
||||||
].filter(Boolean).join(" · ")}
|
<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>
|
||||||
</div>
|
<div className="text-sm text-gray-400 flex-shrink-0">
|
||||||
<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>
|
</button>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FAB */}
|
{/* FAB */}
|
||||||
<div className="fixed bottom-6 right-5 flex flex-col items-end gap-2 z-40">
|
<div className="fixed bottom-20 right-5 flex flex-col items-end gap-2 z-40">
|
||||||
{fabOpen && (["feed", "sleep", "diaper"] as ModalLogType[]).map(t => (
|
{fabOpen && (["feed", "sleep", "diaper"] as ModalLogType[]).map(t => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => { setModalType(t); setFabOpen(false); }}
|
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"
|
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>
|
<span>{t === "feed" ? "🍼" : t === "sleep" ? "😴" : "🚼"}</span>
|
||||||
|
|
@ -221,15 +374,96 @@ export default function ActivityPage() {
|
||||||
|
|
||||||
{fabOpen && <div className="fixed inset-0 z-30" onClick={() => setFabOpen(false)} />}
|
{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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<LogModal
|
<LogModal
|
||||||
type={modalType}
|
type={modalType}
|
||||||
childId={childId}
|
childId={childId}
|
||||||
onClose={() => setModalType(null)}
|
onClose={() => { setModalType(null); setSmartDefault(null); setPendingDeleteId(null); }}
|
||||||
onSaved={fetchLogs}
|
onSaved={async () => {
|
||||||
smartDefault={modalType ? (() => {
|
// If editing, delete the old log after the new one is saved
|
||||||
const last = logs.find(l => l.type === modalType);
|
if (pendingDeleteId) {
|
||||||
return last ? { subType: last.subType ?? "", amountMl: last.amount ?? undefined } : null;
|
try {
|
||||||
})() : null}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
116
src/app/api/logs/[id]/route.ts
Normal file
116
src/app/api/logs/[id]/route.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { sql } from "@/db";
|
||||||
|
import { requireFamily } from "@/lib/auth";
|
||||||
|
|
||||||
|
const TABLE: Record<string, string> = {
|
||||||
|
feed: "feeds",
|
||||||
|
sleep: "sleeps",
|
||||||
|
diaper: "diapers_logs",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const auth = await requireFamily();
|
||||||
|
if (!auth.success) {
|
||||||
|
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const type = searchParams.get("type");
|
||||||
|
if (!type || !TABLE[type]) {
|
||||||
|
return NextResponse.json({ error: "Invalid or missing type" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = TABLE[type];
|
||||||
|
const familyId = auth.session!.familyId!;
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify the log belongs to this family via children.family_id
|
||||||
|
const rows = await sql.unsafe(
|
||||||
|
`SELECT l.id FROM ${table} l
|
||||||
|
JOIN children c ON c.id = l.child_id
|
||||||
|
WHERE l.id = $1 AND c.family_id = $2`,
|
||||||
|
[id, familyId]
|
||||||
|
);
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql.unsafe(`DELETE FROM ${table} WHERE id = $1`, [id]);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const auth = await requireFamily();
|
||||||
|
if (!auth.success) {
|
||||||
|
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const type = searchParams.get("type");
|
||||||
|
if (!type || !TABLE[type]) {
|
||||||
|
return NextResponse.json({ error: "Invalid or missing type" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = TABLE[type];
|
||||||
|
const familyId = auth.session!.familyId!;
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ownership check
|
||||||
|
const rows = await sql.unsafe(
|
||||||
|
`SELECT l.id FROM ${table} l
|
||||||
|
JOIN children c ON c.id = l.child_id
|
||||||
|
WHERE l.id = $1 AND c.family_id = $2`,
|
||||||
|
[id, familyId]
|
||||||
|
);
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { notes, amountMl, subType } = body;
|
||||||
|
|
||||||
|
if (type === "feed") {
|
||||||
|
await sql.unsafe(
|
||||||
|
`UPDATE feeds SET
|
||||||
|
type = COALESCE($2, type),
|
||||||
|
amount_ml = COALESCE($3, amount_ml),
|
||||||
|
notes = COALESCE($4, notes)
|
||||||
|
WHERE id = $1`,
|
||||||
|
[id, subType ?? null, amountMl ?? null, notes ?? null]
|
||||||
|
);
|
||||||
|
} else if (type === "diaper") {
|
||||||
|
await sql.unsafe(
|
||||||
|
`UPDATE diapers_logs SET
|
||||||
|
type = COALESCE($2, type),
|
||||||
|
notes = COALESCE($3, notes)
|
||||||
|
WHERE id = $1`,
|
||||||
|
[id, subType ?? null, notes ?? null]
|
||||||
|
);
|
||||||
|
} else if (type === "sleep") {
|
||||||
|
await sql.unsafe(
|
||||||
|
`UPDATE sleeps SET
|
||||||
|
type = COALESCE($2, type),
|
||||||
|
notes = COALESCE($3, notes)
|
||||||
|
WHERE id = $1`,
|
||||||
|
[id, subType ?? null, notes ?? null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue