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:
parent
93db148a65
commit
3ebb3055a5
5 changed files with 328 additions and 152 deletions
|
|
@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useFamily } from "../FamilyProvider";
|
import { useFamily } from "../FamilyProvider";
|
||||||
import { getGuideline, getAgeInMonths } from "@/lib/guidelines";
|
import { getGuideline, getAgeInMonths } from "@/lib/guidelines";
|
||||||
|
import { LogModal, type LogType as ModalLogType } from "@/components/LogModal";
|
||||||
|
|
||||||
type ViewMode = "timeline" | "calendar";
|
type ViewMode = "timeline" | "calendar";
|
||||||
type LogType = "feed" | "sleep" | "diaper";
|
type LogType = "feed" | "sleep" | "diaper";
|
||||||
|
|
@ -219,6 +220,8 @@ export default function ActivityPage() {
|
||||||
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 [generating, setGenerating] = useState(false);
|
||||||
|
const [fabOpen, setFabOpen] = useState(false);
|
||||||
|
const [modalType, setModalType] = useState<ModalLogType | null>(null);
|
||||||
const childId = providerChildId || "";
|
const childId = providerChildId || "";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -398,6 +401,46 @@ export default function ActivityPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ interface LogEntry {
|
||||||
notes?: string;
|
notes?: string;
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
endedAt?: string;
|
endedAt?: string;
|
||||||
|
loggedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
|
@ -20,7 +21,7 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: LogEntry = await request.json();
|
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) {
|
if (!type || !childId || !subType) {
|
||||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
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 });
|
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") {
|
if (type === "feed") {
|
||||||
const method = subType.includes("breast") ? "breast_both" : "bottle";
|
const method = subType.includes("breast") ? "breast_both" : "bottle";
|
||||||
await sql.unsafe(
|
await sql.unsafe(
|
||||||
`INSERT INTO feeds (child_id, type, method, amount_ml, notes, logged_at) VALUES ($1, $2, $3, $4, $5, $6)`,
|
`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") {
|
} else if (type === "diaper") {
|
||||||
await sql.unsafe(
|
await sql.unsafe(
|
||||||
`INSERT INTO diapers_logs (child_id, type, notes, logged_at) VALUES ($1, $2, $3, $4)`,
|
`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") {
|
} else if (type === "sleep") {
|
||||||
const startTime = startedAt || now;
|
const startTime = startedAt || timestamp;
|
||||||
const endTime = endedAt || null;
|
const endTime = endedAt || null;
|
||||||
const durationMinutes = endTime
|
const durationMinutes = endTime
|
||||||
? Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / 60000)
|
? Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / 60000)
|
||||||
|
|
@ -54,7 +56,7 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
await sql.unsafe(
|
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)`,
|
`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]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
202
src/app/page.tsx
202
src/app/page.tsx
|
|
@ -5,9 +5,9 @@ import Link from "next/link";
|
||||||
import { useTheme } from "./ThemeProvider";
|
import { useTheme } from "./ThemeProvider";
|
||||||
import { useFamily } from "./FamilyProvider";
|
import { useFamily } from "./FamilyProvider";
|
||||||
import { useStageCheck, type BabyStage } from "@/hooks/useStageCheck";
|
import { useStageCheck, type BabyStage } from "@/hooks/useStageCheck";
|
||||||
import { Button, Modal, Select, Input } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
|
import { LogModal, type LogType } from "@/components/LogModal";
|
||||||
const OFFLINE_QUEUE_KEY = "tia_offline_queue";
|
import { getOfflineQueue, addToOfflineQueue, processOfflineQueue } from "@/lib/offline-queue";
|
||||||
|
|
||||||
interface AIChat {
|
interface AIChat {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -24,38 +24,6 @@ interface ChatSession {
|
||||||
updatedAt: string;
|
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[]> {
|
async function getSessions(cid: string): Promise<ChatSession[]> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -77,84 +45,6 @@ async function createSession(cid: string): Promise<ChatSession | null> {
|
||||||
} catch { return 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) {
|
function calculateAge(birthDate: string) {
|
||||||
if (!birthDate) return "";
|
if (!birthDate) return "";
|
||||||
|
|
@ -196,27 +86,35 @@ function formatTimeAgo(dateStr: string | null | undefined) {
|
||||||
return `${diffDays}d ago`;
|
return `${diffDays}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActivityScroller({ lastLogs }: { lastLogs: any[] }) {
|
function TodaySummary({ logs }: { logs: any[] }) {
|
||||||
const feedLog = lastLogs.find(l => l?.type === "feed");
|
const todayStr = new Date().toDateString();
|
||||||
const sleepLog = lastLogs.find(l => l?.type === "sleep");
|
const today = logs.filter(l => new Date(l.loggedAt).toDateString() === todayStr);
|
||||||
const diaperLog = lastLogs.find(l => l?.type === "diaper");
|
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 = [
|
if (!lastFeed && !lastDiaper && !lastSleep) return null;
|
||||||
{ 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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-4 mb-4 overflow-hidden">
|
<div className="mx-4 mb-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
|
||||||
<div className="flex animate-marquee gap-4">
|
<div className="grid grid-cols-3 divide-x divide-gray-100 dark:divide-gray-700">
|
||||||
{[...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">
|
{ icon: "🍼", label: "Feeds", count: counts.feed, last: lastFeed?.loggedAt },
|
||||||
<span className="text-lg">{item.icon}</span>
|
{ icon: "🚼", label: "Diapers", count: counts.diaper, last: lastDiaper?.loggedAt },
|
||||||
<span className="text-sm dark:text-gray-300">{item.label}:</span>
|
{ icon: "😴", label: "Sleep", count: counts.sleep, last: lastSleep?.loggedAt },
|
||||||
<span className="text-sm font-medium text-rose-500">{formatTimeAgo(item.time)}</span>
|
].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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -234,7 +132,7 @@ export default function HomePage() {
|
||||||
const [aiLoading, setAiLoading] = useState(false);
|
const [aiLoading, setAiLoading] = useState(false);
|
||||||
const [homeSessionId, setHomeSessionId] = useState<string | null>(null);
|
const [homeSessionId, setHomeSessionId] = useState<string | null>(null);
|
||||||
const [pendingCount, setPendingCount] = useState(0);
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
const [lastLogs, setLastLogs] = useState<any[]>([]);
|
const [recentLogs, setRecentLogs] = useState<any[]>([]);
|
||||||
const [logsLoading, setLogsLoading] = useState(true);
|
const [logsLoading, setLogsLoading] = useState(true);
|
||||||
const [vaccineReminders, setVaccineReminders] = useState<any[]>([]);
|
const [vaccineReminders, setVaccineReminders] = useState<any[]>([]);
|
||||||
const { theme, toggle: toggleTheme } = useTheme();
|
const { theme, toggle: toggleTheme } = useTheme();
|
||||||
|
|
@ -263,17 +161,20 @@ export default function HomePage() {
|
||||||
return () => window.removeEventListener("online", handleOnline);
|
return () => window.removeEventListener("online", handleOnline);
|
||||||
}, [childId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!childId) return;
|
if (!childId) return;
|
||||||
setLogsLoading(true);
|
setLogsLoading(true);
|
||||||
Promise.all([
|
fetchRecentLogs();
|
||||||
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));
|
|
||||||
}, [childId]);
|
}, [childId]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -385,7 +286,7 @@ export default function HomePage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ActivityScroller lastLogs={lastLogs} />
|
<TodaySummary logs={recentLogs} />
|
||||||
|
|
||||||
<div className="px-4 mb-4">
|
<div className="px-4 mb-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
|
@ -435,19 +336,28 @@ export default function HomePage() {
|
||||||
<div className="px-4 mt-4">
|
<div className="px-4 mt-4">
|
||||||
<h2 className="font-semibold mb-3 ml-1">Recent Activity</h2>
|
<h2 className="font-semibold mb-3 ml-1">Recent Activity</h2>
|
||||||
<div className="space-y-2">
|
<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) => (
|
{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={i} className="flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-xl">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xl">{log.type === "feed" && "🍼"}{log.type === "sleep" && "😴"}{log.type === "diaper" && "🚼"}</span>
|
<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>
|
</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>
|
</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 && (
|
{aiOpen && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => { setAiOpen(false); setHomeSessionId(null); }}>
|
<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
183
src/components/LogModal.tsx
Normal 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
38
src/lib/offline-queue.ts
Normal 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));
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue