tia/src/components/LogModal.tsx
Mannu deaa1810d7 feat: add Umami self-hosted analytics with custom event tracking
- 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>
2026-05-30 00:40:05 +05:30

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