chore: initial import

This commit is contained in:
chelsea
2025-11-11 23:11:59 -06:00
parent 7598942bc5
commit c15fe83651
28 changed files with 3755 additions and 4 deletions

804
web_App.tsx Normal file
View File

@@ -0,0 +1,804 @@
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<string | null>(null);
const [actions, setActions] = useState<ActionItem[]>([]);
const [actionsLoading, setActionsLoading] = useState(false);
const [actionsError, setActionsError] = useState<string | null>(null);
const [newActionTitle, setNewActionTitle] = useState("");
const [newActionCadence, setNewActionCadence] = useState("daily");
const [newActionInterval, setNewActionInterval] = useState("");
const [newActionDetails, setNewActionDetails] = useState("");
const [progressDrafts, setProgressDrafts] = useState<Record<string, ProgressDraft>>({});
const [messages, setMessages] = useState<Message[]>(() => {
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<number | null>(null);
const selectedPrompt = useMemo(() => promptOptions[modeIndex], [modeIndex]);
const conversationRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(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<HTMLFormElement>) => {
event.preventDefault();
void sendMessage();
};
const handleComposerKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
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<HTMLFormElement>) => {
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 (
<div className="chat-shell">
<header className="chat-header glass">
<div>
<p className="eyebrow">ADHD Coach Console</p>
<h1>Instant Messaging</h1>
<p className="muted">One thread for prompts, plans, notes, and reminders.</p>
<div className="status-row">
<StatusChip variant={isOnline ? "online" : "offline"} label={isOnline ? "Online" : "Offline"} />
<button type="button" className="reset-chat" onClick={handleResetConversation}>
Reset chat
</button>
</div>
</div>
<div className="header-controls">
<label>
<span>User</span>
<input value={userId} onChange={(e) => setUserId(e.target.value)} autoComplete="off" />
</label>
<label>
<span>Mode</span>
<select value={modeIndex} onChange={(e) => setModeIndex(Number(e.target.value))}>
{promptOptions.map((option, index) => (
<option key={option.promptName} value={index}>
{option.label}
</option>
))}
</select>
</label>
<small className="muted tiny">{selectedPrompt.description}</small>
</div>
</header>
<section className="stat-row">
<StatCard label="Current mode" value={selectedPrompt.label} sublabel={selectedPrompt.description} accent={selectedPrompt.accent} />
<StatCard label="Action items" value={actionSummary.activeCount} sublabel={`Last update ${formattedActionUpdate}`} />
<StatCard label="Progress notes" value={actionSummary.totalProgress} sublabel="All time" />
<StatCard label="Chat turns" value={conversationSummary.turns} sublabel={`Last reply ${formattedLastReply}`} />
</section>
<ModeSwitch options={promptOptions} activeIndex={modeIndex} onChange={setModeIndex} />
<section className="dashboard-grid">
<div className="conversation-stack glass">
<div className="conversation-header">
<div>
<p className="eyebrow">Live thread</p>
<h2>{selectedPrompt.label}</h2>
<p className="muted tiny">{selectedPrompt.description}</p>
</div>
<span className="muted tiny">User ID: {userId || "—"}</span>
</div>
<div className="chat-main" ref={conversationRef}>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{isSending && <TypingIndicator />}
</div>
<section className="composer-wrapper">
<form className="composer-form" onSubmit={handleSubmit}>
<textarea
ref={composerRef}
rows={2}
value={context}
onChange={(event) => setContext(event.target.value)}
onKeyDown={handleComposerKeyDown}
placeholder="Type anything… reminders, notes, or planning requests."
/>
<button type="submit" disabled={!canSend}>
{isSending ? "Sending…" : "Send"}
</button>
</form>
<div className="composer-meta">
<span>{isOverLimit ? "Trim this message a little." : "Cmd/Ctrl + Enter to send"}</span>
<span className={isOverLimit ? "muted danger" : "muted"}>
{charCount}/{maxContextLength}
</span>
</div>
{error && <p className="error-banner">{error}</p>}
<SuggestionChips suggestions={contextSuggestions} onSelect={applySuggestion} />
</section>
</div>
<section className="actions-panel glass">
<div className="actions-panel__header">
<div>
<p className="eyebrow">Daily & Periodic</p>
<h2>Action Items</h2>
<p className="muted tiny">Last synced {formattedRefresh}</p>
</div>
<button type="button" onClick={() => fetchActions(userId)} className="refresh-button">
Refresh
</button>
</div>
<form className="new-action-form" onSubmit={handleNewAction}>
<div className="field-cluster">
<label>
<span>Title</span>
<input value={newActionTitle} onChange={(e) => setNewActionTitle(e.target.value)} required />
</label>
<label>
<span>Cadence</span>
<select value={newActionCadence} onChange={(e) => setNewActionCadence(e.target.value)}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="periodic">Periodic</option>
</select>
</label>
<label>
<span>Interval (min)</span>
<input
type="number"
min={0}
value={newActionInterval}
onChange={(e) => setNewActionInterval(e.target.value)}
placeholder="Optional"
/>
</label>
</div>
<label>
<span>Details</span>
<textarea
rows={2}
value={newActionDetails}
onChange={(e) => setNewActionDetails(e.target.value)}
placeholder="What should you consider when this comes up?"
/>
</label>
<button type="submit">Add Action</button>
</form>
{actionsError && <p className="error-banner">{actionsError}</p>}
{actionsLoading ? (
<p className="muted">Loading actions</p>
) : actions.length === 0 ? (
<p className="muted">No saved actions yet. Add one above to experiment.</p>
) : (
<ul className="action-list">
{actions.map((action) => (
<ActionCard
key={action.id}
action={action}
draft={loadProgressDraft(action.id)}
onDraftChange={(field, value) => handleProgressDraftChange(action.id, field, value)}
onSubmit={() => handleProgressSubmit(action.id)}
onDelete={() => handleDeleteAction(action.id)}
/>
))}
</ul>
)}
</section>
</section>
</div>
);
}
type MessageBubbleProps = {
message: Message;
};
function MessageBubble({ message }: MessageBubbleProps) {
const authorLabel = message.role === "user" ? "You" : message.role === "assistant" ? "ADHDbot" : "System";
const timeLabel = new Intl.DateTimeFormat("en", { hour: "numeric", minute: "2-digit" }).format(message.timestamp);
return (
<article className={`bubble ${message.role}`}>
<div className="meta">
<span>
{authorLabel}
{message.status === "pending" && <span className="pill pill--pending">sending</span>}
{message.status === "error" && <span className="pill pill--error">error</span>}
</span>
<time dateTime={new Date(message.timestamp).toISOString()}>{timeLabel}</time>
</div>
<p>{message.text}</p>
</article>
);
}
function TypingIndicator() {
return (
<article className="bubble assistant ghost">
<div className="meta">
<span>ADHDbot</span>
<span className="muted tiny">drafting</span>
</div>
<p className="typing-dots">
<span />
<span />
<span />
</p>
</article>
);
}
type SuggestionChipsProps = {
suggestions: string[];
onSelect: (value: string) => void;
};
function SuggestionChips({ suggestions, onSelect }: SuggestionChipsProps) {
return (
<div className="suggestion-row">
{suggestions.map((suggestion) => (
<button key={suggestion} type="button" onClick={() => onSelect(suggestion)}>
{suggestion}
</button>
))}
</div>
);
}
type StatCardProps = {
label: string;
value: string | number;
sublabel?: string;
accent?: string;
};
function StatCard({ label, value, sublabel, accent }: StatCardProps) {
return (
<div className="stat-card glass">
<p className="stat-label">{label}</p>
<strong style={{ color: accent ?? "#f8fafc" }}>{value}</strong>
{sublabel && <small className="muted tiny">{sublabel}</small>}
</div>
);
}
type ModeSwitchProps = {
options: PromptOption[];
activeIndex: number;
onChange: (index: number) => void;
};
function ModeSwitch({ options, activeIndex, onChange }: ModeSwitchProps) {
return (
<div className="mode-switch">
{options.map((option, index) => (
<button
key={option.promptName}
type="button"
className={`mode-pill ${index === activeIndex ? "active" : ""}`}
onClick={() => onChange(index)}
>
<span className="mode-pill__label">
<span className="mode-pill__dot" style={{ background: option.accent }} />
{option.label}
</span>
<small>{option.description}</small>
</button>
))}
</div>
);
}
type StatusChipProps = {
label: string;
variant?: "online" | "offline" | "default";
};
function StatusChip({ label, variant = "default" }: StatusChipProps) {
return (
<span className={`status-chip ${variant}`}>
<span className="status-dot" />
{label}
</span>
);
}
type ActionCardProps = {
action: ActionItem;
draft: ProgressDraft;
onDraftChange: (field: keyof ProgressDraft, value: string) => void;
onSubmit: () => void;
onDelete: () => void;
};
function ActionCard({ action, draft, onDraftChange, onSubmit, onDelete }: ActionCardProps) {
const recentLabel = lastProgressLabel(action);
return (
<li className="action-card glass">
<div className="action-card__body">
<div>
<div className="action-card__title-row">
<h3>{action.title}</h3>
<span className="pill">{action.cadence}</span>
</div>
<p>{action.details || "No extra details stored."}</p>
</div>
<dl>
<div>
<dt>Interval</dt>
<dd>{action.interval_minutes ?? "—"}</dd>
</div>
<div>
<dt>Created</dt>
<dd>{formatDateSafe(action.created_at)}</dd>
</div>
<div>
<dt>Updated</dt>
<dd>{formatDateSafe(action.updated_at)}</dd>
</div>
</dl>
</div>
<div className="action-card__progress">
<p className="muted tiny">
<strong>Recent:</strong> {recentLabel}
</p>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<select value={draft.status} onChange={(event) => onDraftChange("status", event.target.value)}>
<option value="update">Update</option>
<option value="done">Done</option>
<option value="skipped">Skipped</option>
<option value="blocked">Blocked</option>
</select>
<input
value={draft.note}
onChange={(event) => onDraftChange("note", event.target.value)}
placeholder="Add note (optional)"
/>
<button type="submit">Log</button>
<button type="button" className="ghost" onClick={onDelete}>
Delete
</button>
</form>
</div>
</li>
);
}
function lastProgressLabel(item: ActionItem) {
if (!item.progress?.length) {
return "No progress logged yet.";
}
const entry = item.progress[item.progress.length - 1];
const time = formatDateSafe(entry.timestamp);
const note = entry.note ? `${entry.note}` : "";
return `${entry.status} @ ${time}${note}`;
}
function formatDateSafe(value: string | number | Date | null | undefined) {
if (value === null || value === undefined) {
return "—";
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return "—";
}
return shortDateFormatter.format(date);
}
export default App;