Initial commit: Second Brain Platform

Complete platform with unified design system and real API integration.

Apps: Dashboard, Fitness, Budget, Inventory, Trips, Reader, Media, Settings
Infrastructure: SvelteKit + Python gateway + Docker Compose
This commit is contained in:
Yusuf Suleman
2026-03-28 23:20:40 -05:00
commit d3e250e361
159 changed files with 44797 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
const API_BASE = import.meta.env.VITE_API_URL || '';
export function getToken(): string | null {
return typeof window !== 'undefined' ? localStorage.getItem('session_token') : null;
}
export function hasToken(): boolean {
return !!getToken();
}
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
if (!token && typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
window.location.href = '/login';
throw new Error('No token');
}
const headers: Record<string, string> = {
...(options.headers as Record<string, string> || {})
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (options.body && typeof options.body === 'string') {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: 'include'
});
if (res.status === 401) {
if (typeof window !== 'undefined') {
localStorage.removeItem('session_token');
window.location.href = '/login';
}
throw new Error('Unauthorized');
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || `HTTP ${res.status}`);
}
return res.json();
}
export function get<T>(path: string) {
return api<T>(path);
}
export function post<T>(path: string, data: unknown) {
return api<T>(path, { method: 'POST', body: JSON.stringify(data) });
}
export function patch<T>(path: string, data: unknown) {
return api<T>(path, { method: 'PATCH', body: JSON.stringify(data) });
}
export function put<T>(path: string, data: unknown) {
return api<T>(path, { method: 'PUT', body: JSON.stringify(data) });
}
export function del<T>(path: string) {
return api<T>(path, { method: 'DELETE' });
}
export function today(): string {
return new Date().toISOString().split('T')[0];
}
export function formatDate(d: string): string {
return new Date(d + 'T00:00:00').toLocaleDateString('en-US', {
weekday: 'short', month: 'short', day: 'numeric'
});
}

View File

@@ -0,0 +1,165 @@
export interface User {
id: string;
username: string;
display_name: string;
telegram_user_id?: string;
}
export interface Food {
id: string;
name: string;
brand?: string;
barcode?: string;
base_unit: string;
calories_per_base: number;
protein_per_base: number;
carbs_per_base: number;
fat_per_base: number;
status: string;
image_path?: string;
servings: FoodServing[];
aliases?: FoodAlias[];
score?: number;
match_type?: string;
}
export interface FoodServing {
id: string;
food_id: string;
name: string;
amount_in_base: number;
is_default: number;
}
export interface FoodAlias {
id: string;
food_id: string;
alias: string;
alias_normalized: string;
}
export interface FoodEntry {
id: string;
user_id: string;
food_id?: string;
meal_type: string;
entry_date: string;
entry_type: string;
quantity: number;
unit: string;
serving_description?: string;
snapshot_food_name: string;
snapshot_serving_label?: string;
snapshot_grams?: number;
snapshot_calories: number;
snapshot_protein: number;
snapshot_carbs: number;
snapshot_fat: number;
source: string;
entry_method: string;
raw_text?: string;
confidence_score?: number;
note?: string;
food_image_path?: string;
created_at: string;
}
export interface DailyTotals {
total_calories: number;
total_protein: number;
total_carbs: number;
total_fat: number;
entry_count: number;
}
export interface Goal {
id: string;
user_id: string;
start_date: string;
end_date?: string;
calories: number;
protein: number;
carbs: number;
fat: number;
is_active: number;
}
export interface MealTemplate {
id: string;
user_id: string;
name: string;
meal_type?: string;
is_favorite: number;
items: MealTemplateItem[];
}
export interface MealTemplateItem {
id: string;
food_id: string;
quantity: number;
unit: string;
snapshot_food_name: string;
snapshot_calories: number;
snapshot_protein: number;
snapshot_carbs: number;
snapshot_fat: number;
}
export interface QueueItem {
id: string;
user_id: string;
raw_text: string;
proposed_food_id?: string;
candidates_json?: string;
confidence: number;
meal_type?: string;
entry_date?: string;
source?: string;
created_at: string;
}
export interface ExternalFood {
name: string;
brand?: string;
barcode?: string;
calories_per_100g: number;
protein_per_100g: number;
carbs_per_100g: number;
fat_per_100g: number;
serving_size_text?: string;
serving_grams?: number;
source: string;
relevance_score?: number;
}
export interface ResolveResult {
resolution_type: 'matched' | 'confirm' | 'queued' | 'quick_add' | 'ai_estimated' | 'external_match';
confidence: number;
matched_food?: Food;
candidate_foods: Food[];
external_results: ExternalFood[];
ai_estimate?: Record<string, unknown>;
parsed: {
quantity: number;
unit: string;
food_description: string;
meal_type?: string;
brand?: string;
modifiers?: string;
exclusions?: string;
};
raw_text: string;
queue_id?: string;
reason?: string;
}
export type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
export const MEAL_TYPES: MealType[] = ['breakfast', 'lunch', 'dinner', 'snack'];
export const MEAL_LABELS: Record<MealType, string> = {
breakfast: 'B',
lunch: 'L',
dinner: 'D',
snack: 'S'
};

View File

@@ -0,0 +1,270 @@
<script lang="ts">
import { get, post } from '$lib/api/client.ts';
import type { Food, MealType, ResolveResult } from '$lib/api/types.ts';
import { MEAL_TYPES } from '$lib/api/types.ts';
let { date = '', defaultMeal = 'snack' as MealType, onSave = () => {}, onClose = () => {} }:
{ date: string; defaultMeal: MealType; onSave: () => void; onClose: () => void } = $props();
let query = $state('');
let results = $state<Food[]>([]);
let recentFoods = $state<Food[]>([]);
let searching = $state(false);
let saving = $state(false);
let selectedFood = $state<Food | null>(null);
let selectedServing = $state('');
let quantity = $state(1);
let mealType = $state<MealType>(defaultMeal);
let mode = $state<'search' | 'quick' | 'ai'>('search');
let quickCalories = $state(0);
let quickName = $state('Quick add');
let aiQuery = $state('');
let aiResolving = $state(false);
let aiResult = $state<ResolveResult & { snapshot_name_override?: string; note?: string } | null>(null);
let searchTimeout: ReturnType<typeof setTimeout>;
$effect(() => { loadRecent(); });
async function loadRecent() {
try { recentFoods = await get<Food[]>('/api/foods/recent?limit=15'); } catch {}
}
async function doSearch() {
if (!query.trim()) { results = []; return; }
searching = true;
try { results = await get<Food[]>(`/api/foods/search?q=${encodeURIComponent(query)}&limit=10`); }
catch {} finally { searching = false; }
}
function onInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(doSearch, 300);
}
function selectFood(food: Food) {
selectedFood = food;
const defaultServing = food.servings?.find(s => s.is_default) || food.servings?.[0];
selectedServing = defaultServing?.id || '';
quantity = 1;
}
async function aiResolve() {
if (!aiQuery.trim()) return;
aiResolving = true;
aiResult = null;
try {
const result = await post<ResolveResult>('/api/foods/resolve', {
raw_phrase: aiQuery,
meal_type: mealType,
entry_date: date,
source: 'web',
});
aiResult = result;
// If matched or AI estimated, auto-select the food
if ((result.resolution_type === 'matched' || result.resolution_type === 'ai_estimated') && result.matched_food) {
selectFood(result.matched_food);
quantity = result.parsed?.quantity || 1;
}
} catch {} finally { aiResolving = false; }
}
async function save() {
saving = true;
try {
if (mode === 'quick') {
await post('/api/entries', {
entry_type: 'quick_add',
meal_type: mealType,
entry_date: date,
snapshot_food_name: quickName,
snapshot_calories: quickCalories,
source: 'web',
entry_method: 'quick_add',
});
} else if (selectedFood) {
const entryData: Record<string, unknown> = {
food_id: selectedFood.id,
meal_type: mealType,
entry_date: date,
quantity,
serving_id: selectedServing || undefined,
source: 'web',
entry_method: mode === 'ai' ? 'ai_plate' : 'search',
raw_text: aiQuery || undefined,
};
if (aiResult?.snapshot_name_override) {
entryData.snapshot_food_name_override = aiResult.snapshot_name_override;
}
if (aiResult?.note) {
entryData.note = aiResult.note;
}
await post('/api/entries', entryData);
}
onSave();
} catch {} finally { saving = false; }
}
function calcNutrition(food: Food, qty: number, servingId: string) {
const serving = food.servings?.find(s => s.id === servingId);
const mult = serving ? qty * serving.amount_in_base : qty;
return {
cal: Math.round(food.calories_per_base * mult),
pro: Math.round(food.protein_per_base * mult),
carb: Math.round(food.carbs_per_base * mult),
fat: Math.round(food.fat_per_base * mult),
};
}
</script>
<div class="modal modal-open" role="dialog">
<div class="modal-box max-w-lg max-h-[90vh]">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={onClose}>X</button>
<h3 class="font-bold text-lg mb-3">Add Food</h3>
<!-- Meal type pills -->
<div class="flex gap-1 mb-3">
{#each MEAL_TYPES as mt}
<button class="btn btn-xs" class:btn-primary={mealType === mt} onclick={() => mealType = mt}>
{mt}
</button>
{/each}
</div>
<!-- Mode tabs -->
<div role="tablist" class="tabs tabs-boxed mb-3">
<button role="tab" class="tab" class:tab-active={mode === 'search'} onclick={() => { mode = 'search'; selectedFood = null; }}>Search</button>
<button role="tab" class="tab" class:tab-active={mode === 'ai'} onclick={() => { mode = 'ai'; selectedFood = null; }}>AI Describe</button>
<button role="tab" class="tab" class:tab-active={mode === 'quick'} onclick={() => { mode = 'quick'; selectedFood = null; }}>Quick Add</button>
</div>
{#if mode === 'quick'}
<div class="flex flex-col gap-3">
<input class="input input-bordered w-full" placeholder="Label (optional)" bind:value={quickName} />
<input class="input input-bordered w-full" type="number" placeholder="Calories" bind:value={quickCalories} />
<button class="btn btn-primary w-full" onclick={save} disabled={saving || quickCalories <= 0}>
{#if saving}<span class="loading loading-spinner loading-sm"></span>{/if}
Add {quickCalories} cal to {mealType}
</button>
</div>
{:else if mode === 'ai' && !selectedFood}
<!-- AI describe mode -->
<div class="flex flex-col gap-3">
<textarea
class="textarea textarea-bordered w-full"
rows="3"
placeholder="Describe what you ate, e.g.&#10;2 mince tacos, no sour cream&#10;A scoop of vanilla ice cream&#10;Homemade smash burger"
bind:value={aiQuery}
></textarea>
<button class="btn btn-primary w-full" onclick={aiResolve} disabled={aiResolving || !aiQuery.trim()}>
{#if aiResolving}<span class="loading loading-spinner loading-sm"></span> Estimating...{:else}Estimate with AI{/if}
</button>
{#if aiResult && aiResult.resolution_type === 'queued'}
<div class="alert alert-warning text-sm">
<span>Could not estimate. Queued for review.</span>
</div>
{/if}
{#if aiResult && aiResult.resolution_type === 'confirm' && aiResult.candidate_foods?.length}
<div class="text-sm font-medium">Did you mean one of these?</div>
{#each aiResult.candidate_foods as c}
<button class="btn btn-sm btn-outline w-full justify-between" onclick={() => selectFood(c)}>
<span>{c.name}</span>
<span class="text-xs">{c.calories_per_base} cal/{c.base_unit}</span>
</button>
{/each}
{/if}
</div>
{:else if selectedFood}
<!-- Selected food detail -->
<div class="card bg-base-200 p-3 mb-3">
<div class="flex justify-between items-start">
<div>
<div class="font-semibold">{selectedFood.name}</div>
{#if selectedFood.brand}<div class="text-xs text-base-content/50">{selectedFood.brand}</div>{/if}
{#if selectedFood.status === 'ai_created'}<span class="badge badge-xs badge-info">AI estimated</span>{/if}
</div>
<button class="btn btn-ghost btn-xs" onclick={() => selectedFood = null}>Change</button>
</div>
<div class="flex gap-3 mt-2">
<div class="form-control flex-1">
<label class="label py-0"><span class="label-text text-xs">Qty</span></label>
<input class="input input-bordered input-sm w-full" type="number" min="0.25" step="0.25" bind:value={quantity} />
</div>
{#if selectedFood.servings?.length > 0}
<div class="form-control flex-[2]">
<label class="label py-0"><span class="label-text text-xs">Serving</span></label>
<select class="select select-bordered select-sm w-full" bind:value={selectedServing}>
{#each selectedFood.servings as s}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</div>
{/if}
</div>
{#if selectedFood}
{@const n = calcNutrition(selectedFood, quantity, selectedServing)}
<div class="grid grid-cols-4 gap-2 mt-3 text-center text-sm">
<div><div class="font-bold text-primary">{n.cal}</div><div class="text-xs text-base-content/50">cal</div></div>
<div><div class="font-bold text-secondary">{n.pro}g</div><div class="text-xs text-base-content/50">protein</div></div>
<div><div class="font-bold text-accent">{n.carb}g</div><div class="text-xs text-base-content/50">carbs</div></div>
<div><div class="font-bold text-info">{n.fat}g</div><div class="text-xs text-base-content/50">fat</div></div>
</div>
{/if}
</div>
<button class="btn btn-primary w-full" onclick={save} disabled={saving}>
{#if saving}<span class="loading loading-spinner loading-sm"></span>{/if}
Add to {mealType}
</button>
{:else}
<!-- Search mode -->
<input
class="input input-bordered w-full mb-2"
placeholder="Search your foods..."
bind:value={query}
oninput={onInput}
/>
{#if searching}
<div class="flex justify-center py-4"><span class="loading loading-spinner loading-md"></span></div>
{:else}
<div class="overflow-y-auto max-h-60">
{#if query.trim() && results.length > 0}
{#each results as food}
<button class="w-full text-left px-3 py-2 hover:bg-base-200 rounded-lg flex justify-between items-center" onclick={() => selectFood(food)}>
<div>
<div class="text-sm font-medium">{food.name}</div>
{#if food.brand}<span class="text-xs text-base-content/40">{food.brand}</span>{/if}
</div>
<div class="text-xs text-base-content/50">{food.calories_per_base} cal/{food.base_unit}</div>
</button>
{/each}
{:else if query.trim()}
<div class="text-center py-4">
<div class="text-sm text-base-content/50 mb-2">No matches found</div>
<button class="btn btn-sm btn-outline" onclick={() => { mode = 'ai'; aiQuery = query; }}>
Try AI estimate for "{query}"
</button>
</div>
{:else}
{#if recentFoods.length > 0}
<div class="text-xs text-base-content/50 mb-1">Recent</div>
{/if}
{#each recentFoods as food}
<button class="w-full text-left px-3 py-2 hover:bg-base-200 rounded-lg flex justify-between items-center" onclick={() => selectFood(food)}>
<div>
<div class="text-sm font-medium">{food.name}</div>
<div class="text-xs text-base-content/50">{food.calories_per_base} cal/{food.base_unit}</div>
</div>
</button>
{/each}
{#if recentFoods.length === 0}
<div class="text-center text-sm text-base-content/50 py-4">No foods yet. Use AI Describe to add your first food.</div>
{/if}
{/if}
</div>
{/if}
{/if}
</div>
<form method="dialog" class="modal-backdrop"><button onclick={onClose}>close</button></form>
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
let { value = 0, goal = 0, label = '', unit = '', color = 'primary' }:
{ value: number; goal: number; label: string; unit: string; color: string } = $props();
let pct = $derived(goal > 0 ? Math.min((value / goal) * 100, 100) : 0);
let remaining = $derived(Math.max(goal - value, 0));
let over = $derived(value > goal && goal > 0);
let borderColor = $derived(over ? 'border-l-error' : color === 'primary' ? 'border-l-primary' : color === 'secondary' ? 'border-l-secondary' : color === 'accent' ? 'border-l-accent' : 'border-l-info');
let progressClass = $derived(over ? 'progress-error' : color === 'primary' ? 'progress-primary' : color === 'secondary' ? 'progress-secondary' : color === 'accent' ? 'progress-accent' : 'progress-info');
</script>
<div class="bg-base-200 rounded-xl p-4 border-l-4 {borderColor}">
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">{label}</div>
<div class="flex items-baseline gap-1.5 mt-1">
<span class="text-2xl font-bold">{Math.round(value)}</span>
<span class="text-sm text-base-content/40">/ {Math.round(goal)}{unit}</span>
</div>
<progress class="progress w-full h-1.5 mt-2 {progressClass}" value={pct} max="100"></progress>
<div class="text-xs mt-1 {over ? 'text-error' : 'text-base-content/40'}">
{#if over}{Math.round(value - goal)}{unit} over{:else}{Math.round(remaining)}{unit} left{/if}
</div>
</div>

View File

@@ -0,0 +1,84 @@
<script lang="ts">
let theme = $state('night');
let { user = null, currentPage = '' }: { user?: { display_name: string } | null; currentPage?: string } = $props();
function toggleTheme() {
theme = theme === 'night' ? 'light' : 'night';
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
function logout() {
localStorage.removeItem('session_token');
window.location.href = '/login';
}
$effect(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('theme');
if (saved) {
theme = saved;
document.documentElement.setAttribute('data-theme', saved);
}
}
});
let mobileOpen = $state(false);
const navItems = [
{ href: '/', label: 'Dashboard', page: 'dashboard' },
{ href: '/foods', label: 'Foods', page: 'foods' },
{ href: '/goals', label: 'Goals', page: 'goals' },
{ href: '/templates', label: 'Templates', page: 'templates' },
{ href: '/admin', label: 'Admin', page: 'admin' },
];
</script>
<div class="bg-base-200/80 backdrop-blur-md sticky top-0 z-50 border-b border-base-300">
<div class="navbar px-4">
<div class="flex-1 flex items-center gap-1">
<!-- Mobile hamburger -->
<button class="btn btn-ghost btn-sm btn-circle sm:hidden" onclick={() => mobileOpen = !mobileOpen}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>
</button>
<a href="/" class="flex items-center gap-2 text-xl font-bold text-primary hover:opacity-80 transition-opacity mr-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20V10"/>
<path d="M18 20V4"/>
<path d="M6 20v-4"/>
</svg>
<span class="hidden sm:inline">Calories</span>
</a>
{#each navItems as item}
<a href={item.href} class="btn btn-ghost btn-sm hidden sm:inline-flex" class:btn-active={currentPage === item.page}>{item.label}</a>
{/each}
</div>
<div class="flex-none flex items-center gap-2">
<!-- Theme toggle -->
<label class="swap swap-rotate btn btn-ghost btn-circle btn-sm">
<input type="checkbox" checked={theme === 'light'} onchange={toggleTheme} />
<svg class="swap-on h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/></svg>
<svg class="swap-off h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/></svg>
</label>
<!-- User dropdown -->
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm gap-1">
<div class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<span class="hidden sm:inline text-sm">{user?.display_name || 'User'}</span>
</div>
<ul class="dropdown-content menu bg-base-200 rounded-box z-[1] w-52 p-2 shadow-lg border border-base-300 mt-2">
<li><button onclick={logout}>Logout</button></li>
</ul>
</div>
</div>
</div>
{#if mobileOpen}
<div class="sm:hidden border-t border-base-300 px-4 py-2">
{#each navItems as item}
<a href={item.href} class="block py-2 px-3 rounded-lg hover:bg-base-300 transition-colors {currentPage === item.page ? 'text-primary font-semibold' : 'text-base-content'}" onclick={() => mobileOpen = false}>{item.label}</a>
{/each}
</div>
{/if}
</div>