From b4e0615e77263d5a23221e05fff82f539e9fdeb7 Mon Sep 17 00:00:00 2001 From: Ned Halksworth Date: Sat, 23 May 2026 20:25:21 +0100 Subject: [PATCH] Refactor coach to plain Appwrite storage with integrated overview UI. Remove client-side encryption, migrate coach_chats schema, fix the Ollama proxy, and embed coach on overview alongside the dedicated tab. Co-authored-by: Cursor --- .env.example | 5 +- APPWRITE_SETUP.md | 12 +- scripts/setup-appwrite.mjs | 30 +- src/App.tsx | 669 +++++----------------------------- src/components/CoachPanel.tsx | 195 ++++++++++ src/index.css | 354 +++++++++--------- src/lib/coachChats.ts | 107 ++++++ src/lib/encryptedChats.ts | 178 --------- src/lib/greeting.ts | 90 +++++ src/lib/useCoachSession.ts | 402 ++++++++++++++++++++ vite.config.ts | 98 ++++- 11 files changed, 1182 insertions(+), 958 deletions(-) create mode 100644 src/components/CoachPanel.tsx create mode 100644 src/lib/coachChats.ts delete mode 100644 src/lib/encryptedChats.ts create mode 100644 src/lib/greeting.ts create mode 100644 src/lib/useCoachSession.ts diff --git a/.env.example b/.env.example index 8705f05..a623884 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,5 @@ VITE_OLLAMA_PROXY_URL=/api/ollama-chat # Server/admin only. Never prefix with VITE_. Needed only for npm run setup:appwrite. APPWRITE_API_KEY= -# Appwrite chat table columns needed for encrypted coach chats: -# userId, encryptedTitle, encryptedMessages, titleIv, messagesIv, salt, updatedAt as strings -# version as integer. Enable row security and Users -> Create at table level. +# Appwrite chat table columns: userId, title, messages, updatedAt. +# Enable row security and Users -> Create at table level. diff --git a/APPWRITE_SETUP.md b/APPWRITE_SETUP.md index 3ed9557..b7736e6 100644 --- a/APPWRITE_SETUP.md +++ b/APPWRITE_SETUP.md @@ -180,19 +180,15 @@ Recommended table-level permissions: - Update: none - Delete: none -The app encrypts chat titles and messages in the browser before writing rows. The encryption passphrase is not stored, and Appwrite only receives ciphertext. +The app stores coach chat titles and messages as plain JSON in Appwrite with row-level user permissions. Create these chat columns: | Key | Type | Required | Notes | | --- | --- | --- | --- | | `userId` | String, 64 | Yes | Current Appwrite user ID | -| `encryptedTitle` | String, 4000 | Yes | AES-GCM ciphertext | -| `encryptedMessages` | String, 50000+ | Yes | AES-GCM ciphertext for message JSON | -| `titleIv` | String, 128 | Yes | Base64 IV | -| `messagesIv` | String, 128 | Yes | Base64 IV | -| `salt` | String, 128 | Yes | Base64 PBKDF2 salt | -| `version` | Integer | Yes | Crypto version | +| `title` | String, 512 | Yes | Chat title | +| `messages` | Longtext | Yes | JSON array of coach messages | | `updatedAt` | DateTime | Yes | Sort key | Recommended chat index: @@ -204,7 +200,7 @@ Recommended chat index: - `src/App.tsx`: UI shell, auth gate, dashboard/logbook/trends/coach/data views, modals, and action state. - `src/lib/appwrite.ts`: Appwrite SDK client, account/database services, env config, and ping helper. - `src/lib/appwriteEntries.ts`: User-scoped Appwrite CRUD, document permissions, duplicate signatures. -- `src/lib/encryptedChats.ts`: Client-side encrypted chat storage for Appwrite. +- `src/lib/coachChats.ts`: Appwrite-backed coach chat storage. - `src/lib/excel.ts`: Styled `.xlsx` export, summary sheet, row validation, duplicate-aware import preview. - `src/lib/metrics.ts`: Prices, caffeine/sugar estimates, stats, grouping, streaks. - `src/lib/storage.ts`: JSON backup export/import parser. diff --git a/scripts/setup-appwrite.mjs b/scripts/setup-appwrite.mjs index cc11891..b04777a 100644 --- a/scripts/setup-appwrite.mjs +++ b/scripts/setup-appwrite.mjs @@ -44,16 +44,21 @@ await ensureTable({ name: "Coach chats", columns: [ { kind: "string", key: "userId", size: 64, required: true }, - { kind: "string", key: "encryptedTitle", size: 4000, required: true, encrypt: true }, - { kind: "longtext", key: "encryptedMessages", required: true, encrypt: true }, - { kind: "string", key: "titleIv", size: 128, required: true }, - { kind: "string", key: "messagesIv", size: 128, required: true }, - { kind: "string", key: "salt", size: 128, required: true }, - { kind: "integer", key: "version", required: true }, + { kind: "string", key: "title", size: 512, required: true }, + { kind: "longtext", key: "messages", required: true }, { kind: "datetime", key: "updatedAt", required: true }, ], indexes: [{ key: "user_chat_updated", type: "key", columns: ["userId", "updatedAt"], orders: ["ASC", "DESC"], lengths: [32] }], }); +await retireLegacyChatColumns(chatTableId, [ + "encryptedTitle", + "encryptedMessages", + "titleIv", + "messagesIv", + "salt", + "version", +]); +await waitForColumns(chatTableId, ["userId", "title", "messages", "updatedAt"]); console.log("Appwrite database and tables ready."); @@ -122,6 +127,19 @@ async function ensureColumn(tableId, column) { console.log(`Column ${tableId}.${column.key} created.`); } +async function retireLegacyChatColumns(tableId, keys) { + for (const key of keys) { + const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [200, 404]); + if (existing.status === 404) { + console.log(`Legacy column ${tableId}.${key} already removed.`); + continue; + } + + await request("DELETE", `/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, undefined, [204, 404]); + console.log(`Legacy column ${tableId}.${key} removed.`); + } +} + async function ensureIndex(tableId, index) { const existing = await request("GET", `/tablesdb/${databaseId}/tables/${tableId}/indexes/${index.key}`, undefined, [200, 404]); if (existing.status === 200) { diff --git a/src/App.tsx b/src/App.tsx index 56217b7..8d3718f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,6 @@ import type { Models } from "appwrite"; import { Activity, AlertTriangle, - Brain, CalendarDays, CheckCircle2, ChevronRight, @@ -17,21 +16,17 @@ import { Home, LineChart, Loader2, - Lock, LogIn, LogOut, MessageCircle, - MessageSquarePlus, Plus, PoundSterling, RefreshCcw, RotateCcw, - Send, Search, Settings2, ShieldCheck, Sparkles, - Square, TimerReset, Trash2, Upload, @@ -88,13 +83,10 @@ import { listEntries, updateEntry, } from "./lib/appwriteEntries"; -import { - chatStorageErrorMessage, - createEncryptedChat, - deleteEncryptedChat, - listEncryptedChats, - updateEncryptedChat, -} from "./lib/encryptedChats"; +import { CoachPanel } from "./components/CoachPanel"; +import { buildDynamicGreeting } from "./lib/greeting"; +import type { CoachSession } from "./lib/useCoachSession"; +import { useCoachSession } from "./lib/useCoachSession"; import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel"; import { caffeineFor, @@ -123,15 +115,12 @@ import { wholeNumber, } from "./lib/metrics"; import { exportPayload, parseImport } from "./lib/storage"; -import type { CoachChat, CoachMessage, DateFilter, EntryDraft, Filters, Flavour, ImportPreview, RedBullEntry } from "./types"; +import type { DateFilter, EntryDraft, Filters, Flavour, ImportPreview, RedBullEntry } from "./types"; type AppView = "overview" | "logbook" | "trends" | "coach" | "settings"; type AuthMode = "login" | "signup"; type AuthUser = Models.User; type SetupStatus = { state: "checking" | "ok" | "error"; message: string }; -type OllamaStreamChunk = { error?: string; message?: { content?: string; thinking?: string } }; -const OLLAMA_MODEL = "deepseek-v4-pro:cloud"; -const OLLAMA_PROXY_URL = import.meta.env.VITE_OLLAMA_PROXY_URL?.trim() || "/api/ollama-chat"; const DEFAULT_FILTERS: Filters = { flavour: "all", @@ -284,6 +273,7 @@ function App() { const flavourData = useMemo(() => groupByFlavour(filteredEntries), [filteredEntries]); const insights = useMemo(() => buildInsights(entries), [entries]); const recentEntries = useMemo(() => entries.slice(0, 5), [entries]); + const coachSession = useCoachSession(user ?? { $id: "", email: "", name: "" } as AuthUser, dashboard, entries); async function login(email: string, password: string) { setActionLoading("auth"); @@ -596,7 +586,7 @@ function App() { transition={{ duration: 0.2 }} className="app-main" > - {activeView === "overview" && ( + {activeView === "overview" && user && ( void quickAdd(item)} onAdd={openNewEntry} - onOpenCoach={() => setActiveView("coach")} + onOpenCoach={(prompt) => { + if (prompt) coachSession.queuePrompt(prompt); + setActiveView("coach"); + }} onOpenLogbook={() => setActiveView("logbook")} /> )} @@ -642,7 +636,14 @@ function App() { /> )} - {activeView === "coach" && } + {activeView === "coach" && user && ( + + )} {activeView === "settings" && ( ; flavourData: Array<{ name: string; value: number; spend: number; accent: string }>; user: AuthUser; + coachSession: CoachSession; onQuickAdd: (item: (typeof QUICK_ADDS)[number]) => void; onAdd: () => void; - onOpenCoach: () => void; + onOpenCoach: (prompt?: string) => void; onOpenLogbook: () => void; }) { return (
- + + +
+ onOpenCoach()} + /> + +
- + +
+ + + + +
+
@@ -1263,20 +1287,39 @@ function OverviewView({ function GreetingPanel({ dashboard, - entries, user, onOpenCoach, }: { dashboard: Dashboard; - entries: RedBullEntry[]; user: AuthUser; - onOpenCoach: () => void; + onOpenCoach: (prompt?: string) => void; }) { const todayNumber = Number.parseFloat(dashboard.todayCans) || 0; const progress = Math.min(100, Math.round((todayNumber / 4) * 100)); const name = firstName(user); - const favourite = dashboard.favouriteFlavour === "None yet" ? "still forming" : dashboard.favouriteFlavour; - const redBullLabel = todayNumber === 1 ? "Red Bull" : "Red Bulls"; + const greeting = buildDynamicGreeting({ + name, + todayCans: todayNumber, + favouriteFlavour: dashboard.favouriteFlavour, + currentStreak: Number.parseInt(dashboard.currentStreak, 10) || 0, + todayCaffeineMg: Number.parseFloat(dashboard.todayCaffeine.replace(/[^\d.]/g, "")) || 0, + allTimeCans: Number.parseFloat(dashboard.allTimeCans) || 0, + }); + + const coachPrompts = [ + { + label: "Pace today's caffeine", + prompt: "what does my red bull pattern say about today?", + }, + { + label: "Sugar-free swap", + prompt: "give me one lower-sugar swap based on my favourite flavour.", + }, + { + label: "Weekly spend trend", + prompt: "review my weekly spend trend and suggest one saving.", + }, + ]; return (
@@ -1291,33 +1334,25 @@ function GreetingPanel({
-

- Hey {name}, you've had {dashboard.todayCans} {redBullLabel} today and your favourite flavour is {favourite}. -

-

- Clean caffeine, sugar, spend, and streak signals in one glance. -

+

{greeting.headline}

+

{greeting.subline}

- +
- - - + {coachPrompts.map((item) => ( + + ))}
); @@ -1542,467 +1577,6 @@ function TrendsView({ ); } -function CoachView({ dashboard, entries, user }: { dashboard: Dashboard; entries: RedBullEntry[]; user: AuthUser }) { - const [chats, setChats] = useState([]); - const [activeChatId, setActiveChatId] = useState(null); - const [savedChatIds, setSavedChatIds] = useState>(() => new Set()); - const [chatKey, setChatKey] = useState(""); - const [chatKeyInput, setChatKeyInput] = useState(""); - const [chatStorageStatus, setChatStorageStatus] = useState("unlock encrypted chat storage"); - const [input, setInput] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const [openThinkingIds, setOpenThinkingIds] = useState([]); - const abortRef = useRef(null); - const messagesEndRef = useRef(null); - const activeChat = chats.find((chat) => chat.id === activeChatId) ?? null; - const messages = useMemo(() => activeChat?.messages ?? [], [activeChat]); - const visibleMessages = useMemo(() => messages.filter((message) => message.id !== "coach-welcome"), [messages]); - - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ block: "end", behavior: "smooth" }); - }, [activeChatId, messages]); - - const quickPrompts = [ - "what does my red bull pattern say about today?", - "give me one lower-sugar swap based on my favourite flavour.", - "how should i pace caffeine for the rest of the day?", - ]; - - async function unlockChats(event: FormEvent) { - event.preventDefault(); - const passphrase = chatKeyInput.trim(); - if (!passphrase) return; - - setBusy(true); - setError(""); - setChatStorageStatus("opening encrypted appwrite chats..."); - try { - const savedChats = await listEncryptedChats(user.$id, passphrase); - const initialChats = savedChats.length ? savedChats : [buildNewCoachChat(user)]; - setChatKey(passphrase); - setChats(initialChats); - setSavedChatIds(new Set(savedChats.map((chat) => chat.id))); - setActiveChatId(initialChats[0].id); - setChatStorageStatus(savedChats.length ? `${savedChats.length} encrypted chat${savedChats.length === 1 ? "" : "s"} loaded` : "new encrypted chat ready"); - } catch (caught) { - const message = chatStorageErrorMessage(caught); - setError(message); - setChatKey(""); - setChatStorageStatus("encrypted chat unlock failed"); - } finally { - setBusy(false); - } - } - - function startNewChat() { - if (!chatKey) return; - const chat = buildNewCoachChat(user); - setChats((current) => [chat, ...current]); - setActiveChatId(chat.id); - setInput(""); - setError(""); - } - - async function submit(event: FormEvent) { - event.preventDefault(); - await sendPrompt(input); - } - - async function sendPrompt(prompt: string) { - const trimmed = prompt.trim(); - if (!trimmed || busy) return; - if (!chatKey) { - setError("unlock encrypted chat storage first."); - return; - } - - const currentChat = activeChat ?? buildNewCoachChat(user); - const userMessage: CoachMessage = { id: makeId(), role: "user", content: trimmed }; - const assistantId = makeId(); - const assistantMessage: CoachMessage = { id: assistantId, role: "assistant", content: "", thinking: "", pending: true }; - const conversation = [...currentChat.messages, userMessage]; - const now = new Date().toISOString(); - const draftChat: CoachChat = { - ...currentChat, - title: titleForChat(currentChat.title, trimmed), - messages: [...conversation, assistantMessage], - updatedAt: now, - }; - - upsertChatState(draftChat); - setActiveChatId(draftChat.id); - setInput(""); - setBusy(true); - setError(""); - - let streamedContent = ""; - let streamedThinking = ""; - const abortController = new AbortController(); - abortRef.current = abortController; - - try { - const requestMessages: Array<{ role: string; content: string; thinking?: string }> = [ - { role: "system", content: buildCoachSystemPrompt(user, dashboard, entries) }, - ...conversation - .filter((message) => message.content.trim().length > 0) - .map((message) => ({ - role: message.role, - content: message.content, - ...(message.thinking ? { thinking: message.thinking } : {}), - })), - ]; - - const response = await fetch(OLLAMA_PROXY_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: OLLAMA_MODEL, - messages: requestMessages, - stream: true, - think: true, - }), - signal: abortController.signal, - }); - - if (!response.ok) { - const detail = await response.text(); - throw new Error(detail || `Ollama request failed with status ${response.status}.`); - } - if (!response.body) { - throw new Error("Streaming response was empty."); - } - - await readOllamaStream(response.body, (chunk) => { - if (chunk.error) throw new Error(chunk.error); - if (chunk.message?.thinking) streamedThinking += chunk.message.thinking; - if (chunk.message?.content) streamedContent += chunk.message.content.toLocaleLowerCase(); - - patchAssistantMessage(draftChat.id, assistantId, { - content: streamedContent, - thinking: streamedThinking, - pending: true, - }); - }); - - const finalChat = withAssistantMessage(draftChat, assistantId, { - content: streamedContent || "no answer returned.", - thinking: streamedThinking, - pending: false, - }); - upsertChatState(finalChat); - await persistChat(finalChat); - } catch (caught) { - const aborted = abortController.signal.aborted; - const message = caught instanceof Error ? caught.message : "Coach request failed."; - const finalChat = withAssistantMessage(draftChat, assistantId, { - content: aborted ? streamedContent || "stopped thinking." : `coach unavailable: ${message}`.toLocaleLowerCase(), - thinking: streamedThinking, - pending: false, - stopped: aborted, - }); - upsertChatState(finalChat); - await persistChat(finalChat); - if (!aborted) setError(message); - } finally { - abortRef.current = null; - setBusy(false); - } - } - - function stopThinking() { - abortRef.current?.abort(); - } - - function toggleThinking(id: string) { - setOpenThinkingIds((current) => (current.includes(id) ? current.filter((value) => value !== id) : [...current, id])); - } - - function upsertChatState(chat: CoachChat) { - setChats((current) => { - const exists = current.some((item) => item.id === chat.id); - return exists ? current.map((item) => (item.id === chat.id ? chat : item)) : [chat, ...current]; - }); - } - - function patchAssistantMessage(chatId: string, messageId: string, patch: Partial) { - setChats((current) => - current.map((chat) => - chat.id === chatId - ? { - ...chat, - updatedAt: new Date().toISOString(), - messages: chat.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)), - } - : chat, - ), - ); - } - - function withAssistantMessage(chat: CoachChat, messageId: string, patch: Partial): CoachChat { - return { - ...chat, - updatedAt: new Date().toISOString(), - messages: chat.messages.map((message) => (message.id === messageId ? { ...message, ...patch } : message)), - }; - } - - async function persistChat(chat: CoachChat) { - if (!chatKey) return; - try { - const saved = savedChatIds.has(chat.id) - ? await updateEncryptedChat(user.$id, chatKey, chat) - : await createEncryptedChat(user.$id, chatKey, chat); - setSavedChatIds((current) => new Set(current).add(saved.id)); - upsertChatState(saved); - setChatStorageStatus("encrypted chat saved to appwrite"); - } catch (caught) { - setChatStorageStatus("encrypted chat save failed"); - setError(chatStorageErrorMessage(caught)); - } - } - - async function removeChat(chatId: string) { - if (busy) return; - try { - if (savedChatIds.has(chatId)) await deleteEncryptedChat(chatId); - setSavedChatIds((current) => { - const next = new Set(current); - next.delete(chatId); - return next; - }); - setChats((current) => { - const next = current.filter((chat) => chat.id !== chatId); - const fallback = buildNewCoachChat(user); - setActiveChatId(next[0]?.id ?? fallback.id); - return next.length ? next : [fallback]; - }); - setChatStorageStatus("encrypted chat deleted"); - } catch (caught) { - setError(chatStorageErrorMessage(caught)); - } - } - - if (!chatKey) { - return ( -
-
-
-
-

unlock coach

-

- messages are encrypted before appwrite stores them. your passphrase is never saved — use the same one on every device. -

-
- setChatKeyInput(event.target.value)} - placeholder="encryption passphrase" - autoComplete="current-password" - /> - -
- {error &&

{error}

} -
-
- ); - } - - const userInitials = user.name - ? user.name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) - : (user.email?.[0] ?? "U").toUpperCase(); - - return ( -
-
- - -
-
- - - {busy ? "thinking" : "ready"} - - {OLLAMA_MODEL} -
- -
-
- {!visibleMessages.length ? ( -
-
-
-

how can I help?

-

ask about caffeine, sugar, spending, or your flavour patterns.

-
- {quickPrompts.map((prompt) => ( - - ))} -
-
- ) : ( - visibleMessages.map((message) => ( - toggleThinking(message.id)} - /> - )) - )} -
-
-
- - {error && ( -
-
- {error} -
-
- )} - -
-
- -