mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
52159ee07a
commit
a412ccc6fb
11 changed files with 1297 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────
|
||||
|
|
|
|||
101
apps/mana/apps/web/src/lib/modules/body/collections.ts
Normal file
101
apps/mana/apps/web/src/lib/modules/body/collections.ts
Normal 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[],
|
||||
};
|
||||
70
apps/mana/apps/web/src/lib/modules/body/index.ts
Normal file
70
apps/mana/apps/web/src/lib/modules/body/index.ts
Normal 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';
|
||||
14
apps/mana/apps/web/src/lib/modules/body/module.config.ts
Normal file
14
apps/mana/apps/web/src/lib/modules/body/module.config.ts
Normal 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' },
|
||||
],
|
||||
};
|
||||
260
apps/mana/apps/web/src/lib/modules/body/queries.ts
Normal file
260
apps/mana/apps/web/src/lib/modules/body/queries.ts
Normal 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;
|
||||
}
|
||||
402
apps/mana/apps/web/src/lib/modules/body/stores/body.svelte.ts
Normal file
402
apps/mana/apps/web/src/lib/modules/body/stores/body.svelte.ts
Normal 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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
265
apps/mana/apps/web/src/lib/modules/body/types.ts
Normal file
265
apps/mana/apps/web/src/lib/modules/body/types.ts
Normal 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;
|
||||
/** 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;
|
||||
120
docs/future/MODULE_IDEAS.md
Normal file
120
docs/future/MODULE_IDEAS.md
Normal 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)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue