From 3ebb3055a53f62b33906007eed35525d52e3006d Mon Sep 17 00:00:00 2001 From: Mannu Date: Mon, 18 May 2026 11:21:00 +0530 Subject: [PATCH] feat(logging): time presets, FAB on activity, today summary, smart defaults - Extract offline queue to src/lib/offline-queue.ts - Extract shared LogModal with time presets (Just now/5/15/30min/Custom) and smart default pre-fill from last log of same type - Replace ActivityScroller with TodaySummary (today's counts + last time) - Fix activity page: GET /api/logs without type param now returns all logs merged - Fix field naming: log.loggedAt / log.amount (camelCase throughout) - Add FAB to activity page for zero-navigation quick logging - Recent Activity shows 5 most recent entries with correct field names Co-Authored-By: Claude Sonnet 4.6 --- src/app/activity/page.tsx | 43 ++++++++ src/app/api/logs/route.ts | 14 +-- src/app/page.tsx | 202 ++++++++++-------------------------- src/components/LogModal.tsx | 183 ++++++++++++++++++++++++++++++++ src/lib/offline-queue.ts | 38 +++++++ 5 files changed, 328 insertions(+), 152 deletions(-) create mode 100644 src/components/LogModal.tsx create mode 100644 src/lib/offline-queue.ts diff --git a/src/app/activity/page.tsx b/src/app/activity/page.tsx index ffba68e..5d75136 100644 --- a/src/app/activity/page.tsx +++ b/src/app/activity/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from "react"; import Link from "next/link"; import { useFamily } from "../FamilyProvider"; import { getGuideline, getAgeInMonths } from "@/lib/guidelines"; +import { LogModal, type LogType as ModalLogType } from "@/components/LogModal"; type ViewMode = "timeline" | "calendar"; type LogType = "feed" | "sleep" | "diaper"; @@ -219,6 +220,8 @@ export default function ActivityPage() { const [filter, setFilter] = useState("all"); const [showSuggested, setShowSuggested] = useState(true); const [generating, setGenerating] = useState(false); + const [fabOpen, setFabOpen] = useState(false); + const [modalType, setModalType] = useState(null); const childId = providerChildId || ""; useEffect(() => { @@ -398,6 +401,46 @@ export default function ActivityPage() { )} + + {/* FAB */} +
+ {fabOpen && ( + <> + {(["feed", "sleep", "diaper"] as ModalLogType[]).map(t => ( + + ))} + + )} + +
+ + setModalType(null)} + onSaved={fetchLogs} + smartDefault={modalType ? (() => { + const last = logs.find(l => l.type === modalType); + return last ? { subType: last.subType ?? "", amountMl: last.amount ?? undefined } : null; + })() : null} + /> + + {fabOpen && ( +
setFabOpen(false)} /> + )}
); } diff --git a/src/app/api/logs/route.ts b/src/app/api/logs/route.ts index a5bae77..644aac0 100644 --- a/src/app/api/logs/route.ts +++ b/src/app/api/logs/route.ts @@ -10,6 +10,7 @@ interface LogEntry { notes?: string; startedAt?: string; endedAt?: string; + loggedAt?: string; } export async function POST(request: Request) { @@ -20,7 +21,7 @@ export async function POST(request: Request) { try { const body: LogEntry = await request.json(); - const { type, childId, subType, amountMl, notes, startedAt, endedAt } = body; + const { type, childId, subType, amountMl, notes, startedAt, endedAt, loggedAt } = body; if (!type || !childId || !subType) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); @@ -32,21 +33,22 @@ export async function POST(request: Request) { return NextResponse.json({ error: ownership.error }, { status: ownership.status }); } - const now = new Date().toISOString(); + // Use client-provided timestamp (supports time presets) or fall back to now + const timestamp = loggedAt ? new Date(loggedAt).toISOString() : new Date().toISOString(); if (type === "feed") { const method = subType.includes("breast") ? "breast_both" : "bottle"; await sql.unsafe( `INSERT INTO feeds (child_id, type, method, amount_ml, notes, logged_at) VALUES ($1, $2, $3, $4, $5, $6)`, - [childId, subType, method, amountMl || null, notes || null, now] + [childId, subType, method, amountMl || null, notes || null, timestamp] ); } else if (type === "diaper") { await sql.unsafe( `INSERT INTO diapers_logs (child_id, type, notes, logged_at) VALUES ($1, $2, $3, $4)`, - [childId, subType, notes || null, now] + [childId, subType, notes || null, timestamp] ); } else if (type === "sleep") { - const startTime = startedAt || now; + const startTime = startedAt || timestamp; const endTime = endedAt || null; const durationMinutes = endTime ? Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / 60000) @@ -54,7 +56,7 @@ export async function POST(request: Request) { await sql.unsafe( `INSERT INTO sleeps (child_id, type, started_at, ended_at, duration_minutes, notes, logged_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, - [childId, subType, startTime, endTime, durationMinutes, notes || null, now] + [childId, subType, startTime, endTime, durationMinutes, notes || null, timestamp] ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 51558dd..3b13bfb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,9 +5,9 @@ import Link from "next/link"; import { useTheme } from "./ThemeProvider"; import { useFamily } from "./FamilyProvider"; import { useStageCheck, type BabyStage } from "@/hooks/useStageCheck"; -import { Button, Modal, Select, Input } from "@/components/ui"; - -const OFFLINE_QUEUE_KEY = "tia_offline_queue"; +import { Button } from "@/components/ui"; +import { LogModal, type LogType } from "@/components/LogModal"; +import { getOfflineQueue, addToOfflineQueue, processOfflineQueue } from "@/lib/offline-queue"; interface AIChat { id: string; @@ -24,38 +24,6 @@ interface ChatSession { updatedAt: string; } -export interface OfflineEntry { - id: string; - type: "feed" | "diaper" | "sleep"; - data: any; - timestamp: number; -} - -export function getOfflineQueue(): OfflineEntry[] { - if (typeof window === "undefined") return []; - try { - const data = localStorage.getItem(OFFLINE_QUEUE_KEY); - return data ? JSON.parse(data) : []; - } catch { return []; } -} - -export function addToOfflineQueue(entry: Omit) { - const queue = getOfflineQueue(); - queue.push({ ...entry, id: crypto.randomUUID(), timestamp: Date.now() }); - localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue)); -} - -export async function processOfflineQueue() { - const queue = getOfflineQueue(); - if (queue.length === 0) return; - const failed: OfflineEntry[] = []; - for (const entry of queue) { - try { - await fetch("/api/logs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(entry.data) }); - } catch { failed.push(entry); } - } - localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(failed)); -} async function getSessions(cid: string): Promise { try { @@ -77,84 +45,6 @@ async function createSession(cid: string): Promise { } catch { return null; } } -function LogModal({ type, childId, onClose }: { type: "feed" | "diaper" | "sleep" | null; childId: string; onClose: () => void }) { - const [loading, setLoading] = useState(false); - const [subType, setSubType] = useState("breast_milk"); - const [amountMl, setAmountMl] = useState(""); - const [notes, setNotes] = useState(""); - - // Reset subType default whenever the log type changes - useEffect(() => { - if (type === "feed") setSubType("breast_milk"); - else if (type === "diaper") setSubType("wet"); - else if (type === "sleep") setSubType("nap"); - }, [type]); - - if (!type) return null; - - const handleSubmit = async () => { - setLoading(true); - const data = { type, childId, subType, amountMl: amountMl ? Number(amountMl) : undefined, notes: notes || undefined }; - try { - const res = await fetch("/api/logs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }); - if (!res.ok) { - if (!navigator.onLine) { - addToOfflineQueue({ type: type as any, data }); - } else { - setLoading(false); - return; - } - } - onClose(); - } catch { - if (!navigator.onLine) { - addToOfflineQueue({ type: type as any, data }); - onClose(); - } else { - setLoading(false); - return; - } - } - setLoading(false); - }; - - const title = type === "feed" ? "Log Feed" : type === "diaper" ? "Log Diaper" : "Log Sleep"; - return ( - -
- {type === "feed" && ( - <> - - setAmountMl(e.target.value)} /> - - )} - {type === "diaper" && ( - - )} - {type === "sleep" && ( - - )} - setNotes(e.target.value)} /> -
- - -
-
-
- ); -} function calculateAge(birthDate: string) { if (!birthDate) return ""; @@ -196,27 +86,35 @@ function formatTimeAgo(dateStr: string | null | undefined) { return `${diffDays}d ago`; } -function ActivityScroller({ lastLogs }: { lastLogs: any[] }) { - const feedLog = lastLogs.find(l => l?.type === "feed"); - const sleepLog = lastLogs.find(l => l?.type === "sleep"); - const diaperLog = lastLogs.find(l => l?.type === "diaper"); +function TodaySummary({ logs }: { logs: any[] }) { + const todayStr = new Date().toDateString(); + const today = logs.filter(l => new Date(l.loggedAt).toDateString() === todayStr); + const counts = { + feed: today.filter(l => l.type === "feed").length, + diaper: today.filter(l => l.type === "diaper").length, + sleep: today.filter(l => l.type === "sleep").length, + }; + const lastFeed = logs.find(l => l.type === "feed"); + const lastDiaper = logs.find(l => l.type === "diaper"); + const lastSleep = logs.find(l => l.type === "sleep"); - const items = [ - { icon: "🍼", label: "Fed", time: feedLog?.logged_at }, - { icon: "😴", label: "Sleep", time: sleepLog?.logged_at }, - { icon: "🚼", label: "Diaper", time: diaperLog?.logged_at }, - ].filter(item => item.time); - - if (items.length === 0) return null; + if (!lastFeed && !lastDiaper && !lastSleep) return null; return ( -
-
- {[...items, ...items].map((item, i) => ( -
- {item.icon} - {item.label}: - {formatTimeAgo(item.time)} +
+
+ {[ + { icon: "🍼", label: "Feeds", count: counts.feed, last: lastFeed?.loggedAt }, + { icon: "🚼", label: "Diapers", count: counts.diaper, last: lastDiaper?.loggedAt }, + { icon: "😴", label: "Sleep", count: counts.sleep, last: lastSleep?.loggedAt }, + ].map(item => ( +
+ {item.icon} + {item.count} + {item.label} + {item.last && ( + {formatTimeAgo(item.last)} + )}
))}
@@ -234,7 +132,7 @@ export default function HomePage() { const [aiLoading, setAiLoading] = useState(false); const [homeSessionId, setHomeSessionId] = useState(null); const [pendingCount, setPendingCount] = useState(0); - const [lastLogs, setLastLogs] = useState([]); + const [recentLogs, setRecentLogs] = useState([]); const [logsLoading, setLogsLoading] = useState(true); const [vaccineReminders, setVaccineReminders] = useState([]); const { theme, toggle: toggleTheme } = useTheme(); @@ -263,17 +161,20 @@ export default function HomePage() { return () => window.removeEventListener("online", handleOnline); }, [childId]); + const fetchRecentLogs = async () => { + if (!childId) return; + try { + const res = await fetch(`/api/logs?childId=${childId}&limit=50`); + const data = await res.json(); + setRecentLogs(data.entries || []); + } catch {} + setLogsLoading(false); + }; + useEffect(() => { if (!childId) return; setLogsLoading(true); - Promise.all([ - fetch(`/api/logs?type=feed&childId=${childId}&limit=1`).then(r => r.json()), - fetch(`/api/logs?type=sleep&childId=${childId}&limit=1`).then(r => r.json()), - fetch(`/api/logs?type=diaper&childId=${childId}&limit=1`).then(r => r.json()), - ]).then(([feed, sleep, diaper]) => { - setLastLogs([feed.entries?.[0], sleep.entries?.[0], diaper.entries?.[0]].filter(Boolean)); - setLogsLoading(false); - }).catch(() => setLogsLoading(false)); + fetchRecentLogs(); }, [childId]); if (loading) { @@ -385,7 +286,7 @@ export default function HomePage() {
)} - +
@@ -435,19 +336,28 @@ export default function HomePage() {

Recent Activity

- {logsLoading ?

Loading...

: lastLogs.length === 0 ?

No logs yet today

: lastLogs.filter(Boolean).map((log: any, i: number) => ( -
+ {logsLoading ?

Loading...

: recentLogs.length === 0 ?

No logs yet today

: recentLogs.slice(0, 5).map((log: any) => ( +
{log.type === "feed" && "🍼"}{log.type === "sleep" && "😴"}{log.type === "diaper" && "🚼"} -
{log.type}
{new Date(log.logged_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
+
{log.type}
{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
- {log.amount_ml && {log.amount_ml}ml} + {log.amount && {log.amount}ml}
))}
- setModalType(null)} /> + setModalType(null)} + onSaved={fetchRecentLogs} + smartDefault={modalType ? (() => { + const last = recentLogs.find(l => l.type === modalType); + return last ? { subType: last.subType, amountMl: last.amount ?? undefined } : null; + })() : null} + /> {aiOpen && (
{ setAiOpen(false); setHomeSessionId(null); }}> diff --git a/src/components/LogModal.tsx b/src/components/LogModal.tsx new file mode 100644 index 0000000..4aa23e8 --- /dev/null +++ b/src/components/LogModal.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button, Modal, Select, Input } from "@/components/ui"; +import { addToOfflineQueue } from "@/lib/offline-queue"; + +export type LogType = "feed" | "diaper" | "sleep"; + +export interface SmartDefault { + subType: string; + amountMl?: number; +} + +interface Props { + type: LogType | null; + childId: string; + onClose: () => void; + onSaved?: () => void; + smartDefault?: SmartDefault | null; +} + +type TimePreset = "now" | "5" | "15" | "30" | "custom"; + +const TIME_PRESETS: { value: TimePreset; label: string }[] = [ + { value: "now", label: "Just now" }, + { value: "5", label: "5 min ago" }, + { value: "15", label: "15 min ago" }, + { value: "30", label: "30 min ago" }, + { value: "custom", label: "Custom" }, +]; + +function localDatetimeNow() { + const now = new Date(); + const offset = now.getTimezoneOffset() * 60000; + return new Date(now.getTime() - offset).toISOString().slice(0, 16); +} + +function resolveLoggedAt(preset: TimePreset, customValue: string): string { + const now = new Date(); + if (preset === "5") return new Date(now.getTime() - 5 * 60_000).toISOString(); + if (preset === "15") return new Date(now.getTime() - 15 * 60_000).toISOString(); + if (preset === "30") return new Date(now.getTime() - 30 * 60_000).toISOString(); + if (preset === "custom" && customValue) return new Date(customValue).toISOString(); + return now.toISOString(); +} + +const DEFAULT_SUBTYPE: Record = { + feed: "breast_milk", + diaper: "wet", + sleep: "nap", +}; + +export function LogModal({ type, childId, onClose, onSaved, smartDefault }: Props) { + const [loading, setLoading] = useState(false); + const [subType, setSubType] = useState("breast_milk"); + const [amountMl, setAmountMl] = useState(""); + const [notes, setNotes] = useState(""); + const [timePreset, setTimePreset] = useState("now"); + const [customTime, setCustomTime] = useState(localDatetimeNow); + + // Reset fields and apply smart defaults whenever type changes + useEffect(() => { + if (!type) return; + setSubType(smartDefault?.subType ?? DEFAULT_SUBTYPE[type]); + setAmountMl(type === "feed" && smartDefault?.amountMl ? String(smartDefault.amountMl) : ""); + setNotes(""); + setTimePreset("now"); + setCustomTime(localDatetimeNow()); + }, [type]); + + if (!type) return null; + + const handleSubmit = async () => { + setLoading(true); + const loggedAt = resolveLoggedAt(timePreset, customTime); + const data = { + type, + childId, + subType, + amountMl: amountMl ? Number(amountMl) : undefined, + notes: notes || undefined, + loggedAt, + }; + try { + const res = await fetch("/api/logs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok && !navigator.onLine) { + addToOfflineQueue({ type, data }); + } + onSaved?.(); + onClose(); + } catch { + if (!navigator.onLine) addToOfflineQueue({ type, data }); + onClose(); + } + setLoading(false); + }; + + const titles: Record = { feed: "Log Feed", diaper: "Log Diaper", sleep: "Log Sleep" }; + + return ( + +
+ + {/* Time presets */} +
+

When?

+
+ {TIME_PRESETS.map(p => ( + + ))} +
+ {timePreset === "custom" && ( + setCustomTime(e.target.value)} + className="mt-2" + /> + )} +
+ + {/* Type-specific fields */} + {type === "feed" && ( + <> + + setAmountMl(e.target.value)} + /> + + )} + {type === "diaper" && ( + + )} + {type === "sleep" && ( + + )} + + setNotes(e.target.value)} + /> + +
+ + +
+
+
+ ); +} diff --git a/src/lib/offline-queue.ts b/src/lib/offline-queue.ts new file mode 100644 index 0000000..45dbb0f --- /dev/null +++ b/src/lib/offline-queue.ts @@ -0,0 +1,38 @@ +export interface OfflineEntry { + id: string; + type: "feed" | "diaper" | "sleep"; + data: any; + timestamp: number; +} + +const KEY = "tia_offline_queue"; + +export function getOfflineQueue(): OfflineEntry[] { + if (typeof window === "undefined") return []; + try { + const d = localStorage.getItem(KEY); + return d ? JSON.parse(d) : []; + } catch { return []; } +} + +export function addToOfflineQueue(entry: Omit) { + const q = getOfflineQueue(); + q.push({ ...entry, id: crypto.randomUUID(), timestamp: Date.now() }); + localStorage.setItem(KEY, JSON.stringify(q)); +} + +export async function processOfflineQueue() { + const q = getOfflineQueue(); + if (q.length === 0) return; + const failed: OfflineEntry[] = []; + for (const entry of q) { + try { + await fetch("/api/logs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(entry.data), + }); + } catch { failed.push(entry); } + } + localStorage.setItem(KEY, JSON.stringify(failed)); +}