Compare commits

3 Commits

Author SHA1 Message Date
3e8340fde1 feat: add period tracker with calendar, predictions, fertility tracking, and sharing
All checks were successful
CI / update (push) Successful in 3m18s
Full period tracking system for the fitness measure page:
- Period logging with start/end dates, edit/delete support
- EMA-based cycle and period length predictions (α=0.3, 12 future cycles)
- Calendar view with connected range strips, overflow days, today marker
- Fertility window, peak fertility, ovulation, and luteal phase visualization
- Period sharing between users with profile picture avatars
- Cycle/period stats with 95% CI below calendar
- Redesigned profile card as inline header metadata with Venus/Mars icons
- Collapsible weight and period history sections
- Full DE/EN i18n support
2026-04-06 15:12:03 +02:00
d10946774d feat: add recipe and OpenFoodFacts search to nutrition food search
Recipes from /rezepte now appear in the food search on /fitness/nutrition,
with per-100g nutrition computed server-side from ingredient mappings.
Recipe results are boosted above BLS/USDA/OFF in search ranking.

OpenFoodFacts products are now searchable by name/brand via MongoDB
text index, alongside the existing barcode lookup.

Recipe and OFF queries run in parallel with in-memory BLS/USDA scans.
2026-04-06 15:09:56 +02:00
201847400e perf: parallelize DB queries across routes, clean up fitness UI
Parallelize sequential DB queries in 11 API routes and page loaders
using Promise.all — measurements/latest, stats/overview, goal streak,
exercises, sessions, task stats, monthly expenses, icon page, offline-db.

Move calorie tracking out of /fitness/measure (now under /fitness/nutrition
only). Remove fade-in entrance animations from nutrition page.

Progressive streak computation: scan 3 months first, widen only if needed.

Bump versions to 1.1.1 / 0.2.1.
2026-04-06 13:12:29 +02:00
32 changed files with 2350 additions and 344 deletions

View File

@@ -85,7 +85,6 @@ When committing, bump version numbers as appropriate using semver:
- **minor** (x.Y.0): new features, significant UI changes, new pages/routes
- **major** (X.0.0): breaking changes, major redesigns, data model changes
Version files to update (keep in sync):
- `package.json` — site version
- `src-tauri/tauri.conf.json` — Tauri/Android app version
- `src-tauri/Cargo.toml` — Rust crate version
Version files to update:
- `package.json` — site version (bump on every commit)
- `src-tauri/tauri.conf.json` + `src-tauri/Cargo.toml` — Tauri/Android app version. Only bump these when the Tauri app codebase itself changes (e.g. `src-tauri/` files), NOT for website-only changes.

View File

@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.1.0",
"version": "1.2.0",
"private": true,
"type": "module",
"scripts": {

2
src-tauri/Cargo.lock generated
View File

@@ -144,7 +144,7 @@ dependencies = [
[[package]]
name = "bocken"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"serde",
"serde_json",

View File

@@ -1,6 +1,6 @@
[package]
name = "bocken"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
[lib]

View File

@@ -1,7 +1,7 @@
{
"productName": "Bocken",
"identifier": "org.bocken.app",
"version": "0.2.0",
"version": "0.3.0",
"build": {
"devUrl": "http://192.168.1.4:5173",
"frontendDist": "https://bocken.org"

View File

@@ -156,6 +156,7 @@
if (source === 'bls') return 'BLS';
if (source === 'usda') return 'USDA';
if (source === 'off') return 'OFF';
if (source === 'recipe') return '🍴';
return source?.toUpperCase() ?? '';
}
@@ -402,7 +403,7 @@
<div class="fs-result-info">
<span class="fs-result-name">{item.name}</span>
<span class="fs-result-meta">
<span class="fs-source-badge" class:usda={item.source === 'usda'} class:off={item.source === 'off'}>{sourceLabel(item.source)}</span>
<span class="fs-source-badge" class:usda={item.source === 'usda'} class:off={item.source === 'off'} class:recipe={item.source === 'recipe'}>{sourceLabel(item.source)}</span>
{#if item.brands}<span class="fs-result-brands">{item.brands}</span>{/if}
{#if item.category}{item.category}{/if}
</span>
@@ -426,7 +427,7 @@
<div class="fs-selected">
<div class="fs-selected-header">
<span class="fs-selected-name">
<span class="fs-source-badge" class:usda={selected.source === 'usda'} class:off={selected.source === 'off'}>{sourceLabel(selected.source)}</span>
<span class="fs-source-badge" class:usda={selected.source === 'usda'} class:off={selected.source === 'off'} class:recipe={selected.source === 'recipe'}>{sourceLabel(selected.source)}</span>
{selected.name}
</span>
{#if selected.brands}
@@ -689,6 +690,12 @@
background: color-mix(in srgb, var(--nord15) 15%, transparent);
color: var(--nord15);
}
.fs-source-badge.recipe {
background: color-mix(in srgb, var(--nord14) 15%, transparent);
color: var(--nord14);
font-size: 0.7rem;
padding: 0.02rem 0.15rem;
}
.fs-result-cal {
font-size: 0.85rem;
font-weight: 700;

File diff suppressed because it is too large Load Diff

View File

@@ -298,6 +298,38 @@ const translations: Translations = {
log_food: { en: 'Log', de: 'Eintragen' },
delete_entry_confirm: { en: 'Delete this food entry?', de: 'Diesen Eintrag löschen?' },
// Period tracker
period_tracker: { en: 'Period Tracker', de: 'Periodentracker' },
current_period: { en: 'Current Period', de: 'Aktuelle Periode' },
no_period_data: { en: 'No period data yet. Log your first period to start tracking.', de: 'Noch keine Periodendaten. Erfasse deine erste Periode.' },
start_period: { en: 'Start Period', de: 'Periode starten' },
end_period: { en: 'End Period', de: 'Periode beenden' },
period_day: { en: 'Day', de: 'Tag' },
predicted_end: { en: 'Predicted end', de: 'Voraussichtliches Ende' },
next_period: { en: 'Next period', de: 'Nächste Periode' },
cycle_length: { en: 'Cycle length', de: 'Zykluslänge' },
period_length: { en: 'Period length', de: 'Periodenlänge' },
avg_cycle: { en: 'Avg. cycle', de: 'Ø Zyklus' },
avg_period: { en: 'Avg. period', de: 'Ø Periode' },
days: { en: 'days', de: 'Tage' },
delete_period_confirm: { en: 'Delete this period entry?', de: 'Diesen Periodeneintrag löschen?' },
add_past_period: { en: 'Add Past Period', de: 'Vergangene Periode hinzufügen' },
period_start: { en: 'Start', de: 'Beginn' },
period_end: { en: 'End', de: 'Ende' },
ongoing: { en: 'ongoing', de: 'laufend' },
share: { en: 'Share', de: 'Teilen' },
shared_with: { en: 'Shared with', de: 'Geteilt mit' },
add_user: { en: 'Add user…', de: 'Nutzer hinzufügen…' },
no_shared: { en: 'Not shared with anyone.', de: 'Mit niemandem geteilt.' },
shared_by: { en: 'Shared by', de: 'Geteilt von' },
fertile_window: { en: 'Fertile window', de: 'Fruchtbares Fenster' },
peak_fertility: { en: 'Peak fertility', de: 'Höchste Fruchtbarkeit' },
ovulation: { en: 'Ovulation', de: 'Eisprung' },
fertile: { en: 'Fertile', de: 'Fruchtbar' },
luteal_phase: { en: 'Luteal', de: 'Luteal' },
predicted_ovulation: { en: 'Predicted ovulation', de: 'Voraussichtlicher Eisprung' },
to: { en: 'to', de: 'bis' },
// Custom meals
custom_meals: { en: 'Custom Meals', de: 'Eigene Mahlzeiten' },
custom_meal: { en: 'Custom Meal', de: 'Eigene Mahlzeit' },

View File

@@ -47,6 +47,8 @@ const OpenFoodFactSchema = new mongoose.Schema({
per100g: { type: Per100gSchema, required: true },
}, { collection: 'openfoodfacts' });
OpenFoodFactSchema.index({ name: 'text', nameDe: 'text', brands: 'text' });
let _model: mongoose.Model<IOpenFoodFact>;
try { _model = mongoose.model<IOpenFoodFact>('OpenFoodFact'); } catch { _model = mongoose.model<IOpenFoodFact>('OpenFoodFact', OpenFoodFactSchema); }
export const OpenFoodFact = _model;

23
src/models/PeriodEntry.ts Normal file
View File

@@ -0,0 +1,23 @@
import mongoose from 'mongoose';
export interface IPeriodEntry {
_id?: string;
startDate: Date;
endDate?: Date;
createdBy: string;
createdAt?: Date;
updatedAt?: Date;
}
const PeriodEntrySchema = new mongoose.Schema(
{
startDate: { type: Date, required: true },
endDate: { type: Date, default: null },
createdBy: { type: String, required: true, trim: true }
},
{ timestamps: true }
);
PeriodEntrySchema.index({ createdBy: 1, startDate: -1 });
export const PeriodEntry = mongoose.model<IPeriodEntry>('PeriodEntry', PeriodEntrySchema);

18
src/models/PeriodShare.ts Normal file
View File

@@ -0,0 +1,18 @@
import mongoose from 'mongoose';
export interface IPeriodShare {
owner: string;
sharedWith: string[];
}
const PeriodShareSchema = new mongoose.Schema(
{
owner: { type: String, required: true, unique: true, trim: true },
sharedWith: [{ type: String, trim: true }]
},
{ timestamps: true }
);
PeriodShareSchema.index({ sharedWith: 1 });
export const PeriodShare = mongoose.model<IPeriodShare>('PeriodShare', PeriodShareSchema);

View File

@@ -4,13 +4,9 @@ import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favori
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const apiBase = `/api/${params.recipeLang}`;
const res_season = await fetch(`${apiBase}/items/icon/` + params.icon);
const res_icons = await fetch(`/api/rezepte/items/icon`); // Icons are shared across languages
const icons = await res_icons.json();
const item_season = await res_season.json();
// Get user favorites and session
const [userFavorites, session] = await Promise.all([
const [item_season, icons, userFavorites, session] = await Promise.all([
fetch(`${apiBase}/items/icon/` + params.icon).then(r => r.json()),
fetch(`/api/rezepte/items/icon`).then(r => r.json()),
getUserFavorites(fetch, locals),
locals.auth()
]);

View File

@@ -6,15 +6,13 @@ import { dbConnect } from '$utils/db';
export const GET: RequestHandler = async () => {
await dbConnect();
// Fetch brief recipes (for lists/filtering)
// Include images for thumbnail caching during offline sync
const briefRecipes = await Recipe.find(
{},
'name short_name tags category icon description season dateModified images translations'
).lean() as unknown as BriefRecipeType[];
// Fetch full recipes with populated base recipe references
const fullRecipes = await Recipe.find({})
// Fetch brief and full recipes in parallel
const [briefRecipes, fullRecipes] = await Promise.all([
Recipe.find(
{},
'name short_name tags category icon description season dateModified images translations'
).lean() as unknown as Promise<BriefRecipeType[]>,
Recipe.find({})
.populate({
path: 'ingredients.baseRecipeRef',
select: 'short_name name ingredients translations',
@@ -39,7 +37,8 @@ export const GET: RequestHandler = async () => {
}
}
})
.lean() as unknown as RecipeModelType[];
.lean() as unknown as Promise<RecipeModelType[]>
]);
// Map populated refs to resolvedRecipe field (same as individual item endpoint)
function mapBaseRecipeRefs(items: any[]): any[] {

View File

@@ -20,20 +20,12 @@ export const GET: RequestHandler = async ({ url, locals }) => {
const startDate = new Date();
startDate.setMonth(startDate.getMonth() - monthsBack);
const totalPayments = await Payment.countDocuments();
const paymentsInRange = await Payment.countDocuments({
date: {
$gte: startDate,
$lte: endDate
}
});
const expensePayments = await Payment.countDocuments({
date: {
$gte: startDate,
$lte: endDate
},
category: { $ne: 'settlement' }
});
const dateRange = { $gte: startDate, $lte: endDate };
const [totalPayments, paymentsInRange, expensePayments] = await Promise.all([
Payment.countDocuments(),
Payment.countDocuments({ date: dateRange }),
Payment.countDocuments({ date: dateRange, category: { $ne: 'settlement' } })
]);
// Aggregate payments by month and category
const pipeline = [

View File

@@ -46,13 +46,12 @@ export const GET: RequestHandler = async ({ url, locals }) => {
exerciseQuery = exerciseQuery.sort({ name: 1 });
}
const exercises = await exerciseQuery
.limit(limit)
.skip(offset)
.select('exerciseId name gifUrl bodyPart equipment target difficulty');
const total = await Exercise.countDocuments(query);
const [exercises, total] = await Promise.all([
exerciseQuery.limit(limit).skip(offset)
.select('exerciseId name gifUrl bodyPart equipment target difficulty'),
Exercise.countDocuments(query)
]);
return json({ exercises, total, limit, offset });
} catch (error) {
console.error('Error fetching exercises:', error);

View File

@@ -73,62 +73,48 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
};
async function computeStreak(username: string, weeklyGoal: number): Promise<number> {
// Get weekly workout counts going back up to 2 years
const cutoff = new Date();
cutoff.setFullYear(cutoff.getFullYear() - 2);
const weeklyAgg = await WorkoutSession.aggregate([
{
$match: {
createdBy: username,
startTime: { $gte: cutoff }
}
},
{
$group: {
_id: {
year: { $isoWeekYear: '$startTime' },
week: { $isoWeek: '$startTime' }
},
count: { $sum: 1 }
}
},
{
$sort: { '_id.year': -1, '_id.week': -1 }
}
]);
// Build a set of weeks that met the goal
const metGoal = new Set<string>();
for (const item of weeklyAgg) {
if (item.count >= weeklyGoal) {
metGoal.add(`${item._id.year}-${item._id.week}`);
}
}
// Walk backwards week-by-week counting consecutive weeks that met the goal.
// Current (incomplete) week counts if it already meets the goal, otherwise skip it.
// Get weekly workout counts — only scan back enough to find the streak break.
// Start with 13 weeks; if the streak fills the entire window, widen the search.
const now = new Date();
let streak = 0;
const currentKey = isoWeekKey(now);
const currentWeekMet = metGoal.has(currentKey);
for (let months = 3; months <= 24; months += 6) {
const cutoff = new Date(now);
cutoff.setMonth(cutoff.getMonth() - months);
// If current week already met: count it, then check previous weeks.
// If not: start checking from last week (current week still in progress).
if (currentWeekMet) streak = 1;
const weeklyAgg = await WorkoutSession.aggregate([
{ $match: { createdBy: username, startTime: { $gte: cutoff } } },
{ $group: { _id: { year: { $isoWeekYear: '$startTime' }, week: { $isoWeek: '$startTime' } }, count: { $sum: 1 } } },
{ $sort: { '_id.year': -1, '_id.week': -1 } }
]);
for (let i = 1; i <= 104; i++) {
const weekDate = new Date(now);
weekDate.setDate(weekDate.getDate() - i * 7);
if (metGoal.has(isoWeekKey(weekDate))) {
streak++;
} else {
break;
const metGoal = new Set<string>();
for (const item of weeklyAgg) {
if (item.count >= weeklyGoal) metGoal.add(`${item._id.year}-${item._id.week}`);
}
let streak = 0;
const currentKey = isoWeekKey(now);
if (metGoal.has(currentKey)) streak = 1;
const maxWeeks = Math.ceil(months * 4.35);
let foundBreak = false;
for (let i = 1; i <= maxWeeks; i++) {
const weekDate = new Date(now);
weekDate.setDate(weekDate.getDate() - i * 7);
if (metGoal.has(isoWeekKey(weekDate))) {
streak++;
} else {
foundBreak = true;
break;
}
}
// If we found where the streak broke, we're done
if (foundBreak || streak < maxWeeks) return streak;
// Otherwise widen the window and try again
}
return streak;
return 104; // Max 2-year streak
}
function isoWeekKey(date: Date): string {

View File

@@ -22,13 +22,10 @@ export const GET: RequestHandler = async ({ url, locals }) => {
query.date = dateFilter;
}
const measurements = await BodyMeasurement.find(query)
.sort({ date: -1 })
.skip(offset)
.limit(limit)
.lean();
const total = await BodyMeasurement.countDocuments(query);
const [measurements, total] = await Promise.all([
BodyMeasurement.find(query).sort({ date: -1 }).skip(offset).limit(limit).lean(),
BodyMeasurement.countDocuments(query)
]);
return json({ measurements, total, limit, offset });
};

View File

@@ -8,38 +8,16 @@ export const GET: RequestHandler = async ({ locals }) => {
const user = await requireAuth(locals);
await dbConnect();
// Get latest measurement that has each field
const latestWithWeight = await BodyMeasurement.findOne({
createdBy: user.nickname,
weight: { $exists: true, $ne: null }
})
.sort({ date: -1 })
.select('weight date')
.lean();
const latestWithBodyFat = await BodyMeasurement.findOne({
createdBy: user.nickname,
bodyFatPercent: { $exists: true, $ne: null }
})
.sort({ date: -1 })
.select('bodyFatPercent date')
.lean();
const latestWithCalories = await BodyMeasurement.findOne({
createdBy: user.nickname,
caloricIntake: { $exists: true, $ne: null }
})
.sort({ date: -1 })
.select('caloricIntake date')
.lean();
const latestWithMeasurements = await BodyMeasurement.findOne({
createdBy: user.nickname,
measurements: { $exists: true, $ne: null }
})
.sort({ date: -1 })
.select('measurements date')
.lean();
// Get latest measurement that has each field (parallel)
const base = { createdBy: user.nickname };
const [latestWithWeight, latestWithBodyFat, latestWithMeasurements] = await Promise.all([
BodyMeasurement.findOne({ ...base, weight: { $exists: true, $ne: null } })
.sort({ date: -1 }).select('weight date').lean(),
BodyMeasurement.findOne({ ...base, bodyFatPercent: { $exists: true, $ne: null } })
.sort({ date: -1 }).select('bodyFatPercent date').lean(),
BodyMeasurement.findOne({ ...base, measurements: { $exists: true, $ne: null } })
.sort({ date: -1 }).select('measurements date').lean(),
]);
return json({
weight: latestWithWeight
@@ -48,9 +26,6 @@ export const GET: RequestHandler = async ({ locals }) => {
bodyFatPercent: latestWithBodyFat
? { value: latestWithBodyFat.bodyFatPercent, date: latestWithBodyFat.date }
: null,
caloricIntake: latestWithCalories
? { value: latestWithCalories.caloricIntake, date: latestWithCalories.date }
: null,
measurements: latestWithMeasurements
? {
value: latestWithMeasurements.measurements,

View File

@@ -0,0 +1,56 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { PeriodEntry } from '$models/PeriodEntry';
/** GET: List period entries (most recent first) */
export const GET: RequestHandler = async ({ url, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 100);
const entries = await PeriodEntry.find({ createdBy: user.nickname })
.sort({ startDate: -1 })
.limit(limit)
.lean();
return json({ entries });
};
/** POST: Start a new period (or create a completed one with startDate + endDate) */
export const POST: RequestHandler = async ({ request, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const data = await request.json();
const { startDate, endDate } = data;
if (!startDate) {
return json({ error: 'startDate is required' }, { status: 400 });
}
const start = new Date(startDate);
if (isNaN(start.getTime())) {
return json({ error: 'Invalid startDate' }, { status: 400 });
}
// Check no ongoing period exists (endDate is null)
if (!endDate) {
const ongoing = await PeriodEntry.findOne({
createdBy: user.nickname,
endDate: null
});
if (ongoing) {
return json({ error: 'An ongoing period already exists. End it first.' }, { status: 409 });
}
}
const entry = await PeriodEntry.create({
startDate: start,
endDate: endDate ? new Date(endDate) : null,
createdBy: user.nickname
});
return json({ entry }, { status: 201 });
};

View File

@@ -0,0 +1,55 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { PeriodEntry } from '$models/PeriodEntry';
import mongoose from 'mongoose';
/** PUT: Update a period entry (e.g. set endDate to end an ongoing period) */
export const PUT: RequestHandler = async ({ params, request, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
if (!mongoose.Types.ObjectId.isValid(params.id)) {
return json({ error: 'Invalid period ID' }, { status: 400 });
}
const data = await request.json();
const updateData: Record<string, unknown> = {};
if (data.startDate !== undefined) updateData.startDate = new Date(data.startDate);
if (data.endDate !== undefined) updateData.endDate = data.endDate ? new Date(data.endDate) : null;
const entry = await PeriodEntry.findOneAndUpdate(
{ _id: params.id, createdBy: user.nickname },
updateData,
{ returnDocument: 'after' }
);
if (!entry) {
return json({ error: 'Period entry not found or unauthorized' }, { status: 404 });
}
return json({ entry });
};
/** DELETE: Remove a period entry */
export const DELETE: RequestHandler = async ({ params, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
if (!mongoose.Types.ObjectId.isValid(params.id)) {
return json({ error: 'Invalid period ID' }, { status: 400 });
}
const entry = await PeriodEntry.findOneAndDelete({
_id: params.id,
createdBy: user.nickname
});
if (!entry) {
return json({ error: 'Period entry not found or unauthorized' }, { status: 404 });
}
return json({ message: 'Period entry deleted' });
};

View File

@@ -0,0 +1,38 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { PeriodShare } from '$models/PeriodShare';
/** GET: Get current share settings */
export const GET: RequestHandler = async ({ locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const doc = await PeriodShare.findOne({ owner: user.nickname }).lean();
return json({ sharedWith: doc?.sharedWith ?? [] });
};
/** PUT: Update share list (set full list of usernames) */
export const PUT: RequestHandler = async ({ request, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const { sharedWith } = await request.json();
if (!Array.isArray(sharedWith)) {
return json({ error: 'sharedWith must be an array of usernames' }, { status: 400 });
}
// Sanitize: lowercase, trim, dedupe, remove self
const cleaned = [...new Set(
sharedWith.map((u: string) => u.trim().toLowerCase()).filter((u: string) => u && u !== user.nickname.toLowerCase())
)];
const doc = await PeriodShare.findOneAndUpdate(
{ owner: user.nickname },
{ sharedWith: cleaned },
{ upsert: true, returnDocument: 'after' }
);
return json({ sharedWith: doc?.sharedWith ?? cleaned });
};

View File

@@ -0,0 +1,38 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { PeriodShare } from '$models/PeriodShare';
import { PeriodEntry } from '$models/PeriodEntry';
/** GET: Get period data from all users who have shared with the current user */
export const GET: RequestHandler = async ({ locals }) => {
const user = await requireAuth(locals);
await dbConnect();
// Find all share docs where current user is in the sharedWith list
const shares = await PeriodShare.find({ sharedWith: user.nickname }).lean();
const owners = shares.map(s => s.owner);
if (owners.length === 0) return json({ shared: [] });
// Fetch period entries for all sharing owners
const entries = await PeriodEntry.find({ createdBy: { $in: owners } })
.sort({ startDate: -1 })
.limit(200)
.lean();
// Group by owner
const byOwner = new Map<string, any[]>();
for (const e of entries) {
const list = byOwner.get(e.createdBy) ?? [];
list.push(e);
byOwner.set(e.createdBy, list);
}
const shared = owners
.filter(o => byOwner.has(o))
.map(owner => ({ owner, entries: byOwner.get(owner)! }));
return json({ shared });
};

View File

@@ -28,14 +28,13 @@ export const GET: RequestHandler = async ({ url, locals }) => {
const limit = parseInt(url.searchParams.get('limit') || '20');
const offset = parseInt(url.searchParams.get('offset') || '0');
const sessions = await WorkoutSession.find({ createdBy: session.user.nickname })
.select('-exercises.gpsTrack -gpsTrack')
.sort({ startTime: -1 })
.limit(limit)
.skip(offset);
const total = await WorkoutSession.countDocuments({ createdBy: session.user.nickname });
const query = { createdBy: session.user.nickname };
const [sessions, total] = await Promise.all([
WorkoutSession.find(query).select('-exercises.gpsTrack -gpsTrack')
.sort({ startTime: -1 }).limit(limit).skip(offset),
WorkoutSession.countDocuments(query)
]);
return json({ sessions, total, limit, offset });
} catch (error) {
console.error('Error fetching workout sessions:', error);

View File

@@ -16,31 +16,13 @@ export const GET: RequestHandler = async ({ locals }) => {
const tenWeeksAgo = new Date();
tenWeeksAgo.setDate(tenWeeksAgo.getDate() - 70);
const totalWorkouts = await WorkoutSession.countDocuments({ createdBy: user.nickname });
const weeklyAgg = await WorkoutSession.aggregate([
{
$match: {
createdBy: user.nickname,
startTime: { $gte: tenWeeksAgo }
}
},
{
$group: {
_id: {
year: { $isoWeekYear: '$startTime' },
week: { $isoWeek: '$startTime' }
},
count: { $sum: 1 }
}
},
{
$sort: { '_id.year': 1, '_id.week': 1 }
}
]);
// Fetch user demographics for kcal estimation
const [goal, latestMeasurement] = await Promise.all([
const [totalWorkouts, weeklyAgg, goal, latestMeasurement] = await Promise.all([
WorkoutSession.countDocuments({ createdBy: user.nickname }),
WorkoutSession.aggregate([
{ $match: { createdBy: user.nickname, startTime: { $gte: tenWeeksAgo } } },
{ $group: { _id: { year: { $isoWeekYear: '$startTime' }, week: { $isoWeek: '$startTime' } }, count: { $sum: 1 } } },
{ $sort: { '_id.year': 1, '_id.week': 1 } }
]),
FitnessGoal.findOne({ username: user.nickname }).lean() as any,
BodyMeasurement.findOne(
{ createdBy: user.nickname, weight: { $ne: null } },
@@ -57,10 +39,19 @@ export const GET: RequestHandler = async ({ locals }) => {
// Lifetime totals: tonnage lifted + cardio km + kcal estimate
// Use stored kcalEstimate when available; fall back to on-the-fly for legacy sessions
const allSessions = await WorkoutSession.find(
{ createdBy: user.nickname },
{ 'exercises.exerciseId': 1, 'exercises.sets': 1, 'exercises.totalDistance': 1, kcalEstimate: 1 }
).lean();
const DISPLAY_LIMIT = 30;
const SMA_LOOKBACK = 6; // w - 1 where w = 7 max
const [allSessions, weightMeasurements] = await Promise.all([
WorkoutSession.find(
{ createdBy: user.nickname },
{ 'exercises.exerciseId': 1, 'exercises.sets': 1, 'exercises.totalDistance': 1, kcalEstimate: 1 }
).lean(),
BodyMeasurement.find(
{ createdBy: user.nickname, weight: { $ne: null } },
{ date: 1, weight: 1, _id: 0 }
).sort({ date: -1 }).limit(DISPLAY_LIMIT + SMA_LOOKBACK).lean()
]);
weightMeasurements.reverse(); // back to chronological order
let totalTonnage = 0;
let totalCardioKm = 0;
@@ -146,18 +137,6 @@ export const GET: RequestHandler = async ({ locals }) => {
upper: totalKcal + combinedMargin,
};
// Fetch extra measurements beyond the display limit to fill the SMA lookback window
const DISPLAY_LIMIT = 30;
const SMA_LOOKBACK = 6; // w - 1 where w = 7 max
const weightMeasurements = await BodyMeasurement.find(
{ createdBy: user.nickname, weight: { $ne: null } },
{ date: 1, weight: 1, _id: 0 }
)
.sort({ date: -1 })
.limit(DISPLAY_LIMIT + SMA_LOOKBACK)
.lean();
weightMeasurements.reverse(); // back to chronological order
// Split into lookback-only (not displayed) and display portions
const displayStart = Math.max(0, weightMeasurements.length - DISPLAY_LIMIT);

View File

@@ -5,8 +5,23 @@ import { fuzzyScore } from '$lib/js/fuzzy';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { FavoriteIngredient } from '$models/FavoriteIngredient';
import { Recipe } from '$models/Recipe';
import { OpenFoodFact } from '$models/OpenFoodFact';
import { getBlsEntryByCode, getNutritionEntryByFdcId } from '$lib/server/nutritionMatcher';
type SearchResult = { source: 'bls' | 'usda'; id: string; name: string; category: string; calories: number; favorited?: boolean; per100g?: any; portions?: any[] };
type SearchResult = {
source: 'bls' | 'usda' | 'recipe' | 'off';
id: string;
name: string;
category: string;
calories: number;
favorited?: boolean;
per100g?: any;
portions?: any[];
brands?: string;
icon?: string;
image?: string;
};
function lookupBls(blsCode: string, full: boolean): SearchResult | null {
const entry = BLS_DB.find(e => e.blsCode === blsCode);
@@ -34,7 +49,74 @@ function lookupUsda(fdcId: string, full: boolean): SearchResult | null {
};
}
/** GET: Search BLS + USDA nutrition databases by fuzzy name match */
/** Parse ingredient amount string to a number */
function parseAmount(amount: string | undefined): number {
if (!amount?.trim()) return 0;
let s = amount.trim().replace(',', '.');
const rangeMatch = s.match(/^(\d+(?:\.\d+)?)\s*[-]\s*(\d+(?:\.\d+)?)$/);
if (rangeMatch) return (parseFloat(rangeMatch[1]) + parseFloat(rangeMatch[2])) / 2;
const fractionMatch = s.match(/^(\d+)\s*\/\s*(\d+)$/);
if (fractionMatch) return parseInt(fractionMatch[1]) / parseInt(fractionMatch[2]);
const parsed = parseFloat(s);
return isNaN(parsed) ? 0 : parsed;
}
/** Compute per-100g nutrition for a recipe from its ingredients + nutritionMappings */
function computeRecipePer100g(recipe: any): { per100g: Record<string, number>; totalGrams: number } | null {
const mappings = recipe.nutritionMappings;
if (!mappings?.length) return null;
const keys = [
'calories', 'protein', 'fat', 'saturatedFat', 'carbs', 'fiber', 'sugars',
'calcium', 'iron', 'magnesium', 'phosphorus', 'potassium', 'sodium', 'zinc',
'vitaminA', 'vitaminC', 'vitaminD', 'vitaminE', 'vitaminK',
'thiamin', 'riboflavin', 'niacin', 'vitaminB6', 'vitaminB12', 'folate', 'cholesterol',
];
const totals: Record<string, number> = {};
for (const k of keys) totals[k] = 0;
let totalGrams = 0;
// Build mapping index
const mappingIndex = new Map<string, any>();
for (const m of mappings) {
mappingIndex.set(`${m.sectionIndex}-${m.ingredientIndex}`, m);
}
// Resolve per100g for each mapping and sum
for (const m of mappings) {
if (m.matchMethod === 'none' || m.excluded || !m.gramsPerUnit) continue;
let per100g = m.per100g;
if (!per100g) {
// Resolve from DB
if (m.source === 'bls' && m.blsCode) {
per100g = getBlsEntryByCode(m.blsCode)?.per100g;
} else if (m.fdcId) {
per100g = getNutritionEntryByFdcId(m.fdcId)?.per100g;
}
}
if (!per100g) continue;
// Find the ingredient in the recipe to get its amount
const section = recipe.ingredients?.[m.sectionIndex];
const items = section?.list ?? section?.ingredients ?? section?.items ?? [];
const ing = items[m.ingredientIndex];
const parsedAmount = (ing ? parseAmount(ing.amount) : 0) || (m.defaultAmountUsed ? 1 : 0);
const grams = parsedAmount * m.gramsPerUnit;
totalGrams += grams;
const factor = grams / 100;
for (const k of keys) totals[k] += factor * (per100g[k] ?? 0);
}
if (totalGrams <= 0) return null;
const per100g: Record<string, number> = {};
for (const k of keys) per100g[k] = totals[k] / totalGrams * 100;
return { per100g, totalGrams };
}
/** GET: Search recipes, BLS, USDA, and OpenFoodFacts by fuzzy name match */
export const GET: RequestHandler = async ({ url, locals }) => {
const q = (url.searchParams.get('q') || '').toLowerCase().trim();
if (q.length < 2) return json([]);
@@ -70,7 +152,59 @@ export const GET: RequestHandler = async ({ url, locals }) => {
const scored: (SearchResult & { score: number })[] = [];
// Search BLS (primary)
// Search recipes + OFF in parallel with BLS/USDA (which are in-memory)
await dbConnect();
const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const words = q.split(/\s+/).filter(Boolean);
const nameRegex = words.map(w => `(?=.*${w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`).join('') + '.*';
const [recipes, offResults] = await Promise.all([
Recipe.find({
$or: [
{ name: { $regex: nameRegex, $options: 'i' } },
{ short_name: { $regex: nameRegex, $options: 'i' } },
{ tags: { $regex: escaped, $options: 'i' } },
]
}).select('name short_name icon images ingredients nutritionMappings portions').limit(10).lean()
.catch(() => [] as any[]),
OpenFoodFact.find(
{ $text: { $search: q } },
{ ...(full ? {} : { name: 1, nameDe: 1, brands: 1, category: 1, 'per100g.calories': 1, serving: 1 }), score: { $meta: 'textScore' } }
).sort({ score: { $meta: 'textScore' } }).limit(15).lean()
.catch(() => [] as any[]),
]);
// Process recipe results (highest priority — scored with bonus)
for (const r of recipes as any[]) {
const scoreName = fuzzyScore(q, (r.name || '').toLowerCase());
const scoreShort = fuzzyScore(q, (r.short_name || '').replace(/_/g, ' ').toLowerCase());
const best = Math.max(scoreName, scoreShort);
if (best <= 0) continue;
const nutrition = computeRecipePer100g(r);
const image = r.images?.[0]?.mediapath;
const portionsMatch = r.portions?.match(/^(\d+(?:[.,]\d+)?)/);
const portionCount = portionsMatch ? parseFloat(portionsMatch[1].replace(',', '.')) : 0;
const portions: any[] = [];
if (portionCount > 0 && nutrition) {
const gramsPerPortion = Math.round(nutrition.totalGrams / portionCount);
portions.push({ description: '1 Portion', grams: gramsPerPortion });
}
scored.push({
source: 'recipe',
id: String(r._id),
name: r.name.replace(/&shy;|­/g, ''),
category: r.icon || '🍽️',
calories: Math.round(nutrition?.per100g.calories ?? 0),
score: best + 100, // Boost recipes above BLS/USDA/OFF
...(full && nutrition && { per100g: nutrition.per100g }),
...(portions.length > 0 && { portions }),
...(image && { image }),
});
}
// Search BLS (in-memory, primary)
for (const entry of BLS_DB) {
const scoreDe = fuzzyScore(q, entry.nameDe.toLowerCase());
const scoreEn = entry.nameEn ? fuzzyScore(q, entry.nameEn.toLowerCase()) : 0;
@@ -106,6 +240,35 @@ export const GET: RequestHandler = async ({ url, locals }) => {
}
}
// Process OpenFoodFacts results
{
for (const entry of offResults as any[]) {
const displayName = entry.nameDe || entry.name;
// Use fuzzy score for ranking consistency with BLS/USDA
const scoreDe = entry.nameDe ? fuzzyScore(q, entry.nameDe.toLowerCase()) : 0;
const scoreEn = fuzzyScore(q, entry.name.toLowerCase());
const scoreBrand = entry.brands ? fuzzyScore(q, entry.brands.toLowerCase()) : 0;
const best = Math.max(scoreDe, scoreEn, scoreBrand, 1); // text search already filtered
const portions: any[] = [];
if (entry.serving?.grams) {
portions.push(entry.serving);
}
scored.push({
source: 'off',
id: entry.barcode,
name: displayName,
category: entry.category || '',
calories: entry.per100g?.calories ?? 0,
brands: entry.brands,
score: best,
...(full && { per100g: entry.per100g }),
...(portions.length > 0 && { portions }),
});
}
}
// Sort by score descending, return top 30 (without score field)
scored.sort((a, b) => b.score - a.score);
const searchResults = scored.slice(0, 30).map(({ score, ...rest }) => rest);

View File

@@ -9,25 +9,19 @@ export const GET: RequestHandler = async ({ locals }) => {
await dbConnect();
// Completions per user
const userStats = await TaskCompletion.aggregate([
{ $group: { _id: '$completedBy', count: { $sum: 1 } } },
{ $sort: { count: -1 } }
const [userStats, userStickers, recentCompletions] = await Promise.all([
TaskCompletion.aggregate([
{ $group: { _id: '$completedBy', count: { $sum: 1 } } },
{ $sort: { count: -1 } }
]),
TaskCompletion.aggregate([
{ $match: { stickerId: { $exists: true, $ne: null } } },
{ $group: { _id: { user: '$completedBy', sticker: '$stickerId' }, count: { $sum: 1 } } },
{ $sort: { '_id.user': 1, count: -1 } }
]),
TaskCompletion.find().sort({ completedAt: -1 }).limit(500).lean()
]);
// Stickers per user
const userStickers = await TaskCompletion.aggregate([
{ $match: { stickerId: { $exists: true, $ne: null } } },
{ $group: { _id: { user: '$completedBy', sticker: '$stickerId' }, count: { $sum: 1 } } },
{ $sort: { '_id.user': 1, count: -1 } }
]);
// Recent completions (enough for ~3 months of calendar)
const recentCompletions = await TaskCompletion.find()
.sort({ completedAt: -1 })
.limit(500)
.lean();
return json({ userStats, userStickers, recentCompletions });
};

View File

@@ -1,15 +1,21 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => {
const [latestRes, listRes, goalRes] = await Promise.all([
const [latestRes, listRes, goalRes, periodRes, shareRes, sharedRes] = await Promise.all([
fetch('/api/fitness/measurements/latest'),
fetch('/api/fitness/measurements?limit=20'),
fetch('/api/fitness/goal')
fetch('/api/fitness/goal'),
fetch('/api/fitness/period').catch(() => null),
fetch('/api/fitness/period/share').catch(() => null),
fetch('/api/fitness/period/shared').catch(() => null)
]);
return {
latest: await latestRes.json(),
measurements: await listRes.json(),
profile: goalRes.ok ? await goalRes.json() : {}
profile: goalRes.ok ? await goalRes.json() : {},
periods: periodRes?.ok ? (await periodRes.json()).entries : [],
periodSharedWith: shareRes?.ok ? (await shareRes.json()).sharedWith : [],
sharedPeriods: sharedRes?.ok ? (await sharedRes.json()).shared : []
};
};

View File

@@ -1,6 +1,6 @@
<script>
import { page } from '$app/stores';
import { Pencil, Trash2, ChevronDown } from '@lucide/svelte';
import { Pencil, Trash2, ChevronRight, Venus, Mars } from '@lucide/svelte';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
import { toast } from '$lib/js/toast.svelte';
@@ -8,19 +8,32 @@
const measureSlug = $derived(lang === 'en' ? 'measure' : 'messen');
import { getWorkout } from '$lib/js/workout.svelte';
import AddButton from '$lib/components/AddButton.svelte';
import PeriodTracker from '$lib/components/fitness/PeriodTracker.svelte';
let { data } = $props();
const workout = getWorkout();
let latest = $state(data.latest ? { ...data.latest } : {});
let measurements = $state(data.measurements?.measurements ? [...data.measurements.measurements] : []);
let showWeightHistory = $state(false);
// Profile fields (sex, height, birth year) — stored in FitnessGoal
let showProfile = $state(false);
let profileSex = $state(data.profile?.sex ?? 'male');
let profileHeight = $state(data.profile?.heightCm != null ? String(data.profile.heightCm) : '');
let profileBirthYear = $state(data.profile?.birthYear != null ? String(data.profile.birthYear) : '');
let profileSaving = $state(false);
let profileEditing = $state(false);
const profileParts = $derived.by(() => {
/** @type {string[]} */
const parts = [];
const h = data.profile?.heightCm;
if (h) parts.push(`${h}cm`);
const by = data.profile?.birthYear;
if (by) parts.push(`*${by}`);
return parts;
});
let profileDirty = $derived(
profileSex !== (data.profile?.sex ?? 'male') ||
profileHeight !== (data.profile?.heightCm != null ? String(data.profile.heightCm) : '') ||
@@ -99,7 +112,6 @@
const parts = [];
if (m.weight != null) parts.push(`${m.weight} kg`);
if (m.bodyFatPercent != null) parts.push(`${m.bodyFatPercent}% bf`);
if (m.caloricIntake != null) parts.push(`${m.caloricIntake} kcal`);
return parts.join(' · ') || t('body_measurements_only', lang);
}
</script>
@@ -107,38 +119,51 @@
<svelte:head><title>{lang === 'en' ? 'Measure' : 'Messen'} - Bocken</title></svelte:head>
<div class="measure-page">
<h1>{t('measure_title', lang)}</h1>
<div class="page-header">
<h1>{t('measure_title', lang)}</h1>
<div class="profile-meta">
{#if data.profile?.sex}
<span class="profile-sex-icon">
{#if data.profile.sex === 'female'}
<Venus size={16} />
{:else}
<Mars size={16} />
{/if}
</span>
{/if}
{#if profileParts.length > 0}
<span class="profile-summary">{profileParts.join(' · ')}</span>
{/if}
<button class="profile-edit-btn" onclick={() => profileEditing = !profileEditing} aria-label="Edit profile">
<Pencil size={12} />
</button>
</div>
</div>
<section class="profile-section">
<button class="profile-toggle" onclick={() => showProfile = !showProfile}>
<h2>{t('profile', lang)}</h2>
<ChevronDown size={16} class={showProfile ? 'chevron open' : 'chevron'} />
</button>
{#if showProfile}
<div class="profile-row">
<div class="form-group">
<label for="p-sex">{t('sex', lang)}</label>
<select id="p-sex" bind:value={profileSex}>
<option value="male">{t('male', lang)}</option>
<option value="female">{t('female', lang)}</option>
</select>
</div>
<div class="form-group">
<label for="p-height">{t('height', lang)}</label>
<input id="p-height" type="number" min="100" max="250" placeholder="175" bind:value={profileHeight} />
</div>
<div class="form-group">
<label for="p-birthyear">{t('birth_year', lang)}</label>
<input id="p-birthyear" type="number" min="1900" max="2020" placeholder="1990" bind:value={profileBirthYear} />
</div>
{#if profileDirty}
<button class="profile-save-btn" onclick={saveProfile} disabled={profileSaving}>
{profileSaving ? t('saving', lang) : t('save', lang)}
</button>
{/if}
{#if profileEditing}
<div class="profile-fields">
<div class="form-group">
<label for="p-sex">{t('sex', lang)}</label>
<select id="p-sex" bind:value={profileSex}>
<option value="male">{t('male', lang)}</option>
<option value="female">{t('female', lang)}</option>
</select>
</div>
{/if}
</section>
<div class="form-group">
<label for="p-height">{t('height', lang)}</label>
<input id="p-height" type="number" min="100" max="250" placeholder="175" bind:value={profileHeight} />
</div>
<div class="form-group">
<label for="p-birthyear">{t('birth_year', lang)}</label>
<input id="p-birthyear" type="number" min="1900" max="2020" placeholder="1990" bind:value={profileBirthYear} />
</div>
{#if profileDirty}
<button class="profile-save-btn" onclick={saveProfile} disabled={profileSaving}>
{profileSaving ? t('saving', lang) : t('save', lang)}
</button>
{/if}
</div>
{/if}
<section class="latest-section">
<h2>{t('latest', lang)}</h2>
@@ -151,10 +176,6 @@
<span class="stat-label">{t('body_fat', lang)}</span>
<span class="stat-value">{latest.bodyFatPercent?.value ?? '—'}<small>%</small></span>
</div>
<div class="stat-card">
<span class="stat-label">{t('calories', lang)}</span>
<span class="stat-value">{latest.caloricIntake?.value ?? '—'} <small>kcal</small></span>
</div>
</div>
</section>
@@ -174,29 +195,42 @@
{#if measurements.length > 0}
<section class="history-section">
<h2>{t('history', lang)}</h2>
<div class="history-list">
{#each measurements as m (m._id)}
<div class="history-item">
<div class="history-main">
<div class="history-info">
<span class="history-date">{formatDate(m.date)}</span>
<span class="history-summary">{summaryParts(m)}</span>
</div>
<div class="history-actions">
<a class="icon-btn edit" href="/fitness/{measureSlug}/edit/{m._id}" aria-label="Edit measurement">
<Pencil size={14} />
</a>
<button class="icon-btn delete" onclick={() => deleteMeasurement(m._id)} aria-label="Delete measurement">
<Trash2 size={14} />
</button>
<button class="history-toggle" onclick={() => showWeightHistory = !showWeightHistory}>
<h2>{t('history', lang)}</h2>
<ChevronRight size={14} class={showWeightHistory ? 'chevron open' : 'chevron'} />
</button>
{#if showWeightHistory}
<div class="history-list">
{#each measurements as m (m._id)}
<div class="history-item">
<div class="history-main">
<div class="history-info">
<span class="history-date">{formatDate(m.date)}</span>
<span class="history-summary">{summaryParts(m)}</span>
</div>
<div class="history-actions">
<a class="icon-btn edit" href="/fitness/{measureSlug}/edit/{m._id}" aria-label="Edit measurement">
<Pencil size={14} />
</a>
<button class="icon-btn delete" onclick={() => deleteMeasurement(m._id)} aria-label="Delete measurement">
<Trash2 size={14} />
</button>
</div>
</div>
</div>
</div>
{/each}
</div>
{/each}
</div>
{/if}
</section>
{/if}
{#if data.profile?.sex === 'female'}
<PeriodTracker periods={data.periods ?? []} {lang} sharedWith={data.periodSharedWith ?? []} />
{/if}
{#each data.sharedPeriods ?? [] as shared (shared.owner)}
<PeriodTracker periods={shared.entries} {lang} readOnly ownerName={shared.owner} />
{/each}
</div>
{#if !workout.active}
@@ -217,43 +251,47 @@
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
/* Profile */
.profile-section {
background: var(--color-surface);
border-radius: 8px;
box-shadow: var(--shadow-sm);
padding: 0.75rem 1rem;
/* Header with inline profile */
.page-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.profile-toggle {
.profile-meta {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 0.4rem;
}
.profile-sex-icon {
display: flex;
color: var(--color-text-secondary);
}
.profile-summary {
font-size: 0.8rem;
color: var(--color-text-secondary);
letter-spacing: 0.02em;
}
.profile-edit-btn {
display: flex;
align-items: center;
padding: 0.25rem;
background: none;
border: none;
cursor: pointer;
padding: 0;
color: inherit;
color: var(--color-text-tertiary);
opacity: 0.6;
transition: opacity 0.15s;
}
.profile-toggle h2 {
margin: 0;
font-size: 0.9rem;
.profile-edit-btn:hover {
opacity: 1;
color: var(--color-text-secondary);
}
.profile-toggle :global(.chevron) {
transition: transform 0.2s;
}
.profile-toggle :global(.chevron.open) {
transform: rotate(180deg);
}
.profile-section h2 {
margin: 0 0 0.5rem;
font-size: 0.9rem;
}
.profile-row {
.profile-fields {
display: flex;
gap: 0.75rem;
align-items: flex-end;
margin-top: 0.5rem;
}
.profile-save-btn {
padding: 0.4rem 0.75rem;
@@ -303,7 +341,7 @@
/* Latest */
.stat-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(2, 1fr);
gap: 0.6rem;
}
.stat-card {
@@ -351,6 +389,27 @@
}
/* History */
.history-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: none;
border: none;
cursor: pointer;
padding: 0;
color: inherit;
}
.history-toggle h2 {
margin: 0;
font-size: 1.1rem;
}
.history-toggle :global(.chevron) {
transition: transform 0.2s;
}
.history-toggle :global(.chevron.open) {
transform: rotate(90deg);
}
.history-list {
display: flex;
flex-direction: column;

View File

@@ -13,7 +13,6 @@
let formDate = $state(new Date().toISOString().slice(0, 10));
let formWeight = $state('');
let formBodyFat = $state('');
let formCalories = $state('');
let formNeck = $state('');
let formShoulders = $state('');
let formChest = $state('');
@@ -35,9 +34,6 @@
else body.weight = null;
if (formBodyFat) body.bodyFatPercent = Number(formBodyFat);
else body.bodyFatPercent = null;
if (formCalories) body.caloricIntake = Number(formCalories);
else body.caloricIntake = null;
/** @type {any} */
const m = {};
if (formNeck) m.neck = Number(formNeck);
@@ -102,10 +98,6 @@
<label for="m-bf">{t('body_fat_pct', lang)}</label>
<input id="m-bf" type="number" step="0.1" bind:value={formBodyFat} placeholder="--" />
</div>
<div class="form-group">
<label for="m-cal">{t('calories_kcal', lang)}</label>
<input id="m-cal" type="number" bind:value={formCalories} placeholder="--" />
</div>
</div>
<h3>{t('body_parts_cm', lang)}</h3>

View File

@@ -19,8 +19,6 @@
let formDate = $state(m ? new Date(m.date).toISOString().slice(0, 10) : '');
let formWeight = $state(m?.weight != null ? String(m.weight) : '');
let formBodyFat = $state(m?.bodyFatPercent != null ? String(m.bodyFatPercent) : '');
let formCalories = $state(m?.caloricIntake != null ? String(m.caloricIntake) : '');
const bp = m?.measurements ?? {};
let formNeck = $state(bp.neck != null ? String(bp.neck) : '');
let formShoulders = $state(bp.shoulders != null ? String(bp.shoulders) : '');
@@ -43,9 +41,6 @@
else body.weight = null;
if (formBodyFat) body.bodyFatPercent = Number(formBodyFat);
else body.bodyFatPercent = null;
if (formCalories) body.caloricIntake = Number(formCalories);
else body.caloricIntake = null;
/** @type {any} */
const ms = {};
if (formNeck) ms.neck = Number(formNeck);
@@ -128,10 +123,6 @@
<label for="m-bf">{t('body_fat_pct', lang)}</label>
<input id="m-bf" type="number" step="0.1" bind:value={formBodyFat} placeholder="--" />
</div>
<div class="form-group">
<label for="m-cal">{t('calories_kcal', lang)}</label>
<input id="m-cal" type="number" bind:value={formCalories} placeholder="--" />
</div>
</div>
<h3>{t('body_parts_cm', lang)}</h3>

View File

@@ -8,29 +8,33 @@ import mongoose from 'mongoose';
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
const dateParam = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
const [foodRes, goalRes, weightRes] = await Promise.all([
// Run all independent work in parallel: 3 API calls + workout kcal DB query
const dayStart = new Date(dateParam + 'T00:00:00.000Z');
const dayEnd = new Date(dateParam + 'T23:59:59.999Z');
const exercisePromise = (async () => {
try {
const user = await requireAuth(locals);
await dbConnect();
const sessions = await WorkoutSession.find({
createdBy: user.nickname,
startTime: { $gte: dayStart, $lte: dayEnd }
}).select('kcalEstimate').lean();
let kcal = 0;
for (const s of sessions) {
if (s.kcalEstimate?.kcal) kcal += s.kcalEstimate.kcal;
}
return kcal;
} catch { return 0; }
})();
const [foodRes, goalRes, weightRes, exerciseKcal] = await Promise.all([
fetch(`/api/fitness/food-log?date=${dateParam}`),
fetch('/api/fitness/goal'),
fetch('/api/fitness/measurements/latest')
fetch('/api/fitness/measurements/latest'),
exercisePromise
]);
// Fetch today's workout kcal burned
let exerciseKcal = 0;
try {
const user = await requireAuth(locals);
await dbConnect();
const dayStart = new Date(dateParam + 'T00:00:00.000Z');
const dayEnd = new Date(dateParam + 'T23:59:59.999Z');
const sessions = await WorkoutSession.find({
createdBy: user.nickname,
startTime: { $gte: dayStart, $lte: dayEnd }
}).select('kcalEstimate').lean();
for (const s of sessions) {
if (s.kcalEstimate?.kcal) exerciseKcal += s.kcalEstimate.kcal;
}
} catch {}
const foodLog = foodRes.ok ? await foodRes.json() : { entries: [] };
// Resolve recipe images for entries with source=recipe

View File

@@ -731,7 +731,7 @@
{@const mealCal = mealEntries.reduce((s, e) => s + entryCalories(e), 0)}
{@const meta = mealMeta[meal]}
{@const MealSectionIcon = meta.icon}
<div class="meal-section" style="--meal-color: {meta.color}; animation-delay: {mi * 60}ms">
<div class="meal-section" style="--meal-color: {meta.color}">
<div class="meal-header">
<div class="meal-title">
<div class="meal-icon">
@@ -862,11 +862,6 @@
gap: 0.75rem;
}
/* ── Entrance animations ── */
@keyframes fade-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Date Navigator ── */
.date-nav {
@@ -874,7 +869,7 @@
align-items: center;
justify-content: center;
gap: 0.25rem;
animation: fade-up 0.3s ease both;
}
.date-btn {
background: none;
@@ -927,8 +922,8 @@
padding: 1.25rem;
box-shadow: var(--shadow-sm);
position: relative;
animation: fade-up 0.35s ease both;
animation-delay: 50ms;
}
.daily-summary::before {
content: '';
@@ -1148,7 +1143,7 @@
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
animation: fade-up 0.25s ease both;
}
.micro-section h4 {
margin: 0 0 0.4rem;
@@ -1205,7 +1200,7 @@
background: var(--color-surface);
border-radius: 12px;
box-shadow: var(--shadow-sm);
animation: fade-up 0.35s ease both;
}
.no-goal-icon {
display: flex;
@@ -1247,7 +1242,7 @@
border-radius: 12px;
padding: 1.25rem;
box-shadow: var(--shadow-sm);
animation: fade-up 0.25s ease both;
}
.preset-section {
margin-bottom: 1rem;
@@ -1370,7 +1365,7 @@
/* ── Meal Sections ── */
.meal-section {
animation: fade-up 0.35s ease both;
}
.meal-header {
display: flex;
@@ -1568,7 +1563,7 @@
border-radius: 10px;
padding: 0.85rem;
box-shadow: var(--shadow-sm);
animation: fade-up 0.2s ease both;
}
/* Search/food selection handled by FoodSearch component */