- Root layout: load Umami script (afterInteractive) — covers all pages including
SPA navigation auto-tracking
- Marketing layout: remove Plausible script (Umami now covers marketing pages too)
- src/lib/analytics.ts: type-safe track() wrapper + typed helpers for each event;
window.umami declared globally; safe no-op on SSR/ad-block
- Custom events wired:
log-created { logType } — LogModal on successful save
garment-added — wardrobe/add after save
memory-added — memories after upload pipeline completes
growth-logged — growth page after measurement saved
pwa-installed — InstallPrompt when Android prompt accepted
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
206 lines
7 KiB
TypeScript
206 lines
7 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { Button, Modal, Select, Input } from "@/components/ui";
|
|
import { addToOfflineQueue } from "@/lib/offline-queue";
|
|
import { trackLogCreated } from "@/lib/analytics";
|
|
import type { LogType } from "@/types";
|
|
|
|
export type { LogType };
|
|
|
|
export interface SmartDefault {
|
|
subType: string;
|
|
amountMl?: number;
|
|
/** UTC ISO string of the original log time — when set, pre-fills the time picker for editing. */
|
|
editTime?: string;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Convert a UTC ISO string to a datetime-local value in the device's local timezone.
|
|
* This is the inverse of what new Date(datetimeLocalValue).toISOString() does.
|
|
* Used to pre-fill the time picker when editing an existing log.
|
|
*/
|
|
function isoToLocalDatetimeLocal(utcIso: string): string {
|
|
const date = new Date(utcIso);
|
|
const offset = date.getTimezoneOffset() * 60000; // negative for UTC+tz (e.g. IST = -330*60000)
|
|
return new Date(date.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 the modal opens (type changes to non-null)
|
|
useEffect(() => {
|
|
if (!type) return;
|
|
setSubType(smartDefault?.subType ?? DEFAULT_SUBTYPE[type]);
|
|
setAmountMl(type === "feed" && smartDefault?.amountMl ? String(smartDefault.amountMl) : "");
|
|
setNotes("");
|
|
if (smartDefault?.editTime) {
|
|
// Editing an existing log — pre-fill the picker with its original time
|
|
setTimePreset("custom");
|
|
setCustomTime(isoToLocalDatetimeLocal(smartDefault.editTime));
|
|
} else {
|
|
setTimePreset("now");
|
|
setCustomTime(localDatetimeNow());
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [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 });
|
|
}
|
|
trackLogCreated(type as "feed" | "sleep" | "diaper");
|
|
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>
|
|
);
|
|
}
|