feat: multi-user support, goals editing, shared food library
Some checks failed
Security Checks / dependency-audit (push) Has been cancelled
Security Checks / secret-scanning (push) Has been cancelled
Security Checks / dockerfile-lint (push) Has been cancelled

Multi-user:
- Madiha account with per-user nav visibility
- Dashboard greeting uses actual user display name
- Navbar and MobileTabBar accept visibleApps prop
- Madiha sees: Dashboard, Trips, Fitness, Budget, Media (no Inventory, Reader)

Goals editing:
- Goals page now has Edit Goals mode with inline number inputs
- Saves via PUT /api/fitness/goals
- Shows "No goals set" state for new users

Food library:
- Default view shows all shared foods (not just user's recent)
- Both users see the same food database
- Cleaned up duplicates: archived Eggs (kept Egg), Green Grapes (kept Grapes),
  duplicate Bellwether Yogurt, Latte Macchiato (kept Madiha's Caramel Latte)

Add to meal buttons:
- "Add to breakfast/lunch/dinner/snack" now focuses the resolve input
  and sets the meal type so AI logs to the correct meal
This commit is contained in:
Yusuf Suleman
2026-03-29 14:44:46 -05:00
parent 9e13984b05
commit 810502ab9d
8 changed files with 359 additions and 169 deletions

View File

@@ -0,0 +1,97 @@
Work in the `platform` repo and continue from the current remediation state.
Use Gitea issues as the source of truth:
- `#1` umbrella
- `#5` Gateway Trust Model
- `#8` Dependency Security
- `#9` Performance Hardening
Important instruction:
- Do NOT rotate or change the admin password during this pass.
- Treat admin password rotation as a final manual ops step after all code and config fixes are complete and verified.
- If you mention password rotation in comments or summaries, explicitly mark it as "LAST STEP".
First, re-verify the repo state before changing anything. Do not trust prior summaries blindly.
Current verified status:
- Completed: `#2`, `#3`, `#4`, `#6`, `#7`, `#10`
- Partial: `#5`, `#8`, `#9`
Remaining work by issue:
`#5 Gateway Trust Model`
Current state:
- Token validation is improved and uses protected endpoints.
- Inventory `/debug-nocodb` has been removed.
- Inventory search sanitization is better.
- The gateway still has a service-global trust model for gateway-key services.
What remains:
- Re-check whether the current gateway-key service model is acceptable as-is or should be narrowed further.
- If it stays, document it precisely and avoid claiming it was eliminated.
- Review inventory and similar internal services for any remaining permissive/debug/admin-style surfaces.
- Review whether service-global access should be limited at route level, method level, or by explicit allowlist.
- Make sure issue comments and final summary describe the trust model accurately, not optimistically.
Acceptance bar:
- No remaining accidental debug endpoint exposure.
- Remaining gateway-key trust assumptions are explicit, minimal, and documented.
- No false claim that per-user auth exists where it does not.
`#8 Dependency Security`
Current state:
- Budget dependency audit is clean.
- `.gitea/workflows/security.yml` exists.
What remains:
- Review the workflow for correctness and realism.
- Tighten the workflow if needed so repo-side enforcement is actually meaningful.
- Verify whether secret scanning and dependency checks cover the important paths.
- Do not mark this issue complete if a Gitea Actions runner is still required for execution.
- Clearly separate "repo-side complete" from "operationally active".
Acceptance bar:
- Workflow file is committed and sane.
- Remaining runner dependency is clearly documented.
- Issue remains partial or blocked if execution infrastructure is missing.
`#9 Performance Hardening`
Current state:
- Gateway dashboard response is cached.
- Budget summary is cached.
- Inventory `/issues` and `/needs-review-count` no longer full-scan all rows.
What remains:
- Re-check inventory endpoints for any other repeated full-table fetches.
- Re-check budget endpoints for repeated account fan-out, especially `/transactions/recent`.
- If Actual Budget API forces per-account queries, document that constraint explicitly.
- Prefer targeted improvements such as short-TTL caching, narrower query windows, or reused lookups over broad refactors.
- Do not mark this issue complete unless the remaining hot paths are either fixed or clearly bounded and documented.
Acceptance bar:
- The worst remaining repeated-scan or repeated-fan-out paths are either reduced or documented with clear justification.
- Final status does not overstate completion.
Instructions:
- Make minimal, production-oriented fixes.
- Preserve unrelated user changes.
- After each issue-sized change:
- verify it with direct checks
- comment on the relevant Gitea issue with:
- what changed
- files touched
- verification performed
- what remains
- Do not close `#5`, `#8`, or `#9` unless the actual code and behavior support it.
- If an issue is still partial, say so directly.
- Avoid renaming something and then claiming the underlying architectural concern is solved.
Manual ops note:
- Admin password rotation is intentionally deferred.
- If referenced, mark it exactly as: `LAST STEP: rotate admin password after all remaining fixes are complete and verified.`
Final output format:
- `Completed:`
- `Partial:`
- `Blocked:`
- `Manual ops actions:`

View File

@@ -2,6 +2,15 @@
import { page } from '$app/state';
import { LayoutDashboard, DollarSign, Package, Activity, MoreVertical, MapPin, BookOpen, Library, Settings } from '@lucide/svelte';
interface Props {
visibleApps?: string[];
}
let { visibleApps = ['trips', 'fitness', 'inventory', 'budget', 'reader', 'media'] }: Props = $props();
function showApp(id: string): boolean {
return visibleApps.includes(id);
}
let moreOpen = $state(false);
function isActive(path: string): boolean {
@@ -20,18 +29,24 @@
<LayoutDashboard size={22} />
Dashboard
</a>
{#if showApp('budget')}
<a href="/budget" class="mobile-tab" class:active={isActive('/budget')}>
<DollarSign size={22} />
Budget
</a>
{/if}
{#if showApp('inventory')}
<a href="/inventory" class="mobile-tab" class:active={isActive('/inventory')}>
<Package size={22} />
Inventory
</a>
{/if}
{#if showApp('fitness')}
<a href="/fitness" class="mobile-tab" class:active={isActive('/fitness')}>
<Activity size={22} />
Fitness
</a>
{/if}
<button class="mobile-tab" class:active={moreOpen} onclick={() => moreOpen = true}>
<MoreVertical size={22} />
More
@@ -45,18 +60,24 @@
<div class="more-sheet-overlay open" onclick={(e) => { if (e.target === e.currentTarget) closeMore(); }} onkeydown={() => {}}>
<div class="more-sheet">
<div class="more-sheet-handle"></div>
{#if showApp('trips')}
<a href="/trips" class="more-sheet-item" onclick={closeMore}>
<MapPin size={20} />
Trips
</a>
{/if}
{#if showApp('reader')}
<a href="/reader" class="more-sheet-item" onclick={closeMore}>
<BookOpen size={20} />
Reader
</a>
{/if}
{#if showApp('media')}
<a href="/media" class="more-sheet-item" onclick={closeMore}>
<Library size={20} />
Media
</a>
{/if}
<a href="/settings" class="more-sheet-item" onclick={closeMore}>
<Settings size={20} />
Settings

View File

@@ -5,9 +5,14 @@
interface Props {
onOpenCommand?: () => void;
visibleApps?: string[];
}
let { onOpenCommand }: Props = $props();
let { onOpenCommand, visibleApps = ['trips', 'fitness', 'inventory', 'budget', 'reader', 'media'] }: Props = $props();
function showApp(id: string): boolean {
return visibleApps.includes(id);
}
let tripsOpen = $state(false);
let fitnessOpen = $state(false);
@@ -34,10 +39,11 @@
<div class="navbar-links">
<a href="/" class="navbar-link" class:active={page.url.pathname === '/'}>Dashboard</a>
<!-- Trips dropdown -->
{#if showApp('trips')}
<a href="/trips" class="navbar-link" class:active={isActive('/trips')}>Trips</a>
{/if}
<!-- Fitness dropdown -->
{#if showApp('fitness')}
<div class="nav-dropdown" role="menu">
<button
class="navbar-link"
@@ -53,11 +59,20 @@
</div>
{/if}
</div>
{/if}
{#if showApp('inventory')}
<a href="/inventory" class="navbar-link" class:active={isActive('/inventory')}>Inventory</a>
{/if}
{#if showApp('budget')}
<a href="/budget" class="navbar-link" class:active={isActive('/budget')}>Budget</a>
{/if}
{#if showApp('reader')}
<a href="/reader" class="navbar-link" class:active={isActive('/reader')}>Reader</a>
{/if}
{#if showApp('media')}
<a href="/media" class="navbar-link" class:active={isActive('/media')}>Media</a>
{/if}
</div>
<button class="search-trigger" onclick={onOpenCommand}>

View File

@@ -18,7 +18,15 @@ export const load: LayoutServerLoad = async ({ cookies, url }) => {
if (res.ok) {
const data = await res.json();
if (data.authenticated) {
return { user: data.user };
// Per-user nav visibility — hide apps not relevant to this user
// Apps not in this list are hidden from nav (but still accessible via URL)
const allApps = ['trips', 'fitness', 'inventory', 'budget', 'reader', 'media'];
const hiddenByUser: Record<string, string[]> = {
'madiha': ['inventory', 'reader'],
};
const hidden = hiddenByUser[data.user.username] || [];
const visibleApps = allApps.filter(a => !hidden.includes(a));
return { user: data.user, visibleApps };
}
}
} catch { /* gateway down — let client handle */ }

View File

@@ -3,8 +3,10 @@
import MobileTabBar from '$lib/components/layout/MobileTabBar.svelte';
import CommandPalette from '$lib/components/layout/CommandPalette.svelte';
let { children } = $props();
let { children, data } = $props();
let commandOpen = $state(false);
const visibleApps = data?.visibleApps || ['trips', 'fitness', 'inventory', 'budget', 'reader', 'media'];
const userName = data?.user?.display_name || '';
function openCommand() {
commandOpen = true;
@@ -25,13 +27,13 @@
<svelte:window onkeydown={handleKeydown} />
<div class="app">
<Navbar onOpenCommand={openCommand} />
<Navbar onOpenCommand={openCommand} {visibleApps} />
<main>
{@render children()}
</main>
<MobileTabBar />
<MobileTabBar {visibleApps} />
<CommandPalette bind:open={commandOpen} onclose={closeCommand} />
</div>

View File

@@ -1,8 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import DashboardActionCard from '$lib/components/dashboard/DashboardActionCard.svelte';
import BudgetModule from '$lib/components/dashboard/BudgetModule.svelte';
import FitnessModule from '$lib/components/dashboard/FitnessModule.svelte';
const userName = $derived((page as any).data?.user?.display_name || 'there');
import IssuesModule from '$lib/components/dashboard/IssuesModule.svelte';
let inventoryIssueCount = $state(0);
@@ -59,7 +62,7 @@
<div class="app-surface">
<div class="page-header">
<div class="page-title">Dashboard</div>
<div class="page-greeting">Good to see you, <strong>Yusuf</strong></div>
<div class="page-greeting">Good to see you, <strong>{userName}</strong></div>
</div>
<div class="action-cards">

View File

@@ -127,7 +127,7 @@
try {
const url = query
? `/api/fitness/foods/search?q=${encodeURIComponent(query)}&limit=30`
: `/api/fitness/foods/recent?limit=30`;
: `/api/fitness/foods?limit=100`;
const res = await fetch(url, { credentials: 'include' });
if (res.ok) {
const raw = await res.json();
@@ -959,7 +959,7 @@
{/each}
{/if}
<button class="add-food-btn">
<button class="add-food-btn" onclick={() => { resolveMeal = meal; const input = document.querySelector('.resolve-input') as HTMLInputElement; if (input) { input.focus(); input.placeholder = `Add to ${meal}...`; } }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add to {meal}
</button>

View File

@@ -1,34 +1,82 @@
<script lang="ts">
import { onMount } from 'svelte';
let goals = $state([
{ label: 'Calories', value: '...', unit: 'kcal/day' },
{ label: 'Protein', value: '...', unit: 'grams/day' },
{ label: 'Carbs', value: '...', unit: 'grams/day' },
{ label: 'Fat', value: '...', unit: 'grams/day' }
]);
let calories = $state(2000);
let protein = $state(150);
let carbs = $state(200);
let fat = $state(65);
let startDate = $state('');
let loading = $state(true);
let editing = $state(false);
let saving = $state(false);
let hasGoal = $state(false);
onMount(async () => {
try {
// Edit state
let editCal = $state('2000');
let editProtein = $state('150');
let editCarbs = $state('200');
let editFat = $state('65');
function today(): string {
const n = new Date();
const today = `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}`;
const res = await fetch(`/api/fitness/goals/for-date?date=${today}`, { credentials: 'include' });
return `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}`;
}
async function loadGoals() {
try {
const res = await fetch(`/api/fitness/goals/for-date?date=${today()}`, { credentials: 'include' });
if (res.ok) {
const g = await res.json();
goals = [
{ label: 'Calories', value: (g.calories || 0).toLocaleString(), unit: 'kcal/day' },
{ label: 'Protein', value: String(g.protein || 0), unit: 'grams/day' },
{ label: 'Carbs', value: String(g.carbs || 0), unit: 'grams/day' },
{ label: 'Fat', value: String(g.fat || 0), unit: 'grams/day' },
];
calories = g.calories || 2000;
protein = g.protein || 150;
carbs = g.carbs || 200;
fat = g.fat || 65;
hasGoal = true;
if (g.start_date) {
const d = new Date(g.start_date + 'T00:00:00');
startDate = d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
}
}
} catch { /* silent */ }
loading = false;
}
function startEdit() {
editCal = String(Math.round(calories));
editProtein = String(Math.round(protein));
editCarbs = String(Math.round(carbs));
editFat = String(Math.round(fat));
editing = true;
}
function cancelEdit() {
editing = false;
}
async function saveGoals() {
saving = true;
try {
const res = await fetch('/api/fitness/goals', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
calories: parseFloat(editCal) || 2000,
protein: parseFloat(editProtein) || 150,
carbs: parseFloat(editCarbs) || 200,
fat: parseFloat(editFat) || 65,
start_date: today(),
}),
});
if (res.ok) {
editing = false;
await loadGoals();
}
} catch { /* silent */ }
saving = false;
}
onMount(loadGoals);
</script>
<div class="page">
@@ -41,117 +89,113 @@
<div class="module">
<div class="module-header">
<div class="module-title">CURRENT GOALS</div>
<button class="module-action">Edit Goals</button>
{#if !editing}
<button class="module-action" onclick={startEdit}>Edit Goals</button>
{/if}
</div>
{#if editing}
<div class="edit-grid">
<div class="edit-field">
<label class="edit-label">Calories (kcal/day)</label>
<input class="edit-input" type="number" bind:value={editCal} />
</div>
<div class="edit-field">
<label class="edit-label">Protein (g/day)</label>
<input class="edit-input" type="number" bind:value={editProtein} />
</div>
<div class="edit-field">
<label class="edit-label">Carbs (g/day)</label>
<input class="edit-input" type="number" bind:value={editCarbs} />
</div>
<div class="edit-field">
<label class="edit-label">Fat (g/day)</label>
<input class="edit-input" type="number" bind:value={editFat} />
</div>
</div>
<div class="edit-actions">
<button class="btn-cancel" onclick={cancelEdit}>Cancel</button>
<button class="btn-save" onclick={saveGoals} disabled={saving}>
{saving ? 'Saving...' : 'Save Goals'}
</button>
</div>
{:else}
<div class="goals-grid">
{#each goals as goal}
<div class="goal-card">
<div class="goal-label">{goal.label}</div>
<div class="goal-value">{goal.value}</div>
<div class="goal-unit">{goal.unit}</div>
<div class="goal-label">Calories</div>
<div class="goal-value">{loading ? '...' : Math.round(calories).toLocaleString()}</div>
<div class="goal-unit">kcal/day</div>
</div>
<div class="goal-card">
<div class="goal-label">Protein</div>
<div class="goal-value">{loading ? '...' : Math.round(protein)}</div>
<div class="goal-unit">grams/day</div>
</div>
<div class="goal-card">
<div class="goal-label">Carbs</div>
<div class="goal-value">{loading ? '...' : Math.round(carbs)}</div>
<div class="goal-unit">grams/day</div>
</div>
<div class="goal-card">
<div class="goal-label">Fat</div>
<div class="goal-value">{loading ? '...' : Math.round(fat)}</div>
<div class="goal-unit">grams/day</div>
</div>
{/each}
</div>
{#if startDate}
<div class="start-date">
<span class="start-date-label">Start date</span>
<span class="start-date-value">{startDate || '—'}</span>
<span class="start-date-label">Active since</span>
<span class="start-date-value">{startDate}</span>
</div>
{:else if !loading && !hasGoal}
<div class="no-goal">No goals set yet. Tap "Edit Goals" to get started.</div>
{/if}
{/if}
</div>
</div>
</div>
<style>
.page-subtitle {
font-size: var(--text-2xl);
font-weight: 300;
color: var(--text-1);
line-height: 1.2;
}
.page-subtitle { font-size: var(--text-2xl); font-weight: 300; color: var(--text-1); line-height: 1.2; }
.module {
background: var(--card);
border-radius: var(--radius);
padding: var(--card-pad-primary);
box-shadow: var(--card-shadow);
border: 1px solid var(--border);
}
.module-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-5);
}
.module-title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.module-action {
font-size: var(--text-sm);
color: var(--accent);
font-weight: 500;
cursor: pointer;
background: none;
border: none;
padding: 0;
}
.module { background: var(--card); border-radius: var(--radius); padding: var(--card-pad-primary); box-shadow: var(--card-shadow); border: 1px solid var(--border); }
.module-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-5); }
.module-title { font-size: var(--text-sm); font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; }
.module-action { font-size: var(--text-sm); color: var(--accent); font-weight: 500; cursor: pointer; background: none; border: none; padding: 0; }
.module-action:hover { text-decoration: underline; }
.goals-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--row-gap);
margin-bottom: var(--sp-5);
}
.goal-card {
background: var(--surface-secondary);
border-radius: var(--radius-sm);
padding: var(--sp-4);
border: 1px solid var(--border);
}
.goal-label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: var(--sp-2);
}
.goal-value {
font-size: var(--text-xl);
font-weight: 500;
font-family: var(--mono);
color: var(--text-1);
line-height: 1;
}
.goal-unit {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: var(--sp-1);
}
.goals-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--row-gap); margin-bottom: var(--sp-5); }
.goal-card { background: var(--surface-secondary); border-radius: var(--radius-sm); padding: var(--sp-4); border: 1px solid var(--border); }
.goal-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: var(--sp-2); }
.goal-value { font-size: var(--text-xl); font-weight: 500; font-family: var(--mono); color: var(--text-1); line-height: 1; }
.goal-unit { font-size: var(--text-sm); color: var(--text-3); margin-top: var(--sp-1); }
.start-date {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--sp-4);
border-top: 1px solid var(--border);
}
.start-date-label {
font-size: var(--text-sm);
color: var(--text-3);
}
.start-date-value {
font-size: var(--text-sm);
color: var(--text-2);
font-weight: 500;
}
.start-date { display: flex; align-items: center; justify-content: space-between; padding-top: var(--sp-4); border-top: 1px solid var(--border); }
.start-date-label { font-size: var(--text-sm); color: var(--text-3); }
.start-date-value { font-size: var(--text-sm); color: var(--text-2); font-weight: 500; }
.no-goal { font-size: var(--text-sm); color: var(--text-3); text-align: center; padding: var(--sp-4) 0; }
@media (max-width: 768px) {
.page-subtitle { font-size: var(--text-xl); }
.edit-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); margin-bottom: var(--sp-5); }
.edit-field { display: flex; flex-direction: column; gap: var(--sp-1); }
.edit-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-3); }
.edit-input {
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border);
background: var(--surface-secondary); color: var(--text-1); font-size: var(--text-lg); font-family: var(--mono);
}
.edit-input:focus { outline: none; border-color: var(--accent); }
.edit-actions { display: flex; gap: var(--sp-2); justify-content: flex-end; }
.btn-cancel {
padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-md);
background: var(--card-secondary); color: var(--text-2); border: 1px solid var(--border);
font-size: var(--text-sm); font-weight: 500; cursor: pointer; font-family: var(--font);
}
.btn-save {
padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-md);
background: var(--accent); color: white; border: none;
font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font);
}
.btn-save:disabled { opacity: 0.5; }
@media (max-width: 768px) { .page-subtitle { font-size: var(--text-xl); } }
</style>