import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent } from "react"; import "./App.css"; type MessageRole = "user" | "assistant" | "system"; type MessageStatus = "sent" | "pending" | "error"; type Message = { id: string; role: MessageRole; text: string; timestamp: number; status?: MessageStatus; }; type ProgressEntry = { timestamp: string; status: string; note?: string | null; }; type ActionItem = { id: string; title: string; cadence: string; details?: string | null; interval_minutes?: number | null; created_at: string; updated_at: string; progress: ProgressEntry[]; }; type ProgressDraft = { status: string; note: string; }; type PromptOption = { label: string; description: string; category: string; promptName: string; accent: string; }; const promptOptions: PromptOption[] = [ { label: "General support", description: "Quick welcome / encouragement.", category: "general", promptName: "welcome", accent: "#38bdf8", }, { label: "Plan a thing", description: "Break work into small steps.", category: "planning", promptName: "breakdown", accent: "#f472b6", }, { label: "Schedule reminder", description: "Confirm timing + emit JSON.", category: "reminders", promptName: "schedule", accent: "#c084fc", }, ]; const contextSuggestions = [ "Take a note that I'm experimenting with DeepSeek.", "Help me plan my inbox zero session for 30 min.", "Remind me in 10 minutes to stand up and stretch.", "Break down cleaning my kitchen tonight.", "Draft a gentle check-in for future-me about therapy homework.", ]; const storageKey = "adhd-conversation-cache"; const maxContextLength = 1500; const defaultProgress: ProgressDraft = { status: "update", note: "" }; const safeId = () => { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } return `id-${Math.random().toString(36).slice(2)}`; }; const createSystemMessage = (): Message => ({ id: safeId(), role: "system", timestamp: Date.now(), text: "✨ Fresh chat. Pick a mode above, type anything below, and I'll relay it to ADHDbot.", }); const shortDateFormatter = new Intl.DateTimeFormat("en", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }); function App() { const [userId, setUserId] = useState("chelsea"); const [modeIndex, setModeIndex] = useState(0); const [context, setContext] = useState(""); const [isSending, setIsSending] = useState(false); const [error, setError] = useState(null); const [actions, setActions] = useState([]); const [actionsLoading, setActionsLoading] = useState(false); const [actionsError, setActionsError] = useState(null); const [newActionTitle, setNewActionTitle] = useState(""); const [newActionCadence, setNewActionCadence] = useState("daily"); const [newActionInterval, setNewActionInterval] = useState(""); const [newActionDetails, setNewActionDetails] = useState(""); const [progressDrafts, setProgressDrafts] = useState>({}); const [messages, setMessages] = useState(() => { if (typeof window === "undefined") { return [createSystemMessage()]; } try { const cached = window.localStorage.getItem(storageKey); if (cached) { const parsed = JSON.parse(cached) as Message[]; if (Array.isArray(parsed) && parsed.length) { return parsed; } } } catch { /* ignore */ } return [createSystemMessage()]; }); const [isOnline, setIsOnline] = useState(() => (typeof navigator === "undefined" ? true : navigator.onLine)); const [lastRefreshedAt, setLastRefreshedAt] = useState(null); const selectedPrompt = useMemo(() => promptOptions[modeIndex], [modeIndex]); const conversationRef = useRef(null); const composerRef = useRef(null); const charCount = context.length; const isOverLimit = charCount > maxContextLength; const canSend = Boolean(context.trim()) && !isSending && !isOverLimit; const loadProgressDraft = useCallback( (actionId: string): ProgressDraft => { return progressDrafts[actionId] ?? defaultProgress; }, [progressDrafts], ); const fetchActions = useCallback( async (targetUserId: string) => { if (!targetUserId) { return; } setActionsLoading(true); setActionsError(null); try { const response = await fetch(`/api/users/${targetUserId}/actions`, { credentials: "include", }); if (!response.ok) { throw new Error(`Failed to fetch actions: ${response.status}`); } const data = (await response.json()) as { action_items: ActionItem[] }; setActions(data.action_items ?? []); setProgressDrafts({}); setLastRefreshedAt(Date.now()); } catch (err) { console.error(err); setActionsError("Couldn't load action items."); } finally { setActionsLoading(false); } }, [], ); const actionSummary = useMemo(() => { if (!actions.length) { return { activeCount: 0, totalProgress: 0, lastUpdated: null as number | null }; } let lastUpdated: number | null = null; let totalProgress = 0; actions.forEach((action) => { const updated = Date.parse(action.updated_at); if (!Number.isNaN(updated) && (lastUpdated === null || updated > lastUpdated)) { lastUpdated = updated; } totalProgress += action.progress?.length ?? 0; }); return { activeCount: actions.length, totalProgress, lastUpdated }; }, [actions]); const conversationSummary = useMemo(() => { const assistantTurns = messages.filter((msg) => msg.role === "assistant").length; const userTurns = messages.filter((msg) => msg.role === "user").length; const lastReply = messages.length ? messages[messages.length - 1]?.timestamp : null; return { turns: assistantTurns + userTurns, lastReply, }; }, [messages]); const formattedActionUpdate = actionSummary.lastUpdated ? shortDateFormatter.format(actionSummary.lastUpdated) : "No updates yet"; const formattedRefresh = lastRefreshedAt ? shortDateFormatter.format(lastRefreshedAt) : "Not synced yet"; const formattedLastReply = conversationSummary.lastReply ? shortDateFormatter.format(conversationSummary.lastReply) : "—"; useEffect(() => { if (typeof window === "undefined") { return; } window.localStorage.setItem(storageKey, JSON.stringify(messages)); }, [messages]); useEffect(() => { conversationRef.current?.scrollTo({ top: conversationRef.current.scrollHeight, behavior: "smooth", }); }, [messages, isSending]); useEffect(() => { if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js").catch(() => undefined); } document.cookie = "adhd_auth=1; Path=/; Max-Age=31536000; SameSite=Lax"; }, []); useEffect(() => { fetchActions(userId); }, [userId, fetchActions]); useEffect(() => { const handleOnline = () => setIsOnline(true); const handleOffline = () => setIsOnline(false); window.addEventListener("online", handleOnline); window.addEventListener("offline", handleOffline); return () => { window.removeEventListener("online", handleOnline); window.removeEventListener("offline", handleOffline); }; }, []); useEffect(() => { if (!composerRef.current) { return; } const element = composerRef.current; element.style.height = "auto"; const maxHeight = 240; element.style.height = `${Math.min(element.scrollHeight, maxHeight)}px`; }, [context]); useEffect(() => { composerRef.current?.focus(); }, [modeIndex]); useEffect(() => { document.title = `ADHDbot • ${selectedPrompt.label}`; }, [selectedPrompt.label]); const sendMessage = useCallback(async () => { if (!context.trim() || isSending) { return; } const trimmedContext = context.trim(); const historyPayload = messages .filter((msg) => msg.role !== "system") .map((msg) => ({ role: msg.role, content: msg.text })); const payload = { userId, category: selectedPrompt.category, promptName: selectedPrompt.promptName, context: trimmedContext, history: historyPayload, modeHint: selectedPrompt.label, }; const userMessage: Message = { id: safeId(), role: "user", timestamp: Date.now(), text: trimmedContext, status: "sent", }; setMessages((prev) => [...prev, userMessage]); setContext(""); setIsSending(true); setError(null); try { const response = await fetch("/api/run", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(payload), }); if (!response.ok) { throw new Error(`Request failed: ${response.status}`); } const data = await response.json(); const botMessage: Message = { id: safeId(), role: "assistant", timestamp: Date.now(), text: data.message ?? "(No response returned)", status: "sent", }; setMessages((prev) => [...prev, botMessage]); } catch (err) { console.error(err); setError("Something went sideways. Double-check the API and try again."); const errorMessage: Message = { id: safeId(), role: "system", timestamp: Date.now(), text: "⚠️ Message failed. Please verify the API service is reachable.", status: "error", }; setMessages((prev) => [...prev, errorMessage]); } finally { setIsSending(false); } }, [context, isSending, messages, selectedPrompt.category, selectedPrompt.label, selectedPrompt.promptName, userId]); const handleSubmit = (event: FormEvent) => { event.preventDefault(); void sendMessage(); }; const handleComposerKeyDown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { event.preventDefault(); void sendMessage(); } }; const applySuggestion = (suggestion: string) => { setContext((prev) => (prev ? `${prev}\n${suggestion}` : suggestion)); composerRef.current?.focus(); }; const handleNewAction = async (event: FormEvent) => { event.preventDefault(); const payload = { title: newActionTitle.trim(), cadence: newActionCadence, interval_minutes: newActionInterval ? Number(newActionInterval) : null, details: newActionDetails.trim() || undefined, }; if (!payload.title) { return; } try { const response = await fetch(`/api/users/${userId}/actions`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(payload), }); if (!response.ok) { throw new Error(`Action create failed: ${response.status}`); } setNewActionTitle(""); setNewActionDetails(""); setNewActionInterval(""); setNewActionCadence("daily"); await fetchActions(userId); } catch (err) { console.error(err); setActionsError("Couldn't save the new action."); } }; const handleDeleteAction = async (actionId: string) => { if (!window.confirm("Remove this action item? This only affects local memory.")) { return; } try { const response = await fetch(`/api/users/${userId}/actions/${actionId}`, { method: "DELETE", credentials: "include", }); if (!response.ok && response.status !== 204) { throw new Error(`Delete failed: ${response.status}`); } await fetchActions(userId); } catch (err) { console.error(err); setActionsError("Couldn't delete that action."); } }; const handleProgressDraftChange = (actionId: string, field: keyof ProgressDraft, value: string) => { setProgressDrafts((prev) => { const base = prev[actionId] ?? defaultProgress; return { ...prev, [actionId]: { ...base, [field]: value, }, }; }); }; const handleProgressSubmit = async (actionId: string) => { const draft = loadProgressDraft(actionId); try { const response = await fetch(`/api/users/${userId}/actions/${actionId}/progress`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ status: draft.status || "update", note: draft.note || undefined, }), }); if (!response.ok) { throw new Error(`Progress failed: ${response.status}`); } setProgressDrafts((prev) => ({ ...prev, [actionId]: defaultProgress, })); await fetchActions(userId); } catch (err) { console.error(err); setActionsError("Couldn't log progress."); } }; const handleResetConversation = () => { if (!window.confirm("Start a fresh chat? This only clears your local history.")) { return; } const fresh = createSystemMessage(); setMessages([fresh]); if (typeof window !== "undefined") { window.localStorage.setItem(storageKey, JSON.stringify([fresh])); } }; return (

ADHD Coach Console

Instant Messaging

One thread for prompts, plans, notes, and reminders.

{selectedPrompt.description}

Live thread

{selectedPrompt.label}

{selectedPrompt.description}

User ID: {userId || "—"}
{messages.map((message) => ( ))} {isSending && }