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:
5
services/fitness/Dockerfile.backend
Normal file
5
services/fitness/Dockerfile.backend
Normal file
@@ -0,0 +1,5 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY server.py .
|
||||
EXPOSE 8095
|
||||
CMD ["python3", "server.py"]
|
||||
28
services/fitness/docker-compose.yml
Normal file
28
services/fitness/docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
container_name: calorietracker-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8095:8095"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
env_file: .env
|
||||
environment:
|
||||
- PORT=8095
|
||||
- DATA_DIR=/app/data
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: calorietracker-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8096:3000"
|
||||
environment:
|
||||
- VITE_API_URL=http://calorietracker-backend:8095
|
||||
depends_on:
|
||||
- backend
|
||||
16
services/fitness/frontend-legacy/Dockerfile
Normal file
16
services/fitness/frontend-legacy/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:22-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-slim
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/build ./build
|
||||
COPY --from=build /app/package.json ./
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
CMD ["node", "build"]
|
||||
2320
services/fitness/frontend-legacy/package-lock.json
generated
Normal file
2320
services/fitness/frontend-legacy/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
services/fitness/frontend-legacy/package.json
Normal file
28
services/fitness/frontend-legacy/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "calorietracker-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"daisyui": "^5.5.19",
|
||||
"tailwindcss": "^4.2.2"
|
||||
}
|
||||
}
|
||||
25
services/fitness/frontend-legacy/src/app.css
Normal file
25
services/fitness/frontend-legacy/src/app.css
Normal file
@@ -0,0 +1,25 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin 'daisyui' {
|
||||
themes: night, dim, light;
|
||||
}
|
||||
|
||||
:root {
|
||||
--header-height: 4rem;
|
||||
}
|
||||
|
||||
* {
|
||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: oklch(0.2 0.02 260); }
|
||||
::-webkit-scrollbar-thumb { background: oklch(0.4 0.02 260); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: oklch(0.5 0.02 260); }
|
||||
|
||||
/* Card hover elevation */
|
||||
.card-hover {
|
||||
transition: box-shadow 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
.card-hover:hover {
|
||||
box-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.15), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
5
services/fitness/frontend-legacy/src/app.d.ts
vendored
Normal file
5
services/fitness/frontend-legacy/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare global {
|
||||
namespace App {}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
services/fitness/frontend-legacy/src/app.html
Normal file
12
services/fitness/frontend-legacy/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="night">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%237c3aed%22 stroke-width=%222%22><path d=%22M12 20V10%22/><path d=%22M18 20V4%22/><path d=%22M6 20v-4%22/></svg>" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-prerender="true">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
39
services/fitness/frontend-legacy/src/hooks.server.ts
Normal file
39
services/fitness/frontend-legacy/src/hooks.server.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
const API_BACKEND = process.env.VITE_API_URL || 'http://localhost:8095';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
if (event.url.pathname.startsWith('/api/') || event.url.pathname.startsWith('/images/')) {
|
||||
const targetUrl = `${API_BACKEND}${event.url.pathname}${event.url.search}`;
|
||||
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of event.request.headers.entries()) {
|
||||
if (['authorization', 'content-type', 'cookie', 'x-api-key', 'x-telegram-user-id'].includes(key.toLowerCase())) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: event.request.method,
|
||||
headers,
|
||||
body: event.request.method !== 'GET' && event.request.method !== 'HEAD'
|
||||
? await event.request.arrayBuffer()
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Proxy error:', err);
|
||||
return new Response(JSON.stringify({ error: 'Backend unavailable' }), {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
81
services/fitness/frontend-legacy/src/lib/api/client.ts
Normal file
81
services/fitness/frontend-legacy/src/lib/api/client.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
165
services/fitness/frontend-legacy/src/lib/api/types.ts
Normal file
165
services/fitness/frontend-legacy/src/lib/api/types.ts
Normal 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'
|
||||
};
|
||||
@@ -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. 2 mince tacos, no sour cream A scoop of vanilla ice cream 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
37
services/fitness/frontend-legacy/src/routes/+layout.svelte
Normal file
37
services/fitness/frontend-legacy/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import Navbar from '$lib/components/Navbar.svelte';
|
||||
import { get, hasToken } from '$lib/api/client.ts';
|
||||
import type { User } from '$lib/api/types.ts';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { children } = $props();
|
||||
let user = $state<User | null>(null);
|
||||
|
||||
let currentPage = $derived(
|
||||
page.url.pathname === '/' ? 'dashboard' :
|
||||
page.url.pathname.startsWith('/foods') ? 'foods' :
|
||||
page.url.pathname.startsWith('/goals') ? 'goals' :
|
||||
page.url.pathname.startsWith('/templates') ? 'templates' :
|
||||
page.url.pathname.startsWith('/admin') ? 'admin' : ''
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined' && hasToken() && !page.url.pathname.startsWith('/login')) {
|
||||
get<User>('/api/user').then(u => user = u).catch(() => {});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Calorie Tracker</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-base-100 flex flex-col">
|
||||
{#if !page.url.pathname.startsWith('/login')}
|
||||
<Navbar {user} {currentPage} />
|
||||
{/if}
|
||||
<main class="flex-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
297
services/fitness/frontend-legacy/src/routes/+page.svelte
Normal file
297
services/fitness/frontend-legacy/src/routes/+page.svelte
Normal file
@@ -0,0 +1,297 @@
|
||||
<script lang="ts">
|
||||
import { get, del, today, formatDate } from '$lib/api/client.ts';
|
||||
import type { FoodEntry, DailyTotals, Goal, User, MealType, QueueItem } from '$lib/api/types.ts';
|
||||
import { MEAL_TYPES } from '$lib/api/types.ts';
|
||||
import AddFoodModal from '$lib/components/AddFoodModal.svelte';
|
||||
|
||||
let selectedDate = $state(today());
|
||||
let entries = $state<FoodEntry[]>([]);
|
||||
let totals = $state<DailyTotals>({ total_calories: 0, total_protein: 0, total_carbs: 0, total_fat: 0, entry_count: 0 });
|
||||
let goal = $state<Goal | null>(null);
|
||||
let users = $state<User[]>([]);
|
||||
let selectedUser = $state('');
|
||||
let showAddModal = $state(false);
|
||||
let addMealType = $state<MealType>('snack');
|
||||
let queueCount = $state(0);
|
||||
let loading = $state(true);
|
||||
let expandedMeals = $state<Set<string>>(new Set(['breakfast', 'lunch', 'dinner', 'snack']));
|
||||
|
||||
let isToday = $derived(selectedDate === today());
|
||||
|
||||
$effect(() => { loadUsers(); });
|
||||
$effect(() => { if (selectedUser) loadDay(); });
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
users = await get<User[]>('/api/users');
|
||||
const me = await get<User>('/api/user');
|
||||
selectedUser = me.id;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadDay() {
|
||||
loading = true;
|
||||
try {
|
||||
const [e, t, g, q] = await Promise.all([
|
||||
get<FoodEntry[]>(`/api/entries?date=${selectedDate}&user_id=${selectedUser}`),
|
||||
get<DailyTotals>(`/api/entries/totals?date=${selectedDate}&user_id=${selectedUser}`),
|
||||
get<Goal>(`/api/goals/for-date?date=${selectedDate}&user_id=${selectedUser}`).catch(() => null),
|
||||
get<QueueItem[]>('/api/resolution-queue').catch(() => []),
|
||||
]);
|
||||
entries = e; totals = t; goal = g; queueCount = q.length;
|
||||
} catch {} finally { loading = false; }
|
||||
}
|
||||
|
||||
function entriesByMeal(meal: MealType) { return entries.filter(e => e.meal_type === meal); }
|
||||
function mealCalories(meal: MealType) { return entriesByMeal(meal).reduce((s, e) => s + e.snapshot_calories, 0); }
|
||||
function mealProtein(meal: MealType) { return entriesByMeal(meal).reduce((s, e) => s + e.snapshot_protein, 0); }
|
||||
|
||||
async function deleteEntry(id: string) { await del(`/api/entries/${id}`); loadDay(); }
|
||||
function openAdd(meal: MealType) { addMealType = meal; showAddModal = true; }
|
||||
|
||||
function shiftDate(days: number) {
|
||||
const d = new Date(selectedDate + 'T00:00:00');
|
||||
d.setDate(d.getDate() + days);
|
||||
selectedDate = d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function toggleMeal(meal: string) {
|
||||
const next = new Set(expandedMeals);
|
||||
if (next.has(meal)) next.delete(meal); else next.add(meal);
|
||||
expandedMeals = next;
|
||||
}
|
||||
|
||||
function userName(id: string) { return users.find(u => u.id === id)?.display_name || ''; }
|
||||
</script>
|
||||
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<!-- Header row: title, date picker, user — all aligned -->
|
||||
<div class="flex items-center justify-between gap-4 mb-8">
|
||||
<h1 class="text-3xl font-bold">
|
||||
{#if isToday}Today{:else}{formatDate(selectedDate)}{/if}
|
||||
</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => shiftDate(-1)}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
<input type="date" class="input input-bordered input-sm" bind:value={selectedDate} />
|
||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => shiftDate(1)}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
{#if !isToday}
|
||||
<button class="btn btn-ghost btn-xs" onclick={() => selectedDate = today()}>Today</button>
|
||||
{/if}
|
||||
<div class="divider divider-horizontal mx-0"></div>
|
||||
<select class="select select-bordered select-sm" bind:value={selectedUser}>
|
||||
{#each users as u}<option value={u.id}>{u.display_name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-16"><span class="loading loading-spinner loading-lg text-primary"></span></div>
|
||||
{:else}
|
||||
<!-- Review queue -->
|
||||
{#if queueCount > 0}
|
||||
<a href="/admin" class="flex items-center justify-between bg-base-200 rounded-xl p-4 mb-6 border-l-4 border-l-warning">
|
||||
<div>
|
||||
<div class="font-medium text-sm">{queueCount} food{queueCount > 1 ? 's' : ''} need review</div>
|
||||
<div class="text-xs text-base-content/50">Tap to resolve</div>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-base-content/30" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- Stats cards (trips StatsBar style, with goal progress built in) -->
|
||||
{@const g = goal}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="card bg-gradient-to-br from-primary/10 to-primary/5 border border-primary/20 shadow-md hover:shadow-xl transition-shadow duration-300">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex flex-row items-center gap-3">
|
||||
<div class="p-3 rounded-2xl bg-primary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-primary" 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>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-base-content">{Math.round(totals.total_calories)}</div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">Calories</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if g}
|
||||
<progress class="progress progress-primary w-full h-1.5 mt-3" value={Math.min(totals.total_calories / g.calories * 100, 100)} max="100"></progress>
|
||||
<div class="text-xs text-base-content/40 mt-1">{Math.round(Math.max(g.calories - totals.total_calories, 0))} left of {Math.round(g.calories)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-gradient-to-br from-secondary/10 to-secondary/5 border border-secondary/20 shadow-md hover:shadow-xl transition-shadow duration-300">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex flex-row items-center gap-3">
|
||||
<div class="p-3 rounded-2xl bg-secondary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-secondary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 6.5a3.5 3.5 0 1 0 7 0 3.5 3.5 0 1 0-7 0"/><path d="M1.5 21v-1a7 7 0 0 1 7-7"/><path d="M17.5 12l2 2 4-4"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-base-content">{Math.round(totals.total_protein)}g</div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">Protein</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if g}
|
||||
<progress class="progress progress-secondary w-full h-1.5 mt-3" value={Math.min(totals.total_protein / g.protein * 100, 100)} max="100"></progress>
|
||||
<div class="text-xs text-base-content/40 mt-1">{Math.round(Math.max(g.protein - totals.total_protein, 0))}g left of {Math.round(g.protein)}g</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-gradient-to-br from-accent/10 to-accent/5 border border-accent/20 shadow-md hover:shadow-xl transition-shadow duration-300">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex flex-row items-center gap-3">
|
||||
<div class="p-3 rounded-2xl bg-accent/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 22 16 8"/><path d="M3.47 12.53 5 11l1.53 1.53a3.5 3.5 0 0 1 0 4.94L5 19l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z"/><path d="M7.47 8.53 9 7l1.53 1.53a3.5 3.5 0 0 1 0 4.94L9 15l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z"/><path d="M11.47 4.53 13 3l1.53 1.53a3.5 3.5 0 0 1 0 4.94L13 11l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z"/><path d="M20 2h2v2a4 4 0 0 1-4 4h-2V6a4 4 0 0 1 4-4Z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-base-content">{Math.round(totals.total_carbs)}g</div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">Carbs</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if g}
|
||||
<progress class="progress progress-accent w-full h-1.5 mt-3" value={Math.min(totals.total_carbs / g.carbs * 100, 100)} max="100"></progress>
|
||||
<div class="text-xs text-base-content/40 mt-1">{Math.round(Math.max(g.carbs - totals.total_carbs, 0))}g left of {Math.round(g.carbs)}g</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-gradient-to-br from-info/10 to-info/5 border border-info/20 shadow-md hover:shadow-xl transition-shadow duration-300">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex flex-row items-center gap-3">
|
||||
<div class="p-3 rounded-2xl bg-info/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5"/><path d="M8.5 8.5v.01"/><path d="M16 15.5v.01"/><path d="M12 12v.01"/><path d="M11 17v.01"/><path d="M7 14v.01"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-base-content">{Math.round(totals.total_fat)}g</div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">Fat</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if g}
|
||||
<progress class="progress progress-info w-full h-1.5 mt-3" value={Math.min(totals.total_fat / g.fat * 100, 100)} max="100"></progress>
|
||||
<div class="text-xs text-base-content/40 mt-1">{Math.round(Math.max(g.fat - totals.total_fat, 0))}g left of {Math.round(g.fat)}g</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !g}
|
||||
<div class="text-center text-sm text-base-content/40 mb-8">
|
||||
<a href="/goals" class="link link-primary">Set goals</a> to see progress bars
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Meals (trip day-view style: collapsible sections with timeline) -->
|
||||
{#each MEAL_TYPES as meal}
|
||||
{@const mealEntries = entriesByMeal(meal)}
|
||||
{@const mCal = mealCalories(meal)}
|
||||
{@const mPro = mealProtein(meal)}
|
||||
{@const expanded = expandedMeals.has(meal)}
|
||||
|
||||
<div class="mb-4">
|
||||
<!-- Meal header (like trip day header) -->
|
||||
<button class="w-full flex items-center gap-3 py-3 group" onclick={() => toggleMeal(meal)}>
|
||||
<div class="w-10 h-10 rounded-full {mealEntries.length > 0 ? 'bg-primary/15' : 'bg-base-300'} flex items-center justify-center shrink-0">
|
||||
<span class="text-sm font-bold {mealEntries.length > 0 ? 'text-primary' : 'text-base-content/30'}">{MEAL_TYPES.indexOf(meal) + 1}</span>
|
||||
</div>
|
||||
<div class="flex-1 text-left">
|
||||
<span class="font-bold capitalize">{meal}</span>
|
||||
{#if mCal > 0}
|
||||
<span class="text-base-content/40 text-sm ml-2">{Math.round(mCal)} cal</span>
|
||||
<span class="text-base-content/30 text-xs ml-1">· {Math.round(mPro)}g protein</span>
|
||||
{/if}
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-base-content/30 transition-transform {expanded ? 'rotate-180' : ''}" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="ml-4 border-l-2 border-base-300 pl-7">
|
||||
{#if mealEntries.length > 0}
|
||||
{#each mealEntries as entry}
|
||||
<div class="bg-base-200 rounded-xl mb-2 overflow-hidden group hover:shadow-md transition-shadow {entry.food_image_path ? '' : 'p-4'}">
|
||||
{#if entry.food_image_path}
|
||||
<div class="relative h-28">
|
||||
<img src="/images/{entry.food_image_path}" alt="" class="w-full h-full object-cover" />
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-base-300/90 via-base-300/30 to-transparent"></div>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-3 flex items-end justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-base-content">{entry.snapshot_food_name}</div>
|
||||
<div class="text-xs text-base-content/60">
|
||||
{entry.serving_description || `${entry.quantity} ${entry.unit}`}
|
||||
{#if entry.entry_method === 'ai_plate' || entry.entry_method === 'ai_label'}
|
||||
<span class="badge badge-xs badge-info">AI</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if entry.note}
|
||||
<div class="text-xs text-base-content/40 italic">{entry.note}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-right ml-3">
|
||||
<div class="font-bold text-base-content">{Math.round(entry.snapshot_calories)}</div>
|
||||
<div class="text-xs text-base-content/60">cal</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs btn-circle absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-base-300/50"
|
||||
onclick={() => deleteEntry(entry.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/50" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium">{entry.snapshot_food_name}</div>
|
||||
<div class="text-xs text-base-content/40 mt-0.5 flex items-center gap-2">
|
||||
<span>{entry.serving_description || `${entry.quantity} ${entry.unit}`}</span>
|
||||
{#if entry.entry_method === 'ai_plate' || entry.entry_method === 'ai_label'}
|
||||
<span class="badge badge-xs badge-info">AI</span>
|
||||
{/if}
|
||||
{#if entry.entry_method === 'quick_add'}
|
||||
<span class="badge badge-xs badge-ghost">quick</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if entry.note}
|
||||
<div class="text-xs text-base-content/30 italic mt-0.5">{entry.note}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-right">
|
||||
<div class="font-bold">{Math.round(entry.snapshot_calories)}</div>
|
||||
<div class="text-xs text-base-content/40">cal</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs btn-circle opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick={() => deleteEntry(entry.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/30" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="py-3 text-sm text-base-content/30">No entries</div>
|
||||
{/if}
|
||||
<button class="btn btn-ghost btn-sm gap-1 text-primary mt-1 mb-4" onclick={() => openAdd(meal)}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
|
||||
Add food
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- FAB for quick add (like trips) -->
|
||||
<button class="btn btn-primary btn-circle btn-lg shadow-lg fixed bottom-6 right-6 z-40" onclick={() => openAdd('snack')}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
|
||||
{#if showAddModal}
|
||||
<AddFoodModal date={selectedDate} defaultMeal={addMealType} onSave={() => { showAddModal = false; loadDay(); }} onClose={() => showAddModal = false} />
|
||||
{/if}
|
||||
171
services/fitness/frontend-legacy/src/routes/admin/+page.svelte
Normal file
171
services/fitness/frontend-legacy/src/routes/admin/+page.svelte
Normal file
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { get, post } from '$lib/api/client.ts';
|
||||
import type { QueueItem, Food } from '$lib/api/types.ts';
|
||||
|
||||
let queue = $state<QueueItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let tab = $state<'queue' | 'merge'>('queue');
|
||||
|
||||
let mergeSourceQuery = $state('');
|
||||
let mergeTargetQuery = $state('');
|
||||
let mergeSource = $state<Food | null>(null);
|
||||
let mergeTarget = $state<Food | null>(null);
|
||||
let mergeSourceResults = $state<Food[]>([]);
|
||||
let mergeTargetResults = $state<Food[]>([]);
|
||||
let merging = $state(false);
|
||||
|
||||
$effect(() => { loadQueue(); });
|
||||
|
||||
async function loadQueue() {
|
||||
loading = true;
|
||||
try { queue = await get<QueueItem[]>('/api/resolution-queue'); }
|
||||
catch {} finally { loading = false; }
|
||||
}
|
||||
|
||||
function parseCandidates(json: string | undefined): Array<{ food_id: string; name: string; score: number }> {
|
||||
if (!json) return [];
|
||||
try { return JSON.parse(json); } catch { return []; }
|
||||
}
|
||||
|
||||
async function resolveItem(queueId: string, action: string, foodId?: string) {
|
||||
await post(`/api/resolution-queue/${queueId}/resolve`, { action, food_id: foodId });
|
||||
loadQueue();
|
||||
}
|
||||
|
||||
async function searchMerge(query: string, which: 'source' | 'target') {
|
||||
if (!query.trim()) return;
|
||||
const results = await get<Food[]>(`/api/foods/search?q=${encodeURIComponent(query)}&limit=5`);
|
||||
if (which === 'source') mergeSourceResults = results;
|
||||
else mergeTargetResults = results;
|
||||
}
|
||||
|
||||
async function doMerge() {
|
||||
if (!mergeSource || !mergeTarget) return;
|
||||
if (!confirm(`Merge "${mergeSource.name}" into "${mergeTarget.name}"? Source will be archived.`)) return;
|
||||
merging = true;
|
||||
try {
|
||||
await post('/api/foods/merge', { source_id: mergeSource.id, target_id: mergeTarget.id });
|
||||
mergeSource = null; mergeTarget = null;
|
||||
mergeSourceQuery = ''; mergeTargetQuery = '';
|
||||
alert('Merged successfully');
|
||||
} catch {} finally { merging = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 py-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold">Admin</h1>
|
||||
<p class="text-base-content/50 text-sm mt-0.5">Review queue and manage duplicates</p>
|
||||
</div>
|
||||
|
||||
<div role="tablist" class="tabs tabs-boxed mb-6">
|
||||
<button role="tab" class="tab gap-2" class:tab-active={tab === 'queue'} onclick={() => tab = 'queue'}>
|
||||
Review Queue
|
||||
{#if queue.length > 0}<span class="badge badge-sm badge-warning">{queue.length}</span>{/if}
|
||||
</button>
|
||||
<button role="tab" class="tab" class:tab-active={tab === 'merge'} onclick={() => tab = 'merge'}>Merge Foods</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'queue'}
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12"><span class="loading loading-spinner loading-lg text-primary"></span></div>
|
||||
{:else if queue.length === 0}
|
||||
<div class="text-center py-16">
|
||||
<div class="p-4 rounded-2xl bg-success/15 inline-block mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
</div>
|
||||
<div class="text-base-content/50">No items to review</div>
|
||||
<div class="text-sm text-base-content/30 mt-1">All caught up!</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each queue as item}
|
||||
{@const candidates = parseCandidates(item.candidates_json)}
|
||||
<div class="rounded-xl border border-warning/20 bg-gradient-to-br from-warning/5 to-warning/0 p-5 shadow-sm card-hover">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<div class="font-bold text-lg">"{item.raw_text}"</div>
|
||||
<div class="flex gap-2 mt-1 text-xs text-base-content/40">
|
||||
{#if item.source}<span class="badge badge-xs badge-ghost">{item.source}</span>{/if}
|
||||
<span>confidence: {(item.confidence * 100).toFixed(0)}%</span>
|
||||
{#if item.meal_type}<span>{item.meal_type}</span>{/if}
|
||||
{#if item.entry_date}<span>{item.entry_date}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" onclick={() => resolveItem(item.id, 'dismissed')}>Dismiss</button>
|
||||
</div>
|
||||
|
||||
{#if candidates.length > 0}
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-2">Match to existing</div>
|
||||
<div class="space-y-1.5">
|
||||
{#each candidates as c}
|
||||
<button
|
||||
class="btn btn-sm btn-outline w-full justify-between"
|
||||
onclick={() => resolveItem(item.id, 'matched', c.food_id)}
|
||||
>
|
||||
<span>{c.name}</span>
|
||||
<span class="badge badge-sm badge-ghost">{(c.score * 100).toFixed(0)}%</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-sm text-base-content/30">No candidates — create a new food manually from the Foods page</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
<div class="rounded-xl border border-base-300 bg-base-200/50 p-6 shadow-md">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 rounded-xl bg-secondary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-secondary" viewBox="0 0 20 20" fill="currentColor"><path d="M8 5a1 1 0 100 2h5.586l-1.293 1.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L13.586 5H8zM12 15a1 1 0 100-2H6.414l1.293-1.293a1 1 0 10-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L6.414 15H12z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="font-bold text-lg">Merge Duplicate Foods</h2>
|
||||
<p class="text-xs text-base-content/40">Source gets archived. Entries and aliases move to target.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div class="text-xs text-error font-medium uppercase tracking-wide mb-2">Source (will be archived)</div>
|
||||
<div class="flex gap-1 mb-2">
|
||||
<input class="input input-bordered input-sm flex-1" placeholder="Search..." bind:value={mergeSourceQuery} />
|
||||
<button class="btn btn-sm btn-ghost" onclick={() => searchMerge(mergeSourceQuery, 'source')}>Go</button>
|
||||
</div>
|
||||
{#if mergeSource}
|
||||
<div class="rounded-lg border border-error/30 bg-error/5 p-3 text-sm font-medium">{mergeSource.name}</div>
|
||||
{/if}
|
||||
{#each mergeSourceResults as f}
|
||||
<button class="btn btn-sm btn-ghost w-full justify-start mt-1 text-left" onclick={() => { mergeSource = f; mergeSourceResults = []; }}>
|
||||
{f.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-xs text-success font-medium uppercase tracking-wide mb-2">Target (will keep)</div>
|
||||
<div class="flex gap-1 mb-2">
|
||||
<input class="input input-bordered input-sm flex-1" placeholder="Search..." bind:value={mergeTargetQuery} />
|
||||
<button class="btn btn-sm btn-ghost" onclick={() => searchMerge(mergeTargetQuery, 'target')}>Go</button>
|
||||
</div>
|
||||
{#if mergeTarget}
|
||||
<div class="rounded-lg border border-success/30 bg-success/5 p-3 text-sm font-medium">{mergeTarget.name}</div>
|
||||
{/if}
|
||||
{#each mergeTargetResults as f}
|
||||
<button class="btn btn-sm btn-ghost w-full justify-start mt-1 text-left" onclick={() => { mergeTarget = f; mergeTargetResults = []; }}>
|
||||
{f.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-warning w-full mt-6 gap-2" onclick={doMerge} disabled={!mergeSource || !mergeTarget || merging}>
|
||||
{#if merging}<span class="loading loading-spinner loading-sm"></span>{/if}
|
||||
Merge Foods
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
338
services/fitness/frontend-legacy/src/routes/foods/+page.svelte
Normal file
338
services/fitness/frontend-legacy/src/routes/foods/+page.svelte
Normal file
@@ -0,0 +1,338 @@
|
||||
<script lang="ts">
|
||||
import { get, post, patch, del } from '$lib/api/client.ts';
|
||||
import type { Food } from '$lib/api/types.ts';
|
||||
|
||||
let query = $state('');
|
||||
let allFoods = $state<Food[]>([]);
|
||||
let favorites = $state<Food[]>([]);
|
||||
let loading = $state(true);
|
||||
let editFood = $state<Food | null>(null);
|
||||
let showCreate = $state(false);
|
||||
let tab = $state<'all' | 'favorites'>('all');
|
||||
|
||||
// Image picker state
|
||||
let imageQuery = $state('');
|
||||
let imageResults = $state<Array<{url: string; thumbnail: string; title: string}>>([]);
|
||||
let imageSearching = $state(false);
|
||||
let imageSettingUrl = $state('');
|
||||
|
||||
async function searchImages() {
|
||||
if (!imageQuery.trim() || !editFood) return;
|
||||
imageSearching = true;
|
||||
try {
|
||||
const res = await post<{images: Array<{url: string; thumbnail: string; title: string}>}>('/api/images/search', { query: imageQuery + ' food', num: 9 });
|
||||
imageResults = res.images || [];
|
||||
} catch {} finally { imageSearching = false; }
|
||||
}
|
||||
|
||||
async function pickImage(fullUrl: string, thumbnailUrl: string) {
|
||||
if (!editFood) return;
|
||||
imageSettingUrl = fullUrl;
|
||||
const foodId = editFood.id;
|
||||
const urls = [fullUrl, thumbnailUrl];
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const res = await post<{success: boolean; image_path?: string}>(`/api/foods/${foodId}/image`, { url });
|
||||
if (res.success && res.image_path) {
|
||||
if (editFood) editFood.image_path = res.image_path;
|
||||
imageResults = [];
|
||||
imageSettingUrl = '';
|
||||
loadAll();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
imageSettingUrl = '';
|
||||
}
|
||||
|
||||
let newName = $state('');
|
||||
let newBrand = $state('');
|
||||
let newCal = $state(0);
|
||||
let newProtein = $state(0);
|
||||
let newCarbs = $state(0);
|
||||
let newFat = $state(0);
|
||||
let newUnit = $state('100g');
|
||||
let newServingName = $state('');
|
||||
let newServingAmount = $state(1);
|
||||
|
||||
let filtered = $derived(
|
||||
query.trim()
|
||||
? allFoods.filter(f => f.name.toLowerCase().includes(query.toLowerCase()) || (f.brand || '').toLowerCase().includes(query.toLowerCase()))
|
||||
: allFoods
|
||||
);
|
||||
|
||||
$effect(() => { loadAll(); });
|
||||
|
||||
async function loadAll() {
|
||||
loading = true;
|
||||
try {
|
||||
[allFoods, favorites] = await Promise.all([
|
||||
get<Food[]>('/api/foods'),
|
||||
get<Food[]>('/api/favorites'),
|
||||
]);
|
||||
} catch {} finally { loading = false; }
|
||||
}
|
||||
|
||||
async function toggleFavorite(food: Food) {
|
||||
const isFav = favorites.some(f => f.id === food.id);
|
||||
if (isFav) { await del(`/api/favorites/${food.id}`); }
|
||||
else { await post('/api/favorites', { food_id: food.id }); }
|
||||
loadAll();
|
||||
}
|
||||
|
||||
async function createFood() {
|
||||
const servings = [{ name: `1 ${newUnit}`, amount_in_base: 1.0, is_default: true }];
|
||||
if (newServingName) servings.push({ name: newServingName, amount_in_base: newServingAmount, is_default: false });
|
||||
await post('/api/foods', {
|
||||
name: newName, brand: newBrand || undefined,
|
||||
calories_per_base: newCal, protein_per_base: newProtein,
|
||||
carbs_per_base: newCarbs, fat_per_base: newFat,
|
||||
base_unit: newUnit, servings,
|
||||
});
|
||||
showCreate = false;
|
||||
newName = ''; newBrand = ''; newCal = 0; newProtein = 0; newCarbs = 0; newFat = 0;
|
||||
loadAll();
|
||||
}
|
||||
|
||||
async function deleteFood() {
|
||||
if (!editFood) return;
|
||||
if (!confirm(`Delete "${editFood.name}"? This will archive it.`)) return;
|
||||
await del(`/api/foods/${editFood.id}`);
|
||||
editFood = null;
|
||||
loadAll();
|
||||
}
|
||||
|
||||
async function updateFood() {
|
||||
if (!editFood) return;
|
||||
await patch(`/api/foods/${editFood.id}`, {
|
||||
name: editFood.name, brand: editFood.brand,
|
||||
calories_per_base: editFood.calories_per_base,
|
||||
protein_per_base: editFood.protein_per_base,
|
||||
carbs_per_base: editFood.carbs_per_base,
|
||||
fat_per_base: editFood.fat_per_base,
|
||||
status: editFood.status,
|
||||
});
|
||||
editFood = null;
|
||||
loadAll();
|
||||
}
|
||||
|
||||
async function addAlias(foodId: string) {
|
||||
const alias = prompt('Add alias:');
|
||||
if (alias) {
|
||||
await post(`/api/foods/${foodId}/aliases`, { alias });
|
||||
if (editFood?.id === foodId) editFood = await get<Food>(`/api/foods/${foodId}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 py-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Food Library</h1>
|
||||
<p class="text-base-content/50 text-sm mt-0.5">{allFoods.length} foods saved</p>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm gap-2" onclick={() => showCreate = true}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
|
||||
New Food
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div role="tablist" class="tabs tabs-boxed mb-4">
|
||||
<button role="tab" class="tab" class:tab-active={tab === 'all'} onclick={() => tab = 'all'}>All ({allFoods.length})</button>
|
||||
<button role="tab" class="tab" class:tab-active={tab === 'favorites'} onclick={() => tab = 'favorites'}>Favorites ({favorites.length})</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'all'}
|
||||
<div class="relative mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/30" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/></svg>
|
||||
<input class="input input-bordered w-full pl-10" placeholder="Filter foods..." bind:value={query} />
|
||||
</div>
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12"><span class="loading loading-spinner loading-lg text-primary"></span></div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each filtered as food}
|
||||
<div class="rounded-xl border border-base-300 bg-base-200/50 p-4 shadow-sm card-hover flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold">{food.name}</span>
|
||||
{#if food.status === 'ai_created'}<span class="badge badge-xs badge-info badge-outline">AI</span>{/if}
|
||||
{#if food.status === 'needs_review'}<span class="badge badge-xs badge-warning">Review</span>{/if}
|
||||
</div>
|
||||
{#if food.brand}<div class="text-xs text-base-content/40">{food.brand}</div>{/if}
|
||||
<div class="flex gap-3 mt-1 text-xs text-base-content/50">
|
||||
<span>{food.calories_per_base} cal</span>
|
||||
<span>{food.protein_per_base}g P</span>
|
||||
<span>{food.carbs_per_base}g C</span>
|
||||
<span>{food.fat_per_base}g F</span>
|
||||
<span class="text-base-content/30">per {food.base_unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1 ml-2">
|
||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => toggleFavorite(food)} title="Favorite">
|
||||
{#if favorites.some(f => f.id === food.id)}
|
||||
<span class="text-warning">★</span>
|
||||
{:else}
|
||||
<span class="text-base-content/30">☆</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => { editFood = food; imageResults = []; imageQuery = ''; }} title="Edit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if filtered.length === 0}
|
||||
<div class="text-center py-12 text-base-content/30">
|
||||
{#if query.trim()}No foods match "{query}"{:else}No foods yet{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each favorites as food}
|
||||
<div class="rounded-xl border border-warning/20 bg-gradient-to-br from-warning/5 to-warning/0 p-4 shadow-sm card-hover flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-semibold">{food.name}</div>
|
||||
<div class="text-xs text-base-content/50">{food.calories_per_base} cal/{food.base_unit}</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => toggleFavorite(food)}>
|
||||
<span class="text-warning">★</span>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if favorites.length === 0}
|
||||
<div class="text-center py-12 text-base-content/30">No favorites yet</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Create food modal -->
|
||||
{#if showCreate}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-lg">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={() => showCreate = false}>X</button>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 rounded-xl bg-primary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
|
||||
</div>
|
||||
<h3 class="font-bold text-lg">New Food</h3>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<input class="input input-bordered" placeholder="Food name *" bind:value={newName} />
|
||||
<input class="input input-bordered" placeholder="Brand (optional)" bind:value={newBrand} />
|
||||
<select class="select select-bordered" bind:value={newUnit}>
|
||||
<option value="100g">Per 100g</option>
|
||||
<option value="piece">Per piece</option>
|
||||
<option value="serving">Per serving</option>
|
||||
<option value="scoop">Per scoop</option>
|
||||
<option value="slice">Per slice</option>
|
||||
</select>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input class="input input-bordered" type="number" placeholder="Calories" bind:value={newCal} />
|
||||
<input class="input input-bordered" type="number" placeholder="Protein (g)" bind:value={newProtein} />
|
||||
<input class="input input-bordered" type="number" placeholder="Carbs (g)" bind:value={newCarbs} />
|
||||
<input class="input input-bordered" type="number" placeholder="Fat (g)" bind:value={newFat} />
|
||||
</div>
|
||||
<div class="divider text-xs my-0">Optional Serving</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input class="input input-bordered input-sm" placeholder="e.g. 1 cup (240g)" bind:value={newServingName} />
|
||||
<input class="input input-bordered input-sm" type="number" step="0.01" placeholder="Base multiplier" bind:value={newServingAmount} />
|
||||
</div>
|
||||
<button class="btn btn-primary mt-1" onclick={createFood} disabled={!newName}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button onclick={() => showCreate = false}>close</button></form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Edit food modal -->
|
||||
{#if editFood}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-lg">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={() => editFood = null}>X</button>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 rounded-xl bg-info/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-info" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
|
||||
</div>
|
||||
<h3 class="font-bold text-lg">Edit: {editFood.name}</h3>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<input class="input input-bordered" bind:value={editFood.name} />
|
||||
<input class="input input-bordered" placeholder="Brand" bind:value={editFood.brand} />
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input class="input input-bordered" type="number" bind:value={editFood.calories_per_base} />
|
||||
<input class="input input-bordered" type="number" bind:value={editFood.protein_per_base} />
|
||||
<input class="input input-bordered" type="number" bind:value={editFood.carbs_per_base} />
|
||||
<input class="input input-bordered" type="number" bind:value={editFood.fat_per_base} />
|
||||
</div>
|
||||
<select class="select select-bordered" bind:value={editFood.status}>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="ai_created">AI Created</option>
|
||||
<option value="needs_review">Needs Review</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
{#if editFood.aliases}
|
||||
<div class="text-sm font-medium">Aliases</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each editFood.aliases as a}
|
||||
<span class="badge badge-sm badge-outline">{a.alias}</span>
|
||||
{/each}
|
||||
<button class="badge badge-sm badge-primary badge-outline cursor-pointer" onclick={() => addAlias(editFood!.id)}>+ add</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Image picker -->
|
||||
<div class="divider text-xs my-1">Image</div>
|
||||
{#if editFood.image_path}
|
||||
<div class="relative rounded-lg overflow-hidden h-32">
|
||||
<img src="/images/{editFood.image_path}" alt="" class="w-full h-full object-cover" />
|
||||
<div class="absolute bottom-2 right-2">
|
||||
<button class="btn btn-xs btn-ghost bg-base-300/70" onclick={() => { imageQuery = editFood!.name; searchImages(); }}>Change</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="btn btn-sm btn-outline w-full" onclick={() => { imageQuery = editFood!.name; searchImages(); }}>
|
||||
Search for image
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if imageResults.length > 0 || imageSearching}
|
||||
<div class="flex gap-1 mt-2">
|
||||
<input class="input input-bordered input-sm flex-1" placeholder="Search images..." bind:value={imageQuery} onkeydown={(e) => { if (e.key === 'Enter') searchImages(); }} />
|
||||
<button class="btn btn-sm" onclick={searchImages} disabled={imageSearching}>
|
||||
{#if imageSearching}<span class="loading loading-spinner loading-xs"></span>{:else}Search{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 mt-2 max-h-48 overflow-y-auto">
|
||||
{#each imageResults as img}
|
||||
<button
|
||||
class="relative rounded-lg overflow-hidden h-20 hover:ring-2 hover:ring-primary transition-all {imageSettingUrl === img.url ? 'opacity-50' : ''}"
|
||||
onclick={() => pickImage(img.url, img.thumbnail)}
|
||||
disabled={!!imageSettingUrl}
|
||||
>
|
||||
<img src={img.thumbnail} alt={img.title} class="w-full h-full object-cover" />
|
||||
{#if imageSettingUrl === img.url}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-base-300/50">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button class="btn btn-primary flex-1" onclick={updateFood}>Save</button>
|
||||
<button class="btn btn-error btn-outline" onclick={deleteFood}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button onclick={() => editFood = null}>close</button></form>
|
||||
</div>
|
||||
{/if}
|
||||
136
services/fitness/frontend-legacy/src/routes/goals/+page.svelte
Normal file
136
services/fitness/frontend-legacy/src/routes/goals/+page.svelte
Normal file
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import { get, put } from '$lib/api/client.ts';
|
||||
import type { Goal, User } from '$lib/api/types.ts';
|
||||
import { today } from '$lib/api/client.ts';
|
||||
|
||||
let users = $state<User[]>([]);
|
||||
let selectedUser = $state('');
|
||||
let goals = $state<Goal[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
let calories = $state(2000);
|
||||
let protein = $state(150);
|
||||
let carbs = $state(200);
|
||||
let fat = $state(65);
|
||||
let startDate = $state(today());
|
||||
let saving = $state(false);
|
||||
|
||||
$effect(() => { loadUsers(); });
|
||||
$effect(() => { if (selectedUser) loadGoals(); });
|
||||
|
||||
async function loadUsers() {
|
||||
users = await get<User[]>('/api/users');
|
||||
const me = await get<User>('/api/user');
|
||||
selectedUser = me.id;
|
||||
}
|
||||
|
||||
async function loadGoals() {
|
||||
loading = true;
|
||||
try {
|
||||
goals = await get<Goal[]>(`/api/goals?user_id=${selectedUser}`);
|
||||
const current = goals.find(g => g.is_active);
|
||||
if (current) {
|
||||
calories = current.calories; protein = current.protein;
|
||||
carbs = current.carbs; fat = current.fat;
|
||||
}
|
||||
} catch {} finally { loading = false; }
|
||||
}
|
||||
|
||||
async function saveGoal() {
|
||||
saving = true;
|
||||
try {
|
||||
await put('/api/goals', { user_id: selectedUser, start_date: startDate, calories, protein, carbs, fat });
|
||||
loadGoals();
|
||||
} catch {} finally { saving = false; }
|
||||
}
|
||||
|
||||
function userName(id: string) { return users.find(u => u.id === id)?.display_name || ''; }
|
||||
</script>
|
||||
|
||||
<div class="max-w-2xl mx-auto px-4 py-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Goals</h1>
|
||||
<p class="text-base-content/50 text-sm mt-0.5">Daily nutrition targets</p>
|
||||
</div>
|
||||
<select class="select select-bordered select-sm" bind:value={selectedUser}>
|
||||
{#each users as u}
|
||||
<option value={u.id}>{u.display_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12"><span class="loading loading-spinner loading-lg text-primary"></span></div>
|
||||
{:else}
|
||||
<!-- Set goals card -->
|
||||
<div class="rounded-xl border border-primary/20 bg-gradient-to-br from-primary/10 to-primary/5 p-6 mb-8 shadow-md">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 rounded-xl bg-primary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"/></svg>
|
||||
</div>
|
||||
<h2 class="font-bold text-lg">Set Goals for {userName(selectedUser)}</h2>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Start Date</div>
|
||||
<input type="date" class="input input-bordered input-sm w-full max-w-xs" bind:value={startDate} />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Calories</div>
|
||||
<input type="number" class="input input-bordered w-full" bind:value={calories} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Protein (g)</div>
|
||||
<input type="number" class="input input-bordered w-full" bind:value={protein} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Carbs (g)</div>
|
||||
<input type="number" class="input input-bordered w-full" bind:value={carbs} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Fat (g)</div>
|
||||
<input type="number" class="input input-bordered w-full" bind:value={fat} />
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary gap-2 mt-1" onclick={saveGoal} disabled={saving}>
|
||||
{#if saving}<span class="loading loading-spinner loading-sm"></span>{/if}
|
||||
Save Goals
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Goal history -->
|
||||
{#if goals.length > 0}
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="w-2 h-2 rounded-full bg-info"></div>
|
||||
<h2 class="text-xl font-bold">History</h2>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each goals as g}
|
||||
<div class="rounded-xl border p-4 shadow-sm card-hover {g.is_active ? 'border-primary/30 bg-gradient-to-br from-primary/5 to-transparent' : 'border-base-300 bg-base-200/50'}">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<div class="text-sm font-medium">
|
||||
{g.start_date}{g.end_date ? ` — ${g.end_date}` : ''}
|
||||
</div>
|
||||
<div class="flex gap-3 text-xs text-base-content/50 mt-1">
|
||||
<span>{g.calories} cal</span>
|
||||
<span>{g.protein}g P</span>
|
||||
<span>{g.carbs}g C</span>
|
||||
<span>{g.fat}g F</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if g.is_active}
|
||||
<span class="badge badge-primary badge-sm">Active</span>
|
||||
{:else}
|
||||
<span class="badge badge-ghost badge-sm">Ended</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleLogin() {
|
||||
if (!username.trim() || !password) { error = 'Enter username and password'; return; }
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username.trim(), password }),
|
||||
credentials: 'include'
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
localStorage.setItem('session_token', data.token);
|
||||
goto('/');
|
||||
} else { error = 'Invalid credentials'; }
|
||||
} catch { error = 'Connection failed'; }
|
||||
finally { loading = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center px-4">
|
||||
<div class="card bg-base-200 shadow-xl w-full max-w-md border border-base-300">
|
||||
<div class="card-body">
|
||||
<div class="text-center mb-6">
|
||||
<div class="p-4 rounded-2xl bg-primary/15 inline-block mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-primary" 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>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-base-content">Calorie Tracker</h2>
|
||||
<p class="text-base-content/50 text-sm mt-1">Sign in to continue</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error text-sm py-2"><span>{error}</span></div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }}>
|
||||
<div class="form-control">
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Username</div>
|
||||
<input type="text" class="input input-bordered w-full" bind:value={username} />
|
||||
</div>
|
||||
<div class="form-control mt-3">
|
||||
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Password</div>
|
||||
<input type="password" class="input input-bordered w-full" bind:value={password} />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-full mt-5 gap-2" disabled={loading}>
|
||||
{#if loading}<span class="loading loading-spinner loading-sm"></span>{/if}
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { get, post, del } from '$lib/api/client.ts';
|
||||
import type { MealTemplate, MealType } from '$lib/api/types.ts';
|
||||
import { MEAL_TYPES } from '$lib/api/types.ts';
|
||||
import { today } from '$lib/api/client.ts';
|
||||
|
||||
let templates = $state<MealTemplate[]>([]);
|
||||
let loading = $state(true);
|
||||
let logDate = $state(today());
|
||||
let logMeal = $state<MealType>('lunch');
|
||||
let loggingId = $state('');
|
||||
|
||||
$effect(() => { loadTemplates(); });
|
||||
|
||||
async function loadTemplates() {
|
||||
loading = true;
|
||||
try { templates = await get<MealTemplate[]>('/api/templates'); }
|
||||
catch {} finally { loading = false; }
|
||||
}
|
||||
|
||||
async function logTemplate(id: string) {
|
||||
loggingId = id;
|
||||
try {
|
||||
await post(`/api/templates/${id}/log`, { meal_type: logMeal, entry_date: logDate });
|
||||
alert('Logged!');
|
||||
} catch {} finally { loggingId = ''; }
|
||||
}
|
||||
|
||||
async function deleteTemplate(id: string) {
|
||||
if (confirm('Archive this template?')) {
|
||||
await del(`/api/templates/${id}`);
|
||||
loadTemplates();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 py-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold">Meal Templates</h1>
|
||||
<p class="text-base-content/50 text-sm mt-0.5">Save and reuse your favorite meals</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-info/20 bg-gradient-to-br from-info/10 to-info/5 p-4 mb-6 shadow-md flex items-center gap-4 flex-wrap">
|
||||
<span class="text-xs text-base-content/50 font-medium uppercase tracking-wide">Log to:</span>
|
||||
<input type="date" class="input input-bordered input-sm" bind:value={logDate} />
|
||||
<select class="select select-bordered select-sm" bind:value={logMeal}>
|
||||
{#each MEAL_TYPES as mt}
|
||||
<option value={mt}>{mt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12"><span class="loading loading-spinner loading-lg text-primary"></span></div>
|
||||
{:else if templates.length === 0}
|
||||
<div class="text-center py-16">
|
||||
<div class="p-4 rounded-2xl bg-primary/15 inline-block mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></svg>
|
||||
</div>
|
||||
<div class="text-base-content/50">No templates yet</div>
|
||||
<div class="text-sm text-base-content/30 mt-1">Templates can be created from the daily log or via API</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each templates as t}
|
||||
<div class="rounded-xl border border-base-300 bg-base-200/50 p-5 shadow-sm card-hover">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold text-lg">{t.name}</span>
|
||||
{#if t.meal_type}<span class="badge badge-sm badge-outline">{t.meal_type}</span>{/if}
|
||||
{#if t.is_favorite}<span class="text-warning">★</span>{/if}
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-primary btn-sm gap-1" onclick={() => logTemplate(t.id)} disabled={loggingId === t.id}>
|
||||
{#if loggingId === t.id}<span class="loading loading-spinner loading-xs"></span>{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
|
||||
{/if}
|
||||
Log
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick={() => deleteTemplate(t.id)}>Archive</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if t.items?.length > 0}
|
||||
<div class="space-y-1.5">
|
||||
{#each t.items as item}
|
||||
<div class="flex justify-between text-sm text-base-content/70">
|
||||
<span>{item.snapshot_food_name} <span class="text-base-content/30">x{item.quantity}</span></span>
|
||||
<span class="font-medium">{Math.round(item.snapshot_calories * item.quantity)} cal</span>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="border-t border-base-300/50 pt-2 mt-2 flex justify-between font-bold text-sm">
|
||||
<span>Total</span>
|
||||
<span>{Math.round(t.items.reduce((s, i) => s + i.snapshot_calories * i.quantity, 0))} cal</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
14
services/fitness/frontend-legacy/svelte.config.js
Normal file
14
services/fitness/frontend-legacy/svelte.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
},
|
||||
vitePlugin: {
|
||||
dynamicCompileOptions: ({ filename }) =>
|
||||
filename.includes('node_modules') ? undefined : { runes: true }
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
15
services/fitness/frontend-legacy/tsconfig.json
Normal file
15
services/fitness/frontend-legacy/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
19
services/fitness/frontend-legacy/vite.config.ts
Normal file
19
services/fitness/frontend-legacy/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8095',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/images': {
|
||||
target: 'http://localhost:8095',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
2695
services/fitness/server.py
Normal file
2695
services/fitness/server.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user