Specfork

@specfork/fitness-log

Personal fitness and health log with exercises, routines with progression, set logging, records, body measurements, food logging with macros, charts and private data export.

personalhealth Version 0.1.1 99 requirements Licence CC-BY-4.0 Specfork/fitness-log

Fitness log

One person keeps their training and health data in one private place: an exercise catalogue, routines that plan sets, reps and weight week by week, a log of every set actually done, personal records, body weight and other measurements, nutrition plans and a food diary with calories and macros, and charts over all of it. Metric or imperial units, the user's own time zone. Nothing is shared, and everything can be exported, imported or deleted at once.

Behaviour derived from wger's open-source code (AGPL-3.0-or-later), written fresh and stack-free. wger's exercise and food data (Creative Commons and ODbL licences, set per entry) were not used; catalogue and food data come through the generic source contracts in EXC and FOOD. Not affiliated with or endorsed by the wger project. The name is used only to credit the source.

Area Prefix Covers
profile.md PRF Units, body profile, energy estimate formulas
exercises.md EXC Exercise catalogue, catalogue source, custom exercises, search
routines.md RTN Routines, days, slots, set types, calendar sequence, templates
progression.md PROG Per-iteration rules for sets, weight, reps, RiR, rest; gating; rounding
sessions.md LOG Workout sessions and set logs
records.md STAT 1RM estimate, personal records, volume, intensity, charts
body.md BODY Body weight, measurements, calculated metrics, import, progress photos
foods.md FOOD Foods, nutrients, portions, validation, food source contract
nutrition.md NUT Nutrition plans, meals, food diary, totals and goals
privacy.md PRIV Single owner, owner-only access, inventory, export contents, import, delete-all, no advice

Accounts and sign-in come from @specfork/auth, in single_owner mode by default (PRIV-1); export, import and delete-all from @specfork/privacy. All times are stored in UTC; calendar days follow the user's time zone (PRF-2); API names are snake_case.

Body

Status: Draft · Prefix: BODY

Purpose

Body weight and other body measurements over time, values calculated from them, weight import, and private progress photos.

Requirements

ID Requirement Verified by
BODY-1 Measurement category: {id, name (1–100), unit (≤ 30), metric_type, chart_type?: line|bar|heatmap|delta|distribution, order, calculated: none|bmi|waist_to_height|one_rep_max|one_rep_max_total (default none), calculated_params}. At most one category per user per metric_type other than custom. second body_fat category → refused duplicate_metric_type
BODY-2 metric_type: custom|body_weight|body_fat|lean_body_mass|height|steps|distance|energy; anything else → invalid_metric_type. heart_rate → refused
BODY-3 Entry: {id, category, measured_at (default now), value (≥ 0, 2 decimals, ≤ 999999.99), unit?, notes (≤ 100), source: manual|import|calculated}. unit is the unit the value was entered in, default the category unit. Only body weight converts between kg and lb on read (PRF-3); any other unit mismatch → unit_mismatch. weight 80 kg read as lb → 176.37
BODY-4 Hard limits (value in stored unit, outside → implausible_value): body_weight 20–350 kg / 44–770 lb; body_fat 2–60 %; lean_body_mass 10–250 kg; height 50–250 cm; steps 0–100000; distance 0–500 km; energy 0–10000 kcal; custom: BODY-3 range. body weight 900 kg → refused
BODY-5 Body weight: each user has one official body_weight category (unit = profile unit at creation), created on first need; it cannot be deleted while it has entries. "Latest body weight" = its entry with the newest measured_at. first weight entry → category auto-created
BODY-6 Calculated categories are read-only (manual entries → read_only), hold source: calculated entries only, and cannot be the input of another calculated category. They are recomputed whenever an input entry, the profile height or their params change. add entry to BMI category → refused
BODY-7 bmi: one entry per body weight entry, at the same time, value = weight_kg / (height_cm / 100)², 2 decimals; none while profile height is empty. Shown as a number labelled "estimate", with no category (PRIV-9). 80 kg, 180 cm → 24.69, labelled estimate
BODY-8 waist_to_height (param category_id of a length category: unit mm|cm|m|in): one entry per source entry, value = length_cm / height_cm, 2 decimals; unknown unit entries are skipped. waist 34 in, 180 cm → 0.48
BODY-9 one_rep_max (params exercise_id, max_reps 1–10 default 5) and one_rep_max_total (params exercise_ids 2–5, max_reps, window_days 7–120 default 30) hold the series of STAT-4 and STAT-5 as entries in kg. log new squat set → 1RM category gains a point
BODY-10 Weight import from CSV text: ≤ 1000 rows of date, value, a user-chosen date format, decimal comma accepted, value in the profile unit. Rows that fail to parse, break BODY-4, repeat a date in the file or match an existing entry's measured_at are skipped; the response lists created count and skipped rows with reasons. 3 rows, 1 duplicate → 2 created, 1 skipped
BODY-11 Progress photo: {id, taken_on (date), image (JPEG/PNG/WebP, ≤ 10 MB), width, height, description (≤ 1000)}. Location and device metadata are stripped before storage; photos are served only to their owner (PRIV-2). upload photo with GPS EXIF → stored file has none
BODY-12 Deleting a category deletes its entries; deleting an input category empties calculated categories that read it. delete waist category → WHtR entries gone

Out of scope

  • Import from phone health platforms or wearables (the import source is reserved for them).
  • Blood pressure, heart rate, resting heart rate, blood oxygen and sleep (stages and totals): not in 0.1.
  • Soft "normal range" bands, warnings or colour coding of health values.

Decisions

  • Entries keep the unit they were typed in; conversion on read avoids rounding drift, as in the source.
  • No range bands: the source used soft limits for warnings; here they would read as medical judgement (PRIV-9).
  • Photo metadata stripping and a size limit are added; the source stored uploads as given.

Exercises

Status: Draft · Prefix: EXC

Purpose

The exercises a user can plan and log: a read-only catalogue filled from an exercise source, plus the user's own private exercises.

Requirements

ID Requirement Verified by
EXC-1 Exercise: {id, uuid, origin: catalogue|custom, category, primary_muscles[], secondary_muscles[], equipment[], variation_group?, translations[], images[], license, updated_at, archived (default false)}. Translation: {language (ISO 639-1), name (2–200 chars), description (≤ 2000 chars, plain text), aliases[] (each ≤ 200)}; one translation per language. Variations: exercises sharing variation_group are listed on each other's detail. two en translations → second refused duplicate_language; two exercises in one group → each lists the other
EXC-2 Reference lists: category {id, name}, muscle {id, name, is_front}, equipment {id, name}. They arrive with the catalogue and are read-only; custom exercises pick from them. create custom exercise with unknown muscle id → refused
EXC-3 Display name: translation in the user's locale (@specfork/auth:AUTH-1), else en, else the first translation by language code. locale de, only en and fr → en name
EXC-4 Licence and attribution per catalogue exercise, image and translation: {license_id (SPDX), author?, author_url?, source_url?, derived_from_url?}; shown on the exercise detail. Custom exercises carry none. catalogue exercise detail → licence and author shown
EXC-5 Exercise source contract: list_changes(since?, cursor?) → pages of {exercises[], reference_lists, deletions: [{uuid, replaced_by_uuid?}], next_cursor?}. A sync upserts by uuid, stores the newest updated_at seen and passes it as since next time. The source is configured by the instance operator; with none configured the catalogue is empty and the app still works. second sync with no upstream change → 0 writes
EXC-6 Sync is pull-only: it sends only since and cursor, never user data, and runs on demand or at most daily. A failed page leaves earlier pages applied and since unchanged. network error on page 3 → pages 1–2 kept, retry resumes
EXC-7 A source deletion with replaced_by_uuid re-points the user's slot entries (RTN-4) and set logs (LOG-1) to the replacement, then removes the exercise. Without a replacement, an exercise that any routine or log uses is set archived instead of removed; an unused one is removed. Logs are never deleted by a sync. Archived exercises stay readable and loggable from existing routines but are hidden from search and from choosing in new slot entries. upstream deletes used exercise → logs keep it, archived; search for archived name → no hit
EXC-8 Custom exercise: origin: custom, owned by one user, visible only to them (PRIV-2); same fields as EXC-1 except licence; at least one translation in the user's locale. Edit and delete are owner-only; deleting one that routines or logs use archives it instead. delete used custom exercise → archived, logs intact
EXC-9 Search: q (≥ 2 chars) matches name or alias words by case-insensitive prefix in the chosen language plus en; filters category, muscle (primary or secondary), equipment (all given must match), origin. Ordered: exact name match, then name prefix, then alias match, then by name; page size ≤ 50. q=bench, equipment barbell → barbell bench press first

Out of scope

  • Crowd editing of the shared catalogue: submissions, edit history, trust levels, review, translation workflows.
  • Exercise videos and image upload to the catalogue; the catalogue's own media hosting.
  • Muscle diagrams.

Decisions

  • Catalogue changes never delete a user's history: unused exercises are removed, used ones archived. The source cascaded deletes.
  • The catalogue source is a generic contract; no dataset is bundled. Licences are kept per item because catalogue data may be licensed differently from the app.
  • Custom exercises are private; the source let users add to the shared catalogue.

Foods

Status: Draft · Prefix: FOOD

Purpose

The foods a user can plan and log, their nutrients per 100 g and portion sizes, the checks every food passes, and a generic contract for external food sources.

Requirements

ID Requirement Verified by
FOOD-1 Food: {id, uuid, origin: source|custom, name (3–200), brand? (≤ 200), language, barcode? (≤ 200), energy_kcal (integer ≥ 0), protein, carbohydrates, sugar?, fat, saturated_fat?, fiber?, sodium?, is_vegan?, is_vegetarian?, updated_at}. All nutrients are grams per 100 g, 0–100, 2 decimals. Energy given only in kJ is converted: kcal = round(kJ / 4.184). Shown in the user's energy_unit (PRF-1). protein 120 → refused; 1674 kJ → 400 kcal
FOOD-2 Consistency: saturated_fat ≤ fat; sugar ≤ carbohydrates; protein + carbohydrates + fat ≤ 105. Failing → inconsistent_nutrients naming the rule. sugar 30, carbs 20 → refused
FOOD-3 Energy check: computed = 4 × protein + 4 × carbohydrates + 9 × fat + 2 × (fiber or 0). Skipped when both energy_kcal and computed are ≤ 13; otherwise computed must lie within [energy_kcal × 0.7 − 5, energy_kcal × 1.3 + 5], else energy_mismatch. 400 kcal, P 10 C 10 F 1 → refused
FOOD-4 Portion: {food, name (1–200), grams (integer 1–5000)}; a food has 0–20 portions; plain grams are always available without a portion. add "slice" 30 g → choosable in diary
FOOD-5 Custom food: origin: custom, owned by one user, private (PRIV-2), editable and deletable by them; deleting one that plans or diary entries use → in_use. delete custom food used in diary → refused
FOOD-6 Source foods also carry {source_name, remote_id, source_url?, license_id (SPDX), attribution?}, shown on the food detail; they are read-only to users, and the user may copy one into a custom food to correct it. edit source food → refused; copy → custom food
FOOD-7 Food source contract: search(text, language, limit ≤ 50) → candidates; lookup_barcode(code) → candidate or none; candidate = FOOD-1 fields + FOOD-6 fields + portions[]. A candidate failing FOOD-1–3 is dropped. Several sources may be configured; none is configured by default (PRIV-4). candidate with fat 150 → not offered
FOOD-8 A source candidate is stored locally on first use (upsert by source_name + remote_id) so plans and diary keep working when the source is unreachable; later lookups refresh fields that changed. source offline → logged food still shows nutrients
FOOD-9 Requests to a food source carry only the search text, language, barcode and limit, never user identifiers or diary data; a source failing or timing out after 5 s gives local results only, flagged source_unavailable. source timeout → local matches + flag
FOOD-10 Search: q (≥ 2 chars) case-insensitive on name and brand in the user's language, local foods first (user's custom, then stored source foods), then source candidates not yet stored; filters is_vegan, is_vegetarian; exact barcode in q returns that food first. q=oat → custom "oat mix" before source foods

Out of scope

  • Bundled food datasets, bulk dataset sync, product images, quality grades from specific databases.
  • Recipes built from other foods.

Decisions

  • Food databases are a generic source contract; no vendor is named or required.
  • The 105 g cap and energy tolerance follow common label tolerances, as in the source.
  • Source foods are cached locally so the diary never depends on a third party at read time.

Nutrition

Status: Draft · Prefix: NUT

Purpose

Nutrition plans with meals and goals, a food diary, and the exact arithmetic for calories and macros.

Requirements

ID Requirement Verified by
NUT-1 Plan: {id, description (≤ 80), start (default local today), end?, diary_only (default false), created_at}; end ≥ start else invalid_range. A plan is active on dates in [start, end] (open end = no end). The current plan = active plan with the latest start. two active plans → later start is current
NUT-2 Goals per plan, each optional integer ≥ 0: goal_energy_kcal, goal_protein_g, goal_carbohydrates_g, goal_fat_g, goal_fiber_g. Goals are set only by the user. estimate shown → goal stays empty until saved
NUT-3 Meal: {plan, order, time?, name (≤ 25)}; meal item: {meal, food, portion?, amount (0.01–1000, 2 decimals), order}. diary_only plans have no meals (adding one → diary_only). add meal to diary-only plan → refused
NUT-4 Item grams = amount × portion.grams, or amount when no portion. Item nutrient = food nutrient per 100 g × grams / 100, for energy and each FOOD-1 nutrient (FOOD-1). Stored unrounded; shown rounded to 1 decimal (energy to whole kcal). 2 slices of 30 g, 250 kcal/100 g → 150 kcal
NUT-5 Totals of a meal, plan or day = sum of items. For sugar, saturated_fat, fiber, sodium: items lacking the value are skipped, and the total is empty only if every item lacks it. 2 items, fiber 3 and empty → fiber 3
NUT-6 Energy share of a macro = macro_g × factor / total_energy_kcal × 100 with factors protein 4, carbohydrates 4, fat 9; all 0 when total energy is 0. Shares need not sum to 100. P 100 g, energy 2000 → 20 %
NUT-7 Per body weight: macro_g / weight_kg, using the body weight entry closest in date to the plan's created_at (tie → earlier entry); empty when there is no body weight. P 160 g, 80 kg → 2.0 g/kg
NUT-8 Diary entry: {id, plan, meal?, eaten_at (default now), food, portion?, amount (0.01–1000), comment?}; nutrients per NUT-4. Deleting a meal clears meal on its diary entries; deleting a plan deletes its meals and diary entries. delete meal → its diary entries keep meal = null
NUT-9 Day view: for a local date, diary entries of the plan with their totals, the plan's planned totals (sum of meals), and per goal: remaining = goal − logged (may be negative). Diary history: per local day totals for a date range, energy in the user's energy_unit; days without entries are omitted. goal 2000, logged 1500 → remaining 500; 7-day range, 5 logged days → 5 rows
NUT-10 Log a planned meal: creates one diary entry per item of the meal with the same food, portion and amount, meal set and eaten_at = given time or now. meal of 3 items → 3 diary entries
NUT-11 Goal band for a day's energy vs goal_energy_kcal: on_target within ±3 %, close within ±7 %, off within ±10 %, else far (bounds exclusive). Shown as a band name only, no advice. goal 2000, logged 2100 → close
NUT-12 Copy plan: new plan owned by the user with start = local today, same description, goals, meals and items; diary entries are not copied. copy plan with diary → copy has 0 entries

Out of scope

  • Meal suggestions, diet generation, micronutrient tracking beyond FOOD-1, PDF export of plans.

Decisions

  • Amount minimum 0.01 instead of the source's 1, so fractional portions (half a slice) work.
  • Goals never fill themselves from the energy estimate (PRF-8).
  • Goal bands compare the logged day, not the plan; the source compared plan totals with a profile calorie value.

Privacy

Status: Draft · Prefix: PRIV

Purpose

Health data is sensitive: who may see it, what is stored, what leaves the instance, and what its export, import and delete-all cover.

Requirements

ID Requirement Verified by
PRIV-1 Default auth mode single_owner (@specfork/auth:AUTH-18); any further account (@specfork/auth:AUTH-22) is a separate, fully isolated owner. second sign-up on fresh instance → refused
PRIV-2 Owner-only access: every routine, session, log, measurement, photo, plan, diary entry, custom exercise, custom food and profile belongs to one user and is readable and writable only by them; a request for another user's record → not_found (never forbidden). Instance admins get no read access to it either. user B fetches A's weight entry → not found
PRIV-3 Inventory (@specfork/privacy:PRV-9): account (@specfork/auth), profile (PRF-1, PRF-6), routines, sessions and logs, measurements and photos, plans and diary, custom exercises and foods; each kept until the user deletes it. No location, contacts, device identifiers or advertising IDs are collected. manual: inventory lists these and the data model has no other personal fields
PRIV-4 No sharing: no public pages, share links, social feeds or third-party analytics. Outbound traffic goes only to the operator-configured exercise source (EXC-6) and food sources (FOOD-9), which receive no personal data; the inventory names them (@specfork/privacy:PRV-10). manual: network log of a full session shows no other hosts
PRIV-5 Export (@specfork/privacy:PRV-1), format @specfork/fitness-log, version 1, one JSON Lines file per entity: profile, routines, routine_days, slots, slot_entries, progression_rules, routine_labels, sessions, set_logs, measurement_categories, measurements, progress_photos, plans, meals, meal_items, diary_entries, custom_exercises, custom_foods (with portions). Catalogue exercises and source foods are referenced by uuid and name. Also photos/<id>.<ext> (original files) and set_logs.csv, measurements.csv, diary.csv for spreadsheets. export → ZIP contains every log and photo
PRIV-6 Import (@specfork/privacy:PRV-5): referenced catalogue exercises and source foods are matched by uuid; unknown ones become custom items with the exported name. Calculated categories are recomputed, not imported (BODY-6). import into empty account → same charts and records
PRIV-7 Delete all my data (@specfork/privacy:PRV-6) removes every record of PRIV-3 except the account, photo files included, and resets the profile to defaults; account deletion is @specfork/privacy:PRV-7. delete all → history, weights, photos empty; can still sign in
PRIV-8 Health values (weights, measurements, food, notes, photos) never appear in logs, error reports, URLs or query strings; records are addressed by id only. manual: server log after logging weight has no value
PRIV-9 No medical features: the app shows values and the stated formulas only; it gives no diagnosis, risk score, health category (e.g. BMI classes), normal ranges, warnings or advice. Every estimate (basal rate, daily energy, BMI, 1RM) is labelled "estimate". BMI 31 → shown "31.00 (estimate)", no class label

Out of scope

  • Sharing with trainers, coaches, gyms or friends; multi-user teams (@specfork/teams is not used).
  • End-to-end encryption.

Decisions

  • Not-found instead of forbidden, so record ids reveal nothing.
  • One owner per instance by default (single_owner); extra accounts possible but never connected.
  • Export packaging, delivery, limits, re-auth, delete-all timing and backup purge come from @specfork/privacy; this area keeps what is health-specific.
  • Delete-all keeps the account so a user can restart cleanly; the source only offered full account deletion.

Profile

Status: Draft · Prefix: PRF

Purpose

The user's units and body profile, and the exact formulas that turn them into estimates.

Requirements

ID Requirement Verified by
PRF-1 Profile (one per user, created with the account): unit_system: metric|imperial (default metric), energy_unit: kcal|kj (default kcal), birthdate?, height_cm? (100–250, 1 decimal), sex_for_formulas?: male|female (no default), weight_rounding?, repetitions_rounding? (each 0.01–99.99, default null = no rounding). birthdate must give an age of 10–100 full years today; age = full years between birthdate and the user's local today. birthdate 5 years ago → refused
PRF-2 Local day: a timestamp counts for the calendar date it has in the user's time_zone (@specfork/auth:AUTH-1). Every per-day and "today" rule in this spec uses it. 23:30 UTC, zone Asia/Tokyo → next date
PRF-3 Units: metric shows kg and cm, imperial shows lb and inches. Exact factors: 1 lb = 0.45359237 kg, 1 in = 2.54 cm, 1 kcal = 4.184 kJ. Shown values round half-up to 2 decimals; stored values keep the unit they were entered in (BODY-3). 100 kg shown imperial → 220.46 lb
PRF-4 Changing unit_system or energy_unit changes display and new-entry defaults only; no stored value is rewritten. Profile edits never rewrite past entries; formulas always read the current profile and the latest body weight. switch to imperial → old entries still stored in kg; change height → past weight entries unchanged
PRF-5 Basal rate estimate (Mifflin–St Jeor), kcal/day = 10 × weight_kg + 6.25 × height_cm − 5 × age_years + s, where s = +5 for male, −161 for female; weight_kg is the latest body weight entry (BODY-3); result rounded to 2 decimals and labelled "estimate". Any input missing → missing_inputs listing them. 80 kg, 180 cm, 30 y, male → 1780.00, labelled estimate
PRF-6 Activity inputs: sleep_hours 4–10 (default 7), work_hours 1–15 (default 8), work_intensity, sport_hours_per_week 0–30 (default 3), sport_intensity, free_hours 1–15 (default 8), free_intensity; intensities low|medium|high (defaults low, medium, low). sleep_hours + work_hours + free_hours > 24 → hours_exceed_day. 10 + 10 + 10 → refused
PRF-7 Activity factor = (sleep_hours × 0.95 + work_hours × w + sport_hours_per_week / 7 × p + free_hours × f) / 24, rounded to 2 decimals; w = 1.5/1.8/2.2, p = 4/6/10, f = 1.3/1.9/2.4 for low/medium/high. defaults → 1.32
PRF-8 Daily energy estimate = round(basal rate × activity factor) kcal, computed only on request, labelled "estimate", and never written anywhere unless the user copies it into a plan goal (NUT-2). 1780 × 1.32 → 2350 kcal; plan goal unchanged

Out of scope

  • Email reminders (workout ending, weight entry overdue), notification language, gamified trophies.
  • Gym membership, trainer-created users, contracts.

Decisions

  • No default sex: the source defaulted to male, which silently skews the basal rate. Without it the estimate is unavailable.
  • Height 100–250 cm instead of the source's 140–230, which rejected real adults.
  • lb ↔ kg uses the exact international factor both ways; the source used a rounded inverse.
  • Sport hours may be 0; the source required at least 1.

Progression

Status: Draft · Prefix: PROG

Purpose

How a slot entry's prescription (sets, weight, reps, reps in reserve, rest) is set for iteration 1 and changes in later iterations, optionally only after the user hit the previous target.

Requirements

ID Requirement Verified by
PROG-1 Fields: sets, max_sets, weight, max_weight, repetitions, max_repetitions, rir, max_rir, rest (seconds), max_rest. A max_* field turns its base field into a range. weight 60, max_weight 70 → shown 60–70
PROG-2 Rule per field: {slot_entry, field, iteration ≥ 1, value (0–3000, 2 decimals), operation: add|subtract|replace (default replace), step: absolute|percent (default absolute), repeat (default false), requirements?}; one rule per (slot_entry, field, iteration). A rule at iteration 1 is always replace. add rule at iteration 1 → stored as replace
PROG-3 Value at iteration n: start empty and walk i = 1..n. At i, the field's rule for i becomes the active rule and is applied; with no rule for i, an active rule with repeat is applied again; otherwise a waiting rule (PROG-6) is tried; otherwise the value stays. weight replace 60 @1, add 2.5 repeat @2 → @4 = 67.5
PROG-4 Applying: replace → value; add/subtract with absolute → current ± value; with percent → current ± current × value / 100; an empty current counts as 0. Result capped at 9999.99 (rir, max_rir: 9.5). 100 + 10 % → 110
PROG-5 Requirements: {rules: subset of weight|repetitions|rir|rest|max_weight|max_repetitions (≥ 1), all_sets (default false)}; unknown keys or rules → invalid_requirements. A rule with requirements applies at iteration i > 1 only if the set logs of iteration i − 1 for this slot entry qualify: by default at least one log has, for every listed rule, its logged value ≥ the shown target of iteration i − 1 (max_weight/max_repetitions read the top of the range, or the base value when no range); with all_sets, at least sets logs (min 1) were made and every one qualifies. target 8 reps, logs 8, 6 → qualifies; with all_sets → not
PROG-6 A gated rule that does not qualify is skipped; if it does not repeat it waits and is tried on each next iteration until it applies or a newer rule for that field replaces it. A max_* rule is gated by its base field's current rule. add 5 gated @2 fails, passes @3 → applied once at @3
PROG-7 Shown values: weight rounded to the entry's weight_rounding, repetitions to repetitions_rounding, rir to 0.5, rest to 1 s; rounding r gives r × round(x / r), halves to even; no rounding when r is empty. Whole numbers are shown without decimals. A max_* value is shown only if it is greater than its base value; sets with no value is 1. Units shown only when the base value exists. 61.3 kg, rounding 2.5 → 62.5; max_weight 50, weight 60 → no range shown
PROG-8 rir values (logged or prescribed) are one of 0, 0.5, 1, … 4.5; anything else → invalid_rir. rir 5 → refused
PROG-9 The prescription for an iteration is recomputed when rules, rounding or the slot entry's logs change; a change never alters logs already written (LOG-2 keeps targets as logged). edit rule → past log weight_target unchanged

Out of scope

  • Named progression schemes shipped as code (e.g. 5/3/1 calculators); they are expressible as rules.

Decisions

  • Rounding is half-to-even, matching the source's rounding function; implementations must not use half-up here.
  • A field's rules are walked iteration by iteration so gated, non-repeating rules can earn one step each time the user qualifies, like the source.

Records

Status: Draft · Prefix: STAT

Purpose

Numbers derived from set logs (estimated one-rep max, personal records, volume, intensity) and the rules every progress chart follows.

Requirements

ID Requirement Verified by
STAT-1 Qualifying set: repetitions_unit = repetitions, weight_unit in kg|lb, weight > 0, repetitions ≥ 1. Weights are converted to kg (PRF-3) before any formula here. 100 lb × 5 → 45.36 kg × 5
STAT-2 Estimated one-rep max (Brzycki) = weight_kg × 36 / (37 − repetitions), rounded to 2 decimals, defined only for 1 ≤ repetitions ≤ 10; RiR is ignored. 100 kg × 5 → 112.50
STAT-3 Personal records per exercise, over all qualifying sets: best_estimated_1rm (STAT-2), heaviest_weight (max weight_kg with ≥ 1 rep), most_reps_at_weight (max repetitions per exact weight_kg). Each record keeps {value, log_id, performed_at}; the earliest log wins ties. Saving a log that beats a current record (strictly greater) marks that log as a new record of that kind; editing or deleting logs recomputes records from the remaining logs. 100×5 then 105×3 → 1RM 112.50 stays, heaviest 105; delete record log → previous best restored
STAT-4 Daily 1RM series per exercise: one point per local day with qualifying sets of ≤ max_reps (1–10, default 5): the day's highest STAT-2 value. sets 3 reps and 8 reps same day, max 5 → only 3-rep set counts
STAT-5 1RM total: for 2–5 distinct exercises and a window_days of 7–120 (default 30), one point on each local day any of them was trained: sum over the exercises of their best daily 1RM within the window ending that day; no point when any exercise has none in the window. bench+squat, squat last trained 40 d ago, window 30 → no point
STAT-6 Volume = Σ weight_kg × repetitions over qualifying sets (non-qualifying count 0); set count = number of logs; intensity = average of weight_kg / 1RM_estimate (= (37 − repetitions) / 36) over qualifying sets with repetitions ≤ 10. 2 sets 100 kg × 5 → volume 1000, intensity 0.89
STAT-7 STAT-6 values are grouped for a routine by local day, ISO week, iteration and whole routine, each split by exercise and by primary muscle (EXC-1); a muscle total counts each set once per primary muscle. squat with 2 primary muscles → both get its volume
STAT-8 Chart buckets: a series is condensed to calendar buckets hour|day|week|month (or auto) in the user's time zone; auto takes the finest unit that yields ≤ max_points (default 200) buckets, else month. Weeks start on the user's week_start (@specfork/auth:AUTH-1). 3 years of daily weights, auto → week
STAT-9 Each bucket returns {start, count, sum, min, max}, mean = sum / count, grouped also by stored unit so kg and lb are never averaged together; the client converts then merges. An entry that already summarises a range (e.g. an imported day of steps with min/max) contributes its own bounds. bucket with 80 kg and 176.37 lb → two rows
STAT-10 Charts available: body measurement categories (BODY), STAT-4 and STAT-5 series, routine volume/sets/intensity (STAT-7), daily energy and macros logged vs goal (NUT-9). Each chart covers a chosen date range and shows only the user's data. chart weight last 90 days → points only in range

Out of scope

  • Trophies, badges, streak rewards, leaderboards or comparisons with other people.

Decisions

  • One 1RM formula (36 / (37 − reps)) for records and series; the source added RiR to reps for records but not for series. Capped at 10 reps because the estimate degrades above that.
  • Volume and intensity include lb sets after conversion; the source dropped non-kg sets.
  • Records replace the source's trophy for beating an estimate; no gamification.

Routines

Status: Draft · Prefix: RTN

Purpose

Training plans: a routine of ordered days that repeat over a date range, each day holding slots of exercises with set prescriptions.

Requirements

ID Requirement Verified by
RTN-1 Routine: {id, name (≤ 25), description (≤ 1000), start (date), end (date), is_template (default false), fit_in_week (default false), created_at}. end ≥ start and end − start ≤ 120 days, else invalid_range. Owned by one user. start Jan 1, end Jun 1 → refused
RTN-2 Day: {routine, order (≥ 1), name (≤ 20), description (≤ 1000), type: custom|emom|amrap|hiit|tabata|edt|rft|afap (default custom), is_rest (default false), needs_logs_to_advance (default false)}. type is a label only. A routine with no days has an empty calendar. A rest day has no slots: setting is_rest deletes the day's slots, and adding a slot to a rest day → rest_day. routine without days → calendar []; add slot to rest day → refused
RTN-3 Slot: {day, order (≥ 1), comment (≤ 200)} holding ≥ 0 slot entries. A slot with more than one entry is a superset. slot with 2 entries → superset
RTN-4 Slot entry: {slot, exercise, order, type: normal|warmup|dropset|myo|partial|forced|tut|iso_hold|jump (default normal), comment (≤ 100), weight_unit (default kg), repetitions_unit (default repetitions), weight_rounding?, repetitions_rounding?} plus progression rules (PROG). New entries copy the rounding defaults from the profile (PRF-1) and get order = last + 1. new entry, profile weight_rounding 2.5 → entry 2.5
RTN-5 Weight units: kg|lb|body_weight|plates|km_per_hour|miles_per_hour. Repetition units: repetitions|until_failure|max_reps|seconds|minutes|meters|kilometers|miles. Only kg and lb convert (PRF-3); only repetitions counts as reps in statistics (STAT). log 30 seconds → excluded from volume
RTN-6 Calendar: walk the dates from start to end. The first date gets day 1. Each next date moves to the next day by order, wrapping to day 1 after the last, unless the current day has needs_logs_to_advance, there is no session for that day on the previous date (LOG-3), and the date is not after the user's local today; then the current day repeats. Today's workout: the calendar entry whose date is the user's local today; none if outside the range or a free date. 3 days, no flags → Mon d1, Tue d2, Wed d3, Thu d1; today after end → none
RTN-7 Iteration: the k-th run of a day in the calendar has iteration k; a day repeated by RTN-6 keeps its iteration. Iterations select progression values (PROG-3). d1 on Mon and Thu → iterations 1, 2
RTN-8 fit_in_week: when the day count is not a multiple of 7 and the cycle wraps to day 1, the dates up to the next Monday get no day (free), and day 1 resumes that Monday. 3 days, start Mon, fit → Thu–Sun free, d1 next Mon
RTN-9 Labels: {routine, start_offset ≥ 0, end_offset ≥ start_offset, text (≤ 35)}; a label applies to dates start + start_offset through start + end_offset and is shown on those calendar dates. label 0–6 "deload" → first 7 dates labelled
RTN-10 Set expansion for doing a day: a single-entry slot gives its entry sets times; a superset interleaves round-robin, one set per entry per round, skipping entries whose sets are done. A×3, B×2 → A, B, A, B, A
RTN-11 Templates: a template (is_template = true) is read-only except for clearing the flag. Copying any routine creates a new non-template routine owned by the copier with start = local today, end = today + (original end − original start), and copies of all days, slots, entries, rules and labels; logs and sessions are not copied. copy 8-week template → new routine 8 weeks from today, no logs
RTN-12 Deleting a routine deletes its days, slots, entries, rules and labels; its sessions and set logs remain with routine and slot_entry cleared. delete routine → logs still listed in history

Out of scope

  • Public or shared templates, trainer-assigned routines, calendar (iCal) and PDF export.
  • Custom progression code plug-ins per slot entry.

Decisions

  • Deleting a routine keeps the training history; the source kept logs too but by clearing links, which this spec makes explicit.
  • Day type is a display label only; the source stored it without behaviour.
  • Public templates removed: a personal app does not share.

Sessions

Status: Draft · Prefix: LOG

Purpose

What the user actually did: one set log per performed set, grouped into workout sessions.

Requirements

ID Requirement Verified by
LOG-1 Set log: {id, performed_at (default now), exercise, session, routine?, slot_entry?, iteration?, repetitions?, repetitions_unit, weight?, weight_unit, rir?, rest? (seconds, integer ≥ 0), next_log?, plus *_target copies}. repetitions, weight ≥ 0 with 2 decimals, ≤ 9999.99. weight −5 → refused
LOG-2 A log needs repetitions or weight (both empty → empty_log); a unit is required for each value given. Logging from a routine copies the shown prescription of that iteration into repetitions_target, weight_target, rir_target, rest_target; targets never change afterwards. log with neither value → refused
LOG-3 Session: {id, routine?, day?, started_at (default now), ended_at?, notes?, impression: bad|neutral|good (default neutral)}. ended_at ≥ started_at, and the span ≤ the instance's max_session_hours (default 5) → else invalid_session_span. A session counts for the local day of started_at (PRF-2). session 23:00–01:00 → counts for first date
LOG-4 A log without a session is attached on save: first a session of the same routine whose [started_at, ended_at] contains performed_at; else the newest open session of that routine started within max_session_hours before performed_at; else a new session starting at performed_at. Concurrent saves for one user must not create two sessions. two logs 10 min apart, no session → one session
LOG-5 When all routine-linked logs of a session come from slot entries of one day of that session's routine and the session has no day, the session's day is set to it. 3 logs all from day "Legs" → session day = Legs
LOG-6 Drop sets and similar chains: next_log points to the following log of the chain; deleting a log deletes the logs after it in its chain. Deleting a session deletes its logs. Deleting a log never deletes its session. delete first of 3-chain → all 3 gone; delete session with 5 logs → 0 logs left
LOG-7 A log or session may reference only the user's own routine, slot entry, session or log; a foreign reference → not_found, nothing saved. log with another user's session → refused
LOG-8 History: logs listed newest first, filterable by exercise, routine, local date range; grouped by session with the session's notes and impression. Logging works without a routine: a free session with any exercises, routine, slot_entry and iteration empty. filter exercise squat → only squat logs; log curl with no routine → stored, new session

Out of scope

  • Live workout timer, rest timer sounds, wearable heart-rate capture during sets.

Decisions

  • Chained logs are deleted together; the source cascaded the same way.
  • Sessions longer than the limit are refused, not split.

From Specfork/fitness-log at 8f43edc, licence CC-BY-4.0.