From 12e79297d448c4ed6701110c9e12e0ab21f30811 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sun, 16 Aug 2026 23:26:27 +0530 Subject: [PATCH 1/3] refactor: extract TodayPage and Worker into focused modules Keep worker.ts as the Hono composition root and TodayPage as the router export. Route groups move under src/worker/; Today sections and hook move under src/pages/today/. Food-entry behavior, auth, and D1 SQL semantics are unchanged. Closes #43 --- src/mcp-source.test.ts | 15 +- src/pages/TodayPage.tsx | 2139 ++------------------ src/pages/today/TodayDailyActions.tsx | 112 ++ src/pages/today/TodayEntrySheet.tsx | 399 ++++ src/pages/today/TodayLog.tsx | 99 + src/pages/today/TodayLoggingLaunchpad.tsx | 76 + src/pages/today/TodayMedicationPanel.tsx | 154 ++ src/pages/today/TodaySummary.tsx | 160 ++ src/pages/today/TodayTiming.tsx | 79 + src/pages/today/TodayWaterPanel.tsx | 143 ++ src/pages/today/today-utils.ts | 84 + src/pages/today/useTodayPage.ts | 890 +++++++++ src/worker-source.test.ts | 10 +- src/worker.ts | 2160 +-------------------- src/worker/account.ts | 471 +++++ src/worker/auth.ts | 181 ++ src/worker/db.ts | 397 ++++ src/worker/http.ts | 81 + src/worker/journal.ts | 524 +++++ src/worker/mcp.ts | 334 ++++ src/worker/reads.ts | 266 +++ src/worker/types.ts | 13 + tsconfig.app.json | 2 +- tsconfig.worker.json | 1 + 24 files changed, 4675 insertions(+), 4115 deletions(-) create mode 100644 src/pages/today/TodayDailyActions.tsx create mode 100644 src/pages/today/TodayEntrySheet.tsx create mode 100644 src/pages/today/TodayLog.tsx create mode 100644 src/pages/today/TodayLoggingLaunchpad.tsx create mode 100644 src/pages/today/TodayMedicationPanel.tsx create mode 100644 src/pages/today/TodaySummary.tsx create mode 100644 src/pages/today/TodayTiming.tsx create mode 100644 src/pages/today/TodayWaterPanel.tsx create mode 100644 src/pages/today/today-utils.ts create mode 100644 src/pages/today/useTodayPage.ts create mode 100644 src/worker/account.ts create mode 100644 src/worker/auth.ts create mode 100644 src/worker/db.ts create mode 100644 src/worker/http.ts create mode 100644 src/worker/journal.ts create mode 100644 src/worker/mcp.ts create mode 100644 src/worker/reads.ts create mode 100644 src/worker/types.ts diff --git a/src/mcp-source.test.ts b/src/mcp-source.test.ts index 28ae685..eef19d8 100644 --- a/src/mcp-source.test.ts +++ b/src/mcp-source.test.ts @@ -1,16 +1,19 @@ -import { readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -const worker = readFileSync(new URL('./worker.ts', import.meta.url), 'utf8'); +const worker = [ + readFileSync(new URL('./worker.ts', import.meta.url), 'utf8'), + ...readdirSync(new URL('./worker/', import.meta.url)) + .filter((name) => name.endsWith('.ts')) + .sort() + .map((name) => readFileSync(new URL(`./worker/${name}`, import.meta.url), 'utf8')), +].join('\n'); const migration = readFileSync( new URL('../migrations/0006_mcp_read_tokens.sql', import.meta.url), 'utf8' ); const tokenSource = readFileSync(new URL('./server/read-tokens.ts', import.meta.url), 'utf8'); -const mcpReads = worker.slice( - worker.indexOf("app.get('/api/mcp/daily'"), - worker.indexOf('app.notFound') -); +const mcpReads = readFileSync(new URL('./worker/mcp.ts', import.meta.url), 'utf8'); describe('Calorie MCP source boundary', () => { it('stores only a hash and revokes tokens within the signed-in owner', () => { diff --git a/src/pages/TodayPage.tsx b/src/pages/TodayPage.tsx index 12f2d35..ed8a578 100644 --- a/src/pages/TodayPage.tsx +++ b/src/pages/TodayPage.tsx @@ -1,150 +1,14 @@ -import { - Apple, - Archive, - Check, - ChevronRight, - Clock3, - Droplets, - Dumbbell, - Flame, - Leaf, - Moon, - Pencil, - Pill, - Plus, - RotateCcw, - Save, - Scale, - Sprout, - Trash2, - Wheat, - X, -} from 'lucide-react'; -import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { DailyScoreBadge } from '../components/DailyScoreBadge'; -import { EntryTrackedQualityBadge } from '../components/EntryTrackedQualityBadge'; -import { NutrientDensityBadge } from '../components/NutrientDensityBadge'; -import { - addFoodEntry, - addMedicationCheckIn, - addWater, - addWeight, - archiveMedication, - createFood, - deleteFoodEntry, - deleteMedicationCheckIn, - deleteWater, - getDashboard, - saveMedication, - updateFoodEntry, - updateMedication, - updateWater, -} from '../lib/api'; -import { enabledDailyActions } from '../lib/daily-action-preferences'; -import { type DailyActionKey, getDailyActionState } from '../lib/daily-actions'; -import { directEntryError, foodFromDirectEntry, mergeDashboardEntry } from '../lib/entries'; -import { normalizeFoodLabels } from '../lib/food-context'; -import { waterTotal } from '../lib/log-corrections'; -import { computeMacroCompletion } from '../lib/macro-completion'; -import { calculateDailyScore, calculateEntryTrackedQuality } from '../lib/nutrient-density'; -import { - calculateGymGuidance, - calculateSleepGuidance, - formatCalorieAdjustmentRange, - minutesToTime, - scaleNutrients, -} from '../lib/recommendations'; -import type { - Dashboard, - Food, - FoodEntry, - Medication, - MedicationSchedule, - WaterEntry, - WeightEntry, -} from '../lib/types'; - -function formatTime(timestamp: number) { - return new Intl.DateTimeFormat(undefined, { - hour: 'numeric', - minute: '2-digit', - }).format(timestamp); -} - -function formatDuration(hours: number) { - const totalMinutes = Math.round(hours * 60); - const wholeHours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - if (!wholeHours) return `${minutes}m`; - return minutes ? `${wholeHours}h ${minutes}m` : `${wholeHours}h`; -} - -function greeting() { - const hour = new Date().getHours(); - if (hour < 5) { - return { - title: 'Rest well', - subtitle: 'It’s late—your journal will still be here after sleep.', - }; - } - if (hour < 12) return { title: 'Good morning', subtitle: 'Here’s your day at a glance.' }; - if (hour < 18) return { title: 'Good afternoon', subtitle: 'Here’s your day at a glance.' }; - return { title: 'Good evening', subtitle: 'Here’s your day at a glance.' }; -} - -function withEntries( - dashboard: Dashboard, - entries: FoodEntry[], - waterEntries: WaterEntry[] -): Dashboard { - const nutrients = entries.reduce( - (total, entry) => ({ - calories: total.calories + entry.calories, - carbsG: total.carbsG + entry.carbsG, - proteinG: total.proteinG + entry.proteinG, - fibreG: total.fibreG + entry.fibreG, - }), - { calories: 0, carbsG: 0, proteinG: 0, fibreG: 0 } - ); - return { - ...dashboard, - entries: [...entries].sort((a, b) => b.eatenAt - a.eatenAt), - waterEntries: [...waterEntries].sort((a, b) => b.drankAt - a.drankAt), - totals: { - ...nutrients, - waterMl: waterTotal(waterEntries), - }, - }; -} - -type UndoAction = - | { kind: 'food'; id: string; label: string } - | { kind: 'water'; id: string; label: string } - | { kind: 'delete-water'; entry: WaterEntry; label: string } - | { kind: 'delete-entry'; entry: FoodEntry; label: string }; - -type EntryDraft = { - entryId: string | null; - mode: 'saved' | 'direct'; - foodId: string | null; - foodName: string; - amount: number; - unitLabel: string; - calories: number; - carbsG: number; - proteinG: number; - fibreG: number; - eatenAt: string; - saveForLater: boolean; - isPackaged: boolean; - labels: string[]; -}; - -function toLocalInput(timestamp: number) { - const date = new Date(timestamp); - const local = new Date(timestamp - date.getTimezoneOffset() * 60 * 1000); - return local.toISOString().slice(0, 16); -} +import { Flame, Leaf, Sprout, Wheat } from 'lucide-react'; +import { TodayDailyActions } from './today/TodayDailyActions'; +import { TodayEntrySheet } from './today/TodayEntrySheet'; +import { TodayLog } from './today/TodayLog'; +import { TodayLoggingLaunchpad } from './today/TodayLoggingLaunchpad'; +import { TodayMedicationPanel } from './today/TodayMedicationPanel'; +import { TodayRemaining, TodaySummary } from './today/TodaySummary'; +import { TodayTiming } from './today/TodayTiming'; +import { TodayWaterPanel } from './today/TodayWaterPanel'; +import { greeting } from './today/today-utils'; +import { useTodayPage } from './today/useTodayPage'; export function TodayPage({ cloudRevision, @@ -155,792 +19,70 @@ export function TodayPage({ onOpenFoods: () => void; onOpenSettings: () => void; }) { - const [dashboard, setDashboard] = useState(null); - const [error, setError] = useState(null); - const [pendingId, setPendingId] = useState(null); - const [undo, setUndo] = useState(null); - const [entryDraft, setEntryDraft] = useState(null); - const [entryError, setEntryError] = useState(null); - const [medicationEditorOpen, setMedicationEditorOpen] = useState(false); - const [medicationName, setMedicationName] = useState(''); - const [medicationSchedule, setMedicationSchedule] = useState('morning'); - const [editingMedicationId, setEditingMedicationId] = useState(null); - const [weightEditorOpen, setWeightEditorOpen] = useState(false); - const [weightValue, setWeightValue] = useState(''); - const [dailyAnnouncement, setDailyAnnouncement] = useState(''); - const [editingWaterId, setEditingWaterId] = useState(null); - const [waterAmount, setWaterAmount] = useState(''); - const [waterTime, setWaterTime] = useState(''); - const entryFoodSelectRef = useRef(null); - const entryNameInputRef = useRef(null); - const entrySheetRef = useRef(null); - const entrySheetBackdropRef = useRef(null); - const entrySheetOpenerRef = useRef(null); - const pageStackRef = useRef(null); - const weightInputRef = useRef(null); - const dailyActionsRef = useRef(null); - const previousIncompleteRef = useRef(null); - const entrySheetOpen = entryDraft !== null; - - const load = useCallback(async () => { - setError(null); - try { - setDashboard(await getDashboard()); - } catch (caught) { - setError(caught instanceof Error ? caught.message : 'Today could not load.'); - } - }, []); - - useEffect(() => { - void load(); - }, [load, cloudRevision]); - - useEffect(() => { - if (!undo) return; - const timer = window.setTimeout(() => setUndo(null), 6000); - return () => window.clearTimeout(timer); - }, [undo]); - - useEffect(() => { - if (!entrySheetOpen) return; - entrySheetOpenerRef.current = - document.activeElement instanceof HTMLElement ? document.activeElement : null; - const inertTargets = [ - document.querySelector('.app-header'), - document.querySelector('.offline-banner'), - document.querySelector('.desktop-nav'), - document.querySelector('.bottom-nav'), - ...Array.from(pageStackRef.current?.children ?? []).filter( - (element): element is HTMLElement => - element instanceof HTMLElement && element !== entrySheetBackdropRef.current - ), - ].filter((element): element is HTMLElement => Boolean(element)); - const inertState = inertTargets.map((element) => ({ - element, - wasInert: element.hasAttribute('inert'), - })); - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - for (const { element } of inertState) element.setAttribute('inert', ''); - - return () => { - document.body.style.overflow = previousOverflow; - for (const { element, wasInert } of inertState) { - if (!wasInert) element.removeAttribute('inert'); - } - const opener = entrySheetOpenerRef.current; - entrySheetOpenerRef.current = null; - window.requestAnimationFrame(() => opener?.focus()); - }; - }, [entrySheetOpen]); - - useEffect(() => { - if (!entrySheetOpen) return; - if (entryDraft?.mode === 'direct') entryNameInputRef.current?.focus(); - else entryFoodSelectRef.current?.focus(); - }, [entryDraft?.mode, entrySheetOpen]); - - const handleEntrySheetKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - setEntryDraft(null); - return; - } - if (event.key !== 'Tab') return; - const sheet = entrySheetRef.current; - if (!sheet) return; - const focusable = Array.from( - sheet.querySelectorAll( - 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])' - ) - ).filter((element) => !element.hasAttribute('hidden')); - if (!focusable.length) { - event.preventDefault(); - sheet.focus(); - return; - } - const first = focusable[0]; - const last = focusable.at(-1); - if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last?.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first.focus(); - } - }; - - useEffect(() => { - if (!weightEditorOpen) return; - window.requestAnimationFrame(() => weightInputRef.current?.focus()); - }, [weightEditorOpen]); - - const gym = useMemo( - () => (dashboard ? calculateGymGuidance(dashboard.entries) : null), - [dashboard] - ); - const sleep = useMemo(() => { - if (!dashboard) return null; - const last = [...dashboard.entries].sort((a, b) => b.eatenAt - a.eatenAt)[0]; - const lastDate = last ? new Date(last.eatenAt) : null; - return calculateSleepGuidance({ - wakeTime: dashboard.profile.wakeTime, - sleepHours: dashboard.profile.sleepHours, - lastEntryLocalMinutes: lastDate ? lastDate.getHours() * 60 + lastDate.getMinutes() : null, - lastEntryCalories: last?.calories ?? null, - }); - }, [dashboard]); - const latestFast = useMemo(() => dashboard?.completedFasts.at(-1) ?? null, [dashboard]); - const completion = useMemo( - () => - dashboard?.target.calorieTarget - ? computeMacroCompletion({ - totals: dashboard.totals, - target: dashboard.target, - foods: dashboard.foods, - }) - : null, - [dashboard] - ); - const quickFoods = useMemo( - () => - dashboard - ? [...dashboard.foods].sort( - (a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0) || a.name.localeCompare(b.name) - ) - : [], - [dashboard] - ); - const dailyActionState = useMemo( - () => - dashboard - ? getDailyActionState({ - date: dashboard.date, - timezone: dashboard.timezone, - entries: dashboard.entries, - waterEntries: dashboard.waterEntries, - medications: dashboard.medications, - medicationCheckIns: dashboard.medicationCheckIns, - latestWeight: dashboard.latestWeight, - }) - : null, - [dashboard] - ); - const incompleteActions = useMemo( - () => - (dashboard ? enabledDailyActions(dashboard.profile) : []).filter( - (key) => !dailyActionState?.completed[key] - ), - [dailyActionState, dashboard] - ); - - useEffect(() => { - const previous = previousIncompleteRef.current; - previousIncompleteRef.current = incompleteActions; - if (!previous || incompleteActions.length >= previous.length) return; - window.requestAnimationFrame(() => { - const next = dailyActionsRef.current?.querySelector( - 'button:not([disabled]), input:not([disabled])' - ); - next?.focus(); - }); - }, [incompleteActions]); - - const quickAdd = async (food: Food) => { - if (!dashboard || pendingId) return; - const id = crypto.randomUUID(); - const nutrients = scaleNutrients(food, food.servingMode, food.defaultAmount); - const optimistic: FoodEntry = { - id, - foodId: food.id, - foodName: food.name, - amount: food.defaultAmount, - unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, - ...nutrients, - eatenAt: Date.now(), - }; - setPendingId(food.id); - setDashboard( - withEntries(dashboard, [optimistic, ...dashboard.entries], dashboard.waterEntries) - ); - try { - const saved = await addFoodEntry({ - ...optimistic, - optimistic, - }); - setDashboard((current) => - current - ? withEntries( - current, - current.entries.map((entry) => (entry.id === id ? saved : entry)), - current.waterEntries - ) - : current - ); - setUndo({ kind: 'food', id, label: `${food.name} logged` }); - setDailyAnnouncement(`Food logged. ${food.name} is in today’s journal.`); - } catch (caught) { - setDashboard(dashboard); - setError(caught instanceof Error ? caught.message : 'Food could not be logged.'); - } finally { - setPendingId(null); - } - }; - - const quickWater = async (amountMl: number) => { - if (!dashboard || pendingId) return; - const entry: WaterEntry = { - id: crypto.randomUUID(), - amountMl, - drankAt: Date.now(), - }; - setPendingId(`water-${amountMl}`); - setDashboard(withEntries(dashboard, dashboard.entries, [entry, ...dashboard.waterEntries])); - try { - await addWater(entry); - setUndo({ kind: 'water', id: entry.id, label: `${amountMl} ml water logged` }); - setDailyAnnouncement(`${amountMl} ml water logged.`); - } catch (caught) { - setDashboard(dashboard); - setError(caught instanceof Error ? caught.message : 'Water could not be logged.'); - } finally { - setPendingId(null); - } - }; - - const beginWaterEdit = (entry: WaterEntry) => { - setEditingWaterId(entry.id); - setWaterAmount(String(entry.amountMl)); - setWaterTime(toLocalInput(entry.drankAt)); - }; - - const saveWaterEdit = async () => { - if (!dashboard || !editingWaterId) return; - const amountMl = Number(waterAmount); - const drankAt = new Date(waterTime).getTime(); - if ( - !Number.isFinite(amountMl) || - amountMl < 1 || - amountMl > 5000 || - !Number.isFinite(drankAt) - ) { - setError('Enter water between 1 and 5,000 ml and choose a valid time.'); - return; - } - const prior = dashboard; - const updated: WaterEntry = { id: editingWaterId, amountMl: Math.round(amountMl), drankAt }; - setDashboard( - withEntries( - dashboard, - dashboard.entries, - dashboard.waterEntries.map((entry) => (entry.id === updated.id ? updated : entry)) - ) - ); - setEditingWaterId(null); - try { - await updateWater(updated); - setDailyAnnouncement(`${updated.amountMl} ml water check-in updated.`); - } catch (caught) { - setDashboard(prior); - setError(caught instanceof Error ? caught.message : 'Water check-in could not be updated.'); - } - }; - - const removeWater = async (entry: WaterEntry) => { - if (!dashboard) return; - const prior = dashboard; - setDashboard( - withEntries( - dashboard, - dashboard.entries, - dashboard.waterEntries.filter((item) => item.id !== entry.id) - ) - ); - setUndo({ kind: 'delete-water', entry, label: `${entry.amountMl} ml water removed` }); - try { - await deleteWater(entry.id); - setDailyAnnouncement('Water check-in removed.'); - } catch (caught) { - setDashboard(prior); - setUndo(null); - setError(caught instanceof Error ? caught.message : 'Water check-in could not be removed.'); - } - }; - - const addMedication = async () => { - const name = medicationName.trim(); - if (!dashboard || !name || pendingId) return; - const editing = dashboard.medications.find( - (medication) => medication.id === editingMedicationId - ); - const medication: Medication = { - id: editing?.id ?? crypto.randomUUID(), - name, - schedule: medicationSchedule, - createdAt: editing?.createdAt ?? Date.now(), - archivedAt: null, - }; - setPendingId(`medication-${medication.id}`); - setDashboard({ - ...dashboard, - medications: editing - ? dashboard.medications.map((item) => (item.id === medication.id ? medication : item)) - : [...dashboard.medications, medication], - }); - try { - if (editing) await updateMedication(medication); - else await saveMedication(medication); - if (/^creatine(?:\s|$)/i.test(medication.name)) { - setDailyAnnouncement('Creatine routine is ready to check in.'); - } - setMedicationName(''); - setMedicationSchedule('morning'); - setEditingMedicationId(null); - } catch (caught) { - setDashboard(dashboard); - setError(caught instanceof Error ? caught.message : 'Medication could not be saved.'); - } finally { - setPendingId(null); - } - }; - - const removeMedication = async (medication: Medication) => { - if (!dashboard || pendingId) return; - setPendingId(`archive-medication-${medication.id}`); - setDashboard({ - ...dashboard, - medications: dashboard.medications.filter((item) => item.id !== medication.id), - }); - try { - await archiveMedication(medication); - } catch (caught) { - setDashboard(dashboard); - setError(caught instanceof Error ? caught.message : 'Medication could not be archived.'); - } finally { - setPendingId(null); - } - }; - - const toggleMedication = async (medication: Medication) => { - if (!dashboard || pendingId) return; - const existing = dashboard.medicationCheckIns.find( - (checkIn) => checkIn.medicationId === medication.id - ); - setPendingId(`medication-check-in-${medication.id}`); - if (existing) { - setDashboard({ - ...dashboard, - medicationCheckIns: dashboard.medicationCheckIns.filter( - (checkIn) => checkIn.id !== existing.id - ), - }); - try { - await deleteMedicationCheckIn(existing.id); - setDailyAnnouncement(`${medication.name} check-in removed.`); - } catch (caught) { - setDashboard(dashboard); - setError(caught instanceof Error ? caught.message : 'Check-off could not be updated.'); - } finally { - setPendingId(null); - } - return; - } - - const checkIn = { - id: crypto.randomUUID(), - medicationId: medication.id, - takenOn: dashboard.date, - takenAt: Date.now(), - }; - setDashboard({ - ...dashboard, - medicationCheckIns: [checkIn, ...dashboard.medicationCheckIns], - }); - try { - await addMedicationCheckIn(checkIn); - setDailyAnnouncement(`${medication.name} checked in for today.`); - } catch (caught) { - setDashboard(dashboard); - setError(caught instanceof Error ? caught.message : 'Check-off could not be updated.'); - } finally { - setPendingId(null); - } - }; - - const undoLast = async () => { - if (!undo || !dashboard) return; - const action = undo; - setUndo(null); - if (action.kind === 'food') { - setDashboard( - withEntries( - dashboard, - dashboard.entries.filter((entry) => entry.id !== action.id), - dashboard.waterEntries - ) - ); - await deleteFoodEntry(action.id).catch(() => void load()); - } else if (action.kind === 'water') { - setDashboard( - withEntries( - dashboard, - dashboard.entries, - dashboard.waterEntries.filter((entry) => entry.id !== action.id) - ) - ); - await deleteWater(action.id).catch(() => void load()); - } else if (action.kind === 'delete-entry') { - const entry = action.entry; - setDashboard(withEntries(dashboard, [entry, ...dashboard.entries], dashboard.waterEntries)); - await addFoodEntry({ - ...entry, - optimistic: entry, - }).catch(() => void load()); - } else { - const entry = action.entry; - setDashboard(withEntries(dashboard, dashboard.entries, [entry, ...dashboard.waterEntries])); - await addWater(entry).catch(() => void load()); - } - }; - - const openNewEntry = () => { - if (!dashboard) return; - const food = dashboard.foods[0]; - setEntryError(null); - setEntryDraft({ - entryId: null, - mode: food ? 'saved' : 'direct', - foodId: food?.id ?? null, - foodName: food?.name ?? '', - amount: food?.defaultAmount ?? 1, - unitLabel: food ? (food.servingMode === 'per_100g' ? 'g' : food.unitLabel) : 'serving', - ...(food - ? scaleNutrients(food, food.servingMode, food.defaultAmount) - : { calories: 0, carbsG: 0, proteinG: 0, fibreG: 0 }), - eatenAt: toLocalInput(Date.now()), - saveForLater: false, - isPackaged: food?.isPackaged ?? false, - labels: food?.labels ?? [], - }); - }; - - const openEntry = (entry: FoodEntry) => { - const hasSavedFood = dashboard?.foods.some((food) => food.id === entry.foodId) ?? false; - setEntryError(null); - setEntryDraft({ - entryId: entry.id, - mode: hasSavedFood ? 'saved' : 'direct', - foodId: hasSavedFood ? entry.foodId : null, - foodName: entry.foodName, - amount: entry.amount, - unitLabel: entry.unitLabel, - calories: entry.calories, - carbsG: entry.carbsG, - proteinG: entry.proteinG, - fibreG: entry.fibreG, - eatenAt: toLocalInput(entry.eatenAt), - saveForLater: false, - isPackaged: entry.isPackaged ?? false, - labels: entry.labels ?? [], - }); - }; - - const chooseEntryFood = (foodId: string) => { - if (!dashboard) return; - const food = dashboard.foods.find((item) => item.id === foodId); - setEntryDraft((current) => - current && food - ? { - ...current, - foodId, - foodName: food.name, - amount: food.defaultAmount, - unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, - ...scaleNutrients(food, food.servingMode, food.defaultAmount), - isPackaged: food.isPackaged ?? false, - labels: food.labels ?? [], - } - : current - ); - }; - - const chooseEntryMode = (mode: EntryDraft['mode']) => { - if (!dashboard) return; - setEntryError(null); - setEntryDraft((current) => { - if (!current || current.mode === mode) return current; - if (mode === 'direct') { - if (current.entryId) { - const food = dashboard.foods.find((item) => item.id === current.foodId); - const nutrients = food - ? scaleNutrients(food, food.servingMode, current.amount) - : { - calories: current.calories, - carbsG: current.carbsG, - proteinG: current.proteinG, - fibreG: current.fibreG, - }; - return { - ...current, - mode, - foodId: null, - foodName: food?.name ?? current.foodName, - unitLabel: - food?.servingMode === 'per_100g' ? 'g' : (food?.unitLabel ?? current.unitLabel), - saveForLater: false, - ...nutrients, - }; - } - return { - ...current, - mode, - foodId: null, - foodName: '', - amount: 1, - unitLabel: 'serving', - calories: 0, - carbsG: 0, - proteinG: 0, - fibreG: 0, - saveForLater: false, - isPackaged: false, - labels: [], - }; - } - - const food = dashboard.foods[0]; - if (!food) return current; - return { - ...current, - mode, - saveForLater: false, - foodId: food.id, - foodName: food.name, - amount: food.defaultAmount, - unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, - ...scaleNutrients(food, food.servingMode, food.defaultAmount), - isPackaged: food.isPackaged ?? false, - labels: food.labels ?? [], - }; - }); - }; - - const saveEntry = async () => { - if (!dashboard || !entryDraft || pendingId) return; - const food = dashboard.foods.find((item) => item.id === entryDraft.foodId); - const eatenAt = new Date(entryDraft.eatenAt).getTime(); - if (entryDraft.mode === 'saved' && !food) { - setEntryError('Choose a saved food.'); - return; - } - if (!Number.isFinite(entryDraft.amount) || entryDraft.amount <= 0) { - setEntryError('Add an amount above zero.'); - return; - } - if (!Number.isFinite(eatenAt) || eatenAt > Date.now() + 24 * 60 * 60 * 1000) { - setEntryError('Choose a valid time.'); - return; - } - - const id = entryDraft.entryId ?? crypto.randomUUID(); - const directEntry: FoodEntry = - entryDraft.mode === 'saved' && food - ? { - id, - foodId: food.id, - foodName: food.name, - amount: entryDraft.amount, - unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, - ...scaleNutrients(food, food.servingMode, entryDraft.amount), - eatenAt, - isPackaged: food.isPackaged, - labels: food.labels, - } - : { - id, - foodId: null, - foodName: entryDraft.foodName, - amount: entryDraft.amount, - unitLabel: entryDraft.unitLabel, - calories: entryDraft.calories, - carbsG: entryDraft.carbsG, - proteinG: entryDraft.proteinG, - fibreG: entryDraft.fibreG, - eatenAt, - isPackaged: entryDraft.isPackaged, - labels: normalizeFoodLabels(entryDraft.labels), - }; - const directError = directEntry.foodId === null ? directEntryError(directEntry) : null; - if (directError) { - setEntryError(directError); - return; - } - - const reusableFood = - directEntry.foodId === null && entryDraft.saveForLater - ? foodFromDirectEntry(directEntry, crypto.randomUUID()) - : null; - const optimistic: FoodEntry = reusableFood - ? { ...directEntry, foodId: reusableFood.id, foodName: reusableFood.name } - : directEntry; - const previous = dashboard; - const nextEntries = mergeDashboardEntry( - dashboard.entries, - optimistic, - dashboard.date, - dashboard.timezone - ); - let savedFood: Food | null = null; - setPendingId(`entry-${id}`); - try { - savedFood = reusableFood ? await createFood(reusableFood) : null; - setDashboard( - withEntries( - { ...dashboard, foods: savedFood ? [savedFood, ...dashboard.foods] : dashboard.foods }, - nextEntries, - dashboard.waterEntries - ) - ); - const saved = entryDraft.entryId - ? await updateFoodEntry({ - ...optimistic, - optimistic, - }) - : await addFoodEntry({ - ...optimistic, - optimistic, - }); - setDashboard((current) => - current - ? withEntries( - current, - current.entries.map((entry) => (entry.id === id ? saved : entry)), - current.waterEntries - ) - : current - ); - if (!entryDraft.entryId) { - setUndo({ - kind: 'food', - id, - label: savedFood - ? `${optimistic.foodName} saved and logged` - : `${optimistic.foodName} logged`, - }); - setDailyAnnouncement(`${optimistic.foodName} logged.`); - } - setEntryDraft(null); - } catch (caught) { - setDashboard( - savedFood - ? withEntries( - { ...previous, foods: [savedFood, ...previous.foods] }, - previous.entries, - previous.waterEntries - ) - : previous - ); - setEntryError( - savedFood - ? 'Food was saved, but this entry could not be logged. Try logging it again.' - : caught instanceof Error - ? caught.message - : 'Entry could not be saved.' - ); - } finally { - setPendingId(null); - } - }; - - const removeEntry = async () => { - if (!dashboard || !entryDraft?.entryId || pendingId) return; - const entry = dashboard.entries.find((item) => item.id === entryDraft.entryId); - if (!entry) return; - const previous = dashboard; - setPendingId(`entry-${entry.id}`); - setDashboard( - withEntries( - dashboard, - dashboard.entries.filter((item) => item.id !== entry.id), - dashboard.waterEntries - ) - ); - setEntryDraft(null); - try { - await deleteFoodEntry(entry.id); - setUndo({ kind: 'delete-entry', entry, label: `${entry.foodName} removed` }); - } catch (caught) { - setDashboard(previous); - setError(caught instanceof Error ? caught.message : 'Entry could not be removed.'); - } finally { - setPendingId(null); - } - }; - - const saveWeightCheckIn = async () => { - if (!dashboard || pendingId) return; - let weightKg = Number(weightValue); - if (dashboard.profile.units === 'imperial') weightKg /= 2.20462; - if (!Number.isFinite(weightKg) || weightKg < 30 || weightKg > 400) { - setError('Enter a weight between 30 and 400 kg (66 and 882 lb).'); - return; - } - const entry: WeightEntry = { - id: crypto.randomUUID(), - weightKg: Math.round(weightKg * 10) / 10, - recordedAt: Date.now(), - }; - const previous = dashboard; - setPendingId('weight-check-in'); - setDashboard({ ...dashboard, latestWeight: entry }); - try { - await addWeight(entry); - setWeightEditorOpen(false); - setWeightValue(''); - setDailyAnnouncement('Weight checked in for today.'); - } catch (caught) { - setDashboard(previous); - setError(caught instanceof Error ? caught.message : 'Weight could not be logged.'); - } finally { - setPendingId(null); - } - }; - - const handleDailyAction = (action: DailyActionKey) => { - if (!dashboard || pendingId) return; - if (action === 'weight') { - const latest = dashboard.latestWeight?.weightKg ?? null; - const display = - latest === null - ? '' - : dashboard.profile.units === 'imperial' - ? String(Math.round(latest * 2.20462 * 10) / 10) - : String(latest); - setWeightValue(display); - setWeightEditorOpen(true); - return; - } - if (action === 'food') { - openNewEntry(); - return; - } - if (action === 'water') { - void quickWater(250); - return; - } - if (dailyActionState?.creatineRoutine) { - void toggleMedication(dailyActionState.creatineRoutine); - return; - } - setMedicationEditorOpen(true); - setEditingMedicationId(null); - setMedicationName('Creatine'); - setMedicationSchedule('either'); - setDailyAnnouncement('Creatine setup opened below.'); - window.requestAnimationFrame(() => - document.getElementById('medication-editor')?.scrollIntoView({ block: 'center' }) - ); - }; + const today = useTodayPage({ cloudRevision }); + const { + dashboard, + error, + setError, + pendingId, + undo, + entryDraft, + setEntryDraft, + entryError, + setEntryError, + medicationEditorOpen, + setMedicationEditorOpen, + medicationName, + setMedicationName, + medicationSchedule, + setMedicationSchedule, + editingMedicationId, + setEditingMedicationId, + weightEditorOpen, + setWeightEditorOpen, + weightValue, + setWeightValue, + dailyAnnouncement, + editingWaterId, + setEditingWaterId, + waterAmount, + setWaterAmount, + waterTime, + setWaterTime, + entryFoodSelectRef, + entryNameInputRef, + entrySheetRef, + entrySheetBackdropRef, + pageStackRef, + weightInputRef, + dailyActionsRef, + load, + handleEntrySheetKeyDown, + gym, + sleep, + latestFast, + completion, + quickFoods, + dailyActionState, + incompleteActions, + quickAdd, + quickWater, + beginWaterEdit, + saveWaterEdit, + removeWater, + addMedication, + removeMedication, + toggleMedication, + undoLast, + openNewEntry, + openEntry, + chooseEntryFood, + chooseEntryMode, + saveEntry, + removeEntry, + saveWeightCheckIn, + handleDailyAction, + } = today; if (!dashboard && !error) { return ( @@ -969,12 +111,6 @@ export function TodayPage({ const calorieProgress = target ? Math.min(100, Math.max(0, (dashboard.totals.calories / target) * 100)) : 0; - const dailyScore = calculateDailyScore({ - entries: dashboard.entries, - foods: dashboard.foods, - target: dashboard.target, - isCurrentDay: true, - }); const proteinTarget = dashboard.target.proteinRangeG?.[0] ?? null; const fibreTarget = dashboard.target.fibreTargetG; const waterPercent = Math.max( @@ -982,7 +118,6 @@ export function TodayPage({ (dashboard.totals.waterMl / dashboard.profile.waterTargetMl) * 100 ); const waterBarProgress = Math.min(100, waterPercent); - const nutrients = [ { label: 'Calories', @@ -1018,64 +153,6 @@ export function TodayPage({ }, ]; - const loggingLaunchpad = ( -
-
-
-

Your journal

-

Log food now

-

Start with a usual food, or add anything else.

-
-
- - -
-
-
- {quickFoods.slice(0, 4).map((food) => ( - - ))} - {dashboard.foods.length === 0 ? ( - - ) : null} -
-
- ); - return (
@@ -1108,974 +185,118 @@ export function TodayPage({
) : null} - {loggingLaunchpad} - - {incompleteActions.length ? ( -
-
-
-

Daily basics

-

Up next

-
-
- -
- {incompleteActions.map((action) => { - const details = { - weight: { label: 'Check in weight', hint: 'Today’s measurement', Icon: Scale }, - creatine: { - label: dailyActionState?.creatineRoutine ? 'Log creatine' : 'Set up creatine', - hint: dailyActionState?.creatineRoutine - ? 'One-tap check-in' - : 'Create the routine', - Icon: Pill, - }, - food: { label: 'Log food', hint: 'Add your first meal', Icon: Apple }, - water: { label: 'Add water', hint: '+250 ml', Icon: Droplets }, - }[action]; - const Icon = details.Icon; - return ( - - ); - })} -
- - {weightEditorOpen ? ( -
{ - event.preventDefault(); - void saveWeightCheckIn(); - }} - > - -
- - -
-
- ) : null} -
- ) : null} + void quickAdd(food)} + /> + + setWeightEditorOpen(false)} + onSaveWeight={() => void saveWeightCheckIn()} + />

{dailyAnnouncement}

-
-
-
- Daily range - - {target - ? `${dashboard.target.calorieRange?.[0].toLocaleString()}–${dashboard.target.calorieRange?.[1].toLocaleString()} kcal` - : 'Targets not set'} - - {!target ? ( - - ) : null} - {dashboard.target.maintenanceCalories ? ( - - {dashboard.target.maintenanceCalories.toLocaleString()} maintenance{' '} - {formatCalorieAdjustmentRange(dashboard.target.goalAdjustmentRangeCalories)} for - your goal - - ) : null} -
-
- {target ? `${Math.round(calorieProgress)}%` : '—'} -
-
- -
- {nutrients.map((item) => { - const Icon = item.icon; - return ( -
-
- ); - })} -
-
- - {completion ? ( -
-
-
-

Remaining today

-

- {completion.complete - ? 'You’ve hit your tracked targets.' - : completion.leadingMacro === 'protein' - ? 'Protein is your widest gap.' - : 'Fibre is your widest gap.'} -

-
-
- {completion.complete ? ( -
-
- ) : ( - <> -
-
- {completion.remainingCalories.toLocaleString()} - kcal remaining -
-
- {completion.remainingProteinG.toLocaleString()} - g protein left -
-
- {completion.remainingFibreG.toLocaleString()} - g fibre left -
-
- {completion.suggestions.length ? ( -
-

One serving covers the most:

- {completion.suggestions.map((item) => ( - - ))} -
- ) : ( -

- Save a few foods to get one-tap suggestions that fill the gap. -

- )} - - )} -
- ) : null} - -
-
- - -
-

Water

- - {dashboard.totals.waterMl.toLocaleString()} - / {dashboard.profile.waterTargetMl.toLocaleString()} ml target - -
- {Math.round(waterPercent)}% -
- -
- Quick log water - {[250, 350, 500].map((amount) => ( - - ))} -
-
-
- Today’s check-ins - {dashboard.waterEntries.length} logged -
- {dashboard.waterEntries.length ? ( - dashboard.waterEntries.map((entry) => - editingWaterId === entry.id ? ( -
- - - - -
- ) : ( -
- - {entry.amountMl.toLocaleString()} ml - {formatTime(entry.drankAt)} - - - -
- ) - ) - ) : ( -

- No water yet. A quick amount above is the fastest start. -

- )} -
-
- -
-
-
-

-

-

Track the routine you set for today.

-
- -
- - {dashboard.medications.length ? ( -
- {dashboard.medications.map((medication) => { - const checked = dashboard.medicationCheckIns.some( - (checkIn) => checkIn.medicationId === medication.id - ); - return ( -
- - {medicationEditorOpen ? ( - - - - - ) : null} -
- ); - })} -
- ) : ( -

Add your routine, then check it off here each day.

- )} - - {medicationEditorOpen ? ( -
{ - event.preventDefault(); - void addMedication(); - }} - > - - - - {editingMedicationId ? ( - - ) : null} - - Routine tracking only—not dosage or medical advice. - -
- ) : null} -
- -
-
-
-

Your timing

-

Useful estimates from today’s log.

-
-
- -
- - - - - Next best exercise window - - {gym?.state === 'window' - ? `${gym.carbsG} g carbs in ${gym.sourceEntry}` - : 'No recent carb signal'} - - - - {gym?.startAt && gym.endAt - ? gym.phase === 'active' - ? `Now–${formatTime(gym.endAt)}` - : `${formatTime(gym.startAt)}–${formatTime(gym.endAt)}` - : 'Any time'} - - -

{gym?.explanation} This is a practical estimate, not a requirement.

-
- -
- - - - - Wind down after - Routine + last food - - {sleep ? minutesToTime(sleep.recommendedMinutes) : '—'} - -

{sleep?.explanation} Adjust this if your body or clinician tells you differently.

-
- -
- - -
- {latestFast ? formatDuration(latestFast.durationHours) : '—'} - - {latestFast - ? `Latest fasting window · ${formatTime(latestFast.startAt)}–${formatTime(latestFast.endAt)}` - : 'Your last food and next first food set this automatically'} - -
-
-
- -
-
-
-

Today’s log

-

- {dashboard.entries.length} food entr - {dashboard.entries.length === 1 ? 'y' : 'ies'} -

-
- -
- {dashboard.entries.length ? ( -
-
- {dailyScore.label} - - Based on {dashboard.entries.length} logged food{' '} - {dashboard.entries.length === 1 ? 'entry' : 'entries'} - -
- -
- ) : null} - {dashboard.entries.length ? ( -
- {dashboard.entries.map((entry) => { - const tracked = calculateEntryTrackedQuality(entry, dashboard.foods); - const score = - tracked.quality.score === null - ? 'tracked score unavailable' - : `${tracked.quality.score} of 100 tracked`; - return ( - - ); - })} -
- ) : ( -
-
- )} -
+ + + void quickAdd(food)} + /> + + void quickWater(amount)} + onBeginEdit={beginWaterEdit} + onAmountChange={setWaterAmount} + onTimeChange={setWaterTime} + onSaveEdit={() => void saveWaterEdit()} + onCancelEdit={() => setEditingWaterId(null)} + onRemove={(entry) => void removeWater(entry)} + /> + + setMedicationEditorOpen((open) => !open)} + onToggle={(medication) => void toggleMedication(medication)} + onEdit={(medication) => { + setEditingMedicationId(medication.id); + setMedicationName(medication.name); + setMedicationSchedule(medication.schedule); + }} + onArchive={(medication) => void removeMedication(medication)} + onNameChange={setMedicationName} + onScheduleChange={setMedicationSchedule} + onSave={() => void addMedication()} + onCancelEdit={() => { + setEditingMedicationId(null); + setMedicationName(''); + setMedicationSchedule('morning'); + }} + /> + + + + {entryDraft ? ( -
-
-
-
+ setEntryDraft(null)} + onChooseMode={chooseEntryMode} + onChooseFood={chooseEntryFood} + onDraftChange={(updater) => + setEntryDraft((current) => (current ? updater(current) : current)) + } + onClearEntryError={() => setEntryError(null)} + onSave={() => void saveEntry()} + onRemove={() => void removeEntry()} + /> ) : null} {undo ? ( diff --git a/src/pages/today/TodayDailyActions.tsx b/src/pages/today/TodayDailyActions.tsx new file mode 100644 index 0000000..2410e72 --- /dev/null +++ b/src/pages/today/TodayDailyActions.tsx @@ -0,0 +1,112 @@ +import { Apple, ChevronRight, Droplets, Pill, Scale } from 'lucide-react'; +import type { RefObject } from 'react'; +import type { DailyActionKey, getDailyActionState } from '../../lib/daily-actions'; +import type { Units } from '../../lib/types'; + +export function TodayDailyActions({ + incompleteActions, + dailyActionState, + pendingId, + weightEditorOpen, + weightValue, + units, + dailyActionsRef, + weightInputRef, + onAction, + onWeightValueChange, + onCancelWeight, + onSaveWeight, +}: { + incompleteActions: DailyActionKey[]; + dailyActionState: ReturnType | null; + pendingId: string | null; + weightEditorOpen: boolean; + weightValue: string; + units: Units; + dailyActionsRef: RefObject; + weightInputRef: RefObject; + onAction: (action: DailyActionKey) => void; + onWeightValueChange: (value: string) => void; + onCancelWeight: () => void; + onSaveWeight: () => void; +}) { + if (!incompleteActions.length) return null; + + return ( +
+
+
+

Daily basics

+

Up next

+
+
+ +
+ {incompleteActions.map((action) => { + const details = { + weight: { label: 'Check in weight', hint: 'Today’s measurement', Icon: Scale }, + creatine: { + label: dailyActionState?.creatineRoutine ? 'Log creatine' : 'Set up creatine', + hint: dailyActionState?.creatineRoutine ? 'One-tap check-in' : 'Create the routine', + Icon: Pill, + }, + food: { label: 'Log food', hint: 'Add your first meal', Icon: Apple }, + water: { label: 'Add water', hint: '+250 ml', Icon: Droplets }, + }[action]; + const Icon = details.Icon; + return ( + + ); + })} +
+ + {weightEditorOpen ? ( +
{ + event.preventDefault(); + onSaveWeight(); + }} + > + +
+ + +
+
+ ) : null} +
+ ); +} diff --git a/src/pages/today/TodayEntrySheet.tsx b/src/pages/today/TodayEntrySheet.tsx new file mode 100644 index 0000000..a73c4b7 --- /dev/null +++ b/src/pages/today/TodayEntrySheet.tsx @@ -0,0 +1,399 @@ +import { Check, Clock3, Plus, Save, Trash2, X } from 'lucide-react'; +import type { KeyboardEvent, RefObject } from 'react'; +import { NutrientDensityBadge } from '../../components/NutrientDensityBadge'; +import { scaleNutrients } from '../../lib/recommendations'; +import type { Dashboard } from '../../lib/types'; +import { type EntryDraft, toLocalInput } from './today-utils'; + +export function TodayEntrySheet({ + dashboard, + entryDraft, + entryError, + pendingId, + entrySheetBackdropRef, + entrySheetRef, + entryFoodSelectRef, + entryNameInputRef, + onKeyDown, + onClose, + onChooseMode, + onChooseFood, + onDraftChange, + onClearEntryError, + onSave, + onRemove, +}: { + dashboard: Dashboard; + entryDraft: EntryDraft; + entryError: string | null; + pendingId: string | null; + entrySheetBackdropRef: RefObject; + entrySheetRef: RefObject; + entryFoodSelectRef: RefObject; + entryNameInputRef: RefObject; + onKeyDown: (event: KeyboardEvent) => void; + onClose: () => void; + onChooseMode: (mode: EntryDraft['mode']) => void; + onChooseFood: (foodId: string) => void; + onDraftChange: (updater: (current: EntryDraft) => EntryDraft) => void; + onClearEntryError: () => void; + onSave: () => void; + onRemove: () => void; +}) { + return ( +
+
+
+
+ ); +} diff --git a/src/pages/today/TodayLog.tsx b/src/pages/today/TodayLog.tsx new file mode 100644 index 0000000..9bdd3d7 --- /dev/null +++ b/src/pages/today/TodayLog.tsx @@ -0,0 +1,99 @@ +import { Leaf, Plus } from 'lucide-react'; +import { DailyScoreBadge } from '../../components/DailyScoreBadge'; +import { EntryTrackedQualityBadge } from '../../components/EntryTrackedQualityBadge'; +import { calculateDailyScore, calculateEntryTrackedQuality } from '../../lib/nutrient-density'; +import type { Dashboard, FoodEntry } from '../../lib/types'; +import { formatTime } from './today-utils'; + +export function TodayLog({ + dashboard, + onOpenNewEntry, + onOpenEntry, +}: { + dashboard: Dashboard; + onOpenNewEntry: () => void; + onOpenEntry: (entry: FoodEntry) => void; +}) { + const dailyScore = calculateDailyScore({ + entries: dashboard.entries, + foods: dashboard.foods, + target: dashboard.target, + isCurrentDay: true, + }); + + return ( +
+
+
+

Today’s log

+

+ {dashboard.entries.length} food entr + {dashboard.entries.length === 1 ? 'y' : 'ies'} +

+
+ +
+ {dashboard.entries.length ? ( +
+
+ {dailyScore.label} + + Based on {dashboard.entries.length} logged food{' '} + {dashboard.entries.length === 1 ? 'entry' : 'entries'} + +
+ +
+ ) : null} + {dashboard.entries.length ? ( +
+ {dashboard.entries.map((entry) => { + const tracked = calculateEntryTrackedQuality(entry, dashboard.foods); + const score = + tracked.quality.score === null + ? 'tracked score unavailable' + : `${tracked.quality.score} of 100 tracked`; + return ( + + ); + })} +
+ ) : ( +
+
+ )} +
+ ); +} diff --git a/src/pages/today/TodayLoggingLaunchpad.tsx b/src/pages/today/TodayLoggingLaunchpad.tsx new file mode 100644 index 0000000..1535816 --- /dev/null +++ b/src/pages/today/TodayLoggingLaunchpad.tsx @@ -0,0 +1,76 @@ +import { Apple, Plus } from 'lucide-react'; +import type { Food } from '../../lib/types'; + +export function TodayLoggingLaunchpad({ + foods, + quickFoods, + pendingId, + onOpenFoods, + onOpenNewEntry, + onQuickAdd, +}: { + foods: Food[]; + quickFoods: Food[]; + pendingId: string | null; + onOpenFoods: () => void; + onOpenNewEntry: () => void; + onQuickAdd: (food: Food) => void; +}) { + return ( +
+
+
+

Your journal

+

Log food now

+

Start with a usual food, or add anything else.

+
+
+ + +
+
+
+ {quickFoods.slice(0, 4).map((food) => ( + + ))} + {foods.length === 0 ? ( + + ) : null} +
+
+ ); +} diff --git a/src/pages/today/TodayMedicationPanel.tsx b/src/pages/today/TodayMedicationPanel.tsx new file mode 100644 index 0000000..8afaaf4 --- /dev/null +++ b/src/pages/today/TodayMedicationPanel.tsx @@ -0,0 +1,154 @@ +import { Archive, Check, Pencil, Pill, Plus } from 'lucide-react'; +import type { Medication, MedicationCheckIn, MedicationSchedule } from '../../lib/types'; + +export function TodayMedicationPanel({ + medications, + medicationCheckIns, + pendingId, + editorOpen, + medicationName, + medicationSchedule, + editingMedicationId, + onToggleEditor, + onToggle, + onEdit, + onArchive, + onNameChange, + onScheduleChange, + onSave, + onCancelEdit, +}: { + medications: Medication[]; + medicationCheckIns: MedicationCheckIn[]; + pendingId: string | null; + editorOpen: boolean; + medicationName: string; + medicationSchedule: MedicationSchedule; + editingMedicationId: string | null; + onToggleEditor: () => void; + onToggle: (medication: Medication) => void; + onEdit: (medication: Medication) => void; + onArchive: (medication: Medication) => void; + onNameChange: (value: string) => void; + onScheduleChange: (value: MedicationSchedule) => void; + onSave: () => void; + onCancelEdit: () => void; +}) { + return ( +
+
+
+

+

+

Track the routine you set for today.

+
+ +
+ + {medications.length ? ( +
+ {medications.map((medication) => { + const checked = medicationCheckIns.some( + (checkIn) => checkIn.medicationId === medication.id + ); + return ( +
+ + {editorOpen ? ( + + + + + ) : null} +
+ ); + })} +
+ ) : ( +

Add your routine, then check it off here each day.

+ )} + + {editorOpen ? ( +
{ + event.preventDefault(); + onSave(); + }} + > + + + + {editingMedicationId ? ( + + ) : null} + + Routine tracking only—not dosage or medical advice. + +
+ ) : null} +
+ ); +} diff --git a/src/pages/today/TodaySummary.tsx b/src/pages/today/TodaySummary.tsx new file mode 100644 index 0000000..011f2ac --- /dev/null +++ b/src/pages/today/TodaySummary.tsx @@ -0,0 +1,160 @@ +import { Apple, Check, Plus } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import type { MacroCompletion } from '../../lib/macro-completion'; +import { formatCalorieAdjustmentRange } from '../../lib/recommendations'; +import type { Dashboard, Food } from '../../lib/types'; + +type NutrientItem = { + label: string; + value: number; + unit: string; + target: number | null; + icon: LucideIcon; + className: string; +}; + +export function TodaySummary({ + dashboard, + target, + calorieProgress, + nutrients, + onOpenSettings, +}: { + dashboard: Dashboard; + target: number | null; + calorieProgress: number; + nutrients: NutrientItem[]; + onOpenSettings: () => void; +}) { + return ( +
+
+
+ Daily range + + {target + ? `${dashboard.target.calorieRange?.[0].toLocaleString()}–${dashboard.target.calorieRange?.[1].toLocaleString()} kcal` + : 'Targets not set'} + + {!target ? ( + + ) : null} + {dashboard.target.maintenanceCalories ? ( + + {dashboard.target.maintenanceCalories.toLocaleString()} maintenance{' '} + {formatCalorieAdjustmentRange(dashboard.target.goalAdjustmentRangeCalories)} for your + goal + + ) : null} +
+
+ {target ? `${Math.round(calorieProgress)}%` : '—'} +
+
+ +
+ {nutrients.map((item) => { + const Icon = item.icon; + return ( +
+
+ ); + })} +
+
+ ); +} + +export function TodayRemaining({ + completion, + pendingId, + onQuickAdd, +}: { + completion: MacroCompletion | null; + pendingId: string | null; + onQuickAdd: (food: Food) => void; +}) { + if (!completion) return null; + + return ( +
+
+
+

Remaining today

+

+ {completion.complete + ? 'You’ve hit your tracked targets.' + : completion.leadingMacro === 'protein' + ? 'Protein is your widest gap.' + : 'Fibre is your widest gap.'} +

+
+
+ {completion.complete ? ( +
+
+ ) : ( + <> +
+
+ {completion.remainingCalories.toLocaleString()} + kcal remaining +
+
+ {completion.remainingProteinG.toLocaleString()} + g protein left +
+
+ {completion.remainingFibreG.toLocaleString()} + g fibre left +
+
+ {completion.suggestions.length ? ( +
+

One serving covers the most:

+ {completion.suggestions.map((item) => ( + + ))} +
+ ) : ( +

+ Save a few foods to get one-tap suggestions that fill the gap. +

+ )} + + )} +
+ ); +} diff --git a/src/pages/today/TodayTiming.tsx b/src/pages/today/TodayTiming.tsx new file mode 100644 index 0000000..e6739a2 --- /dev/null +++ b/src/pages/today/TodayTiming.tsx @@ -0,0 +1,79 @@ +import { ChevronRight, Dumbbell, Moon, RotateCcw } from 'lucide-react'; +import { minutesToTime } from '../../lib/recommendations'; +import type { FastWindow, GymGuidance, SleepGuidance } from '../../lib/types'; +import { formatDuration, formatTime } from './today-utils'; + +export function TodayTiming({ + gym, + sleep, + latestFast, +}: { + gym: GymGuidance | null; + sleep: SleepGuidance | null; + latestFast: FastWindow | null; +}) { + return ( +
+
+
+

Your timing

+

Useful estimates from today’s log.

+
+
+ +
+ + + + + Next best exercise window + + {gym?.state === 'window' + ? `${gym.carbsG} g carbs in ${gym.sourceEntry}` + : 'No recent carb signal'} + + + + {gym?.startAt && gym.endAt + ? gym.phase === 'active' + ? `Now–${formatTime(gym.endAt)}` + : `${formatTime(gym.startAt)}–${formatTime(gym.endAt)}` + : 'Any time'} + + +

{gym?.explanation} This is a practical estimate, not a requirement.

+
+ +
+ + + + + Wind down after + Routine + last food + + {sleep ? minutesToTime(sleep.recommendedMinutes) : '—'} + +

{sleep?.explanation} Adjust this if your body or clinician tells you differently.

+
+ +
+ + +
+ {latestFast ? formatDuration(latestFast.durationHours) : '—'} + + {latestFast + ? `Latest fasting window · ${formatTime(latestFast.startAt)}–${formatTime(latestFast.endAt)}` + : 'Your last food and next first food set this automatically'} + +
+
+
+ ); +} diff --git a/src/pages/today/TodayWaterPanel.tsx b/src/pages/today/TodayWaterPanel.tsx new file mode 100644 index 0000000..d822114 --- /dev/null +++ b/src/pages/today/TodayWaterPanel.tsx @@ -0,0 +1,143 @@ +import { Droplets, Pencil, Plus, Trash2 } from 'lucide-react'; +import type { WaterEntry } from '../../lib/types'; +import { formatTime } from './today-utils'; + +export function TodayWaterPanel({ + waterMl, + waterTargetMl, + waterPercent, + waterBarProgress, + waterEntries, + pendingId, + editingWaterId, + waterAmount, + waterTime, + onQuickWater, + onBeginEdit, + onAmountChange, + onTimeChange, + onSaveEdit, + onCancelEdit, + onRemove, +}: { + waterMl: number; + waterTargetMl: number; + waterPercent: number; + waterBarProgress: number; + waterEntries: WaterEntry[]; + pendingId: string | null; + editingWaterId: string | null; + waterAmount: string; + waterTime: string; + onQuickWater: (amountMl: number) => void; + onBeginEdit: (entry: WaterEntry) => void; + onAmountChange: (value: string) => void; + onTimeChange: (value: string) => void; + onSaveEdit: () => void; + onCancelEdit: () => void; + onRemove: (entry: WaterEntry) => void; +}) { + return ( +
+
+ + +
+

Water

+ + {waterMl.toLocaleString()} + / {waterTargetMl.toLocaleString()} ml target + +
+ {Math.round(waterPercent)}% +
+ +
+ Quick log water + {[250, 350, 500].map((amount) => ( + + ))} +
+
+
+ Today’s check-ins + {waterEntries.length} logged +
+ {waterEntries.length ? ( + waterEntries.map((entry) => + editingWaterId === entry.id ? ( +
+ + + + +
+ ) : ( +
+ + {entry.amountMl.toLocaleString()} ml + {formatTime(entry.drankAt)} + + + +
+ ) + ) + ) : ( +

+ No water yet. A quick amount above is the fastest start. +

+ )} +
+
+ ); +} diff --git a/src/pages/today/today-utils.ts b/src/pages/today/today-utils.ts new file mode 100644 index 0000000..aca9380 --- /dev/null +++ b/src/pages/today/today-utils.ts @@ -0,0 +1,84 @@ +import { waterTotal } from '../../lib/log-corrections'; +import type { Dashboard, FoodEntry, WaterEntry } from '../../lib/types'; + +export function formatTime(timestamp: number) { + return new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: '2-digit', + }).format(timestamp); +} + +export function formatDuration(hours: number) { + const totalMinutes = Math.round(hours * 60); + const wholeHours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (!wholeHours) return `${minutes}m`; + return minutes ? `${wholeHours}h ${minutes}m` : `${wholeHours}h`; +} + +export function greeting() { + const hour = new Date().getHours(); + if (hour < 5) { + return { + title: 'Rest well', + subtitle: 'It’s late—your journal will still be here after sleep.', + }; + } + if (hour < 12) return { title: 'Good morning', subtitle: 'Here’s your day at a glance.' }; + if (hour < 18) return { title: 'Good afternoon', subtitle: 'Here’s your day at a glance.' }; + return { title: 'Good evening', subtitle: 'Here’s your day at a glance.' }; +} + +export function withEntries( + dashboard: Dashboard, + entries: FoodEntry[], + waterEntries: WaterEntry[] +): Dashboard { + const nutrients = entries.reduce( + (total, entry) => ({ + calories: total.calories + entry.calories, + carbsG: total.carbsG + entry.carbsG, + proteinG: total.proteinG + entry.proteinG, + fibreG: total.fibreG + entry.fibreG, + }), + { calories: 0, carbsG: 0, proteinG: 0, fibreG: 0 } + ); + return { + ...dashboard, + entries: [...entries].sort((a, b) => b.eatenAt - a.eatenAt), + waterEntries: [...waterEntries].sort((a, b) => b.drankAt - a.drankAt), + totals: { + ...nutrients, + waterMl: waterTotal(waterEntries), + }, + }; +} + +export type UndoAction = + | { kind: 'food'; id: string; label: string } + | { kind: 'water'; id: string; label: string } + | { kind: 'delete-water'; entry: WaterEntry; label: string } + | { kind: 'delete-entry'; entry: FoodEntry; label: string }; + +export type EntryDraft = { + entryId: string | null; + mode: 'saved' | 'direct'; + foodId: string | null; + foodName: string; + amount: number; + unitLabel: string; + calories: number; + carbsG: number; + proteinG: number; + fibreG: number; + eatenAt: string; + saveForLater: boolean; + isPackaged: boolean; + labels: string[]; +}; + +export function toLocalInput(timestamp: number) { + const date = new Date(timestamp); + const local = new Date(timestamp - date.getTimezoneOffset() * 60 * 1000); + return local.toISOString().slice(0, 16); +} diff --git a/src/pages/today/useTodayPage.ts b/src/pages/today/useTodayPage.ts new file mode 100644 index 0000000..5f2e934 --- /dev/null +++ b/src/pages/today/useTodayPage.ts @@ -0,0 +1,890 @@ +import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + addFoodEntry, + addMedicationCheckIn, + addWater, + addWeight, + archiveMedication, + createFood, + deleteFoodEntry, + deleteMedicationCheckIn, + deleteWater, + getDashboard, + saveMedication, + updateFoodEntry, + updateMedication, + updateWater, +} from '../../lib/api'; +import { enabledDailyActions } from '../../lib/daily-action-preferences'; +import { type DailyActionKey, getDailyActionState } from '../../lib/daily-actions'; +import { directEntryError, foodFromDirectEntry, mergeDashboardEntry } from '../../lib/entries'; +import { normalizeFoodLabels } from '../../lib/food-context'; +import { computeMacroCompletion } from '../../lib/macro-completion'; +import { + calculateGymGuidance, + calculateSleepGuidance, + scaleNutrients, +} from '../../lib/recommendations'; +import type { + Dashboard, + Food, + FoodEntry, + Medication, + MedicationSchedule, + WaterEntry, + WeightEntry, +} from '../../lib/types'; +import { type EntryDraft, toLocalInput, type UndoAction, withEntries } from './today-utils'; + +export function useTodayPage({ cloudRevision }: { cloudRevision: number }) { + const [dashboard, setDashboard] = useState(null); + const [error, setError] = useState(null); + const [pendingId, setPendingId] = useState(null); + const [undo, setUndo] = useState(null); + const [entryDraft, setEntryDraft] = useState(null); + const [entryError, setEntryError] = useState(null); + const [medicationEditorOpen, setMedicationEditorOpen] = useState(false); + const [medicationName, setMedicationName] = useState(''); + const [medicationSchedule, setMedicationSchedule] = useState('morning'); + const [editingMedicationId, setEditingMedicationId] = useState(null); + const [weightEditorOpen, setWeightEditorOpen] = useState(false); + const [weightValue, setWeightValue] = useState(''); + const [dailyAnnouncement, setDailyAnnouncement] = useState(''); + const [editingWaterId, setEditingWaterId] = useState(null); + const [waterAmount, setWaterAmount] = useState(''); + const [waterTime, setWaterTime] = useState(''); + const entryFoodSelectRef = useRef(null); + const entryNameInputRef = useRef(null); + const entrySheetRef = useRef(null); + const entrySheetBackdropRef = useRef(null); + const entrySheetOpenerRef = useRef(null); + const pageStackRef = useRef(null); + const weightInputRef = useRef(null); + const dailyActionsRef = useRef(null); + const previousIncompleteRef = useRef(null); + const entrySheetOpen = entryDraft !== null; + + const load = useCallback(async () => { + setError(null); + try { + setDashboard(await getDashboard()); + } catch (caught) { + setError(caught instanceof Error ? caught.message : 'Today could not load.'); + } + }, []); + + useEffect(() => { + void load(); + }, [load, cloudRevision]); + + useEffect(() => { + if (!undo) return; + const timer = window.setTimeout(() => setUndo(null), 6000); + return () => window.clearTimeout(timer); + }, [undo]); + + useEffect(() => { + if (!entrySheetOpen) return; + entrySheetOpenerRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + const inertTargets = [ + document.querySelector('.app-header'), + document.querySelector('.offline-banner'), + document.querySelector('.desktop-nav'), + document.querySelector('.bottom-nav'), + ...Array.from(pageStackRef.current?.children ?? []).filter( + (element): element is HTMLElement => + element instanceof HTMLElement && element !== entrySheetBackdropRef.current + ), + ].filter((element): element is HTMLElement => Boolean(element)); + const inertState = inertTargets.map((element) => ({ + element, + wasInert: element.hasAttribute('inert'), + })); + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + for (const { element } of inertState) element.setAttribute('inert', ''); + + return () => { + document.body.style.overflow = previousOverflow; + for (const { element, wasInert } of inertState) { + if (!wasInert) element.removeAttribute('inert'); + } + const opener = entrySheetOpenerRef.current; + entrySheetOpenerRef.current = null; + window.requestAnimationFrame(() => opener?.focus()); + }; + }, [entrySheetOpen]); + + useEffect(() => { + if (!entrySheetOpen) return; + if (entryDraft?.mode === 'direct') entryNameInputRef.current?.focus(); + else entryFoodSelectRef.current?.focus(); + }, [entryDraft?.mode, entrySheetOpen]); + + const handleEntrySheetKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + setEntryDraft(null); + return; + } + if (event.key !== 'Tab') return; + const sheet = entrySheetRef.current; + if (!sheet) return; + const focusable = Array.from( + sheet.querySelectorAll( + 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])' + ) + ).filter((element) => !element.hasAttribute('hidden')); + if (!focusable.length) { + event.preventDefault(); + sheet.focus(); + return; + } + const first = focusable[0]; + const last = focusable.at(-1); + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + useEffect(() => { + if (!weightEditorOpen) return; + window.requestAnimationFrame(() => weightInputRef.current?.focus()); + }, [weightEditorOpen]); + + const gym = useMemo( + () => (dashboard ? calculateGymGuidance(dashboard.entries) : null), + [dashboard] + ); + const sleep = useMemo(() => { + if (!dashboard) return null; + const last = [...dashboard.entries].sort((a, b) => b.eatenAt - a.eatenAt)[0]; + const lastDate = last ? new Date(last.eatenAt) : null; + return calculateSleepGuidance({ + wakeTime: dashboard.profile.wakeTime, + sleepHours: dashboard.profile.sleepHours, + lastEntryLocalMinutes: lastDate ? lastDate.getHours() * 60 + lastDate.getMinutes() : null, + lastEntryCalories: last?.calories ?? null, + }); + }, [dashboard]); + const latestFast = useMemo(() => dashboard?.completedFasts.at(-1) ?? null, [dashboard]); + const completion = useMemo( + () => + dashboard?.target.calorieTarget + ? computeMacroCompletion({ + totals: dashboard.totals, + target: dashboard.target, + foods: dashboard.foods, + }) + : null, + [dashboard] + ); + const quickFoods = useMemo( + () => + dashboard + ? [...dashboard.foods].sort( + (a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0) || a.name.localeCompare(b.name) + ) + : [], + [dashboard] + ); + const dailyActionState = useMemo( + () => + dashboard + ? getDailyActionState({ + date: dashboard.date, + timezone: dashboard.timezone, + entries: dashboard.entries, + waterEntries: dashboard.waterEntries, + medications: dashboard.medications, + medicationCheckIns: dashboard.medicationCheckIns, + latestWeight: dashboard.latestWeight, + }) + : null, + [dashboard] + ); + const incompleteActions = useMemo( + () => + (dashboard ? enabledDailyActions(dashboard.profile) : []).filter( + (key) => !dailyActionState?.completed[key] + ), + [dailyActionState, dashboard] + ); + + useEffect(() => { + const previous = previousIncompleteRef.current; + previousIncompleteRef.current = incompleteActions; + if (!previous || incompleteActions.length >= previous.length) return; + window.requestAnimationFrame(() => { + const next = dailyActionsRef.current?.querySelector( + 'button:not([disabled]), input:not([disabled])' + ); + next?.focus(); + }); + }, [incompleteActions]); + + const quickAdd = async (food: Food) => { + if (!dashboard || pendingId) return; + const id = crypto.randomUUID(); + const nutrients = scaleNutrients(food, food.servingMode, food.defaultAmount); + const optimistic: FoodEntry = { + id, + foodId: food.id, + foodName: food.name, + amount: food.defaultAmount, + unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, + ...nutrients, + eatenAt: Date.now(), + }; + setPendingId(food.id); + setDashboard( + withEntries(dashboard, [optimistic, ...dashboard.entries], dashboard.waterEntries) + ); + try { + const saved = await addFoodEntry({ + ...optimistic, + optimistic, + }); + setDashboard((current) => + current + ? withEntries( + current, + current.entries.map((entry) => (entry.id === id ? saved : entry)), + current.waterEntries + ) + : current + ); + setUndo({ kind: 'food', id, label: `${food.name} logged` }); + setDailyAnnouncement(`Food logged. ${food.name} is in today’s journal.`); + } catch (caught) { + setDashboard(dashboard); + setError(caught instanceof Error ? caught.message : 'Food could not be logged.'); + } finally { + setPendingId(null); + } + }; + + const quickWater = async (amountMl: number) => { + if (!dashboard || pendingId) return; + const entry: WaterEntry = { + id: crypto.randomUUID(), + amountMl, + drankAt: Date.now(), + }; + setPendingId(`water-${amountMl}`); + setDashboard(withEntries(dashboard, dashboard.entries, [entry, ...dashboard.waterEntries])); + try { + await addWater(entry); + setUndo({ kind: 'water', id: entry.id, label: `${amountMl} ml water logged` }); + setDailyAnnouncement(`${amountMl} ml water logged.`); + } catch (caught) { + setDashboard(dashboard); + setError(caught instanceof Error ? caught.message : 'Water could not be logged.'); + } finally { + setPendingId(null); + } + }; + + const beginWaterEdit = (entry: WaterEntry) => { + setEditingWaterId(entry.id); + setWaterAmount(String(entry.amountMl)); + setWaterTime(toLocalInput(entry.drankAt)); + }; + + const saveWaterEdit = async () => { + if (!dashboard || !editingWaterId) return; + const amountMl = Number(waterAmount); + const drankAt = new Date(waterTime).getTime(); + if ( + !Number.isFinite(amountMl) || + amountMl < 1 || + amountMl > 5000 || + !Number.isFinite(drankAt) + ) { + setError('Enter water between 1 and 5,000 ml and choose a valid time.'); + return; + } + const prior = dashboard; + const updated: WaterEntry = { id: editingWaterId, amountMl: Math.round(amountMl), drankAt }; + setDashboard( + withEntries( + dashboard, + dashboard.entries, + dashboard.waterEntries.map((entry) => (entry.id === updated.id ? updated : entry)) + ) + ); + setEditingWaterId(null); + try { + await updateWater(updated); + setDailyAnnouncement(`${updated.amountMl} ml water check-in updated.`); + } catch (caught) { + setDashboard(prior); + setError(caught instanceof Error ? caught.message : 'Water check-in could not be updated.'); + } + }; + + const removeWater = async (entry: WaterEntry) => { + if (!dashboard) return; + const prior = dashboard; + setDashboard( + withEntries( + dashboard, + dashboard.entries, + dashboard.waterEntries.filter((item) => item.id !== entry.id) + ) + ); + setUndo({ kind: 'delete-water', entry, label: `${entry.amountMl} ml water removed` }); + try { + await deleteWater(entry.id); + setDailyAnnouncement('Water check-in removed.'); + } catch (caught) { + setDashboard(prior); + setUndo(null); + setError(caught instanceof Error ? caught.message : 'Water check-in could not be removed.'); + } + }; + + const addMedication = async () => { + const name = medicationName.trim(); + if (!dashboard || !name || pendingId) return; + const editing = dashboard.medications.find( + (medication) => medication.id === editingMedicationId + ); + const medication: Medication = { + id: editing?.id ?? crypto.randomUUID(), + name, + schedule: medicationSchedule, + createdAt: editing?.createdAt ?? Date.now(), + archivedAt: null, + }; + setPendingId(`medication-${medication.id}`); + setDashboard({ + ...dashboard, + medications: editing + ? dashboard.medications.map((item) => (item.id === medication.id ? medication : item)) + : [...dashboard.medications, medication], + }); + try { + if (editing) await updateMedication(medication); + else await saveMedication(medication); + if (/^creatine(?:\s|$)/i.test(medication.name)) { + setDailyAnnouncement('Creatine routine is ready to check in.'); + } + setMedicationName(''); + setMedicationSchedule('morning'); + setEditingMedicationId(null); + } catch (caught) { + setDashboard(dashboard); + setError(caught instanceof Error ? caught.message : 'Medication could not be saved.'); + } finally { + setPendingId(null); + } + }; + + const removeMedication = async (medication: Medication) => { + if (!dashboard || pendingId) return; + setPendingId(`archive-medication-${medication.id}`); + setDashboard({ + ...dashboard, + medications: dashboard.medications.filter((item) => item.id !== medication.id), + }); + try { + await archiveMedication(medication); + } catch (caught) { + setDashboard(dashboard); + setError(caught instanceof Error ? caught.message : 'Medication could not be archived.'); + } finally { + setPendingId(null); + } + }; + + const toggleMedication = async (medication: Medication) => { + if (!dashboard || pendingId) return; + const existing = dashboard.medicationCheckIns.find( + (checkIn) => checkIn.medicationId === medication.id + ); + setPendingId(`medication-check-in-${medication.id}`); + if (existing) { + setDashboard({ + ...dashboard, + medicationCheckIns: dashboard.medicationCheckIns.filter( + (checkIn) => checkIn.id !== existing.id + ), + }); + try { + await deleteMedicationCheckIn(existing.id); + setDailyAnnouncement(`${medication.name} check-in removed.`); + } catch (caught) { + setDashboard(dashboard); + setError(caught instanceof Error ? caught.message : 'Check-off could not be updated.'); + } finally { + setPendingId(null); + } + return; + } + + const checkIn = { + id: crypto.randomUUID(), + medicationId: medication.id, + takenOn: dashboard.date, + takenAt: Date.now(), + }; + setDashboard({ + ...dashboard, + medicationCheckIns: [checkIn, ...dashboard.medicationCheckIns], + }); + try { + await addMedicationCheckIn(checkIn); + setDailyAnnouncement(`${medication.name} checked in for today.`); + } catch (caught) { + setDashboard(dashboard); + setError(caught instanceof Error ? caught.message : 'Check-off could not be updated.'); + } finally { + setPendingId(null); + } + }; + + const undoLast = async () => { + if (!undo || !dashboard) return; + const action = undo; + setUndo(null); + if (action.kind === 'food') { + setDashboard( + withEntries( + dashboard, + dashboard.entries.filter((entry) => entry.id !== action.id), + dashboard.waterEntries + ) + ); + await deleteFoodEntry(action.id).catch(() => void load()); + } else if (action.kind === 'water') { + setDashboard( + withEntries( + dashboard, + dashboard.entries, + dashboard.waterEntries.filter((entry) => entry.id !== action.id) + ) + ); + await deleteWater(action.id).catch(() => void load()); + } else if (action.kind === 'delete-entry') { + const entry = action.entry; + setDashboard(withEntries(dashboard, [entry, ...dashboard.entries], dashboard.waterEntries)); + await addFoodEntry({ + ...entry, + optimistic: entry, + }).catch(() => void load()); + } else { + const entry = action.entry; + setDashboard(withEntries(dashboard, dashboard.entries, [entry, ...dashboard.waterEntries])); + await addWater(entry).catch(() => void load()); + } + }; + + const openNewEntry = () => { + if (!dashboard) return; + const food = dashboard.foods[0]; + setEntryError(null); + setEntryDraft({ + entryId: null, + mode: food ? 'saved' : 'direct', + foodId: food?.id ?? null, + foodName: food?.name ?? '', + amount: food?.defaultAmount ?? 1, + unitLabel: food ? (food.servingMode === 'per_100g' ? 'g' : food.unitLabel) : 'serving', + ...(food + ? scaleNutrients(food, food.servingMode, food.defaultAmount) + : { calories: 0, carbsG: 0, proteinG: 0, fibreG: 0 }), + eatenAt: toLocalInput(Date.now()), + saveForLater: false, + isPackaged: food?.isPackaged ?? false, + labels: food?.labels ?? [], + }); + }; + + const openEntry = (entry: FoodEntry) => { + const hasSavedFood = dashboard?.foods.some((food) => food.id === entry.foodId) ?? false; + setEntryError(null); + setEntryDraft({ + entryId: entry.id, + mode: hasSavedFood ? 'saved' : 'direct', + foodId: hasSavedFood ? entry.foodId : null, + foodName: entry.foodName, + amount: entry.amount, + unitLabel: entry.unitLabel, + calories: entry.calories, + carbsG: entry.carbsG, + proteinG: entry.proteinG, + fibreG: entry.fibreG, + eatenAt: toLocalInput(entry.eatenAt), + saveForLater: false, + isPackaged: entry.isPackaged ?? false, + labels: entry.labels ?? [], + }); + }; + + const chooseEntryFood = (foodId: string) => { + if (!dashboard) return; + const food = dashboard.foods.find((item) => item.id === foodId); + setEntryDraft((current) => + current && food + ? { + ...current, + foodId, + foodName: food.name, + amount: food.defaultAmount, + unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, + ...scaleNutrients(food, food.servingMode, food.defaultAmount), + isPackaged: food.isPackaged ?? false, + labels: food.labels ?? [], + } + : current + ); + }; + + const chooseEntryMode = (mode: EntryDraft['mode']) => { + if (!dashboard) return; + setEntryError(null); + setEntryDraft((current) => { + if (!current || current.mode === mode) return current; + if (mode === 'direct') { + if (current.entryId) { + const food = dashboard.foods.find((item) => item.id === current.foodId); + const nutrients = food + ? scaleNutrients(food, food.servingMode, current.amount) + : { + calories: current.calories, + carbsG: current.carbsG, + proteinG: current.proteinG, + fibreG: current.fibreG, + }; + return { + ...current, + mode, + foodId: null, + foodName: food?.name ?? current.foodName, + unitLabel: + food?.servingMode === 'per_100g' ? 'g' : (food?.unitLabel ?? current.unitLabel), + saveForLater: false, + ...nutrients, + }; + } + return { + ...current, + mode, + foodId: null, + foodName: '', + amount: 1, + unitLabel: 'serving', + calories: 0, + carbsG: 0, + proteinG: 0, + fibreG: 0, + saveForLater: false, + isPackaged: false, + labels: [], + }; + } + + const food = dashboard.foods[0]; + if (!food) return current; + return { + ...current, + mode, + saveForLater: false, + foodId: food.id, + foodName: food.name, + amount: food.defaultAmount, + unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, + ...scaleNutrients(food, food.servingMode, food.defaultAmount), + isPackaged: food.isPackaged ?? false, + labels: food.labels ?? [], + }; + }); + }; + + const saveEntry = async () => { + if (!dashboard || !entryDraft || pendingId) return; + const food = dashboard.foods.find((item) => item.id === entryDraft.foodId); + const eatenAt = new Date(entryDraft.eatenAt).getTime(); + if (entryDraft.mode === 'saved' && !food) { + setEntryError('Choose a saved food.'); + return; + } + if (!Number.isFinite(entryDraft.amount) || entryDraft.amount <= 0) { + setEntryError('Add an amount above zero.'); + return; + } + if (!Number.isFinite(eatenAt) || eatenAt > Date.now() + 24 * 60 * 60 * 1000) { + setEntryError('Choose a valid time.'); + return; + } + + const id = entryDraft.entryId ?? crypto.randomUUID(); + const directEntry: FoodEntry = + entryDraft.mode === 'saved' && food + ? { + id, + foodId: food.id, + foodName: food.name, + amount: entryDraft.amount, + unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, + ...scaleNutrients(food, food.servingMode, entryDraft.amount), + eatenAt, + isPackaged: food.isPackaged, + labels: food.labels, + } + : { + id, + foodId: null, + foodName: entryDraft.foodName, + amount: entryDraft.amount, + unitLabel: entryDraft.unitLabel, + calories: entryDraft.calories, + carbsG: entryDraft.carbsG, + proteinG: entryDraft.proteinG, + fibreG: entryDraft.fibreG, + eatenAt, + isPackaged: entryDraft.isPackaged, + labels: normalizeFoodLabels(entryDraft.labels), + }; + const directError = directEntry.foodId === null ? directEntryError(directEntry) : null; + if (directError) { + setEntryError(directError); + return; + } + + const reusableFood = + directEntry.foodId === null && entryDraft.saveForLater + ? foodFromDirectEntry(directEntry, crypto.randomUUID()) + : null; + const optimistic: FoodEntry = reusableFood + ? { ...directEntry, foodId: reusableFood.id, foodName: reusableFood.name } + : directEntry; + const previous = dashboard; + const nextEntries = mergeDashboardEntry( + dashboard.entries, + optimistic, + dashboard.date, + dashboard.timezone + ); + let savedFood: Food | null = null; + setPendingId(`entry-${id}`); + try { + savedFood = reusableFood ? await createFood(reusableFood) : null; + setDashboard( + withEntries( + { ...dashboard, foods: savedFood ? [savedFood, ...dashboard.foods] : dashboard.foods }, + nextEntries, + dashboard.waterEntries + ) + ); + const saved = entryDraft.entryId + ? await updateFoodEntry({ + ...optimistic, + optimistic, + }) + : await addFoodEntry({ + ...optimistic, + optimistic, + }); + setDashboard((current) => + current + ? withEntries( + current, + current.entries.map((entry) => (entry.id === id ? saved : entry)), + current.waterEntries + ) + : current + ); + if (!entryDraft.entryId) { + setUndo({ + kind: 'food', + id, + label: savedFood + ? `${optimistic.foodName} saved and logged` + : `${optimistic.foodName} logged`, + }); + setDailyAnnouncement(`${optimistic.foodName} logged.`); + } + setEntryDraft(null); + } catch (caught) { + setDashboard( + savedFood + ? withEntries( + { ...previous, foods: [savedFood, ...previous.foods] }, + previous.entries, + previous.waterEntries + ) + : previous + ); + setEntryError( + savedFood + ? 'Food was saved, but this entry could not be logged. Try logging it again.' + : caught instanceof Error + ? caught.message + : 'Entry could not be saved.' + ); + } finally { + setPendingId(null); + } + }; + + const removeEntry = async () => { + if (!dashboard || !entryDraft?.entryId || pendingId) return; + const entry = dashboard.entries.find((item) => item.id === entryDraft.entryId); + if (!entry) return; + const previous = dashboard; + setPendingId(`entry-${entry.id}`); + setDashboard( + withEntries( + dashboard, + dashboard.entries.filter((item) => item.id !== entry.id), + dashboard.waterEntries + ) + ); + setEntryDraft(null); + try { + await deleteFoodEntry(entry.id); + setUndo({ kind: 'delete-entry', entry, label: `${entry.foodName} removed` }); + } catch (caught) { + setDashboard(previous); + setError(caught instanceof Error ? caught.message : 'Entry could not be removed.'); + } finally { + setPendingId(null); + } + }; + + const saveWeightCheckIn = async () => { + if (!dashboard || pendingId) return; + let weightKg = Number(weightValue); + if (dashboard.profile.units === 'imperial') weightKg /= 2.20462; + if (!Number.isFinite(weightKg) || weightKg < 30 || weightKg > 400) { + setError('Enter a weight between 30 and 400 kg (66 and 882 lb).'); + return; + } + const entry: WeightEntry = { + id: crypto.randomUUID(), + weightKg: Math.round(weightKg * 10) / 10, + recordedAt: Date.now(), + }; + const previous = dashboard; + setPendingId('weight-check-in'); + setDashboard({ ...dashboard, latestWeight: entry }); + try { + await addWeight(entry); + setWeightEditorOpen(false); + setWeightValue(''); + setDailyAnnouncement('Weight checked in for today.'); + } catch (caught) { + setDashboard(previous); + setError(caught instanceof Error ? caught.message : 'Weight could not be logged.'); + } finally { + setPendingId(null); + } + }; + + const handleDailyAction = (action: DailyActionKey) => { + if (!dashboard || pendingId) return; + if (action === 'weight') { + const latest = dashboard.latestWeight?.weightKg ?? null; + const display = + latest === null + ? '' + : dashboard.profile.units === 'imperial' + ? String(Math.round(latest * 2.20462 * 10) / 10) + : String(latest); + setWeightValue(display); + setWeightEditorOpen(true); + return; + } + if (action === 'food') { + openNewEntry(); + return; + } + if (action === 'water') { + void quickWater(250); + return; + } + if (dailyActionState?.creatineRoutine) { + void toggleMedication(dailyActionState.creatineRoutine); + return; + } + setMedicationEditorOpen(true); + setEditingMedicationId(null); + setMedicationName('Creatine'); + setMedicationSchedule('either'); + setDailyAnnouncement('Creatine setup opened below.'); + window.requestAnimationFrame(() => + document.getElementById('medication-editor')?.scrollIntoView({ block: 'center' }) + ); + }; + + return { + dashboard, + error, + setError, + pendingId, + undo, + entryDraft, + setEntryDraft, + entryError, + setEntryError, + medicationEditorOpen, + setMedicationEditorOpen, + medicationName, + setMedicationName, + medicationSchedule, + setMedicationSchedule, + editingMedicationId, + setEditingMedicationId, + weightEditorOpen, + setWeightEditorOpen, + weightValue, + setWeightValue, + dailyAnnouncement, + editingWaterId, + setEditingWaterId, + waterAmount, + setWaterAmount, + waterTime, + setWaterTime, + entryFoodSelectRef, + entryNameInputRef, + entrySheetRef, + entrySheetBackdropRef, + pageStackRef, + weightInputRef, + dailyActionsRef, + load, + handleEntrySheetKeyDown, + gym, + sleep, + latestFast, + completion, + quickFoods, + dailyActionState, + incompleteActions, + quickAdd, + quickWater, + beginWaterEdit, + saveWaterEdit, + removeWater, + addMedication, + removeMedication, + toggleMedication, + undoLast, + openNewEntry, + openEntry, + chooseEntryFood, + chooseEntryMode, + saveEntry, + removeEntry, + saveWeightCheckIn, + handleDailyAction, + }; +} diff --git a/src/worker-source.test.ts b/src/worker-source.test.ts index c7678c6..c2753df 100644 --- a/src/worker-source.test.ts +++ b/src/worker-source.test.ts @@ -1,7 +1,13 @@ -import { readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -const source = readFileSync(new URL('./worker.ts', import.meta.url), 'utf8'); +const source = [ + readFileSync(new URL('./worker.ts', import.meta.url), 'utf8'), + ...readdirSync(new URL('./worker/', import.meta.url)) + .filter((name) => name.endsWith('.ts')) + .sort() + .map((name) => readFileSync(new URL(`./worker/${name}`, import.meta.url), 'utf8')), +].join('\n'); describe('private Worker data controls', () => { it('scopes correction mutations to the signed-in owner', () => { diff --git a/src/worker.ts b/src/worker.ts index ee89ca4..0ffebc7 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,73 +1,15 @@ import { Hono } from 'hono'; import { handleAgentEdge } from './agent-edge.mjs'; -import { - normalizeDailyActionHidden, - normalizeDailyActionOrder, -} from './lib/daily-action-preferences'; -import { normalizeDirectEntry } from './lib/entries'; -import { normalizeFoodLabels, normalizeIsPackaged } from './lib/food-context'; -import { cycleFromGoal } from './lib/goal-cycles'; -import { createJournalExport } from './lib/journal-export'; -import { - calculateCompletedFasts, - calculateNutritionTarget, - round, - scaleNutrients, -} from './lib/recommendations'; -import type { - ActivityLevel, - Dashboard, - EquationProfile, - Food, - FoodEntry, - Goal, - GoalCycle, - GoalCycleSession, - HistoryDay, - HistoryResponse, - Medication, - MedicationCheckIn, - MedicationSchedule, - ServingMode, - UserProfile, - WaterEntry, - WeightEntry, -} from './lib/types'; -import { - type AuthBindings, - createAuth, - isAppleConfigured, - isAppleWebConfigured, - isGoogleConfigured, -} from './server/auth'; -import { - consumeNativeHandoff, - createNativeHandoffCode, - isAllowedNativeCallback, - NATIVE_AUTH_CALLBACK, - saveNativeHandoff, -} from './server/native-handoff'; -import { DASHBOARD_FOODS_QUERY } from './server/queries'; -import { authenticateMcpRead, createReadToken, hashReadToken } from './server/read-tokens'; - -type AppBindings = AuthBindings; -type AppVariables = { - userId: string; - userName: string; - userEmail: string; - userImage: string | null; - mcpUserId: string; -}; +import { registerAccountRoutes } from './worker/account'; +import { registerAuthRoutes, registerSessionMiddleware } from './worker/auth'; +import { SECURITY_HEADERS } from './worker/http'; +import { registerJournalRoutes } from './worker/journal'; +import { registerMcpRoutes } from './worker/mcp'; +import { registerReadRoutes } from './worker/reads'; +import type { AppBindings, AppVariables } from './worker/types'; const app = new Hono<{ Bindings: AppBindings; Variables: AppVariables }>(); -const SECURITY_HEADERS = { - 'X-Content-Type-Options': 'nosniff', - 'X-Frame-Options': 'DENY', - 'Referrer-Policy': 'strict-origin-when-cross-origin', - 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', -}; - app.use('*', async (c, next) => { const agentResponse = handleAgentEdge(c.req.raw); if (agentResponse) return agentResponse; @@ -82,2088 +24,12 @@ app.use('/api/*', async (c, next) => { if (!c.res.headers.has('Cache-Control')) c.header('Cache-Control', 'no-store'); }); -/** - * Weak ETag helper for read-only API responses. Combines the user ID, request - * query string, and a 30-second time bucket so responses are cacheable for 30s - * on the client and via conditional requests (If-None-Match → 304). - */ -function etagFor(userId: string, query: string): string { - const bucket = Math.floor(Date.now() / 30_000); - return `W/"${userId}:${bucket}:${query.length}"`; -} - -function conditionalJson( - c: { - req: { url: string; header: (name: string) => string | undefined }; - get: (key: 'userId') => string; - header: (name: string, value: string) => void; - json: (data: T) => Response; - body: (data: null, status: number) => Response; - }, - data: T -): Response { - const tag = etagFor(c.get('userId'), new URL(c.req.url).search); - if (c.req.header('If-None-Match') === tag) return c.body(null, 304); - c.header('ETag', tag); - c.header('Cache-Control', 'private, max-age=30'); - return c.json(data); -} - -app.get('/api/health', (c) => - c.json({ - ok: true, - auth: { - googleConfigured: isGoogleConfigured(c.env), - appleConfigured: isAppleConfigured(c.env), - appleWebConfigured: isAppleWebConfigured(c.env), - }, - storage: 'd1', - }) -); - -app.get('/api/auth/config', (c) => - c.json({ - googleConfigured: isGoogleConfigured(c.env), - appleConfigured: isAppleConfigured(c.env), - appleWebConfigured: isAppleWebConfigured(c.env), - }) -); - -app.on(['GET', 'POST'], '/api/auth/*', async (c) => { - const path = new URL(c.req.url).pathname; - if (path.endsWith('/sign-in/social') && c.req.method === 'POST') { - const body = await c.req.raw - .clone() - .json<{ idToken?: unknown; provider?: unknown }>() - .catch(() => null); - const provider = body?.provider; - if (provider === 'google' && !isGoogleConfigured(c.env)) { - return c.json( - { - code: 'OAUTH_NOT_CONFIGURED', - message: 'Google sign-in is not configured in this environment.', - }, - 503 - ); - } - if (provider === 'apple' && !isAppleConfigured(c.env)) { - return c.json( - { - code: 'OAUTH_NOT_CONFIGURED', - message: 'Apple sign-in is not configured in this environment.', - }, - 503 - ); - } - if (provider === 'apple' && !body?.idToken && !isAppleWebConfigured(c.env)) { - return c.json( - { - code: 'OAUTH_NOT_CONFIGURED', - message: 'Apple browser sign-in is not configured in this environment.', - }, - 503 - ); - } - } - return createAuth(c.env, c.req.url).handler(c.req.raw); -}); - -app.get('/api/native/auth/google/start', async (c) => { - if (!isGoogleConfigured(c.env)) { - return c.json({ code: 'OAUTH_NOT_CONFIGURED', message: 'Google sign-in is unavailable.' }, 503); - } - const callback = c.req.query('callback') ?? NATIVE_AUTH_CALLBACK; - if (!isAllowedNativeCallback(callback)) { - return c.json( - { code: 'INVALID_CALLBACK', message: 'The native callback is not allowed.' }, - 400 - ); - } - const completeURL = new URL('/api/native/auth/google/complete', c.req.url); - completeURL.searchParams.set('callback', callback); - const result = await createAuth(c.env, c.req.url).api.signInSocial({ - body: { - provider: 'google', - callbackURL: completeURL.toString(), - errorCallbackURL: completeURL.toString(), - }, - headers: c.req.raw.headers, - }); - if (!result.url) { - return c.json({ code: 'OAUTH_START_FAILED', message: 'Google sign-in could not start.' }, 502); - } - return c.redirect(result.url); -}); - -app.get('/api/native/auth/google/complete', async (c) => { - const callback = c.req.query('callback') ?? NATIVE_AUTH_CALLBACK; - if (!isAllowedNativeCallback(callback)) { - return c.json( - { code: 'INVALID_CALLBACK', message: 'The native callback is not allowed.' }, - 400 - ); - } - const session = await createAuth(c.env, c.req.url).api.getSession({ - headers: c.req.raw.headers, - }); - const redirect = new URL(callback); - if (!session?.session.token) { - redirect.searchParams.set('error', 'google_auth_failed'); - return c.redirect(redirect.toString()); - } - const code = createNativeHandoffCode(); - await saveNativeHandoff(c.env.DB, code, session.session.token); - redirect.searchParams.set('code', code); - return c.redirect(redirect.toString()); -}); - -app.post('/api/native/auth/exchange', async (c) => { - const body = await c.req.json<{ code?: unknown }>().catch(() => null); - const code = typeof body?.code === 'string' ? body.code.trim() : ''; - if (code.length < 32 || code.length > 128) { - return c.json({ code: 'INVALID_HANDOFF', message: 'The sign-in handoff is invalid.' }, 400); - } - const token = await consumeNativeHandoff(c.env.DB, code); - if (!token) { - return c.json( - { code: 'EXPIRED_HANDOFF', message: 'The sign-in handoff expired or was already used.' }, - 401 - ); - } - return c.json({ token }); -}); - -app.use('/api/app/*', async (c, next) => { - const session = await createAuth(c.env, c.req.url).api.getSession({ - headers: c.req.raw.headers, - }); - if (!session?.user?.id) { - return c.json({ code: 'UNAUTHORIZED', message: 'Sign in to continue.' }, 401); - } - c.set('userId', session.user.id); - c.set('userName', session.user.name || 'You'); - c.set('userEmail', session.user.email || ''); - c.set('userImage', session.user.image || null); - await next(); -}); - -app.use('/api/mcp/*', async (c, next) => { - const auth = await authenticateMcpRead(c.env.DB, c.req.header('Authorization'), c.env); - if (auth.status === 'account_not_found') { - return c.json( - { - code: 'ACCOUNT_NOT_FOUND', - message: 'Sign in to Calorie with the same Google account first.', - }, - 403 - ); - } - if (auth.status !== 'authorized') { - return c.json({ code: 'UNAUTHORIZED', message: 'Provide a valid Calorie read token.' }, 401); - } - c.set('mcpUserId', auth.userId); - await next(); -}); - -function finiteNumber(value: unknown, min: number, max: number): number | null { - const number = typeof value === 'number' ? value : Number(value); - return Number.isFinite(number) && number >= min && number <= max ? number : null; -} - -function optionalText(value: unknown, max = 80): string | null { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - return trimmed.length > 0 && trimmed.length <= max ? trimmed : null; -} - -function requiredText(value: unknown, max = 80): string | null { - return optionalText(value, max); -} - -function validTimestamp(value: unknown): number | null { - const timestamp = finiteNumber(value, 0, Date.now() + 24 * 60 * 60 * 1000); - return timestamp === null ? null : Math.round(timestamp); -} - -function jsonError(message: string, fields?: Record) { - return { code: 'VALIDATION_ERROR', message, fields }; -} - -function directEntryFromBody( - body: Record, - id: string, - amount: number, - eatenAt: number -): FoodEntry | null { - const foodName = optionalText(body.foodName, 80); - const unitLabel = optionalText(body.unitLabel, 24); - const calories = finiteNumber(body.calories, 0, 100_000); - const carbsG = finiteNumber(body.carbsG, 0, 100_000); - const proteinG = finiteNumber(body.proteinG, 0, 100_000); - const fibreG = finiteNumber(body.fibreG, 0, 100_000); - if ( - !foodName || - !unitLabel || - calories === null || - carbsG === null || - proteinG === null || - fibreG === null - ) { - return null; - } - return normalizeDirectEntry({ - id, - foodId: null, - foodName, - amount, - unitLabel, - calories, - carbsG, - proteinG, - fibreG, - eatenAt, - isPackaged: normalizeIsPackaged(body.isPackaged, body.foodKind), - labels: normalizeFoodLabels(body.labels), - }); -} - -type ProfileRow = { - user_id: string; - display_name: string; - units: 'metric' | 'imperial'; - age_years: number | null; - gender_identity: string | null; - equation_profile: EquationProfile | null; - height_cm: number | null; - activity_level: ActivityLevel; - goal: Goal; - target_weight_kg: number | null; - manual_calorie_target: number | null; - manual_calorie_min: number | null; - manual_calorie_max: number | null; - daily_action_order: string; - daily_action_hidden: string; - wake_time: string; - sleep_hours: number; - fasting_threshold_hours: 12 | 14 | 16; - water_target_ml: number; - onboarding_complete: number; -}; - -function mapProfile(row: ProfileRow): UserProfile { - return { - userId: row.user_id, - displayName: row.display_name, - units: row.units, - ageYears: row.age_years, - genderIdentity: row.gender_identity, - equationProfile: row.equation_profile, - heightCm: row.height_cm, - activityLevel: row.activity_level, - goal: row.goal, - targetWeightKg: row.target_weight_kg, - manualCalorieTarget: row.manual_calorie_target, - manualCalorieRange: - row.manual_calorie_min !== null && row.manual_calorie_max !== null - ? [row.manual_calorie_min, row.manual_calorie_max] - : row.manual_calorie_target - ? [Math.max(800, row.manual_calorie_target - 100), row.manual_calorie_target + 100] - : null, - wakeTime: row.wake_time, - sleepHours: row.sleep_hours, - fastingThresholdHours: row.fasting_threshold_hours, - waterTargetMl: row.water_target_ml, - dailyActionOrder: normalizeDailyActionOrder(row.daily_action_order?.split(',') ?? []), - dailyActionHidden: normalizeDailyActionHidden(row.daily_action_hidden?.split(',') ?? []), - onboardingComplete: Boolean(row.onboarding_complete), - }; -} - -function defaultProfile(userId: string, name: string): UserProfile { - return { - userId, - displayName: name, - units: 'metric', - ageYears: null, - genderIdentity: null, - equationProfile: null, - heightCm: null, - activityLevel: 'moderate', - goal: 'maintain', - targetWeightKg: null, - manualCalorieTarget: null, - manualCalorieRange: null, - wakeTime: '07:00', - sleepHours: 8, - fastingThresholdHours: 12, - waterTargetMl: 2000, - dailyActionOrder: normalizeDailyActionOrder([]), - dailyActionHidden: [], - onboardingComplete: false, - }; -} - -async function readProfile( - db: D1Database, - userId: string, - fallbackName: string -): Promise { - const row = await db - .prepare('SELECT * FROM profiles WHERE user_id = ?') - .bind(userId) - .first(); - return row ? mapProfile(row) : defaultProfile(userId, fallbackName); -} - -type FoodRow = { - id: string; - name: string; - serving_mode: ServingMode; - unit_label: string; - default_amount: number; - calories: number; - carbs_g: number; - protein_g: number; - fibre_g: number; - favourite: number; - last_used_at: number | null; - archived_at: number | null; - food_kind: string; - is_packaged: number; - labels_json: string; -}; - -function mapFood(row: FoodRow): Food { - return { - id: row.id, - name: row.name, - servingMode: row.serving_mode, - unitLabel: row.unit_label, - defaultAmount: row.default_amount, - calories: row.calories, - carbsG: row.carbs_g, - proteinG: row.protein_g, - fibreG: row.fibre_g, - favourite: Boolean(row.favourite), - lastUsedAt: row.last_used_at, - archivedAt: row.archived_at ?? null, - isPackaged: normalizeIsPackaged(row.is_packaged, row.food_kind), - labels: normalizeFoodLabels(JSON.parse(row.labels_json || '[]')), - }; -} - -type FoodEntryRow = { - id: string; - food_id: string | null; - food_name: string; - amount: number; - unit_label: string; - calories: number; - carbs_g: number; - protein_g: number; - fibre_g: number; - eaten_at: number; - food_kind: string; - is_packaged: number; - labels_json: string; -}; - -function mapFoodEntry(row: FoodEntryRow): FoodEntry { - return { - id: row.id, - foodId: row.food_id, - foodName: row.food_name, - amount: row.amount, - unitLabel: row.unit_label, - calories: row.calories, - carbsG: row.carbs_g, - proteinG: row.protein_g, - fibreG: row.fibre_g, - eatenAt: row.eaten_at, - isPackaged: normalizeIsPackaged(row.is_packaged, row.food_kind), - labels: normalizeFoodLabels(JSON.parse(row.labels_json || '[]')), - }; -} - -type WaterRow = { id: string; amount_ml: number; drank_at: number }; -type WeightRow = { id: string; weight_kg: number; recorded_at: number }; -type GoalCycleRow = { - id: string; - user_id: string; - cycle: GoalCycle; - goal: Goal; - start_on: string; - end_on: string | null; - calorie_range_low: number | null; - calorie_range_high: number | null; - protein_range_low: number | null; - protein_range_high: number | null; - created_at: number; - updated_at: number; -}; -type MedicationRow = { - id: string; - name: string; - schedule: MedicationSchedule; - created_at: number; - archived_at: number | null; -}; -type MedicationCheckInRow = { - id: string; - medication_id: string; - taken_on: string; - taken_at: number; -}; -type MedicationHistoryRow = { - id: string; - medication_id: string; - medication_name: string; - taken_at: number; -}; - -function mapWater(row: WaterRow): WaterEntry { - return { id: row.id, amountMl: row.amount_ml, drankAt: row.drank_at }; -} - -function mapWeight(row: WeightRow): WeightEntry { - return { id: row.id, weightKg: row.weight_kg, recordedAt: row.recorded_at }; -} - -function mapGoalCycle(row: GoalCycleRow): GoalCycleSession { - return { - id: row.id, - userId: row.user_id, - cycle: row.cycle, - goal: row.goal, - startOn: row.start_on, - endOn: row.end_on, - calorieRange: - row.calorie_range_low !== null && row.calorie_range_high !== null - ? [row.calorie_range_low, row.calorie_range_high] - : null, - proteinRangeG: - row.protein_range_low !== null && row.protein_range_high !== null - ? [row.protein_range_low, row.protein_range_high] - : null, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; -} - -function validDateKey(value: unknown) { - return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null; -} - -async function currentTarget(db: D1Database, profile: UserProfile, userId: string) { - const latestWeight = await db - .prepare( - 'SELECT id, weight_kg, recorded_at FROM weight_entries WHERE user_id = ? ORDER BY recorded_at DESC LIMIT 1' - ) - .bind(userId) - .first(); - return calculateNutritionTarget({ - weightKg: latestWeight?.weight_kg ?? null, - heightCm: profile.heightCm, - ageYears: profile.ageYears, - equationProfile: profile.equationProfile, - activityLevel: profile.activityLevel, - goal: profile.goal, - manualCalorieTarget: profile.manualCalorieTarget, - manualCalorieRange: profile.manualCalorieRange, - }); -} - -function cycleInsertStatement( - db: D1Database, - input: { - id: string; - userId: string; - goal: Goal; - startOn: string; - calorieRange: [number, number] | null; - proteinRangeG: [number, number] | null; - now: number; - } -) { - return db - .prepare( - `INSERT INTO goal_cycles ( - id, user_id, cycle, goal, start_on, end_on, - calorie_range_low, calorie_range_high, protein_range_low, protein_range_high, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?)` - ) - .bind( - input.id, - input.userId, - cycleFromGoal(input.goal), - input.goal, - input.startOn, - input.calorieRange?.[0] ?? null, - input.calorieRange?.[1] ?? null, - input.proteinRangeG?.[0] ?? null, - input.proteinRangeG?.[1] ?? null, - input.now, - input.now - ); -} - -function mapMedication(row: MedicationRow): Medication { - return { - id: row.id, - name: row.name, - schedule: row.schedule, - createdAt: row.created_at, - archivedAt: row.archived_at, - }; -} - -function mapMedicationCheckIn(row: MedicationCheckInRow): MedicationCheckIn { - return { - id: row.id, - medicationId: row.medication_id, - takenOn: row.taken_on, - takenAt: row.taken_at, - }; -} - -type ReadTokenRow = { - id: string; - name: string; - token_hint: string; - created_at: number; -}; - -app.get('/api/app/mcp-tokens', async (c) => { - const result = await c.env.DB.prepare( - `SELECT id, name, token_hint, created_at FROM mcp_read_tokens - WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 20` - ) - .bind(c.get('userId')) - .all(); - return c.json( - result.results.map((row) => ({ - id: row.id, - name: row.name, - tokenHint: row.token_hint, - createdAt: row.created_at, - })) - ); -}); - -app.post('/api/app/mcp-tokens', async (c) => { - const body = await c.req - .json>() - .catch((): Record => ({})); - const name = optionalText(body.name, 50) ?? 'ChatGPT read access'; - const token = createReadToken(); - const id = crypto.randomUUID(); - const createdAt = Date.now(); - await c.env.DB.prepare( - `INSERT INTO mcp_read_tokens - (id, user_id, name, token_hash, token_hint, created_at, revoked_at) - VALUES (?, ?, ?, ?, ?, ?, NULL)` - ) - .bind(id, c.get('userId'), name, await hashReadToken(token), token.slice(0, 24), createdAt) - .run(); - return c.json({ id, name, token, tokenHint: token.slice(0, 24), createdAt }, 201); -}); - -app.delete('/api/app/mcp-tokens/:id', async (c) => { - const result = await c.env.DB.prepare( - `UPDATE mcp_read_tokens SET revoked_at = ? - WHERE id = ? AND user_id = ? AND revoked_at IS NULL` - ) - .bind(Date.now(), c.req.param('id'), c.get('userId')) - .run(); - return result.meta.changes - ? c.body(null, 204) - : c.json({ message: 'Read token not found.' }, 404); -}); - -app.get('/api/app/profile', async (c) => { - const profile = await readProfile(c.env.DB, c.get('userId'), c.get('userName')); - return conditionalJson(c, profile); -}); - -app.get('/api/app/bootstrap', async (c) => { - const userId = c.get('userId'); - const profile = await readProfile(c.env.DB, userId, c.get('userName')); - return c.json({ - session: { - user: { - id: userId, - name: c.get('userName'), - email: c.get('userEmail'), - image: c.get('userImage'), - }, - }, - profile, - }); -}); - -app.put('/api/app/profile', async (c) => { - const body = await c.req.json>().catch(() => null); - if (!body) return c.json(jsonError('Profile details are required.'), 400); - - const displayName = requiredText(body.displayName, 60); - const units = body.units === 'imperial' ? 'imperial' : body.units === 'metric' ? 'metric' : null; - const ageYears = finiteNumber(body.ageYears, 18, 120); - const heightCm = finiteNumber(body.heightCm, 100, 250); - const equationProfile = ['female', 'male', 'none'].includes(String(body.equationProfile)) - ? (body.equationProfile as EquationProfile) - : null; - const activityLevel = ['sedentary', 'light', 'moderate', 'very'].includes( - String(body.activityLevel) - ) - ? (body.activityLevel as ActivityLevel) - : null; - const goal = ['lose_gentle', 'lose_steady', 'maintain', 'gain_gentle'].includes(String(body.goal)) - ? (body.goal as Goal) - : null; - const targetWeightKg = - body.targetWeightKg === null ? null : finiteNumber(body.targetWeightKg, 30, 400); - const initialWeightKg = - body.initialWeightKg === undefined ? null : finiteNumber(body.initialWeightKg, 30, 400); - const manualTarget = - body.manualCalorieTarget === null || body.manualCalorieTarget === undefined - ? null - : finiteNumber(body.manualCalorieTarget, 800, 6000); - const manualRangeInput = Array.isArray(body.manualCalorieRange) ? body.manualCalorieRange : null; - const manualRangeMin = manualRangeInput ? finiteNumber(manualRangeInput[0], 800, 6000) : null; - const manualRangeMax = manualRangeInput ? finiteNumber(manualRangeInput[1], 800, 6000) : null; - const hasInvalidManualRange = - manualRangeInput !== null && - (manualRangeMin === null || manualRangeMax === null || manualRangeMin > manualRangeMax); - const sleepHours = finiteNumber(body.sleepHours, 5, 12); - const waterTargetMl = finiteNumber(body.waterTargetMl, 250, 10000); - const fastingThreshold = [12, 14, 16].includes(Number(body.fastingThresholdHours)) - ? Number(body.fastingThresholdHours) - : null; - const wakeTime = - typeof body.wakeTime === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(body.wakeTime) - ? body.wakeTime - : null; - const dailyActionOrder = normalizeDailyActionOrder( - Array.isArray(body.dailyActionOrder) ? body.dailyActionOrder : [] - ); - const dailyActionHidden = normalizeDailyActionHidden( - Array.isArray(body.dailyActionHidden) ? body.dailyActionHidden : [] - ); - const cycleDate = validDateKey(body.cycleDate) ?? dateKey(Date.now(), 'UTC'); - - if ( - !displayName || - !units || - ageYears === null || - heightCm === null || - !equationProfile || - !activityLevel || - !goal || - sleepHours === null || - waterTargetMl === null || - hasInvalidManualRange || - fastingThreshold === null || - !wakeTime - ) { - return c.json(jsonError('Check the highlighted profile details and try again.'), 400); - } - - const now = Date.now(); - const userId = c.get('userId'); - const genderIdentity = optionalText(body.genderIdentity, 40); - const onboardingComplete = body.onboardingComplete === false ? 0 : 1; - const manualRange = - manualRangeMin !== null && manualRangeMax !== null - ? ([Math.round(manualRangeMin), Math.round(manualRangeMax)] as const) - : null; - - const statements = [ - c.env.DB.prepare( - `INSERT INTO profiles ( - user_id, display_name, units, age_years, gender_identity, equation_profile, - height_cm, activity_level, goal, target_weight_kg, manual_calorie_target, - manual_calorie_min, manual_calorie_max, - wake_time, sleep_hours, fasting_threshold_hours, water_target_ml, - daily_action_order, daily_action_hidden, onboarding_complete, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(user_id) DO UPDATE SET - display_name = excluded.display_name, - units = excluded.units, - age_years = excluded.age_years, - gender_identity = excluded.gender_identity, - equation_profile = excluded.equation_profile, - height_cm = excluded.height_cm, - activity_level = excluded.activity_level, - goal = excluded.goal, - target_weight_kg = excluded.target_weight_kg, - manual_calorie_target = excluded.manual_calorie_target, - manual_calorie_min = excluded.manual_calorie_min, - manual_calorie_max = excluded.manual_calorie_max, - wake_time = excluded.wake_time, - sleep_hours = excluded.sleep_hours, - fasting_threshold_hours = excluded.fasting_threshold_hours, - water_target_ml = excluded.water_target_ml, - daily_action_order = excluded.daily_action_order, - daily_action_hidden = excluded.daily_action_hidden, - onboarding_complete = excluded.onboarding_complete, - updated_at = excluded.updated_at` - ).bind( - userId, - displayName, - units, - ageYears, - genderIdentity, - equationProfile, - heightCm, - activityLevel, - goal, - targetWeightKg, - manualRange ? Math.round((manualRange[0] + manualRange[1]) / 2) : manualTarget, - manualRange?.[0] ?? null, - manualRange?.[1] ?? null, - wakeTime, - sleepHours, - fastingThreshold, - Math.round(waterTargetMl), - dailyActionOrder.join(','), - dailyActionHidden.join(','), - onboardingComplete, - now, - now - ), - ]; - - const initialWeightId = optionalText(body.initialWeightId, 80); - if (initialWeightKg !== null && initialWeightId) { - statements.push( - c.env.DB.prepare( - `INSERT OR IGNORE INTO weight_entries - (id, user_id, weight_kg, recorded_at, created_at) - VALUES (?, ?, ?, ?, ?)` - ).bind(initialWeightId, userId, initialWeightKg, now, now) - ); - } - - const nextProfile: UserProfile = { - userId, - displayName, - units, - ageYears, - genderIdentity, - equationProfile, - heightCm, - activityLevel, - goal, - targetWeightKg, - manualCalorieTarget: manualRange - ? Math.round((manualRange[0] + manualRange[1]) / 2) - : manualTarget, - manualCalorieRange: manualRange ? [manualRange[0], manualRange[1]] : null, - wakeTime, - sleepHours, - fastingThresholdHours: fastingThreshold as 12 | 14 | 16, - waterTargetMl: Math.round(waterTargetMl), - dailyActionOrder, - dailyActionHidden, - onboardingComplete: Boolean(onboardingComplete), - }; - const target = initialWeightKg - ? calculateNutritionTarget({ - weightKg: initialWeightKg, - heightCm, - ageYears, - equationProfile, - activityLevel, - goal, - manualCalorieTarget: nextProfile.manualCalorieTarget, - manualCalorieRange: nextProfile.manualCalorieRange, - }) - : await currentTarget(c.env.DB, nextProfile, userId); - const activeCycle = await c.env.DB.prepare( - 'SELECT * FROM goal_cycles WHERE user_id = ? AND end_on IS NULL' - ) - .bind(userId) - .first(); - if (!activeCycle) { - statements.push( - cycleInsertStatement(c.env.DB, { - id: crypto.randomUUID(), - userId, - goal, - startOn: cycleDate, - calorieRange: target.calorieRange, - proteinRangeG: target.proteinRangeG, - now, - }) - ); - } else if (activeCycle.cycle === cycleFromGoal(goal)) { - statements.push( - c.env.DB.prepare( - `UPDATE goal_cycles SET goal = ?, calorie_range_low = ?, calorie_range_high = ?, - protein_range_low = ?, protein_range_high = ?, updated_at = ? - WHERE id = ? AND user_id = ? AND end_on IS NULL` - ).bind( - goal, - target.calorieRange?.[0] ?? null, - target.calorieRange?.[1] ?? null, - target.proteinRangeG?.[0] ?? null, - target.proteinRangeG?.[1] ?? null, - now, - activeCycle.id, - userId - ) - ); - } else { - statements.push( - c.env.DB.prepare( - 'UPDATE goal_cycles SET end_on = ?, updated_at = ? WHERE id = ? AND user_id = ? AND end_on IS NULL' - ).bind(cycleDate, now, activeCycle.id, userId), - cycleInsertStatement(c.env.DB, { - id: crypto.randomUUID(), - userId, - goal, - startOn: cycleDate, - calorieRange: target.calorieRange, - proteinRangeG: target.proteinRangeG, - now, - }) - ); - } - await c.env.DB.batch(statements); - return c.json(await readProfile(c.env.DB, userId, displayName)); -}); - -app.get('/api/app/cycles', async (c) => { - const userId = c.get('userId'); - const today = validDateKey(c.req.query('date')); - if (!today) return c.json(jsonError('Choose a valid local date.'), 400); - let result = await c.env.DB.prepare( - 'SELECT * FROM goal_cycles WHERE user_id = ? ORDER BY start_on DESC LIMIT 20' - ) - .bind(userId) - .all(); - if (!result.results.some((row) => row.end_on === null)) { - const profile = await readProfile(c.env.DB, userId, c.get('userName')); - const target = await currentTarget(c.env.DB, profile, userId); - await cycleInsertStatement(c.env.DB, { - id: crypto.randomUUID(), - userId, - goal: profile.goal, - startOn: today, - calorieRange: target.calorieRange, - proteinRangeG: target.proteinRangeG, - now: Date.now(), - }).run(); - result = await c.env.DB.prepare( - 'SELECT * FROM goal_cycles WHERE user_id = ? ORDER BY start_on DESC LIMIT 20' - ) - .bind(userId) - .all(); - } - return conditionalJson(c, result.results.map(mapGoalCycle)); -}); - -app.patch('/api/app/cycles/active', async (c) => { - const body = await c.req.json>().catch(() => null); - const startOn = validDateKey(body?.startOn); - const today = validDateKey(body?.today); - if (!startOn || !today || startOn > today) { - return c.json(jsonError('Choose a cycle start date that is not in the future.'), 400); - } - const userId = c.get('userId'); - const active = await c.env.DB.prepare( - 'SELECT * FROM goal_cycles WHERE user_id = ? AND end_on IS NULL' - ) - .bind(userId) - .first(); - if (!active) return c.json({ message: 'Active cycle not found.' }, 404); - const previous = await c.env.DB.prepare( - `SELECT * FROM goal_cycles WHERE user_id = ? AND end_on IS NOT NULL - ORDER BY end_on DESC LIMIT 1` - ) - .bind(userId) - .first(); - if (previous?.end_on && startOn < previous.end_on) { - return c.json( - jsonError(`Cycle start must be on or after ${previous.end_on}.`, { - startOn: 'Overlaps the previous cycle.', - }), - 400 - ); - } - await c.env.DB.prepare( - 'UPDATE goal_cycles SET start_on = ?, updated_at = ? WHERE id = ? AND user_id = ? AND end_on IS NULL' - ) - .bind(startOn, Date.now(), active.id, userId) - .run(); - const updated = await c.env.DB.prepare('SELECT * FROM goal_cycles WHERE id = ? AND user_id = ?') - .bind(active.id, userId) - .first(); - if (!updated) return c.json({ message: 'The cycle could not be read back.' }, 500); - return c.json(mapGoalCycle(updated)); -}); - -app.get('/api/app/foods', async (c) => { - const search = c.req.query('q')?.trim().slice(0, 60); - const lifecycleWhere = - c.req.query('status') === 'archived' ? 'archived_at IS NOT NULL' : 'archived_at IS NULL'; - const result = search - ? await c.env.DB.prepare( - `SELECT * FROM foods - WHERE user_id = ? AND ${lifecycleWhere} AND name LIKE ? ESCAPE '\\' - ORDER BY last_used_at DESC, name ASC LIMIT 50` - ) - .bind(c.get('userId'), `%${search.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`) - .all() - : await c.env.DB.prepare( - `SELECT * FROM foods WHERE user_id = ? AND ${lifecycleWhere} - ORDER BY last_used_at DESC, name ASC LIMIT 100` - ) - .bind(c.get('userId')) - .all(); - return c.json(result.results.map(mapFood)); -}); - -function parseFoodBody(body: Record) { - const name = requiredText(body.name, 80); - const servingMode = ['per_100g', 'per_unit'].includes(String(body.servingMode)) - ? (body.servingMode as ServingMode) - : null; - const unitLabel = requiredText(body.unitLabel, 24); - const defaultAmount = finiteNumber(body.defaultAmount, 0.01, 10000); - const calories = finiteNumber(body.calories, 0, 10000); - const carbsG = finiteNumber(body.carbsG, 0, 1000); - const proteinG = finiteNumber(body.proteinG, 0, 1000); - const fibreG = finiteNumber(body.fibreG, 0, 1000); - if ( - !name || - !servingMode || - !unitLabel || - defaultAmount === null || - calories === null || - carbsG === null || - proteinG === null || - fibreG === null - ) { - return null; - } - return { - name, - servingMode, - unitLabel, - defaultAmount, - calories, - carbsG, - proteinG, - fibreG, - favourite: body.favourite === true ? 1 : 0, - isPackaged: normalizeIsPackaged(body.isPackaged, body.foodKind), - labels: normalizeFoodLabels(body.labels), - }; -} - -app.post('/api/app/foods', async (c) => { - const body = await c.req.json>().catch(() => null); - const parsed = body ? parseFoodBody(body) : null; - const id = body ? optionalText(body.id, 80) : null; - if (!parsed || !id) return c.json(jsonError('Complete all four nutrient values.'), 400); - const now = Date.now(); - try { - await c.env.DB.prepare( - `INSERT INTO foods ( - id, user_id, name, serving_mode, unit_label, default_amount, - calories, carbs_g, protein_g, fibre_g, favourite, food_kind, is_packaged, labels_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ) - .bind( - id, - c.get('userId'), - parsed.name, - parsed.servingMode, - parsed.unitLabel, - parsed.defaultAmount, - parsed.calories, - parsed.carbsG, - parsed.proteinG, - parsed.fibreG, - parsed.favourite, - parsed.isPackaged ? 'packaged' : 'prepared', - parsed.isPackaged ? 1 : 0, - JSON.stringify(parsed.labels), - now, - now - ) - .run(); - } catch (error) { - console.error(JSON.stringify({ event: 'food_create_failed', message: String(error) })); - return c.json( - jsonError('A food with that name already exists. Edit the existing food instead.'), - 409 - ); - } - const row = await c.env.DB.prepare('SELECT * FROM foods WHERE id = ? AND user_id = ?') - .bind(id, c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The saved food could not be read back.' }, 500); - return c.json(mapFood(row), 201); -}); - -app.put('/api/app/foods/:id', async (c) => { - const body = await c.req.json>().catch(() => null); - const parsed = body ? parseFoodBody(body) : null; - if (!parsed) return c.json(jsonError('Complete all four nutrient values.'), 400); - const result = await c.env.DB.prepare( - `UPDATE foods SET name = ?, serving_mode = ?, unit_label = ?, default_amount = ?, - calories = ?, carbs_g = ?, protein_g = ?, fibre_g = ?, favourite = ?, food_kind = ?, is_packaged = ?, labels_json = ?, updated_at = ? - WHERE id = ? AND user_id = ?` - ) - .bind( - parsed.name, - parsed.servingMode, - parsed.unitLabel, - parsed.defaultAmount, - parsed.calories, - parsed.carbsG, - parsed.proteinG, - parsed.fibreG, - parsed.favourite, - parsed.isPackaged ? 'packaged' : 'prepared', - parsed.isPackaged ? 1 : 0, - JSON.stringify(parsed.labels), - Date.now(), - c.req.param('id'), - c.get('userId') - ) - .run(); - if (!result.meta.changes) return c.json({ message: 'Food not found.' }, 404); - const row = await c.env.DB.prepare('SELECT * FROM foods WHERE id = ? AND user_id = ?') - .bind(c.req.param('id'), c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The saved food could not be read back.' }, 500); - return c.json(mapFood(row)); -}); - -app.patch('/api/app/foods/:id', async (c) => { - const body = await c.req.json>().catch(() => null); - if (!body || !('archivedAt' in body)) { - return c.json(jsonError('Choose whether this food is active or archived.'), 400); - } - const archivedAt = body.archivedAt === null ? null : validTimestamp(body.archivedAt); - if (body.archivedAt !== null && archivedAt === null) { - return c.json(jsonError('Choose a valid archive time.'), 400); - } - const result = await c.env.DB.prepare( - 'UPDATE foods SET archived_at = ?, updated_at = ? WHERE id = ? AND user_id = ?' - ) - .bind(archivedAt, Date.now(), c.req.param('id'), c.get('userId')) - .run(); - if (!result.meta.changes) return c.json({ message: 'Food not found.' }, 404); - const row = await c.env.DB.prepare('SELECT * FROM foods WHERE id = ? AND user_id = ?') - .bind(c.req.param('id'), c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The saved food could not be read back.' }, 500); - return c.json(mapFood(row)); -}); - -app.delete('/api/app/foods/:id', async (c) => { - const result = await c.env.DB.prepare('DELETE FROM foods WHERE id = ? AND user_id = ?') - .bind(c.req.param('id'), c.get('userId')) - .run(); - return result.meta.changes ? c.body(null, 204) : c.json({ message: 'Food not found.' }, 404); -}); - -app.post('/api/app/entries', async (c) => { - const body = await c.req.json>().catch(() => null); - const id = body ? optionalText(body.id, 80) : null; - const foodId = body ? optionalText(body.foodId, 80) : null; - const amount = body ? finiteNumber(body.amount, 0.01, 10000) : null; - const eatenAt = body ? validTimestamp(body.eatenAt) : null; - if (!body || !id || amount === null || eatenAt === null) { - return c.json(jsonError('Add a valid amount and time.'), 400); - } - let entry: FoodEntry; - let foodUpdate: D1PreparedStatement | null = null; - const now = Date.now(); - - if (foodId) { - const foodRow = await c.env.DB.prepare( - 'SELECT * FROM foods WHERE id = ? AND user_id = ? AND archived_at IS NULL' - ) - .bind(foodId, c.get('userId')) - .first(); - if (!foodRow) return c.json({ message: 'Food not found.' }, 404); - const food = mapFood(foodRow); - entry = { - id, - foodId: food.id, - foodName: food.name, - amount, - unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, - ...scaleNutrients(food, food.servingMode, amount), - eatenAt, - isPackaged: food.isPackaged, - labels: food.labels, - }; - foodUpdate = c.env.DB.prepare( - 'UPDATE foods SET last_used_at = ?, updated_at = ? WHERE id = ? AND user_id = ?' - ).bind(eatenAt, now, food.id, c.get('userId')); - } else { - const directEntry = directEntryFromBody(body, id, amount, eatenAt); - if (!directEntry) { - return c.json(jsonError('Add a name, unit, and valid nutrient totals.'), 400); - } - entry = directEntry; - } - - const insert = c.env.DB.prepare( - `INSERT OR IGNORE INTO food_entries ( - id, user_id, food_id, food_name, amount, unit_label, calories, - carbs_g, protein_g, fibre_g, food_kind, is_packaged, labels_json, eaten_at, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).bind( - entry.id, - c.get('userId'), - entry.foodId, - entry.foodName, - entry.amount, - entry.unitLabel, - entry.calories, - entry.carbsG, - entry.proteinG, - entry.fibreG, - entry.isPackaged ? 'packaged' : 'prepared', - entry.isPackaged ? 1 : 0, - JSON.stringify(normalizeFoodLabels(entry.labels)), - entry.eatenAt, - now - ); - if (foodUpdate) await c.env.DB.batch([insert, foodUpdate]); - else await insert.run(); - - const row = await c.env.DB.prepare('SELECT * FROM food_entries WHERE id = ? AND user_id = ?') - .bind(id, c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The food entry could not be read back.' }, 500); - return c.json(mapFoodEntry(row), 201); -}); - -app.patch('/api/app/entries/:id', async (c) => { - const body = await c.req.json>().catch(() => null); - if (!body) return c.json(jsonError('Send an entry to update.'), 400); - const foodId = optionalText(body.foodId, 80); - const amount = finiteNumber(body.amount, 0.01, 10_000); - const eatenAt = validTimestamp(body.eatenAt); - if (amount === null || eatenAt === null) { - return c.json(jsonError('Add a valid amount and time.'), 400); - } - - let entry: FoodEntry; - if (foodId) { - const foodRow = await c.env.DB.prepare( - 'SELECT * FROM foods WHERE id = ? AND user_id = ? AND archived_at IS NULL' - ) - .bind(foodId, c.get('userId')) - .first(); - if (!foodRow) return c.json({ message: 'Saved food not found.' }, 404); - const food = mapFood(foodRow); - entry = { - id: c.req.param('id'), - foodId: food.id, - foodName: food.name, - amount, - unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, - ...scaleNutrients(food, food.servingMode, amount), - eatenAt, - isPackaged: food.isPackaged, - labels: food.labels, - }; - } else { - const directEntry = directEntryFromBody(body, c.req.param('id'), amount, eatenAt); - if (!directEntry) { - return c.json(jsonError('Add a name, unit, and valid nutrient totals.'), 400); - } - entry = directEntry; - } - - const result = await c.env.DB.prepare( - `UPDATE food_entries SET food_id = ?, food_name = ?, amount = ?, unit_label = ?, - calories = ?, carbs_g = ?, protein_g = ?, fibre_g = ?, food_kind = ?, is_packaged = ?, labels_json = ?, eaten_at = ? - WHERE id = ? AND user_id = ?` - ) - .bind( - entry.foodId, - entry.foodName, - entry.amount, - entry.unitLabel, - entry.calories, - entry.carbsG, - entry.proteinG, - entry.fibreG, - entry.isPackaged ? 'packaged' : 'prepared', - entry.isPackaged ? 1 : 0, - JSON.stringify(normalizeFoodLabels(entry.labels)), - entry.eatenAt, - entry.id, - c.get('userId') - ) - .run(); - if (!result.meta.changes) return c.json({ message: 'Food entry not found.' }, 404); - - const row = await c.env.DB.prepare('SELECT * FROM food_entries WHERE id = ? AND user_id = ?') - .bind(c.req.param('id'), c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The food entry could not be read back.' }, 500); - return c.json(mapFoodEntry(row)); -}); - -app.delete('/api/app/entries/:id', async (c) => { - const result = await c.env.DB.prepare('DELETE FROM food_entries WHERE id = ? AND user_id = ?') - .bind(c.req.param('id'), c.get('userId')) - .run(); - return result.meta.changes ? c.body(null, 204) : c.json({ message: 'Entry not found.' }, 404); -}); - -app.post('/api/app/water', async (c) => { - const body = await c.req.json>().catch(() => null); - const id = body ? optionalText(body.id, 80) : null; - const amountMl = body ? finiteNumber(body.amountMl, 1, 5000) : null; - const drankAt = body ? validTimestamp(body.drankAt) : null; - if (!id || amountMl === null || drankAt === null) { - return c.json(jsonError('Choose a water amount and time.'), 400); - } - await c.env.DB.prepare( - `INSERT OR IGNORE INTO water_entries - (id, user_id, amount_ml, drank_at, created_at) VALUES (?, ?, ?, ?, ?)` - ) - .bind(id, c.get('userId'), Math.round(amountMl), drankAt, Date.now()) - .run(); - const row = await c.env.DB.prepare( - 'SELECT id, amount_ml, drank_at FROM water_entries WHERE id = ? AND user_id = ?' - ) - .bind(id, c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The water entry could not be read back.' }, 500); - return c.json(mapWater(row), 201); -}); - -app.patch('/api/app/water/:id', async (c) => { - const body = await c.req.json>().catch(() => null); - const amountMl = body ? finiteNumber(body.amountMl, 1, 5000) : null; - const drankAt = body ? validTimestamp(body.drankAt) : null; - if (amountMl === null || drankAt === null) { - return c.json(jsonError('Choose a water amount and time.'), 400); - } - const result = await c.env.DB.prepare( - 'UPDATE water_entries SET amount_ml = ?, drank_at = ? WHERE id = ? AND user_id = ?' - ) - .bind(Math.round(amountMl), drankAt, c.req.param('id'), c.get('userId')) - .run(); - if (!result.meta.changes) return c.json({ message: 'Water entry not found.' }, 404); - const row = await c.env.DB.prepare( - 'SELECT id, amount_ml, drank_at FROM water_entries WHERE id = ? AND user_id = ?' - ) - .bind(c.req.param('id'), c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The water entry could not be read back.' }, 500); - return c.json(mapWater(row)); -}); - -app.delete('/api/app/water/:id', async (c) => { - const result = await c.env.DB.prepare('DELETE FROM water_entries WHERE id = ? AND user_id = ?') - .bind(c.req.param('id'), c.get('userId')) - .run(); - return result.meta.changes - ? c.body(null, 204) - : c.json({ message: 'Water entry not found.' }, 404); -}); - -app.post('/api/app/medications', async (c) => { - const body = await c.req.json>().catch(() => null); - const id = body ? optionalText(body.id, 80) : null; - const name = body ? requiredText(body.name, 80) : null; - const schedule = - body && ['morning', 'evening', 'either'].includes(String(body.schedule)) - ? (body.schedule as MedicationSchedule) - : null; - const createdAt = body ? validTimestamp(body.createdAt) : null; - if (!id || !name || !schedule || createdAt === null) { - return c.json(jsonError('Add a medication name and when you take it.'), 400); - } - const now = Date.now(); - await c.env.DB.prepare( - `INSERT OR IGNORE INTO medications - (id, user_id, name, schedule, created_at, updated_at, archived_at) - VALUES (?, ?, ?, ?, ?, ?, NULL)` - ) - .bind(id, c.get('userId'), name, schedule, createdAt, now) - .run(); - const row = await c.env.DB.prepare( - `SELECT id, name, schedule, created_at, archived_at - FROM medications WHERE id = ? AND user_id = ?` - ) - .bind(id, c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The medication could not be read back.' }, 500); - return c.json(mapMedication(row), 201); -}); - -app.patch('/api/app/medications/:id', async (c) => { - const body = await c.req.json>().catch(() => null); - const name = body ? requiredText(body.name, 80) : null; - const schedule = - body && ['morning', 'evening', 'either'].includes(String(body.schedule)) - ? (body.schedule as MedicationSchedule) - : null; - const archivedAt = - body?.archivedAt === null - ? null - : body?.archivedAt === undefined - ? undefined - : validTimestamp(body.archivedAt); - const hasInvalidArchivedAt = - body?.archivedAt !== null && body?.archivedAt !== undefined && archivedAt === null; - if (!body || !name || !schedule || archivedAt === undefined || hasInvalidArchivedAt) { - return c.json(jsonError('Add a medication name and when you take it.'), 400); - } - const result = await c.env.DB.prepare( - `UPDATE medications SET name = ?, schedule = ?, archived_at = ?, updated_at = ? - WHERE id = ? AND user_id = ?` - ) - .bind(name, schedule, archivedAt, Date.now(), c.req.param('id'), c.get('userId')) - .run(); - if (!result.meta.changes) return c.json({ message: 'Medication not found.' }, 404); - const row = await c.env.DB.prepare( - `SELECT id, name, schedule, created_at, archived_at - FROM medications WHERE id = ? AND user_id = ?` - ) - .bind(c.req.param('id'), c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The medication could not be read back.' }, 500); - return c.json(mapMedication(row)); -}); - -app.post('/api/app/medication-check-ins', async (c) => { - const body = await c.req.json>().catch(() => null); - const id = body ? optionalText(body.id, 80) : null; - const medicationId = body ? optionalText(body.medicationId, 80) : null; - const takenOn = - body && typeof body.takenOn === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(body.takenOn) - ? body.takenOn - : null; - const takenAt = body ? validTimestamp(body.takenAt) : null; - if (!id || !medicationId || !takenOn || takenAt === null) { - return c.json(jsonError('Choose a medication and valid day.'), 400); - } - const medication = await c.env.DB.prepare( - 'SELECT id FROM medications WHERE id = ? AND user_id = ? AND archived_at IS NULL' - ) - .bind(medicationId, c.get('userId')) - .first<{ id: string }>(); - if (!medication) return c.json({ message: 'Medication not found.' }, 404); - await c.env.DB.prepare( - `INSERT OR IGNORE INTO medication_check_ins - (id, user_id, medication_id, taken_on, taken_at, created_at) - VALUES (?, ?, ?, ?, ?, ?)` - ) - .bind(id, c.get('userId'), medicationId, takenOn, takenAt, Date.now()) - .run(); - const row = await c.env.DB.prepare( - `SELECT id, medication_id, taken_on, taken_at FROM medication_check_ins - WHERE user_id = ? AND medication_id = ? AND taken_on = ?` - ) - .bind(c.get('userId'), medicationId, takenOn) - .first(); - if (!row) return c.json({ message: 'The medication check-off could not be read back.' }, 500); - return c.json(mapMedicationCheckIn(row), 201); -}); - -app.delete('/api/app/medication-check-ins/:id', async (c) => { - const result = await c.env.DB.prepare( - 'DELETE FROM medication_check_ins WHERE id = ? AND user_id = ?' - ) - .bind(c.req.param('id'), c.get('userId')) - .run(); - return result.meta.changes - ? c.body(null, 204) - : c.json({ message: 'Medication check-off not found.' }, 404); -}); - -app.post('/api/app/weights', async (c) => { - const body = await c.req.json>().catch(() => null); - const id = body ? optionalText(body.id, 80) : null; - const weightKg = body ? finiteNumber(body.weightKg, 30, 400) : null; - const recordedAt = body ? validTimestamp(body.recordedAt) : null; - if (!id || weightKg === null || recordedAt === null) { - return c.json(jsonError('Enter a valid weight and date.'), 400); - } - await c.env.DB.prepare( - `INSERT OR IGNORE INTO weight_entries - (id, user_id, weight_kg, recorded_at, created_at) VALUES (?, ?, ?, ?, ?)` - ) - .bind(id, c.get('userId'), weightKg, recordedAt, Date.now()) - .run(); - const row = await c.env.DB.prepare( - 'SELECT id, weight_kg, recorded_at FROM weight_entries WHERE id = ? AND user_id = ?' - ) - .bind(id, c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The weight entry could not be read back.' }, 500); - return c.json(mapWeight(row), 201); -}); - -app.patch('/api/app/weights/:id', async (c) => { - const body = await c.req.json>().catch(() => null); - const weightKg = body ? finiteNumber(body.weightKg, 30, 400) : null; - const recordedAt = body ? validTimestamp(body.recordedAt) : null; - if (weightKg === null || recordedAt === null) { - return c.json(jsonError('Enter a valid weight and date.'), 400); - } - const result = await c.env.DB.prepare( - 'UPDATE weight_entries SET weight_kg = ?, recorded_at = ? WHERE id = ? AND user_id = ?' - ) - .bind(weightKg, recordedAt, c.req.param('id'), c.get('userId')) - .run(); - if (!result.meta.changes) return c.json({ message: 'Weight entry not found.' }, 404); - const row = await c.env.DB.prepare( - 'SELECT id, weight_kg, recorded_at FROM weight_entries WHERE id = ? AND user_id = ?' - ) - .bind(c.req.param('id'), c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The weight entry could not be read back.' }, 500); - return c.json(mapWeight(row)); -}); - -app.delete('/api/app/weights/:id', async (c) => { - const result = await c.env.DB.prepare('DELETE FROM weight_entries WHERE id = ? AND user_id = ?') - .bind(c.req.param('id'), c.get('userId')) - .run(); - return result.meta.changes - ? c.body(null, 204) - : c.json({ message: 'Weight entry not found.' }, 404); -}); - -app.get('/api/app/export', async (c) => { - const userId = c.get('userId'); - const [profile, foods, entries, water, medications, checkIns, weights, cycles] = - await Promise.all([ - readProfile(c.env.DB, userId, c.get('userName')), - c.env.DB.prepare('SELECT * FROM foods WHERE user_id = ? ORDER BY created_at ASC') - .bind(userId) - .all(), - c.env.DB.prepare('SELECT * FROM food_entries WHERE user_id = ? ORDER BY eaten_at ASC') - .bind(userId) - .all(), - c.env.DB.prepare( - 'SELECT id, amount_ml, drank_at FROM water_entries WHERE user_id = ? ORDER BY drank_at ASC' - ) - .bind(userId) - .all(), - c.env.DB.prepare( - 'SELECT id, name, schedule, created_at, archived_at FROM medications WHERE user_id = ? ORDER BY created_at ASC' - ) - .bind(userId) - .all(), - c.env.DB.prepare( - 'SELECT id, medication_id, taken_on, taken_at FROM medication_check_ins WHERE user_id = ? ORDER BY taken_at ASC' - ) - .bind(userId) - .all(), - c.env.DB.prepare( - 'SELECT id, weight_kg, recorded_at FROM weight_entries WHERE user_id = ? ORDER BY recorded_at ASC' - ) - .bind(userId) - .all(), - c.env.DB.prepare('SELECT * FROM goal_cycles WHERE user_id = ? ORDER BY start_on ASC') - .bind(userId) - .all(), - ]); - return c.json( - createJournalExport({ - profile, - foods: foods.results.map(mapFood), - entries: entries.results.map(mapFoodEntry), - waterEntries: water.results.map(mapWater), - medications: medications.results.map(mapMedication), - medicationCheckIns: checkIns.results.map(mapMedicationCheckIn), - weights: weights.results.map(mapWeight), - cycleSessions: cycles.results.map(mapGoalCycle), - }) - ); -}); - -function parseRange(c: { - req: { query: (name: string) => string | undefined }; -}): { start: number; end: number } | null { - const start = finiteNumber(c.req.query('start'), 0, Date.now() + 24 * 60 * 60 * 1000); - const end = finiteNumber(c.req.query('end'), 0, Date.now() + 48 * 60 * 60 * 1000); - if (start === null || end === null || end <= start || end - start > 366 * 24 * 60 * 60 * 1000) { - return null; - } - return { start, end }; -} - -app.get('/api/app/dashboard', async (c) => { - const range = parseRange(c); - if (!range || range.end - range.start > 48 * 60 * 60 * 1000) { - return c.json(jsonError('Choose a valid local-day range.'), 400); - } - const userId = c.get('userId'); - const [ - profile, - foodsResult, - entriesResult, - waterResult, - medicationResult, - medicationCheckInResult, - latestWeightRow, - fastingRows, - ] = await Promise.all([ - readProfile(c.env.DB, userId, c.get('userName')), - c.env.DB.prepare(DASHBOARD_FOODS_QUERY).bind(userId).all(), - c.env.DB.prepare( - `SELECT * FROM food_entries - WHERE user_id = ? AND eaten_at >= ? AND eaten_at < ? - ORDER BY eaten_at DESC` - ) - .bind(userId, range.start, range.end) - .all(), - c.env.DB.prepare( - `SELECT id, amount_ml, drank_at FROM water_entries - WHERE user_id = ? AND drank_at >= ? AND drank_at < ? - ORDER BY drank_at DESC` - ) - .bind(userId, range.start, range.end) - .all(), - c.env.DB.prepare( - `SELECT id, name, schedule, created_at, archived_at FROM medications - WHERE user_id = ? AND archived_at IS NULL - ORDER BY created_at ASC` - ) - .bind(userId) - .all(), - c.env.DB.prepare( - `SELECT id, medication_id, taken_on, taken_at FROM medication_check_ins - WHERE user_id = ? AND taken_on = ? ORDER BY taken_at DESC` - ) - .bind(userId, c.req.query('date') ?? '') - .all(), - c.env.DB.prepare( - `SELECT id, weight_kg, recorded_at FROM weight_entries - WHERE user_id = ? ORDER BY recorded_at DESC LIMIT 1` - ) - .bind(userId) - .first(), - c.env.DB.prepare( - `SELECT id, food_id, food_name, amount, unit_label, calories, carbs_g, - protein_g, fibre_g, eaten_at - FROM food_entries WHERE user_id = ? AND eaten_at >= ? - ORDER BY eaten_at ASC` - ) - .bind(userId, range.start - 31 * 24 * 60 * 60 * 1000) - .all(), - ]); - - const foods: Food[] = foodsResult.results.map(mapFood); - const entries: FoodEntry[] = entriesResult.results.map(mapFoodEntry); - const waterEntries: WaterEntry[] = waterResult.results.map(mapWater); - const medications: Medication[] = medicationResult.results.map(mapMedication); - const medicationCheckIns: MedicationCheckIn[] = - medicationCheckInResult.results.map(mapMedicationCheckIn); - const totals = entries.reduce( - (sum, entry) => ({ - calories: sum.calories + entry.calories, - carbsG: sum.carbsG + entry.carbsG, - proteinG: sum.proteinG + entry.proteinG, - fibreG: sum.fibreG + entry.fibreG, - waterMl: sum.waterMl, - }), - { - calories: 0, - carbsG: 0, - proteinG: 0, - fibreG: 0, - waterMl: waterEntries.reduce((sum, entry) => sum + entry.amountMl, 0), - } - ); - const latestWeight = latestWeightRow ? mapWeight(latestWeightRow) : null; - const timezone = c.req.query('timezone') ?? 'UTC'; - const target = calculateNutritionTarget({ - weightKg: latestWeight?.weightKg ?? null, - heightCm: profile.heightCm, - ageYears: profile.ageYears, - equationProfile: profile.equationProfile, - activityLevel: profile.activityLevel, - goal: profile.goal, - manualCalorieTarget: profile.manualCalorieTarget, - manualCalorieRange: profile.manualCalorieRange, - }); - - const dashboard: Dashboard = { - profile, - foods, - entries, - waterEntries, - medications, - medicationCheckIns, - latestWeight, - totals: { - calories: round(totals.calories), - carbsG: round(totals.carbsG, 1), - proteinG: round(totals.proteinG, 1), - fibreG: round(totals.fibreG, 1), - waterMl: totals.waterMl, - }, - target, - completedFasts: calculateCompletedFasts(fastingRows.results.map(mapFoodEntry), timezone), - date: c.req.query('date') ?? '', - timezone, - }; - return conditionalJson(c, dashboard); -}); - -function dateKey(timestamp: number, timezone: string) { - return new Intl.DateTimeFormat('en-CA', { - timeZone: timezone, - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(timestamp); -} - -app.get('/api/app/history', async (c) => { - const range = parseRange(c); - const requestedDays = Number(c.req.query('days')); - const rangeDays = requestedDays === 30 ? 30 : requestedDays === 7 ? 7 : undefined; - const timezone = c.req.query('timezone') || 'UTC'; - if (!range || range.end - range.start > 366 * 24 * 60 * 60 * 1000) { - return c.json(jsonError('Choose a history range of one year or less.'), 400); - } - const userId = c.get('userId'); - const [profile, entriesResult, waterResult, weightResult, medicationResult, priorEntry] = - await Promise.all([ - readProfile(c.env.DB, userId, c.get('userName')), - c.env.DB.prepare( - `SELECT * FROM food_entries - WHERE user_id = ? AND eaten_at >= ? AND eaten_at < ? ORDER BY eaten_at ASC` - ) - .bind(userId, range.start, range.end) - .all(), - c.env.DB.prepare( - `SELECT id, amount_ml, drank_at FROM water_entries - WHERE user_id = ? AND drank_at >= ? AND drank_at < ? ORDER BY drank_at ASC` - ) - .bind(userId, range.start, range.end) - .all(), - c.env.DB.prepare( - `SELECT id, weight_kg, recorded_at FROM weight_entries - WHERE user_id = ? AND recorded_at >= ? AND recorded_at < ? ORDER BY recorded_at ASC` - ) - .bind(userId, range.start, range.end) - .all(), - c.env.DB.prepare( - `SELECT c.id, c.medication_id, c.taken_at, m.name AS medication_name - FROM medication_check_ins c - JOIN medications m ON m.id = c.medication_id AND m.user_id = c.user_id - WHERE c.user_id = ? AND c.taken_at >= ? AND c.taken_at < ? ORDER BY c.taken_at ASC` - ) - .bind(userId, range.start, range.end) - .all(), - c.env.DB.prepare( - `SELECT * FROM food_entries - WHERE user_id = ? AND eaten_at < ? ORDER BY eaten_at DESC LIMIT 1` - ) - .bind(userId, range.start) - .first(), - ]); - const entries = entriesResult.results.map(mapFoodEntry); - const water = waterResult.results.map(mapWater); - const dayMap = new Map(); - const ensureDay = (key: string) => { - const existing = dayMap.get(key); - if (existing) return existing; - const created: HistoryDay = { - date: key, - calories: 0, - carbsG: 0, - proteinG: 0, - fibreG: 0, - waterMl: 0, - fastCount: 0, - }; - dayMap.set(key, created); - return created; - }; - - if (rangeDays) { - for (let index = 0; index < rangeDays; index += 1) { - ensureDay(dateKey(range.start + index * 24 * 60 * 60 * 1000, timezone)); - } - } - for (const entry of entries) { - const day = ensureDay(dateKey(entry.eatenAt, timezone)); - day.calories += entry.calories; - day.carbsG += entry.carbsG; - day.proteinG += entry.proteinG; - day.fibreG += entry.fibreG; - } - for (const entry of water) { - ensureDay(dateKey(entry.drankAt, timezone)).waterMl += entry.amountMl; - } - const fastingEntries = priorEntry ? [mapFoodEntry(priorEntry), ...entries] : entries; - const fastingThreshold = profile.fastingThresholdHours; - for (const fast of calculateCompletedFasts(fastingEntries, timezone)) { - if ( - fast.endAt >= range.start && - fast.endAt < range.end && - fast.durationHours >= fastingThreshold - ) { - ensureDay(dateKey(fast.endAt, timezone)).fastCount += 1; - } - } - const days = [...dayMap.values()] - .sort((a, b) => a.date.localeCompare(b.date)) - .map((day) => ({ - ...day, - calories: round(day.calories), - carbsG: round(day.carbsG, 1), - proteinG: round(day.proteinG, 1), - fibreG: round(day.fibreG, 1), - })); - - const response: HistoryResponse = { - days, - weights: weightResult.results.map(mapWeight), - entries, - medicationEvents: medicationResult.results.map((row) => ({ - id: row.id, - medicationId: row.medication_id, - medicationName: row.medication_name, - takenAt: row.taken_at, - })), - ...(rangeDays ? { rangeDays } : {}), - }; - return conditionalJson(c, response); -}); - -const MCP_DAY_MS = 24 * 60 * 60 * 1000; - -function mcpLimit(value: string | undefined, fallback = 30, maximum = 90) { - const parsed = Number(value); - return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback; -} - -function mcpOffset(value: string | undefined) { - const parsed = Number(value); - return Number.isInteger(parsed) && parsed >= 0 ? Math.min(parsed, 10_000) : 0; -} - -function addUtcDays(date: string, amount: number) { - const [year, month, day] = date.split('-').map(Number); - return new Date(Date.UTC(year, month - 1, day + amount)).toISOString().slice(0, 10); -} - -function timezoneOffset(timestamp: number, timezone: string) { - const parts = new Intl.DateTimeFormat('en-US', { - timeZone: timezone, - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hourCycle: 'h23', - }).formatToParts(timestamp); - const value = (type: Intl.DateTimeFormatPartTypes) => - Number(parts.find((part) => part.type === type)?.value); - return ( - Date.UTC( - value('year'), - value('month') - 1, - value('day'), - value('hour'), - value('minute'), - value('second') - ) - timestamp - ); -} - -function localMidnight(date: string, timezone: string): number | null { - if (!validDateKey(date)) return null; - const [year, month, day] = date.split('-').map(Number); - const approximate = Date.UTC(year, month - 1, day); - try { - let result = approximate - timezoneOffset(approximate, timezone); - result = approximate - timezoneOffset(result, timezone); - return dateKey(result, timezone) === date ? result : null; - } catch { - return null; - } -} - -function mcpPage(c: { req: { query: (name: string) => string | undefined } }) { - return { - limit: mcpLimit(c.req.query('limit')), - offset: mcpOffset(c.req.query('offset')), - }; -} - -type McpTargetRow = { - manual_calorie_target: number | null; - manual_calorie_min: number | null; - manual_calorie_max: number | null; - water_target_ml: number; - fasting_threshold_hours: 12 | 14 | 16; -}; - -async function readMcpTargets(db: D1Database, userId: string) { - const row = await db - .prepare( - `SELECT manual_calorie_target, manual_calorie_min, manual_calorie_max, - water_target_ml, fasting_threshold_hours - FROM profiles WHERE user_id = ?` - ) - .bind(userId) - .first(); - const calorieRange = - row?.manual_calorie_min !== null && - row?.manual_calorie_min !== undefined && - row.manual_calorie_max !== null - ? ([row.manual_calorie_min, row.manual_calorie_max] as [number, number]) - : row?.manual_calorie_target - ? ([Math.max(800, row.manual_calorie_target - 100), row.manual_calorie_target + 100] as [ - number, - number, - ]) - : null; - return { - calorieRange, - waterMl: row?.water_target_ml ?? 2000, - fastingThresholdHours: row?.fasting_threshold_hours ?? 12, - }; -} - -app.get('/api/mcp/daily', async (c) => { - const date = validDateKey(c.req.query('date')); - const timezone = c.req.query('timezone')?.slice(0, 80) || 'UTC'; - const start = date ? localMidnight(date, timezone) : null; - const end = date ? localMidnight(addUtcDays(date, 1), timezone) : null; - if (!date || start === null || end === null) { - return c.json(jsonError('Choose a valid date and IANA timezone.'), 400); - } - const userId = c.get('mcpUserId'); - const [profileTargets, entriesResult, waterResult, cycle, priorEntry] = await Promise.all([ - readMcpTargets(c.env.DB, userId), - c.env.DB.prepare( - `SELECT * FROM food_entries - WHERE user_id = ? AND eaten_at >= ? AND eaten_at < ? - ORDER BY eaten_at ASC LIMIT 251` - ) - .bind(userId, start, end) - .all(), - c.env.DB.prepare( - `SELECT id, amount_ml, drank_at FROM water_entries - WHERE user_id = ? AND drank_at >= ? AND drank_at < ? - ORDER BY drank_at ASC LIMIT 251` - ) - .bind(userId, start, end) - .all(), - c.env.DB.prepare( - `SELECT * FROM goal_cycles WHERE user_id = ? - AND start_on <= ? AND (end_on IS NULL OR end_on >= ?) - ORDER BY start_on DESC LIMIT 1` - ) - .bind(userId, date, date) - .first(), - c.env.DB.prepare( - `SELECT eaten_at FROM food_entries - WHERE user_id = ? AND eaten_at < ? ORDER BY eaten_at DESC LIMIT 1` - ) - .bind(userId, start) - .first<{ eaten_at: number }>(), - ]); - const entries = entriesResult.results.slice(0, 250).map(mapFoodEntry); - const waterEntries = waterResult.results.slice(0, 250).map(mapWater); - const totals = entries.reduce( - (sum, entry) => ({ - calories: sum.calories + entry.calories, - carbsG: sum.carbsG + entry.carbsG, - proteinG: sum.proteinG + entry.proteinG, - fibreG: sum.fibreG + entry.fibreG, - }), - { calories: 0, carbsG: 0, proteinG: 0, fibreG: 0 } - ); - const completedFasts = calculateCompletedFasts( - [...(priorEntry ? [{ eatenAt: priorEntry.eaten_at }] : []), ...entries], - timezone - ).filter( - (fast) => - fast.endAt >= start && - fast.endAt < end && - fast.durationHours >= profileTargets.fastingThresholdHours - ); - return c.json({ - schemaVersion: '1', - provenance: 'calculated-from-recorded-entries', - date, - timezone, - totals: { - calories: round(totals.calories), - carbsG: round(totals.carbsG, 1), - proteinG: round(totals.proteinG, 1), - fibreG: round(totals.fibreG, 1), - waterMl: waterEntries.reduce((sum, entry) => sum + entry.amountMl, 0), - }, - targets: { - calorieRange: cycle ? mapGoalCycle(cycle).calorieRange : profileTargets.calorieRange, - proteinRangeG: cycle ? mapGoalCycle(cycle).proteinRangeG : null, - waterMl: profileTargets.waterMl, - }, - fasting: { - thresholdHours: profileTargets.fastingThresholdHours, - completed: completedFasts, - provenance: 'calculated-from-recorded-entry-times', - }, - entries, - waterEntries, - truncated: entriesResult.results.length > 250 || waterResult.results.length > 250, - }); -}); - -app.get('/api/mcp/history', async (c) => { - const startDate = validDateKey(c.req.query('start')); - const endDate = validDateKey(c.req.query('end')); - const timezone = c.req.query('timezone')?.slice(0, 80) || 'UTC'; - if (!startDate || !endDate || startDate > endDate) { - return c.json(jsonError('Choose a valid inclusive date range.'), 400); - } - const totalDays = - Math.round( - (Date.parse(`${endDate}T00:00:00Z`) - Date.parse(`${startDate}T00:00:00Z`)) / MCP_DAY_MS - ) + 1; - if (totalDays < 1 || totalDays > 366) { - return c.json(jsonError('Choose a history range of one year or less.'), 400); - } - const { limit, offset } = mcpPage(c); - const pageStartDate = addUtcDays(startDate, Math.min(offset, totalDays)); - const pageDays = Math.max(0, Math.min(limit, totalDays - offset)); - const pageEndDate = addUtcDays(pageStartDate, pageDays); - const start = localMidnight(pageStartDate, timezone); - const end = localMidnight(pageEndDate, timezone); - if (start === null || end === null) { - return c.json(jsonError('Choose a valid IANA timezone.'), 400); - } - const userId = c.get('mcpUserId'); - const [entriesResult, waterResult] = await Promise.all([ - c.env.DB.prepare( - `SELECT * FROM food_entries WHERE user_id = ? AND eaten_at >= ? AND eaten_at < ? - ORDER BY eaten_at ASC LIMIT 1001` - ) - .bind(userId, start, end) - .all(), - c.env.DB.prepare( - `SELECT id, amount_ml, drank_at FROM water_entries - WHERE user_id = ? AND drank_at >= ? AND drank_at < ? ORDER BY drank_at ASC LIMIT 1001` - ) - .bind(userId, start, end) - .all(), - ]); - const entries = entriesResult.results.slice(0, 1000).map(mapFoodEntry); - const waterEntries = waterResult.results.slice(0, 1000).map(mapWater); - const days = Array.from({ length: pageDays }, (_, index) => ({ - date: addUtcDays(pageStartDate, index), - calories: 0, - carbsG: 0, - proteinG: 0, - fibreG: 0, - waterMl: 0, - recorded: false, - })); - const byDate = new Map(days.map((day) => [day.date, day])); - for (const entry of entries) { - const day = byDate.get(dateKey(entry.eatenAt, timezone)); - if (!day) continue; - day.recorded = true; - day.calories += entry.calories; - day.carbsG += entry.carbsG; - day.proteinG += entry.proteinG; - day.fibreG += entry.fibreG; - } - for (const entry of waterEntries) { - const day = byDate.get(dateKey(entry.drankAt, timezone)); - if (day) { - day.recorded = true; - day.waterMl += entry.amountMl; - } - } - return c.json({ - schemaVersion: '1', - items: days.map((day) => ({ - ...day, - calories: round(day.calories), - carbsG: round(day.carbsG, 1), - proteinG: round(day.proteinG, 1), - fibreG: round(day.fibreG, 1), - provenance: day.recorded ? 'calculated-from-recorded-entries' : 'missing-day', - })), - entries, - page: { - limit, - offset, - total: totalDays, - nextOffset: offset + pageDays < totalDays ? offset + pageDays : null, - }, - truncated: entriesResult.results.length > 1000 || waterResult.results.length > 1000, - }); -}); - -app.get('/api/mcp/foods', async (c) => { - const { limit, offset } = mcpPage(c); - const search = c.req.query('q')?.trim().slice(0, 60); - const lifecycleWhere = - c.req.query('status') === 'archived' ? 'archived_at IS NOT NULL' : 'archived_at IS NULL'; - const escaped = search?.replaceAll('%', '\\%').replaceAll('_', '\\_'); - const where = `user_id = ? AND ${lifecycleWhere}${search ? " AND name LIKE ? ESCAPE '\\\\'" : ''}`; - const binds = search ? [c.get('mcpUserId'), `%${escaped}%`] : [c.get('mcpUserId')]; - const [result, count] = await Promise.all([ - c.env.DB.prepare( - `SELECT * FROM foods WHERE ${where} - ORDER BY last_used_at DESC, name ASC LIMIT ? OFFSET ?` - ) - .bind(...binds, limit, offset) - .all(), - c.env.DB.prepare(`SELECT COUNT(*) AS total FROM foods WHERE ${where}`) - .bind(...binds) - .first<{ total: number }>(), - ]); - const total = count?.total ?? 0; - return c.json({ - schemaVersion: '1', - items: result.results.map(mapFood), - page: { limit, offset, total, nextOffset: offset + limit < total ? offset + limit : null }, - }); -}); - -app.get('/api/mcp/cycles', async (c) => { - const { limit, offset } = mcpPage(c); - const [result, count] = await Promise.all([ - c.env.DB.prepare( - `SELECT * FROM goal_cycles WHERE user_id = ? - ORDER BY start_on DESC LIMIT ? OFFSET ?` - ) - .bind(c.get('mcpUserId'), limit, offset) - .all(), - c.env.DB.prepare('SELECT COUNT(*) AS total FROM goal_cycles WHERE user_id = ?') - .bind(c.get('mcpUserId')) - .first<{ total: number }>(), - ]); - const total = count?.total ?? 0; - return c.json({ - schemaVersion: '1', - items: result.results.map(mapGoalCycle), - page: { limit, offset, total, nextOffset: offset + limit < total ? offset + limit : null }, - }); -}); +registerAuthRoutes(app); +registerSessionMiddleware(app); +registerAccountRoutes(app); +registerJournalRoutes(app); +registerReadRoutes(app); +registerMcpRoutes(app); app.notFound((c) => c.json({ code: 'NOT_FOUND', message: 'That Calorie route does not exist.' }, 404) diff --git a/src/worker/account.ts b/src/worker/account.ts new file mode 100644 index 0000000..cf25b0d --- /dev/null +++ b/src/worker/account.ts @@ -0,0 +1,471 @@ +import { + normalizeDailyActionHidden, + normalizeDailyActionOrder, +} from '../lib/daily-action-preferences'; +import { cycleFromGoal } from '../lib/goal-cycles'; +import { createJournalExport } from '../lib/journal-export'; +import { calculateNutritionTarget } from '../lib/recommendations'; +import type { ActivityLevel, EquationProfile, Goal, UserProfile } from '../lib/types'; +import { createReadToken, hashReadToken } from '../server/read-tokens'; +import { + cycleInsertStatement, + currentTarget, + type FoodEntryRow, + type FoodRow, + type GoalCycleRow, + mapFood, + mapFoodEntry, + mapGoalCycle, + mapMedication, + mapMedicationCheckIn, + mapWater, + mapWeight, + type MedicationCheckInRow, + type MedicationRow, + readProfile, + type WaterRow, + type WeightRow, +} from './db'; +import { + conditionalJson, + dateKey, + finiteNumber, + jsonError, + optionalText, + requiredText, + validDateKey, +} from './http'; +import type { App } from './types'; + +type ReadTokenRow = { + id: string; + name: string; + token_hint: string; + created_at: number; +}; + +export function registerAccountRoutes(app: App) { + app.get('/api/app/mcp-tokens', async (c) => { + const result = await c.env.DB.prepare( + `SELECT id, name, token_hint, created_at FROM mcp_read_tokens + WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 20` + ) + .bind(c.get('userId')) + .all(); + return c.json( + result.results.map((row) => ({ + id: row.id, + name: row.name, + tokenHint: row.token_hint, + createdAt: row.created_at, + })) + ); + }); + + app.post('/api/app/mcp-tokens', async (c) => { + const body = await c.req + .json>() + .catch((): Record => ({})); + const name = optionalText(body.name, 50) ?? 'ChatGPT read access'; + const token = createReadToken(); + const id = crypto.randomUUID(); + const createdAt = Date.now(); + await c.env.DB.prepare( + `INSERT INTO mcp_read_tokens + (id, user_id, name, token_hash, token_hint, created_at, revoked_at) + VALUES (?, ?, ?, ?, ?, ?, NULL)` + ) + .bind(id, c.get('userId'), name, await hashReadToken(token), token.slice(0, 24), createdAt) + .run(); + return c.json({ id, name, token, tokenHint: token.slice(0, 24), createdAt }, 201); + }); + + app.delete('/api/app/mcp-tokens/:id', async (c) => { + const result = await c.env.DB.prepare( + `UPDATE mcp_read_tokens SET revoked_at = ? + WHERE id = ? AND user_id = ? AND revoked_at IS NULL` + ) + .bind(Date.now(), c.req.param('id'), c.get('userId')) + .run(); + return result.meta.changes + ? c.body(null, 204) + : c.json({ message: 'Read token not found.' }, 404); + }); + + app.get('/api/app/profile', async (c) => { + const profile = await readProfile(c.env.DB, c.get('userId'), c.get('userName')); + return conditionalJson(c, profile); + }); + + app.get('/api/app/bootstrap', async (c) => { + const userId = c.get('userId'); + const profile = await readProfile(c.env.DB, userId, c.get('userName')); + return c.json({ + session: { + user: { + id: userId, + name: c.get('userName'), + email: c.get('userEmail'), + image: c.get('userImage'), + }, + }, + profile, + }); + }); + + app.put('/api/app/profile', async (c) => { + const body = await c.req.json>().catch(() => null); + if (!body) return c.json(jsonError('Profile details are required.'), 400); + + const displayName = requiredText(body.displayName, 60); + const units = + body.units === 'imperial' ? 'imperial' : body.units === 'metric' ? 'metric' : null; + const ageYears = finiteNumber(body.ageYears, 18, 120); + const heightCm = finiteNumber(body.heightCm, 100, 250); + const equationProfile = ['female', 'male', 'none'].includes(String(body.equationProfile)) + ? (body.equationProfile as EquationProfile) + : null; + const activityLevel = ['sedentary', 'light', 'moderate', 'very'].includes( + String(body.activityLevel) + ) + ? (body.activityLevel as ActivityLevel) + : null; + const goal = ['lose_gentle', 'lose_steady', 'maintain', 'gain_gentle'].includes( + String(body.goal) + ) + ? (body.goal as Goal) + : null; + const targetWeightKg = + body.targetWeightKg === null ? null : finiteNumber(body.targetWeightKg, 30, 400); + const initialWeightKg = + body.initialWeightKg === undefined ? null : finiteNumber(body.initialWeightKg, 30, 400); + const manualTarget = + body.manualCalorieTarget === null || body.manualCalorieTarget === undefined + ? null + : finiteNumber(body.manualCalorieTarget, 800, 6000); + const manualRangeInput = Array.isArray(body.manualCalorieRange) + ? body.manualCalorieRange + : null; + const manualRangeMin = manualRangeInput ? finiteNumber(manualRangeInput[0], 800, 6000) : null; + const manualRangeMax = manualRangeInput ? finiteNumber(manualRangeInput[1], 800, 6000) : null; + const hasInvalidManualRange = + manualRangeInput !== null && + (manualRangeMin === null || manualRangeMax === null || manualRangeMin > manualRangeMax); + const sleepHours = finiteNumber(body.sleepHours, 5, 12); + const waterTargetMl = finiteNumber(body.waterTargetMl, 250, 10000); + const fastingThreshold = [12, 14, 16].includes(Number(body.fastingThresholdHours)) + ? Number(body.fastingThresholdHours) + : null; + const wakeTime = + typeof body.wakeTime === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(body.wakeTime) + ? body.wakeTime + : null; + const dailyActionOrder = normalizeDailyActionOrder( + Array.isArray(body.dailyActionOrder) ? body.dailyActionOrder : [] + ); + const dailyActionHidden = normalizeDailyActionHidden( + Array.isArray(body.dailyActionHidden) ? body.dailyActionHidden : [] + ); + const cycleDate = validDateKey(body.cycleDate) ?? dateKey(Date.now(), 'UTC'); + + if ( + !displayName || + !units || + ageYears === null || + heightCm === null || + !equationProfile || + !activityLevel || + !goal || + sleepHours === null || + waterTargetMl === null || + hasInvalidManualRange || + fastingThreshold === null || + !wakeTime + ) { + return c.json(jsonError('Check the highlighted profile details and try again.'), 400); + } + + const now = Date.now(); + const userId = c.get('userId'); + const genderIdentity = optionalText(body.genderIdentity, 40); + const onboardingComplete = body.onboardingComplete === false ? 0 : 1; + const manualRange = + manualRangeMin !== null && manualRangeMax !== null + ? ([Math.round(manualRangeMin), Math.round(manualRangeMax)] as const) + : null; + + const statements = [ + c.env.DB.prepare( + `INSERT INTO profiles ( + user_id, display_name, units, age_years, gender_identity, equation_profile, + height_cm, activity_level, goal, target_weight_kg, manual_calorie_target, + manual_calorie_min, manual_calorie_max, + wake_time, sleep_hours, fasting_threshold_hours, water_target_ml, + daily_action_order, daily_action_hidden, onboarding_complete, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + display_name = excluded.display_name, + units = excluded.units, + age_years = excluded.age_years, + gender_identity = excluded.gender_identity, + equation_profile = excluded.equation_profile, + height_cm = excluded.height_cm, + activity_level = excluded.activity_level, + goal = excluded.goal, + target_weight_kg = excluded.target_weight_kg, + manual_calorie_target = excluded.manual_calorie_target, + manual_calorie_min = excluded.manual_calorie_min, + manual_calorie_max = excluded.manual_calorie_max, + wake_time = excluded.wake_time, + sleep_hours = excluded.sleep_hours, + fasting_threshold_hours = excluded.fasting_threshold_hours, + water_target_ml = excluded.water_target_ml, + daily_action_order = excluded.daily_action_order, + daily_action_hidden = excluded.daily_action_hidden, + onboarding_complete = excluded.onboarding_complete, + updated_at = excluded.updated_at` + ).bind( + userId, + displayName, + units, + ageYears, + genderIdentity, + equationProfile, + heightCm, + activityLevel, + goal, + targetWeightKg, + manualRange ? Math.round((manualRange[0] + manualRange[1]) / 2) : manualTarget, + manualRange?.[0] ?? null, + manualRange?.[1] ?? null, + wakeTime, + sleepHours, + fastingThreshold, + Math.round(waterTargetMl), + dailyActionOrder.join(','), + dailyActionHidden.join(','), + onboardingComplete, + now, + now + ), + ]; + + const initialWeightId = optionalText(body.initialWeightId, 80); + if (initialWeightKg !== null && initialWeightId) { + statements.push( + c.env.DB.prepare( + `INSERT OR IGNORE INTO weight_entries + (id, user_id, weight_kg, recorded_at, created_at) + VALUES (?, ?, ?, ?, ?)` + ).bind(initialWeightId, userId, initialWeightKg, now, now) + ); + } + + const nextProfile: UserProfile = { + userId, + displayName, + units, + ageYears, + genderIdentity, + equationProfile, + heightCm, + activityLevel, + goal, + targetWeightKg, + manualCalorieTarget: manualRange + ? Math.round((manualRange[0] + manualRange[1]) / 2) + : manualTarget, + manualCalorieRange: manualRange ? [manualRange[0], manualRange[1]] : null, + wakeTime, + sleepHours, + fastingThresholdHours: fastingThreshold as 12 | 14 | 16, + waterTargetMl: Math.round(waterTargetMl), + dailyActionOrder, + dailyActionHidden, + onboardingComplete: Boolean(onboardingComplete), + }; + const target = initialWeightKg + ? calculateNutritionTarget({ + weightKg: initialWeightKg, + heightCm, + ageYears, + equationProfile, + activityLevel, + goal, + manualCalorieTarget: nextProfile.manualCalorieTarget, + manualCalorieRange: nextProfile.manualCalorieRange, + }) + : await currentTarget(c.env.DB, nextProfile, userId); + const activeCycle = await c.env.DB.prepare( + 'SELECT * FROM goal_cycles WHERE user_id = ? AND end_on IS NULL' + ) + .bind(userId) + .first(); + if (!activeCycle) { + statements.push( + cycleInsertStatement(c.env.DB, { + id: crypto.randomUUID(), + userId, + goal, + startOn: cycleDate, + calorieRange: target.calorieRange, + proteinRangeG: target.proteinRangeG, + now, + }) + ); + } else if (activeCycle.cycle === cycleFromGoal(goal)) { + statements.push( + c.env.DB.prepare( + `UPDATE goal_cycles SET goal = ?, calorie_range_low = ?, calorie_range_high = ?, + protein_range_low = ?, protein_range_high = ?, updated_at = ? + WHERE id = ? AND user_id = ? AND end_on IS NULL` + ).bind( + goal, + target.calorieRange?.[0] ?? null, + target.calorieRange?.[1] ?? null, + target.proteinRangeG?.[0] ?? null, + target.proteinRangeG?.[1] ?? null, + now, + activeCycle.id, + userId + ) + ); + } else { + statements.push( + c.env.DB.prepare( + 'UPDATE goal_cycles SET end_on = ?, updated_at = ? WHERE id = ? AND user_id = ? AND end_on IS NULL' + ).bind(cycleDate, now, activeCycle.id, userId), + cycleInsertStatement(c.env.DB, { + id: crypto.randomUUID(), + userId, + goal, + startOn: cycleDate, + calorieRange: target.calorieRange, + proteinRangeG: target.proteinRangeG, + now, + }) + ); + } + await c.env.DB.batch(statements); + return c.json(await readProfile(c.env.DB, userId, displayName)); + }); + + app.get('/api/app/cycles', async (c) => { + const userId = c.get('userId'); + const today = validDateKey(c.req.query('date')); + if (!today) return c.json(jsonError('Choose a valid local date.'), 400); + let result = await c.env.DB.prepare( + 'SELECT * FROM goal_cycles WHERE user_id = ? ORDER BY start_on DESC LIMIT 20' + ) + .bind(userId) + .all(); + if (!result.results.some((row) => row.end_on === null)) { + const profile = await readProfile(c.env.DB, userId, c.get('userName')); + const target = await currentTarget(c.env.DB, profile, userId); + await cycleInsertStatement(c.env.DB, { + id: crypto.randomUUID(), + userId, + goal: profile.goal, + startOn: today, + calorieRange: target.calorieRange, + proteinRangeG: target.proteinRangeG, + now: Date.now(), + }).run(); + result = await c.env.DB.prepare( + 'SELECT * FROM goal_cycles WHERE user_id = ? ORDER BY start_on DESC LIMIT 20' + ) + .bind(userId) + .all(); + } + return conditionalJson(c, result.results.map(mapGoalCycle)); + }); + + app.patch('/api/app/cycles/active', async (c) => { + const body = await c.req.json>().catch(() => null); + const startOn = validDateKey(body?.startOn); + const today = validDateKey(body?.today); + if (!startOn || !today || startOn > today) { + return c.json(jsonError('Choose a cycle start date that is not in the future.'), 400); + } + const userId = c.get('userId'); + const active = await c.env.DB.prepare( + 'SELECT * FROM goal_cycles WHERE user_id = ? AND end_on IS NULL' + ) + .bind(userId) + .first(); + if (!active) return c.json({ message: 'Active cycle not found.' }, 404); + const previous = await c.env.DB.prepare( + `SELECT * FROM goal_cycles WHERE user_id = ? AND end_on IS NOT NULL + ORDER BY end_on DESC LIMIT 1` + ) + .bind(userId) + .first(); + if (previous?.end_on && startOn < previous.end_on) { + return c.json( + jsonError(`Cycle start must be on or after ${previous.end_on}.`, { + startOn: 'Overlaps the previous cycle.', + }), + 400 + ); + } + await c.env.DB.prepare( + 'UPDATE goal_cycles SET start_on = ?, updated_at = ? WHERE id = ? AND user_id = ? AND end_on IS NULL' + ) + .bind(startOn, Date.now(), active.id, userId) + .run(); + const updated = await c.env.DB.prepare('SELECT * FROM goal_cycles WHERE id = ? AND user_id = ?') + .bind(active.id, userId) + .first(); + if (!updated) return c.json({ message: 'The cycle could not be read back.' }, 500); + return c.json(mapGoalCycle(updated)); + }); + + app.get('/api/app/export', async (c) => { + const userId = c.get('userId'); + const [profile, foods, entries, water, medications, checkIns, weights, cycles] = + await Promise.all([ + readProfile(c.env.DB, userId, c.get('userName')), + c.env.DB.prepare('SELECT * FROM foods WHERE user_id = ? ORDER BY created_at ASC') + .bind(userId) + .all(), + c.env.DB.prepare('SELECT * FROM food_entries WHERE user_id = ? ORDER BY eaten_at ASC') + .bind(userId) + .all(), + c.env.DB.prepare( + 'SELECT id, amount_ml, drank_at FROM water_entries WHERE user_id = ? ORDER BY drank_at ASC' + ) + .bind(userId) + .all(), + c.env.DB.prepare( + 'SELECT id, name, schedule, created_at, archived_at FROM medications WHERE user_id = ? ORDER BY created_at ASC' + ) + .bind(userId) + .all(), + c.env.DB.prepare( + 'SELECT id, medication_id, taken_on, taken_at FROM medication_check_ins WHERE user_id = ? ORDER BY taken_at ASC' + ) + .bind(userId) + .all(), + c.env.DB.prepare( + 'SELECT id, weight_kg, recorded_at FROM weight_entries WHERE user_id = ? ORDER BY recorded_at ASC' + ) + .bind(userId) + .all(), + c.env.DB.prepare('SELECT * FROM goal_cycles WHERE user_id = ? ORDER BY start_on ASC') + .bind(userId) + .all(), + ]); + return c.json( + createJournalExport({ + profile, + foods: foods.results.map(mapFood), + entries: entries.results.map(mapFoodEntry), + waterEntries: water.results.map(mapWater), + medications: medications.results.map(mapMedication), + medicationCheckIns: checkIns.results.map(mapMedicationCheckIn), + weights: weights.results.map(mapWeight), + cycleSessions: cycles.results.map(mapGoalCycle), + }) + ); + }); +} diff --git a/src/worker/auth.ts b/src/worker/auth.ts new file mode 100644 index 0000000..85cd263 --- /dev/null +++ b/src/worker/auth.ts @@ -0,0 +1,181 @@ +import { + createAuth, + isAppleConfigured, + isAppleWebConfigured, + isGoogleConfigured, +} from '../server/auth'; +import { + consumeNativeHandoff, + createNativeHandoffCode, + isAllowedNativeCallback, + NATIVE_AUTH_CALLBACK, + saveNativeHandoff, +} from '../server/native-handoff'; +import { authenticateMcpRead } from '../server/read-tokens'; +import type { App } from './types'; + +export function registerAuthRoutes(app: App) { + app.get('/api/health', (c) => + c.json({ + ok: true, + auth: { + googleConfigured: isGoogleConfigured(c.env), + appleConfigured: isAppleConfigured(c.env), + appleWebConfigured: isAppleWebConfigured(c.env), + }, + storage: 'd1', + }) + ); + + app.get('/api/auth/config', (c) => + c.json({ + googleConfigured: isGoogleConfigured(c.env), + appleConfigured: isAppleConfigured(c.env), + appleWebConfigured: isAppleWebConfigured(c.env), + }) + ); + + app.on(['GET', 'POST'], '/api/auth/*', async (c) => { + const path = new URL(c.req.url).pathname; + if (path.endsWith('/sign-in/social') && c.req.method === 'POST') { + const body = await c.req.raw + .clone() + .json<{ idToken?: unknown; provider?: unknown }>() + .catch(() => null); + const provider = body?.provider; + if (provider === 'google' && !isGoogleConfigured(c.env)) { + return c.json( + { + code: 'OAUTH_NOT_CONFIGURED', + message: 'Google sign-in is not configured in this environment.', + }, + 503 + ); + } + if (provider === 'apple' && !isAppleConfigured(c.env)) { + return c.json( + { + code: 'OAUTH_NOT_CONFIGURED', + message: 'Apple sign-in is not configured in this environment.', + }, + 503 + ); + } + if (provider === 'apple' && !body?.idToken && !isAppleWebConfigured(c.env)) { + return c.json( + { + code: 'OAUTH_NOT_CONFIGURED', + message: 'Apple browser sign-in is not configured in this environment.', + }, + 503 + ); + } + } + return createAuth(c.env, c.req.url).handler(c.req.raw); + }); + + app.get('/api/native/auth/google/start', async (c) => { + if (!isGoogleConfigured(c.env)) { + return c.json( + { code: 'OAUTH_NOT_CONFIGURED', message: 'Google sign-in is unavailable.' }, + 503 + ); + } + const callback = c.req.query('callback') ?? NATIVE_AUTH_CALLBACK; + if (!isAllowedNativeCallback(callback)) { + return c.json( + { code: 'INVALID_CALLBACK', message: 'The native callback is not allowed.' }, + 400 + ); + } + const completeURL = new URL('/api/native/auth/google/complete', c.req.url); + completeURL.searchParams.set('callback', callback); + const result = await createAuth(c.env, c.req.url).api.signInSocial({ + body: { + provider: 'google', + callbackURL: completeURL.toString(), + errorCallbackURL: completeURL.toString(), + }, + headers: c.req.raw.headers, + }); + if (!result.url) { + return c.json( + { code: 'OAUTH_START_FAILED', message: 'Google sign-in could not start.' }, + 502 + ); + } + return c.redirect(result.url); + }); + + app.get('/api/native/auth/google/complete', async (c) => { + const callback = c.req.query('callback') ?? NATIVE_AUTH_CALLBACK; + if (!isAllowedNativeCallback(callback)) { + return c.json( + { code: 'INVALID_CALLBACK', message: 'The native callback is not allowed.' }, + 400 + ); + } + const session = await createAuth(c.env, c.req.url).api.getSession({ + headers: c.req.raw.headers, + }); + const redirect = new URL(callback); + if (!session?.session.token) { + redirect.searchParams.set('error', 'google_auth_failed'); + return c.redirect(redirect.toString()); + } + const code = createNativeHandoffCode(); + await saveNativeHandoff(c.env.DB, code, session.session.token); + redirect.searchParams.set('code', code); + return c.redirect(redirect.toString()); + }); + + app.post('/api/native/auth/exchange', async (c) => { + const body = await c.req.json<{ code?: unknown }>().catch(() => null); + const code = typeof body?.code === 'string' ? body.code.trim() : ''; + if (code.length < 32 || code.length > 128) { + return c.json({ code: 'INVALID_HANDOFF', message: 'The sign-in handoff is invalid.' }, 400); + } + const token = await consumeNativeHandoff(c.env.DB, code); + if (!token) { + return c.json( + { code: 'EXPIRED_HANDOFF', message: 'The sign-in handoff expired or was already used.' }, + 401 + ); + } + return c.json({ token }); + }); +} + +export function registerSessionMiddleware(app: App) { + app.use('/api/app/*', async (c, next) => { + const session = await createAuth(c.env, c.req.url).api.getSession({ + headers: c.req.raw.headers, + }); + if (!session?.user?.id) { + return c.json({ code: 'UNAUTHORIZED', message: 'Sign in to continue.' }, 401); + } + c.set('userId', session.user.id); + c.set('userName', session.user.name || 'You'); + c.set('userEmail', session.user.email || ''); + c.set('userImage', session.user.image || null); + await next(); + }); + + app.use('/api/mcp/*', async (c, next) => { + const auth = await authenticateMcpRead(c.env.DB, c.req.header('Authorization'), c.env); + if (auth.status === 'account_not_found') { + return c.json( + { + code: 'ACCOUNT_NOT_FOUND', + message: 'Sign in to Calorie with the same Google account first.', + }, + 403 + ); + } + if (auth.status !== 'authorized') { + return c.json({ code: 'UNAUTHORIZED', message: 'Provide a valid Calorie read token.' }, 401); + } + c.set('mcpUserId', auth.userId); + await next(); + }); +} diff --git a/src/worker/db.ts b/src/worker/db.ts new file mode 100644 index 0000000..5525338 --- /dev/null +++ b/src/worker/db.ts @@ -0,0 +1,397 @@ +import { + normalizeDailyActionHidden, + normalizeDailyActionOrder, +} from '../lib/daily-action-preferences'; +import { normalizeDirectEntry } from '../lib/entries'; +import { normalizeFoodLabels, normalizeIsPackaged } from '../lib/food-context'; +import { cycleFromGoal } from '../lib/goal-cycles'; +import { calculateNutritionTarget } from '../lib/recommendations'; +import type { + ActivityLevel, + EquationProfile, + Food, + FoodEntry, + Goal, + GoalCycle, + GoalCycleSession, + Medication, + MedicationCheckIn, + MedicationSchedule, + ServingMode, + UserProfile, + WaterEntry, + WeightEntry, +} from '../lib/types'; +import { finiteNumber, optionalText, requiredText } from './http'; + +export function directEntryFromBody( + body: Record, + id: string, + amount: number, + eatenAt: number +): FoodEntry | null { + const foodName = optionalText(body.foodName, 80); + const unitLabel = optionalText(body.unitLabel, 24); + const calories = finiteNumber(body.calories, 0, 100_000); + const carbsG = finiteNumber(body.carbsG, 0, 100_000); + const proteinG = finiteNumber(body.proteinG, 0, 100_000); + const fibreG = finiteNumber(body.fibreG, 0, 100_000); + if ( + !foodName || + !unitLabel || + calories === null || + carbsG === null || + proteinG === null || + fibreG === null + ) { + return null; + } + return normalizeDirectEntry({ + id, + foodId: null, + foodName, + amount, + unitLabel, + calories, + carbsG, + proteinG, + fibreG, + eatenAt, + isPackaged: normalizeIsPackaged(body.isPackaged, body.foodKind), + labels: normalizeFoodLabels(body.labels), + }); +} + +type ProfileRow = { + user_id: string; + display_name: string; + units: 'metric' | 'imperial'; + age_years: number | null; + gender_identity: string | null; + equation_profile: EquationProfile | null; + height_cm: number | null; + activity_level: ActivityLevel; + goal: Goal; + target_weight_kg: number | null; + manual_calorie_target: number | null; + manual_calorie_min: number | null; + manual_calorie_max: number | null; + daily_action_order: string; + daily_action_hidden: string; + wake_time: string; + sleep_hours: number; + fasting_threshold_hours: 12 | 14 | 16; + water_target_ml: number; + onboarding_complete: number; +}; + +function mapProfile(row: ProfileRow): UserProfile { + return { + userId: row.user_id, + displayName: row.display_name, + units: row.units, + ageYears: row.age_years, + genderIdentity: row.gender_identity, + equationProfile: row.equation_profile, + heightCm: row.height_cm, + activityLevel: row.activity_level, + goal: row.goal, + targetWeightKg: row.target_weight_kg, + manualCalorieTarget: row.manual_calorie_target, + manualCalorieRange: + row.manual_calorie_min !== null && row.manual_calorie_max !== null + ? [row.manual_calorie_min, row.manual_calorie_max] + : row.manual_calorie_target + ? [Math.max(800, row.manual_calorie_target - 100), row.manual_calorie_target + 100] + : null, + wakeTime: row.wake_time, + sleepHours: row.sleep_hours, + fastingThresholdHours: row.fasting_threshold_hours, + waterTargetMl: row.water_target_ml, + dailyActionOrder: normalizeDailyActionOrder(row.daily_action_order?.split(',') ?? []), + dailyActionHidden: normalizeDailyActionHidden(row.daily_action_hidden?.split(',') ?? []), + onboardingComplete: Boolean(row.onboarding_complete), + }; +} + +function defaultProfile(userId: string, name: string): UserProfile { + return { + userId, + displayName: name, + units: 'metric', + ageYears: null, + genderIdentity: null, + equationProfile: null, + heightCm: null, + activityLevel: 'moderate', + goal: 'maintain', + targetWeightKg: null, + manualCalorieTarget: null, + manualCalorieRange: null, + wakeTime: '07:00', + sleepHours: 8, + fastingThresholdHours: 12, + waterTargetMl: 2000, + dailyActionOrder: normalizeDailyActionOrder([]), + dailyActionHidden: [], + onboardingComplete: false, + }; +} + +export async function readProfile( + db: D1Database, + userId: string, + fallbackName: string +): Promise { + const row = await db + .prepare('SELECT * FROM profiles WHERE user_id = ?') + .bind(userId) + .first(); + return row ? mapProfile(row) : defaultProfile(userId, fallbackName); +} + +export type FoodRow = { + id: string; + name: string; + serving_mode: ServingMode; + unit_label: string; + default_amount: number; + calories: number; + carbs_g: number; + protein_g: number; + fibre_g: number; + favourite: number; + last_used_at: number | null; + archived_at: number | null; + food_kind: string; + is_packaged: number; + labels_json: string; +}; + +export function mapFood(row: FoodRow): Food { + return { + id: row.id, + name: row.name, + servingMode: row.serving_mode, + unitLabel: row.unit_label, + defaultAmount: row.default_amount, + calories: row.calories, + carbsG: row.carbs_g, + proteinG: row.protein_g, + fibreG: row.fibre_g, + favourite: Boolean(row.favourite), + lastUsedAt: row.last_used_at, + archivedAt: row.archived_at ?? null, + isPackaged: normalizeIsPackaged(row.is_packaged, row.food_kind), + labels: normalizeFoodLabels(JSON.parse(row.labels_json || '[]')), + }; +} + +export type FoodEntryRow = { + id: string; + food_id: string | null; + food_name: string; + amount: number; + unit_label: string; + calories: number; + carbs_g: number; + protein_g: number; + fibre_g: number; + eaten_at: number; + food_kind: string; + is_packaged: number; + labels_json: string; +}; + +export function mapFoodEntry(row: FoodEntryRow): FoodEntry { + return { + id: row.id, + foodId: row.food_id, + foodName: row.food_name, + amount: row.amount, + unitLabel: row.unit_label, + calories: row.calories, + carbsG: row.carbs_g, + proteinG: row.protein_g, + fibreG: row.fibre_g, + eatenAt: row.eaten_at, + isPackaged: normalizeIsPackaged(row.is_packaged, row.food_kind), + labels: normalizeFoodLabels(JSON.parse(row.labels_json || '[]')), + }; +} + +export type WaterRow = { id: string; amount_ml: number; drank_at: number }; +export type WeightRow = { id: string; weight_kg: number; recorded_at: number }; +export type GoalCycleRow = { + id: string; + user_id: string; + cycle: GoalCycle; + goal: Goal; + start_on: string; + end_on: string | null; + calorie_range_low: number | null; + calorie_range_high: number | null; + protein_range_low: number | null; + protein_range_high: number | null; + created_at: number; + updated_at: number; +}; +export type MedicationRow = { + id: string; + name: string; + schedule: MedicationSchedule; + created_at: number; + archived_at: number | null; +}; +export type MedicationCheckInRow = { + id: string; + medication_id: string; + taken_on: string; + taken_at: number; +}; +export type MedicationHistoryRow = { + id: string; + medication_id: string; + medication_name: string; + taken_at: number; +}; + +export function mapWater(row: WaterRow): WaterEntry { + return { id: row.id, amountMl: row.amount_ml, drankAt: row.drank_at }; +} + +export function mapWeight(row: WeightRow): WeightEntry { + return { id: row.id, weightKg: row.weight_kg, recordedAt: row.recorded_at }; +} + +export function mapGoalCycle(row: GoalCycleRow): GoalCycleSession { + return { + id: row.id, + userId: row.user_id, + cycle: row.cycle, + goal: row.goal, + startOn: row.start_on, + endOn: row.end_on, + calorieRange: + row.calorie_range_low !== null && row.calorie_range_high !== null + ? [row.calorie_range_low, row.calorie_range_high] + : null, + proteinRangeG: + row.protein_range_low !== null && row.protein_range_high !== null + ? [row.protein_range_low, row.protein_range_high] + : null, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export async function currentTarget(db: D1Database, profile: UserProfile, userId: string) { + const latestWeight = await db + .prepare( + 'SELECT id, weight_kg, recorded_at FROM weight_entries WHERE user_id = ? ORDER BY recorded_at DESC LIMIT 1' + ) + .bind(userId) + .first(); + return calculateNutritionTarget({ + weightKg: latestWeight?.weight_kg ?? null, + heightCm: profile.heightCm, + ageYears: profile.ageYears, + equationProfile: profile.equationProfile, + activityLevel: profile.activityLevel, + goal: profile.goal, + manualCalorieTarget: profile.manualCalorieTarget, + manualCalorieRange: profile.manualCalorieRange, + }); +} + +export function cycleInsertStatement( + db: D1Database, + input: { + id: string; + userId: string; + goal: Goal; + startOn: string; + calorieRange: [number, number] | null; + proteinRangeG: [number, number] | null; + now: number; + } +) { + return db + .prepare( + `INSERT INTO goal_cycles ( + id, user_id, cycle, goal, start_on, end_on, + calorie_range_low, calorie_range_high, protein_range_low, protein_range_high, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?)` + ) + .bind( + input.id, + input.userId, + cycleFromGoal(input.goal), + input.goal, + input.startOn, + input.calorieRange?.[0] ?? null, + input.calorieRange?.[1] ?? null, + input.proteinRangeG?.[0] ?? null, + input.proteinRangeG?.[1] ?? null, + input.now, + input.now + ); +} + +export function mapMedication(row: MedicationRow): Medication { + return { + id: row.id, + name: row.name, + schedule: row.schedule, + createdAt: row.created_at, + archivedAt: row.archived_at, + }; +} + +export function mapMedicationCheckIn(row: MedicationCheckInRow): MedicationCheckIn { + return { + id: row.id, + medicationId: row.medication_id, + takenOn: row.taken_on, + takenAt: row.taken_at, + }; +} + +export function parseFoodBody(body: Record) { + const name = requiredText(body.name, 80); + const servingMode = ['per_100g', 'per_unit'].includes(String(body.servingMode)) + ? (body.servingMode as ServingMode) + : null; + const unitLabel = requiredText(body.unitLabel, 24); + const defaultAmount = finiteNumber(body.defaultAmount, 0.01, 10000); + const calories = finiteNumber(body.calories, 0, 10000); + const carbsG = finiteNumber(body.carbsG, 0, 1000); + const proteinG = finiteNumber(body.proteinG, 0, 1000); + const fibreG = finiteNumber(body.fibreG, 0, 1000); + if ( + !name || + !servingMode || + !unitLabel || + defaultAmount === null || + calories === null || + carbsG === null || + proteinG === null || + fibreG === null + ) { + return null; + } + return { + name, + servingMode, + unitLabel, + defaultAmount, + calories, + carbsG, + proteinG, + fibreG, + favourite: body.favourite === true ? 1 : 0, + isPackaged: normalizeIsPackaged(body.isPackaged, body.foodKind), + labels: normalizeFoodLabels(body.labels), + }; +} diff --git a/src/worker/http.ts b/src/worker/http.ts new file mode 100644 index 0000000..af70c9e --- /dev/null +++ b/src/worker/http.ts @@ -0,0 +1,81 @@ +/** + * Weak ETag helper for read-only API responses. Combines the user ID, request + * query string, and a 30-second time bucket so responses are cacheable for 30s + * on the client and via conditional requests (If-None-Match → 304). + */ +function etagFor(userId: string, query: string): string { + const bucket = Math.floor(Date.now() / 30_000); + return `W/"${userId}:${bucket}:${query.length}"`; +} + +export const SECURITY_HEADERS = { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', +}; + +export function conditionalJson( + c: { + req: { url: string; header: (name: string) => string | undefined }; + get: (key: 'userId') => string; + header: (name: string, value: string) => void; + json: (data: T) => Response; + body: (data: null, status: number) => Response; + }, + data: T +): Response { + const tag = etagFor(c.get('userId'), new URL(c.req.url).search); + if (c.req.header('If-None-Match') === tag) return c.body(null, 304); + c.header('ETag', tag); + c.header('Cache-Control', 'private, max-age=30'); + return c.json(data); +} + +export function finiteNumber(value: unknown, min: number, max: number): number | null { + const number = typeof value === 'number' ? value : Number(value); + return Number.isFinite(number) && number >= min && number <= max ? number : null; +} + +export function optionalText(value: unknown, max = 80): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 && trimmed.length <= max ? trimmed : null; +} + +export function requiredText(value: unknown, max = 80): string | null { + return optionalText(value, max); +} + +export function validTimestamp(value: unknown): number | null { + const timestamp = finiteNumber(value, 0, Date.now() + 24 * 60 * 60 * 1000); + return timestamp === null ? null : Math.round(timestamp); +} + +export function jsonError(message: string, fields?: Record) { + return { code: 'VALIDATION_ERROR', message, fields }; +} + +export function validDateKey(value: unknown) { + return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null; +} + +export function parseRange(c: { + req: { query: (name: string) => string | undefined }; +}): { start: number; end: number } | null { + const start = finiteNumber(c.req.query('start'), 0, Date.now() + 24 * 60 * 60 * 1000); + const end = finiteNumber(c.req.query('end'), 0, Date.now() + 48 * 60 * 60 * 1000); + if (start === null || end === null || end <= start || end - start > 366 * 24 * 60 * 60 * 1000) { + return null; + } + return { start, end }; +} + +export function dateKey(timestamp: number, timezone: string) { + return new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(timestamp); +} diff --git a/src/worker/journal.ts b/src/worker/journal.ts new file mode 100644 index 0000000..d8b91f0 --- /dev/null +++ b/src/worker/journal.ts @@ -0,0 +1,524 @@ +import { normalizeFoodLabels } from '../lib/food-context'; +import { scaleNutrients } from '../lib/recommendations'; +import type { FoodEntry, MedicationSchedule } from '../lib/types'; +import { + directEntryFromBody, + type FoodEntryRow, + type FoodRow, + mapFood, + mapFoodEntry, + mapMedication, + mapMedicationCheckIn, + mapWater, + mapWeight, + type MedicationCheckInRow, + type MedicationRow, + parseFoodBody, + type WaterRow, + type WeightRow, +} from './db'; +import { finiteNumber, jsonError, optionalText, requiredText, validTimestamp } from './http'; +import type { App } from './types'; + +export function registerJournalRoutes(app: App) { + app.get('/api/app/foods', async (c) => { + const search = c.req.query('q')?.trim().slice(0, 60); + const lifecycleWhere = + c.req.query('status') === 'archived' ? 'archived_at IS NOT NULL' : 'archived_at IS NULL'; + const result = search + ? await c.env.DB.prepare( + `SELECT * FROM foods + WHERE user_id = ? AND ${lifecycleWhere} AND name LIKE ? ESCAPE '\\' + ORDER BY last_used_at DESC, name ASC LIMIT 50` + ) + .bind(c.get('userId'), `%${search.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`) + .all() + : await c.env.DB.prepare( + `SELECT * FROM foods WHERE user_id = ? AND ${lifecycleWhere} + ORDER BY last_used_at DESC, name ASC LIMIT 100` + ) + .bind(c.get('userId')) + .all(); + return c.json(result.results.map(mapFood)); + }); + + app.post('/api/app/foods', async (c) => { + const body = await c.req.json>().catch(() => null); + const parsed = body ? parseFoodBody(body) : null; + const id = body ? optionalText(body.id, 80) : null; + if (!parsed || !id) return c.json(jsonError('Complete all four nutrient values.'), 400); + const now = Date.now(); + try { + await c.env.DB.prepare( + `INSERT INTO foods ( + id, user_id, name, serving_mode, unit_label, default_amount, + calories, carbs_g, protein_g, fibre_g, favourite, food_kind, is_packaged, labels_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + id, + c.get('userId'), + parsed.name, + parsed.servingMode, + parsed.unitLabel, + parsed.defaultAmount, + parsed.calories, + parsed.carbsG, + parsed.proteinG, + parsed.fibreG, + parsed.favourite, + parsed.isPackaged ? 'packaged' : 'prepared', + parsed.isPackaged ? 1 : 0, + JSON.stringify(parsed.labels), + now, + now + ) + .run(); + } catch (error) { + console.error(JSON.stringify({ event: 'food_create_failed', message: String(error) })); + return c.json( + jsonError('A food with that name already exists. Edit the existing food instead.'), + 409 + ); + } + const row = await c.env.DB.prepare('SELECT * FROM foods WHERE id = ? AND user_id = ?') + .bind(id, c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The saved food could not be read back.' }, 500); + return c.json(mapFood(row), 201); + }); + + app.put('/api/app/foods/:id', async (c) => { + const body = await c.req.json>().catch(() => null); + const parsed = body ? parseFoodBody(body) : null; + if (!parsed) return c.json(jsonError('Complete all four nutrient values.'), 400); + const result = await c.env.DB.prepare( + `UPDATE foods SET name = ?, serving_mode = ?, unit_label = ?, default_amount = ?, + calories = ?, carbs_g = ?, protein_g = ?, fibre_g = ?, favourite = ?, food_kind = ?, is_packaged = ?, labels_json = ?, updated_at = ? + WHERE id = ? AND user_id = ?` + ) + .bind( + parsed.name, + parsed.servingMode, + parsed.unitLabel, + parsed.defaultAmount, + parsed.calories, + parsed.carbsG, + parsed.proteinG, + parsed.fibreG, + parsed.favourite, + parsed.isPackaged ? 'packaged' : 'prepared', + parsed.isPackaged ? 1 : 0, + JSON.stringify(parsed.labels), + Date.now(), + c.req.param('id'), + c.get('userId') + ) + .run(); + if (!result.meta.changes) return c.json({ message: 'Food not found.' }, 404); + const row = await c.env.DB.prepare('SELECT * FROM foods WHERE id = ? AND user_id = ?') + .bind(c.req.param('id'), c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The saved food could not be read back.' }, 500); + return c.json(mapFood(row)); + }); + + app.patch('/api/app/foods/:id', async (c) => { + const body = await c.req.json>().catch(() => null); + if (!body || !('archivedAt' in body)) { + return c.json(jsonError('Choose whether this food is active or archived.'), 400); + } + const archivedAt = body.archivedAt === null ? null : validTimestamp(body.archivedAt); + if (body.archivedAt !== null && archivedAt === null) { + return c.json(jsonError('Choose a valid archive time.'), 400); + } + const result = await c.env.DB.prepare( + 'UPDATE foods SET archived_at = ?, updated_at = ? WHERE id = ? AND user_id = ?' + ) + .bind(archivedAt, Date.now(), c.req.param('id'), c.get('userId')) + .run(); + if (!result.meta.changes) return c.json({ message: 'Food not found.' }, 404); + const row = await c.env.DB.prepare('SELECT * FROM foods WHERE id = ? AND user_id = ?') + .bind(c.req.param('id'), c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The saved food could not be read back.' }, 500); + return c.json(mapFood(row)); + }); + + app.delete('/api/app/foods/:id', async (c) => { + const result = await c.env.DB.prepare('DELETE FROM foods WHERE id = ? AND user_id = ?') + .bind(c.req.param('id'), c.get('userId')) + .run(); + return result.meta.changes ? c.body(null, 204) : c.json({ message: 'Food not found.' }, 404); + }); + + app.post('/api/app/entries', async (c) => { + const body = await c.req.json>().catch(() => null); + const id = body ? optionalText(body.id, 80) : null; + const foodId = body ? optionalText(body.foodId, 80) : null; + const amount = body ? finiteNumber(body.amount, 0.01, 10000) : null; + const eatenAt = body ? validTimestamp(body.eatenAt) : null; + if (!body || !id || amount === null || eatenAt === null) { + return c.json(jsonError('Add a valid amount and time.'), 400); + } + let entry: FoodEntry; + let foodUpdate: D1PreparedStatement | null = null; + const now = Date.now(); + + if (foodId) { + const foodRow = await c.env.DB.prepare( + 'SELECT * FROM foods WHERE id = ? AND user_id = ? AND archived_at IS NULL' + ) + .bind(foodId, c.get('userId')) + .first(); + if (!foodRow) return c.json({ message: 'Food not found.' }, 404); + const food = mapFood(foodRow); + entry = { + id, + foodId: food.id, + foodName: food.name, + amount, + unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, + ...scaleNutrients(food, food.servingMode, amount), + eatenAt, + isPackaged: food.isPackaged, + labels: food.labels, + }; + foodUpdate = c.env.DB.prepare( + 'UPDATE foods SET last_used_at = ?, updated_at = ? WHERE id = ? AND user_id = ?' + ).bind(eatenAt, now, food.id, c.get('userId')); + } else { + const directEntry = directEntryFromBody(body, id, amount, eatenAt); + if (!directEntry) { + return c.json(jsonError('Add a name, unit, and valid nutrient totals.'), 400); + } + entry = directEntry; + } + + const insert = c.env.DB.prepare( + `INSERT OR IGNORE INTO food_entries ( + id, user_id, food_id, food_name, amount, unit_label, calories, + carbs_g, protein_g, fibre_g, food_kind, is_packaged, labels_json, eaten_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).bind( + entry.id, + c.get('userId'), + entry.foodId, + entry.foodName, + entry.amount, + entry.unitLabel, + entry.calories, + entry.carbsG, + entry.proteinG, + entry.fibreG, + entry.isPackaged ? 'packaged' : 'prepared', + entry.isPackaged ? 1 : 0, + JSON.stringify(normalizeFoodLabels(entry.labels)), + entry.eatenAt, + now + ); + if (foodUpdate) await c.env.DB.batch([insert, foodUpdate]); + else await insert.run(); + + const row = await c.env.DB.prepare('SELECT * FROM food_entries WHERE id = ? AND user_id = ?') + .bind(id, c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The food entry could not be read back.' }, 500); + return c.json(mapFoodEntry(row), 201); + }); + + app.patch('/api/app/entries/:id', async (c) => { + const body = await c.req.json>().catch(() => null); + if (!body) return c.json(jsonError('Send an entry to update.'), 400); + const foodId = optionalText(body.foodId, 80); + const amount = finiteNumber(body.amount, 0.01, 10_000); + const eatenAt = validTimestamp(body.eatenAt); + if (amount === null || eatenAt === null) { + return c.json(jsonError('Add a valid amount and time.'), 400); + } + + let entry: FoodEntry; + if (foodId) { + const foodRow = await c.env.DB.prepare( + 'SELECT * FROM foods WHERE id = ? AND user_id = ? AND archived_at IS NULL' + ) + .bind(foodId, c.get('userId')) + .first(); + if (!foodRow) return c.json({ message: 'Saved food not found.' }, 404); + const food = mapFood(foodRow); + entry = { + id: c.req.param('id'), + foodId: food.id, + foodName: food.name, + amount, + unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, + ...scaleNutrients(food, food.servingMode, amount), + eatenAt, + isPackaged: food.isPackaged, + labels: food.labels, + }; + } else { + const directEntry = directEntryFromBody(body, c.req.param('id'), amount, eatenAt); + if (!directEntry) { + return c.json(jsonError('Add a name, unit, and valid nutrient totals.'), 400); + } + entry = directEntry; + } + + const result = await c.env.DB.prepare( + `UPDATE food_entries SET food_id = ?, food_name = ?, amount = ?, unit_label = ?, + calories = ?, carbs_g = ?, protein_g = ?, fibre_g = ?, food_kind = ?, is_packaged = ?, labels_json = ?, eaten_at = ? + WHERE id = ? AND user_id = ?` + ) + .bind( + entry.foodId, + entry.foodName, + entry.amount, + entry.unitLabel, + entry.calories, + entry.carbsG, + entry.proteinG, + entry.fibreG, + entry.isPackaged ? 'packaged' : 'prepared', + entry.isPackaged ? 1 : 0, + JSON.stringify(normalizeFoodLabels(entry.labels)), + entry.eatenAt, + entry.id, + c.get('userId') + ) + .run(); + if (!result.meta.changes) return c.json({ message: 'Food entry not found.' }, 404); + + const row = await c.env.DB.prepare('SELECT * FROM food_entries WHERE id = ? AND user_id = ?') + .bind(c.req.param('id'), c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The food entry could not be read back.' }, 500); + return c.json(mapFoodEntry(row)); + }); + + app.delete('/api/app/entries/:id', async (c) => { + const result = await c.env.DB.prepare('DELETE FROM food_entries WHERE id = ? AND user_id = ?') + .bind(c.req.param('id'), c.get('userId')) + .run(); + return result.meta.changes ? c.body(null, 204) : c.json({ message: 'Entry not found.' }, 404); + }); + + app.post('/api/app/water', async (c) => { + const body = await c.req.json>().catch(() => null); + const id = body ? optionalText(body.id, 80) : null; + const amountMl = body ? finiteNumber(body.amountMl, 1, 5000) : null; + const drankAt = body ? validTimestamp(body.drankAt) : null; + if (!id || amountMl === null || drankAt === null) { + return c.json(jsonError('Choose a water amount and time.'), 400); + } + await c.env.DB.prepare( + `INSERT OR IGNORE INTO water_entries + (id, user_id, amount_ml, drank_at, created_at) VALUES (?, ?, ?, ?, ?)` + ) + .bind(id, c.get('userId'), Math.round(amountMl), drankAt, Date.now()) + .run(); + const row = await c.env.DB.prepare( + 'SELECT id, amount_ml, drank_at FROM water_entries WHERE id = ? AND user_id = ?' + ) + .bind(id, c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The water entry could not be read back.' }, 500); + return c.json(mapWater(row), 201); + }); + + app.patch('/api/app/water/:id', async (c) => { + const body = await c.req.json>().catch(() => null); + const amountMl = body ? finiteNumber(body.amountMl, 1, 5000) : null; + const drankAt = body ? validTimestamp(body.drankAt) : null; + if (amountMl === null || drankAt === null) { + return c.json(jsonError('Choose a water amount and time.'), 400); + } + const result = await c.env.DB.prepare( + 'UPDATE water_entries SET amount_ml = ?, drank_at = ? WHERE id = ? AND user_id = ?' + ) + .bind(Math.round(amountMl), drankAt, c.req.param('id'), c.get('userId')) + .run(); + if (!result.meta.changes) return c.json({ message: 'Water entry not found.' }, 404); + const row = await c.env.DB.prepare( + 'SELECT id, amount_ml, drank_at FROM water_entries WHERE id = ? AND user_id = ?' + ) + .bind(c.req.param('id'), c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The water entry could not be read back.' }, 500); + return c.json(mapWater(row)); + }); + + app.delete('/api/app/water/:id', async (c) => { + const result = await c.env.DB.prepare('DELETE FROM water_entries WHERE id = ? AND user_id = ?') + .bind(c.req.param('id'), c.get('userId')) + .run(); + return result.meta.changes + ? c.body(null, 204) + : c.json({ message: 'Water entry not found.' }, 404); + }); + + app.post('/api/app/medications', async (c) => { + const body = await c.req.json>().catch(() => null); + const id = body ? optionalText(body.id, 80) : null; + const name = body ? requiredText(body.name, 80) : null; + const schedule = + body && ['morning', 'evening', 'either'].includes(String(body.schedule)) + ? (body.schedule as MedicationSchedule) + : null; + const createdAt = body ? validTimestamp(body.createdAt) : null; + if (!id || !name || !schedule || createdAt === null) { + return c.json(jsonError('Add a medication name and when you take it.'), 400); + } + const now = Date.now(); + await c.env.DB.prepare( + `INSERT OR IGNORE INTO medications + (id, user_id, name, schedule, created_at, updated_at, archived_at) + VALUES (?, ?, ?, ?, ?, ?, NULL)` + ) + .bind(id, c.get('userId'), name, schedule, createdAt, now) + .run(); + const row = await c.env.DB.prepare( + `SELECT id, name, schedule, created_at, archived_at + FROM medications WHERE id = ? AND user_id = ?` + ) + .bind(id, c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The medication could not be read back.' }, 500); + return c.json(mapMedication(row), 201); + }); + + app.patch('/api/app/medications/:id', async (c) => { + const body = await c.req.json>().catch(() => null); + const name = body ? requiredText(body.name, 80) : null; + const schedule = + body && ['morning', 'evening', 'either'].includes(String(body.schedule)) + ? (body.schedule as MedicationSchedule) + : null; + const archivedAt = + body?.archivedAt === null + ? null + : body?.archivedAt === undefined + ? undefined + : validTimestamp(body.archivedAt); + const hasInvalidArchivedAt = + body?.archivedAt !== null && body?.archivedAt !== undefined && archivedAt === null; + if (!body || !name || !schedule || archivedAt === undefined || hasInvalidArchivedAt) { + return c.json(jsonError('Add a medication name and when you take it.'), 400); + } + const result = await c.env.DB.prepare( + `UPDATE medications SET name = ?, schedule = ?, archived_at = ?, updated_at = ? + WHERE id = ? AND user_id = ?` + ) + .bind(name, schedule, archivedAt, Date.now(), c.req.param('id'), c.get('userId')) + .run(); + if (!result.meta.changes) return c.json({ message: 'Medication not found.' }, 404); + const row = await c.env.DB.prepare( + `SELECT id, name, schedule, created_at, archived_at + FROM medications WHERE id = ? AND user_id = ?` + ) + .bind(c.req.param('id'), c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The medication could not be read back.' }, 500); + return c.json(mapMedication(row)); + }); + + app.post('/api/app/medication-check-ins', async (c) => { + const body = await c.req.json>().catch(() => null); + const id = body ? optionalText(body.id, 80) : null; + const medicationId = body ? optionalText(body.medicationId, 80) : null; + const takenOn = + body && typeof body.takenOn === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(body.takenOn) + ? body.takenOn + : null; + const takenAt = body ? validTimestamp(body.takenAt) : null; + if (!id || !medicationId || !takenOn || takenAt === null) { + return c.json(jsonError('Choose a medication and valid day.'), 400); + } + const medication = await c.env.DB.prepare( + 'SELECT id FROM medications WHERE id = ? AND user_id = ? AND archived_at IS NULL' + ) + .bind(medicationId, c.get('userId')) + .first<{ id: string }>(); + if (!medication) return c.json({ message: 'Medication not found.' }, 404); + await c.env.DB.prepare( + `INSERT OR IGNORE INTO medication_check_ins + (id, user_id, medication_id, taken_on, taken_at, created_at) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .bind(id, c.get('userId'), medicationId, takenOn, takenAt, Date.now()) + .run(); + const row = await c.env.DB.prepare( + `SELECT id, medication_id, taken_on, taken_at FROM medication_check_ins + WHERE user_id = ? AND medication_id = ? AND taken_on = ?` + ) + .bind(c.get('userId'), medicationId, takenOn) + .first(); + if (!row) return c.json({ message: 'The medication check-off could not be read back.' }, 500); + return c.json(mapMedicationCheckIn(row), 201); + }); + + app.delete('/api/app/medication-check-ins/:id', async (c) => { + const result = await c.env.DB.prepare( + 'DELETE FROM medication_check_ins WHERE id = ? AND user_id = ?' + ) + .bind(c.req.param('id'), c.get('userId')) + .run(); + return result.meta.changes + ? c.body(null, 204) + : c.json({ message: 'Medication check-off not found.' }, 404); + }); + + app.post('/api/app/weights', async (c) => { + const body = await c.req.json>().catch(() => null); + const id = body ? optionalText(body.id, 80) : null; + const weightKg = body ? finiteNumber(body.weightKg, 30, 400) : null; + const recordedAt = body ? validTimestamp(body.recordedAt) : null; + if (!id || weightKg === null || recordedAt === null) { + return c.json(jsonError('Enter a valid weight and date.'), 400); + } + await c.env.DB.prepare( + `INSERT OR IGNORE INTO weight_entries + (id, user_id, weight_kg, recorded_at, created_at) VALUES (?, ?, ?, ?, ?)` + ) + .bind(id, c.get('userId'), weightKg, recordedAt, Date.now()) + .run(); + const row = await c.env.DB.prepare( + 'SELECT id, weight_kg, recorded_at FROM weight_entries WHERE id = ? AND user_id = ?' + ) + .bind(id, c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The weight entry could not be read back.' }, 500); + return c.json(mapWeight(row), 201); + }); + + app.patch('/api/app/weights/:id', async (c) => { + const body = await c.req.json>().catch(() => null); + const weightKg = body ? finiteNumber(body.weightKg, 30, 400) : null; + const recordedAt = body ? validTimestamp(body.recordedAt) : null; + if (weightKg === null || recordedAt === null) { + return c.json(jsonError('Enter a valid weight and date.'), 400); + } + const result = await c.env.DB.prepare( + 'UPDATE weight_entries SET weight_kg = ?, recorded_at = ? WHERE id = ? AND user_id = ?' + ) + .bind(weightKg, recordedAt, c.req.param('id'), c.get('userId')) + .run(); + if (!result.meta.changes) return c.json({ message: 'Weight entry not found.' }, 404); + const row = await c.env.DB.prepare( + 'SELECT id, weight_kg, recorded_at FROM weight_entries WHERE id = ? AND user_id = ?' + ) + .bind(c.req.param('id'), c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The weight entry could not be read back.' }, 500); + return c.json(mapWeight(row)); + }); + + app.delete('/api/app/weights/:id', async (c) => { + const result = await c.env.DB.prepare('DELETE FROM weight_entries WHERE id = ? AND user_id = ?') + .bind(c.req.param('id'), c.get('userId')) + .run(); + return result.meta.changes + ? c.body(null, 204) + : c.json({ message: 'Weight entry not found.' }, 404); + }); +} diff --git a/src/worker/mcp.ts b/src/worker/mcp.ts new file mode 100644 index 0000000..75b87ff --- /dev/null +++ b/src/worker/mcp.ts @@ -0,0 +1,334 @@ +import { calculateCompletedFasts, round } from '../lib/recommendations'; +import { + type FoodEntryRow, + type FoodRow, + type GoalCycleRow, + mapFood, + mapFoodEntry, + mapGoalCycle, + mapWater, + type WaterRow, +} from './db'; +import { dateKey, jsonError, validDateKey } from './http'; +import type { App } from './types'; + +const MCP_DAY_MS = 24 * 60 * 60 * 1000; + +function mcpLimit(value: string | undefined, fallback = 30, maximum = 90) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback; +} + +function mcpOffset(value: string | undefined) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? Math.min(parsed, 10_000) : 0; +} + +function addUtcDays(date: string, amount: number) { + const [year, month, day] = date.split('-').map(Number); + return new Date(Date.UTC(year, month - 1, day + amount)).toISOString().slice(0, 10); +} + +function timezoneOffset(timestamp: number, timezone: string) { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }).formatToParts(timestamp); + const value = (type: Intl.DateTimeFormatPartTypes) => + Number(parts.find((part) => part.type === type)?.value); + return ( + Date.UTC( + value('year'), + value('month') - 1, + value('day'), + value('hour'), + value('minute'), + value('second') + ) - timestamp + ); +} + +function localMidnight(date: string, timezone: string): number | null { + if (!validDateKey(date)) return null; + const [year, month, day] = date.split('-').map(Number); + const approximate = Date.UTC(year, month - 1, day); + try { + let result = approximate - timezoneOffset(approximate, timezone); + result = approximate - timezoneOffset(result, timezone); + return dateKey(result, timezone) === date ? result : null; + } catch { + return null; + } +} + +function mcpPage(c: { req: { query: (name: string) => string | undefined } }) { + return { + limit: mcpLimit(c.req.query('limit')), + offset: mcpOffset(c.req.query('offset')), + }; +} + +type McpTargetRow = { + manual_calorie_target: number | null; + manual_calorie_min: number | null; + manual_calorie_max: number | null; + water_target_ml: number; + fasting_threshold_hours: 12 | 14 | 16; +}; + +async function readMcpTargets(db: D1Database, userId: string) { + const row = await db + .prepare( + `SELECT manual_calorie_target, manual_calorie_min, manual_calorie_max, + water_target_ml, fasting_threshold_hours + FROM profiles WHERE user_id = ?` + ) + .bind(userId) + .first(); + const calorieRange = + row?.manual_calorie_min !== null && + row?.manual_calorie_min !== undefined && + row.manual_calorie_max !== null + ? ([row.manual_calorie_min, row.manual_calorie_max] as [number, number]) + : row?.manual_calorie_target + ? ([Math.max(800, row.manual_calorie_target - 100), row.manual_calorie_target + 100] as [ + number, + number, + ]) + : null; + return { + calorieRange, + waterMl: row?.water_target_ml ?? 2000, + fastingThresholdHours: row?.fasting_threshold_hours ?? 12, + }; +} + +export function registerMcpRoutes(app: App) { + app.get('/api/mcp/daily', async (c) => { + const date = validDateKey(c.req.query('date')); + const timezone = c.req.query('timezone')?.slice(0, 80) || 'UTC'; + const start = date ? localMidnight(date, timezone) : null; + const end = date ? localMidnight(addUtcDays(date, 1), timezone) : null; + if (!date || start === null || end === null) { + return c.json(jsonError('Choose a valid date and IANA timezone.'), 400); + } + const userId = c.get('mcpUserId'); + const [profileTargets, entriesResult, waterResult, cycle, priorEntry] = await Promise.all([ + readMcpTargets(c.env.DB, userId), + c.env.DB.prepare( + `SELECT * FROM food_entries + WHERE user_id = ? AND eaten_at >= ? AND eaten_at < ? + ORDER BY eaten_at ASC LIMIT 251` + ) + .bind(userId, start, end) + .all(), + c.env.DB.prepare( + `SELECT id, amount_ml, drank_at FROM water_entries + WHERE user_id = ? AND drank_at >= ? AND drank_at < ? + ORDER BY drank_at ASC LIMIT 251` + ) + .bind(userId, start, end) + .all(), + c.env.DB.prepare( + `SELECT * FROM goal_cycles WHERE user_id = ? + AND start_on <= ? AND (end_on IS NULL OR end_on >= ?) + ORDER BY start_on DESC LIMIT 1` + ) + .bind(userId, date, date) + .first(), + c.env.DB.prepare( + `SELECT eaten_at FROM food_entries + WHERE user_id = ? AND eaten_at < ? ORDER BY eaten_at DESC LIMIT 1` + ) + .bind(userId, start) + .first<{ eaten_at: number }>(), + ]); + const entries = entriesResult.results.slice(0, 250).map(mapFoodEntry); + const waterEntries = waterResult.results.slice(0, 250).map(mapWater); + const totals = entries.reduce( + (sum, entry) => ({ + calories: sum.calories + entry.calories, + carbsG: sum.carbsG + entry.carbsG, + proteinG: sum.proteinG + entry.proteinG, + fibreG: sum.fibreG + entry.fibreG, + }), + { calories: 0, carbsG: 0, proteinG: 0, fibreG: 0 } + ); + const completedFasts = calculateCompletedFasts( + [...(priorEntry ? [{ eatenAt: priorEntry.eaten_at }] : []), ...entries], + timezone + ).filter( + (fast) => + fast.endAt >= start && + fast.endAt < end && + fast.durationHours >= profileTargets.fastingThresholdHours + ); + return c.json({ + schemaVersion: '1', + provenance: 'calculated-from-recorded-entries', + date, + timezone, + totals: { + calories: round(totals.calories), + carbsG: round(totals.carbsG, 1), + proteinG: round(totals.proteinG, 1), + fibreG: round(totals.fibreG, 1), + waterMl: waterEntries.reduce((sum, entry) => sum + entry.amountMl, 0), + }, + targets: { + calorieRange: cycle ? mapGoalCycle(cycle).calorieRange : profileTargets.calorieRange, + proteinRangeG: cycle ? mapGoalCycle(cycle).proteinRangeG : null, + waterMl: profileTargets.waterMl, + }, + fasting: { + thresholdHours: profileTargets.fastingThresholdHours, + completed: completedFasts, + provenance: 'calculated-from-recorded-entry-times', + }, + entries, + waterEntries, + truncated: entriesResult.results.length > 250 || waterResult.results.length > 250, + }); + }); + + app.get('/api/mcp/history', async (c) => { + const startDate = validDateKey(c.req.query('start')); + const endDate = validDateKey(c.req.query('end')); + const timezone = c.req.query('timezone')?.slice(0, 80) || 'UTC'; + if (!startDate || !endDate || startDate > endDate) { + return c.json(jsonError('Choose a valid inclusive date range.'), 400); + } + const totalDays = + Math.round( + (Date.parse(`${endDate}T00:00:00Z`) - Date.parse(`${startDate}T00:00:00Z`)) / MCP_DAY_MS + ) + 1; + if (totalDays < 1 || totalDays > 366) { + return c.json(jsonError('Choose a history range of one year or less.'), 400); + } + const { limit, offset } = mcpPage(c); + const pageStartDate = addUtcDays(startDate, Math.min(offset, totalDays)); + const pageDays = Math.max(0, Math.min(limit, totalDays - offset)); + const pageEndDate = addUtcDays(pageStartDate, pageDays); + const start = localMidnight(pageStartDate, timezone); + const end = localMidnight(pageEndDate, timezone); + if (start === null || end === null) { + return c.json(jsonError('Choose a valid IANA timezone.'), 400); + } + const userId = c.get('mcpUserId'); + const [entriesResult, waterResult] = await Promise.all([ + c.env.DB.prepare( + `SELECT * FROM food_entries WHERE user_id = ? AND eaten_at >= ? AND eaten_at < ? + ORDER BY eaten_at ASC LIMIT 1001` + ) + .bind(userId, start, end) + .all(), + c.env.DB.prepare( + `SELECT id, amount_ml, drank_at FROM water_entries + WHERE user_id = ? AND drank_at >= ? AND drank_at < ? ORDER BY drank_at ASC LIMIT 1001` + ) + .bind(userId, start, end) + .all(), + ]); + const entries = entriesResult.results.slice(0, 1000).map(mapFoodEntry); + const waterEntries = waterResult.results.slice(0, 1000).map(mapWater); + const days = Array.from({ length: pageDays }, (_, index) => ({ + date: addUtcDays(pageStartDate, index), + calories: 0, + carbsG: 0, + proteinG: 0, + fibreG: 0, + waterMl: 0, + recorded: false, + })); + const byDate = new Map(days.map((day) => [day.date, day])); + for (const entry of entries) { + const day = byDate.get(dateKey(entry.eatenAt, timezone)); + if (!day) continue; + day.recorded = true; + day.calories += entry.calories; + day.carbsG += entry.carbsG; + day.proteinG += entry.proteinG; + day.fibreG += entry.fibreG; + } + for (const entry of waterEntries) { + const day = byDate.get(dateKey(entry.drankAt, timezone)); + if (day) { + day.recorded = true; + day.waterMl += entry.amountMl; + } + } + return c.json({ + schemaVersion: '1', + items: days.map((day) => ({ + ...day, + calories: round(day.calories), + carbsG: round(day.carbsG, 1), + proteinG: round(day.proteinG, 1), + fibreG: round(day.fibreG, 1), + provenance: day.recorded ? 'calculated-from-recorded-entries' : 'missing-day', + })), + entries, + page: { + limit, + offset, + total: totalDays, + nextOffset: offset + pageDays < totalDays ? offset + pageDays : null, + }, + truncated: entriesResult.results.length > 1000 || waterResult.results.length > 1000, + }); + }); + + app.get('/api/mcp/foods', async (c) => { + const { limit, offset } = mcpPage(c); + const search = c.req.query('q')?.trim().slice(0, 60); + const lifecycleWhere = + c.req.query('status') === 'archived' ? 'archived_at IS NOT NULL' : 'archived_at IS NULL'; + const escaped = search?.replaceAll('%', '\\%').replaceAll('_', '\\_'); + const where = `user_id = ? AND ${lifecycleWhere}${search ? " AND name LIKE ? ESCAPE '\\\\'" : ''}`; + const binds = search ? [c.get('mcpUserId'), `%${escaped}%`] : [c.get('mcpUserId')]; + const [result, count] = await Promise.all([ + c.env.DB.prepare( + `SELECT * FROM foods WHERE ${where} + ORDER BY last_used_at DESC, name ASC LIMIT ? OFFSET ?` + ) + .bind(...binds, limit, offset) + .all(), + c.env.DB.prepare(`SELECT COUNT(*) AS total FROM foods WHERE ${where}`) + .bind(...binds) + .first<{ total: number }>(), + ]); + const total = count?.total ?? 0; + return c.json({ + schemaVersion: '1', + items: result.results.map(mapFood), + page: { limit, offset, total, nextOffset: offset + limit < total ? offset + limit : null }, + }); + }); + + app.get('/api/mcp/cycles', async (c) => { + const { limit, offset } = mcpPage(c); + const [result, count] = await Promise.all([ + c.env.DB.prepare( + `SELECT * FROM goal_cycles WHERE user_id = ? + ORDER BY start_on DESC LIMIT ? OFFSET ?` + ) + .bind(c.get('mcpUserId'), limit, offset) + .all(), + c.env.DB.prepare('SELECT COUNT(*) AS total FROM goal_cycles WHERE user_id = ?') + .bind(c.get('mcpUserId')) + .first<{ total: number }>(), + ]); + const total = count?.total ?? 0; + return c.json({ + schemaVersion: '1', + items: result.results.map(mapGoalCycle), + page: { limit, offset, total, nextOffset: offset + limit < total ? offset + limit : null }, + }); + }); +} diff --git a/src/worker/reads.ts b/src/worker/reads.ts new file mode 100644 index 0000000..75f2456 --- /dev/null +++ b/src/worker/reads.ts @@ -0,0 +1,266 @@ +import { calculateCompletedFasts, calculateNutritionTarget, round } from '../lib/recommendations'; +import type { + Dashboard, + Food, + FoodEntry, + HistoryDay, + HistoryResponse, + Medication, + MedicationCheckIn, + WaterEntry, +} from '../lib/types'; +import { DASHBOARD_FOODS_QUERY } from '../server/queries'; +import { + type FoodEntryRow, + type FoodRow, + type MedicationCheckInRow, + type MedicationHistoryRow, + type MedicationRow, + mapFood, + mapFoodEntry, + mapMedication, + mapMedicationCheckIn, + mapWater, + mapWeight, + readProfile, + type WaterRow, + type WeightRow, +} from './db'; +import { conditionalJson, dateKey, jsonError, parseRange } from './http'; +import type { App } from './types'; + +export function registerReadRoutes(app: App) { + app.get('/api/app/dashboard', async (c) => { + const range = parseRange(c); + if (!range || range.end - range.start > 48 * 60 * 60 * 1000) { + return c.json(jsonError('Choose a valid local-day range.'), 400); + } + const userId = c.get('userId'); + const [ + profile, + foodsResult, + entriesResult, + waterResult, + medicationResult, + medicationCheckInResult, + latestWeightRow, + fastingRows, + ] = await Promise.all([ + readProfile(c.env.DB, userId, c.get('userName')), + c.env.DB.prepare(DASHBOARD_FOODS_QUERY).bind(userId).all(), + c.env.DB.prepare( + `SELECT * FROM food_entries + WHERE user_id = ? AND eaten_at >= ? AND eaten_at < ? + ORDER BY eaten_at DESC` + ) + .bind(userId, range.start, range.end) + .all(), + c.env.DB.prepare( + `SELECT id, amount_ml, drank_at FROM water_entries + WHERE user_id = ? AND drank_at >= ? AND drank_at < ? + ORDER BY drank_at DESC` + ) + .bind(userId, range.start, range.end) + .all(), + c.env.DB.prepare( + `SELECT id, name, schedule, created_at, archived_at FROM medications + WHERE user_id = ? AND archived_at IS NULL + ORDER BY created_at ASC` + ) + .bind(userId) + .all(), + c.env.DB.prepare( + `SELECT id, medication_id, taken_on, taken_at FROM medication_check_ins + WHERE user_id = ? AND taken_on = ? ORDER BY taken_at DESC` + ) + .bind(userId, c.req.query('date') ?? '') + .all(), + c.env.DB.prepare( + `SELECT id, weight_kg, recorded_at FROM weight_entries + WHERE user_id = ? ORDER BY recorded_at DESC LIMIT 1` + ) + .bind(userId) + .first(), + c.env.DB.prepare( + `SELECT id, food_id, food_name, amount, unit_label, calories, carbs_g, + protein_g, fibre_g, eaten_at + FROM food_entries WHERE user_id = ? AND eaten_at >= ? + ORDER BY eaten_at ASC` + ) + .bind(userId, range.start - 31 * 24 * 60 * 60 * 1000) + .all(), + ]); + + const foods: Food[] = foodsResult.results.map(mapFood); + const entries: FoodEntry[] = entriesResult.results.map(mapFoodEntry); + const waterEntries: WaterEntry[] = waterResult.results.map(mapWater); + const medications: Medication[] = medicationResult.results.map(mapMedication); + const medicationCheckIns: MedicationCheckIn[] = + medicationCheckInResult.results.map(mapMedicationCheckIn); + const totals = entries.reduce( + (sum, entry) => ({ + calories: sum.calories + entry.calories, + carbsG: sum.carbsG + entry.carbsG, + proteinG: sum.proteinG + entry.proteinG, + fibreG: sum.fibreG + entry.fibreG, + waterMl: sum.waterMl, + }), + { + calories: 0, + carbsG: 0, + proteinG: 0, + fibreG: 0, + waterMl: waterEntries.reduce((sum, entry) => sum + entry.amountMl, 0), + } + ); + const latestWeight = latestWeightRow ? mapWeight(latestWeightRow) : null; + const timezone = c.req.query('timezone') ?? 'UTC'; + const target = calculateNutritionTarget({ + weightKg: latestWeight?.weightKg ?? null, + heightCm: profile.heightCm, + ageYears: profile.ageYears, + equationProfile: profile.equationProfile, + activityLevel: profile.activityLevel, + goal: profile.goal, + manualCalorieTarget: profile.manualCalorieTarget, + manualCalorieRange: profile.manualCalorieRange, + }); + + const dashboard: Dashboard = { + profile, + foods, + entries, + waterEntries, + medications, + medicationCheckIns, + latestWeight, + totals: { + calories: round(totals.calories), + carbsG: round(totals.carbsG, 1), + proteinG: round(totals.proteinG, 1), + fibreG: round(totals.fibreG, 1), + waterMl: totals.waterMl, + }, + target, + completedFasts: calculateCompletedFasts(fastingRows.results.map(mapFoodEntry), timezone), + date: c.req.query('date') ?? '', + timezone, + }; + return conditionalJson(c, dashboard); + }); + + app.get('/api/app/history', async (c) => { + const range = parseRange(c); + const requestedDays = Number(c.req.query('days')); + const rangeDays = requestedDays === 30 ? 30 : requestedDays === 7 ? 7 : undefined; + const timezone = c.req.query('timezone') || 'UTC'; + if (!range || range.end - range.start > 366 * 24 * 60 * 60 * 1000) { + return c.json(jsonError('Choose a history range of one year or less.'), 400); + } + const userId = c.get('userId'); + const [profile, entriesResult, waterResult, weightResult, medicationResult, priorEntry] = + await Promise.all([ + readProfile(c.env.DB, userId, c.get('userName')), + c.env.DB.prepare( + `SELECT * FROM food_entries + WHERE user_id = ? AND eaten_at >= ? AND eaten_at < ? ORDER BY eaten_at ASC` + ) + .bind(userId, range.start, range.end) + .all(), + c.env.DB.prepare( + `SELECT id, amount_ml, drank_at FROM water_entries + WHERE user_id = ? AND drank_at >= ? AND drank_at < ? ORDER BY drank_at ASC` + ) + .bind(userId, range.start, range.end) + .all(), + c.env.DB.prepare( + `SELECT id, weight_kg, recorded_at FROM weight_entries + WHERE user_id = ? AND recorded_at >= ? AND recorded_at < ? ORDER BY recorded_at ASC` + ) + .bind(userId, range.start, range.end) + .all(), + c.env.DB.prepare( + `SELECT c.id, c.medication_id, c.taken_at, m.name AS medication_name + FROM medication_check_ins c + JOIN medications m ON m.id = c.medication_id AND m.user_id = c.user_id + WHERE c.user_id = ? AND c.taken_at >= ? AND c.taken_at < ? ORDER BY c.taken_at ASC` + ) + .bind(userId, range.start, range.end) + .all(), + c.env.DB.prepare( + `SELECT * FROM food_entries + WHERE user_id = ? AND eaten_at < ? ORDER BY eaten_at DESC LIMIT 1` + ) + .bind(userId, range.start) + .first(), + ]); + const entries = entriesResult.results.map(mapFoodEntry); + const water = waterResult.results.map(mapWater); + const dayMap = new Map(); + const ensureDay = (key: string) => { + const existing = dayMap.get(key); + if (existing) return existing; + const created: HistoryDay = { + date: key, + calories: 0, + carbsG: 0, + proteinG: 0, + fibreG: 0, + waterMl: 0, + fastCount: 0, + }; + dayMap.set(key, created); + return created; + }; + + if (rangeDays) { + for (let index = 0; index < rangeDays; index += 1) { + ensureDay(dateKey(range.start + index * 24 * 60 * 60 * 1000, timezone)); + } + } + for (const entry of entries) { + const day = ensureDay(dateKey(entry.eatenAt, timezone)); + day.calories += entry.calories; + day.carbsG += entry.carbsG; + day.proteinG += entry.proteinG; + day.fibreG += entry.fibreG; + } + for (const entry of water) { + ensureDay(dateKey(entry.drankAt, timezone)).waterMl += entry.amountMl; + } + const fastingEntries = priorEntry ? [mapFoodEntry(priorEntry), ...entries] : entries; + const fastingThreshold = profile.fastingThresholdHours; + for (const fast of calculateCompletedFasts(fastingEntries, timezone)) { + if ( + fast.endAt >= range.start && + fast.endAt < range.end && + fast.durationHours >= fastingThreshold + ) { + ensureDay(dateKey(fast.endAt, timezone)).fastCount += 1; + } + } + const days = [...dayMap.values()] + .sort((a, b) => a.date.localeCompare(b.date)) + .map((day) => ({ + ...day, + calories: round(day.calories), + carbsG: round(day.carbsG, 1), + proteinG: round(day.proteinG, 1), + fibreG: round(day.fibreG, 1), + })); + + const response: HistoryResponse = { + days, + weights: weightResult.results.map(mapWeight), + entries, + medicationEvents: medicationResult.results.map((row) => ({ + id: row.id, + medicationId: row.medication_id, + medicationName: row.medication_name, + takenAt: row.taken_at, + })), + ...(rangeDays ? { rangeDays } : {}), + }; + return conditionalJson(c, response); + }); +} diff --git a/src/worker/types.ts b/src/worker/types.ts new file mode 100644 index 0000000..3dad2c2 --- /dev/null +++ b/src/worker/types.ts @@ -0,0 +1,13 @@ +import type { Hono } from 'hono'; +import type { AuthBindings } from '../server/auth'; + +export type AppBindings = AuthBindings; +export type AppVariables = { + userId: string; + userName: string; + userEmail: string; + userImage: string | null; + mcpUserId: string; +}; + +export type App = Hono<{ Bindings: AppBindings; Variables: AppVariables }>; diff --git a/tsconfig.app.json b/tsconfig.app.json index 2f6fdf2..e392f49 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -18,5 +18,5 @@ "types": ["vite/client"] }, "include": ["src/**/*.ts", "src/**/*.tsx"], - "exclude": ["src/worker.ts", "src/server/**", "src/**/*.test.ts"] + "exclude": ["src/worker.ts", "src/worker/**", "src/server/**", "src/**/*.test.ts"] } diff --git a/tsconfig.worker.json b/tsconfig.worker.json index 477efc3..925d1a9 100644 --- a/tsconfig.worker.json +++ b/tsconfig.worker.json @@ -15,6 +15,7 @@ "include": [ "worker-configuration.d.ts", "src/worker.ts", + "src/worker/**/*.ts", "src/server/**/*.ts", "src/lib/types.ts", "src/lib/recommendations.ts" From d8194ae16335421d29008272c40d46d163282ffb Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sun, 16 Aug 2026 23:33:12 +0530 Subject: [PATCH 2/3] fix: share nutrient totals so the extract does not raise duplication Closes #43 --- scripts/check-code-health.mjs | 2 +- src/lib/local-store.ts | 11 ++--------- src/lib/nutrients.ts | 13 +++++++++++++ src/pages/today/today-utils.ts | 11 ++--------- 4 files changed, 18 insertions(+), 19 deletions(-) create mode 100644 src/lib/nutrients.ts diff --git a/scripts/check-code-health.mjs b/scripts/check-code-health.mjs index 59d350f..4acf585 100644 --- a/scripts/check-code-health.mjs +++ b/scripts/check-code-health.mjs @@ -19,7 +19,7 @@ const productionPaths = [ const sourceExtensions = new Set(['.js', '.jsx', '.mjs', '.mts', '.swift', '.ts', '.tsx']); const baselines = { complexity: { violations: 30, maxCcn: 98, maxLength: 616, maxParams: 19 }, - duplication: { clones: 18, duplicatedLines: 234 }, + duplication: { clones: 18, duplicatedLines: 217 }, unused: { files: 0, exports: 5, diff --git a/src/lib/local-store.ts b/src/lib/local-store.ts index da3ac27..446ba50 100644 --- a/src/lib/local-store.ts +++ b/src/lib/local-store.ts @@ -10,6 +10,7 @@ import { } from './food-library'; import { entriesWithinRange } from './history'; import { createJournalExport } from './journal-export'; +import { sumNutrients } from './nutrients'; import { activeMedications, upsertMedicationCheckIn } from './medications'; import { calculateCompletedFasts, @@ -321,15 +322,7 @@ export function localDashboard(): Dashboard { .filter((entry) => entry.drankAt >= range.start && entry.drankAt < range.end) .sort((a, b) => b.drankAt - a.drankAt); const latestWeight = [...state.weights].sort((a, b) => b.recordedAt - a.recordedAt)[0] ?? null; - const nutrients = entries.reduce( - (total, entry) => ({ - calories: total.calories + entry.calories, - carbsG: total.carbsG + entry.carbsG, - proteinG: total.proteinG + entry.proteinG, - fibreG: total.fibreG + entry.fibreG, - }), - { calories: 0, carbsG: 0, proteinG: 0, fibreG: 0 } - ); + const nutrients = sumNutrients(entries); return { profile: state.profile, foods: foodsByLifecycle(state.foods, 'active').sort( diff --git a/src/lib/nutrients.ts b/src/lib/nutrients.ts new file mode 100644 index 0000000..88fd342 --- /dev/null +++ b/src/lib/nutrients.ts @@ -0,0 +1,13 @@ +import type { Nutrients } from './types'; + +export function sumNutrients(entries: Iterable): Nutrients { + return [...entries].reduce( + (total, entry) => ({ + calories: total.calories + entry.calories, + carbsG: total.carbsG + entry.carbsG, + proteinG: total.proteinG + entry.proteinG, + fibreG: total.fibreG + entry.fibreG, + }), + { calories: 0, carbsG: 0, proteinG: 0, fibreG: 0 } + ); +} diff --git a/src/pages/today/today-utils.ts b/src/pages/today/today-utils.ts index aca9380..58b60b4 100644 --- a/src/pages/today/today-utils.ts +++ b/src/pages/today/today-utils.ts @@ -1,4 +1,5 @@ import { waterTotal } from '../../lib/log-corrections'; +import { sumNutrients } from '../../lib/nutrients'; import type { Dashboard, FoodEntry, WaterEntry } from '../../lib/types'; export function formatTime(timestamp: number) { @@ -34,15 +35,7 @@ export function withEntries( entries: FoodEntry[], waterEntries: WaterEntry[] ): Dashboard { - const nutrients = entries.reduce( - (total, entry) => ({ - calories: total.calories + entry.calories, - carbsG: total.carbsG + entry.carbsG, - proteinG: total.proteinG + entry.proteinG, - fibreG: total.fibreG + entry.fibreG, - }), - { calories: 0, carbsG: 0, proteinG: 0, fibreG: 0 } - ); + const nutrients = sumNutrients(entries); return { ...dashboard, entries: [...entries].sort((a, b) => b.eatenAt - a.eatenAt), From 1579ead97e9c82c990b1c748784754eca4d699a2 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sun, 16 Aug 2026 23:52:04 +0530 Subject: [PATCH 3/3] fix: extract today entry sheet so lizard stays under the ratchet Move entry-sheet state, effects, and handlers into useTodayEntrySheet with module-scope helpers so useTodayPage is a composer under the 616 line cap. Lift worker route handlers out of register* wrappers and group a couple of Today panel props so complexity stays at 30 violations. --- src/pages/TodayPage.tsx | 66 +- src/pages/today/TodayDailyActions.tsx | 59 +- src/pages/today/TodayWaterPanel.tsx | 68 +- src/pages/today/useTodayEntrySheet.ts | 511 ++++++++++++++ src/pages/today/useTodayPage.ts | 389 +---------- src/worker/account.ts | 783 +++++++++++----------- src/worker/auth.ts | 223 +++---- src/worker/journal.ts | 923 +++++++++++++------------- src/worker/reads.ts | 421 ++++++------ src/worker/types.ts | 3 +- 10 files changed, 1830 insertions(+), 1616 deletions(-) create mode 100644 src/pages/today/useTodayEntrySheet.ts diff --git a/src/pages/TodayPage.tsx b/src/pages/TodayPage.tsx index ed8a578..a54cf95 100644 --- a/src/pages/TodayPage.tsx +++ b/src/pages/TodayPage.tsx @@ -195,18 +195,24 @@ export function TodayPage({ /> setWeightEditorOpen(false)} - onSaveWeight={() => void saveWeightCheckIn()} + onWeight={{ + onValueChange: setWeightValue, + onCancel: () => setWeightEditorOpen(false), + onSave: () => void saveWeightCheckIn(), + }} />

@@ -228,22 +234,28 @@ export function TodayPage({ /> void quickWater(amount)} - onBeginEdit={beginWaterEdit} - onAmountChange={setWaterAmount} - onTimeChange={setWaterTime} - onSaveEdit={() => void saveWaterEdit()} - onCancelEdit={() => setEditingWaterId(null)} - onRemove={(entry) => void removeWater(entry)} + summary={{ + waterMl: dashboard.totals.waterMl, + waterTargetMl: dashboard.profile.waterTargetMl, + waterPercent, + waterBarProgress, + waterEntries: dashboard.waterEntries, + }} + editor={{ + pendingId, + editingWaterId, + waterAmount, + waterTime, + }} + handlers={{ + onQuickWater: (amount) => void quickWater(amount), + onBeginEdit: beginWaterEdit, + onAmountChange: setWaterAmount, + onTimeChange: setWaterTime, + onSaveEdit: () => void saveWaterEdit(), + onCancelEdit: () => setEditingWaterId(null), + onRemove: (entry) => void removeWater(entry), + }} /> | null; - pendingId: string | null; - weightEditorOpen: boolean; - weightValue: string; - units: Units; - dailyActionsRef: RefObject; - weightInputRef: RefObject; + actions: { + incomplete: DailyActionKey[]; + state: ReturnType | null; + pendingId: string | null; + sectionRef: RefObject; + }; + weight: { + editorOpen: boolean; + value: string; + units: Units; + inputRef: RefObject; + }; onAction: (action: DailyActionKey) => void; - onWeightValueChange: (value: string) => void; - onCancelWeight: () => void; - onSaveWeight: () => void; + onWeight: { + onValueChange: (value: string) => void; + onCancel: () => void; + onSave: () => void; + }; }) { + const { + incomplete: incompleteActions, + state: dailyActionState, + pendingId, + sectionRef: dailyActionsRef, + } = actions; + const { + editorOpen: weightEditorOpen, + value: weightValue, + units, + inputRef: weightInputRef, + } = weight; + const { + onValueChange: onWeightValueChange, + onCancel: onCancelWeight, + onSave: onSaveWeight, + } = onWeight; if (!incompleteActions.length) return null; return ( diff --git a/src/pages/today/TodayWaterPanel.tsx b/src/pages/today/TodayWaterPanel.tsx index d822114..c1466b3 100644 --- a/src/pages/today/TodayWaterPanel.tsx +++ b/src/pages/today/TodayWaterPanel.tsx @@ -3,40 +3,44 @@ import type { WaterEntry } from '../../lib/types'; import { formatTime } from './today-utils'; export function TodayWaterPanel({ - waterMl, - waterTargetMl, - waterPercent, - waterBarProgress, - waterEntries, - pendingId, - editingWaterId, - waterAmount, - waterTime, - onQuickWater, - onBeginEdit, - onAmountChange, - onTimeChange, - onSaveEdit, - onCancelEdit, - onRemove, + summary, + editor, + handlers, }: { - waterMl: number; - waterTargetMl: number; - waterPercent: number; - waterBarProgress: number; - waterEntries: WaterEntry[]; - pendingId: string | null; - editingWaterId: string | null; - waterAmount: string; - waterTime: string; - onQuickWater: (amountMl: number) => void; - onBeginEdit: (entry: WaterEntry) => void; - onAmountChange: (value: string) => void; - onTimeChange: (value: string) => void; - onSaveEdit: () => void; - onCancelEdit: () => void; - onRemove: (entry: WaterEntry) => void; + summary: { + waterMl: number; + waterTargetMl: number; + waterPercent: number; + waterBarProgress: number; + waterEntries: WaterEntry[]; + }; + editor: { + pendingId: string | null; + editingWaterId: string | null; + waterAmount: string; + waterTime: string; + }; + handlers: { + onQuickWater: (amountMl: number) => void; + onBeginEdit: (entry: WaterEntry) => void; + onAmountChange: (value: string) => void; + onTimeChange: (value: string) => void; + onSaveEdit: () => void; + onCancelEdit: () => void; + onRemove: (entry: WaterEntry) => void; + }; }) { + const { waterMl, waterTargetMl, waterPercent, waterBarProgress, waterEntries } = summary; + const { pendingId, editingWaterId, waterAmount, waterTime } = editor; + const { + onQuickWater, + onBeginEdit, + onAmountChange, + onTimeChange, + onSaveEdit, + onCancelEdit, + onRemove, + } = handlers; return (

diff --git a/src/pages/today/useTodayEntrySheet.ts b/src/pages/today/useTodayEntrySheet.ts new file mode 100644 index 0000000..40540b8 --- /dev/null +++ b/src/pages/today/useTodayEntrySheet.ts @@ -0,0 +1,511 @@ +import { + type Dispatch, + type KeyboardEvent, + type RefObject, + type SetStateAction, + useEffect, + useRef, + useState, +} from 'react'; +import { addFoodEntry, createFood, deleteFoodEntry, updateFoodEntry } from '../../lib/api'; +import { directEntryError, foodFromDirectEntry, mergeDashboardEntry } from '../../lib/entries'; +import { normalizeFoodLabels } from '../../lib/food-context'; +import { scaleNutrients } from '../../lib/recommendations'; +import type { Dashboard, Food, FoodEntry } from '../../lib/types'; +import { type EntryDraft, toLocalInput, type UndoAction, withEntries } from './today-utils'; + +type Setter = Dispatch>; + +type EntrySheetDeps = { + dashboard: Dashboard | null; + pendingId: string | null; + setPendingId: Setter; + setDashboard: Setter; + setUndo: Setter; + setDailyAnnouncement: Setter; + setError: Setter; +}; + +function emptyNutrients() { + return { calories: 0, carbsG: 0, proteinG: 0, fibreG: 0 }; +} + +function foodUnitLabel(food: Food) { + return food.servingMode === 'per_100g' ? 'g' : food.unitLabel; +} + +function lockEntrySheet( + pageStackRef: RefObject, + entrySheetBackdropRef: RefObject, + entrySheetOpenerRef: RefObject +) { + entrySheetOpenerRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + const inertTargets = [ + document.querySelector('.app-header'), + document.querySelector('.offline-banner'), + document.querySelector('.desktop-nav'), + document.querySelector('.bottom-nav'), + ...Array.from(pageStackRef.current?.children ?? []).filter( + (element): element is HTMLElement => + element instanceof HTMLElement && element !== entrySheetBackdropRef.current + ), + ].filter((element): element is HTMLElement => Boolean(element)); + const inertState = inertTargets.map((element) => ({ + element, + wasInert: element.hasAttribute('inert'), + })); + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + for (const { element } of inertState) element.setAttribute('inert', ''); + + return () => { + document.body.style.overflow = previousOverflow; + for (const { element, wasInert } of inertState) { + if (!wasInert) element.removeAttribute('inert'); + } + const opener = entrySheetOpenerRef.current; + entrySheetOpenerRef.current = null; + window.requestAnimationFrame(() => opener?.focus()); + }; +} + +function handleEntrySheetKeyDown( + event: KeyboardEvent, + entrySheetRef: RefObject, + setEntryDraft: Setter +) { + if (event.key === 'Escape') { + event.preventDefault(); + setEntryDraft(null); + return; + } + if (event.key !== 'Tab') return; + const sheet = entrySheetRef.current; + if (!sheet) return; + const focusable = Array.from( + sheet.querySelectorAll( + 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])' + ) + ).filter((element) => !element.hasAttribute('hidden')); + if (!focusable.length) { + event.preventDefault(); + sheet.focus(); + return; + } + const first = focusable[0]; + const last = focusable.at(-1); + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } +} + +function openNewEntry( + dashboard: Dashboard | null, + setEntryError: Setter, + setEntryDraft: Setter +) { + if (!dashboard) return; + const food = dashboard.foods[0]; + setEntryError(null); + setEntryDraft({ + entryId: null, + mode: food ? 'saved' : 'direct', + foodId: food?.id ?? null, + foodName: food?.name ?? '', + amount: food?.defaultAmount ?? 1, + unitLabel: food ? foodUnitLabel(food) : 'serving', + ...(food ? scaleNutrients(food, food.servingMode, food.defaultAmount) : emptyNutrients()), + eatenAt: toLocalInput(Date.now()), + saveForLater: false, + isPackaged: food?.isPackaged ?? false, + labels: food?.labels ?? [], + }); +} + +function openEntry( + entry: FoodEntry, + dashboard: Dashboard | null, + setEntryError: Setter, + setEntryDraft: Setter +) { + const hasSavedFood = dashboard?.foods.some((food) => food.id === entry.foodId) ?? false; + setEntryError(null); + setEntryDraft({ + entryId: entry.id, + mode: hasSavedFood ? 'saved' : 'direct', + foodId: hasSavedFood ? entry.foodId : null, + foodName: entry.foodName, + amount: entry.amount, + unitLabel: entry.unitLabel, + calories: entry.calories, + carbsG: entry.carbsG, + proteinG: entry.proteinG, + fibreG: entry.fibreG, + eatenAt: toLocalInput(entry.eatenAt), + saveForLater: false, + isPackaged: entry.isPackaged ?? false, + labels: entry.labels ?? [], + }); +} + +function chooseEntryFood( + foodId: string, + dashboard: Dashboard | null, + setEntryDraft: Setter +) { + if (!dashboard) return; + const food = dashboard.foods.find((item) => item.id === foodId); + setEntryDraft((current) => + current && food + ? { + ...current, + foodId, + foodName: food.name, + amount: food.defaultAmount, + unitLabel: foodUnitLabel(food), + ...scaleNutrients(food, food.servingMode, food.defaultAmount), + isPackaged: food.isPackaged ?? false, + labels: food.labels ?? [], + } + : current + ); +} + +function applyDirectEntryMode(current: EntryDraft, dashboard: Dashboard): EntryDraft { + if (current.entryId) { + const food = dashboard.foods.find((item) => item.id === current.foodId); + const nutrients = food + ? scaleNutrients(food, food.servingMode, current.amount) + : { + calories: current.calories, + carbsG: current.carbsG, + proteinG: current.proteinG, + fibreG: current.fibreG, + }; + return { + ...current, + mode: 'direct', + foodId: null, + foodName: food?.name ?? current.foodName, + unitLabel: food?.servingMode === 'per_100g' ? 'g' : (food?.unitLabel ?? current.unitLabel), + saveForLater: false, + ...nutrients, + }; + } + return { + ...current, + mode: 'direct', + foodId: null, + foodName: '', + amount: 1, + unitLabel: 'serving', + ...emptyNutrients(), + saveForLater: false, + isPackaged: false, + labels: [], + }; +} + +function chooseEntryMode( + mode: EntryDraft['mode'], + dashboard: Dashboard | null, + setEntryError: Setter, + setEntryDraft: Setter +) { + if (!dashboard) return; + setEntryError(null); + setEntryDraft((current) => { + if (!current || current.mode === mode) return current; + if (mode === 'direct') return applyDirectEntryMode(current, dashboard); + const food = dashboard.foods[0]; + if (!food) return current; + return { + ...current, + mode, + saveForLater: false, + foodId: food.id, + foodName: food.name, + amount: food.defaultAmount, + unitLabel: foodUnitLabel(food), + ...scaleNutrients(food, food.servingMode, food.defaultAmount), + isPackaged: food.isPackaged ?? false, + labels: food.labels ?? [], + }; + }); +} + +function draftToFoodEntry( + entryDraft: EntryDraft, + food: Food | undefined, + id: string, + eatenAt: number +) { + return entryDraft.mode === 'saved' && food + ? { + id, + foodId: food.id, + foodName: food.name, + amount: entryDraft.amount, + unitLabel: foodUnitLabel(food), + ...scaleNutrients(food, food.servingMode, entryDraft.amount), + eatenAt, + isPackaged: food.isPackaged, + labels: food.labels, + } + : { + id, + foodId: null, + foodName: entryDraft.foodName, + amount: entryDraft.amount, + unitLabel: entryDraft.unitLabel, + calories: entryDraft.calories, + carbsG: entryDraft.carbsG, + proteinG: entryDraft.proteinG, + fibreG: entryDraft.fibreG, + eatenAt, + isPackaged: entryDraft.isPackaged, + labels: normalizeFoodLabels(entryDraft.labels), + }; +} + +async function saveEntry( + input: EntrySheetDeps & { + entryDraft: EntryDraft | null; + setEntryDraft: Setter; + setEntryError: Setter; + } +) { + const { + dashboard, + entryDraft, + pendingId, + setPendingId, + setDashboard, + setUndo, + setDailyAnnouncement, + setEntryDraft, + setEntryError, + } = input; + if (!dashboard || !entryDraft || pendingId) return; + const food = dashboard.foods.find((item) => item.id === entryDraft.foodId); + const eatenAt = new Date(entryDraft.eatenAt).getTime(); + if (entryDraft.mode === 'saved' && !food) { + setEntryError('Choose a saved food.'); + return; + } + if (!Number.isFinite(entryDraft.amount) || entryDraft.amount <= 0) { + setEntryError('Add an amount above zero.'); + return; + } + if (!Number.isFinite(eatenAt) || eatenAt > Date.now() + 24 * 60 * 60 * 1000) { + setEntryError('Choose a valid time.'); + return; + } + + const id = entryDraft.entryId ?? crypto.randomUUID(); + const directEntry: FoodEntry = draftToFoodEntry(entryDraft, food, id, eatenAt); + const directError = directEntry.foodId === null ? directEntryError(directEntry) : null; + if (directError) { + setEntryError(directError); + return; + } + + const reusableFood = + directEntry.foodId === null && entryDraft.saveForLater + ? foodFromDirectEntry(directEntry, crypto.randomUUID()) + : null; + const optimistic: FoodEntry = reusableFood + ? { ...directEntry, foodId: reusableFood.id, foodName: reusableFood.name } + : directEntry; + const previous = dashboard; + const nextEntries = mergeDashboardEntry( + dashboard.entries, + optimistic, + dashboard.date, + dashboard.timezone + ); + let savedFood: Food | null = null; + setPendingId(`entry-${id}`); + try { + savedFood = reusableFood ? await createFood(reusableFood) : null; + setDashboard( + withEntries( + { ...dashboard, foods: savedFood ? [savedFood, ...dashboard.foods] : dashboard.foods }, + nextEntries, + dashboard.waterEntries + ) + ); + const saved = entryDraft.entryId + ? await updateFoodEntry({ + ...optimistic, + optimistic, + }) + : await addFoodEntry({ + ...optimistic, + optimistic, + }); + setDashboard((current) => + current + ? withEntries( + current, + current.entries.map((entry) => (entry.id === id ? saved : entry)), + current.waterEntries + ) + : current + ); + if (!entryDraft.entryId) { + setUndo({ + kind: 'food', + id, + label: savedFood + ? `${optimistic.foodName} saved and logged` + : `${optimistic.foodName} logged`, + }); + setDailyAnnouncement(`${optimistic.foodName} logged.`); + } + setEntryDraft(null); + } catch (caught) { + setDashboard( + savedFood + ? withEntries( + { ...previous, foods: [savedFood, ...previous.foods] }, + previous.entries, + previous.waterEntries + ) + : previous + ); + setEntryError( + savedFood + ? 'Food was saved, but this entry could not be logged. Try logging it again.' + : caught instanceof Error + ? caught.message + : 'Entry could not be saved.' + ); + } finally { + setPendingId(null); + } +} + +async function removeEntry( + input: Pick< + EntrySheetDeps, + 'dashboard' | 'pendingId' | 'setPendingId' | 'setDashboard' | 'setUndo' | 'setError' + > & { + entryDraft: EntryDraft | null; + setEntryDraft: Setter; + } +) { + const { + dashboard, + entryDraft, + pendingId, + setPendingId, + setDashboard, + setUndo, + setError, + setEntryDraft, + } = input; + if (!dashboard || !entryDraft?.entryId || pendingId) return; + const entry = dashboard.entries.find((item) => item.id === entryDraft.entryId); + if (!entry) return; + const previous = dashboard; + setPendingId(`entry-${entry.id}`); + setDashboard( + withEntries( + dashboard, + dashboard.entries.filter((item) => item.id !== entry.id), + dashboard.waterEntries + ) + ); + setEntryDraft(null); + try { + await deleteFoodEntry(entry.id); + setUndo({ kind: 'delete-entry', entry, label: `${entry.foodName} removed` }); + } catch (caught) { + setDashboard(previous); + setError(caught instanceof Error ? caught.message : 'Entry could not be removed.'); + } finally { + setPendingId(null); + } +} + +export function useTodayEntrySheet(deps: EntrySheetDeps) { + const { + dashboard, + pendingId, + setPendingId, + setDashboard, + setUndo, + setDailyAnnouncement, + setError, + } = deps; + const [entryDraft, setEntryDraft] = useState(null); + const [entryError, setEntryError] = useState(null); + const entryFoodSelectRef = useRef(null); + const entryNameInputRef = useRef(null); + const entrySheetRef = useRef(null); + const entrySheetBackdropRef = useRef(null); + const entrySheetOpenerRef = useRef(null); + const pageStackRef = useRef(null); + const entrySheetOpen = entryDraft !== null; + + useEffect(() => { + if (!entrySheetOpen) return; + return lockEntrySheet(pageStackRef, entrySheetBackdropRef, entrySheetOpenerRef); + }, [entrySheetOpen]); + + useEffect(() => { + if (!entrySheetOpen) return; + if (entryDraft?.mode === 'direct') entryNameInputRef.current?.focus(); + else entryFoodSelectRef.current?.focus(); + }, [entryDraft?.mode, entrySheetOpen]); + + return { + entryDraft, + setEntryDraft, + entryError, + setEntryError, + entryFoodSelectRef, + entryNameInputRef, + entrySheetRef, + entrySheetBackdropRef, + pageStackRef, + handleEntrySheetKeyDown: (event: KeyboardEvent) => { + handleEntrySheetKeyDown(event, entrySheetRef, setEntryDraft); + }, + openNewEntry: () => openNewEntry(dashboard, setEntryError, setEntryDraft), + openEntry: (entry: FoodEntry) => openEntry(entry, dashboard, setEntryError, setEntryDraft), + chooseEntryFood: (foodId: string) => chooseEntryFood(foodId, dashboard, setEntryDraft), + chooseEntryMode: (mode: EntryDraft['mode']) => + chooseEntryMode(mode, dashboard, setEntryError, setEntryDraft), + saveEntry: () => + saveEntry({ + dashboard, + pendingId, + setPendingId, + setDashboard, + setUndo, + setDailyAnnouncement, + setError, + entryDraft, + setEntryDraft, + setEntryError, + }), + removeEntry: () => + removeEntry({ + dashboard, + pendingId, + setPendingId, + setDashboard, + setUndo, + setError, + entryDraft, + setEntryDraft, + }), + }; +} diff --git a/src/pages/today/useTodayPage.ts b/src/pages/today/useTodayPage.ts index 5f2e934..6ee152b 100644 --- a/src/pages/today/useTodayPage.ts +++ b/src/pages/today/useTodayPage.ts @@ -1,24 +1,20 @@ -import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { addFoodEntry, addMedicationCheckIn, addWater, addWeight, archiveMedication, - createFood, deleteFoodEntry, deleteMedicationCheckIn, deleteWater, getDashboard, saveMedication, - updateFoodEntry, updateMedication, updateWater, } from '../../lib/api'; import { enabledDailyActions } from '../../lib/daily-action-preferences'; import { type DailyActionKey, getDailyActionState } from '../../lib/daily-actions'; -import { directEntryError, foodFromDirectEntry, mergeDashboardEntry } from '../../lib/entries'; -import { normalizeFoodLabels } from '../../lib/food-context'; import { computeMacroCompletion } from '../../lib/macro-completion'; import { calculateGymGuidance, @@ -34,15 +30,14 @@ import type { WaterEntry, WeightEntry, } from '../../lib/types'; -import { type EntryDraft, toLocalInput, type UndoAction, withEntries } from './today-utils'; +import { toLocalInput, type UndoAction, withEntries } from './today-utils'; +import { useTodayEntrySheet } from './useTodayEntrySheet'; export function useTodayPage({ cloudRevision }: { cloudRevision: number }) { const [dashboard, setDashboard] = useState(null); const [error, setError] = useState(null); const [pendingId, setPendingId] = useState(null); const [undo, setUndo] = useState(null); - const [entryDraft, setEntryDraft] = useState(null); - const [entryError, setEntryError] = useState(null); const [medicationEditorOpen, setMedicationEditorOpen] = useState(false); const [medicationName, setMedicationName] = useState(''); const [medicationSchedule, setMedicationSchedule] = useState('morning'); @@ -53,16 +48,18 @@ export function useTodayPage({ cloudRevision }: { cloudRevision: number }) { const [editingWaterId, setEditingWaterId] = useState(null); const [waterAmount, setWaterAmount] = useState(''); const [waterTime, setWaterTime] = useState(''); - const entryFoodSelectRef = useRef(null); - const entryNameInputRef = useRef(null); - const entrySheetRef = useRef(null); - const entrySheetBackdropRef = useRef(null); - const entrySheetOpenerRef = useRef(null); - const pageStackRef = useRef(null); const weightInputRef = useRef(null); const dailyActionsRef = useRef(null); const previousIncompleteRef = useRef(null); - const entrySheetOpen = entryDraft !== null; + const entrySheet = useTodayEntrySheet({ + dashboard, + pendingId, + setPendingId, + setDashboard, + setUndo, + setDailyAnnouncement, + setError, + }); const load = useCallback(async () => { setError(null); @@ -83,75 +80,6 @@ export function useTodayPage({ cloudRevision }: { cloudRevision: number }) { return () => window.clearTimeout(timer); }, [undo]); - useEffect(() => { - if (!entrySheetOpen) return; - entrySheetOpenerRef.current = - document.activeElement instanceof HTMLElement ? document.activeElement : null; - const inertTargets = [ - document.querySelector('.app-header'), - document.querySelector('.offline-banner'), - document.querySelector('.desktop-nav'), - document.querySelector('.bottom-nav'), - ...Array.from(pageStackRef.current?.children ?? []).filter( - (element): element is HTMLElement => - element instanceof HTMLElement && element !== entrySheetBackdropRef.current - ), - ].filter((element): element is HTMLElement => Boolean(element)); - const inertState = inertTargets.map((element) => ({ - element, - wasInert: element.hasAttribute('inert'), - })); - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - for (const { element } of inertState) element.setAttribute('inert', ''); - - return () => { - document.body.style.overflow = previousOverflow; - for (const { element, wasInert } of inertState) { - if (!wasInert) element.removeAttribute('inert'); - } - const opener = entrySheetOpenerRef.current; - entrySheetOpenerRef.current = null; - window.requestAnimationFrame(() => opener?.focus()); - }; - }, [entrySheetOpen]); - - useEffect(() => { - if (!entrySheetOpen) return; - if (entryDraft?.mode === 'direct') entryNameInputRef.current?.focus(); - else entryFoodSelectRef.current?.focus(); - }, [entryDraft?.mode, entrySheetOpen]); - - const handleEntrySheetKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - setEntryDraft(null); - return; - } - if (event.key !== 'Tab') return; - const sheet = entrySheetRef.current; - if (!sheet) return; - const focusable = Array.from( - sheet.querySelectorAll( - 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])' - ) - ).filter((element) => !element.hasAttribute('hidden')); - if (!focusable.length) { - event.preventDefault(); - sheet.focus(); - return; - } - const first = focusable[0]; - const last = focusable.at(-1); - if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last?.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first.focus(); - } - }; - useEffect(() => { if (!weightEditorOpen) return; window.requestAnimationFrame(() => weightInputRef.current?.focus()); @@ -485,280 +413,6 @@ export function useTodayPage({ cloudRevision }: { cloudRevision: number }) { } }; - const openNewEntry = () => { - if (!dashboard) return; - const food = dashboard.foods[0]; - setEntryError(null); - setEntryDraft({ - entryId: null, - mode: food ? 'saved' : 'direct', - foodId: food?.id ?? null, - foodName: food?.name ?? '', - amount: food?.defaultAmount ?? 1, - unitLabel: food ? (food.servingMode === 'per_100g' ? 'g' : food.unitLabel) : 'serving', - ...(food - ? scaleNutrients(food, food.servingMode, food.defaultAmount) - : { calories: 0, carbsG: 0, proteinG: 0, fibreG: 0 }), - eatenAt: toLocalInput(Date.now()), - saveForLater: false, - isPackaged: food?.isPackaged ?? false, - labels: food?.labels ?? [], - }); - }; - - const openEntry = (entry: FoodEntry) => { - const hasSavedFood = dashboard?.foods.some((food) => food.id === entry.foodId) ?? false; - setEntryError(null); - setEntryDraft({ - entryId: entry.id, - mode: hasSavedFood ? 'saved' : 'direct', - foodId: hasSavedFood ? entry.foodId : null, - foodName: entry.foodName, - amount: entry.amount, - unitLabel: entry.unitLabel, - calories: entry.calories, - carbsG: entry.carbsG, - proteinG: entry.proteinG, - fibreG: entry.fibreG, - eatenAt: toLocalInput(entry.eatenAt), - saveForLater: false, - isPackaged: entry.isPackaged ?? false, - labels: entry.labels ?? [], - }); - }; - - const chooseEntryFood = (foodId: string) => { - if (!dashboard) return; - const food = dashboard.foods.find((item) => item.id === foodId); - setEntryDraft((current) => - current && food - ? { - ...current, - foodId, - foodName: food.name, - amount: food.defaultAmount, - unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, - ...scaleNutrients(food, food.servingMode, food.defaultAmount), - isPackaged: food.isPackaged ?? false, - labels: food.labels ?? [], - } - : current - ); - }; - - const chooseEntryMode = (mode: EntryDraft['mode']) => { - if (!dashboard) return; - setEntryError(null); - setEntryDraft((current) => { - if (!current || current.mode === mode) return current; - if (mode === 'direct') { - if (current.entryId) { - const food = dashboard.foods.find((item) => item.id === current.foodId); - const nutrients = food - ? scaleNutrients(food, food.servingMode, current.amount) - : { - calories: current.calories, - carbsG: current.carbsG, - proteinG: current.proteinG, - fibreG: current.fibreG, - }; - return { - ...current, - mode, - foodId: null, - foodName: food?.name ?? current.foodName, - unitLabel: - food?.servingMode === 'per_100g' ? 'g' : (food?.unitLabel ?? current.unitLabel), - saveForLater: false, - ...nutrients, - }; - } - return { - ...current, - mode, - foodId: null, - foodName: '', - amount: 1, - unitLabel: 'serving', - calories: 0, - carbsG: 0, - proteinG: 0, - fibreG: 0, - saveForLater: false, - isPackaged: false, - labels: [], - }; - } - - const food = dashboard.foods[0]; - if (!food) return current; - return { - ...current, - mode, - saveForLater: false, - foodId: food.id, - foodName: food.name, - amount: food.defaultAmount, - unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, - ...scaleNutrients(food, food.servingMode, food.defaultAmount), - isPackaged: food.isPackaged ?? false, - labels: food.labels ?? [], - }; - }); - }; - - const saveEntry = async () => { - if (!dashboard || !entryDraft || pendingId) return; - const food = dashboard.foods.find((item) => item.id === entryDraft.foodId); - const eatenAt = new Date(entryDraft.eatenAt).getTime(); - if (entryDraft.mode === 'saved' && !food) { - setEntryError('Choose a saved food.'); - return; - } - if (!Number.isFinite(entryDraft.amount) || entryDraft.amount <= 0) { - setEntryError('Add an amount above zero.'); - return; - } - if (!Number.isFinite(eatenAt) || eatenAt > Date.now() + 24 * 60 * 60 * 1000) { - setEntryError('Choose a valid time.'); - return; - } - - const id = entryDraft.entryId ?? crypto.randomUUID(); - const directEntry: FoodEntry = - entryDraft.mode === 'saved' && food - ? { - id, - foodId: food.id, - foodName: food.name, - amount: entryDraft.amount, - unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, - ...scaleNutrients(food, food.servingMode, entryDraft.amount), - eatenAt, - isPackaged: food.isPackaged, - labels: food.labels, - } - : { - id, - foodId: null, - foodName: entryDraft.foodName, - amount: entryDraft.amount, - unitLabel: entryDraft.unitLabel, - calories: entryDraft.calories, - carbsG: entryDraft.carbsG, - proteinG: entryDraft.proteinG, - fibreG: entryDraft.fibreG, - eatenAt, - isPackaged: entryDraft.isPackaged, - labels: normalizeFoodLabels(entryDraft.labels), - }; - const directError = directEntry.foodId === null ? directEntryError(directEntry) : null; - if (directError) { - setEntryError(directError); - return; - } - - const reusableFood = - directEntry.foodId === null && entryDraft.saveForLater - ? foodFromDirectEntry(directEntry, crypto.randomUUID()) - : null; - const optimistic: FoodEntry = reusableFood - ? { ...directEntry, foodId: reusableFood.id, foodName: reusableFood.name } - : directEntry; - const previous = dashboard; - const nextEntries = mergeDashboardEntry( - dashboard.entries, - optimistic, - dashboard.date, - dashboard.timezone - ); - let savedFood: Food | null = null; - setPendingId(`entry-${id}`); - try { - savedFood = reusableFood ? await createFood(reusableFood) : null; - setDashboard( - withEntries( - { ...dashboard, foods: savedFood ? [savedFood, ...dashboard.foods] : dashboard.foods }, - nextEntries, - dashboard.waterEntries - ) - ); - const saved = entryDraft.entryId - ? await updateFoodEntry({ - ...optimistic, - optimistic, - }) - : await addFoodEntry({ - ...optimistic, - optimistic, - }); - setDashboard((current) => - current - ? withEntries( - current, - current.entries.map((entry) => (entry.id === id ? saved : entry)), - current.waterEntries - ) - : current - ); - if (!entryDraft.entryId) { - setUndo({ - kind: 'food', - id, - label: savedFood - ? `${optimistic.foodName} saved and logged` - : `${optimistic.foodName} logged`, - }); - setDailyAnnouncement(`${optimistic.foodName} logged.`); - } - setEntryDraft(null); - } catch (caught) { - setDashboard( - savedFood - ? withEntries( - { ...previous, foods: [savedFood, ...previous.foods] }, - previous.entries, - previous.waterEntries - ) - : previous - ); - setEntryError( - savedFood - ? 'Food was saved, but this entry could not be logged. Try logging it again.' - : caught instanceof Error - ? caught.message - : 'Entry could not be saved.' - ); - } finally { - setPendingId(null); - } - }; - - const removeEntry = async () => { - if (!dashboard || !entryDraft?.entryId || pendingId) return; - const entry = dashboard.entries.find((item) => item.id === entryDraft.entryId); - if (!entry) return; - const previous = dashboard; - setPendingId(`entry-${entry.id}`); - setDashboard( - withEntries( - dashboard, - dashboard.entries.filter((item) => item.id !== entry.id), - dashboard.waterEntries - ) - ); - setEntryDraft(null); - try { - await deleteFoodEntry(entry.id); - setUndo({ kind: 'delete-entry', entry, label: `${entry.foodName} removed` }); - } catch (caught) { - setDashboard(previous); - setError(caught instanceof Error ? caught.message : 'Entry could not be removed.'); - } finally { - setPendingId(null); - } - }; - const saveWeightCheckIn = async () => { if (!dashboard || pendingId) return; let weightKg = Number(weightValue); @@ -803,7 +457,7 @@ export function useTodayPage({ cloudRevision }: { cloudRevision: number }) { return; } if (action === 'food') { - openNewEntry(); + entrySheet.openNewEntry(); return; } if (action === 'water') { @@ -830,10 +484,6 @@ export function useTodayPage({ cloudRevision }: { cloudRevision: number }) { setError, pendingId, undo, - entryDraft, - setEntryDraft, - entryError, - setEntryError, medicationEditorOpen, setMedicationEditorOpen, medicationName, @@ -853,15 +503,9 @@ export function useTodayPage({ cloudRevision }: { cloudRevision: number }) { setWaterAmount, waterTime, setWaterTime, - entryFoodSelectRef, - entryNameInputRef, - entrySheetRef, - entrySheetBackdropRef, - pageStackRef, weightInputRef, dailyActionsRef, load, - handleEntrySheetKeyDown, gym, sleep, latestFast, @@ -878,13 +522,8 @@ export function useTodayPage({ cloudRevision }: { cloudRevision: number }) { removeMedication, toggleMedication, undoLast, - openNewEntry, - openEntry, - chooseEntryFood, - chooseEntryMode, - saveEntry, - removeEntry, saveWeightCheckIn, handleDailyAction, + ...entrySheet, }; } diff --git a/src/worker/account.ts b/src/worker/account.ts index cf25b0d..cfef597 100644 --- a/src/worker/account.ts +++ b/src/worker/account.ts @@ -35,7 +35,7 @@ import { requiredText, validDateKey, } from './http'; -import type { App } from './types'; +import type { App, AppContext } from './types'; type ReadTokenRow = { id: string; @@ -44,224 +44,181 @@ type ReadTokenRow = { created_at: number; }; -export function registerAccountRoutes(app: App) { - app.get('/api/app/mcp-tokens', async (c) => { - const result = await c.env.DB.prepare( - `SELECT id, name, token_hint, created_at FROM mcp_read_tokens - WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 20` - ) - .bind(c.get('userId')) - .all(); - return c.json( - result.results.map((row) => ({ - id: row.id, - name: row.name, - tokenHint: row.token_hint, - createdAt: row.created_at, - })) - ); - }); +async function getMcpTokens(c: AppContext) { + const result = await c.env.DB.prepare( + `SELECT id, name, token_hint, created_at FROM mcp_read_tokens + WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 20` + ) + .bind(c.get('userId')) + .all(); + return c.json( + result.results.map((row) => ({ + id: row.id, + name: row.name, + tokenHint: row.token_hint, + createdAt: row.created_at, + })) + ); +} - app.post('/api/app/mcp-tokens', async (c) => { - const body = await c.req - .json>() - .catch((): Record => ({})); - const name = optionalText(body.name, 50) ?? 'ChatGPT read access'; - const token = createReadToken(); - const id = crypto.randomUUID(); - const createdAt = Date.now(); - await c.env.DB.prepare( - `INSERT INTO mcp_read_tokens - (id, user_id, name, token_hash, token_hint, created_at, revoked_at) - VALUES (?, ?, ?, ?, ?, ?, NULL)` - ) - .bind(id, c.get('userId'), name, await hashReadToken(token), token.slice(0, 24), createdAt) - .run(); - return c.json({ id, name, token, tokenHint: token.slice(0, 24), createdAt }, 201); - }); +async function postMcpTokens(c: AppContext) { + const body = await c.req + .json>() + .catch((): Record => ({})); + const name = optionalText(body.name, 50) ?? 'ChatGPT read access'; + const token = createReadToken(); + const id = crypto.randomUUID(); + const createdAt = Date.now(); + await c.env.DB.prepare( + `INSERT INTO mcp_read_tokens + (id, user_id, name, token_hash, token_hint, created_at, revoked_at) + VALUES (?, ?, ?, ?, ?, ?, NULL)` + ) + .bind(id, c.get('userId'), name, await hashReadToken(token), token.slice(0, 24), createdAt) + .run(); + return c.json({ id, name, token, tokenHint: token.slice(0, 24), createdAt }, 201); +} - app.delete('/api/app/mcp-tokens/:id', async (c) => { - const result = await c.env.DB.prepare( - `UPDATE mcp_read_tokens SET revoked_at = ? - WHERE id = ? AND user_id = ? AND revoked_at IS NULL` - ) - .bind(Date.now(), c.req.param('id'), c.get('userId')) - .run(); - return result.meta.changes - ? c.body(null, 204) - : c.json({ message: 'Read token not found.' }, 404); - }); +async function deleteMcpTokensId(c: AppContext) { + const result = await c.env.DB.prepare( + `UPDATE mcp_read_tokens SET revoked_at = ? + WHERE id = ? AND user_id = ? AND revoked_at IS NULL` + ) + .bind(Date.now(), c.req.param('id'), c.get('userId')) + .run(); + return result.meta.changes + ? c.body(null, 204) + : c.json({ message: 'Read token not found.' }, 404); +} - app.get('/api/app/profile', async (c) => { - const profile = await readProfile(c.env.DB, c.get('userId'), c.get('userName')); - return conditionalJson(c, profile); - }); +async function getProfile(c: AppContext) { + const profile = await readProfile(c.env.DB, c.get('userId'), c.get('userName')); + return conditionalJson(c, profile); +} - app.get('/api/app/bootstrap', async (c) => { - const userId = c.get('userId'); - const profile = await readProfile(c.env.DB, userId, c.get('userName')); - return c.json({ - session: { - user: { - id: userId, - name: c.get('userName'), - email: c.get('userEmail'), - image: c.get('userImage'), - }, +async function getBootstrap(c: AppContext) { + const userId = c.get('userId'); + const profile = await readProfile(c.env.DB, userId, c.get('userName')); + return c.json({ + session: { + user: { + id: userId, + name: c.get('userName'), + email: c.get('userEmail'), + image: c.get('userImage'), }, - profile, - }); + }, + profile, }); +} - app.put('/api/app/profile', async (c) => { - const body = await c.req.json>().catch(() => null); - if (!body) return c.json(jsonError('Profile details are required.'), 400); +async function putProfile(c: AppContext) { + const body = await c.req.json>().catch(() => null); + if (!body) return c.json(jsonError('Profile details are required.'), 400); - const displayName = requiredText(body.displayName, 60); - const units = - body.units === 'imperial' ? 'imperial' : body.units === 'metric' ? 'metric' : null; - const ageYears = finiteNumber(body.ageYears, 18, 120); - const heightCm = finiteNumber(body.heightCm, 100, 250); - const equationProfile = ['female', 'male', 'none'].includes(String(body.equationProfile)) - ? (body.equationProfile as EquationProfile) - : null; - const activityLevel = ['sedentary', 'light', 'moderate', 'very'].includes( - String(body.activityLevel) - ) - ? (body.activityLevel as ActivityLevel) - : null; - const goal = ['lose_gentle', 'lose_steady', 'maintain', 'gain_gentle'].includes( - String(body.goal) - ) - ? (body.goal as Goal) + const displayName = requiredText(body.displayName, 60); + const units = body.units === 'imperial' ? 'imperial' : body.units === 'metric' ? 'metric' : null; + const ageYears = finiteNumber(body.ageYears, 18, 120); + const heightCm = finiteNumber(body.heightCm, 100, 250); + const equationProfile = ['female', 'male', 'none'].includes(String(body.equationProfile)) + ? (body.equationProfile as EquationProfile) + : null; + const activityLevel = ['sedentary', 'light', 'moderate', 'very'].includes( + String(body.activityLevel) + ) + ? (body.activityLevel as ActivityLevel) + : null; + const goal = ['lose_gentle', 'lose_steady', 'maintain', 'gain_gentle'].includes(String(body.goal)) + ? (body.goal as Goal) + : null; + const targetWeightKg = + body.targetWeightKg === null ? null : finiteNumber(body.targetWeightKg, 30, 400); + const initialWeightKg = + body.initialWeightKg === undefined ? null : finiteNumber(body.initialWeightKg, 30, 400); + const manualTarget = + body.manualCalorieTarget === null || body.manualCalorieTarget === undefined + ? null + : finiteNumber(body.manualCalorieTarget, 800, 6000); + const manualRangeInput = Array.isArray(body.manualCalorieRange) ? body.manualCalorieRange : null; + const manualRangeMin = manualRangeInput ? finiteNumber(manualRangeInput[0], 800, 6000) : null; + const manualRangeMax = manualRangeInput ? finiteNumber(manualRangeInput[1], 800, 6000) : null; + const hasInvalidManualRange = + manualRangeInput !== null && + (manualRangeMin === null || manualRangeMax === null || manualRangeMin > manualRangeMax); + const sleepHours = finiteNumber(body.sleepHours, 5, 12); + const waterTargetMl = finiteNumber(body.waterTargetMl, 250, 10000); + const fastingThreshold = [12, 14, 16].includes(Number(body.fastingThresholdHours)) + ? Number(body.fastingThresholdHours) + : null; + const wakeTime = + typeof body.wakeTime === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(body.wakeTime) + ? body.wakeTime : null; - const targetWeightKg = - body.targetWeightKg === null ? null : finiteNumber(body.targetWeightKg, 30, 400); - const initialWeightKg = - body.initialWeightKg === undefined ? null : finiteNumber(body.initialWeightKg, 30, 400); - const manualTarget = - body.manualCalorieTarget === null || body.manualCalorieTarget === undefined - ? null - : finiteNumber(body.manualCalorieTarget, 800, 6000); - const manualRangeInput = Array.isArray(body.manualCalorieRange) - ? body.manualCalorieRange - : null; - const manualRangeMin = manualRangeInput ? finiteNumber(manualRangeInput[0], 800, 6000) : null; - const manualRangeMax = manualRangeInput ? finiteNumber(manualRangeInput[1], 800, 6000) : null; - const hasInvalidManualRange = - manualRangeInput !== null && - (manualRangeMin === null || manualRangeMax === null || manualRangeMin > manualRangeMax); - const sleepHours = finiteNumber(body.sleepHours, 5, 12); - const waterTargetMl = finiteNumber(body.waterTargetMl, 250, 10000); - const fastingThreshold = [12, 14, 16].includes(Number(body.fastingThresholdHours)) - ? Number(body.fastingThresholdHours) - : null; - const wakeTime = - typeof body.wakeTime === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(body.wakeTime) - ? body.wakeTime - : null; - const dailyActionOrder = normalizeDailyActionOrder( - Array.isArray(body.dailyActionOrder) ? body.dailyActionOrder : [] - ); - const dailyActionHidden = normalizeDailyActionHidden( - Array.isArray(body.dailyActionHidden) ? body.dailyActionHidden : [] - ); - const cycleDate = validDateKey(body.cycleDate) ?? dateKey(Date.now(), 'UTC'); - - if ( - !displayName || - !units || - ageYears === null || - heightCm === null || - !equationProfile || - !activityLevel || - !goal || - sleepHours === null || - waterTargetMl === null || - hasInvalidManualRange || - fastingThreshold === null || - !wakeTime - ) { - return c.json(jsonError('Check the highlighted profile details and try again.'), 400); - } + const dailyActionOrder = normalizeDailyActionOrder( + Array.isArray(body.dailyActionOrder) ? body.dailyActionOrder : [] + ); + const dailyActionHidden = normalizeDailyActionHidden( + Array.isArray(body.dailyActionHidden) ? body.dailyActionHidden : [] + ); + const cycleDate = validDateKey(body.cycleDate) ?? dateKey(Date.now(), 'UTC'); - const now = Date.now(); - const userId = c.get('userId'); - const genderIdentity = optionalText(body.genderIdentity, 40); - const onboardingComplete = body.onboardingComplete === false ? 0 : 1; - const manualRange = - manualRangeMin !== null && manualRangeMax !== null - ? ([Math.round(manualRangeMin), Math.round(manualRangeMax)] as const) - : null; + if ( + !displayName || + !units || + ageYears === null || + heightCm === null || + !equationProfile || + !activityLevel || + !goal || + sleepHours === null || + waterTargetMl === null || + hasInvalidManualRange || + fastingThreshold === null || + !wakeTime + ) { + return c.json(jsonError('Check the highlighted profile details and try again.'), 400); + } - const statements = [ - c.env.DB.prepare( - `INSERT INTO profiles ( - user_id, display_name, units, age_years, gender_identity, equation_profile, - height_cm, activity_level, goal, target_weight_kg, manual_calorie_target, - manual_calorie_min, manual_calorie_max, - wake_time, sleep_hours, fasting_threshold_hours, water_target_ml, - daily_action_order, daily_action_hidden, onboarding_complete, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(user_id) DO UPDATE SET - display_name = excluded.display_name, - units = excluded.units, - age_years = excluded.age_years, - gender_identity = excluded.gender_identity, - equation_profile = excluded.equation_profile, - height_cm = excluded.height_cm, - activity_level = excluded.activity_level, - goal = excluded.goal, - target_weight_kg = excluded.target_weight_kg, - manual_calorie_target = excluded.manual_calorie_target, - manual_calorie_min = excluded.manual_calorie_min, - manual_calorie_max = excluded.manual_calorie_max, - wake_time = excluded.wake_time, - sleep_hours = excluded.sleep_hours, - fasting_threshold_hours = excluded.fasting_threshold_hours, - water_target_ml = excluded.water_target_ml, - daily_action_order = excluded.daily_action_order, - daily_action_hidden = excluded.daily_action_hidden, - onboarding_complete = excluded.onboarding_complete, - updated_at = excluded.updated_at` - ).bind( - userId, - displayName, - units, - ageYears, - genderIdentity, - equationProfile, - heightCm, - activityLevel, - goal, - targetWeightKg, - manualRange ? Math.round((manualRange[0] + manualRange[1]) / 2) : manualTarget, - manualRange?.[0] ?? null, - manualRange?.[1] ?? null, - wakeTime, - sleepHours, - fastingThreshold, - Math.round(waterTargetMl), - dailyActionOrder.join(','), - dailyActionHidden.join(','), - onboardingComplete, - now, - now - ), - ]; - - const initialWeightId = optionalText(body.initialWeightId, 80); - if (initialWeightKg !== null && initialWeightId) { - statements.push( - c.env.DB.prepare( - `INSERT OR IGNORE INTO weight_entries - (id, user_id, weight_kg, recorded_at, created_at) - VALUES (?, ?, ?, ?, ?)` - ).bind(initialWeightId, userId, initialWeightKg, now, now) - ); - } + const now = Date.now(); + const userId = c.get('userId'); + const genderIdentity = optionalText(body.genderIdentity, 40); + const onboardingComplete = body.onboardingComplete === false ? 0 : 1; + const manualRange = + manualRangeMin !== null && manualRangeMax !== null + ? ([Math.round(manualRangeMin), Math.round(manualRangeMax)] as const) + : null; - const nextProfile: UserProfile = { + const statements = [ + c.env.DB.prepare( + `INSERT INTO profiles ( + user_id, display_name, units, age_years, gender_identity, equation_profile, + height_cm, activity_level, goal, target_weight_kg, manual_calorie_target, + manual_calorie_min, manual_calorie_max, + wake_time, sleep_hours, fasting_threshold_hours, water_target_ml, + daily_action_order, daily_action_hidden, onboarding_complete, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + display_name = excluded.display_name, + units = excluded.units, + age_years = excluded.age_years, + gender_identity = excluded.gender_identity, + equation_profile = excluded.equation_profile, + height_cm = excluded.height_cm, + activity_level = excluded.activity_level, + goal = excluded.goal, + target_weight_kg = excluded.target_weight_kg, + manual_calorie_target = excluded.manual_calorie_target, + manual_calorie_min = excluded.manual_calorie_min, + manual_calorie_max = excluded.manual_calorie_max, + wake_time = excluded.wake_time, + sleep_hours = excluded.sleep_hours, + fasting_threshold_hours = excluded.fasting_threshold_hours, + water_target_ml = excluded.water_target_ml, + daily_action_order = excluded.daily_action_order, + daily_action_hidden = excluded.daily_action_hidden, + onboarding_complete = excluded.onboarding_complete, + updated_at = excluded.updated_at` + ).bind( userId, displayName, units, @@ -272,200 +229,248 @@ export function registerAccountRoutes(app: App) { activityLevel, goal, targetWeightKg, - manualCalorieTarget: manualRange - ? Math.round((manualRange[0] + manualRange[1]) / 2) - : manualTarget, - manualCalorieRange: manualRange ? [manualRange[0], manualRange[1]] : null, + manualRange ? Math.round((manualRange[0] + manualRange[1]) / 2) : manualTarget, + manualRange?.[0] ?? null, + manualRange?.[1] ?? null, wakeTime, sleepHours, - fastingThresholdHours: fastingThreshold as 12 | 14 | 16, - waterTargetMl: Math.round(waterTargetMl), - dailyActionOrder, - dailyActionHidden, - onboardingComplete: Boolean(onboardingComplete), - }; - const target = initialWeightKg - ? calculateNutritionTarget({ - weightKg: initialWeightKg, - heightCm, - ageYears, - equationProfile, - activityLevel, - goal, - manualCalorieTarget: nextProfile.manualCalorieTarget, - manualCalorieRange: nextProfile.manualCalorieRange, - }) - : await currentTarget(c.env.DB, nextProfile, userId); - const activeCycle = await c.env.DB.prepare( - 'SELECT * FROM goal_cycles WHERE user_id = ? AND end_on IS NULL' - ) - .bind(userId) - .first(); - if (!activeCycle) { - statements.push( - cycleInsertStatement(c.env.DB, { - id: crypto.randomUUID(), - userId, - goal, - startOn: cycleDate, - calorieRange: target.calorieRange, - proteinRangeG: target.proteinRangeG, - now, - }) - ); - } else if (activeCycle.cycle === cycleFromGoal(goal)) { - statements.push( - c.env.DB.prepare( - `UPDATE goal_cycles SET goal = ?, calorie_range_low = ?, calorie_range_high = ?, - protein_range_low = ?, protein_range_high = ?, updated_at = ? - WHERE id = ? AND user_id = ? AND end_on IS NULL` - ).bind( - goal, - target.calorieRange?.[0] ?? null, - target.calorieRange?.[1] ?? null, - target.proteinRangeG?.[0] ?? null, - target.proteinRangeG?.[1] ?? null, - now, - activeCycle.id, - userId - ) - ); - } else { - statements.push( - c.env.DB.prepare( - 'UPDATE goal_cycles SET end_on = ?, updated_at = ? WHERE id = ? AND user_id = ? AND end_on IS NULL' - ).bind(cycleDate, now, activeCycle.id, userId), - cycleInsertStatement(c.env.DB, { - id: crypto.randomUUID(), - userId, - goal, - startOn: cycleDate, - calorieRange: target.calorieRange, - proteinRangeG: target.proteinRangeG, - now, - }) - ); - } - await c.env.DB.batch(statements); - return c.json(await readProfile(c.env.DB, userId, displayName)); - }); + fastingThreshold, + Math.round(waterTargetMl), + dailyActionOrder.join(','), + dailyActionHidden.join(','), + onboardingComplete, + now, + now + ), + ]; - app.get('/api/app/cycles', async (c) => { - const userId = c.get('userId'); - const today = validDateKey(c.req.query('date')); - if (!today) return c.json(jsonError('Choose a valid local date.'), 400); - let result = await c.env.DB.prepare( - 'SELECT * FROM goal_cycles WHERE user_id = ? ORDER BY start_on DESC LIMIT 20' - ) - .bind(userId) - .all(); - if (!result.results.some((row) => row.end_on === null)) { - const profile = await readProfile(c.env.DB, userId, c.get('userName')); - const target = await currentTarget(c.env.DB, profile, userId); - await cycleInsertStatement(c.env.DB, { + const initialWeightId = optionalText(body.initialWeightId, 80); + if (initialWeightKg !== null && initialWeightId) { + statements.push( + c.env.DB.prepare( + `INSERT OR IGNORE INTO weight_entries + (id, user_id, weight_kg, recorded_at, created_at) + VALUES (?, ?, ?, ?, ?)` + ).bind(initialWeightId, userId, initialWeightKg, now, now) + ); + } + + const nextProfile: UserProfile = { + userId, + displayName, + units, + ageYears, + genderIdentity, + equationProfile, + heightCm, + activityLevel, + goal, + targetWeightKg, + manualCalorieTarget: manualRange + ? Math.round((manualRange[0] + manualRange[1]) / 2) + : manualTarget, + manualCalorieRange: manualRange ? [manualRange[0], manualRange[1]] : null, + wakeTime, + sleepHours, + fastingThresholdHours: fastingThreshold as 12 | 14 | 16, + waterTargetMl: Math.round(waterTargetMl), + dailyActionOrder, + dailyActionHidden, + onboardingComplete: Boolean(onboardingComplete), + }; + const target = initialWeightKg + ? calculateNutritionTarget({ + weightKg: initialWeightKg, + heightCm, + ageYears, + equationProfile, + activityLevel, + goal, + manualCalorieTarget: nextProfile.manualCalorieTarget, + manualCalorieRange: nextProfile.manualCalorieRange, + }) + : await currentTarget(c.env.DB, nextProfile, userId); + const activeCycle = await c.env.DB.prepare( + 'SELECT * FROM goal_cycles WHERE user_id = ? AND end_on IS NULL' + ) + .bind(userId) + .first(); + if (!activeCycle) { + statements.push( + cycleInsertStatement(c.env.DB, { id: crypto.randomUUID(), userId, - goal: profile.goal, - startOn: today, + goal, + startOn: cycleDate, calorieRange: target.calorieRange, proteinRangeG: target.proteinRangeG, - now: Date.now(), - }).run(); - result = await c.env.DB.prepare( - 'SELECT * FROM goal_cycles WHERE user_id = ? ORDER BY start_on DESC LIMIT 20' + now, + }) + ); + } else if (activeCycle.cycle === cycleFromGoal(goal)) { + statements.push( + c.env.DB.prepare( + `UPDATE goal_cycles SET goal = ?, calorie_range_low = ?, calorie_range_high = ?, + protein_range_low = ?, protein_range_high = ?, updated_at = ? + WHERE id = ? AND user_id = ? AND end_on IS NULL` + ).bind( + goal, + target.calorieRange?.[0] ?? null, + target.calorieRange?.[1] ?? null, + target.proteinRangeG?.[0] ?? null, + target.proteinRangeG?.[1] ?? null, + now, + activeCycle.id, + userId ) - .bind(userId) - .all(); - } - return conditionalJson(c, result.results.map(mapGoalCycle)); - }); + ); + } else { + statements.push( + c.env.DB.prepare( + 'UPDATE goal_cycles SET end_on = ?, updated_at = ? WHERE id = ? AND user_id = ? AND end_on IS NULL' + ).bind(cycleDate, now, activeCycle.id, userId), + cycleInsertStatement(c.env.DB, { + id: crypto.randomUUID(), + userId, + goal, + startOn: cycleDate, + calorieRange: target.calorieRange, + proteinRangeG: target.proteinRangeG, + now, + }) + ); + } + await c.env.DB.batch(statements); + return c.json(await readProfile(c.env.DB, userId, displayName)); +} - app.patch('/api/app/cycles/active', async (c) => { - const body = await c.req.json>().catch(() => null); - const startOn = validDateKey(body?.startOn); - const today = validDateKey(body?.today); - if (!startOn || !today || startOn > today) { - return c.json(jsonError('Choose a cycle start date that is not in the future.'), 400); - } - const userId = c.get('userId'); - const active = await c.env.DB.prepare( - 'SELECT * FROM goal_cycles WHERE user_id = ? AND end_on IS NULL' - ) - .bind(userId) - .first(); - if (!active) return c.json({ message: 'Active cycle not found.' }, 404); - const previous = await c.env.DB.prepare( - `SELECT * FROM goal_cycles WHERE user_id = ? AND end_on IS NOT NULL - ORDER BY end_on DESC LIMIT 1` +async function getCycles(c: AppContext) { + const userId = c.get('userId'); + const today = validDateKey(c.req.query('date')); + if (!today) return c.json(jsonError('Choose a valid local date.'), 400); + let result = await c.env.DB.prepare( + 'SELECT * FROM goal_cycles WHERE user_id = ? ORDER BY start_on DESC LIMIT 20' + ) + .bind(userId) + .all(); + if (!result.results.some((row) => row.end_on === null)) { + const profile = await readProfile(c.env.DB, userId, c.get('userName')); + const target = await currentTarget(c.env.DB, profile, userId); + await cycleInsertStatement(c.env.DB, { + id: crypto.randomUUID(), + userId, + goal: profile.goal, + startOn: today, + calorieRange: target.calorieRange, + proteinRangeG: target.proteinRangeG, + now: Date.now(), + }).run(); + result = await c.env.DB.prepare( + 'SELECT * FROM goal_cycles WHERE user_id = ? ORDER BY start_on DESC LIMIT 20' ) .bind(userId) - .first(); - if (previous?.end_on && startOn < previous.end_on) { - return c.json( - jsonError(`Cycle start must be on or after ${previous.end_on}.`, { - startOn: 'Overlaps the previous cycle.', - }), - 400 - ); - } - await c.env.DB.prepare( - 'UPDATE goal_cycles SET start_on = ?, updated_at = ? WHERE id = ? AND user_id = ? AND end_on IS NULL' - ) - .bind(startOn, Date.now(), active.id, userId) - .run(); - const updated = await c.env.DB.prepare('SELECT * FROM goal_cycles WHERE id = ? AND user_id = ?') - .bind(active.id, userId) - .first(); - if (!updated) return c.json({ message: 'The cycle could not be read back.' }, 500); - return c.json(mapGoalCycle(updated)); - }); + .all(); + } + return conditionalJson(c, result.results.map(mapGoalCycle)); +} - app.get('/api/app/export', async (c) => { - const userId = c.get('userId'); - const [profile, foods, entries, water, medications, checkIns, weights, cycles] = - await Promise.all([ - readProfile(c.env.DB, userId, c.get('userName')), - c.env.DB.prepare('SELECT * FROM foods WHERE user_id = ? ORDER BY created_at ASC') - .bind(userId) - .all(), - c.env.DB.prepare('SELECT * FROM food_entries WHERE user_id = ? ORDER BY eaten_at ASC') - .bind(userId) - .all(), - c.env.DB.prepare( - 'SELECT id, amount_ml, drank_at FROM water_entries WHERE user_id = ? ORDER BY drank_at ASC' - ) - .bind(userId) - .all(), - c.env.DB.prepare( - 'SELECT id, name, schedule, created_at, archived_at FROM medications WHERE user_id = ? ORDER BY created_at ASC' - ) - .bind(userId) - .all(), - c.env.DB.prepare( - 'SELECT id, medication_id, taken_on, taken_at FROM medication_check_ins WHERE user_id = ? ORDER BY taken_at ASC' - ) - .bind(userId) - .all(), - c.env.DB.prepare( - 'SELECT id, weight_kg, recorded_at FROM weight_entries WHERE user_id = ? ORDER BY recorded_at ASC' - ) - .bind(userId) - .all(), - c.env.DB.prepare('SELECT * FROM goal_cycles WHERE user_id = ? ORDER BY start_on ASC') - .bind(userId) - .all(), - ]); +async function patchCyclesActive(c: AppContext) { + const body = await c.req.json>().catch(() => null); + const startOn = validDateKey(body?.startOn); + const today = validDateKey(body?.today); + if (!startOn || !today || startOn > today) { + return c.json(jsonError('Choose a cycle start date that is not in the future.'), 400); + } + const userId = c.get('userId'); + const active = await c.env.DB.prepare( + 'SELECT * FROM goal_cycles WHERE user_id = ? AND end_on IS NULL' + ) + .bind(userId) + .first(); + if (!active) return c.json({ message: 'Active cycle not found.' }, 404); + const previous = await c.env.DB.prepare( + `SELECT * FROM goal_cycles WHERE user_id = ? AND end_on IS NOT NULL + ORDER BY end_on DESC LIMIT 1` + ) + .bind(userId) + .first(); + if (previous?.end_on && startOn < previous.end_on) { return c.json( - createJournalExport({ - profile, - foods: foods.results.map(mapFood), - entries: entries.results.map(mapFoodEntry), - waterEntries: water.results.map(mapWater), - medications: medications.results.map(mapMedication), - medicationCheckIns: checkIns.results.map(mapMedicationCheckIn), - weights: weights.results.map(mapWeight), - cycleSessions: cycles.results.map(mapGoalCycle), - }) + jsonError(`Cycle start must be on or after ${previous.end_on}.`, { + startOn: 'Overlaps the previous cycle.', + }), + 400 ); - }); + } + await c.env.DB.prepare( + 'UPDATE goal_cycles SET start_on = ?, updated_at = ? WHERE id = ? AND user_id = ? AND end_on IS NULL' + ) + .bind(startOn, Date.now(), active.id, userId) + .run(); + const updated = await c.env.DB.prepare('SELECT * FROM goal_cycles WHERE id = ? AND user_id = ?') + .bind(active.id, userId) + .first(); + if (!updated) return c.json({ message: 'The cycle could not be read back.' }, 500); + return c.json(mapGoalCycle(updated)); +} + +async function getExport(c: AppContext) { + const userId = c.get('userId'); + const [profile, foods, entries, water, medications, checkIns, weights, cycles] = + await Promise.all([ + readProfile(c.env.DB, userId, c.get('userName')), + c.env.DB.prepare('SELECT * FROM foods WHERE user_id = ? ORDER BY created_at ASC') + .bind(userId) + .all(), + c.env.DB.prepare('SELECT * FROM food_entries WHERE user_id = ? ORDER BY eaten_at ASC') + .bind(userId) + .all(), + c.env.DB.prepare( + 'SELECT id, amount_ml, drank_at FROM water_entries WHERE user_id = ? ORDER BY drank_at ASC' + ) + .bind(userId) + .all(), + c.env.DB.prepare( + 'SELECT id, name, schedule, created_at, archived_at FROM medications WHERE user_id = ? ORDER BY created_at ASC' + ) + .bind(userId) + .all(), + c.env.DB.prepare( + 'SELECT id, medication_id, taken_on, taken_at FROM medication_check_ins WHERE user_id = ? ORDER BY taken_at ASC' + ) + .bind(userId) + .all(), + c.env.DB.prepare( + 'SELECT id, weight_kg, recorded_at FROM weight_entries WHERE user_id = ? ORDER BY recorded_at ASC' + ) + .bind(userId) + .all(), + c.env.DB.prepare('SELECT * FROM goal_cycles WHERE user_id = ? ORDER BY start_on ASC') + .bind(userId) + .all(), + ]); + return c.json( + createJournalExport({ + profile, + foods: foods.results.map(mapFood), + entries: entries.results.map(mapFoodEntry), + waterEntries: water.results.map(mapWater), + medications: medications.results.map(mapMedication), + medicationCheckIns: checkIns.results.map(mapMedicationCheckIn), + weights: weights.results.map(mapWeight), + cycleSessions: cycles.results.map(mapGoalCycle), + }) + ); +} + +export function registerAccountRoutes(app: App) { + app.get('/api/app/mcp-tokens', getMcpTokens); + app.post('/api/app/mcp-tokens', postMcpTokens); + app.delete('/api/app/mcp-tokens/:id', deleteMcpTokensId); + app.get('/api/app/profile', getProfile); + app.get('/api/app/bootstrap', getBootstrap); + app.put('/api/app/profile', putProfile); + app.get('/api/app/cycles', getCycles); + app.patch('/api/app/cycles/active', patchCyclesActive); + app.get('/api/app/export', getExport); } diff --git a/src/worker/auth.ts b/src/worker/auth.ts index 85cd263..e350184 100644 --- a/src/worker/auth.ts +++ b/src/worker/auth.ts @@ -12,138 +12,139 @@ import { saveNativeHandoff, } from '../server/native-handoff'; import { authenticateMcpRead } from '../server/read-tokens'; -import type { App } from './types'; +import type { App, AppContext } from './types'; -export function registerAuthRoutes(app: App) { - app.get('/api/health', (c) => - c.json({ - ok: true, - auth: { - googleConfigured: isGoogleConfigured(c.env), - appleConfigured: isAppleConfigured(c.env), - appleWebConfigured: isAppleWebConfigured(c.env), - }, - storage: 'd1', - }) - ); - - app.get('/api/auth/config', (c) => - c.json({ +function health(c: AppContext) { + return c.json({ + ok: true, + auth: { googleConfigured: isGoogleConfigured(c.env), appleConfigured: isAppleConfigured(c.env), appleWebConfigured: isAppleWebConfigured(c.env), - }) - ); + }, + storage: 'd1', + }); +} - app.on(['GET', 'POST'], '/api/auth/*', async (c) => { - const path = new URL(c.req.url).pathname; - if (path.endsWith('/sign-in/social') && c.req.method === 'POST') { - const body = await c.req.raw - .clone() - .json<{ idToken?: unknown; provider?: unknown }>() - .catch(() => null); - const provider = body?.provider; - if (provider === 'google' && !isGoogleConfigured(c.env)) { - return c.json( - { - code: 'OAUTH_NOT_CONFIGURED', - message: 'Google sign-in is not configured in this environment.', - }, - 503 - ); - } - if (provider === 'apple' && !isAppleConfigured(c.env)) { - return c.json( - { - code: 'OAUTH_NOT_CONFIGURED', - message: 'Apple sign-in is not configured in this environment.', - }, - 503 - ); - } - if (provider === 'apple' && !body?.idToken && !isAppleWebConfigured(c.env)) { - return c.json( - { - code: 'OAUTH_NOT_CONFIGURED', - message: 'Apple browser sign-in is not configured in this environment.', - }, - 503 - ); - } - } - return createAuth(c.env, c.req.url).handler(c.req.raw); +function authConfig(c: AppContext) { + return c.json({ + googleConfigured: isGoogleConfigured(c.env), + appleConfigured: isAppleConfigured(c.env), + appleWebConfigured: isAppleWebConfigured(c.env), }); +} - app.get('/api/native/auth/google/start', async (c) => { - if (!isGoogleConfigured(c.env)) { +async function authHandler(c: AppContext) { + const path = new URL(c.req.url).pathname; + if (path.endsWith('/sign-in/social') && c.req.method === 'POST') { + const body = await c.req.raw + .clone() + .json<{ idToken?: unknown; provider?: unknown }>() + .catch(() => null); + const provider = body?.provider; + if (provider === 'google' && !isGoogleConfigured(c.env)) { return c.json( - { code: 'OAUTH_NOT_CONFIGURED', message: 'Google sign-in is unavailable.' }, + { + code: 'OAUTH_NOT_CONFIGURED', + message: 'Google sign-in is not configured in this environment.', + }, 503 ); } - const callback = c.req.query('callback') ?? NATIVE_AUTH_CALLBACK; - if (!isAllowedNativeCallback(callback)) { + if (provider === 'apple' && !isAppleConfigured(c.env)) { return c.json( - { code: 'INVALID_CALLBACK', message: 'The native callback is not allowed.' }, - 400 + { + code: 'OAUTH_NOT_CONFIGURED', + message: 'Apple sign-in is not configured in this environment.', + }, + 503 ); } - const completeURL = new URL('/api/native/auth/google/complete', c.req.url); - completeURL.searchParams.set('callback', callback); - const result = await createAuth(c.env, c.req.url).api.signInSocial({ - body: { - provider: 'google', - callbackURL: completeURL.toString(), - errorCallbackURL: completeURL.toString(), - }, - headers: c.req.raw.headers, - }); - if (!result.url) { + if (provider === 'apple' && !body?.idToken && !isAppleWebConfigured(c.env)) { return c.json( - { code: 'OAUTH_START_FAILED', message: 'Google sign-in could not start.' }, - 502 + { + code: 'OAUTH_NOT_CONFIGURED', + message: 'Apple browser sign-in is not configured in this environment.', + }, + 503 ); } - return c.redirect(result.url); - }); + } + return createAuth(c.env, c.req.url).handler(c.req.raw); +} - app.get('/api/native/auth/google/complete', async (c) => { - const callback = c.req.query('callback') ?? NATIVE_AUTH_CALLBACK; - if (!isAllowedNativeCallback(callback)) { - return c.json( - { code: 'INVALID_CALLBACK', message: 'The native callback is not allowed.' }, - 400 - ); - } - const session = await createAuth(c.env, c.req.url).api.getSession({ - headers: c.req.raw.headers, - }); - const redirect = new URL(callback); - if (!session?.session.token) { - redirect.searchParams.set('error', 'google_auth_failed'); - return c.redirect(redirect.toString()); - } - const code = createNativeHandoffCode(); - await saveNativeHandoff(c.env.DB, code, session.session.token); - redirect.searchParams.set('code', code); - return c.redirect(redirect.toString()); +async function startNativeGoogleAuth(c: AppContext) { + if (!isGoogleConfigured(c.env)) { + return c.json({ code: 'OAUTH_NOT_CONFIGURED', message: 'Google sign-in is unavailable.' }, 503); + } + const callback = c.req.query('callback') ?? NATIVE_AUTH_CALLBACK; + if (!isAllowedNativeCallback(callback)) { + return c.json( + { code: 'INVALID_CALLBACK', message: 'The native callback is not allowed.' }, + 400 + ); + } + const completeURL = new URL('/api/native/auth/google/complete', c.req.url); + completeURL.searchParams.set('callback', callback); + const result = await createAuth(c.env, c.req.url).api.signInSocial({ + body: { + provider: 'google', + callbackURL: completeURL.toString(), + errorCallbackURL: completeURL.toString(), + }, + headers: c.req.raw.headers, }); + if (!result.url) { + return c.json({ code: 'OAUTH_START_FAILED', message: 'Google sign-in could not start.' }, 502); + } + return c.redirect(result.url); +} - app.post('/api/native/auth/exchange', async (c) => { - const body = await c.req.json<{ code?: unknown }>().catch(() => null); - const code = typeof body?.code === 'string' ? body.code.trim() : ''; - if (code.length < 32 || code.length > 128) { - return c.json({ code: 'INVALID_HANDOFF', message: 'The sign-in handoff is invalid.' }, 400); - } - const token = await consumeNativeHandoff(c.env.DB, code); - if (!token) { - return c.json( - { code: 'EXPIRED_HANDOFF', message: 'The sign-in handoff expired or was already used.' }, - 401 - ); - } - return c.json({ token }); +async function completeNativeGoogleAuth(c: AppContext) { + const callback = c.req.query('callback') ?? NATIVE_AUTH_CALLBACK; + if (!isAllowedNativeCallback(callback)) { + return c.json( + { code: 'INVALID_CALLBACK', message: 'The native callback is not allowed.' }, + 400 + ); + } + const session = await createAuth(c.env, c.req.url).api.getSession({ + headers: c.req.raw.headers, }); + const redirect = new URL(callback); + if (!session?.session.token) { + redirect.searchParams.set('error', 'google_auth_failed'); + return c.redirect(redirect.toString()); + } + const code = createNativeHandoffCode(); + await saveNativeHandoff(c.env.DB, code, session.session.token); + redirect.searchParams.set('code', code); + return c.redirect(redirect.toString()); +} + +async function exchangeNativeAuth(c: AppContext) { + const body = await c.req.json<{ code?: unknown }>().catch(() => null); + const code = typeof body?.code === 'string' ? body.code.trim() : ''; + if (code.length < 32 || code.length > 128) { + return c.json({ code: 'INVALID_HANDOFF', message: 'The sign-in handoff is invalid.' }, 400); + } + const token = await consumeNativeHandoff(c.env.DB, code); + if (!token) { + return c.json( + { code: 'EXPIRED_HANDOFF', message: 'The sign-in handoff expired or was already used.' }, + 401 + ); + } + return c.json({ token }); +} + +export function registerAuthRoutes(app: App) { + app.get('/api/health', health); + app.get('/api/auth/config', authConfig); + app.on(['GET', 'POST'], '/api/auth/*', authHandler); + app.get('/api/native/auth/google/start', startNativeGoogleAuth); + app.get('/api/native/auth/google/complete', completeNativeGoogleAuth); + app.post('/api/native/auth/exchange', exchangeNativeAuth); } export function registerSessionMiddleware(app: App) { diff --git a/src/worker/journal.ts b/src/worker/journal.ts index d8b91f0..0ff7d1a 100644 --- a/src/worker/journal.ts +++ b/src/worker/journal.ts @@ -18,86 +18,49 @@ import { type WeightRow, } from './db'; import { finiteNumber, jsonError, optionalText, requiredText, validTimestamp } from './http'; -import type { App } from './types'; +import type { App, AppContext } from './types'; -export function registerJournalRoutes(app: App) { - app.get('/api/app/foods', async (c) => { - const search = c.req.query('q')?.trim().slice(0, 60); - const lifecycleWhere = - c.req.query('status') === 'archived' ? 'archived_at IS NOT NULL' : 'archived_at IS NULL'; - const result = search - ? await c.env.DB.prepare( - `SELECT * FROM foods - WHERE user_id = ? AND ${lifecycleWhere} AND name LIKE ? ESCAPE '\\' - ORDER BY last_used_at DESC, name ASC LIMIT 50` - ) - .bind(c.get('userId'), `%${search.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`) - .all() - : await c.env.DB.prepare( - `SELECT * FROM foods WHERE user_id = ? AND ${lifecycleWhere} - ORDER BY last_used_at DESC, name ASC LIMIT 100` - ) - .bind(c.get('userId')) - .all(); - return c.json(result.results.map(mapFood)); - }); +function paramId(c: AppContext) { + return c.req.param('id') ?? ''; +} - app.post('/api/app/foods', async (c) => { - const body = await c.req.json>().catch(() => null); - const parsed = body ? parseFoodBody(body) : null; - const id = body ? optionalText(body.id, 80) : null; - if (!parsed || !id) return c.json(jsonError('Complete all four nutrient values.'), 400); - const now = Date.now(); - try { - await c.env.DB.prepare( - `INSERT INTO foods ( - id, user_id, name, serving_mode, unit_label, default_amount, - calories, carbs_g, protein_g, fibre_g, favourite, food_kind, is_packaged, labels_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` +async function getFoods(c: AppContext) { + const search = c.req.query('q')?.trim().slice(0, 60); + const lifecycleWhere = + c.req.query('status') === 'archived' ? 'archived_at IS NOT NULL' : 'archived_at IS NULL'; + const result = search + ? await c.env.DB.prepare( + `SELECT * FROM foods + WHERE user_id = ? AND ${lifecycleWhere} AND name LIKE ? ESCAPE '\\' + ORDER BY last_used_at DESC, name ASC LIMIT 50` ) - .bind( - id, - c.get('userId'), - parsed.name, - parsed.servingMode, - parsed.unitLabel, - parsed.defaultAmount, - parsed.calories, - parsed.carbsG, - parsed.proteinG, - parsed.fibreG, - parsed.favourite, - parsed.isPackaged ? 'packaged' : 'prepared', - parsed.isPackaged ? 1 : 0, - JSON.stringify(parsed.labels), - now, - now - ) - .run(); - } catch (error) { - console.error(JSON.stringify({ event: 'food_create_failed', message: String(error) })); - return c.json( - jsonError('A food with that name already exists. Edit the existing food instead.'), - 409 - ); - } - const row = await c.env.DB.prepare('SELECT * FROM foods WHERE id = ? AND user_id = ?') - .bind(id, c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The saved food could not be read back.' }, 500); - return c.json(mapFood(row), 201); - }); + .bind(c.get('userId'), `%${search.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`) + .all() + : await c.env.DB.prepare( + `SELECT * FROM foods WHERE user_id = ? AND ${lifecycleWhere} + ORDER BY last_used_at DESC, name ASC LIMIT 100` + ) + .bind(c.get('userId')) + .all(); + return c.json(result.results.map(mapFood)); +} - app.put('/api/app/foods/:id', async (c) => { - const body = await c.req.json>().catch(() => null); - const parsed = body ? parseFoodBody(body) : null; - if (!parsed) return c.json(jsonError('Complete all four nutrient values.'), 400); - const result = await c.env.DB.prepare( - `UPDATE foods SET name = ?, serving_mode = ?, unit_label = ?, default_amount = ?, - calories = ?, carbs_g = ?, protein_g = ?, fibre_g = ?, favourite = ?, food_kind = ?, is_packaged = ?, labels_json = ?, updated_at = ? - WHERE id = ? AND user_id = ?` +async function postFoods(c: AppContext) { + const body = await c.req.json>().catch(() => null); + const parsed = body ? parseFoodBody(body) : null; + const id = body ? optionalText(body.id, 80) : null; + if (!parsed || !id) return c.json(jsonError('Complete all four nutrient values.'), 400); + const now = Date.now(); + try { + await c.env.DB.prepare( + `INSERT INTO foods ( + id, user_id, name, serving_mode, unit_label, default_amount, + calories, carbs_g, protein_g, fibre_g, favourite, food_kind, is_packaged, labels_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind( + id, + c.get('userId'), parsed.name, parsed.servingMode, parsed.unitLabel, @@ -110,99 +73,207 @@ export function registerJournalRoutes(app: App) { parsed.isPackaged ? 'packaged' : 'prepared', parsed.isPackaged ? 1 : 0, JSON.stringify(parsed.labels), - Date.now(), - c.req.param('id'), - c.get('userId') + now, + now ) .run(); - if (!result.meta.changes) return c.json({ message: 'Food not found.' }, 404); - const row = await c.env.DB.prepare('SELECT * FROM foods WHERE id = ? AND user_id = ?') - .bind(c.req.param('id'), c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The saved food could not be read back.' }, 500); - return c.json(mapFood(row)); - }); + } catch (error) { + console.error(JSON.stringify({ event: 'food_create_failed', message: String(error) })); + return c.json( + jsonError('A food with that name already exists. Edit the existing food instead.'), + 409 + ); + } + const row = await c.env.DB.prepare('SELECT * FROM foods WHERE id = ? AND user_id = ?') + .bind(id, c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The saved food could not be read back.' }, 500); + return c.json(mapFood(row), 201); +} - app.patch('/api/app/foods/:id', async (c) => { - const body = await c.req.json>().catch(() => null); - if (!body || !('archivedAt' in body)) { - return c.json(jsonError('Choose whether this food is active or archived.'), 400); - } - const archivedAt = body.archivedAt === null ? null : validTimestamp(body.archivedAt); - if (body.archivedAt !== null && archivedAt === null) { - return c.json(jsonError('Choose a valid archive time.'), 400); - } - const result = await c.env.DB.prepare( - 'UPDATE foods SET archived_at = ?, updated_at = ? WHERE id = ? AND user_id = ?' +async function putFoodsId(c: AppContext) { + const body = await c.req.json>().catch(() => null); + const parsed = body ? parseFoodBody(body) : null; + if (!parsed) return c.json(jsonError('Complete all four nutrient values.'), 400); + const result = await c.env.DB.prepare( + `UPDATE foods SET name = ?, serving_mode = ?, unit_label = ?, default_amount = ?, + calories = ?, carbs_g = ?, protein_g = ?, fibre_g = ?, favourite = ?, food_kind = ?, is_packaged = ?, labels_json = ?, updated_at = ? + WHERE id = ? AND user_id = ?` + ) + .bind( + parsed.name, + parsed.servingMode, + parsed.unitLabel, + parsed.defaultAmount, + parsed.calories, + parsed.carbsG, + parsed.proteinG, + parsed.fibreG, + parsed.favourite, + parsed.isPackaged ? 'packaged' : 'prepared', + parsed.isPackaged ? 1 : 0, + JSON.stringify(parsed.labels), + Date.now(), + paramId(c), + c.get('userId') ) - .bind(archivedAt, Date.now(), c.req.param('id'), c.get('userId')) - .run(); - if (!result.meta.changes) return c.json({ message: 'Food not found.' }, 404); - const row = await c.env.DB.prepare('SELECT * FROM foods WHERE id = ? AND user_id = ?') - .bind(c.req.param('id'), c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The saved food could not be read back.' }, 500); - return c.json(mapFood(row)); - }); + .run(); + if (!result.meta.changes) return c.json({ message: 'Food not found.' }, 404); + const row = await c.env.DB.prepare('SELECT * FROM foods WHERE id = ? AND user_id = ?') + .bind(paramId(c), c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The saved food could not be read back.' }, 500); + return c.json(mapFood(row)); +} - app.delete('/api/app/foods/:id', async (c) => { - const result = await c.env.DB.prepare('DELETE FROM foods WHERE id = ? AND user_id = ?') - .bind(c.req.param('id'), c.get('userId')) - .run(); - return result.meta.changes ? c.body(null, 204) : c.json({ message: 'Food not found.' }, 404); - }); +async function patchFoodsId(c: AppContext) { + const body = await c.req.json>().catch(() => null); + if (!body || !('archivedAt' in body)) { + return c.json(jsonError('Choose whether this food is active or archived.'), 400); + } + const archivedAt = body.archivedAt === null ? null : validTimestamp(body.archivedAt); + if (body.archivedAt !== null && archivedAt === null) { + return c.json(jsonError('Choose a valid archive time.'), 400); + } + const result = await c.env.DB.prepare( + 'UPDATE foods SET archived_at = ?, updated_at = ? WHERE id = ? AND user_id = ?' + ) + .bind(archivedAt, Date.now(), paramId(c), c.get('userId')) + .run(); + if (!result.meta.changes) return c.json({ message: 'Food not found.' }, 404); + const row = await c.env.DB.prepare('SELECT * FROM foods WHERE id = ? AND user_id = ?') + .bind(paramId(c), c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The saved food could not be read back.' }, 500); + return c.json(mapFood(row)); +} - app.post('/api/app/entries', async (c) => { - const body = await c.req.json>().catch(() => null); - const id = body ? optionalText(body.id, 80) : null; - const foodId = body ? optionalText(body.foodId, 80) : null; - const amount = body ? finiteNumber(body.amount, 0.01, 10000) : null; - const eatenAt = body ? validTimestamp(body.eatenAt) : null; - if (!body || !id || amount === null || eatenAt === null) { - return c.json(jsonError('Add a valid amount and time.'), 400); +async function deleteFoodsId(c: AppContext) { + const result = await c.env.DB.prepare('DELETE FROM foods WHERE id = ? AND user_id = ?') + .bind(paramId(c), c.get('userId')) + .run(); + return result.meta.changes ? c.body(null, 204) : c.json({ message: 'Food not found.' }, 404); +} + +async function postEntries(c: AppContext) { + const body = await c.req.json>().catch(() => null); + const id = body ? optionalText(body.id, 80) : null; + const foodId = body ? optionalText(body.foodId, 80) : null; + const amount = body ? finiteNumber(body.amount, 0.01, 10000) : null; + const eatenAt = body ? validTimestamp(body.eatenAt) : null; + if (!body || !id || amount === null || eatenAt === null) { + return c.json(jsonError('Add a valid amount and time.'), 400); + } + let entry: FoodEntry; + let foodUpdate: D1PreparedStatement | null = null; + const now = Date.now(); + + if (foodId) { + const foodRow = await c.env.DB.prepare( + 'SELECT * FROM foods WHERE id = ? AND user_id = ? AND archived_at IS NULL' + ) + .bind(foodId, c.get('userId')) + .first(); + if (!foodRow) return c.json({ message: 'Food not found.' }, 404); + const food = mapFood(foodRow); + entry = { + id, + foodId: food.id, + foodName: food.name, + amount, + unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, + ...scaleNutrients(food, food.servingMode, amount), + eatenAt, + isPackaged: food.isPackaged, + labels: food.labels, + }; + foodUpdate = c.env.DB.prepare( + 'UPDATE foods SET last_used_at = ?, updated_at = ? WHERE id = ? AND user_id = ?' + ).bind(eatenAt, now, food.id, c.get('userId')); + } else { + const directEntry = directEntryFromBody(body, id, amount, eatenAt); + if (!directEntry) { + return c.json(jsonError('Add a name, unit, and valid nutrient totals.'), 400); } - let entry: FoodEntry; - let foodUpdate: D1PreparedStatement | null = null; - const now = Date.now(); + entry = directEntry; + } - if (foodId) { - const foodRow = await c.env.DB.prepare( - 'SELECT * FROM foods WHERE id = ? AND user_id = ? AND archived_at IS NULL' - ) - .bind(foodId, c.get('userId')) - .first(); - if (!foodRow) return c.json({ message: 'Food not found.' }, 404); - const food = mapFood(foodRow); - entry = { - id, - foodId: food.id, - foodName: food.name, - amount, - unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, - ...scaleNutrients(food, food.servingMode, amount), - eatenAt, - isPackaged: food.isPackaged, - labels: food.labels, - }; - foodUpdate = c.env.DB.prepare( - 'UPDATE foods SET last_used_at = ?, updated_at = ? WHERE id = ? AND user_id = ?' - ).bind(eatenAt, now, food.id, c.get('userId')); - } else { - const directEntry = directEntryFromBody(body, id, amount, eatenAt); - if (!directEntry) { - return c.json(jsonError('Add a name, unit, and valid nutrient totals.'), 400); - } - entry = directEntry; + const insert = c.env.DB.prepare( + `INSERT OR IGNORE INTO food_entries ( + id, user_id, food_id, food_name, amount, unit_label, calories, + carbs_g, protein_g, fibre_g, food_kind, is_packaged, labels_json, eaten_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).bind( + entry.id, + c.get('userId'), + entry.foodId, + entry.foodName, + entry.amount, + entry.unitLabel, + entry.calories, + entry.carbsG, + entry.proteinG, + entry.fibreG, + entry.isPackaged ? 'packaged' : 'prepared', + entry.isPackaged ? 1 : 0, + JSON.stringify(normalizeFoodLabels(entry.labels)), + entry.eatenAt, + now + ); + if (foodUpdate) await c.env.DB.batch([insert, foodUpdate]); + else await insert.run(); + + const row = await c.env.DB.prepare('SELECT * FROM food_entries WHERE id = ? AND user_id = ?') + .bind(id, c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The food entry could not be read back.' }, 500); + return c.json(mapFoodEntry(row), 201); +} + +async function patchEntriesId(c: AppContext) { + const body = await c.req.json>().catch(() => null); + if (!body) return c.json(jsonError('Send an entry to update.'), 400); + const foodId = optionalText(body.foodId, 80); + const amount = finiteNumber(body.amount, 0.01, 10_000); + const eatenAt = validTimestamp(body.eatenAt); + if (amount === null || eatenAt === null) { + return c.json(jsonError('Add a valid amount and time.'), 400); + } + + let entry: FoodEntry; + if (foodId) { + const foodRow = await c.env.DB.prepare( + 'SELECT * FROM foods WHERE id = ? AND user_id = ? AND archived_at IS NULL' + ) + .bind(foodId, c.get('userId')) + .first(); + if (!foodRow) return c.json({ message: 'Saved food not found.' }, 404); + const food = mapFood(foodRow); + entry = { + id: paramId(c), + foodId: food.id, + foodName: food.name, + amount, + unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, + ...scaleNutrients(food, food.servingMode, amount), + eatenAt, + isPackaged: food.isPackaged, + labels: food.labels, + }; + } else { + const directEntry = directEntryFromBody(body, paramId(c), amount, eatenAt); + if (!directEntry) { + return c.json(jsonError('Add a name, unit, and valid nutrient totals.'), 400); } + entry = directEntry; + } - const insert = c.env.DB.prepare( - `INSERT OR IGNORE INTO food_entries ( - id, user_id, food_id, food_name, amount, unit_label, calories, - carbs_g, protein_g, fibre_g, food_kind, is_packaged, labels_json, eaten_at, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).bind( - entry.id, - c.get('userId'), + const result = await c.env.DB.prepare( + `UPDATE food_entries SET food_id = ?, food_name = ?, amount = ?, unit_label = ?, + calories = ?, carbs_g = ?, protein_g = ?, fibre_g = ?, food_kind = ?, is_packaged = ?, labels_json = ?, eaten_at = ? + WHERE id = ? AND user_id = ?` + ) + .bind( entry.foodId, entry.foodName, entry.amount, @@ -215,310 +286,262 @@ export function registerJournalRoutes(app: App) { entry.isPackaged ? 1 : 0, JSON.stringify(normalizeFoodLabels(entry.labels)), entry.eatenAt, - now - ); - if (foodUpdate) await c.env.DB.batch([insert, foodUpdate]); - else await insert.run(); - - const row = await c.env.DB.prepare('SELECT * FROM food_entries WHERE id = ? AND user_id = ?') - .bind(id, c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The food entry could not be read back.' }, 500); - return c.json(mapFoodEntry(row), 201); - }); - - app.patch('/api/app/entries/:id', async (c) => { - const body = await c.req.json>().catch(() => null); - if (!body) return c.json(jsonError('Send an entry to update.'), 400); - const foodId = optionalText(body.foodId, 80); - const amount = finiteNumber(body.amount, 0.01, 10_000); - const eatenAt = validTimestamp(body.eatenAt); - if (amount === null || eatenAt === null) { - return c.json(jsonError('Add a valid amount and time.'), 400); - } - - let entry: FoodEntry; - if (foodId) { - const foodRow = await c.env.DB.prepare( - 'SELECT * FROM foods WHERE id = ? AND user_id = ? AND archived_at IS NULL' - ) - .bind(foodId, c.get('userId')) - .first(); - if (!foodRow) return c.json({ message: 'Saved food not found.' }, 404); - const food = mapFood(foodRow); - entry = { - id: c.req.param('id'), - foodId: food.id, - foodName: food.name, - amount, - unitLabel: food.servingMode === 'per_100g' ? 'g' : food.unitLabel, - ...scaleNutrients(food, food.servingMode, amount), - eatenAt, - isPackaged: food.isPackaged, - labels: food.labels, - }; - } else { - const directEntry = directEntryFromBody(body, c.req.param('id'), amount, eatenAt); - if (!directEntry) { - return c.json(jsonError('Add a name, unit, and valid nutrient totals.'), 400); - } - entry = directEntry; - } - - const result = await c.env.DB.prepare( - `UPDATE food_entries SET food_id = ?, food_name = ?, amount = ?, unit_label = ?, - calories = ?, carbs_g = ?, protein_g = ?, fibre_g = ?, food_kind = ?, is_packaged = ?, labels_json = ?, eaten_at = ? - WHERE id = ? AND user_id = ?` + entry.id, + c.get('userId') ) - .bind( - entry.foodId, - entry.foodName, - entry.amount, - entry.unitLabel, - entry.calories, - entry.carbsG, - entry.proteinG, - entry.fibreG, - entry.isPackaged ? 'packaged' : 'prepared', - entry.isPackaged ? 1 : 0, - JSON.stringify(normalizeFoodLabels(entry.labels)), - entry.eatenAt, - entry.id, - c.get('userId') - ) - .run(); - if (!result.meta.changes) return c.json({ message: 'Food entry not found.' }, 404); + .run(); + if (!result.meta.changes) return c.json({ message: 'Food entry not found.' }, 404); - const row = await c.env.DB.prepare('SELECT * FROM food_entries WHERE id = ? AND user_id = ?') - .bind(c.req.param('id'), c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The food entry could not be read back.' }, 500); - return c.json(mapFoodEntry(row)); - }); + const row = await c.env.DB.prepare('SELECT * FROM food_entries WHERE id = ? AND user_id = ?') + .bind(paramId(c), c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The food entry could not be read back.' }, 500); + return c.json(mapFoodEntry(row)); +} - app.delete('/api/app/entries/:id', async (c) => { - const result = await c.env.DB.prepare('DELETE FROM food_entries WHERE id = ? AND user_id = ?') - .bind(c.req.param('id'), c.get('userId')) - .run(); - return result.meta.changes ? c.body(null, 204) : c.json({ message: 'Entry not found.' }, 404); - }); +async function deleteEntriesId(c: AppContext) { + const result = await c.env.DB.prepare('DELETE FROM food_entries WHERE id = ? AND user_id = ?') + .bind(paramId(c), c.get('userId')) + .run(); + return result.meta.changes ? c.body(null, 204) : c.json({ message: 'Entry not found.' }, 404); +} - app.post('/api/app/water', async (c) => { - const body = await c.req.json>().catch(() => null); - const id = body ? optionalText(body.id, 80) : null; - const amountMl = body ? finiteNumber(body.amountMl, 1, 5000) : null; - const drankAt = body ? validTimestamp(body.drankAt) : null; - if (!id || amountMl === null || drankAt === null) { - return c.json(jsonError('Choose a water amount and time.'), 400); - } - await c.env.DB.prepare( - `INSERT OR IGNORE INTO water_entries - (id, user_id, amount_ml, drank_at, created_at) VALUES (?, ?, ?, ?, ?)` - ) - .bind(id, c.get('userId'), Math.round(amountMl), drankAt, Date.now()) - .run(); - const row = await c.env.DB.prepare( - 'SELECT id, amount_ml, drank_at FROM water_entries WHERE id = ? AND user_id = ?' - ) - .bind(id, c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The water entry could not be read back.' }, 500); - return c.json(mapWater(row), 201); - }); +async function postWater(c: AppContext) { + const body = await c.req.json>().catch(() => null); + const id = body ? optionalText(body.id, 80) : null; + const amountMl = body ? finiteNumber(body.amountMl, 1, 5000) : null; + const drankAt = body ? validTimestamp(body.drankAt) : null; + if (!id || amountMl === null || drankAt === null) { + return c.json(jsonError('Choose a water amount and time.'), 400); + } + await c.env.DB.prepare( + `INSERT OR IGNORE INTO water_entries + (id, user_id, amount_ml, drank_at, created_at) VALUES (?, ?, ?, ?, ?)` + ) + .bind(id, c.get('userId'), Math.round(amountMl), drankAt, Date.now()) + .run(); + const row = await c.env.DB.prepare( + 'SELECT id, amount_ml, drank_at FROM water_entries WHERE id = ? AND user_id = ?' + ) + .bind(id, c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The water entry could not be read back.' }, 500); + return c.json(mapWater(row), 201); +} - app.patch('/api/app/water/:id', async (c) => { - const body = await c.req.json>().catch(() => null); - const amountMl = body ? finiteNumber(body.amountMl, 1, 5000) : null; - const drankAt = body ? validTimestamp(body.drankAt) : null; - if (amountMl === null || drankAt === null) { - return c.json(jsonError('Choose a water amount and time.'), 400); - } - const result = await c.env.DB.prepare( - 'UPDATE water_entries SET amount_ml = ?, drank_at = ? WHERE id = ? AND user_id = ?' - ) - .bind(Math.round(amountMl), drankAt, c.req.param('id'), c.get('userId')) - .run(); - if (!result.meta.changes) return c.json({ message: 'Water entry not found.' }, 404); - const row = await c.env.DB.prepare( - 'SELECT id, amount_ml, drank_at FROM water_entries WHERE id = ? AND user_id = ?' - ) - .bind(c.req.param('id'), c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The water entry could not be read back.' }, 500); - return c.json(mapWater(row)); - }); +async function patchWaterId(c: AppContext) { + const body = await c.req.json>().catch(() => null); + const amountMl = body ? finiteNumber(body.amountMl, 1, 5000) : null; + const drankAt = body ? validTimestamp(body.drankAt) : null; + if (amountMl === null || drankAt === null) { + return c.json(jsonError('Choose a water amount and time.'), 400); + } + const result = await c.env.DB.prepare( + 'UPDATE water_entries SET amount_ml = ?, drank_at = ? WHERE id = ? AND user_id = ?' + ) + .bind(Math.round(amountMl), drankAt, paramId(c), c.get('userId')) + .run(); + if (!result.meta.changes) return c.json({ message: 'Water entry not found.' }, 404); + const row = await c.env.DB.prepare( + 'SELECT id, amount_ml, drank_at FROM water_entries WHERE id = ? AND user_id = ?' + ) + .bind(paramId(c), c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The water entry could not be read back.' }, 500); + return c.json(mapWater(row)); +} - app.delete('/api/app/water/:id', async (c) => { - const result = await c.env.DB.prepare('DELETE FROM water_entries WHERE id = ? AND user_id = ?') - .bind(c.req.param('id'), c.get('userId')) - .run(); - return result.meta.changes - ? c.body(null, 204) - : c.json({ message: 'Water entry not found.' }, 404); - }); +async function deleteWaterId(c: AppContext) { + const result = await c.env.DB.prepare('DELETE FROM water_entries WHERE id = ? AND user_id = ?') + .bind(paramId(c), c.get('userId')) + .run(); + return result.meta.changes + ? c.body(null, 204) + : c.json({ message: 'Water entry not found.' }, 404); +} - app.post('/api/app/medications', async (c) => { - const body = await c.req.json>().catch(() => null); - const id = body ? optionalText(body.id, 80) : null; - const name = body ? requiredText(body.name, 80) : null; - const schedule = - body && ['morning', 'evening', 'either'].includes(String(body.schedule)) - ? (body.schedule as MedicationSchedule) - : null; - const createdAt = body ? validTimestamp(body.createdAt) : null; - if (!id || !name || !schedule || createdAt === null) { - return c.json(jsonError('Add a medication name and when you take it.'), 400); - } - const now = Date.now(); - await c.env.DB.prepare( - `INSERT OR IGNORE INTO medications - (id, user_id, name, schedule, created_at, updated_at, archived_at) - VALUES (?, ?, ?, ?, ?, ?, NULL)` - ) - .bind(id, c.get('userId'), name, schedule, createdAt, now) - .run(); - const row = await c.env.DB.prepare( - `SELECT id, name, schedule, created_at, archived_at - FROM medications WHERE id = ? AND user_id = ?` - ) - .bind(id, c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The medication could not be read back.' }, 500); - return c.json(mapMedication(row), 201); - }); +async function postMedications(c: AppContext) { + const body = await c.req.json>().catch(() => null); + const id = body ? optionalText(body.id, 80) : null; + const name = body ? requiredText(body.name, 80) : null; + const schedule = + body && ['morning', 'evening', 'either'].includes(String(body.schedule)) + ? (body.schedule as MedicationSchedule) + : null; + const createdAt = body ? validTimestamp(body.createdAt) : null; + if (!id || !name || !schedule || createdAt === null) { + return c.json(jsonError('Add a medication name and when you take it.'), 400); + } + const now = Date.now(); + await c.env.DB.prepare( + `INSERT OR IGNORE INTO medications + (id, user_id, name, schedule, created_at, updated_at, archived_at) + VALUES (?, ?, ?, ?, ?, ?, NULL)` + ) + .bind(id, c.get('userId'), name, schedule, createdAt, now) + .run(); + const row = await c.env.DB.prepare( + `SELECT id, name, schedule, created_at, archived_at + FROM medications WHERE id = ? AND user_id = ?` + ) + .bind(id, c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The medication could not be read back.' }, 500); + return c.json(mapMedication(row), 201); +} - app.patch('/api/app/medications/:id', async (c) => { - const body = await c.req.json>().catch(() => null); - const name = body ? requiredText(body.name, 80) : null; - const schedule = - body && ['morning', 'evening', 'either'].includes(String(body.schedule)) - ? (body.schedule as MedicationSchedule) - : null; - const archivedAt = - body?.archivedAt === null - ? null - : body?.archivedAt === undefined - ? undefined - : validTimestamp(body.archivedAt); - const hasInvalidArchivedAt = - body?.archivedAt !== null && body?.archivedAt !== undefined && archivedAt === null; - if (!body || !name || !schedule || archivedAt === undefined || hasInvalidArchivedAt) { - return c.json(jsonError('Add a medication name and when you take it.'), 400); - } - const result = await c.env.DB.prepare( - `UPDATE medications SET name = ?, schedule = ?, archived_at = ?, updated_at = ? - WHERE id = ? AND user_id = ?` - ) - .bind(name, schedule, archivedAt, Date.now(), c.req.param('id'), c.get('userId')) - .run(); - if (!result.meta.changes) return c.json({ message: 'Medication not found.' }, 404); - const row = await c.env.DB.prepare( - `SELECT id, name, schedule, created_at, archived_at - FROM medications WHERE id = ? AND user_id = ?` - ) - .bind(c.req.param('id'), c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The medication could not be read back.' }, 500); - return c.json(mapMedication(row)); - }); +async function patchMedicationsId(c: AppContext) { + const body = await c.req.json>().catch(() => null); + const name = body ? requiredText(body.name, 80) : null; + const schedule = + body && ['morning', 'evening', 'either'].includes(String(body.schedule)) + ? (body.schedule as MedicationSchedule) + : null; + const archivedAt = + body?.archivedAt === null + ? null + : body?.archivedAt === undefined + ? undefined + : validTimestamp(body.archivedAt); + const hasInvalidArchivedAt = + body?.archivedAt !== null && body?.archivedAt !== undefined && archivedAt === null; + if (!body || !name || !schedule || archivedAt === undefined || hasInvalidArchivedAt) { + return c.json(jsonError('Add a medication name and when you take it.'), 400); + } + const result = await c.env.DB.prepare( + `UPDATE medications SET name = ?, schedule = ?, archived_at = ?, updated_at = ? + WHERE id = ? AND user_id = ?` + ) + .bind(name, schedule, archivedAt, Date.now(), paramId(c), c.get('userId')) + .run(); + if (!result.meta.changes) return c.json({ message: 'Medication not found.' }, 404); + const row = await c.env.DB.prepare( + `SELECT id, name, schedule, created_at, archived_at + FROM medications WHERE id = ? AND user_id = ?` + ) + .bind(paramId(c), c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The medication could not be read back.' }, 500); + return c.json(mapMedication(row)); +} - app.post('/api/app/medication-check-ins', async (c) => { - const body = await c.req.json>().catch(() => null); - const id = body ? optionalText(body.id, 80) : null; - const medicationId = body ? optionalText(body.medicationId, 80) : null; - const takenOn = - body && typeof body.takenOn === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(body.takenOn) - ? body.takenOn - : null; - const takenAt = body ? validTimestamp(body.takenAt) : null; - if (!id || !medicationId || !takenOn || takenAt === null) { - return c.json(jsonError('Choose a medication and valid day.'), 400); - } - const medication = await c.env.DB.prepare( - 'SELECT id FROM medications WHERE id = ? AND user_id = ? AND archived_at IS NULL' - ) - .bind(medicationId, c.get('userId')) - .first<{ id: string }>(); - if (!medication) return c.json({ message: 'Medication not found.' }, 404); - await c.env.DB.prepare( - `INSERT OR IGNORE INTO medication_check_ins - (id, user_id, medication_id, taken_on, taken_at, created_at) - VALUES (?, ?, ?, ?, ?, ?)` - ) - .bind(id, c.get('userId'), medicationId, takenOn, takenAt, Date.now()) - .run(); - const row = await c.env.DB.prepare( - `SELECT id, medication_id, taken_on, taken_at FROM medication_check_ins - WHERE user_id = ? AND medication_id = ? AND taken_on = ?` - ) - .bind(c.get('userId'), medicationId, takenOn) - .first(); - if (!row) return c.json({ message: 'The medication check-off could not be read back.' }, 500); - return c.json(mapMedicationCheckIn(row), 201); - }); +async function postMedicationCheckIns(c: AppContext) { + const body = await c.req.json>().catch(() => null); + const id = body ? optionalText(body.id, 80) : null; + const medicationId = body ? optionalText(body.medicationId, 80) : null; + const takenOn = + body && typeof body.takenOn === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(body.takenOn) + ? body.takenOn + : null; + const takenAt = body ? validTimestamp(body.takenAt) : null; + if (!id || !medicationId || !takenOn || takenAt === null) { + return c.json(jsonError('Choose a medication and valid day.'), 400); + } + const medication = await c.env.DB.prepare( + 'SELECT id FROM medications WHERE id = ? AND user_id = ? AND archived_at IS NULL' + ) + .bind(medicationId, c.get('userId')) + .first<{ id: string }>(); + if (!medication) return c.json({ message: 'Medication not found.' }, 404); + await c.env.DB.prepare( + `INSERT OR IGNORE INTO medication_check_ins + (id, user_id, medication_id, taken_on, taken_at, created_at) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .bind(id, c.get('userId'), medicationId, takenOn, takenAt, Date.now()) + .run(); + const row = await c.env.DB.prepare( + `SELECT id, medication_id, taken_on, taken_at FROM medication_check_ins + WHERE user_id = ? AND medication_id = ? AND taken_on = ?` + ) + .bind(c.get('userId'), medicationId, takenOn) + .first(); + if (!row) return c.json({ message: 'The medication check-off could not be read back.' }, 500); + return c.json(mapMedicationCheckIn(row), 201); +} - app.delete('/api/app/medication-check-ins/:id', async (c) => { - const result = await c.env.DB.prepare( - 'DELETE FROM medication_check_ins WHERE id = ? AND user_id = ?' - ) - .bind(c.req.param('id'), c.get('userId')) - .run(); - return result.meta.changes - ? c.body(null, 204) - : c.json({ message: 'Medication check-off not found.' }, 404); - }); +async function deleteMedicationCheckInsId(c: AppContext) { + const result = await c.env.DB.prepare( + 'DELETE FROM medication_check_ins WHERE id = ? AND user_id = ?' + ) + .bind(paramId(c), c.get('userId')) + .run(); + return result.meta.changes + ? c.body(null, 204) + : c.json({ message: 'Medication check-off not found.' }, 404); +} - app.post('/api/app/weights', async (c) => { - const body = await c.req.json>().catch(() => null); - const id = body ? optionalText(body.id, 80) : null; - const weightKg = body ? finiteNumber(body.weightKg, 30, 400) : null; - const recordedAt = body ? validTimestamp(body.recordedAt) : null; - if (!id || weightKg === null || recordedAt === null) { - return c.json(jsonError('Enter a valid weight and date.'), 400); - } - await c.env.DB.prepare( - `INSERT OR IGNORE INTO weight_entries - (id, user_id, weight_kg, recorded_at, created_at) VALUES (?, ?, ?, ?, ?)` - ) - .bind(id, c.get('userId'), weightKg, recordedAt, Date.now()) - .run(); - const row = await c.env.DB.prepare( - 'SELECT id, weight_kg, recorded_at FROM weight_entries WHERE id = ? AND user_id = ?' - ) - .bind(id, c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The weight entry could not be read back.' }, 500); - return c.json(mapWeight(row), 201); - }); +async function postWeights(c: AppContext) { + const body = await c.req.json>().catch(() => null); + const id = body ? optionalText(body.id, 80) : null; + const weightKg = body ? finiteNumber(body.weightKg, 30, 400) : null; + const recordedAt = body ? validTimestamp(body.recordedAt) : null; + if (!id || weightKg === null || recordedAt === null) { + return c.json(jsonError('Enter a valid weight and date.'), 400); + } + await c.env.DB.prepare( + `INSERT OR IGNORE INTO weight_entries + (id, user_id, weight_kg, recorded_at, created_at) VALUES (?, ?, ?, ?, ?)` + ) + .bind(id, c.get('userId'), weightKg, recordedAt, Date.now()) + .run(); + const row = await c.env.DB.prepare( + 'SELECT id, weight_kg, recorded_at FROM weight_entries WHERE id = ? AND user_id = ?' + ) + .bind(id, c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The weight entry could not be read back.' }, 500); + return c.json(mapWeight(row), 201); +} - app.patch('/api/app/weights/:id', async (c) => { - const body = await c.req.json>().catch(() => null); - const weightKg = body ? finiteNumber(body.weightKg, 30, 400) : null; - const recordedAt = body ? validTimestamp(body.recordedAt) : null; - if (weightKg === null || recordedAt === null) { - return c.json(jsonError('Enter a valid weight and date.'), 400); - } - const result = await c.env.DB.prepare( - 'UPDATE weight_entries SET weight_kg = ?, recorded_at = ? WHERE id = ? AND user_id = ?' - ) - .bind(weightKg, recordedAt, c.req.param('id'), c.get('userId')) - .run(); - if (!result.meta.changes) return c.json({ message: 'Weight entry not found.' }, 404); - const row = await c.env.DB.prepare( - 'SELECT id, weight_kg, recorded_at FROM weight_entries WHERE id = ? AND user_id = ?' - ) - .bind(c.req.param('id'), c.get('userId')) - .first(); - if (!row) return c.json({ message: 'The weight entry could not be read back.' }, 500); - return c.json(mapWeight(row)); - }); +async function patchWeightsId(c: AppContext) { + const body = await c.req.json>().catch(() => null); + const weightKg = body ? finiteNumber(body.weightKg, 30, 400) : null; + const recordedAt = body ? validTimestamp(body.recordedAt) : null; + if (weightKg === null || recordedAt === null) { + return c.json(jsonError('Enter a valid weight and date.'), 400); + } + const result = await c.env.DB.prepare( + 'UPDATE weight_entries SET weight_kg = ?, recorded_at = ? WHERE id = ? AND user_id = ?' + ) + .bind(weightKg, recordedAt, paramId(c), c.get('userId')) + .run(); + if (!result.meta.changes) return c.json({ message: 'Weight entry not found.' }, 404); + const row = await c.env.DB.prepare( + 'SELECT id, weight_kg, recorded_at FROM weight_entries WHERE id = ? AND user_id = ?' + ) + .bind(paramId(c), c.get('userId')) + .first(); + if (!row) return c.json({ message: 'The weight entry could not be read back.' }, 500); + return c.json(mapWeight(row)); +} - app.delete('/api/app/weights/:id', async (c) => { - const result = await c.env.DB.prepare('DELETE FROM weight_entries WHERE id = ? AND user_id = ?') - .bind(c.req.param('id'), c.get('userId')) - .run(); - return result.meta.changes - ? c.body(null, 204) - : c.json({ message: 'Weight entry not found.' }, 404); - }); +async function deleteWeightsId(c: AppContext) { + const result = await c.env.DB.prepare('DELETE FROM weight_entries WHERE id = ? AND user_id = ?') + .bind(paramId(c), c.get('userId')) + .run(); + return result.meta.changes + ? c.body(null, 204) + : c.json({ message: 'Weight entry not found.' }, 404); +} + +export function registerJournalRoutes(app: App) { + app.get('/api/app/foods', getFoods); + app.post('/api/app/foods', postFoods); + app.put('/api/app/foods/:id', putFoodsId); + app.patch('/api/app/foods/:id', patchFoodsId); + app.delete('/api/app/foods/:id', deleteFoodsId); + app.post('/api/app/entries', postEntries); + app.patch('/api/app/entries/:id', patchEntriesId); + app.delete('/api/app/entries/:id', deleteEntriesId); + app.post('/api/app/water', postWater); + app.patch('/api/app/water/:id', patchWaterId); + app.delete('/api/app/water/:id', deleteWaterId); + app.post('/api/app/medications', postMedications); + app.patch('/api/app/medications/:id', patchMedicationsId); + app.post('/api/app/medication-check-ins', postMedicationCheckIns); + app.delete('/api/app/medication-check-ins/:id', deleteMedicationCheckInsId); + app.post('/api/app/weights', postWeights); + app.patch('/api/app/weights/:id', patchWeightsId); + app.delete('/api/app/weights/:id', deleteWeightsId); } diff --git a/src/worker/reads.ts b/src/worker/reads.ts index 75f2456..d7aadfc 100644 --- a/src/worker/reads.ts +++ b/src/worker/reads.ts @@ -27,240 +27,243 @@ import { type WeightRow, } from './db'; import { conditionalJson, dateKey, jsonError, parseRange } from './http'; -import type { App } from './types'; +import type { App, AppContext } from './types'; -export function registerReadRoutes(app: App) { - app.get('/api/app/dashboard', async (c) => { - const range = parseRange(c); - if (!range || range.end - range.start > 48 * 60 * 60 * 1000) { - return c.json(jsonError('Choose a valid local-day range.'), 400); - } - const userId = c.get('userId'); - const [ - profile, - foodsResult, - entriesResult, - waterResult, - medicationResult, - medicationCheckInResult, - latestWeightRow, - fastingRows, - ] = await Promise.all([ - readProfile(c.env.DB, userId, c.get('userName')), - c.env.DB.prepare(DASHBOARD_FOODS_QUERY).bind(userId).all(), - c.env.DB.prepare( - `SELECT * FROM food_entries +async function readDashboard(c: AppContext) { + const range = parseRange(c); + if (!range || range.end - range.start > 48 * 60 * 60 * 1000) { + return c.json(jsonError('Choose a valid local-day range.'), 400); + } + const userId = c.get('userId'); + const [ + profile, + foodsResult, + entriesResult, + waterResult, + medicationResult, + medicationCheckInResult, + latestWeightRow, + fastingRows, + ] = await Promise.all([ + readProfile(c.env.DB, userId, c.get('userName')), + c.env.DB.prepare(DASHBOARD_FOODS_QUERY).bind(userId).all(), + c.env.DB.prepare( + `SELECT * FROM food_entries WHERE user_id = ? AND eaten_at >= ? AND eaten_at < ? ORDER BY eaten_at DESC` - ) - .bind(userId, range.start, range.end) - .all(), - c.env.DB.prepare( - `SELECT id, amount_ml, drank_at FROM water_entries + ) + .bind(userId, range.start, range.end) + .all(), + c.env.DB.prepare( + `SELECT id, amount_ml, drank_at FROM water_entries WHERE user_id = ? AND drank_at >= ? AND drank_at < ? ORDER BY drank_at DESC` - ) - .bind(userId, range.start, range.end) - .all(), - c.env.DB.prepare( - `SELECT id, name, schedule, created_at, archived_at FROM medications + ) + .bind(userId, range.start, range.end) + .all(), + c.env.DB.prepare( + `SELECT id, name, schedule, created_at, archived_at FROM medications WHERE user_id = ? AND archived_at IS NULL ORDER BY created_at ASC` - ) - .bind(userId) - .all(), - c.env.DB.prepare( - `SELECT id, medication_id, taken_on, taken_at FROM medication_check_ins + ) + .bind(userId) + .all(), + c.env.DB.prepare( + `SELECT id, medication_id, taken_on, taken_at FROM medication_check_ins WHERE user_id = ? AND taken_on = ? ORDER BY taken_at DESC` - ) - .bind(userId, c.req.query('date') ?? '') - .all(), - c.env.DB.prepare( - `SELECT id, weight_kg, recorded_at FROM weight_entries + ) + .bind(userId, c.req.query('date') ?? '') + .all(), + c.env.DB.prepare( + `SELECT id, weight_kg, recorded_at FROM weight_entries WHERE user_id = ? ORDER BY recorded_at DESC LIMIT 1` - ) - .bind(userId) - .first(), - c.env.DB.prepare( - `SELECT id, food_id, food_name, amount, unit_label, calories, carbs_g, + ) + .bind(userId) + .first(), + c.env.DB.prepare( + `SELECT id, food_id, food_name, amount, unit_label, calories, carbs_g, protein_g, fibre_g, eaten_at FROM food_entries WHERE user_id = ? AND eaten_at >= ? ORDER BY eaten_at ASC` - ) - .bind(userId, range.start - 31 * 24 * 60 * 60 * 1000) - .all(), - ]); - - const foods: Food[] = foodsResult.results.map(mapFood); - const entries: FoodEntry[] = entriesResult.results.map(mapFoodEntry); - const waterEntries: WaterEntry[] = waterResult.results.map(mapWater); - const medications: Medication[] = medicationResult.results.map(mapMedication); - const medicationCheckIns: MedicationCheckIn[] = - medicationCheckInResult.results.map(mapMedicationCheckIn); - const totals = entries.reduce( - (sum, entry) => ({ - calories: sum.calories + entry.calories, - carbsG: sum.carbsG + entry.carbsG, - proteinG: sum.proteinG + entry.proteinG, - fibreG: sum.fibreG + entry.fibreG, - waterMl: sum.waterMl, - }), - { - calories: 0, - carbsG: 0, - proteinG: 0, - fibreG: 0, - waterMl: waterEntries.reduce((sum, entry) => sum + entry.amountMl, 0), - } - ); - const latestWeight = latestWeightRow ? mapWeight(latestWeightRow) : null; - const timezone = c.req.query('timezone') ?? 'UTC'; - const target = calculateNutritionTarget({ - weightKg: latestWeight?.weightKg ?? null, - heightCm: profile.heightCm, - ageYears: profile.ageYears, - equationProfile: profile.equationProfile, - activityLevel: profile.activityLevel, - goal: profile.goal, - manualCalorieTarget: profile.manualCalorieTarget, - manualCalorieRange: profile.manualCalorieRange, - }); + ) + .bind(userId, range.start - 31 * 24 * 60 * 60 * 1000) + .all(), + ]); - const dashboard: Dashboard = { - profile, - foods, - entries, - waterEntries, - medications, - medicationCheckIns, - latestWeight, - totals: { - calories: round(totals.calories), - carbsG: round(totals.carbsG, 1), - proteinG: round(totals.proteinG, 1), - fibreG: round(totals.fibreG, 1), - waterMl: totals.waterMl, - }, - target, - completedFasts: calculateCompletedFasts(fastingRows.results.map(mapFoodEntry), timezone), - date: c.req.query('date') ?? '', - timezone, - }; - return conditionalJson(c, dashboard); + const foods: Food[] = foodsResult.results.map(mapFood); + const entries: FoodEntry[] = entriesResult.results.map(mapFoodEntry); + const waterEntries: WaterEntry[] = waterResult.results.map(mapWater); + const medications: Medication[] = medicationResult.results.map(mapMedication); + const medicationCheckIns: MedicationCheckIn[] = + medicationCheckInResult.results.map(mapMedicationCheckIn); + const totals = entries.reduce( + (sum, entry) => ({ + calories: sum.calories + entry.calories, + carbsG: sum.carbsG + entry.carbsG, + proteinG: sum.proteinG + entry.proteinG, + fibreG: sum.fibreG + entry.fibreG, + waterMl: sum.waterMl, + }), + { + calories: 0, + carbsG: 0, + proteinG: 0, + fibreG: 0, + waterMl: waterEntries.reduce((sum, entry) => sum + entry.amountMl, 0), + } + ); + const latestWeight = latestWeightRow ? mapWeight(latestWeightRow) : null; + const timezone = c.req.query('timezone') ?? 'UTC'; + const target = calculateNutritionTarget({ + weightKg: latestWeight?.weightKg ?? null, + heightCm: profile.heightCm, + ageYears: profile.ageYears, + equationProfile: profile.equationProfile, + activityLevel: profile.activityLevel, + goal: profile.goal, + manualCalorieTarget: profile.manualCalorieTarget, + manualCalorieRange: profile.manualCalorieRange, }); - app.get('/api/app/history', async (c) => { - const range = parseRange(c); - const requestedDays = Number(c.req.query('days')); - const rangeDays = requestedDays === 30 ? 30 : requestedDays === 7 ? 7 : undefined; - const timezone = c.req.query('timezone') || 'UTC'; - if (!range || range.end - range.start > 366 * 24 * 60 * 60 * 1000) { - return c.json(jsonError('Choose a history range of one year or less.'), 400); - } - const userId = c.get('userId'); - const [profile, entriesResult, waterResult, weightResult, medicationResult, priorEntry] = - await Promise.all([ - readProfile(c.env.DB, userId, c.get('userName')), - c.env.DB.prepare( - `SELECT * FROM food_entries + const dashboard: Dashboard = { + profile, + foods, + entries, + waterEntries, + medications, + medicationCheckIns, + latestWeight, + totals: { + calories: round(totals.calories), + carbsG: round(totals.carbsG, 1), + proteinG: round(totals.proteinG, 1), + fibreG: round(totals.fibreG, 1), + waterMl: totals.waterMl, + }, + target, + completedFasts: calculateCompletedFasts(fastingRows.results.map(mapFoodEntry), timezone), + date: c.req.query('date') ?? '', + timezone, + }; + return conditionalJson(c, dashboard); +} + +async function readHistory(c: AppContext) { + const range = parseRange(c); + const requestedDays = Number(c.req.query('days')); + const rangeDays = requestedDays === 30 ? 30 : requestedDays === 7 ? 7 : undefined; + const timezone = c.req.query('timezone') || 'UTC'; + if (!range || range.end - range.start > 366 * 24 * 60 * 60 * 1000) { + return c.json(jsonError('Choose a history range of one year or less.'), 400); + } + const userId = c.get('userId'); + const [profile, entriesResult, waterResult, weightResult, medicationResult, priorEntry] = + await Promise.all([ + readProfile(c.env.DB, userId, c.get('userName')), + c.env.DB.prepare( + `SELECT * FROM food_entries WHERE user_id = ? AND eaten_at >= ? AND eaten_at < ? ORDER BY eaten_at ASC` - ) - .bind(userId, range.start, range.end) - .all(), - c.env.DB.prepare( - `SELECT id, amount_ml, drank_at FROM water_entries + ) + .bind(userId, range.start, range.end) + .all(), + c.env.DB.prepare( + `SELECT id, amount_ml, drank_at FROM water_entries WHERE user_id = ? AND drank_at >= ? AND drank_at < ? ORDER BY drank_at ASC` - ) - .bind(userId, range.start, range.end) - .all(), - c.env.DB.prepare( - `SELECT id, weight_kg, recorded_at FROM weight_entries + ) + .bind(userId, range.start, range.end) + .all(), + c.env.DB.prepare( + `SELECT id, weight_kg, recorded_at FROM weight_entries WHERE user_id = ? AND recorded_at >= ? AND recorded_at < ? ORDER BY recorded_at ASC` - ) - .bind(userId, range.start, range.end) - .all(), - c.env.DB.prepare( - `SELECT c.id, c.medication_id, c.taken_at, m.name AS medication_name + ) + .bind(userId, range.start, range.end) + .all(), + c.env.DB.prepare( + `SELECT c.id, c.medication_id, c.taken_at, m.name AS medication_name FROM medication_check_ins c JOIN medications m ON m.id = c.medication_id AND m.user_id = c.user_id WHERE c.user_id = ? AND c.taken_at >= ? AND c.taken_at < ? ORDER BY c.taken_at ASC` - ) - .bind(userId, range.start, range.end) - .all(), - c.env.DB.prepare( - `SELECT * FROM food_entries + ) + .bind(userId, range.start, range.end) + .all(), + c.env.DB.prepare( + `SELECT * FROM food_entries WHERE user_id = ? AND eaten_at < ? ORDER BY eaten_at DESC LIMIT 1` - ) - .bind(userId, range.start) - .first(), - ]); - const entries = entriesResult.results.map(mapFoodEntry); - const water = waterResult.results.map(mapWater); - const dayMap = new Map(); - const ensureDay = (key: string) => { - const existing = dayMap.get(key); - if (existing) return existing; - const created: HistoryDay = { - date: key, - calories: 0, - carbsG: 0, - proteinG: 0, - fibreG: 0, - waterMl: 0, - fastCount: 0, - }; - dayMap.set(key, created); - return created; + ) + .bind(userId, range.start) + .first(), + ]); + const entries = entriesResult.results.map(mapFoodEntry); + const water = waterResult.results.map(mapWater); + const dayMap = new Map(); + const ensureDay = (key: string) => { + const existing = dayMap.get(key); + if (existing) return existing; + const created: HistoryDay = { + date: key, + calories: 0, + carbsG: 0, + proteinG: 0, + fibreG: 0, + waterMl: 0, + fastCount: 0, }; + dayMap.set(key, created); + return created; + }; - if (rangeDays) { - for (let index = 0; index < rangeDays; index += 1) { - ensureDay(dateKey(range.start + index * 24 * 60 * 60 * 1000, timezone)); - } + if (rangeDays) { + for (let index = 0; index < rangeDays; index += 1) { + ensureDay(dateKey(range.start + index * 24 * 60 * 60 * 1000, timezone)); } - for (const entry of entries) { - const day = ensureDay(dateKey(entry.eatenAt, timezone)); - day.calories += entry.calories; - day.carbsG += entry.carbsG; - day.proteinG += entry.proteinG; - day.fibreG += entry.fibreG; + } + for (const entry of entries) { + const day = ensureDay(dateKey(entry.eatenAt, timezone)); + day.calories += entry.calories; + day.carbsG += entry.carbsG; + day.proteinG += entry.proteinG; + day.fibreG += entry.fibreG; + } + for (const entry of water) { + ensureDay(dateKey(entry.drankAt, timezone)).waterMl += entry.amountMl; + } + const fastingEntries = priorEntry ? [mapFoodEntry(priorEntry), ...entries] : entries; + const fastingThreshold = profile.fastingThresholdHours; + for (const fast of calculateCompletedFasts(fastingEntries, timezone)) { + if ( + fast.endAt >= range.start && + fast.endAt < range.end && + fast.durationHours >= fastingThreshold + ) { + ensureDay(dateKey(fast.endAt, timezone)).fastCount += 1; } - for (const entry of water) { - ensureDay(dateKey(entry.drankAt, timezone)).waterMl += entry.amountMl; - } - const fastingEntries = priorEntry ? [mapFoodEntry(priorEntry), ...entries] : entries; - const fastingThreshold = profile.fastingThresholdHours; - for (const fast of calculateCompletedFasts(fastingEntries, timezone)) { - if ( - fast.endAt >= range.start && - fast.endAt < range.end && - fast.durationHours >= fastingThreshold - ) { - ensureDay(dateKey(fast.endAt, timezone)).fastCount += 1; - } - } - const days = [...dayMap.values()] - .sort((a, b) => a.date.localeCompare(b.date)) - .map((day) => ({ - ...day, - calories: round(day.calories), - carbsG: round(day.carbsG, 1), - proteinG: round(day.proteinG, 1), - fibreG: round(day.fibreG, 1), - })); + } + const days = [...dayMap.values()] + .sort((a, b) => a.date.localeCompare(b.date)) + .map((day) => ({ + ...day, + calories: round(day.calories), + carbsG: round(day.carbsG, 1), + proteinG: round(day.proteinG, 1), + fibreG: round(day.fibreG, 1), + })); - const response: HistoryResponse = { - days, - weights: weightResult.results.map(mapWeight), - entries, - medicationEvents: medicationResult.results.map((row) => ({ - id: row.id, - medicationId: row.medication_id, - medicationName: row.medication_name, - takenAt: row.taken_at, - })), - ...(rangeDays ? { rangeDays } : {}), - }; - return conditionalJson(c, response); - }); + const response: HistoryResponse = { + days, + weights: weightResult.results.map(mapWeight), + entries, + medicationEvents: medicationResult.results.map((row) => ({ + id: row.id, + medicationId: row.medication_id, + medicationName: row.medication_name, + takenAt: row.taken_at, + })), + ...(rangeDays ? { rangeDays } : {}), + }; + return conditionalJson(c, response); +} + +export function registerReadRoutes(app: App) { + app.get('/api/app/dashboard', readDashboard); + app.get('/api/app/history', readHistory); } diff --git a/src/worker/types.ts b/src/worker/types.ts index 3dad2c2..e7508ed 100644 --- a/src/worker/types.ts +++ b/src/worker/types.ts @@ -1,4 +1,4 @@ -import type { Hono } from 'hono'; +import type { Context, Hono } from 'hono'; import type { AuthBindings } from '../server/auth'; export type AppBindings = AuthBindings; @@ -11,3 +11,4 @@ export type AppVariables = { }; export type App = Hono<{ Bindings: AppBindings; Variables: AppVariables }>; +export type AppContext = Context<{ Bindings: AppBindings; Variables: AppVariables }>;