feat(mana/web/body): new module — combined fitness training + body comp tracking

Adds the unified Body module that merges what would otherwise be two
separate apps (fitness + bodylog) into one. The value lives in their
intersection: tracking lifts alongside bodyweight is what enables
real progressive-overload + recomp insights, and shared primitives
(charts, time series, units, photos) avoid duplicating UI surface.

This commit lands only the data layer + module registration so the
follow-up UI / route / dashboard widget can build on a stable
foundation.

Tables (db.version(2), already in place):
  bodyExercises    — exercise library (Squat, Bench, Deadlift, OHP,
                     Row, Pull-Up seeded as presets)
  bodyRoutines     — saved workout templates
  bodyWorkouts     — one logged training session
  bodySets         — set rows inside a workout, indexed [workoutId+order]
  bodyMeasurements — weight + measurements over time, indexed [type+date]
  bodyChecks       — daily energy/sleep/soreness/mood self-rating,
                     upserted per day
  bodyPhases       — cut/bulk/maintenance/recomp phase markers, with
                     auto-close on phase change so the "active phase"
                     view always has at most one open row

Encryption (registry.ts): all 7 tables flipped to enabled. Health
data is GDPR Art. 9 special-category, so user-typed text + the
sensitive numeric fields (weight, reps, value, startWeight,
targetWeight, energy/sleep/soreness/mood) are wrapped. Indexed
columns (ids, FKs, ordering, dates, kind/type/equipment enums)
stay plaintext so the existing query layer keeps working without
decrypt-on-every-row.

Module wiring:
  - bodyModuleConfig added to module-registry.ts
  - Body app entry registered in shared-branding mana-apps.ts
    (red→orange icon to set it apart from the green health-adjacent
    modules and the pink cycles icon)
  - APP_ICONS.body added (dumbbell + heart-pulse hybrid SVG)

Also captures the broader module-ideas brainstorm in
docs/future/MODULE_IDEAS.md and marks fitness + bodylog as merged
into the new body module.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 16:28:19 +02:00
parent 52159ee07a
commit a412ccc6fb
11 changed files with 1297 additions and 0 deletions

View file

@ -331,6 +331,46 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
},
newsReactions: { enabled: true, fields: ['reaction', 'sourceSlug', 'topic'] },
// ─── Body (combined fitness + bodylog) ───────────────────
// Health/fitness data is GDPR-sensitive (Art. 9 special category).
// What's encrypted:
// - Free-text everywhere: notes / description / title fields are
// user-typed and the most obviously private bits.
// - bodyMeasurements.value: weight + body-fat + circumference numbers
// are the headline sensitive fields. Encrypting the value while
// leaving (date, type) plaintext keeps the per-metric trend chart's
// [type+date] range scan working — only the projection step has to
// decrypt, not the index lookup.
// - bodySets.weight + reps: same rationale. The (workoutId, exerciseId)
// plaintext indexes still resolve "which sets did I do" without
// leaking how heavy or how many.
// - bodyChecks.energy/sleep/soreness/mood: 1-5 mood-style ratings with
// the same sensitivity as cycleDayLogs.mood.
// - bodyPhases.startWeight/targetWeight: identical reasoning to
// measurement values.
// Plaintext (intentional):
// - All ids, foreign keys, ordering, dates, kind/type discriminators,
// muscleGroup/equipment enums — needed by the index layer and the
// pure aggregation helpers in queries.ts.
// - bodyExercises.name on PRESETS would ideally stay plaintext to
// avoid a per-record decrypt for the exercise picker, but since
// user-created exercises share the same column we encrypt the
// whole field and the picker pays the decrypt cost in JS. The
// library is small (dozens, not thousands) so this is fine.
bodyExercises: { enabled: true, fields: ['name', 'notes'] },
bodyRoutines: { enabled: true, fields: ['name', 'description'] },
bodyWorkouts: { enabled: true, fields: ['title', 'notes'] },
bodySets: { enabled: true, fields: ['weight', 'reps', 'notes'] },
bodyMeasurements: { enabled: true, fields: ['value', 'notes'] },
bodyChecks: {
enabled: true,
fields: ['energy', 'sleep', 'soreness', 'mood', 'notes'],
},
bodyPhases: {
enabled: true,
fields: ['startWeight', 'targetWeight', 'notes'],
},
// ─── TimeBlocks (cross-module hub) ───────────────────────
// Phase 7.1: encrypted alongside tasks + calendar.events + habits
// because the consumer modules denormalize their title/description

View file

@ -85,6 +85,7 @@ import { placesModuleConfig } from '$lib/modules/places/module.config';
import { playgroundModuleConfig } from '$lib/modules/playground/module.config';
import { whoModuleConfig } from '$lib/modules/who/module.config';
import { newsModuleConfig } from '$lib/modules/news/module.config';
import { bodyModuleConfig } from '$lib/modules/body/module.config';
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
manaCoreConfig,
@ -125,6 +126,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
playgroundModuleConfig,
whoModuleConfig,
newsModuleConfig,
bodyModuleConfig,
];
// ─── Derived Maps ──────────────────────────────────────────

View file

@ -0,0 +1,101 @@
/**
* Body module collection accessors and guest seed data.
*
* Tables are defined in the unified database.ts (db.version(2)) as:
* bodyExercises, bodyRoutines, bodyWorkouts, bodySets,
* bodyMeasurements, bodyChecks, bodyPhases.
*/
import { db } from '$lib/data/database';
import type {
LocalBodyExercise,
LocalBodyRoutine,
LocalBodyWorkout,
LocalBodySet,
LocalBodyMeasurement,
LocalBodyCheck,
LocalBodyPhase,
} from './types';
// ─── Collection Accessors ───────────────────────────────────
export const bodyExerciseTable = db.table<LocalBodyExercise>('bodyExercises');
export const bodyRoutineTable = db.table<LocalBodyRoutine>('bodyRoutines');
export const bodyWorkoutTable = db.table<LocalBodyWorkout>('bodyWorkouts');
export const bodySetTable = db.table<LocalBodySet>('bodySets');
export const bodyMeasurementTable = db.table<LocalBodyMeasurement>('bodyMeasurements');
export const bodyCheckTable = db.table<LocalBodyCheck>('bodyChecks');
export const bodyPhaseTable = db.table<LocalBodyPhase>('bodyPhases');
// ─── Guest Seed ─────────────────────────────────────────────
/**
* Minimal preset exercise library so a fresh guest user can start logging
* a workout without first having to fill in a name field for every lift.
* Real users will add and rename freely; presets are flagged isPreset:true
* so the UI can offer "reset to defaults" without nuking custom entries.
*/
export const BODY_GUEST_SEED = {
bodyExercises: [
{
id: 'body-exercise-squat',
name: 'Squat',
muscleGroup: 'quads',
equipment: 'barbell',
notes: null,
isArchived: false,
isPreset: true,
},
{
id: 'body-exercise-bench',
name: 'Bench Press',
muscleGroup: 'chest',
equipment: 'barbell',
notes: null,
isArchived: false,
isPreset: true,
},
{
id: 'body-exercise-deadlift',
name: 'Deadlift',
muscleGroup: 'back',
equipment: 'barbell',
notes: null,
isArchived: false,
isPreset: true,
},
{
id: 'body-exercise-ohp',
name: 'Overhead Press',
muscleGroup: 'shoulders',
equipment: 'barbell',
notes: null,
isArchived: false,
isPreset: true,
},
{
id: 'body-exercise-row',
name: 'Barbell Row',
muscleGroup: 'back',
equipment: 'barbell',
notes: null,
isArchived: false,
isPreset: true,
},
{
id: 'body-exercise-pullup',
name: 'Pull-Up',
muscleGroup: 'back',
equipment: 'bodyweight',
notes: null,
isArchived: false,
isPreset: true,
},
] satisfies LocalBodyExercise[],
bodyRoutines: [] satisfies LocalBodyRoutine[],
bodyWorkouts: [] satisfies LocalBodyWorkout[],
bodySets: [] satisfies LocalBodySet[],
bodyMeasurements: [] satisfies LocalBodyMeasurement[],
bodyChecks: [] satisfies LocalBodyCheck[],
bodyPhases: [] satisfies LocalBodyPhase[],
};

View file

@ -0,0 +1,70 @@
/**
* Body module barrel exports.
*/
// ─── Stores ──────────────────────────────────────────────
export { bodyStore } from './stores/body.svelte';
// ─── Queries ─────────────────────────────────────────────
export {
useAllBodyExercises,
useAllBodyRoutines,
useAllBodyWorkouts,
useAllBodySets,
useSetsForWorkout,
useAllBodyMeasurements,
useAllBodyChecks,
useAllBodyPhases,
toBodyExercise,
toBodyRoutine,
toBodyWorkout,
toBodySet,
toBodyMeasurement,
toBodyCheck,
toBodyPhase,
todayDateStr,
getLatestWeight,
getWorkoutVolume,
getBestSetByExercise,
estimateOneRepMax,
getActiveExercises,
getActiveWorkout,
getActivePhase,
} from './queries';
// ─── Collections ─────────────────────────────────────────
export {
bodyExerciseTable,
bodyRoutineTable,
bodyWorkoutTable,
bodySetTable,
bodyMeasurementTable,
bodyCheckTable,
bodyPhaseTable,
BODY_GUEST_SEED,
} from './collections';
// ─── Types ───────────────────────────────────────────────
export { MUSCLE_GROUPS, EQUIPMENT_TYPES, MEASUREMENT_TYPES } from './types';
export type {
MuscleGroup,
Equipment,
MeasurementType,
WeightUnit,
LengthUnit,
PhaseKind,
LocalBodyExercise,
LocalBodyRoutine,
LocalBodyWorkout,
LocalBodySet,
LocalBodyMeasurement,
LocalBodyCheck,
LocalBodyPhase,
BodyExercise,
BodyRoutine,
BodyWorkout,
BodySet,
BodyMeasurement,
BodyCheck,
BodyPhase,
} from './types';

View file

@ -0,0 +1,14 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const bodyModuleConfig: ModuleConfig = {
appId: 'body',
tables: [
{ name: 'bodyExercises' },
{ name: 'bodyRoutines' },
{ name: 'bodyWorkouts' },
{ name: 'bodySets' },
{ name: 'bodyMeasurements' },
{ name: 'bodyChecks' },
{ name: 'bodyPhases' },
],
};

View file

@ -0,0 +1,260 @@
/**
* Reactive Queries & Pure Helpers for the Body module.
*
* Read-side only mutations live in stores/body.svelte.ts.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { decryptRecords } from '$lib/data/crypto';
import { db } from '$lib/data/database';
import type {
LocalBodyExercise,
LocalBodyRoutine,
LocalBodyWorkout,
LocalBodySet,
LocalBodyMeasurement,
LocalBodyCheck,
LocalBodyPhase,
BodyExercise,
BodyRoutine,
BodyWorkout,
BodySet,
BodyMeasurement,
BodyCheck,
BodyPhase,
} from './types';
// ─── Type Converters ────────────────────────────────────────
export function toBodyExercise(local: LocalBodyExercise): BodyExercise {
const now = new Date().toISOString();
return {
id: local.id,
name: local.name,
muscleGroup: local.muscleGroup,
equipment: local.equipment,
notes: local.notes ?? null,
isArchived: local.isArchived,
isPreset: local.isPreset,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
export function toBodyRoutine(local: LocalBodyRoutine): BodyRoutine {
const now = new Date().toISOString();
return {
id: local.id,
name: local.name,
description: local.description ?? null,
exerciseIds: local.exerciseIds ?? [],
order: local.order,
isArchived: local.isArchived,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
export function toBodyWorkout(local: LocalBodyWorkout): BodyWorkout {
const now = new Date().toISOString();
return {
id: local.id,
startedAt: local.startedAt,
endedAt: local.endedAt ?? null,
routineId: local.routineId ?? null,
title: local.title ?? null,
notes: local.notes ?? null,
rpe: local.rpe ?? null,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
export function toBodySet(local: LocalBodySet): BodySet {
return {
id: local.id,
workoutId: local.workoutId,
exerciseId: local.exerciseId,
order: local.order,
reps: local.reps,
weight: local.weight,
weightUnit: local.weightUnit,
rpe: local.rpe ?? null,
isWarmup: local.isWarmup,
notes: local.notes ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
export function toBodyMeasurement(local: LocalBodyMeasurement): BodyMeasurement {
return {
id: local.id,
date: local.date,
type: local.type,
value: local.value,
unit: local.unit,
notes: local.notes ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
export function toBodyCheck(local: LocalBodyCheck): BodyCheck {
return {
id: local.id,
date: local.date,
energy: local.energy ?? null,
sleep: local.sleep ?? null,
soreness: local.soreness ?? null,
mood: local.mood ?? null,
notes: local.notes ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
export function toBodyPhase(local: LocalBodyPhase): BodyPhase {
const now = new Date().toISOString();
return {
id: local.id,
kind: local.kind,
startDate: local.startDate,
endDate: local.endDate ?? null,
startWeight: local.startWeight ?? null,
targetWeight: local.targetWeight ?? null,
notes: local.notes ?? null,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
// ─── Live Queries ───────────────────────────────────────────
export function useAllBodyExercises() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalBodyExercise>('bodyExercises').toArray();
const visible = locals.filter((e) => !e.deletedAt);
const decrypted = await decryptRecords('bodyExercises', visible);
return decrypted.map(toBodyExercise).sort((a, b) => a.name.localeCompare(b.name));
}, [] as BodyExercise[]);
}
export function useAllBodyRoutines() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalBodyRoutine>('bodyRoutines').orderBy('order').toArray();
const visible = locals.filter((r) => !r.deletedAt);
const decrypted = await decryptRecords('bodyRoutines', visible);
return decrypted.map(toBodyRoutine);
}, [] as BodyRoutine[]);
}
export function useAllBodyWorkouts() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalBodyWorkout>('bodyWorkouts').toArray();
const visible = locals.filter((w) => !w.deletedAt);
const decrypted = await decryptRecords('bodyWorkouts', visible);
return decrypted.map(toBodyWorkout).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
}, [] as BodyWorkout[]);
}
export function useAllBodySets() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalBodySet>('bodySets').toArray();
const visible = locals.filter((s) => !s.deletedAt);
const decrypted = await decryptRecords('bodySets', visible);
return decrypted.map(toBodySet);
}, [] as BodySet[]);
}
export function useSetsForWorkout(workoutId: string) {
return useLiveQueryWithDefault(async () => {
const locals = await db
.table<LocalBodySet>('bodySets')
.where('workoutId')
.equals(workoutId)
.toArray();
const visible = locals.filter((s) => !s.deletedAt);
const decrypted = await decryptRecords('bodySets', visible);
return decrypted.map(toBodySet).sort((a, b) => a.order - b.order);
}, [] as BodySet[]);
}
export function useAllBodyMeasurements() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalBodyMeasurement>('bodyMeasurements').toArray();
const visible = locals.filter((m) => !m.deletedAt);
const decrypted = await decryptRecords('bodyMeasurements', visible);
return decrypted.map(toBodyMeasurement).sort((a, b) => b.date.localeCompare(a.date));
}, [] as BodyMeasurement[]);
}
export function useAllBodyChecks() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalBodyCheck>('bodyChecks').toArray();
const visible = locals.filter((c) => !c.deletedAt);
const decrypted = await decryptRecords('bodyChecks', visible);
return decrypted.map(toBodyCheck).sort((a, b) => b.date.localeCompare(a.date));
}, [] as BodyCheck[]);
}
export function useAllBodyPhases() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalBodyPhase>('bodyPhases').toArray();
const visible = locals.filter((p) => !p.deletedAt);
const decrypted = await decryptRecords('bodyPhases', visible);
return decrypted.map(toBodyPhase).sort((a, b) => b.startDate.localeCompare(a.startDate));
}, [] as BodyPhase[]);
}
// ─── Pure Helpers ───────────────────────────────────────────
/** Today as YYYY-MM-DD. */
export function todayDateStr(): string {
return new Date().toISOString().split('T')[0];
}
/** Latest weight measurement (in whatever unit the user logged it). */
export function getLatestWeight(measurements: BodyMeasurement[]): BodyMeasurement | null {
return measurements.find((m) => m.type === 'weight') ?? null;
}
/** Volume = sum(reps * weight) for non-warmup sets, in the unit of the first set. */
export function getWorkoutVolume(sets: BodySet[]): number {
return sets.filter((s) => !s.isWarmup).reduce((sum, s) => sum + s.reps * s.weight, 0);
}
/**
* Best (heaviest) working set per exercise across the supplied sets.
* Used for the "PR feed" + per-exercise progression chart.
*/
export function getBestSetByExercise(sets: BodySet[]): Map<string, BodySet> {
const best = new Map<string, BodySet>();
for (const s of sets) {
if (s.isWarmup) continue;
const current = best.get(s.exerciseId);
if (!current || s.weight > current.weight) {
best.set(s.exerciseId, s);
}
}
return best;
}
/** Estimated 1-rep-max via the Epley formula. */
export function estimateOneRepMax(weight: number, reps: number): number {
if (reps <= 0) return 0;
if (reps === 1) return weight;
return Math.round(weight * (1 + reps / 30));
}
/** Active (non-archived) exercises sorted by name. */
export function getActiveExercises(exercises: BodyExercise[]): BodyExercise[] {
return exercises.filter((e) => !e.isArchived);
}
/** The currently-running workout (endedAt = null), if any. */
export function getActiveWorkout(workouts: BodyWorkout[]): BodyWorkout | null {
return workouts.find((w) => w.endedAt === null) ?? null;
}
/** Phase that is currently in progress, if any. */
export function getActivePhase(phases: BodyPhase[]): BodyPhase | null {
return phases.find((p) => p.endDate === null) ?? null;
}

View file

@ -0,0 +1,402 @@
/**
* Body Store mutation-only service for the combined fitness/bodylog module.
*
* All reads happen via liveQuery hooks in queries.ts. This file only writes:
* exercise CRUD, routine CRUD, workout/set logging, daily checks, weight +
* measurement entries, and cut/bulk/maintenance phase tracking.
*
* Encryption: every user-typed text field (notes, names, descriptions) is
* passed through encryptRecord() before hitting Dexie. The crypto registry
* in $lib/data/crypto/registry.ts is the source of truth for which fields
* actually get wrapped this store just calls encryptRecord and trusts the
* registry to do the right thing per table.
*/
import { encryptRecord } from '$lib/data/crypto';
import {
bodyExerciseTable,
bodyRoutineTable,
bodyWorkoutTable,
bodySetTable,
bodyMeasurementTable,
bodyCheckTable,
bodyPhaseTable,
} from '../collections';
import {
toBodyExercise,
toBodyRoutine,
toBodyWorkout,
toBodySet,
toBodyMeasurement,
toBodyCheck,
toBodyPhase,
} from '../queries';
import type {
LocalBodyExercise,
LocalBodyRoutine,
LocalBodyWorkout,
LocalBodySet,
LocalBodyMeasurement,
LocalBodyCheck,
LocalBodyPhase,
MuscleGroup,
Equipment,
MeasurementType,
WeightUnit,
LengthUnit,
PhaseKind,
} from '../types';
export const bodyStore = {
// ─── Exercises ──────────────────────────────────────────
async createExercise(input: {
name: string;
muscleGroup: MuscleGroup;
equipment: Equipment;
notes?: string | null;
}) {
const newLocal: LocalBodyExercise = {
id: crypto.randomUUID(),
name: input.name,
muscleGroup: input.muscleGroup,
equipment: input.equipment,
notes: input.notes ?? null,
isArchived: false,
isPreset: false,
};
const snapshot = toBodyExercise({ ...newLocal });
await encryptRecord('bodyExercises', newLocal);
await bodyExerciseTable.add(newLocal);
return snapshot;
},
async updateExercise(
id: string,
patch: Partial<
Pick<LocalBodyExercise, 'name' | 'muscleGroup' | 'equipment' | 'notes' | 'isArchived'>
>
) {
const wrapped = await encryptRecord('bodyExercises', { ...patch });
await bodyExerciseTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async deleteExercise(id: string) {
// Presets are kept around as a recovery option — soft-delete only
// when the user explicitly nukes a custom exercise. The UI should
// already gate the delete button on isPreset === false; this is
// the belt-and-suspenders check.
const exercise = await bodyExerciseTable.get(id);
if (!exercise || exercise.isPreset) return;
await bodyExerciseTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
// ─── Routines ───────────────────────────────────────────
async createRoutine(input: { name: string; description?: string | null; exerciseIds: string[] }) {
const existing = await bodyRoutineTable.toArray();
const order = existing.filter((r) => !r.deletedAt).length;
const newLocal: LocalBodyRoutine = {
id: crypto.randomUUID(),
name: input.name,
description: input.description ?? null,
exerciseIds: input.exerciseIds,
order,
isArchived: false,
};
const snapshot = toBodyRoutine({ ...newLocal });
await encryptRecord('bodyRoutines', newLocal);
await bodyRoutineTable.add(newLocal);
return snapshot;
},
async updateRoutine(
id: string,
patch: Partial<
Pick<LocalBodyRoutine, 'name' | 'description' | 'exerciseIds' | 'order' | 'isArchived'>
>
) {
const wrapped = await encryptRecord('bodyRoutines', { ...patch });
await bodyRoutineTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async deleteRoutine(id: string) {
await bodyRoutineTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
// ─── Workouts ───────────────────────────────────────────
/**
* Start a new training session. Leaves endedAt null so the rest of the
* UI can find "the active workout" with a single .where('endedAt').
* If a workout is already running we return it instead of starting a
* second one the user almost certainly forgot to finish the last one
* and silently double-tracking would corrupt their volume math.
*/
async startWorkout(input: { routineId?: string | null; title?: string | null }) {
const existing = await bodyWorkoutTable.toArray();
const active = existing.find((w) => !w.deletedAt && w.endedAt === null);
if (active) return toBodyWorkout(active);
const newLocal: LocalBodyWorkout = {
id: crypto.randomUUID(),
startedAt: new Date().toISOString(),
endedAt: null,
routineId: input.routineId ?? null,
title: input.title ?? null,
notes: null,
rpe: null,
};
const snapshot = toBodyWorkout({ ...newLocal });
await encryptRecord('bodyWorkouts', newLocal);
await bodyWorkoutTable.add(newLocal);
return snapshot;
},
async finishWorkout(id: string, patch?: { notes?: string | null; rpe?: number | null }) {
const update: Partial<LocalBodyWorkout> = {
endedAt: new Date().toISOString(),
notes: patch?.notes ?? null,
rpe: patch?.rpe ?? null,
};
const wrapped = await encryptRecord('bodyWorkouts', { ...update });
await bodyWorkoutTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async updateWorkout(
id: string,
patch: Partial<Pick<LocalBodyWorkout, 'title' | 'notes' | 'rpe' | 'startedAt' | 'endedAt'>>
) {
const wrapped = await encryptRecord('bodyWorkouts', { ...patch });
await bodyWorkoutTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async deleteWorkout(id: string) {
// Soft-delete the workout AND its sets so the volume aggregates
// stop counting them. We do not touch measurements/checks here;
// those are independent timelines.
const now = new Date().toISOString();
await bodyWorkoutTable.update(id, { deletedAt: now, updatedAt: now });
const sets = await bodySetTable.where('workoutId').equals(id).toArray();
for (const s of sets) {
await bodySetTable.update(s.id, { deletedAt: now });
}
},
// ─── Sets ───────────────────────────────────────────────
async logSet(input: {
workoutId: string;
exerciseId: string;
reps: number;
weight: number;
weightUnit: WeightUnit;
rpe?: number | null;
isWarmup?: boolean;
notes?: string | null;
}) {
const existing = await bodySetTable.where('workoutId').equals(input.workoutId).toArray();
const order = existing.filter((s) => !s.deletedAt).length;
const newLocal: LocalBodySet = {
id: crypto.randomUUID(),
workoutId: input.workoutId,
exerciseId: input.exerciseId,
order,
reps: input.reps,
weight: input.weight,
weightUnit: input.weightUnit,
rpe: input.rpe ?? null,
isWarmup: input.isWarmup ?? false,
notes: input.notes ?? null,
};
const snapshot = toBodySet({ ...newLocal });
await encryptRecord('bodySets', newLocal);
await bodySetTable.add(newLocal);
return snapshot;
},
async updateSet(
id: string,
patch: Partial<
Pick<LocalBodySet, 'reps' | 'weight' | 'weightUnit' | 'rpe' | 'isWarmup' | 'notes'>
>
) {
const wrapped = await encryptRecord('bodySets', { ...patch });
await bodySetTable.update(id, wrapped);
},
async deleteSet(id: string) {
await bodySetTable.update(id, { deletedAt: new Date().toISOString() });
},
// ─── Measurements (weight + body comp) ─────────────────
async logMeasurement(input: {
date?: string;
type: MeasurementType;
value: number;
unit: WeightUnit | LengthUnit | 'percent';
notes?: string | null;
}) {
const newLocal: LocalBodyMeasurement = {
id: crypto.randomUUID(),
date: input.date ?? new Date().toISOString().split('T')[0],
type: input.type,
value: input.value,
unit: input.unit,
notes: input.notes ?? null,
};
const snapshot = toBodyMeasurement({ ...newLocal });
await encryptRecord('bodyMeasurements', newLocal);
await bodyMeasurementTable.add(newLocal);
return snapshot;
},
async updateMeasurement(
id: string,
patch: Partial<Pick<LocalBodyMeasurement, 'value' | 'unit' | 'notes' | 'date'>>
) {
const wrapped = await encryptRecord('bodyMeasurements', { ...patch });
await bodyMeasurementTable.update(id, wrapped);
},
async deleteMeasurement(id: string) {
await bodyMeasurementTable.update(id, { deletedAt: new Date().toISOString() });
},
// ─── Daily Checks ──────────────────────────────────────
/**
* Upsert the check-in row for `date`. We treat (userId, date) as the
* logical key the Dexie schema only indexes by id, so we look up
* locally and update in place if a row already exists for the day.
* This keeps the UI's "rate today's energy" toggle idempotent.
*/
async upsertCheck(input: {
date?: string;
energy?: number | null;
sleep?: number | null;
soreness?: number | null;
mood?: number | null;
notes?: string | null;
}) {
const date = input.date ?? new Date().toISOString().split('T')[0];
const existing = (await bodyCheckTable.toArray()).find((c) => !c.deletedAt && c.date === date);
if (existing) {
const patch: Partial<LocalBodyCheck> = {
energy: input.energy ?? existing.energy,
sleep: input.sleep ?? existing.sleep,
soreness: input.soreness ?? existing.soreness,
mood: input.mood ?? existing.mood,
notes: input.notes ?? existing.notes,
};
const wrapped = await encryptRecord('bodyChecks', { ...patch });
await bodyCheckTable.update(existing.id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
return toBodyCheck({ ...existing, ...patch });
}
const newLocal: LocalBodyCheck = {
id: crypto.randomUUID(),
date,
energy: input.energy ?? null,
sleep: input.sleep ?? null,
soreness: input.soreness ?? null,
mood: input.mood ?? null,
notes: input.notes ?? null,
};
const snapshot = toBodyCheck({ ...newLocal });
await encryptRecord('bodyChecks', newLocal);
await bodyCheckTable.add(newLocal);
return snapshot;
},
async deleteCheck(id: string) {
await bodyCheckTable.update(id, { deletedAt: new Date().toISOString() });
},
// ─── Phases (cut / bulk / maintenance) ─────────────────
async startPhase(input: {
kind: PhaseKind;
startWeight?: number | null;
targetWeight?: number | null;
notes?: string | null;
}) {
// Close any existing open phase before opening a new one — the
// "active phase" view assumes at most one row with endDate = null,
// and a stale open row would otherwise haunt every recommendation.
const existing = await bodyPhaseTable.toArray();
const openPhase = existing.find((p) => !p.deletedAt && p.endDate === null);
if (openPhase) {
await this.endPhase(openPhase.id);
}
const newLocal: LocalBodyPhase = {
id: crypto.randomUUID(),
kind: input.kind,
startDate: new Date().toISOString().split('T')[0],
endDate: null,
startWeight: input.startWeight ?? null,
targetWeight: input.targetWeight ?? null,
notes: input.notes ?? null,
};
const snapshot = toBodyPhase({ ...newLocal });
await encryptRecord('bodyPhases', newLocal);
await bodyPhaseTable.add(newLocal);
return snapshot;
},
async endPhase(id: string) {
await bodyPhaseTable.update(id, {
endDate: new Date().toISOString().split('T')[0],
updatedAt: new Date().toISOString(),
});
},
async updatePhase(
id: string,
patch: Partial<
Pick<
LocalBodyPhase,
'kind' | 'startDate' | 'endDate' | 'startWeight' | 'targetWeight' | 'notes'
>
>
) {
const wrapped = await encryptRecord('bodyPhases', { ...patch });
await bodyPhaseTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async deletePhase(id: string) {
await bodyPhaseTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
};

View file

@ -0,0 +1,265 @@
/**
* Body module types combined fitness training + body composition tracking.
*
* The module merges what would otherwise be two separate apps ("fitness" and
* "bodylog") because the value lives in their intersection: tracking lifts
* alongside bodyweight is what enables real progressive-overload + recomp
* insights. See docs/future/MODULE_IDEAS.md for the rationale.
*
* Tables:
* bodyExercises exercise library (Squat, Bench, Curl )
* bodyRoutines saved workout templates (PPL day, Upper, )
* bodyWorkouts one logged training session
* bodySets individual set rows inside a workout
* bodyMeasurements weight + body measurements over time
* bodyChecks daily mini check-in (energy / sleep / soreness)
* bodyPhases cut / bulk / maintenance phase markers
*/
import type { BaseRecord } from '@mana/local-store';
// ─── Enums / unions ─────────────────────────────────────────
export type MuscleGroup =
| 'chest'
| 'back'
| 'shoulders'
| 'biceps'
| 'triceps'
| 'forearms'
| 'core'
| 'quads'
| 'hamstrings'
| 'glutes'
| 'calves'
| 'cardio'
| 'fullbody';
export type Equipment =
| 'barbell'
| 'dumbbell'
| 'machine'
| 'cable'
| 'bodyweight'
| 'kettlebell'
| 'band'
| 'other';
export type MeasurementType =
| 'weight'
| 'bodyfat'
| 'muscle'
| 'chest'
| 'waist'
| 'hips'
| 'thigh'
| 'arm'
| 'calf'
| 'neck';
export type WeightUnit = 'kg' | 'lbs';
export type LengthUnit = 'cm' | 'in';
export type PhaseKind = 'cut' | 'bulk' | 'maintenance' | 'recomp';
// ─── Local Record Types (Dexie) ─────────────────────────────
export interface LocalBodyExercise extends BaseRecord {
name: string;
muscleGroup: MuscleGroup;
equipment: Equipment;
notes: string | null;
isArchived: boolean;
/** Built-in preset (vs. user-created). Presets are not deleteable. */
isPreset: boolean;
}
export interface LocalBodyRoutine extends BaseRecord {
name: string;
description: string | null;
/** Ordered list of exerciseIds in this routine. */
exerciseIds: string[];
order: number;
isArchived: boolean;
}
export interface LocalBodyWorkout extends BaseRecord {
/** ISO date+time the session started. */
startedAt: string;
/** ISO date+time the session ended (null = still ongoing). */
endedAt: string | null;
routineId: string | null;
title: string | null;
notes: string | null;
/** 110 perceived effort for the whole session. */
rpe: number | null;
}
export interface LocalBodySet extends BaseRecord {
workoutId: string;
exerciseId: string;
/** Sort order within the workout. */
order: number;
reps: number;
weight: number;
weightUnit: WeightUnit;
/** 110 reps in reserve / RPE for this single set. */
rpe: number | null;
isWarmup: boolean;
notes: string | null;
}
export interface LocalBodyMeasurement extends BaseRecord {
/** YYYY-MM-DD */
date: string;
type: MeasurementType;
value: number;
unit: WeightUnit | LengthUnit | 'percent';
notes: string | null;
}
export interface LocalBodyCheck extends BaseRecord {
/** YYYY-MM-DD — one row per day. */
date: string;
/** 15 scale for each. */
energy: number | null;
sleep: number | null;
soreness: number | null;
mood: number | null;
notes: string | null;
}
export interface LocalBodyPhase extends BaseRecord {
kind: PhaseKind;
startDate: string;
endDate: string | null;
startWeight: number | null;
targetWeight: number | null;
notes: string | null;
}
// ─── Domain Types (UI-facing) ───────────────────────────────
export interface BodyExercise {
id: string;
name: string;
muscleGroup: MuscleGroup;
equipment: Equipment;
notes: string | null;
isArchived: boolean;
isPreset: boolean;
createdAt: string;
updatedAt: string;
}
export interface BodyRoutine {
id: string;
name: string;
description: string | null;
exerciseIds: string[];
order: number;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
export interface BodyWorkout {
id: string;
startedAt: string;
endedAt: string | null;
routineId: string | null;
title: string | null;
notes: string | null;
rpe: number | null;
createdAt: string;
updatedAt: string;
}
export interface BodySet {
id: string;
workoutId: string;
exerciseId: string;
order: number;
reps: number;
weight: number;
weightUnit: WeightUnit;
rpe: number | null;
isWarmup: boolean;
notes: string | null;
createdAt: string;
}
export interface BodyMeasurement {
id: string;
date: string;
type: MeasurementType;
value: number;
unit: WeightUnit | LengthUnit | 'percent';
notes: string | null;
createdAt: string;
}
export interface BodyCheck {
id: string;
date: string;
energy: number | null;
sleep: number | null;
soreness: number | null;
mood: number | null;
notes: string | null;
createdAt: string;
}
export interface BodyPhase {
id: string;
kind: PhaseKind;
startDate: string;
endDate: string | null;
startWeight: number | null;
targetWeight: number | null;
notes: string | null;
createdAt: string;
updatedAt: string;
}
// ─── Constants ──────────────────────────────────────────────
export const MUSCLE_GROUPS: readonly MuscleGroup[] = [
'chest',
'back',
'shoulders',
'biceps',
'triceps',
'forearms',
'core',
'quads',
'hamstrings',
'glutes',
'calves',
'cardio',
'fullbody',
] as const;
export const EQUIPMENT_TYPES: readonly Equipment[] = [
'barbell',
'dumbbell',
'machine',
'cable',
'bodyweight',
'kettlebell',
'band',
'other',
] as const;
export const MEASUREMENT_TYPES: readonly MeasurementType[] = [
'weight',
'bodyfat',
'muscle',
'chest',
'waist',
'hips',
'thigh',
'arm',
'calf',
'neck',
] as const;

120
docs/future/MODULE_IDEAS.md Normal file
View file

@ -0,0 +1,120 @@
# Mana — Module Ideas
Brainstorm of potential new product modules for the unified Mana app
(`apps/mana/apps/web/src/lib/modules/`). Captured 2026-04-09.
The app currently ships **37 modules**. Each idea below is a candidate for a
new `modules/{name}/` folder following the standard module pattern
(`module.config.ts`, `collections.ts`, `queries.ts`, `stores/*.svelte.ts`,
plus a route under `(app)/{name}/`).
Most user-typed content should default to **encrypted** (see
`apps/mana/apps/web/src/lib/data/crypto/registry.ts`). Modules marked **(ZK)**
are sensitive enough that zero-knowledge mode should be the default
recommendation.
---
## Current modules (for reference)
**Productivity:** todo, calendar, contacts, notes, habits, times, timeblocks, events
**Knowledge & learning:** cards, zitare, guides, questions, skilltree, memoro, context
**Health & self:** nutriphi, cycles, dreams, moodlit, planta
**Media & creative:** chat, picture, presi, music, photos, storage, uload
**Data & tools:** finance, calc, inventory, places, citycorners, who, news, links, tags, playground
---
## Health & Body
- **body** — ✅ **Built.** Combined fitness training + body composition tracking. Workouts/sets with progressive overload, weight + measurements + body fat, daily energy/sleep/soreness checks, cut/bulk/maintenance phases. Lives at `apps/mana/apps/web/src/lib/modules/body/`. The fitness/bodylog merger was the right call — the value lives in their intersection (volume vs. bodyweight, lifts inside a cut).
- **sleep** — Sleep phases, link to `dreams`, bedtime habit integration
- **meds** — Medications/supplements, reminders, interactions, history log
- **therapy** *(ZK)* — Session notes, mood timelines, homework
## Mind & Reflection
- **journal** — Daily freeform entries with mood, tags, "on this day" recap
- **gratitude** — 3-things-a-day micro-module with streaks
- **values** — Personal values/principles, monthly check-in, ties to `habits`
- **decisions** — Decision journal: assumptions, expected outcome, later review
- **letters** — Letters to your future self (time-locked unlock)
## Knowledge & Creativity
- **bookmarks** — Read-later with auto-extract via `mana-crawler`, tags, highlights
- **highlights** — Book/article quotes + spaced-repetition resurfacing (Readwise-style)
- **library** — Books/films/games/podcasts: wishlist → in progress → done, ratings
- **ideas** — Idea inbox with "cooking" status, links to `notes` and `cards`
- **wiki** — Personal Zettelkasten/wiki with backlinks (Obsidian-style), built on `notes`
- **research** — Topic-based folders: sources, notes, syntheses, AI summaries
- **prompts** — LLM prompt library with variables, versions, captured outputs
## Lifestyle & Hobbies
- **recipes** — Recipes (linked to `nutriphi`), meal plan, shopping list generator
- **wardrobe** — Catalog clothing, build outfits, "last worn", wash status
- **travel** — Trips, itineraries, packing lists, travelogue (combines `places` + `photos`)
- **packing** — Reusable packing list templates per trip type
- **garage** — Car/bike: maintenance, fuel stops, repairs, inspection reminders
- **collections** — Generic collector (vinyl, sneakers, LEGO, coins) with custom fields
## Social & Relationships
- **birthdays** — Standalone from `contacts`: reminders, gift ideas, past gifts
- **gifts** — Gift ideas per person, budget, status (idea → bought → given)
- **interactions** — CRM-light: last contact, "ping it" reminders for relationship upkeep
- **family** — Family tree, shared memories, family lore
## Money & Stuff
- **subscriptions** — Track subscriptions, renewal alerts, annual cost overview, cancel links
- **budgets** — Budget buckets layered on `finance`, savings goals
- **invoices** — Issue invoices (freelancer), status, dunning
- **warranties** — Receipts/warranties, expiry alerts (links to `inventory`)
- **lending** — What you've lent / borrowed (books, tools, money)
## Home & Living
- **home** — Household tasks, maintenance plan (filter changes, chimney sweep), contracts
- **chores** — Recurring household tasks with rotation across roommates
- **shopping** — Universal shopping lists, stores, price comparisons
- **pantry** — Pantry stock with expiry, generates shopping list, links to `recipes`
## Work & Goals
- **goals** — OKRs/quarterly goals, key results, weekly check-in
- **projects** — Generic project module (beyond `todo`): phases, stakeholders, risks
- **standup** — Daily done/doing/blockers log (works for solos too)
- **meetings** — Meeting notes with attendees from `contacts`, action items → `todo`
- **timesheet** — Time tracking per project (extension of `times`), invoice export
- **interviews** — Interview tracker (as candidate or recruiter)
## Playful & Creative
- **streaks** — Pure streak visualizer across all modules (habits, journal, etc.)
- **bucket** — Bucket list with status, completion photos
- **quests** — Gamified self-challenges, RPG-style XP feeding `skilltree`
- **moodboard** — Visual inspiration boards (combines `picture`/`bookmarks`)
- **sketchbook** — Quick browser-canvas doodles, dated
- **soundbites** — Short audio memos, transcribed via `mana-stt`
- **timecapsule** — Save content today, unlock in X years
## People in your life
- **kids** *(ZK)* — Milestones, illnesses, growth, photos
- **pets** — Vet appointments, vaccinations, feeding, weight
- **plants-care** — Extension of `planta`: watering plan, fertilizing, repotting
---
## Next steps
When picking one to build, the standard scaffolding is:
1. `apps/mana/apps/web/src/lib/modules/{name}/module.config.ts` — declare `appId` + tables
2. Add to `apps/mana/apps/web/src/lib/data/module-registry.ts`
3. Add Dexie schema bump in `apps/mana/apps/web/src/lib/data/database.ts`
4. If sensitive: register in `apps/mana/apps/web/src/lib/data/crypto/registry.ts`
5. Route under `apps/mana/apps/web/src/routes/(app)/{name}/`
6. Register in `packages/shared-branding/src/mana-apps.ts` (icon, tier, branding)

View file

@ -147,6 +147,12 @@ export const APP_ICONS = {
events: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ev" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f43f5e"/><stop offset="100%" style="stop-color:#be123c"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ev)"/><path d="M22 78l14-44 30 30-44 14z" fill="white"/><path d="M36 34c4-6 12-8 18-4M50 22c4-2 10 0 12 6M62 28c6-2 12 2 12 10" stroke="white" stroke-width="3" stroke-linecap="round" fill="none"/><circle cx="74" cy="46" r="2.5" fill="white"/><circle cx="80" cy="58" r="2" fill="white" fill-opacity="0.8"/><circle cx="68" cy="62" r="2" fill="white" fill-opacity="0.7"/></svg>`
),
body: svgToDataUrl(
// Dumbbell + heart-pulse hybrid: training (barbell) + body (pulse line).
// Red→orange gradient to set it apart from the green health-adjacent
// modules (planta, nutriphi) and the pink cycles icon.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="bd" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#ef4444"/><stop offset="100%" style="stop-color:#f97316"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#bd)"/><rect x="18" y="42" width="6" height="16" rx="2" fill="white"/><rect x="76" y="42" width="6" height="16" rx="2" fill="white"/><rect x="24" y="46" width="4" height="8" rx="1" fill="white" fill-opacity="0.85"/><rect x="72" y="46" width="4" height="8" rx="1" fill="white" fill-opacity="0.85"/><rect x="28" y="48" width="44" height="4" rx="2" fill="white"/><path d="M30 70h12l4-8 6 16 4-10 6 6h12" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`
),
who: svgToDataUrl(
// Theatre mask silhouette in front of a question mark — references
// the "guess who's behind the disguise" mechanic. Purple gradient.

View file

@ -564,6 +564,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'beta',
requiredTier: 'guest',
},
{
id: 'body',
name: 'Body',
description: {
de: 'Training & Körper-Tracking',
en: 'Training & Body Tracking',
},
longDescription: {
de: 'Logge Workouts, Sätze und progressive Steigerung neben Gewicht, Maßen und täglichen Energie-Checks. Eine App für alles, was deinen Körper bewegt und verändert.',
en: 'Log workouts, sets, and progressive overload alongside weight, measurements, and daily energy check-ins. One app for everything that moves and changes your body.',
},
icon: APP_ICONS.body,
color: '#ef4444',
comingSoon: false,
status: 'development',
requiredTier: 'guest',
},
{
id: 'habits',
name: 'Habits',