AI route was reading LITELLM_URL/LITELLM_KEY but env vars are named LITELLM_BASE_URL/LITELLM_API_KEY — causing 503 on every request. Home page chat now creates a fresh session each time the modal opens and resets homeSessionId on close, so conversations don't pile into the same old session. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
352 lines
No EOL
17 KiB
TypeScript
352 lines
No EOL
17 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import Link from "next/link";
|
|
import { useTheme } from "./ThemeProvider";
|
|
import { useFamily } from "./FamilyProvider";
|
|
|
|
const OFFLINE_QUEUE_KEY = "tia_offline_queue";
|
|
|
|
interface AIChat {
|
|
id: string;
|
|
role: "user" | "assistant";
|
|
content: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
interface ChatSession {
|
|
id: string;
|
|
title: string;
|
|
messages: AIChat[];
|
|
createdAt: 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));
|
|
}
|
|
|
|
// --- AI Session Functions (using database) ---
|
|
async function getSessions(cid: string): Promise<ChatSession[]> {
|
|
try {
|
|
const res = await fetch(`/api/chat?childId=${cid}`);
|
|
const data = await res.json();
|
|
return data.sessions || [];
|
|
} catch { return []; }
|
|
}
|
|
|
|
async function createSession(cid: string): Promise<ChatSession | null> {
|
|
try {
|
|
const res = await fetch("/api/chat", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ childId: cid, title: "New conversation" }),
|
|
});
|
|
const data = await res.json();
|
|
return data.session || 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("");
|
|
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 && !navigator.onLine) addToOfflineQueue({ type: type as any, data });
|
|
onClose();
|
|
} catch { addToOfflineQueue({ type: type as any, data }); onClose(); }
|
|
setLoading(false);
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 w-full max-w-sm mx-4">
|
|
<h2 className="text-xl font-bold mb-4">{type === "feed" && "Log Feed"}{type === "diaper" && "Log Diaper"}{type === "sleep" && "Log Sleep"}</h2>
|
|
{type === "feed" && (<><select value={subType} onChange={e => setSubType(e.target.value)} className="w-full p-3 border dark:border-gray-600 rounded-xl mb-3 bg-white dark:bg-gray-700 dark:text-white"><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)} className="w-full p-3 border dark:border-gray-600 rounded-xl mb-3 bg-white dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" /></>)}
|
|
{type === "diaper" && (<select value={subType} onChange={e => setSubType(e.target.value)} className="w-full p-3 border dark:border-gray-600 rounded-xl mb-3 bg-white dark:bg-gray-700 dark:text-white"><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)} className="w-full p-3 border dark:border-gray-600 rounded-xl mb-3 bg-white dark:bg-gray-700 dark:text-white"><option value="nap">Nap</option><option value="night">Night Sleep</option></select>)}
|
|
<input type="text" placeholder="Notes (optional)" value={notes} onChange={e => setNotes(e.target.value)} className="w-full p-3 border dark:border-gray-600 rounded-xl mb-4 bg-white dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
|
|
<div className="flex gap-3"><button onClick={onClose} className="flex-1 p-3 border dark:border-gray-600 rounded-xl dark:text-white">Cancel</button><button onClick={handleSubmit} disabled={loading} className="flex-1 p-3 bg-rose-400 text-white rounded-xl">{loading ? "Saving..." : "Save"}</button></div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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() {
|
|
const hour = new Date().getHours();
|
|
if (hour < 12) return "Good morning";
|
|
if (hour < 18) return "Good afternoon";
|
|
return "Good evening";
|
|
}
|
|
|
|
const QUICK_QUESTIONS = ["How much should my baby eat?", "When should baby sleep?", "Is fever normal?", "How to increase milk supply?", "Baby won't sleep", "Starting solids?"];
|
|
|
|
export default function HomePage() {
|
|
const [modalType, setModalType] = useState<"feed" | "diaper" | "sleep" | null>(null);
|
|
const [aiOpen, setAiOpen] = useState(false);
|
|
const [aiInput, setAiInput] = useState("");
|
|
const [aiChats, setAiChats] = useState<AIChat[]>([]);
|
|
const [aiLoading, setAiLoading] = useState(false);
|
|
const [homeSessionId, setHomeSessionId] = useState<string | null>(null);
|
|
const [pendingCount, setPendingCount] = useState(0);
|
|
const [lastLogs, setLastLogs] = useState<any[]>([]);
|
|
const [vaccineReminders, setVaccineReminders] = useState<any[]>([]);
|
|
const { theme, toggle: toggleTheme } = useTheme();
|
|
const { childId, child, familyId, loading } = useFamily();
|
|
|
|
useEffect(() => {
|
|
if (!childId) return;
|
|
getSessions(childId).then(sessions => {
|
|
if (sessions.length > 0) {
|
|
setAiChats(sessions[0].messages);
|
|
}
|
|
});
|
|
}, [childId]);
|
|
|
|
useEffect(() => {
|
|
if (!childId) return;
|
|
const queue = getOfflineQueue();
|
|
setPendingCount(queue.length);
|
|
const handleOnline = () => processOfflineQueue();
|
|
window.addEventListener("online", handleOnline);
|
|
fetch(`/api/notifications?childId=${childId}`)
|
|
.then(res => res.json())
|
|
.then(data => setVaccineReminders(data.notifications || []))
|
|
.catch(console.error);
|
|
return () => window.removeEventListener("online", handleOnline);
|
|
}, [childId]);
|
|
|
|
useEffect(() => {
|
|
if (!childId) return;
|
|
Promise.all([
|
|
fetch(`/api/logs?type=feed&childId=${childId}&limit=1`).then(r => r.json()),
|
|
fetch(`/api/logs?type=sleep&childId=${childId}&limit=1`).then(r => r.json()),
|
|
fetch(`/api/logs?type=diaper&childId=${childId}&limit=1`).then(r => r.json()),
|
|
]).then(([feed, sleep, diaper]) => setLastLogs([feed.entries?.[0], sleep.entries?.[0], diaper.entries?.[0]].filter(Boolean)));
|
|
}, [childId]);
|
|
|
|
if (loading) {
|
|
return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
|
|
}
|
|
|
|
// Not logged in - redirect to login
|
|
if (!familyId) {
|
|
if (typeof window !== "undefined") {
|
|
window.location.href = "/login";
|
|
}
|
|
return <div className="min-h-screen flex items-center justify-center">Redirecting...</div>;
|
|
}
|
|
|
|
if (!childId) {
|
|
return <div className="min-h-screen flex items-center justify-center">No child found. Add a child in Family settings.</div>;
|
|
}
|
|
|
|
const toggleDarkMode = () => {
|
|
toggleTheme();
|
|
};
|
|
|
|
// Unified AI chat — creates a fresh session per modal open, reuses it for follow-ups
|
|
const handleAiChat = async (question?: string) => {
|
|
const q = question || aiInput;
|
|
if (!q.trim() || aiLoading) return;
|
|
setAiLoading(true);
|
|
setAiOpen(true);
|
|
|
|
const inputVal = q.trim();
|
|
if (!question) setAiInput("");
|
|
|
|
// Reuse session within same modal open, or create fresh one
|
|
let sessionId = homeSessionId;
|
|
if (!sessionId) {
|
|
const newSession = await createSession(childId);
|
|
if (!newSession) { setAiLoading(false); return; }
|
|
sessionId = newSession.id;
|
|
setHomeSessionId(sessionId);
|
|
setAiChats([]);
|
|
}
|
|
|
|
// Optimistically show user message
|
|
const userMsg: AIChat = { id: "tmp-" + Date.now(), role: "user", content: inputVal, createdAt: new Date().toISOString() };
|
|
setAiChats(prev => [...prev, userMsg]);
|
|
|
|
try {
|
|
await fetch("/api/chat", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ sessionId, role: "user", content: inputVal }),
|
|
});
|
|
|
|
const res = await fetch("/api/ai", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ messages: [{ role: "user", content: inputVal }] }),
|
|
});
|
|
const data = await res.json();
|
|
const reply = data.reply || "Sorry, I'm having trouble connecting. Please try again.";
|
|
|
|
await fetch("/api/chat", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ sessionId, role: "assistant", content: reply }),
|
|
});
|
|
|
|
setAiChats(prev => [...prev, { id: "ai-" + Date.now(), role: "assistant", content: reply, createdAt: new Date().toISOString() }]);
|
|
} catch (err) {
|
|
console.error("AI chat error:", err);
|
|
}
|
|
setAiLoading(false);
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
|
<div className="p-4 flex justify-between items-center">
|
|
<button className="p-2"><Link href="/menu"><svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg></Link></button>
|
|
<button onClick={toggleDarkMode} className="p-2">{theme === "dark" ? "☀️" : "🌙"}</button>
|
|
</div>
|
|
|
|
<div className="px-6 pb-4">
|
|
<h1 className="text-2xl font-bold">{getGreeting()} 👋</h1>
|
|
<p className="text-gray-600 dark:text-gray-300">How is {child?.name || "your baby"} doing today?</p>
|
|
</div>
|
|
|
|
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-md">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-16 h-16 bg-rose-100 dark:bg-rose-900 rounded-full flex items-center justify-center text-2xl">👶</div>
|
|
<div><div className="text-lg font-semibold">{child?.name || "Baby"}</div><div className="text-gray-500 dark:text-gray-400">{calculateAge(child?.birthDate || "")}</div></div>
|
|
</div>
|
|
</div>
|
|
|
|
{pendingCount > 0 && <div className="mx-4 mb-4 bg-amber-100 text-amber-800 px-4 py-2 rounded-xl text-center">{pendingCount} pending log{pendingCount > 1 ? "s" : ""}</div>}
|
|
|
|
{vaccineReminders.length > 0 && (
|
|
<div className="mx-4 mb-4 bg-red-50 border border-red-200 px-4 py-3 rounded-xl">
|
|
<Link href="/medical" className="flex items-center justify-between">
|
|
<div>
|
|
<div className="font-semibold text-red-700">💊 Vaccine Reminder</div>
|
|
<div className="text-sm text-red-600">
|
|
{vaccineReminders[0].status === "overdue"
|
|
? `${vaccineReminders[0].message}`
|
|
: `${vaccineReminders[0].vaccineName} due today`}
|
|
</div>
|
|
</div>
|
|
<span className="text-red-400">→</span>
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
<div className="px-4 mb-4">
|
|
<h2 className="font-semibold mb-3 ml-1">Quick Log</h2>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
<button onClick={() => setModalType("feed")} className="flex flex-col items-center p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm"><span className="text-3xl">🍼</span><span className="text-sm mt-1">Feed</span></button>
|
|
<button onClick={() => setModalType("sleep")} className="flex flex-col items-center p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm"><span className="text-3xl">😴</span><span className="text-sm mt-1">Sleep</span></button>
|
|
<button onClick={() => setModalType("diaper")} className="flex flex-col items-center p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm"><span className="text-3xl">👶</span><span className="text-sm mt-1">Diaper</span></button>
|
|
<Link href="/medical" className="flex flex-col items-center p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm"><span className="text-3xl">💊</span><span className="text-sm mt-1">Medical</span></Link>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-4">
|
|
<div className="flex justify-between items-center mb-3">
|
|
<h2 className="font-semibold ml-1">Ask AI</h2>
|
|
<Link href="/ai" className="text-rose-500 text-sm">View all chats →</Link>
|
|
</div>
|
|
<div className="p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-md">
|
|
<div className="flex gap-2 mb-3">
|
|
<input type="text" value={aiInput} onChange={e => setAiInput(e.target.value)} onKeyDown={e => e.key === "Enter" && handleAiChat()} placeholder="Ask anything..." className="flex-1 p-2 border dark:border-gray-600 rounded-xl text-sm bg-white dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" disabled={aiLoading} />
|
|
<button onClick={() => handleAiChat()} disabled={aiLoading || !aiInput.trim()} className="px-4 bg-rose-400 text-white rounded-xl text-sm">{aiLoading ? "..." : "Ask"}</button>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{QUICK_QUESTIONS.map((q, i) => <button key={i} onClick={() => handleAiChat(q)} className="px-3 py-1 bg-rose-50 dark:bg-gray-700 text-rose-600 dark:text-rose-400 rounded-full text-sm">{q}</button>)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-4 mt-4">
|
|
<h2 className="font-semibold mb-3 ml-1">Recent Activity</h2>
|
|
<div className="space-y-2">
|
|
{lastLogs.length === 0 ? <p className="text-gray-400 text-sm">No logs yet today</p> : lastLogs.filter(Boolean).map((log: any, i: number) => (
|
|
<div key={i} className="flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-xl">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xl">{log.type === "feed" && "🍼"}{log.type === "sleep" && "😴"}{log.type === "diaper" && "👶"}</span>
|
|
<div><div className="font-medium capitalize">{log.type}</div><div className="text-xs text-gray-500 dark:text-gray-400">{new Date(log.logged_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div></div>
|
|
</div>
|
|
{log.amount_ml && <span className="text-sm text-gray-500 dark:text-gray-400">{log.amount_ml}ml</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<LogModal type={modalType} childId={childId} onClose={() => setModalType(null)} />
|
|
|
|
{aiOpen && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => { setAiOpen(false); setHomeSessionId(null); }}>
|
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 w-full max-w-sm mx-4 max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
|
<div className="flex justify-between items-center mb-3"><h2 className="font-bold">Ask AI</h2><button onClick={() => { setAiOpen(false); setHomeSessionId(null); }}>✕</button></div>
|
|
<div className="flex-1 overflow-y-auto space-y-3 mb-3 min-h-[250px]">
|
|
{aiChats.map((chat) => (<div key={chat.id} className={`max-w-[85%] p-3 rounded-xl text-sm ${chat.role === "user" ? "ml-auto bg-rose-400 text-white" : "bg-rose-50 dark:bg-gray-700"}`}>{chat.content}</div>))}
|
|
{aiLoading && <div className="bg-rose-50 dark:bg-gray-700 p-3 rounded-xl text-sm animate-pulse">Thinking...</div>}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<input type="text" value={aiInput} onChange={e => setAiInput(e.target.value)} onKeyDown={e => e.key === "Enter" && handleAiChat()} placeholder="Ask a question..." className="flex-1 p-2 border dark:border-gray-600 rounded-xl text-sm dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" disabled={aiLoading} />
|
|
<button onClick={() => handleAiChat()} disabled={aiLoading || !aiInput.trim()} className="px-3 bg-rose-400 text-white rounded-xl">➤</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |