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 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-18 11:21:00 +05:30
parent 93db148a65
commit 3ebb3055a5
5 changed files with 328 additions and 152 deletions

View file

@ -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<LogType | "all">("all");
const [showSuggested, setShowSuggested] = useState(true);
const [generating, setGenerating] = useState(false);
const [fabOpen, setFabOpen] = useState(false);
const [modalType, setModalType] = useState<ModalLogType | null>(null);
const childId = providerChildId || "";
useEffect(() => {
@ -398,6 +401,46 @@ export default function ActivityPage() {
</div>
)}
</div>
{/* FAB */}
<div className="fixed bottom-6 right-5 flex flex-col items-end gap-2 z-40">
{fabOpen && (
<>
{(["feed", "sleep", "diaper"] as ModalLogType[]).map(t => (
<button
key={t}
onClick={() => { setModalType(t); setFabOpen(false); }}
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>
<LogModal
type={modalType}
childId={childId}
onClose={() => 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 && (
<div className="fixed inset-0 z-30" onClick={() => setFabOpen(false)} />
)}
</div>
);
}

View file

@ -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]
);
}

View file

@ -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<OfflineEntry, "id" | "timestamp">) {
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<ChatSession[]> {
try {
@ -77,84 +45,6 @@ async function createSession(cid: string): Promise<ChatSession | null> {
} 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 (
<Modal open={!!type} onClose={onClose} title={title} maxWidth="sm">
<div className="space-y-3">
{type === "feed" && (
<>
<Select value={subType} onChange={e => setSubType(e.target.value)}>
<option value="breast_milk">Breast Milk</option>
<option value="formula">Formula</option>
<option value="solid">Solid Food</option>
<option value="water">Water</option>
</Select>
<Input type="number" placeholder="Amount (ml)" value={amountMl} onChange={e => setAmountMl(e.target.value)} />
</>
)}
{type === "diaper" && (
<Select value={subType} onChange={e => setSubType(e.target.value)}>
<option value="wet">Wet</option>
<option value="dirty">Dirty</option>
<option value="both">Both</option>
</Select>
)}
{type === "sleep" && (
<Select value={subType} onChange={e => setSubType(e.target.value)}>
<option value="nap">Nap</option>
<option value="night">Night Sleep</option>
</Select>
)}
<Input placeholder="Notes (optional)" value={notes} onChange={e => setNotes(e.target.value)} />
<div className="flex gap-3 pt-1">
<Button variant="secondary" fullWidth onClick={onClose}>Cancel</Button>
<Button variant="primary" fullWidth loading={loading} onClick={handleSubmit}>Save</Button>
</div>
</div>
</Modal>
);
}
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 (
<div className="mx-4 mb-4 overflow-hidden">
<div className="flex animate-marquee gap-4">
{[...items, ...items].map((item, i) => (
<div key={i} className="flex-shrink-0 flex items-center gap-2 px-3 py-2 bg-white dark:bg-gray-800 rounded-full shadow-sm">
<span className="text-lg">{item.icon}</span>
<span className="text-sm dark:text-gray-300">{item.label}:</span>
<span className="text-sm font-medium text-rose-500">{formatTimeAgo(item.time)}</span>
<div className="mx-4 mb-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
<div className="grid grid-cols-3 divide-x divide-gray-100 dark:divide-gray-700">
{[
{ 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 => (
<div key={item.label} className="flex flex-col items-center py-3 px-1">
<span className="text-xl mb-0.5">{item.icon}</span>
<span className="text-xl font-bold text-gray-800 dark:text-white leading-tight">{item.count}</span>
<span className="text-xs text-gray-400 dark:text-gray-500">{item.label}</span>
{item.last && (
<span className="text-xs text-rose-400 mt-0.5">{formatTimeAgo(item.last)}</span>
)}
</div>
))}
</div>
@ -234,7 +132,7 @@ export default function HomePage() {
const [aiLoading, setAiLoading] = useState(false);
const [homeSessionId, setHomeSessionId] = useState<string | null>(null);
const [pendingCount, setPendingCount] = useState(0);
const [lastLogs, setLastLogs] = useState<any[]>([]);
const [recentLogs, setRecentLogs] = useState<any[]>([]);
const [logsLoading, setLogsLoading] = useState(true);
const [vaccineReminders, setVaccineReminders] = useState<any[]>([]);
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() {
</div>
)}
<ActivityScroller lastLogs={lastLogs} />
<TodaySummary logs={recentLogs} />
<div className="px-4 mb-4">
<div className="flex items-center justify-between mb-3">
@ -435,19 +336,28 @@ export default function HomePage() {
<div className="px-4 mt-4">
<h2 className="font-semibold mb-3 ml-1">Recent Activity</h2>
<div className="space-y-2">
{logsLoading ? <p className="text-gray-400 text-sm">Loading...</p> : lastLogs.length === 0 ? <p className="text-gray-400 text-sm">No logs yet today</p> : lastLogs.filter(Boolean).map((log: any, i: number) => (
<div key={i} className="flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-xl">
{logsLoading ? <p className="text-gray-400 text-sm">Loading...</p> : recentLogs.length === 0 ? <p className="text-gray-400 text-sm">No logs yet today</p> : recentLogs.slice(0, 5).map((log: any) => (
<div key={log.id} className="flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-xl">
<div className="flex items-center gap-3">
<span className="text-xl">{log.type === "feed" && "🍼"}{log.type === "sleep" && "😴"}{log.type === "diaper" && "🚼"}</span>
<div><div className="font-medium capitalize">{log.type}</div><div className="text-xs text-gray-500 dark:text-gray-400">{new Date(log.logged_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div></div>
<div><div className="font-medium capitalize">{log.type}</div><div className="text-xs text-gray-500 dark:text-gray-400">{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div></div>
</div>
{log.amount_ml && <span className="text-sm text-gray-500 dark:text-gray-400">{log.amount_ml}ml</span>}
{log.amount && <span className="text-sm text-gray-500 dark:text-gray-400">{log.amount}ml</span>}
</div>
))}
</div>
</div>
<LogModal type={modalType} childId={childId} onClose={() => setModalType(null)} />
<LogModal
type={modalType}
childId={childId}
onClose={() => 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 && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => { setAiOpen(false); setHomeSessionId(null); }}>

183
src/components/LogModal.tsx Normal file
View file

@ -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<LogType, string> = {
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<TimePreset>("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<LogType, string> = { feed: "Log Feed", diaper: "Log Diaper", sleep: "Log Sleep" };
return (
<Modal open={!!type} onClose={onClose} title={titles[type]} maxWidth="sm">
<div className="space-y-3">
{/* Time presets */}
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">When?</p>
<div className="flex flex-wrap gap-1.5">
{TIME_PRESETS.map(p => (
<button
key={p.value}
type="button"
onClick={() => setTimePreset(p.value)}
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
timePreset === p.value
? "bg-rose-400 text-white"
: "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
}`}
>
{p.label}
</button>
))}
</div>
{timePreset === "custom" && (
<Input
type="datetime-local"
value={customTime}
max={localDatetimeNow()}
onChange={e => setCustomTime(e.target.value)}
className="mt-2"
/>
)}
</div>
{/* Type-specific fields */}
{type === "feed" && (
<>
<Select value={subType} onChange={e => setSubType(e.target.value)}>
<option value="breast_milk">Breast Milk</option>
<option value="formula">Formula</option>
<option value="solid">Solid Food</option>
<option value="water">Water</option>
</Select>
<Input
type="number"
placeholder="Amount (ml)"
value={amountMl}
onChange={e => setAmountMl(e.target.value)}
/>
</>
)}
{type === "diaper" && (
<Select value={subType} onChange={e => setSubType(e.target.value)}>
<option value="wet">Wet</option>
<option value="dirty">Dirty</option>
<option value="both">Both</option>
<option value="dry">Dry</option>
</Select>
)}
{type === "sleep" && (
<Select value={subType} onChange={e => setSubType(e.target.value)}>
<option value="nap">Nap</option>
<option value="night">Night Sleep</option>
</Select>
)}
<Input
placeholder="Notes (optional)"
value={notes}
onChange={e => setNotes(e.target.value)}
/>
<div className="flex gap-3 pt-1">
<Button variant="secondary" fullWidth onClick={onClose}>Cancel</Button>
<Button variant="primary" fullWidth loading={loading} onClick={handleSubmit}>Save</Button>
</div>
</div>
</Modal>
);
}

38
src/lib/offline-queue.ts Normal file
View file

@ -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<OfflineEntry, "id" | "timestamp">) {
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));
}