refactor: full codebase sweep — shared types, utilities, component splits

New foundations:
- src/types/index.ts — shared domain types (Child, Log, GrowthRecord, Medicine,
  Dose, Allergy, Visit, Illness, Vaccination, AIChat, ChatSession, Goal)
- src/lib/formatting.ts — calculateAge, formatAge, formatTimeAgo (eliminates
  3 duplicate implementations spread across page.tsx and growth/page.tsx)
- src/lib/api.ts — typed fetch helpers (api.get/post/patch/delete) with
  consistent error handling; replaces manual fetch boilerplate

New shared components:
- src/components/PageHeader.tsx — reusable back-link + title header
- src/components/TabBar.tsx — horizontal pill tab bar
- src/components/CalendarView.tsx — extracted from activity/page.tsx (was ~170 inline lines)
- src/components/medical/ — medical page split into 5 focused tab components:
  VaccineTab, MedicineTab, AllergyTab, VisitTab, IllnessTab

Pages updated:
- medical/page.tsx: 1029 → 42 lines (thin shell wiring the 5 tab components)
- activity/page.tsx: uses CalendarView + shared Log type + api.ts
- growth/page.tsx: uses shared GrowthRecord/Goal types + formatAge; fixes
  `any` catch clauses; fixes undefined → null in Chart.js dataset values
- page.tsx (home): uses shared Log/AIChat/ChatSession types + formatTimeAgo/
  calculateAge from formatting.ts; removes inline type definitions
- ai/page.tsx: uses shared AIChat/ChatSession types
- FamilyProvider.tsx: uses shared Child type; fixes `c: any` mapping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-18 21:37:39 +05:30
parent 3ebb3055a5
commit 96d28cbadc
18 changed files with 1315 additions and 1426 deletions

View file

@ -3,13 +3,7 @@
import { useState, useEffect, createContext, useContext } from "react"; import { useState, useEffect, createContext, useContext } from "react";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { useRouter, usePathname } from "next/navigation"; import { useRouter, usePathname } from "next/navigation";
import type { Child } from "@/types";
interface Child {
id: string;
name: string;
birthDate: string;
sex: string;
}
interface FamilyContextType { interface FamilyContextType {
familyId: string | null; familyId: string | null;
@ -87,7 +81,7 @@ export function FamilyProvider({ children: providerChildren }: { children: React
} }
if (data.children?.length > 0) { if (data.children?.length > 0) {
const childList = data.children.map((c: any) => ({ const childList: Child[] = data.children.map((c: Child) => ({
id: c.id, id: c.id,
name: c.name, name: c.name,
birthDate: c.birthDate, birthDate: c.birthDate,

View file

@ -1,34 +1,21 @@
"use client"; "use client";
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect } 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 { api } from "@/lib/api";
import { CalendarView } from "@/components/CalendarView";
import { LogModal, type LogType as ModalLogType } from "@/components/LogModal"; import { LogModal, type LogType as ModalLogType } from "@/components/LogModal";
import type { Log, LogType } from "@/types";
type ViewMode = "timeline" | "calendar"; type ViewMode = "timeline" | "calendar";
type LogType = "feed" | "sleep" | "diaper";
interface Log {
id: string;
type: LogType;
subType?: string;
amount?: number;
notes?: string;
loggedAt: string;
}
interface DayLogs { interface DayLogs {
date: string; date: string;
logs: Log[]; logs: Log[];
} }
const TYPE_COLORS: Record<LogType, string> = {
feed: "bg-rose-400",
sleep: "bg-blue-400",
diaper: "bg-amber-400",
};
function getIcon(type: LogType) { function getIcon(type: LogType) {
if (type === "feed") return "🍼"; if (type === "feed") return "🍼";
if (type === "sleep") return "😴"; if (type === "sleep") return "😴";
@ -36,193 +23,17 @@ function getIcon(type: LogType) {
return "📝"; return "📝";
} }
// ─── Calendar view ────────────────────────────────────────────────────────────
function CalendarView({ logs, filter }: { logs: Log[]; filter: LogType | "all" }) {
const now = new Date();
const [calMonth, setCalMonth] = useState(new Date(now.getFullYear(), now.getMonth(), 1));
const [selectedDay, setSelectedDay] = useState<string | null>(
now.toISOString().slice(0, 10)
);
const year = calMonth.getFullYear();
const month = calMonth.getMonth();
const today = now.toISOString().slice(0, 10);
const isCurrentMonth = year === now.getFullYear() && month === now.getMonth();
const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter);
// Group logs by YYYY-MM-DD
const logsByDate = useMemo(() => {
const map: Record<string, Log[]> = {};
filteredLogs.forEach(log => {
const key = new Date(log.loggedAt).toISOString().slice(0, 10);
if (!map[key]) map[key] = [];
map[key].push(log);
});
return map;
}, [filteredLogs]);
// Build grid: leading nulls for DOW offset, then day numbers
const firstDow = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells: (number | null)[] = [
...Array(firstDow).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
];
const dayKey = (d: number) =>
`${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const selectedLogs = useMemo(
() => (selectedDay ? (logsByDate[selectedDay] || [])
.slice()
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime())
: []),
[selectedDay, logsByDate]
);
return (
<div className="space-y-3">
{/* Month grid */}
<div className="bg-white dark:bg-gray-800 rounded-xl p-4">
{/* Month nav */}
<div className="flex items-center justify-between mb-4">
<button
onClick={() => setCalMonth(new Date(year, month - 1, 1))}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300 text-lg"
>
</button>
<span className="font-semibold text-gray-800 dark:text-white">
{calMonth.toLocaleDateString("en-US", { month: "long", year: "numeric" })}
</span>
<button
onClick={() => setCalMonth(new Date(year, month + 1, 1))}
disabled={isCurrentMonth}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300 text-lg disabled:opacity-30"
>
</button>
</div>
{/* DOW headers */}
<div className="grid grid-cols-7 mb-1">
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(d => (
<div key={d} className="text-center text-xs text-gray-400 font-medium py-1">{d}</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-y-1">
{cells.map((day, i) => {
if (!day) return <div key={i} />;
const key = dayKey(day);
const dayLogs = logsByDate[key] || [];
const isToday = key === today;
const isSel = key === selectedDay;
const types = [...new Set(dayLogs.map(l => l.type))] as LogType[];
return (
<button
key={i}
onClick={() => setSelectedDay(isSel ? null : key)}
className={`flex flex-col items-center justify-center py-1.5 rounded-lg transition-colors ${
isSel
? "bg-rose-400"
: isToday
? "bg-rose-50 dark:bg-rose-900/30"
: "hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
<span className={`text-sm leading-none ${
isSel ? "text-white font-semibold" :
isToday ? "text-rose-500 font-bold" :
"text-gray-700 dark:text-gray-300"
}`}>
{day}
</span>
<div className="flex gap-0.5 mt-1 h-2 items-center">
{types.slice(0, 3).map(t => (
<span
key={t}
className={`w-1.5 h-1.5 rounded-full ${isSel ? "bg-white/80" : TYPE_COLORS[t]}`}
/>
))}
</div>
</button>
);
})}
</div>
{/* Legend */}
<div className="flex justify-center gap-5 mt-4 pt-3 border-t border-gray-100 dark:border-gray-700">
{(["feed", "sleep", "diaper"] as LogType[]).map(t => (
<div key={t} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className={`w-2 h-2 rounded-full ${TYPE_COLORS[t]}`} />
<span className="capitalize">{t}</span>
</div>
))}
</div>
</div>
{/* Selected day detail */}
{selectedDay && (
<div className="bg-white dark:bg-gray-800 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-gray-800 dark:text-white">
{new Date(selectedDay + "T12:00:00").toLocaleDateString("en-US", {
weekday: "long", month: "short", day: "numeric",
})}
</h3>
<span className="text-sm text-gray-400 dark:text-gray-500">
{selectedLogs.length} {selectedLogs.length === 1 ? "entry" : "entries"}
</span>
</div>
{selectedLogs.length === 0 ? (
<p className="text-center text-gray-400 text-sm py-6">No activity logged</p>
) : (
<div className="space-y-2">
{selectedLogs.map(log => (
<div key={log.id} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-xl">
<span className="text-xl">{getIcon(log.type)}</span>
<div className="flex-1 min-w-0">
<div className="font-medium capitalize text-sm dark:text-white">{log.type}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{[
log.subType?.replace(/_/g, " "),
log.amount ? `${log.amount}ml` : null,
log.notes,
].filter(Boolean).join(" · ")}
</div>
</div>
<div className="text-xs text-gray-400 flex-shrink-0">
{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function ActivityPage() { export default function ActivityPage() {
const { child, childId: providerChildId } = useFamily(); const { child, childId: providerChildId } = useFamily();
const [view, setView] = useState<ViewMode>("timeline"); const [view, setView] = useState<ViewMode>("timeline");
const [logs, setLogs] = useState<Log[]>([]); const [logs, setLogs] = useState<Log[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
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 [fabOpen, setFabOpen] = useState(false);
const [modalType, setModalType] = useState<ModalLogType | null>(null); const [modalType, setModalType] = useState<ModalLogType | null>(null);
const childId = providerChildId || ""; const childId = providerChildId ?? "";
useEffect(() => { useEffect(() => {
if (providerChildId) fetchLogs(); if (providerChildId) fetchLogs();
@ -231,11 +42,10 @@ export default function ActivityPage() {
const fetchLogs = async () => { const fetchLogs = async () => {
if (!childId) return; if (!childId) return;
try { try {
const res = await fetch(`/api/logs?childId=${childId}&limit=200`); const data = await api.get<{ entries: Log[] }>(`/api/logs?childId=${childId}&limit=200`);
const data = await res.json();
setLogs(data.entries || []); setLogs(data.entries || []);
} catch (err) { } catch (err) {
console.error("Failed to fetch:", err); console.error("Failed to fetch logs:", err);
} }
setLoading(false); setLoading(false);
}; };
@ -244,22 +54,17 @@ export default function ActivityPage() {
if (!child) return; if (!child) return;
setGenerating(true); setGenerating(true);
try { try {
const res = await fetch("/api/history", { const data = await api.post<{ success: boolean }>("/api/history", { childId: child.id, birthDate: child.birthDate });
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ childId: child.id, birthDate: child.birthDate }),
});
const data = await res.json();
if (data.success) fetchLogs(); if (data.success) fetchLogs();
} catch (err) { } catch (err) {
console.error("Failed to generate:", err); console.error("Failed to generate history:", err);
} }
setGenerating(false); setGenerating(false);
}; };
const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter); const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter);
const groupedByDay = filteredLogs.reduce((acc: DayLogs[], log) => { const groupedByDay = filteredLogs.reduce<DayLogs[]>((acc, log) => {
const date = new Date(log.loggedAt).toDateString(); const date = new Date(log.loggedAt).toDateString();
const existing = acc.find(d => d.date === date); const existing = acc.find(d => d.date === date);
if (existing) existing.logs.push(log); if (existing) existing.logs.push(log);
@ -286,22 +91,17 @@ export default function ActivityPage() {
</div> </div>
{/* View toggle */} {/* View toggle */}
<div className="flex bg-white dark:bg-gray-800 rounded-lg p-1"> <div className="flex bg-white dark:bg-gray-800 rounded-lg p-1">
<button {(["timeline", "calendar"] as const).map(v => (
onClick={() => setView("timeline")} <button
className={`px-3 py-1 rounded-md text-sm transition-colors ${ key={v}
view === "timeline" ? "bg-rose-400 text-white" : "text-gray-600 dark:text-gray-400" onClick={() => setView(v)}
}`} className={`px-3 py-1 rounded-md text-sm transition-colors capitalize ${
> view === v ? "bg-rose-400 text-white" : "text-gray-600 dark:text-gray-400"
Timeline }`}
</button> >
<button {v}
onClick={() => setView("calendar")} </button>
className={`px-3 py-1 rounded-md text-sm transition-colors ${ ))}
view === "calendar" ? "bg-rose-400 text-white" : "text-gray-600 dark:text-gray-400"
}`}
>
Calendar
</button>
</div> </div>
</div> </div>
@ -321,38 +121,36 @@ export default function ActivityPage() {
</div> </div>
{/* Guidelines card */} {/* Guidelines card */}
{child && showSuggested && ( {child && showSuggested && (() => {
<div className="px-4 mb-4"> const guide = getGuideline(child.birthDate);
{(() => { const ageMonths = getAgeInMonths(child.birthDate);
const guide = getGuideline(child.birthDate); return (
const ageMonths = getAgeInMonths(child.birthDate); <div className="px-4 mb-4">
return ( <div className="p-4 bg-gradient-to-r from-rose-100 to-amber-100 dark:from-rose-900 dark:to-amber-900 rounded-xl">
<div className="p-4 bg-gradient-to-r from-rose-100 to-amber-100 dark:from-rose-900 dark:to-amber-900 rounded-xl"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center justify-between mb-2"> <div className="font-medium text-rose-800 dark:text-rose-200">
<div className="font-medium text-rose-800 dark:text-rose-200"> {child.name} · {ageMonths} months old
{child.name} · {ageMonths} months old
</div>
<button onClick={() => setShowSuggested(false)} className="text-gray-400 text-sm"></button>
</div> </div>
<div className="grid grid-cols-3 gap-2 text-sm"> <button onClick={() => setShowSuggested(false)} className="text-gray-400 text-sm"></button>
<div className="text-center"> </div>
<div className="text-lg font-bold text-rose-600 dark:text-rose-400">{guide.feeds.times}</div> <div className="grid grid-cols-3 gap-2 text-sm">
<div className="text-xs text-gray-600 dark:text-gray-400">feeds/day</div> <div className="text-center">
</div> <div className="text-lg font-bold text-rose-600 dark:text-rose-400">{guide.feeds.times}</div>
<div className="text-center"> <div className="text-xs text-gray-600 dark:text-gray-400">feeds/day</div>
<div className="text-lg font-bold text-amber-600 dark:text-amber-400">{guide.sleep.totalHours}h</div> </div>
<div className="text-xs text-gray-600 dark:text-gray-400">sleep/day</div> <div className="text-center">
</div> <div className="text-lg font-bold text-amber-600 dark:text-amber-400">{guide.sleep.totalHours}h</div>
<div className="text-center"> <div className="text-xs text-gray-600 dark:text-gray-400">sleep/day</div>
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{guide.diapers.count}</div> </div>
<div className="text-xs text-gray-600 dark:text-gray-400">diapers/day</div> <div className="text-center">
</div> <div className="text-lg font-bold text-blue-600 dark:text-blue-400">{guide.diapers.count}</div>
<div className="text-xs text-gray-600 dark:text-gray-400">diapers/day</div>
</div> </div>
</div> </div>
); </div>
})()} </div>
</div> );
)} })()}
{/* Content */} {/* Content */}
<div className="px-4 pb-20"> <div className="px-4 pb-20">
@ -370,9 +168,7 @@ export default function ActivityPage() {
{groupedByDay.map(day => ( {groupedByDay.map(day => (
<div key={day.date}> <div key={day.date}>
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2"> <div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
{new Date(day.date).toLocaleDateString("en-US", { {new Date(day.date).toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" })}
weekday: "long", month: "short", day: "numeric",
})}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{day.logs {day.logs
@ -404,20 +200,16 @@ export default function ActivityPage() {
{/* FAB */} {/* FAB */}
<div className="fixed bottom-6 right-5 flex flex-col items-end gap-2 z-40"> <div className="fixed bottom-6 right-5 flex flex-col items-end gap-2 z-40">
{fabOpen && ( {fabOpen && (["feed", "sleep", "diaper"] as ModalLogType[]).map(t => (
<> <button
{(["feed", "sleep", "diaper"] as ModalLogType[]).map(t => ( key={t}
<button onClick={() => { setModalType(t); setFabOpen(false); }}
key={t} 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"
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}
<span>{t === "feed" ? "🍼" : t === "sleep" ? "😴" : "🚼"}</span> </button>
{t} ))}
</button>
))}
</>
)}
<button <button
onClick={() => setFabOpen(o => !o)} 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" className="w-14 h-14 bg-rose-400 text-white rounded-full shadow-lg flex items-center justify-center text-2xl transition-transform"
@ -427,6 +219,8 @@ export default function ActivityPage() {
</button> </button>
</div> </div>
{fabOpen && <div className="fixed inset-0 z-30" onClick={() => setFabOpen(false)} />}
<LogModal <LogModal
type={modalType} type={modalType}
childId={childId} childId={childId}
@ -437,10 +231,6 @@ export default function ActivityPage() {
return last ? { subType: last.subType ?? "", amountMl: last.amount ?? undefined } : null; return last ? { subType: last.subType ?? "", amountMl: last.amount ?? undefined } : null;
})() : null} })() : null}
/> />
{fabOpen && (
<div className="fixed inset-0 z-30" onClick={() => setFabOpen(false)} />
)}
</div> </div>
); );
} }

View file

@ -3,21 +3,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useFamily } from "../FamilyProvider"; import { useFamily } from "../FamilyProvider";
import { Button, Input, ConfirmDialog } from "@/components/ui"; import { Button, Input, ConfirmDialog } from "@/components/ui";
import type { AIChat, ChatSession } from "@/types";
interface AIChat {
id: string;
role: "user" | "assistant";
content: string;
createdAt: string;
}
interface ChatSession {
id: string;
title: string;
messages: AIChat[];
createdAt: string;
updatedAt: string;
}
export default function AIChatPage() { export default function AIChatPage() {
const { childId } = useFamily(); const { childId } = useFamily();

View file

@ -1,9 +1,11 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect } from "react";
import { useFamily } from "../FamilyProvider"; import { useFamily } from "../FamilyProvider";
import { Button, Card, Input, ConfirmDialog } from "@/components/ui"; import { Button, Card, Input, ConfirmDialog } from "@/components/ui";
import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile, type GrowthStandard } from "@/lib/growth-standards"; import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile } from "@/lib/growth-standards";
import { formatAge } from "@/lib/formatting";
import type { GrowthRecord, Goal } from "@/types";
import { import {
Chart as ChartJS, Chart as ChartJS,
CategoryScale, CategoryScale,
@ -18,57 +20,21 @@ import {
import { Line } from "react-chartjs-2"; import { Line } from "react-chartjs-2";
import { useTheme } from "../ThemeProvider"; import { useTheme } from "../ThemeProvider";
ChartJS.register( ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler);
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
);
interface GrowthRecord { interface Percentiles { p3: number; p15: number; p50: number; p85: number; p97: number }
id: string; interface WhoStandard {
child_id: string; weight: Percentiles;
measured_at: string; height: Percentiles;
weight_kg: number | null; headCircumference: Percentiles;
height_cm: number | null;
head_circumference_cm: number | null;
notes: string | null;
}
interface Goal {
weightKg?: number;
heightCm?: number;
targetDate?: string;
}
function formatAge(birthDate: string, measurementDate?: string): string {
const birth = new Date(birthDate);
const now = measurementDate ? new Date(measurementDate) : new Date();
const years = Math.floor((now.getTime() - birth.getTime()) / (1000 * 60 * 60 * 24 * 365));
const months = Math.floor(((now.getTime() - birth.getTime()) % (1000 * 60 * 60 * 24 * 365)) / (1000 * 60 * 60 * 24 * 30));
if (years > 0 && months > 0) {
return `${years}y ${months}mo`;
} else if (years > 0) {
return `${years}y`;
} else if (months > 0) {
return `${months}mo`;
} else {
return "Newborn";
}
} }
export default function GrowthPage() { export default function GrowthPage() {
const { childId, child, familyId } = useFamily(); const { childId, child, familyId } = useFamily();
const { theme } = useTheme(); useTheme(); // keep theme context alive for dark mode CSS
const isDark = theme === "dark";
const [growthData, setGrowthData] = useState<GrowthRecord[]>([]); const [growthData, setGrowthData] = useState<GrowthRecord[]>([]);
const [whoStandard, setWhoStandard] = useState<any>(null); const [whoStandard, setWhoStandard] = useState<WhoStandard | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false); const [showAdd, setShowAdd] = useState(false);
const [showGoals, setShowGoals] = useState(false); const [showGoals, setShowGoals] = useState(false);
@ -150,8 +116,8 @@ export default function GrowthPage() {
} }
resetForm(); resetForm();
fetchGrowthData(); fetchGrowthData();
} catch (e: any) { } catch (e) {
setSaveError(e.message || "Failed to save"); setSaveError(e instanceof Error ? e.message : "Failed to save");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -312,7 +278,7 @@ export default function GrowthPage() {
// WHO 85th percentile // WHO 85th percentile
{ {
label: "85th", label: "85th",
data: labels.map(() => whoStandard?.[whoKey]?.p85), data: labels.map(() => whoStandard?.[whoKey]?.p85 ?? null),
borderColor: "#fbbf24", borderColor: "#fbbf24",
borderDash: [5, 5], borderDash: [5, 5],
fill: false, fill: false,
@ -322,7 +288,7 @@ export default function GrowthPage() {
// WHO 50th percentile // WHO 50th percentile
{ {
label: "50th", label: "50th",
data: labels.map(() => whoStandard?.[whoKey]?.p50), data: labels.map(() => whoStandard?.[whoKey]?.p50 ?? null),
borderColor: "#22c55e", borderColor: "#22c55e",
borderDash: [5, 5], borderDash: [5, 5],
fill: false, fill: false,
@ -332,21 +298,21 @@ export default function GrowthPage() {
// WHO 15th percentile // WHO 15th percentile
{ {
label: "15th", label: "15th",
data: labels.map(() => whoStandard?.[whoKey]?.p15), data: labels.map(() => whoStandard?.[whoKey]?.p15 ?? null),
borderColor: "#fbbf24", borderColor: "#fbbf24",
borderDash: [5, 5], borderDash: [5, 5],
fill: false, fill: false,
pointRadius: 0, pointRadius: 0,
tension: 0.4, tension: 0.4,
}, },
// WHO 3rd-97th band
{ {
label: "3rd-97th", label: "3rd",
data: labels.map(() => [whoStandard?.[whoKey]?.p3, whoStandard?.[whoKey]?.p97]), data: labels.map(() => whoStandard?.[whoKey]?.p3 ?? null),
borderColor: "transparent", borderColor: "#fbbf24",
backgroundColor: "rgba(34, 197, 94, 0.1)", borderDash: [5, 5],
fill: "+1", fill: false,
pointRadius: 0, pointRadius: 0,
tension: 0.4,
}, },
], ],
}; };

File diff suppressed because it is too large Load diff

View file

@ -7,23 +7,9 @@ import { useFamily } from "./FamilyProvider";
import { useStageCheck, type BabyStage } from "@/hooks/useStageCheck"; import { useStageCheck, type BabyStage } from "@/hooks/useStageCheck";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import { LogModal, type LogType } from "@/components/LogModal"; import { LogModal, type LogType } from "@/components/LogModal";
import { getOfflineQueue, addToOfflineQueue, processOfflineQueue } from "@/lib/offline-queue"; import { getOfflineQueue, processOfflineQueue } from "@/lib/offline-queue";
import { calculateAge, formatTimeAgo } from "@/lib/formatting";
interface AIChat { import type { Log, AIChat, ChatSession } from "@/types";
id: string;
role: "user" | "assistant";
content: string;
createdAt: string;
}
interface ChatSession {
id: string;
title: string;
messages: AIChat[];
createdAt: string;
updatedAt: string;
}
async function getSessions(cid: string): Promise<ChatSession[]> { async function getSessions(cid: string): Promise<ChatSession[]> {
try { try {
@ -46,25 +32,6 @@ async function createSession(cid: string): Promise<ChatSession | null> {
} }
function calculateAge(birthDate: string) {
if (!birthDate) return "";
const birth = new Date(birthDate);
const now = new Date();
let years = now.getFullYear() - birth.getFullYear();
let months = now.getMonth() - birth.getMonth();
let days = now.getDate() - birth.getDate();
if (days < 0) {
months--;
days += new Date(now.getFullYear(), now.getMonth(), 0).getDate();
}
if (months < 0) { years--; months += 12; }
const parts = [];
if (years > 0) parts.push(`${years} year${years > 1 ? "s" : ""}`);
if (months > 0) parts.push(`${months} month${months > 1 ? "s" : ""}`);
if (days > 0) parts.push(`${days} day${days > 1 ? "s" : ""}`);
return parts.length > 0 ? parts.join(", ") : "Newborn";
}
function getGreeting() { function getGreeting() {
const hour = new Date().getHours(); const hour = new Date().getHours();
if (hour < 12) return "Good morning"; if (hour < 12) return "Good morning";
@ -72,21 +39,7 @@ function getGreeting() {
return "Good evening"; return "Good evening";
} }
function formatTimeAgo(dateStr: string | null | undefined) { function TodaySummary({ logs }: { logs: Log[] }) {
if (!dateStr) return null;
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
}
function TodaySummary({ logs }: { logs: any[] }) {
const todayStr = new Date().toDateString(); const todayStr = new Date().toDateString();
const today = logs.filter(l => new Date(l.loggedAt).toDateString() === todayStr); const today = logs.filter(l => new Date(l.loggedAt).toDateString() === todayStr);
const counts = { const counts = {
@ -132,7 +85,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 [recentLogs, setRecentLogs] = useState<any[]>([]); const [recentLogs, setRecentLogs] = useState<Log[]>([]);
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();
@ -336,7 +289,7 @@ 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> : recentLogs.length === 0 ? <p className="text-gray-400 text-sm">No logs yet today</p> : recentLogs.slice(0, 5).map((log: any) => ( {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 => (
<div key={log.id} 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>
@ -355,7 +308,7 @@ export default function HomePage() {
onSaved={fetchRecentLogs} onSaved={fetchRecentLogs}
smartDefault={modalType ? (() => { smartDefault={modalType ? (() => {
const last = recentLogs.find(l => l.type === modalType); const last = recentLogs.find(l => l.type === modalType);
return last ? { subType: last.subType, amountMl: last.amount ?? undefined } : null; return last?.subType ? { subType: last.subType, amountMl: last.amount ?? undefined } : null;
})() : null} })() : null}
/> />

View file

@ -0,0 +1,186 @@
"use client";
import { useState, useMemo } from "react";
import type { Log, LogType } from "@/types";
const TYPE_COLORS: Record<LogType, string> = {
feed: "bg-rose-400",
sleep: "bg-blue-400",
diaper: "bg-amber-400",
};
function getIcon(type: LogType) {
if (type === "feed") return "🍼";
if (type === "sleep") return "😴";
if (type === "diaper") return "🚼";
return "📝";
}
interface Props {
logs: Log[];
filter: LogType | "all";
}
export function CalendarView({ logs, filter }: Props) {
const now = new Date();
const [calMonth, setCalMonth] = useState(new Date(now.getFullYear(), now.getMonth(), 1));
const [selectedDay, setSelectedDay] = useState<string | null>(now.toISOString().slice(0, 10));
const year = calMonth.getFullYear();
const month = calMonth.getMonth();
const today = now.toISOString().slice(0, 10);
const isCurrentMonth = year === now.getFullYear() && month === now.getMonth();
const filteredLogs = filter === "all" ? logs : logs.filter(l => l.type === filter);
const logsByDate = useMemo(() => {
const map: Record<string, Log[]> = {};
filteredLogs.forEach(log => {
const key = new Date(log.loggedAt).toISOString().slice(0, 10);
if (!map[key]) map[key] = [];
map[key].push(log);
});
return map;
}, [filteredLogs]);
const firstDow = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells: (number | null)[] = [
...Array(firstDow).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
];
const dayKey = (d: number) =>
`${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const selectedLogs = useMemo(
() =>
selectedDay
? (logsByDate[selectedDay] || [])
.slice()
.sort((a, b) => new Date(b.loggedAt).getTime() - new Date(a.loggedAt).getTime())
: [],
[selectedDay, logsByDate],
);
return (
<div className="space-y-3">
<div className="bg-white dark:bg-gray-800 rounded-xl p-4">
{/* Month nav */}
<div className="flex items-center justify-between mb-4">
<button
onClick={() => setCalMonth(new Date(year, month - 1, 1))}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300 text-lg"
>
</button>
<span className="font-semibold text-gray-800 dark:text-white">
{calMonth.toLocaleDateString("en-US", { month: "long", year: "numeric" })}
</span>
<button
onClick={() => setCalMonth(new Date(year, month + 1, 1))}
disabled={isCurrentMonth}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300 text-lg disabled:opacity-30"
>
</button>
</div>
{/* DOW headers */}
<div className="grid grid-cols-7 mb-1">
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(d => (
<div key={d} className="text-center text-xs text-gray-400 font-medium py-1">{d}</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-y-1">
{cells.map((day, i) => {
if (!day) return <div key={i} />;
const key = dayKey(day);
const dayLogs = logsByDate[key] || [];
const isToday = key === today;
const isSel = key === selectedDay;
const types = [...new Set(dayLogs.map(l => l.type))] as LogType[];
return (
<button
key={i}
onClick={() => setSelectedDay(isSel ? null : key)}
className={`flex flex-col items-center justify-center py-1.5 rounded-lg transition-colors ${
isSel ? "bg-rose-400" :
isToday ? "bg-rose-50 dark:bg-rose-900/30" :
"hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
<span className={`text-sm leading-none ${
isSel ? "text-white font-semibold" :
isToday ? "text-rose-500 font-bold" :
"text-gray-700 dark:text-gray-300"
}`}>
{day}
</span>
<div className="flex gap-0.5 mt-1 h-2 items-center">
{types.slice(0, 3).map(t => (
<span key={t} className={`w-1.5 h-1.5 rounded-full ${isSel ? "bg-white/80" : TYPE_COLORS[t]}`} />
))}
</div>
</button>
);
})}
</div>
{/* Legend */}
<div className="flex justify-center gap-5 mt-4 pt-3 border-t border-gray-100 dark:border-gray-700">
{(["feed", "sleep", "diaper"] as LogType[]).map(t => (
<div key={t} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className={`w-2 h-2 rounded-full ${TYPE_COLORS[t]}`} />
<span className="capitalize">{t}</span>
</div>
))}
</div>
</div>
{/* Selected day detail */}
{selectedDay && (
<div className="bg-white dark:bg-gray-800 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-gray-800 dark:text-white">
{new Date(selectedDay + "T12:00:00").toLocaleDateString("en-US", {
weekday: "long", month: "short", day: "numeric",
})}
</h3>
<span className="text-sm text-gray-400 dark:text-gray-500">
{selectedLogs.length} {selectedLogs.length === 1 ? "entry" : "entries"}
</span>
</div>
{selectedLogs.length === 0 ? (
<p className="text-center text-gray-400 text-sm py-6">No activity logged</p>
) : (
<div className="space-y-2">
{selectedLogs.map(log => (
<div key={log.id} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-xl">
<span className="text-xl">{getIcon(log.type)}</span>
<div className="flex-1 min-w-0">
<div className="font-medium capitalize text-sm dark:text-white">{log.type}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{[
log.subType?.replace(/_/g, " "),
log.amount ? `${log.amount}ml` : null,
log.notes,
].filter(Boolean).join(" · ")}
</div>
</div>
<div className="text-xs text-gray-400 flex-shrink-0">
{new Date(log.loggedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -3,8 +3,9 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Button, Modal, Select, Input } from "@/components/ui"; import { Button, Modal, Select, Input } from "@/components/ui";
import { addToOfflineQueue } from "@/lib/offline-queue"; import { addToOfflineQueue } from "@/lib/offline-queue";
import type { LogType } from "@/types";
export type LogType = "feed" | "diaper" | "sleep"; export type { LogType };
export interface SmartDefault { export interface SmartDefault {
subType: string; subType: string;

View file

@ -0,0 +1,24 @@
import { ReactNode } from "react";
import Link from "next/link";
interface Props {
title: string;
subtitle?: string;
backHref?: string;
actions?: ReactNode;
}
export function PageHeader({ title, subtitle, backHref = "/menu", actions }: Props) {
return (
<div className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href={backHref} className="p-2 text-gray-600 dark:text-gray-300"></Link>
<div>
<h1 className="text-xl font-bold">{title}</h1>
{subtitle && <p className="text-sm text-gray-500 dark:text-gray-400">{subtitle}</p>}
</div>
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
}

37
src/components/TabBar.tsx Normal file
View file

@ -0,0 +1,37 @@
interface Tab {
value: string;
label: string;
}
interface Props {
tabs: Tab[];
value: string;
onChange: (value: string) => void;
danger?: string;
}
export function TabBar({ tabs, value, onChange, danger }: Props) {
return (
<div className="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
{tabs.map(tab => {
const isActive = tab.value === value;
const isDanger = danger && tab.value === danger;
return (
<button
key={tab.value}
onClick={() => onChange(tab.value)}
className={`px-4 py-2 rounded-xl whitespace-nowrap flex-shrink-0 text-sm font-medium transition-colors ${
isActive
? isDanger
? "bg-red-500 text-white"
: "bg-rose-400 text-white"
: "bg-white dark:bg-gray-800 dark:text-white"
}`}
>
{tab.label}
</button>
);
})}
</div>
);
}

View file

@ -0,0 +1,114 @@
"use client";
import { useState, useEffect } from "react";
import { Button, Input, Select } from "@/components/ui";
import { api } from "@/lib/api";
import type { Allergy } from "@/types";
interface Props { childId: string }
export function AllergyTab({ childId }: Props) {
const [allergies, setAllergies] = useState<Allergy[]>([]);
const [editingAllergy, setEditingAllergy] = useState<Allergy | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [name, setName] = useState("");
const [severity, setSeverity] = useState("mild");
const [notes, setNotes] = useState("");
useEffect(() => { fetchAllergies(); }, [childId]);
const fetchAllergies = () =>
api.get<{ allergies: Allergy[] }>(`/api/allergies?childId=${childId}`)
.then(d => setAllergies(d.allergies || []))
.catch(console.error);
const save = async () => {
if (!name) return;
try {
if (editingAllergy) {
await api.patch("/api/allergies", { id: editingAllergy.id, name, severity, notes });
} else {
await api.post("/api/allergies", { childId, name, severity, notes });
}
fetchAllergies();
} catch (err) { console.error("Failed to save allergy:", err); }
reset();
};
const remove = async (id: string) => {
try {
await api.delete(`/api/allergies?id=${id}`);
fetchAllergies();
} catch (err) { console.error("Failed to delete allergy:", err); }
};
const edit = (a: Allergy) => {
setEditingAllergy(a);
setName(a.name);
setSeverity(a.severity);
setNotes(a.notes);
setShowAdd(true);
};
const reset = () => {
setEditingAllergy(null);
setName("");
setSeverity("mild");
setNotes("");
setShowAdd(false);
};
return (
<div className="space-y-2">
<h2 className="font-semibold mb-3">Known Allergies</h2>
{showAdd && (
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
<Input placeholder="Allergy name (e.g., Peanut, Milk)" value={name} onChange={e => setName(e.target.value)} />
<Select value={severity} onChange={e => setSeverity(e.target.value)}>
<option value="mild">Mild</option>
<option value="moderate">Moderate</option>
<option value="severe">Severe</option>
</Select>
<Input placeholder="Notes" value={notes} onChange={e => setNotes(e.target.value)} />
<div className="flex gap-2">
<Button fullWidth onClick={save}>{editingAllergy ? "Update" : "Add"}</Button>
<Button variant="secondary" fullWidth onClick={reset}>Cancel</Button>
</div>
</div>
)}
{allergies.length === 0 && !showAdd ? (
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl">
<p className="text-gray-500 dark:text-gray-400">No allergies recorded</p>
</div>
) : (
allergies.map(a => (
<div key={a.id} className="p-4 bg-white dark:bg-gray-800 rounded-xl">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="font-medium">{a.name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
<span className={a.severity === "severe" ? "text-red-500" : "text-orange-500"}>
{a.severity.toUpperCase()}
</span>
{a.notes && ` · ${a.notes}`}
</div>
</div>
<div className="flex gap-2">
<button onClick={() => edit(a)} className="p-2 text-gray-400"></button>
<button onClick={() => remove(a.id)} className="p-2 text-red-400">🗑</button>
</div>
</div>
</div>
))
)}
{!showAdd && (
<button onClick={() => setShowAdd(true)} className="w-full p-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl text-gray-500 dark:text-gray-400">
+ Add Allergy
</button>
)}
</div>
);
}

View file

@ -0,0 +1,115 @@
"use client";
import { useState, useEffect } from "react";
import { Button, Input } from "@/components/ui";
import { api } from "@/lib/api";
import type { Illness } from "@/types";
interface Props { childId: string }
export function IllnessTab({ childId }: Props) {
const [illnesses, setIllnesses] = useState<Illness[]>([]);
const [editingIllness, setEditingIllness] = useState<Illness | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [name, setName] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [notes, setNotes] = useState("");
useEffect(() => { fetchIllnesses(); }, [childId]);
const fetchIllnesses = () =>
api.get<{ illnesses: Illness[] }>(`/api/illnesses?childId=${childId}`)
.then(d => setIllnesses(d.illnesses || []))
.catch(console.error);
const save = async () => {
if (!name) return;
try {
if (editingIllness) {
await api.patch("/api/illnesses", { id: editingIllness.id, name, startDate, endDate, notes });
} else {
await api.post("/api/illnesses", { childId, name, startDate, endDate, notes });
}
fetchIllnesses();
} catch (err) { console.error("Failed to save illness:", err); }
reset();
};
const remove = async (id: string) => {
try {
await api.delete(`/api/illnesses?id=${id}`);
fetchIllnesses();
} catch (err) { console.error("Failed to delete illness:", err); }
};
const edit = (ill: Illness) => {
setEditingIllness(ill);
setName(ill.name);
setStartDate(ill.startDate);
setEndDate(ill.endDate || "");
setNotes(ill.notes);
setShowAdd(true);
};
const reset = () => {
setEditingIllness(null);
setName("");
setStartDate("");
setEndDate("");
setNotes("");
setShowAdd(false);
};
return (
<div className="space-y-2">
<h2 className="font-semibold mb-3">Illness Log</h2>
{showAdd && (
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
<Input placeholder="Illness (e.g., Cold, Fever, Flu)" value={name} onChange={e => setName(e.target.value)} />
<div className="grid grid-cols-2 gap-2">
<Input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
<Input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
</div>
<Input placeholder="Notes" value={notes} onChange={e => setNotes(e.target.value)} />
<div className="flex gap-2">
<Button fullWidth onClick={save}>{editingIllness ? "Update" : "Add"}</Button>
<Button variant="secondary" fullWidth onClick={reset}>Cancel</Button>
</div>
</div>
)}
{illnesses.length === 0 && !showAdd ? (
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl">
<p className="text-gray-500 dark:text-gray-400">No illnesses recorded</p>
</div>
) : (
illnesses.map(ill => (
<div key={ill.id} className="p-4 bg-white dark:bg-gray-800 rounded-xl">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="font-medium">{ill.name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{new Date(ill.startDate).toLocaleDateString()}
{ill.endDate && ` - ${new Date(ill.endDate).toLocaleDateString()}`}
{ill.notes && ` · ${ill.notes}`}
</div>
</div>
<div className="flex gap-2">
<button onClick={() => edit(ill)} className="p-2 text-gray-400"></button>
<button onClick={() => remove(ill.id)} className="p-2 text-red-400">🗑</button>
</div>
</div>
</div>
))
)}
{!showAdd && (
<button onClick={() => setShowAdd(true)} className="w-full p-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl text-gray-500 dark:text-gray-400">
+ Log Illness
</button>
)}
</div>
);
}

View file

@ -0,0 +1,247 @@
"use client";
import { useState, useEffect } from "react";
import { Button, Input, Modal } from "@/components/ui";
import { api } from "@/lib/api";
import type { Medicine, Dose } from "@/types";
interface Props { childId: string }
export function MedicineTab({ childId }: Props) {
const [medicines, setMedicines] = useState<Medicine[]>([]);
const [editingMed, setEditingMed] = useState<Medicine | null>(null);
const [showAddMed, setShowAddMed] = useState(false);
const [name, setName] = useState("");
const [dose, setDose] = useState("");
const [notes, setNotes] = useState("");
const [logDoseMedId, setLogDoseMedId] = useState<string | null>(null);
const [doseAmount, setDoseAmount] = useState("");
const [doseNotes, setDoseNotes] = useState("");
const [doseTime, setDoseTime] = useState(() => new Date().toISOString().slice(0, 16));
const [doseLoading, setDoseLoading] = useState(false);
const [doseHistory, setDoseHistory] = useState<Record<string, Dose[]>>({});
const [showDoseHistory, setShowDoseHistory] = useState<Record<string, boolean>>({});
const [correctDose, setCorrectDose] = useState<{ medId: string; dose: Dose } | null>(null);
const [correctAmount, setCorrectAmount] = useState("");
const [correctNotes, setCorrectNotes] = useState("");
const [correctReason, setCorrectReason] = useState("");
const [correctLoading, setCorrectLoading] = useState(false);
useEffect(() => { fetchMedicines(); }, [childId]);
const fetchMedicines = () =>
api.get<{ medicines: Medicine[] }>(`/api/medicines?childId=${childId}`)
.then(d => setMedicines(d.medicines || []))
.catch(console.error);
const saveMedicine = async () => {
if (!name) return;
try {
if (editingMed) {
await api.patch("/api/medicines", { id: editingMed.id, name, dose, notes });
} else {
await api.post("/api/medicines", { childId, name, dose, notes });
}
fetchMedicines();
} catch (err) { console.error("Failed to save medicine:", err); }
resetForm();
};
const deleteMedicine = async (id: string) => {
try {
await api.delete(`/api/medicines?id=${id}`);
fetchMedicines();
} catch (err) { console.error("Failed to delete medicine:", err); }
};
const editMedicine = (med: Medicine) => {
setEditingMed(med);
setName(med.name);
setDose(med.dose);
setNotes(med.notes);
setShowAddMed(true);
};
const resetForm = () => {
setEditingMed(null);
setName("");
setDose("");
setNotes("");
setShowAddMed(false);
};
const openLogDose = (medId: string) => {
setLogDoseMedId(medId);
setDoseAmount("");
setDoseNotes("");
setDoseTime(new Date().toISOString().slice(0, 16));
};
const submitDose = async () => {
if (!logDoseMedId) return;
setDoseLoading(true);
try {
await api.post(`/api/medicines/${logDoseMedId}/doses`, {
amountGiven: doseAmount,
notes: doseNotes,
administeredAt: new Date(doseTime).toISOString(),
});
const medId = logDoseMedId;
setLogDoseMedId(null);
if (showDoseHistory[medId]) fetchDoseHistory(medId);
} catch (err) { console.error("Failed to log dose:", err); }
setDoseLoading(false);
};
const fetchDoseHistory = (medId: string) =>
api.get<{ doses: Dose[] }>(`/api/medicines/${medId}/doses`)
.then(d => setDoseHistory(prev => ({ ...prev, [medId]: d.doses || [] })))
.catch(console.error);
const toggleDoseHistory = (medId: string) => {
const next = !showDoseHistory[medId];
setShowDoseHistory(prev => ({ ...prev, [medId]: next }));
if (next && !doseHistory[medId]) fetchDoseHistory(medId);
};
const submitCorrection = async () => {
if (!correctDose) return;
setCorrectLoading(true);
try {
await api.post(`/api/medicines/${correctDose.medId}/doses/${correctDose.dose.id}/correct`, {
amountGiven: correctAmount,
notes: correctNotes,
reason: correctReason,
});
fetchDoseHistory(correctDose.medId);
setCorrectDose(null);
} catch (err) { console.error("Failed to submit correction:", err); }
setCorrectLoading(false);
};
return (
<div className="space-y-2">
<h2 className="font-semibold mb-3">Medicine &amp; Supplements</h2>
{showAddMed && (
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
<Input placeholder="Medicine name" value={name} onChange={e => setName(e.target.value)} />
<Input placeholder="Dose (e.g., 5ml, 1 tablet)" value={dose} onChange={e => setDose(e.target.value)} />
<Input placeholder="Notes" value={notes} onChange={e => setNotes(e.target.value)} />
<div className="flex gap-2">
<Button fullWidth onClick={saveMedicine}>{editingMed ? "Update" : "Add"}</Button>
<Button variant="secondary" fullWidth onClick={resetForm}>Cancel</Button>
</div>
</div>
)}
{medicines.length === 0 && !showAddMed ? (
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl">
<p className="text-gray-500 dark:text-gray-400">No medicines added</p>
</div>
) : (
medicines.map(med => (
<div key={med.id} className="bg-white dark:bg-gray-800 rounded-xl overflow-hidden">
<div className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="font-medium dark:text-white">{med.name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{med.dose}{med.notes && ` · ${med.notes}`}
{med.reminderTime && ` · ⏰ ${med.reminderTime}`}
</div>
</div>
<div className="flex gap-1">
<button onClick={() => openLogDose(med.id)} className="px-2 py-1 text-xs bg-rose-400 text-white rounded-lg">Log Dose</button>
<button onClick={() => editMedicine(med)} className="p-2 text-gray-400"></button>
<button onClick={() => deleteMedicine(med.id)} className="p-2 text-red-400">🗑</button>
</div>
</div>
<button
onClick={() => toggleDoseHistory(med.id)}
className="mt-2 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
{showDoseHistory[med.id] ? "▲ Hide history" : "▼ Dose history"}
</button>
</div>
{showDoseHistory[med.id] && (
<div className="border-t dark:border-gray-700 px-4 pb-3 space-y-2 pt-2">
{!doseHistory[med.id] ? (
<p className="text-xs text-gray-400">Loading</p>
) : doseHistory[med.id].length === 0 ? (
<p className="text-xs text-gray-400">No doses logged yet.</p>
) : doseHistory[med.id].map(d => {
const latest = d.corrections?.[0]?.corrected_value;
const displayAmount = latest?.amountGiven ?? d.amount_given;
const displayNotes = latest?.notes ?? d.notes;
return (
<div key={d.id} className="text-xs border dark:border-gray-700 rounded-lg p-2">
<div className="flex justify-between items-start">
<div>
<span className="text-gray-600 dark:text-gray-300 font-medium">
{new Date(d.administered_at).toLocaleString()}
</span>
{displayAmount && <span className="ml-2 text-gray-500">· {displayAmount}</span>}
{displayNotes && <span className="ml-1 text-gray-400">· {displayNotes}</span>}
{!!latest && <span className="ml-1 text-amber-500 text-[10px] font-medium">edited</span>}
</div>
<button
onClick={() => { setCorrectDose({ medId: med.id, dose: d }); setCorrectAmount(displayAmount || ""); setCorrectNotes(displayNotes || ""); setCorrectReason(""); }}
className="text-gray-300 dark:text-gray-600 hover:text-amber-500 ml-2"
>
</button>
</div>
</div>
);
})}
</div>
)}
</div>
))
)}
{!showAddMed && (
<button onClick={() => setShowAddMed(true)} className="w-full p-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl text-gray-500 dark:text-gray-400">
+ Add Medicine
</button>
)}
<Modal open={!!logDoseMedId} onClose={() => setLogDoseMedId(null)} title="Log Dose" maxWidth="sm">
<div className="space-y-3">
<Input placeholder="Amount given (e.g. 5ml)" value={doseAmount} onChange={e => setDoseAmount(e.target.value)} />
<Input type="datetime-local" value={doseTime} onChange={e => setDoseTime(e.target.value)} />
<Input placeholder="Notes (optional)" value={doseNotes} onChange={e => setDoseNotes(e.target.value)} />
<div className="flex gap-2">
<Button fullWidth loading={doseLoading} onClick={submitDose}>Save Dose</Button>
<Button variant="secondary" fullWidth onClick={() => setLogDoseMedId(null)}>Cancel</Button>
</div>
</div>
</Modal>
<Modal open={!!correctDose} onClose={() => setCorrectDose(null)} title="Edit Dose (Correction)" maxWidth="sm">
<div className="space-y-3">
<p className="text-xs text-gray-400">Original is kept. A correction record is added.</p>
{correctDose && (
<div className="text-xs bg-gray-50 dark:bg-gray-700 rounded-lg p-2 text-gray-500 dark:text-gray-400">
Original: {correctDose.dose.amount_given || "—"} · {new Date(correctDose.dose.administered_at).toLocaleString()}
</div>
)}
<Input placeholder="Corrected amount" value={correctAmount} onChange={e => setCorrectAmount(e.target.value)} />
<Input placeholder="Corrected notes" value={correctNotes} onChange={e => setCorrectNotes(e.target.value)} />
<Input placeholder="Reason for correction (optional)" value={correctReason} onChange={e => setCorrectReason(e.target.value)} />
<div className="flex gap-2">
<Button fullWidth loading={correctLoading} onClick={submitCorrection} className="bg-amber-500 hover:bg-amber-600">
Save Correction
</Button>
<Button variant="secondary" fullWidth onClick={() => setCorrectDose(null)}>Cancel</Button>
</div>
</div>
</Modal>
</div>
);
}

View file

@ -0,0 +1,173 @@
"use client";
import { useState, useEffect } from "react";
import { TabBar } from "@/components/TabBar";
import { api } from "@/lib/api";
import type { Vaccination } from "@/types";
interface Props {
childId: string;
birthDate: string;
}
type VaccineStatus = "upcoming" | "completed" | "overdue";
const IAP_SCHEDULE = [
{ name: "BCG", weeks: 0 },
{ name: "OPV-0", weeks: 0 },
{ name: "HepB-1", weeks: 0 },
{ name: "OPV-1", weeks: 6 },
{ name: "Pentavalent-1", weeks: 6 },
{ name: "PCV-1", weeks: 6 },
{ name: "Rota-1", weeks: 6 },
{ name: "OPV-2", weeks: 10 },
{ name: "Pentavalent-2", weeks: 10 },
{ name: "PCV-2", weeks: 10 },
{ name: "Rota-2", weeks: 10 },
{ name: "OPV-3", weeks: 14 },
{ name: "Pentavalent-3", weeks: 14 },
{ name: "PCV-3", weeks: 14 },
{ name: "Rota-3", weeks: 14 },
{ name: "MR-1", weeks: 48 },
{ name: "JE-1", weeks: 48 },
{ name: "Vitamin A-1", weeks: 48 },
{ name: "OPV-4", weeks: 48 },
{ name: "MR-2", weeks: 96 },
{ name: "JE-2", weeks: 96 },
{ name: "DPT-Booster-1", weeks: 96 },
{ name: "Vitamin A-2", weeks: 96 },
{ name: "OPV-5", weeks: 96 },
{ name: "DPT-Booster-2", weeks: 208 },
{ name: "Td", weeks: 208 },
];
function calculateDueDate(birthDate: string, weeks: number): string {
const d = new Date(birthDate);
d.setDate(d.getDate() + weeks * 7);
return d.toISOString().split("T")[0];
}
const STATUS_TABS = [
{ value: "upcoming", label: "" },
{ value: "completed", label: "" },
{ value: "overdue", label: "" },
];
export function VaccineTab({ childId, birthDate }: Props) {
const [vaccinations, setVaccinations] = useState<Vaccination[]>([]);
const [loading, setLoading] = useState(true);
const [vaccineTab, setVaccineTab] = useState<VaccineStatus>("upcoming");
const [showAddDate, setShowAddDate] = useState<string | null>(null);
const [givenDate, setGivenDate] = useState("");
useEffect(() => {
api.get<{ vaccinations: Vaccination[] }>(`/api/vaccinations?childId=${childId}`)
.then(d => setVaccinations(d.vaccinations || []))
.catch(console.error)
.finally(() => setLoading(false));
}, [childId]);
const isGiven = (name: string) => vaccinations.some(v => v.vaccine_name === name && v.status === "given");
const getGivenDate = (name: string) => vaccinations.find(v => v.vaccine_name === name && v.status === "given")?.given_date;
const todayStr = new Date().toISOString().split("T")[0];
const getVaccineStatus = (name: string): VaccineStatus => {
const due = calculateDueDate(birthDate, IAP_SCHEDULE.find(v => v.name === name)?.weeks ?? 0);
if (isGiven(name)) return "completed";
if (due < todayStr) return "overdue";
return "upcoming";
};
const getByStatus = (status: VaccineStatus) =>
IAP_SCHEDULE.filter(v => {
const due = calculateDueDate(birthDate, v.weeks);
if (status === "completed") return isGiven(v.name);
if (status === "overdue") return !isGiven(v.name) && due < todayStr;
return !isGiven(v.name) && due >= todayStr;
}).sort((a, b) => a.weeks - b.weeks);
const handleMarkGiven = async (vaccineName: string) => {
const due = calculateDueDate(birthDate, IAP_SCHEDULE.find(v => v.name === vaccineName)?.weeks ?? 0);
const dateToSave = givenDate || todayStr;
try {
await api.post("/api/vaccinations", { childId, vaccineName, scheduledDate: due, givenDate: dateToSave, status: "given" });
setVaccinations(prev => [...prev, { vaccine_name: vaccineName, given_date: dateToSave, status: "given" }]);
} catch (err) {
console.error("Failed to mark given:", err);
}
setShowAddDate(null);
setGivenDate("");
};
const counts = {
upcoming: getByStatus("upcoming").length,
completed: getByStatus("completed").length,
overdue: getByStatus("overdue").length,
};
const statusTabs = STATUS_TABS.map(t => ({
value: t.value,
label: `${t.value.charAt(0).toUpperCase() + t.value.slice(1)} (${counts[t.value as VaccineStatus]})`,
}));
return (
<div className="space-y-2">
<TabBar tabs={statusTabs} value={vaccineTab} onChange={v => setVaccineTab(v as VaccineStatus)} danger="overdue" />
<h2 className="font-semibold mt-2">IAP Schedule</h2>
{loading ? (
<p className="text-gray-500">Loading...</p>
) : (
getByStatus(vaccineTab).map(vaccine => {
const due = calculateDueDate(birthDate, vaccine.weeks);
const status = getVaccineStatus(vaccine.name);
const actualDate = getGivenDate(vaccine.name);
const daysOverdue = status === "overdue"
? Math.floor((Date.now() - new Date(due).getTime()) / 86_400_000)
: 0;
return (
<div
key={vaccine.name}
className={`p-4 bg-white dark:bg-gray-800 rounded-xl ${status === "completed" ? "opacity-60" : ""} ${status === "overdue" ? "border-l-4 border-red-500" : ""}`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="font-medium">{vaccine.name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Due: {new Date(due).toLocaleDateString()}
{actualDate && ` · Given: ${new Date(actualDate).toLocaleDateString()}`}
</div>
{status === "overdue" && (
<div className="text-red-500 text-sm font-medium">{daysOverdue} days overdue</div>
)}
</div>
{isGiven(vaccine.name) ? (
<span className="text-green-500"></span>
) : showAddDate === vaccine.name ? (
<div className="flex gap-2">
<input
type="date"
value={givenDate}
onChange={e => setGivenDate(e.target.value)}
className="p-1 text-sm border dark:border-gray-600 rounded dark:bg-gray-700 dark:text-white"
/>
<button onClick={() => handleMarkGiven(vaccine.name)} className="px-3 py-1 bg-rose-400 text-white rounded-lg text-sm"></button>
<button onClick={() => { setShowAddDate(null); setGivenDate(""); }} className="px-2 py-1 text-gray-400"></button>
</div>
) : (
<button onClick={() => setShowAddDate(vaccine.name)} className="px-4 py-2 bg-rose-400 text-white rounded-lg text-sm">
Mark Given
</button>
)}
</div>
</div>
);
})
)}
</div>
);
}

View file

@ -0,0 +1,113 @@
"use client";
import { useState, useEffect } from "react";
import { Button, Input } from "@/components/ui";
import { api } from "@/lib/api";
import type { Visit } from "@/types";
interface Props { childId: string }
export function VisitTab({ childId }: Props) {
const [visits, setVisits] = useState<Visit[]>([]);
const [editingVisit, setEditingVisit] = useState<Visit | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [doctor, setDoctor] = useState("");
const [reason, setReason] = useState("");
const [date, setDate] = useState("");
const [notes, setNotes] = useState("");
useEffect(() => { fetchVisits(); }, [childId]);
const fetchVisits = () =>
api.get<{ visits: Visit[] }>(`/api/visits?childId=${childId}`)
.then(d => setVisits(d.visits || []))
.catch(console.error);
const save = async () => {
if (!doctor) return;
try {
if (editingVisit) {
await api.patch("/api/visits", { id: editingVisit.id, doctorName: doctor, reason, date, notes });
} else {
await api.post("/api/visits", { childId, doctorName: doctor, reason, date, notes });
}
fetchVisits();
} catch (err) { console.error("Failed to save visit:", err); }
reset();
};
const remove = async (id: string) => {
try {
await api.delete(`/api/visits?id=${id}`);
fetchVisits();
} catch (err) { console.error("Failed to delete visit:", err); }
};
const edit = (v: Visit) => {
setEditingVisit(v);
setDoctor(v.doctorName);
setReason(v.reason);
setDate(v.date);
setNotes(v.notes);
setShowAdd(true);
};
const reset = () => {
setEditingVisit(null);
setDoctor("");
setReason("");
setDate("");
setNotes("");
setShowAdd(false);
};
return (
<div className="space-y-2">
<h2 className="font-semibold mb-3">Doctor Visits</h2>
{showAdd && (
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
<Input placeholder="Doctor name" value={doctor} onChange={e => setDoctor(e.target.value)} />
<Input placeholder="Reason (e.g., Checkup, Fever)" value={reason} onChange={e => setReason(e.target.value)} />
<Input type="date" value={date} onChange={e => setDate(e.target.value)} />
<Input placeholder="Notes" value={notes} onChange={e => setNotes(e.target.value)} />
<div className="flex gap-2">
<Button fullWidth onClick={save}>{editingVisit ? "Update" : "Add"}</Button>
<Button variant="secondary" fullWidth onClick={reset}>Cancel</Button>
</div>
</div>
)}
{visits.length === 0 && !showAdd ? (
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl">
<p className="text-gray-500 dark:text-gray-400">No visits recorded</p>
</div>
) : (
visits.map(v => (
<div key={v.id} className="p-4 bg-white dark:bg-gray-800 rounded-xl">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="font-medium">{v.doctorName}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{new Date(v.date).toLocaleDateString()}
{v.reason && ` · ${v.reason}`}
{v.notes && ` · ${v.notes}`}
</div>
</div>
<div className="flex gap-2">
<button onClick={() => edit(v)} className="p-2 text-gray-400"></button>
<button onClick={() => remove(v.id)} className="p-2 text-red-400">🗑</button>
</div>
</div>
</div>
))
)}
{!showAdd && (
<button onClick={() => setShowAdd(true)} className="w-full p-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl text-gray-500 dark:text-gray-400">
+ Add Visit
</button>
)}
</div>
);
}

30
src/lib/api.ts Normal file
View file

@ -0,0 +1,30 @@
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string };
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return res.json() as Promise<T>;
}
export const api = {
get: <T>(url: string) =>
request<T>(url),
post: <T>(url: string, body: unknown) =>
request<T>(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
patch: <T>(url: string, body: unknown) =>
request<T>(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
delete: (url: string) =>
request<void>(url, { method: "DELETE" }),
};

42
src/lib/formatting.ts Normal file
View file

@ -0,0 +1,42 @@
export function calculateAge(birthDate: string): string {
if (!birthDate) return "";
const birth = new Date(birthDate);
const now = new Date();
let years = now.getFullYear() - birth.getFullYear();
let months = now.getMonth() - birth.getMonth();
let days = now.getDate() - birth.getDate();
if (days < 0) {
months--;
days += new Date(now.getFullYear(), now.getMonth(), 0).getDate();
}
if (months < 0) { years--; months += 12; }
const parts: string[] = [];
if (years > 0) parts.push(`${years} year${years > 1 ? "s" : ""}`);
if (months > 0) parts.push(`${months} month${months > 1 ? "s" : ""}`);
if (days > 0) parts.push(`${days} day${days > 1 ? "s" : ""}`);
return parts.length > 0 ? parts.join(", ") : "Newborn";
}
export function formatAge(birthDate: string, measurementDate?: string): string {
const birth = new Date(birthDate);
const ref = measurementDate ? new Date(measurementDate) : new Date();
const totalDays = (ref.getTime() - birth.getTime()) / (1000 * 60 * 60 * 24);
const years = Math.floor(totalDays / 365);
const months = Math.floor((totalDays % 365) / 30);
if (years > 0 && months > 0) return `${years}y ${months}mo`;
if (years > 0) return `${years}y`;
if (months > 0) return `${months}mo`;
return "Newborn";
}
export function formatTimeAgo(dateStr: string | null | undefined): string | null {
if (!dateStr) return null;
const diffMs = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diffMs / 60_000);
const hours = Math.floor(mins / 60);
const days = Math.floor(hours / 24);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
if (hours < 24) return `${hours}h ago`;
return `${days}d ago`;
}

102
src/types/index.ts Normal file
View file

@ -0,0 +1,102 @@
// Shared domain types — import from here rather than defining inline in each page
export interface Child {
id: string;
name: string;
birthDate: string;
sex: string;
}
export type LogType = "feed" | "diaper" | "sleep";
export interface Log {
id: string;
type: LogType;
subType?: string;
amount?: number | null;
notes?: string | null;
loggedAt: string;
}
export interface GrowthRecord {
id: string;
child_id: string;
measured_at: string;
weight_kg: number | null;
height_cm: number | null;
head_circumference_cm: number | null;
notes: string | null;
}
export interface Goal {
weightKg?: number;
heightCm?: number;
targetDate?: string;
}
export interface Medicine {
id: string;
name: string;
dose: string;
notes: string;
reminderTime?: string;
}
export interface DoseCorrection {
id: string;
corrected_value: Record<string, string>;
reason: string | null;
created_at: string;
}
export interface Dose {
id: string;
administered_at: string;
amount_given: string | null;
notes: string | null;
corrections?: DoseCorrection[];
}
export interface Allergy {
id: string;
name: string;
severity: string;
notes: string;
}
export interface Visit {
id: string;
doctorName: string;
reason: string;
date: string;
notes: string;
}
export interface Illness {
id: string;
name: string;
startDate: string;
endDate?: string;
notes: string;
}
export interface Vaccination {
vaccine_name: string;
given_date: string;
status: "given" | "pending";
}
export interface AIChat {
id: string;
role: "user" | "assistant";
content: string;
createdAt: string;
}
export interface ChatSession {
id: string;
title: string;
messages: AIChat[];
createdAt: string;
updatedAt: string;
}