From a412ccc6fb17eba6c151b3a656ff1a1b91ae4a5b Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 9 Apr 2026 16:28:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(mana/web/body):=20new=20module=20=E2=80=94?= =?UTF-8?q?=20combined=20fitness=20training=20+=20body=20comp=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../apps/web/src/lib/data/crypto/registry.ts | 40 ++ .../apps/web/src/lib/data/module-registry.ts | 2 + .../web/src/lib/modules/body/collections.ts | 101 +++++ .../apps/web/src/lib/modules/body/index.ts | 70 +++ .../web/src/lib/modules/body/module.config.ts | 14 + .../apps/web/src/lib/modules/body/queries.ts | 260 +++++++++++ .../lib/modules/body/stores/body.svelte.ts | 402 ++++++++++++++++++ .../apps/web/src/lib/modules/body/types.ts | 265 ++++++++++++ docs/future/MODULE_IDEAS.md | 120 ++++++ packages/shared-branding/src/app-icons.ts | 6 + packages/shared-branding/src/mana-apps.ts | 17 + 11 files changed, 1297 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/modules/body/collections.ts create mode 100644 apps/mana/apps/web/src/lib/modules/body/index.ts create mode 100644 apps/mana/apps/web/src/lib/modules/body/module.config.ts create mode 100644 apps/mana/apps/web/src/lib/modules/body/queries.ts create mode 100644 apps/mana/apps/web/src/lib/modules/body/stores/body.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/body/types.ts create mode 100644 docs/future/MODULE_IDEAS.md diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 9132e47a9..a3bcce3fa 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -331,6 +331,46 @@ export const ENCRYPTION_REGISTRY: Record = { }, 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 diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index f37b73f73..3b91a0169 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -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 ────────────────────────────────────────── diff --git a/apps/mana/apps/web/src/lib/modules/body/collections.ts b/apps/mana/apps/web/src/lib/modules/body/collections.ts new file mode 100644 index 000000000..89133d32c --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/body/collections.ts @@ -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('bodyExercises'); +export const bodyRoutineTable = db.table('bodyRoutines'); +export const bodyWorkoutTable = db.table('bodyWorkouts'); +export const bodySetTable = db.table('bodySets'); +export const bodyMeasurementTable = db.table('bodyMeasurements'); +export const bodyCheckTable = db.table('bodyChecks'); +export const bodyPhaseTable = db.table('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[], +}; diff --git a/apps/mana/apps/web/src/lib/modules/body/index.ts b/apps/mana/apps/web/src/lib/modules/body/index.ts new file mode 100644 index 000000000..babd18f48 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/body/index.ts @@ -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'; diff --git a/apps/mana/apps/web/src/lib/modules/body/module.config.ts b/apps/mana/apps/web/src/lib/modules/body/module.config.ts new file mode 100644 index 000000000..c15e09483 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/body/module.config.ts @@ -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' }, + ], +}; diff --git a/apps/mana/apps/web/src/lib/modules/body/queries.ts b/apps/mana/apps/web/src/lib/modules/body/queries.ts new file mode 100644 index 000000000..ab81a75cf --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/body/queries.ts @@ -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('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('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('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('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('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('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('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('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 { + const best = new Map(); + 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; +} diff --git a/apps/mana/apps/web/src/lib/modules/body/stores/body.svelte.ts b/apps/mana/apps/web/src/lib/modules/body/stores/body.svelte.ts new file mode 100644 index 000000000..f427040a8 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/body/stores/body.svelte.ts @@ -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 + > + ) { + 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 + > + ) { + 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 = { + 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> + ) { + 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 + > + ) { + 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> + ) { + 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 = { + 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(), + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/body/types.ts b/apps/mana/apps/web/src/lib/modules/body/types.ts new file mode 100644 index 000000000..ee58b3326 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/body/types.ts @@ -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; + /** 1–10 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; + /** 1–10 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; + /** 1–5 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; diff --git a/docs/future/MODULE_IDEAS.md b/docs/future/MODULE_IDEAS.md new file mode 100644 index 000000000..5496d990b --- /dev/null +++ b/docs/future/MODULE_IDEAS.md @@ -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) diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index 3743d24e7..91daf641a 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -147,6 +147,12 @@ export const APP_ICONS = { events: svgToDataUrl( `` ), + 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. + `` + ), who: svgToDataUrl( // Theatre mask silhouette in front of a question mark — references // the "guess who's behind the disguise" mechanic. Purple gradient. diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index 41ec22d24..ad0b3f14c 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -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',