mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 01:29:40 +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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue