SLOPSHOPPER

deskfit

Micro-workouts while Claude works: a daily goal, streaks and leaderboards for people who sit at a desk.

newpanebandcommandtimer
v0.6.2MITupdated 2026-10-05Atanur/deskfit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · deskfit
│ ┃ DeskFit ✕ › fix the failing auth test and add an audit log call │ ┃ ⚡ Welcome to DeskFit 1/7 │ ┃ ⏺ Read(src/auth.ts) │ ┃ Short exercise breaks while Claude works. ⎿ Read 6 lines │ ┃ Everything stays on this device. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⚡ Sets of about 30 seconds, right while Clau ⏺ Bash(bun test) │ ┃ is busy. ⎿ 3 pass, 1 fail │ ┃ │ ┃ ◎ A daily goal, streaks and badges keep you ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ going. │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ⛨ Your data never leaves this device unless │ ┃ you join a leaderboard. › /workout │ ┃ ⎿ deskfit: DeskFit opened. │ ┃ DeskFit gives general fitness suggestions, │ ┃ not medical advice. Stop if anything hurts │ ┃ and check with a doctor if you have a health │ ┃ condition. │ ┃ │ ┃ │ ┃ [ Get started ] │ DeskFit: type /workout to set up ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
DeskFit: type /workout to set up
Pane · DeskFit
⚡ Welcome to DeskFit 1/7 Short exercise breaks while Claude works. Everything stays on this device. ⚡ Sets of about 30 seconds, right while Claude is busy. ◎ A daily goal, streaks and badges keep you going. ⛨ Your data never leaves this device unless you join a leaderboard. DeskFit gives general fitness suggestions, not medical advice. Stop if anything hurts and check with a doctor if you have a health condition. [ Get started ]
README

<img src="site/public/logo.svg" width="72" height="72" alt="DeskFit logo">

<h1 align="center">DeskFit</h1>

<b>Move while Claude thinks.</b><br> A Claude Code mod that turns the seconds you wait on Claude into short, well-chosen exercise sets,<br> with a daily goal, streaks, badges and optional leaderboards.

<a href="https://deskfit.atanur.dev">Website</a> · <a href="#install">Install</a> · <a href="#using-it">Using it</a> · <a href="#privacy">Privacy</a> · <a href="server/README.md">Run your own leaderboard</a>

<img src="site/public/assets/screen-home.png" width="260" alt="DeskFit home: today's goal ring, level, streak and the suggested move"> &nbsp; <img src="site/public/assets/screen-set.png" width="260" alt="A set in progress: a line-art figure and a large timer"> &nbsp; <img src="site/public/assets/screen-stats.png" width="260" alt="Stats: sets per day and work by body region">

What it does

When Claude has been busy for a while, a quiet line above your prompt suggests one move that fits your body, your desk and your week so far. You do a set of 15 to 60 seconds, rate it, and the next ones adjust.

  • 64 moves, each with line-art illustrations, tagged with the muscles it works, where you can do it (33 work at an office desk, all work at home) and the gear it needs. Quiet mode removes jumping and loud moves.
  • A planner, not a shuffle. It balances eight body regions over the last seven days against your goal, rests what you just worked, balances pushing and pulling, starts gently, and respects limits such as knees or lower back.
  • A game that stays out of the way. Daily goal (3, 5, 8 or 12 sets), XP and levels, streaks, 11 badges, calories from MET values.
  • Optional leaderboards and private teams. Join with a nickname only. See Privacy.
  • English and Türkçe, light and dark, in the terminal and in the desktop app.

Install

In Claude Code:

/plugin marketplace add atanur/deskfit /plugin install deskfit@deskfit /workout

/workout opens DeskFit. A one-minute setup asks for your age, goal and where you work out. To remove it later: /plugin uninstall deskfit@deskfit.

Early access. DeskFit is built on Claude Code's plugin function hooks, which Claude Code currently loads only when they are turned on (a rollout flag, so it depends on your version and account). If /workout answers "Unknown command", set this in the environment you start Claude Code from, for example in ~/.zshrc, and start Claude Code again:

export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

With claude --debug, a line "hooks module deskfit@deskfit not loaded" confirms that this is the cause.

Using it

CommandWhat it does
/workoutOpen DeskFit (or start the setup the first time)
/workout movesBrowse all moves, filter by region and place, try one
/workout statsLevel, streak, sets per day, work by body region, badges
/workout boardThe leaderboard, your team and your account
/workout settingsGoal, gear, language, look, sound, your data
/workout focus 2hPause suggestions for a while (focus off ends it)
/workout resetErase your profile and progress from this machine

Inside the pane every button has a key: s start, n shuffle, f finish, c cancel, h m t l o for Home, Moves, Stats, Board and Settings, Esc closes it. The line above the prompt can be hidden in Settings.

Privacy

DeskFit works fully offline and stores everything on your machine. Age, weight, height, goal and limits never leave the device.

The only thing it does online is download the public leaderboard when it opens (a plain request that carries nothing about you). To turn even that off, clear the server address under Settings -> Leaderboard. Nothing about you is sent until you choose to join, and then only your nickname, points, sets and finish times. You can delete the leaderboard account, or everything on your device, from Settings.

By default DeskFit uses a public server at https://deskfit-api.atanur.dev. A company can host its own: see server/README.md (Cloudflare Workers + D1, or a single SQLite file with Node or Docker).

DeskFit gives general, low-impact exercise suggestions and is not medical advice. Stop if something hurts.

How the next move is chosen

Every move lists the muscles it works (hooks/muscles.ts: main muscles count 1, helpers 0.5), its kind (strength, cardio, mobility, stretch), its movement pattern (push, pull, squat, hinge, core, rotate, move, release) and an effort from 1 to 5, taken from its MET value. Muscles roll up into eight body regions.

The planner (hooks/planner.ts) compares the last seven days with the goal's target share per region and favours the regions that are behind. Hard work in a region needs rest (strength and cardio count against it for about two days; stretches do not). It keeps the mix of kinds close to the goal's, balances pushing and pulling, starts the day gently, follows a hard set with an easy one, and never repeats a move from the last eight sets, the same region, or the same movement pattern back to back. Regions picked under Settings -> Focus regions get extra weight. The home screen says why a move was chosen and shows the next few.

Illustrations

Three looks, picked under Settings -> Look and sound -> Illustrations:

  • Line art (default): detailed line drawings from Workout Guide by Bryl Lim, built on Everkinetic artwork, licensed CC BY-SA 4.0 (see NOTICE.md). Some moves were drawn with an image model in the same style (art-extra/) and share that licence. Every move has one.
  • Abstract: bold silhouettes made in code that highlight the muscles a move works.
  • Stick figure: the first, thin look.

Development

claude --plugin-dir . # load the plugin from this folder npm install --legacy-peer-deps

CheckWhat it does
npm run uiRenders the pane in plain Node against a stand-in engine, presses through every screen and filter, fails on an exception, a duplicate key or a tree over 160 KB
npm run planPlays 14 simulated days for seven kinds of person; fails if the plan repeats itself, drifts from the goal's shares or leans on a few moves
npm run musclesChecks the muscle table against the Workout Guide manifest and that no office move needs home-only gear
npm run i18nLists texts missing a Turkish translation or with mismatched {placeholders}
npm run e2eRuns the API checks against a running server (see server/README.md)
npm run preview, npm run mockupsPreview the drawings and the pane designs

To add a move: an entry in hooks/catalog.ts, its pose pair in hooks/art.ts, a MET value in hooks/calories.ts and its Turkish text in hooks/tr.ts. To add a language, add a table like tr.ts, extend Lang in hooks/i18n.ts and the list in hooks/settings.ts; the English text of each message is its key. The drawings are packed into hooks/art-wg*.ts by npx tsx scripts/art/wg-build.mjs; to add a move that Workout Guide covers, put its slug in scripts/art/wg-map.mjs, then run scripts/art/wg-fetch.mjs and wg-build.mjs.

Calories per set = (MET - 1) x resting kcal per hour x hours. MET values are from the 2024 Adult Compendium of Physical Activities (hooks/calories.ts lists the source of each); the resting rate comes from the Mifflin-St Jeor equation using age, weight, height and (optional) sex. Estimates can be off by 20-30%.

Website

site/public/ holds the static site (no build step), served by a Cloudflare Worker with static assets. Deploy with cd site && npx wrangler deploy; change the route in site/wrangler.toml to your own domain first.

Credits and license

MIT, see LICENSE. The exercise line art is CC BY-SA 4.0, see NOTICE.md. DeskFit is an independent project, not made, endorsed or supported by Anthropic. Claude and Claude Code are trademarks of Anthropic.

Source 19 files
hooks/register.tsx 2316 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type {
5  Ability,
6  ActiveSet,
7  Equipment,
8  Exercise,
9  Draft,
10  BoardRow,
11  Goal,
12  Identity,
13  LastResult,
14  Library,
15  Limit,
16  Place,
17  Profile,
18  Progress,
19  Region,
20  Screen,
21  Sex,
22  DeskSettings,
23  Social,
24} from '../types'
25import {
26  badgesSvg,
27  barsSvg,
28  regionBarsSvg,
29  confettiSvg,
30  avatarSvg,
31  celebrateSvg,
32  emptySvg,
33  figureSvg,
34  figureText,
35  flameSvg,
36  iconSvg,
37  ICON_TEXT,
38  levelBarSvg,
39  logoSvg,
40  medalSvg,
41  podiumSvg,
42  rankSvg,
43  ringSvg,
44  setTheme,
45  stepsSvg,
46  timerSvg,
47  weekDotsSvg,
48  xpBarSvg,
49} from './art'
50import type { IconName } from './art'
51import { BADGES, award, badgeName } from './badges'
52import { byId, CATALOG } from './catalog'
53import { fmtKcal, fmtMinutes, kcalFor } from './calories'
54import {
55  clock,
56  dayKey,
57  emptyProgress,
58  levelOf,
59  liveStreak,
60  normalize,
61  recordSet,
62  rollDay,
63  weekAreas,
64  weekRegions,
65  xpForLevel,
66} from './game'
67import { decodeRecovery, encodeRecovery, randomHex, signHeaders } from './net'
68import { tr } from './i18n'
69import type { Vars } from './i18n'
70import { mainRegions, REGION_LABEL, REGIONS, topRegion } from './muscles'
71import { blockers, levelFor, pick, program, secondsFor, shares, why } from './planner'
72import {
73  ACCENTS,
74  CM_PER_IN,
75  DAILY_GOALS,
76  DEFAULT_SETTINGS,
77  ILLUSTRATIONS,
78  KG_PER_LB,
79  LANGUAGES,
80  NUDGE_DELAYS,
81  TOASTS,
82  UNITS,
83  withDefaults,
84} from './settings'
85import { soundOf } from './sound'
86import type { SoundKind } from './sound'
87
88const PANE = 'deskfit'
89const WEEK_MS = 7 * 86400000
90
91type Dollar = EngineInterface
92type Rating = 'easy' | 'right' | 'hard' | 'hurts'
93
94const emptyDraft: Draft = {
95  step: 0,
96  age: '',
97  weight: '',
98  height: '',
99  sex: 'unspecified',
100  goal: 'fitness',
101  abilities: [],
102  limits: [],
103  dailySets: 5,
104  isQuiet: false,
105  places: ['office', 'home'],
106  equipment: [],
107  error: null,
108}
109
110const screen = atom({ plugin: 'deskfit', key: 'screen' } as const, 'onboarding' as Screen)
111const isLoaded = atom({ plugin: 'deskfit', key: 'isLoaded' } as const, false)
112const profile = atom({ plugin: 'deskfit', key: 'profile' } as const, null as Profile | null)
113const progress = atom({ plugin: 'deskfit', key: 'progress' } as const, emptyProgress('') as Progress)
114const draft = atom({ plugin: 'deskfit', key: 'draft' } as const, emptyDraft)
115const current = atom({ plugin: 'deskfit', key: 'current' } as const, null as ActiveSet | null)
116const result = atom({ plugin: 'deskfit', key: 'result' } as const, null as LastResult | null)
117const isBusy = atom({ plugin: 'deskfit', key: 'isBusy' } as const, false)
118const nudge = atom({ plugin: 'deskfit', key: 'nudge' } as const, null as string | null)
119const focusUntil = atom({ plugin: 'deskfit', key: 'focusUntil' } as const, null as number | null)
120
121const emptySocial: Social = {
122  tab: 'week',
123  view: 'board',
124  rows: [],
125  teams: [],
126  activeTeam: null,
127  teamInfo: null,
128  teamTotal: 0,
129  teamPeriod: 'week',
130  isConfirmingDisband: false,
131  me: null,
132  isLoading: false,
133  error: null,
134  notice: null,
135  form: { nickname: '', teamName: '', invite: '', recovery: '' },
136  recoveryCode: null,
137  isConfirmingDelete: false,
138}
139
140const settings = atom({ plugin: 'deskfit', key: 'prefs' } as const, withDefaults(undefined, {}))
141const upNext = atom({ plugin: 'deskfit', key: 'upNext' } as const, null as string | null)
142const identity = atom({ plugin: 'deskfit', key: 'identity' } as const, null as Identity | null)
143const social = atom({ plugin: 'deskfit', key: 'social' } as const, emptySocial)
144const library = atom({ plugin: 'deskfit', key: 'library' } as const, { region: 'all', place: 'all', isMine: false, shown: 4 } as Library)
145
146/** Moves the person has passed on since the last set, so a shuffle never goes back to them. */
147let passed: string[] = []
148
149const GOALS: { value: Goal; label: string }[] = [
150  { value: 'fitness', label: 'General fitness' },
151  { value: 'weight', label: 'Lose weight' },
152  { value: 'strength', label: 'Build strength' },
153  { value: 'posture', label: 'Posture and desk pain' },
154]
155
156const ABILITIES: { value: Ability; label: string }[] = [
157  { value: 'pushup', label: 'Full push-ups' },
158  { value: 'squat', label: 'Bodyweight squats' },
159  { value: 'plank', label: 'Plank for 30 seconds' },
160  { value: 'lunge', label: 'Lunges' },
161  { value: 'jump', label: 'Jumping (jacks, jumps)' },
162]
163
164const LIMITS: { value: Limit; label: string }[] = [
165  { value: 'knees', label: 'Knees' },
166  { value: 'back', label: 'Lower back' },
167  { value: 'wrists', label: 'Wrists' },
168  { value: 'shoulders', label: 'Shoulders' },
169  { value: 'neck', label: 'Neck' },
170]
171
172const RATINGS: { value: Rating; label: string; hotkey: string }[] = [
173  { value: 'easy', label: 'Too easy', hotkey: 'e' },
174  { value: 'right', label: 'Just right', hotkey: 'j' },
175  { value: 'hard', label: 'Too hard', hotkey: 'h' },
176  { value: 'hurts', label: 'It hurt', hotkey: 'p' },
177]
178
179const PLACES: { value: Place; label: string }[] = [
180  { value: 'office', label: 'Office' },
181  { value: 'home', label: 'At home' },
182]
183
184const EQUIPMENT: { value: Equipment; label: string }[] = [
185  { value: 'dumbbell', label: 'Dumbbells' },
186  { value: 'band', label: 'Resistance band' },
187  { value: 'kettlebell', label: 'Kettlebell' },
188  { value: 'bar', label: 'Pull-up bar' },
189  { value: 'mat', label: 'Exercise mat' },
190]
191
192const SEXES: { value: Sex; label: string }[] = [
193  { value: 'unspecified', label: 'Prefer not to say' },
194  { value: 'female', label: 'Female' },
195  { value: 'male', label: 'Male' },
196]
197
198/** welcome, about you, body, goal, what you can do, place and gear, your plan */
199const STEPS = 7
200
201const GOAL_HINTS: Record<Goal, string> = {
202  fitness: 'Stay active and keep your energy up through the day.',
203  weight: 'More movement and more calories burned, steady and low impact.',
204  strength: 'Build strength with push, leg and core work.',
205  posture: 'Ease the neck, back and wrist strain from sitting.',
206}
207
208/** Shared layout: rows of buttons breathe and wrap, cards get a border. */
209const actions = { gap: 2, flexWrap: 'wrap', marginTop: 1 } as const
210const card = { flexDirection: 'column', gap: 1, borderStyle: 'round', paddingX: 2, paddingY: 1 } as const
211const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
212
213const KIND_LABEL = { strength: 'Strength', cardio: 'Cardio', mobility: 'Mobility', stretch: 'Stretch' } as const
214
215const placeTag = (ex: Exercise): string =>
216  ex.places.length > 1 ? 'Office and home' : ex.places[0] === 'home' ? 'Home only' : 'Office only'
217
218const intensityOf = (ex: Exercise): string => (ex.effort <= 2 ? 'light' : ex.effort === 3 ? 'moderate' : 'intense')
219
220const toggle = <T,>(list: T[], v: T): T[] =>
221  list.includes(v) ? list.filter(x => x !== v) : [...list, v]
222
223const dots = (done: number, goal: number): string =>
224  Array.from({ length: Math.max(goal, 1) }, (_, i) => (i < done ? '●' : '○')).join(' ')
225
226/** "2h", "30m" or "off" to milliseconds; null when it is none of those. */
227const parseFocus = (text: string): number | null => {
228  const m = /^(\d{1,3})\s*([mh])$/.exec(text.trim())
229
230  return m ? Number(m[1]) * (m[2] === 'h' ? 3600000 : 60000) : null
231}
232
233let isDebug = false
234let ticker: Timer | null = null
235let nudgeTimer: Timer | null = null
236let seed: Partial<DeskSettings> = {}
237
238const stopTimer = () => {
239  ticker?.cancel()
240  ticker = null
241}
242
243const stopNudge = () => {
244  nudgeTimer?.cancel()
245  nudgeTimer = null
246}
247
248const persist = async ($: Dollar) => {
249  await $.store.set('profile', await read($, profile))
250  await $.store.set('progress', await read($, progress))
251}
252
253/** The server in use: the person's own setting, else the plugin's configured default. */
254/** Bumped when the look changes in a way saved settings should follow once (5: Ink & Signal, 6: the public board is on by default). */
255const UI_VERSION = 6
256
257/** Server addresses an early build saved as the default; they no longer serve the API. */
258const RETIRED_BACKENDS = ['https://deskfit.atanur.dev', 'https://api.deskfit.atanur.dev']
259
260const backendFor = (st: DeskSettings): string => st.backendUrl.trim().replace(/\/+$/, '')
261
262/** Only https, or http to this machine: the sign-up sends the account secret, so it must never cross a network in the clear. */
263const isSecureUrl = (url: string): boolean => /^https:\/\/[^\s/]+/i.test(url) || /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i.test(url)
264
265const saveSettings = async ($: Dollar, change: Partial<DeskSettings>) => {
266  const next = { ...(await read($, settings)), ...change }
267
268  await update($, settings, () => next)
269  await $.store.set('settings', next)
270  setTheme(next.accent)
271}
272
273/** Text in the person's language: the English text is the key, `{name}` marks a value from `vars`. */
274const tx = async ($: Dollar, text: string, vars?: Vars): Promise<string> => tr((await read($, settings)).language, text, vars)
275
276/** Shows a toast, in the person's language, unless they turned that level of message off. */
277const notify = async ($: Dollar, text: string, isImportant = false, vars?: Vars) => {
278  const st = await read($, settings)
279
280  if (st.toasts === 'none' || (st.toasts === 'important' && !isImportant)) return
281
282  $.ui.toast(tr(st.language, text, vars))
283}
284
285const chime = async ($: Dollar, kind: SoundKind) => {
286  if (!(await read($, settings)).sound) return
287
288  try {
289    await $.audio.play({ base64: soundOf(kind), mime: 'audio/wav' }, { gain: 0.6 })
290  } catch {
291    // no player on this machine: stay silent
292  }
293}
294
295const editProfile = async ($: Dollar) => {
296  const prof = await read($, profile)
297  const imperial = (await read($, settings)).units === 'imperial'
298
299  if (prof) {
300    await update($, draft, () => ({
301      step: 1,
302      age: String(prof.age),
303      weight: String(imperial ? Math.round(prof.weightKg / KG_PER_LB) : prof.weightKg),
304      height: prof.heightCm === null ? '' : String(imperial ? Math.round(prof.heightCm / CM_PER_IN) : prof.heightCm),
305      sex: prof.sex,
306      goal: prof.goal,
307      abilities: prof.abilities,
308      limits: prof.limits,
309      dailySets: prof.dailySets,
310      isQuiet: prof.isQuiet,
311      places: prof.places,
312      equipment: prof.equipment,
313      error: null,
314    }))
315  }
316
317  await update($, screen, () => 'onboarding')
318}
319
320const changeProfile = async ($: Dollar, change: Partial<Profile>) => {
321  const prof = await read($, profile)
322
323  if (!prof) return
324
325  await update($, profile, () => ({ ...prof, ...change }))
326  await persist($)
327  await rollNext($)
328}
329
330const exportData = async ($: Dollar, surface: 'terminal' | 'desktop' | 'vscode' | 'mobile') => {
331  const data = {
332    profile: await read($, profile),
333    progress: await read($, progress),
334    settings: await read($, settings),
335  }
336  const res = await $.ui.copy({ text: JSON.stringify(data, null, 2), surface })
337
338  await notify($, res.isCopied ? 'Your data was copied to the clipboard.' : 'Could not reach the clipboard here.', true)
339}
340
341const rollNext = async ($: Dollar, skip: string[] = []) => {
342  if (skip.length === 0) passed = []
343
344  const prof = await read($, profile)
345
346  if (!prof) {
347    await update($, upNext, () => null)
348
349    return
350  }
351
352  const now = await $.clock.now()
353  const prog = rollDay(await read($, progress), dayKey(now))
354
355  const place = await activePlace($)
356  const focus = (await read($, settings)).focus
357
358  await update($, upNext, () => pick(prof, prog, now, skip, place, focus).id)
359}
360
361const fireNudge = async ($: Dollar) => {
362  const prof = await read($, profile)
363  const now = await $.clock.now()
364  const until = await read($, focusUntil)
365
366  if (!prof || !(await read($, isBusy)) || (await read($, current))) return
367  if (until !== null && until > now) return
368
369  const prog = rollDay(await read($, progress), dayKey(now))
370
371  if (prog.today.sets >= prof.dailySets) return
372
373  const ex = pick(prof, prog, now, [], await activePlace($), (await read($, settings)).focus, true)
374
375  await update($, nudge, () => ex.id)
376  await update($, upNext, () => ex.id)
377  const lang = (await read($, settings)).language
378
379  await notify($, 'Claude is working. {secs}s of {name}? Open /workout', false, {
380    secs: secondsFor(ex, prof),
381    name: lang === 'tr' ? tr(lang, ex.name) : ex.name.toLowerCase(),
382  })
383}
384
385const begin = async ($: Dollar) => {
386  const cur = await read($, current)
387
388  if (!cur || cur.phase === 'running') return
389
390  await update($, current, c => (c ? { ...c, phase: 'running' as const } : c))
391  await update($, nudge, () => null)
392  void chime($, 'start')
393  ticker = $.clock.every(1000, async () => {
394    const c = await read($, current)
395
396    if (!c || c.phase !== 'running') return
397
398    const remaining = c.remaining - 1
399    const ex = byId(c.exerciseId)
400
401    const lang = (await read($, settings)).language
402
403    $.ui.status(`💪 ${ex ? tr(lang, ex.name) : tr(lang, 'Workout')} ${clock(Math.max(remaining, 0))}`)
404
405    if (remaining <= 0) {
406      await finish($, c.total)
407    } else {
408      if (remaining <= 3) void chime($, 'tick')
409      await update($, current, x => (x ? { ...x, remaining } : x))
410    }
411  })
412}
413
414const finish = async ($: Dollar, seconds: number) => {
415  const c = await read($, current)
416  const prof = await read($, profile)
417
418  stopTimer()
419  $.ui.status(undefined)
420
421  if (!c || !prof) return
422
423  const ex = byId(c.exerciseId)
424
425  if (ex) {
426    const now = await $.clock.now()
427    const day = dayKey(now)
428    const before = await read($, progress)
429    const kcal = kcalFor(ex.id, seconds, prof)
430    const done = recordSet(before, ex, seconds, prof.dailySets, day, {
431      hour: new Date(now).getHours(),
432      isDuringTurn: await read($, isBusy),
433      kcal,
434    })
435    const awarded = award(done.progress, day)
436    const levelAfter = levelOf(awarded.progress.xp)
437    const levelUp = levelAfter > levelOf(before.xp) ? levelAfter : null
438
439    await update($, progress, () => awarded.progress)
440    await update($, result, () => ({
441      exerciseId: ex.id,
442      seconds,
443      xpGained: done.xpGained,
444      kcal,
445      newBadges: awarded.earned,
446      isGoalReached: done.isGoalReached,
447      levelUp,
448      isRated: false,
449    }))
450    await persist($)
451    await enqueue($, ex.id, seconds, now)
452    await notify(
453      $,
454      done.isGoalReached ? 'Daily goal done! {streak} day streak · +{xp} XP' : 'Set done · +{xp} XP',
455      done.isGoalReached,
456      { streak: awarded.progress.streak, xp: done.xpGained },
457    )
458    void chime($, done.isGoalReached ? 'goal' : 'done')
459  }
460
461  await update($, current, () => null)
462  await rollNext($)
463  await update($, screen, () => 'result')
464}
465
466const rate = async ($: Dollar, rating: Rating) => {
467  const res = await read($, result)
468  const ex = res ? byId(res.exerciseId) : undefined
469
470  if (!res || !ex || res.isRated) return
471
472  const now = await $.clock.now()
473  const delta = rating === 'easy' ? 1 : rating === 'right' ? 0 : -1
474
475  await update($, progress, p => ({
476    ...p,
477    tweak: Math.max(-3, Math.min(3, p.tweak + delta)),
478    pain: rating === 'hurts' ? { ...p.pain, [ex.area]: now + WEEK_MS } : p.pain,
479  }))
480  await update($, result, r => (r ? { ...r, isRated: true } : r))
481  await persist($)
482  await rollNext($)
483
484  if (rating === 'hurts') {
485    const lang = (await read($, settings)).language
486
487    await notify($, 'Skipping {area} moves for a week. If it keeps hurting, see a doctor.', true, { area: lang === 'tr' ? tr(lang, ex.area).charAt(0).toUpperCase() + tr(lang, ex.area).slice(1) : ex.area })
488  } else if (rating === 'easy') {
489    await notify($, 'Noted, next sets get a bit harder.')
490  } else if (rating === 'hard') {
491    await notify($, 'Noted, next sets get a bit easier.')
492  }
493}
494
495const cancelSet = async ($: Dollar) => {
496  stopTimer()
497  $.ui.status(undefined)
498  await update($, current, () => null)
499  await update($, screen, () => 'home')
500}
501
502const startSet = async ($: Dollar, skip: string[] = [], preferId?: string) => {
503  const prof = await read($, profile)
504
505  if (!prof) {
506    await update($, screen, () => 'onboarding')
507
508    return
509  }
510
511  const now = await $.clock.now()
512  const wanted = preferId ?? (skip.length === 0 ? ((await read($, upNext)) ?? undefined) : undefined)
513  const ex = (wanted ? byId(wanted) : undefined) ?? pick(prof, await read($, progress), now, skip, await activePlace($), (await read($, settings)).focus)
514  const total = secondsFor(ex, prof)
515
516  stopTimer()
517  await update($, nudge, () => null)
518  await update($, current, () => ({ exerciseId: ex.id, phase: 'ready' as const, total, remaining: total }))
519  await update($, screen, () => 'workout')
520}
521
522/** A profile saved by an older release gets the newer fields. */
523const withGear = (p: Profile): Profile => ({
524  ...p,
525  sex: p.sex ?? 'unspecified',
526  places: p.places?.length ? p.places : ['office', 'home'],
527  equipment: p.equipment ?? [],
528})
529
530/** Where the person is now: their pick if that is a place they train at, else the first one they do. */
531const activePlace = async ($: Dollar): Promise<Place> => {
532  const prof = await read($, profile)
533  const chosen = (await read($, settings)).place
534
535  return prof && !prof.places.includes(chosen) ? (prof.places[0] ?? 'office') : chosen
536}
537
538const setPlace = async ($: Dollar, place: Place) => {
539  await saveSettings($, { place })
540  await rollNext($)
541}
542
543/** Marks a text for translation without translating it yet. */
544const msg = (text: string): string => text
545
546/** The problem with what was entered on a step, as English text to translate, or null. */
547const validateStep = async ($: Dollar, step: number): Promise<string | null> => {
548  const d = await read($, draft)
549  const imperial = (await read($, settings)).units === 'imperial'
550
551  if (step === 1) {
552    const age = Number(d.age)
553
554    if (!Number.isFinite(age) || age < 16 || age > 90) return msg('DeskFit is for ages 16 to 90.')
555  }
556
557  if (step === 2) {
558    const weightKg = imperial ? Number(d.weight) * KG_PER_LB : Number(d.weight)
559    const heightCm = d.height.trim() === '' ? null : imperial ? Number(d.height) * CM_PER_IN : Number(d.height)
560
561    if (!Number.isFinite(weightKg) || weightKg < 30 || weightKg > 300) return msg('Weight should be 30 to 300 kg.')
562    if (heightCm !== null && (!Number.isFinite(heightCm) || heightCm < 120 || heightCm > 230)) {
563      return msg('Height should be 120 to 230 cm, or left empty.')
564    }
565  }
566
567  if (step === 5 && d.places.length === 0) return msg('Pick at least one place.')
568
569  return null
570}
571
572const submitOnboarding = async ($: Dollar) => {
573  // Every earlier step is checked again, so a profile is never saved half valid.
574  for (const step of [1, 2, 5]) {
575    const message = await validateStep($, step)
576
577    if (message) {
578      const error = await tx($, message)
579
580      await update($, draft, x => ({ ...x, step, error }))
581
582      return
583    }
584  }
585
586  const d = await read($, draft)
587  const imperial = (await read($, settings)).units === 'imperial'
588  const weightKg = imperial ? Number(d.weight) * KG_PER_LB : Number(d.weight)
589  const heightCm = d.height.trim() === '' ? null : imperial ? Number(d.height) * CM_PER_IN : Number(d.height)
590  const prof: Profile = {
591    age: Number(d.age),
592    weightKg: Math.round(weightKg * 10) / 10,
593    heightCm: heightCm === null ? null : Math.round(heightCm),
594    sex: d.sex,
595    goal: d.goal,
596    abilities: d.abilities,
597    limits: d.limits,
598    dailySets: d.dailySets,
599    isQuiet: d.isQuiet,
600    places: d.places,
601    equipment: d.equipment,
602  }
603  const day = dayKey(await $.clock.now())
604
605  await update($, profile, () => prof)
606  await update($, progress, p => rollDay(p.today.day === '' ? emptyProgress(day) : p, day))
607  await persist($)
608  await update($, draft, () => emptyDraft)
609  await rollNext($)
610  await update($, screen, () => 'home')
611
612  const lang = (await read($, settings)).language
613
614  await notify($, 'You are set. {level} workouts are ready.', true, { level: tr(lang, levelFor(prof)) })
615}
616
617const nextStep = async ($: Dollar) => {
618  const d = await read($, draft)
619  const message = await validateStep($, d.step)
620
621  if (message) {
622    const error = await tx($, message)
623
624    await update($, draft, x => ({ ...x, error }))
625
626    return
627  }
628
629  if (d.step >= STEPS - 1) {
630    await submitOnboarding($)
631  } else {
632    await update($, draft, x => ({ ...x, step: x.step + 1, error: null }))
633  }
634}
635
636const setFocus = async ($: Dollar, ms: number | null) => {
637  const until = ms === null ? null : (await $.clock.now()) + ms
638
639  await update($, focusUntil, () => until)
640  await update($, nudge, () => null)
641  await $.store.set('focusUntil', until)
642}
643
644type Creds = { userId: string; secret: string }
645type Reply = { ok: boolean; status: number; data: any }
646type Queued = { id: string; exercise: string; seconds: number; at: number }
647
648const patch = ($: Dollar, p: Partial<Social>) => update($, social, s => ({ ...s, ...p }))
649
650const credentials = async ($: Dollar): Promise<Creds | null> => {
651  const who = await read($, identity)
652  const secret = (await $.store.get('secret')) as string | undefined
653
654  return who && secret ? { userId: who.userId, secret } : null
655}
656
657/** One request to the backend. `creds` undefined signs with the stored account, null sends unsigned. */
658const call = async ($: Dollar, method: string, path: string, body?: unknown, creds?: Creds | null): Promise<Reply> => {
659  const st = await read($, settings)
660  const backendUrl = backendFor(st)
661
662  if (!backendUrl) return { ok: false, status: 0, data: { error: tr(st.language, 'No backend is configured.') } }
663  if (!isSecureUrl(backendUrl)) return { ok: false, status: 0, data: { error: tr(st.language, 'The server address must start with https:// (http:// only works for localhost).') } }
664
665  const text = body === undefined ? '' : JSON.stringify(body)
666  const c = creds === undefined ? await credentials($) : creds
667  const headers: Record<string, string> = { 'content-type': 'application/json' }
668
669  if (c) {
670    Object.assign(
671      headers,
672      await signHeaders(c.userId, c.secret, method, path, text, await $.clock.now(), randomHex(12)),
673    )
674  }
675
676  try {
677    const res = await $.http.fetch(backendUrl + path, { method, headers, body: text === '' ? undefined : text })
678    let data: any = null
679
680    try {
681      data = JSON.parse(res.text)
682    } catch {
683      data = null
684    }
685
686    if (data && typeof data.error === 'string') data.error = tr(st.language, data.error)
687
688    return { ok: res.ok, status: res.status, data }
689  } catch {
690    return { ok: false, status: 0, data: { error: tr(st.language, 'Could not reach the leaderboard server.') } }
691  }
692}
693
694const flush = async ($: Dollar) => {
695  for (let round = 0; round < 5; round++) {
696    if (!(await read($, identity))) return
697
698    const queue = ((await $.store.get('queue')) as Queued[] | undefined) ?? []
699
700    if (queue.length === 0) return
701
702    const batch = queue.slice(0, 20)
703    const reply = await call($, 'POST', '/v1/events', { events: batch })
704
705    if (!reply.ok) return
706
707    const sent = new Set(batch.map(b => b.id))
708
709    await $.store.set('queue', queue.filter(q => !sent.has(q.id)))
710  }
711}
712
713const enqueue = async ($: Dollar, exercise: string, seconds: number, at: number) => {
714  if (!backendFor(await read($, settings)) || !(await read($, identity))) return
715
716  const queue = ((await $.store.get('queue')) as Queued[] | undefined) ?? []
717
718  await $.store.set('queue', [...queue, { id: crypto.randomUUID(), exercise, seconds, at }].slice(-200))
719  void flush($)
720}
721
722const refreshBoard = async ($: Dollar) => {
723  const who = await read($, identity)
724  const s = await read($, social)
725
726  await patch($, { isLoading: true, error: null })
727
728  let me = s.me
729  let teams = s.teams
730  let activeTeam = s.activeTeam
731  let teamInfo = s.teamInfo
732  let teamTotal = s.teamTotal
733
734  if (who) {
735    const mine = await call($, 'GET', '/v1/me')
736
737    if (mine.ok) {
738      me = { weekPoints: mine.data.weekPoints, allPoints: mine.data.allPoints, weekRank: mine.data.weekRank }
739      teams = mine.data.teams ?? []
740      if (!teams.some(t => t.id === activeTeam)) activeTeam = teams[0]?.id ?? null
741    } else if (mine.status === 401) {
742      await patch($, { isLoading: false, error: await tx($, 'This device is not signed in anymore. Use your recovery code.') })
743
744      return
745    }
746  }
747
748  let rows: BoardRow[] = []
749  let error: string | null = null
750
751  if (s.tab === 'teams') {
752    if (activeTeam) {
753      const r = await call($, 'GET', `/v1/teams/${activeTeam}/leaderboard?period=${s.teamPeriod}`)
754
755      if (r.ok) {
756        rows = r.data.rows
757        teamInfo = r.data.team ?? null
758        teamTotal = r.data.total ?? 0
759      } else {
760        error = r.data?.error ?? (await tx($, 'Could not load the team board.'))
761      }
762    } else {
763      teamInfo = null
764    }
765  } else {
766    const r = await call($, 'GET', `/v1/leaderboard?period=${s.tab}&limit=20`, undefined, null)
767
768    if (r.ok) rows = r.data.rows
769    else error = r.data?.error ?? (await tx($, 'Could not load the leaderboard.'))
770  }
771
772  await patch($, { isLoading: false, error, rows, me, teams, activeTeam, teamInfo, teamTotal })
773}
774
775const openBoard = async ($: Dollar) => {
776  await update($, screen, () => 'board')
777  await patch($, { view: 'board', notice: null, error: null, recoveryCode: null, isConfirmingDelete: false })
778  void flush($)
779
780  if (backendFor(await read($, settings))) await refreshBoard($)
781}
782
783const setTab = async ($: Dollar, tab: Social['tab']) => {
784  await patch($, { tab, rows: [] })
785  await refreshBoard($)
786}
787
788const signup = async ($: Dollar) => {
789  const nickname = (await read($, social)).form.nickname.trim()
790
791  if (!/^[A-Za-z0-9_-]{3,20}$/.test(nickname)) {
792    await patch($, { error: await tx($, 'Nickname: 3-20 letters, digits, _ or -. Pick something that is not your real name.') })
793
794    return
795  }
796
797  const secret = randomHex(32)
798  const reply = await call($, 'POST', '/v1/register', { nickname, secret }, null)
799
800  if (!reply.ok) {
801    await patch($, { error: reply.data?.error ?? (await tx($, 'Sign-up failed.')) })
802
803    return
804  }
805
806  const who: Identity = { userId: reply.data.userId, nickname }
807
808  await $.store.set('secret', secret)
809  await $.store.set('identity', who)
810  await update($, identity, () => who)
811  await patch($, { error: null, notice: await tx($, 'Welcome, {name}! Save your recovery code from Account.', { name: nickname }) })
812  await refreshBoard($)
813}
814
815const recover = async ($: Dollar) => {
816  const decoded = decodeRecovery((await read($, social)).form.recovery)
817
818  if (!decoded) {
819    await patch($, { error: await tx($, 'That recovery code is not valid.') })
820
821    return
822  }
823
824  const reply = await call($, 'GET', '/v1/me', undefined, { userId: decoded.userId, secret: decoded.secret })
825
826  if (!reply.ok) {
827    await patch($, { error: reply.data?.error ?? (await tx($, 'Recovery failed.')) })
828
829    return
830  }
831
832  const who: Identity = { userId: decoded.userId, nickname: reply.data.nickname }
833
834  await $.store.set('secret', decoded.secret)
835  await $.store.set('identity', who)
836  await update($, identity, () => who)
837  await patch($, { error: null, notice: await tx($, 'Welcome back, {name}.', { name: who.nickname }) })
838  await refreshBoard($)
839}
840
841const showRecovery = async ($: Dollar) => {
842  const who = await read($, identity)
843  const secret = (await $.store.get('secret')) as string | undefined
844
845  if (who && secret) await patch($, { recoveryCode: encodeRecovery(who.userId, secret) })
846}
847
848const createTeam = async ($: Dollar) => {
849  const name = (await read($, social)).form.teamName.trim()
850  const reply = await call($, 'POST', '/v1/teams', { name })
851
852  if (!reply.ok) {
853    await patch($, { error: reply.data?.error ?? (await tx($, 'Could not create the team.')) })
854
855    return
856  }
857
858  await patch($, {
859    activeTeam: reply.data.id,
860    notice: await tx($, 'Team created. Invite code: {code}', { code: reply.data.inviteCode }),
861    error: null,
862    form: { ...(await read($, social)).form, teamName: '' },
863  })
864  await refreshBoard($)
865}
866
867const joinTeam = async ($: Dollar) => {
868  const inviteCode = (await read($, social)).form.invite.trim()
869  const reply = await call($, 'POST', '/v1/teams/join', { inviteCode })
870
871  if (!reply.ok) {
872    await patch($, { error: reply.data?.error ?? (await tx($, 'Could not join the team.')) })
873
874    return
875  }
876
877  await patch($, {
878    activeTeam: reply.data.id,
879    notice: await tx($, 'Joined {name}.', { name: reply.data.name }),
880    error: null,
881    form: { ...(await read($, social)).form, invite: '' },
882  })
883  await refreshBoard($)
884}
885
886const leaveTeam = async ($: Dollar, id: string) => {
887  const reply = await call($, 'DELETE', `/v1/teams/${id}`)
888
889  await patch(
890    $,
891    reply.ok ? { activeTeam: null, notice: await tx($, 'You left the team.'), error: null } : { error: await tx($, 'Could not leave.') },
892  )
893  await refreshBoard($)
894}
895
896const setTeamPeriod = async ($: Dollar, teamPeriod: Social['teamPeriod']) => {
897  await patch($, { teamPeriod, rows: [] })
898  await refreshBoard($)
899}
900
901const copyInvite = async ($: Dollar, surface: 'terminal' | 'desktop' | 'vscode' | 'mobile') => {
902  const code = (await read($, social)).teamInfo?.inviteCode
903
904  if (!code) return
905
906  const res = await $.ui.copy({ text: code, surface })
907
908  await patch($, { notice: await tx($, res.isCopied ? 'Invite code copied.' : 'Could not reach the clipboard here.') })
909}
910
911const rotateInvite = async ($: Dollar) => {
912  const id = (await read($, social)).activeTeam
913
914  if (!id) return
915
916  const reply = await call($, 'POST', `/v1/teams/${id}/rotate-code`, {})
917
918  if (!reply.ok) {
919    await patch($, { error: reply.data?.error ?? (await tx($, 'Could not do that.')) })
920
921    return
922  }
923
924  await patch($, { notice: await tx($, 'New invite code: {code}', { code: reply.data.inviteCode }), error: null })
925  await refreshBoard($)
926}
927
928const removeFromTeam = async ($: Dollar, nickname: string) => {
929  const id = (await read($, social)).activeTeam
930
931  if (!id) return
932
933  const reply = await call($, 'POST', `/v1/teams/${id}/remove`, { nickname })
934
935  await patch(
936    $,
937    reply.ok
938      ? { notice: await tx($, '{name} was removed.', { name: nickname }), error: null }
939      : { error: reply.data?.error ?? (await tx($, 'Could not do that.')) },
940  )
941  await refreshBoard($)
942}
943
944const disbandTeam = async ($: Dollar) => {
945  const soc = await read($, social)
946
947  if (!soc.activeTeam) return
948
949  if (!soc.isConfirmingDisband) {
950    await patch($, { isConfirmingDisband: true })
951
952    return
953  }
954
955  const reply = await call($, 'DELETE', `/v1/teams/${soc.activeTeam}?disband=1`)
956
957  await patch(
958    $,
959    reply.ok
960      ? { activeTeam: null, teamInfo: null, isConfirmingDisband: false, notice: await tx($, 'Team closed.'), error: null }
961      : { isConfirmingDisband: false, error: reply.data?.error ?? (await tx($, 'Could not do that.')) },
962  )
963  await refreshBoard($)
964}
965
966const deleteAccount = async ($: Dollar) => {
967  if (!(await read($, social)).isConfirmingDelete) {
968    await patch($, { isConfirmingDelete: true })
969
970    return
971  }
972
973  const reply = await call($, 'DELETE', '/v1/me')
974
975  if (!reply.ok) {
976    await patch($, { error: reply.data?.error ?? (await tx($, 'Could not delete the account.')), isConfirmingDelete: false })
977
978    return
979  }
980
981  await $.store.delete('secret')
982  await $.store.delete('identity')
983  await $.store.delete('queue')
984  await update($, identity, () => null)
985  const notice = await tx($, 'Your leaderboard account and its data were deleted.')
986
987  await update($, social, () => ({ ...emptySocial, notice }))
988}
989
990/**
991 * Opens the pane and asks for the keyboard. The first request is refused while the
992 * slash command still sits in the composer, so it is asked again once that is empty;
993 * without the keyboard the first click only focuses the pane and does not press.
994 */
995const openPane = async ($: Dollar) => {
996  await $.ui.open({ id: PANE, title: 'DeskFit', focus: true, closeOnEscape: true })
997  $.clock.after(400, () => void $.ui.open({ id: PANE, title: 'DeskFit', focus: true, closeOnEscape: true }))
998}
999
1000export const register: Register = (on, options) => {
1001  const seconds = Number(options.nudgeAfterSeconds)
1002
1003  // an admin's `backendUrl` option replaces the built-in public server; left empty it falls back to it
1004  const configured = String(options.backendUrl ?? '').trim()
1005
1006  seed = {
1007    ...(configured ? { backendUrl: configured } : {}),
1008    ...(Number.isFinite(seconds) && seconds >= 5 ? { nudgeAfterSeconds: seconds } : {}),
1009  }
1010
1011  on('session.start', async ($, e, next) => {
1012    const stored = (await $.store.get('profile')) as Profile | undefined
1013    const saved = (await $.store.get('progress')) as Progress | undefined
1014    const focus = (await $.store.get('focusUntil')) as number | null | undefined
1015    const day = dayKey(await $.clock.now())
1016    const prefs = withDefaults((await $.store.get('settings')) as Partial<DeskSettings> | undefined, seed)
1017
1018    // The look changed in this release: a colour saved by an older one gives way once.
1019    const uiVersion = Number((await $.store.get('uiVersion')) ?? 0)
1020
1021    if (uiVersion < UI_VERSION) {
1022      // Ink & Signal arrived in version 5: everyone starts with its colour, and can switch in Settings
1023      if (uiVersion < 5) prefs.accent = 'signal'
1024
1025      // the shared board is connected from the first launch (version 6); an address saved empty by an older build gives way once
1026      if (!prefs.backendUrl) prefs.backendUrl = seed.backendUrl ?? DEFAULT_SETTINGS.backendUrl
1027
1028      // line art arrived in version 4: everyone starts with it, and can switch back in Settings
1029      if (uiVersion < 4) prefs.illustrations = 'line'
1030      await $.store.set('settings', prefs)
1031      await $.store.set('uiVersion', UI_VERSION)
1032    }
1033
1034    // The first public build pointed at the website's own address; the API lives on its own host now.
1035    if (RETIRED_BACKENDS.includes(backendFor(prefs))) {
1036      prefs.backendUrl = seed.backendUrl ?? ''
1037      await $.store.set('settings', prefs)
1038    }
1039
1040    await $.command.register({
1041      name: 'workout',
1042      description: tr(prefs.language, 'DeskFit: micro-workouts while Claude works (/workout, stats, board, settings, focus 2h, reset)'),
1043    })
1044    await update($, settings, () => prefs)
1045    setTheme(prefs.accent)
1046    await update($, profile, () => (stored ? withGear(stored) : null))
1047    await update($, progress, () => normalize(saved, day))
1048    await update($, focusUntil, () => focus ?? null)
1049    const savedIdentity = ((await $.store.get('identity')) as Identity | undefined) ?? null
1050
1051    await update($, identity, () => savedIdentity)
1052    void flush($)
1053    // the shared board is ready before the first look at it
1054    if (backendFor(prefs)) void refreshBoard($)
1055    await rollNext($)
1056    await update($, screen, () => (stored ? 'home' : 'onboarding'))
1057    await update($, isLoaded, () => true)
1058
1059    return next(e)
1060  })
1061
1062  on('session.end', async ($, e, next) => {
1063    stopTimer()
1064    stopNudge()
1065    $.ui.status(undefined)
1066
1067    return next(e)
1068  })
1069
1070  on('ui.press', { plugin: 'deskfit' }, async ($, e, next) => {
1071    if (isDebug) $.ui.log(`deskfit press: ${e.element} from ${e.surface}`)
1072
1073    return next(e)
1074  })
1075
1076  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
1077    if (isDebug) $.ui.log(`deskfit focus: ${e.element ?? '(engine)'} by ${e.origin.kind}`)
1078
1079    return next(e)
1080  })
1081
1082  on('turn.start', async ($, e, next) => {
1083    await update($, isBusy, () => true)
1084    await update($, nudge, () => null)
1085    stopNudge()
1086
1087    const st = await read($, settings)
1088
1089    if (st.nudges) nudgeTimer = $.clock.after(st.nudgeAfterSeconds * 1000, () => void fireNudge($))
1090
1091    return next(e)
1092  })
1093
1094  on('turn.complete', async ($, e, next) => {
1095    if (e.agentId) return next(e)
1096
1097    stopNudge()
1098    await update($, isBusy, () => false)
1099    await update($, nudge, () => null)
1100
1101    if ((await read($, current))?.phase === 'running') {
1102      await notify($, 'Claude finished. Wrap up your set, then check the answer.')
1103    }
1104
1105    return next(e)
1106  })
1107
1108  on('command.run', { command: 'workout' }, async ($, e) => {
1109    const arg = e.args.trim().toLowerCase()
1110
1111    if (arg === 'reset') {
1112      stopTimer()
1113      $.ui.status(undefined)
1114      await $.store.delete('profile')
1115      await $.store.delete('progress')
1116      await $.store.delete('focusUntil')
1117      await $.store.delete('secret')
1118      await $.store.delete('identity')
1119      await $.store.delete('queue')
1120      await update($, identity, () => null)
1121      await update($, social, () => emptySocial)
1122      await update($, upNext, () => null)
1123      await update($, profile, () => null)
1124      await update($, progress, () => emptyProgress(dayKey(Date.now())))
1125      await update($, draft, () => emptyDraft)
1126      await update($, current, () => null)
1127      await update($, result, () => null)
1128      await update($, focusUntil, () => null)
1129      await update($, screen, () => 'onboarding')
1130      await openPane($)
1131
1132      return { text: await tx($, 'DeskFit data erased from this device. Starting over.') }
1133    }
1134
1135    if (arg.startsWith('focus')) {
1136      const what = arg.slice(5).trim()
1137
1138      if (what === 'off') {
1139        await setFocus($, null)
1140
1141        return { text: await tx($, 'Focus mode off. DeskFit may suggest sets again.') }
1142      }
1143
1144      const ms = parseFocus(what)
1145
1146      if (ms === null) return { text: await tx($, 'Usage: /workout focus 90m, /workout focus 2h or /workout focus off') }
1147
1148      await setFocus($, ms)
1149
1150      return { text: await tx($, 'Focus mode on for {time}. No suggestions until then.', { time: what }) }
1151    }
1152
1153    if (arg === 'debug') {
1154      isDebug = !isDebug
1155
1156      return { text: await tx($, isDebug ? 'DeskFit debug on: clicks and focus moves are logged.' : 'DeskFit debug off.') }
1157    }
1158
1159    if (arg === 'home') await update($, screen, () => 'home')
1160    if (arg === 'moves') await update($, screen, () => 'moves')
1161    if (arg === 'stats') await update($, screen, () => 'stats')
1162    if (arg === 'board') await openBoard($)
1163    if (arg === 'settings') await update($, screen, () => 'settings')
1164
1165    await openPane($)
1166
1167    return { text: await tx($, 'DeskFit opened.') }
1168  })
1169
1170  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1171    const { Box, Text, Button, Input, Select, Svg } = $.ui.resolve(e) as any
1172    const hasSvg = e.surface !== 'terminal'
1173    const st = await read($, settings)
1174    const backendUrl = backendFor(st)
1175    const isImperial = st.units === 'imperial'
1176
1177    setTheme(st.accent)
1178
1179    const T = (text: string, vars?: Vars): string => tr(st.language, text, vars)
1180    const opts = <O extends { label: string }>(list: O[]): O[] => list.map(o => ({ ...o, label: T(o.label) }))
1181    /** Line art is square, the figures from the rig are 6:5. */
1182    const figH = (w: number): number => (st.illustrations === 'line' ? w : Math.round((w * 100) / 120))
1183
1184    /** An icon (drawn on surfaces with Svg, a glyph elsewhere) beside a title. */
1185    const head = (icon: IconName, text: string, isBold = true) => (
1186      <Box gap={1} alignItems="center">
1187        {hasSvg ? <Svg source={iconSvg(icon, 18)} alt={text} /> : <Text>{ICON_TEXT[icon]}</Text>}
1188        <Text bold={isBold}>{text}</Text>
1189      </Box>
1190    )
1191
1192    if (!(await read($, isLoaded))) return <Text dimColor>{T('Loading DeskFit…')}</Text>
1193
1194    const scr = await read($, screen)
1195    const prof = await read($, profile)
1196    const prog = await read($, progress)
1197    const now = Date.now()
1198    const day = dayKey(now)
1199    const today = prog.today.day === day ? prog.today : { day, sets: 0, seconds: 0 }
1200
hooks/art.ts 1225 lines
1// Drawings, all generated from code so they need no image files or licences.
2// One skeleton rig drives every exercise: a pair of poses per move, drawn as an
3// animated SVG (desktop, vscode, mobile) or as two braille frames (terminal).
4
5import { AI_ART } from './art-ai'
6import { WG_ART, WG_EVEN } from './art-wg'
7import type { IllustrationArt } from './art-ai'
8import { byId } from './catalog'
9
10type Pt = [number, number]
11type Joint = 'head' | 'sh' | 'hip' | 'eN' | 'hN' | 'eF' | 'hF' | 'kN' | 'fN' | 'kF' | 'fF'
12type Pose = Record<Joint, Pt>
13type Scene = 'none' | 'chair' | 'desk' | 'wall' | 'far' | 'mat' | 'bar' | 'wallR'
14
15const J: Joint[] = ['head', 'sh', 'hip', 'eN', 'hN', 'eF', 'hF', 'kN', 'fN', 'kF', 'fF']
16
17const p = (o: Partial<Pose>, base: Pose): Pose => ({ ...base, ...o })
18
19const STAND: Pose = {
20  head: [60, 17], sh: [60, 28], hip: [60, 52],
21  eN: [60, 40], hN: [60, 52], eF: [58, 40], hF: [58, 52],
22  kN: [60, 71], fN: [63, 90], kF: [60, 71], fF: [57, 90],
23}
24
25const ARMS_UP = p({ eN: [66, 18], hN: [64, 6], eF: [54, 18], hF: [56, 6] }, STAND)
26const WIDE_JACK = p(
27  { eN: [66, 18], hN: [64, 6], eF: [54, 18], hF: [56, 6], kN: [65, 71], fN: [71, 90], kF: [55, 71], fF: [49, 90] },
28  STAND,
29)
30const SQUAT: Pose = {
31  head: [55, 34], sh: [52, 44], hip: [43, 64],
32  eN: [64, 45], hN: [77, 45], eF: [63, 46], hF: [76, 46],
33  kN: [62, 69], fN: [61, 90], kF: [62, 69], fF: [57, 90],
34}
35const JUMP = p(
36  {
37    head: [60, 7], sh: [60, 18], hip: [60, 42], eN: [72, 20], hN: [84, 18], eF: [71, 21], hF: [83, 19],
38    kN: [62, 60], fN: [58, 78], kF: [60, 60], fF: [54, 78],
39  },
40  STAND,
41)
42const CALF_UP = p(
43  {
44    head: [60, 12], sh: [60, 23], hip: [60, 47], eN: [60, 35], hN: [60, 47], eF: [58, 35], hF: [58, 47],
45    kN: [60, 66], fN: [63, 90], kF: [60, 66], fF: [57, 90],
46  },
47  STAND,
48)
49const GLUTE = p({ head: [59, 17], sh: [59, 28], hip: [63, 52], hN: [64, 52], hF: [62, 52], kN: [61, 71] }, STAND)
50
51const WALL_STAND: Pose = {
52  head: [32, 17], sh: [32, 28], hip: [32, 52],
53  eN: [34, 40], hN: [35, 52], eF: [33, 40], hF: [34, 52],
54  kN: [32, 71], fN: [34, 90], kF: [32, 71], fF: [30, 90],
55}
56const WALL_SIT: Pose = {
57  head: [32, 31], sh: [32, 42], hip: [32, 66],
58  eN: [42, 54], hN: [50, 64], eF: [41, 55], hF: [49, 65],
59  kN: [54, 66], fN: [54, 90], kF: [54, 66], fF: [50, 90],
60}
61const ANGEL_DOWN = p({ head: [32, 17], eN: [30, 40], hN: [30, 52], eF: [30, 40], hF: [30, 52] }, WALL_STAND)
62const ANGEL_UP = p({ eN: [29, 18], hN: [29, 6], eF: [29, 18], hF: [29, 6] }, WALL_STAND)
63
64const LUNGE: Pose = {
65  head: [61, 25], sh: [60, 36], hip: [58, 60],
66  eN: [62, 48], hN: [58, 58], eF: [60, 48], hF: [56, 58],
67  kN: [74, 66], fN: [73, 90], kF: [44, 78], fF: [30, 90],
68}
69
70const PUSH_UP: Pose = {
71  head: [89, 57], sh: [78, 60], hip: [46, 72],
72  eN: [78, 74], hN: [78, 88], eF: [77, 74], hF: [77, 88],
73  kN: [30, 80], fN: [14, 88], kF: [30, 80], fF: [14, 88],
74}
75const PUSH_DOWN: Pose = {
76  head: [89, 76], sh: [78, 78], hip: [46, 83],
77  eN: [64, 84], hN: [78, 88], eF: [63, 84], hF: [77, 88],
78  kN: [30, 86], fN: [14, 88], kF: [30, 86], fF: [14, 88],
79}
80const DESK_UP: Pose = {
81  head: [90, 34], sh: [78, 40], hip: [48, 65],
82  eN: [80, 49], hN: [83, 57], eF: [79, 49], hF: [82, 57],
83  kN: [34, 77], fN: [20, 90], kF: [34, 77], fF: [20, 90],
84}
85const DESK_DOWN: Pose = {
86  head: [92, 46], sh: [80, 50], hip: [48, 71],
87  eN: [70, 56], hN: [83, 57], eF: [69, 56], hF: [82, 57],
88  kN: [34, 81], fN: [20, 90], kF: [34, 81], fF: [20, 90],
89}
90const PLANK: Pose = {
91  head: [89, 67], sh: [78, 70], hip: [46, 78],
92  eN: [78, 86], hN: [92, 86], eF: [77, 86], hF: [91, 86],
93  kN: [30, 83], fN: [14, 88], kF: [30, 83], fF: [14, 88],
94}
95const PLANK_HIGH = p({ hip: [46, 74], head: [89, 66], sh: [78, 69] }, PLANK)
96const KNEE_PLANK: Pose = {
97  head: [89, 67], sh: [78, 70], hip: [52, 78],
98  eN: [78, 86], hN: [92, 86], eF: [77, 86], hF: [91, 86],
99  kN: [36, 87], fN: [22, 87], kF: [36, 87], fF: [22, 87],
100}
101const KNEE_PLANK_HIGH = p({ hip: [52, 75] }, KNEE_PLANK)
102const CLIMB_A = p({ kN: [30, 80], fN: [14, 88], kF: [64, 72], fF: [58, 86] }, PUSH_UP)
103const CLIMB_B = p({ kN: [64, 72], fN: [58, 86], kF: [30, 80], fF: [14, 88] }, PUSH_UP)
104
105const SIT: Pose = {
106  head: [41, 31], sh: [40, 42], hip: [40, 66],
107  eN: [49, 54], hN: [56, 62], eF: [48, 55], hF: [55, 63],
108  kN: [58, 66], fN: [58, 90], kF: [57, 66], fF: [55, 90],
109}
110const SIT_KNEE = p({ sh: [38, 43], head: [39, 32], eN: [46, 56], hN: [44, 66], kN: [57, 57], fN: [53, 76] }, SIT)
111const SIT_ARCH = p({ head: [48, 31], sh: [44, 42], hN: [56, 62] }, SIT)
112const SIT_ROUND = p({ head: [37, 37], sh: [36, 43], eN: [46, 54], hN: [56, 62] }, SIT)
113const SIT_TWIST_A = p({ eN: [52, 42], hN: [64, 40], eF: [51, 43], hF: [63, 42] }, SIT)
114const SIT_TWIST_B = p({ eN: [34, 40], hN: [24, 34], eF: [35, 41], hF: [26, 38], head: [38, 31] }, SIT)
115
116const DIP_UP: Pose = {
117  head: [32, 34], sh: [33, 45], hip: [42, 70],
118  eN: [31, 56], hN: [30, 67], eF: [32, 56], hF: [31, 67],
119  kN: [60, 76], fN: [78, 88], kF: [60, 76], fF: [78, 88],
120}
121const DIP_DOWN: Pose = {
122  head: [32, 47], sh: [33, 58], hip: [42, 80],
123  eN: [24, 64], hN: [30, 67], eF: [25, 64], hF: [31, 67],
124  kN: [60, 82], fN: [78, 88], kF: [60, 82], fF: [78, 88],
125}
126
127const HINGE: Pose = {
128  head: [80, 41], sh: [70, 44], hip: [48, 54],
129  eN: [60, 50], hN: [50, 54], eF: [59, 50], hF: [49, 54],
130  kN: [54, 72], fN: [58, 90], kF: [54, 72], fF: [55, 90],
131}
132const HANDS_HIPS = p({ eN: [52, 40], hN: [58, 52], eF: [51, 40], hF: [57, 52] }, STAND)
133
134const NECK_FWD = p({ head: [66, 22], sh: [60, 28] }, STAND)
135const NECK_BACK = p({ head: [54, 13], sh: [60, 28] }, STAND)
136const TUCK_FWD = p({ head: [68, 18] }, STAND)
137const TUCK_BACK = p({ head: [57, 18] }, STAND)
138const SHRUG_A = p({ sh: [64, 23], head: [62, 12], eN: [64, 36], hN: [64, 48], eF: [62, 36] }, STAND)
139const SHRUG_B = p({ sh: [55, 32], head: [57, 21], eN: [55, 44], hN: [55, 56], eF: [53, 44] }, STAND)
140const WRIST_UP = p({ eN: [70, 36], hN: [80, 29], eF: [69, 37], hF: [79, 30] }, STAND)
141const WRIST_DOWN = p({ eN: [70, 36], hN: [80, 45], eF: [69, 37], hF: [79, 46] }, STAND)
142const PRAYER_A = p({ eN: [68, 40], hN: [74, 30], eF: [67, 41], hF: [73, 31] }, STAND)
143const PRAYER_B = p({ eN: [70, 40], hN: [82, 42], eF: [69, 41], hF: [81, 43] }, STAND)
144const MARCH_A = p({ kN: [74, 56], fN: [72, 73], eN: [54, 40], hN: [50, 50], eF: [66, 38], hF: [72, 44] }, STAND)
145const MARCH_B = p({ kF: [74, 56], fF: [72, 73], eF: [54, 40], hF: [50, 50], eN: [66, 38], hN: [72, 44] }, STAND)
146const CRUNCH_A = p({ eN: [52, 18], hN: [58, 16], eF: [52, 18], hF: [57, 17] }, STAND)
147const CRUNCH_B = p(
148  { sh: [63, 30], head: [64, 19], eN: [58, 36], hN: [62, 24], eF: [58, 36], hF: [61, 25], kN: [74, 56], fN: [70, 74] },
149  CRUNCH_A,
150)
151const EYE_NEAR = p({ head: [61, 18] }, STAND)
152
153
154// ----------------------------------------------------------------- new moves
155const shift = (pose: Pose, dx: number, dy = 0): Pose =>
156  Object.fromEntries(J.map(j => [j, [pose[j][0] + dx, pose[j][1] + dy] as Pt])) as Pose
157
158// office
159const LEG_OUT = p({ kN: [58, 66], fN: [79, 64] }, SIT)
160const FOUR_A = SIT
161const FOUR_B = p({ kN: [66, 60], fN: [54, 64], sh: [46, 44], head: [49, 33], eN: [56, 56], hN: [60, 62] }, SIT)
162const QUAD_B = p({ kN: [60, 72], fN: [42, 64], hN: [44, 63], eN: [58, 52] }, STAND)
163const KICK_B = p({ kN: [47, 64], fN: [34, 76], sh: [62, 28], head: [63, 17] }, STAND)
164const WALL_PU_A: Pose = {
165  head: [80, 34], sh: [74, 42], hip: [64, 66],
166  eN: [86, 42], hN: [98, 42], eF: [86, 43], hF: [98, 43],
167  kN: [60, 78], fN: [56, 90], kF: [60, 78], fF: [55, 90],
168}
169const WALL_PU_B: Pose = {
170  head: [95, 40], sh: [88, 46], hip: [72, 68],
171  eN: [88, 58], hN: [98, 42], eF: [88, 59], hF: [98, 43],
172  kN: [64, 79], fN: [56, 90], kF: [64, 79], fF: [55, 90],
173}
174const CHEST_A = shift(STAND, 16)
175const CHEST_B = p({ eN: [98, 30], hN: [98, 14], sh: [78, 28], head: [78, 17] }, shift(STAND, 18))
176const REACH_B = p({ eN: [62, 17], hN: [60, 5], eF: [58, 17], hF: [60, 5], head: [60, 15], sh: [60, 26] }, ARMS_UP)
177
178const CALF_A: Pose = {
179  head: [78, 34], sh: [72, 44], hip: [58, 66],
180  eN: [84, 46], hN: [97, 44], eF: [84, 47], hF: [97, 45],
181  kN: [70, 78], fN: [76, 90], kF: [46, 78], fF: [36, 90],
182}
183const CALF_B = p({ head: [84, 36], sh: [78, 45], hip: [62, 66], eN: [88, 47], hN: [98, 44], eF: [88, 48], hF: [98, 45], kF: [50, 78], fF: [40, 90] }, CALF_A)
184const SQUEEZE_A = p({ eN: [50, 52], hN: [60, 50], eF: [49, 53], hF: [59, 51] }, SIT)
185const SQUEEZE_B = p({ eN: [32, 52], hN: [46, 50], eF: [31, 53], hF: [45, 51] }, SIT)
186const Y_A = p({ eN: [62, 44], hN: [66, 54], eF: [61, 44], hF: [65, 55] }, STAND)
187const Y_B = p({ eN: [66, 18], hN: [72, 6], eF: [64, 18], hF: [70, 6] }, STAND)
188
189// home: on the floor (head left), y = 86 so the body rests on the ground line
190const SUPINE: Pose = {
191  head: [14, 84], sh: [28, 86], hip: [56, 86],
192  eN: [38, 86], hN: [50, 86], eF: [38, 87], hF: [50, 87],
193  kN: [76, 66], fN: [86, 86], kF: [76, 66], fF: [86, 86],
194}
195const BRIDGE_UP = p({ hip: [54, 64], sh: [28, 84], head: [14, 83], kN: [76, 62], kF: [76, 62], eN: [38, 85], hN: [50, 84] }, SUPINE)
196const CRUNCH_A2 = p({ eN: [24, 76], hN: [17, 81], eF: [24, 77], hF: [17, 82] }, SUPINE)
197const CRUNCH_B2 = p({ sh: [36, 78], head: [34, 66], eN: [30, 70], hN: [27, 66], eF: [30, 71], hF: [27, 67] }, SUPINE)
198const BIKE_A = p(
199  { sh: [37, 78], head: [34, 68], eN: [31, 70], hN: [28, 66], eF: [31, 71], hF: [28, 67], kN: [66, 64], fN: [76, 74], kF: [76, 80], fF: [96, 76] },
200  SUPINE,
201)
202const BIKE_B = p({ kF: [66, 64], fF: [76, 74], kN: [76, 80], fN: [96, 76] }, BIKE_A)
203const LEGS_FLAT = p({ kN: [76, 86], fN: [96, 86], kF: [76, 87], fF: [96, 87] }, SUPINE)
204const LEGS_UP = p({ kN: [57, 64], fN: [58, 42], kF: [58, 64], fF: [59, 42] }, SUPINE)
205const SITUP_A = p({ eN: [24, 76], hN: [17, 81], eF: [24, 77], hF: [17, 82] }, SUPINE)
206const SITUP_B = p({ sh: [50, 62], head: [47, 52], eN: [57, 52], hN: [50, 48], eF: [57, 53], hF: [50, 49] }, SUPINE)
207const PRONE: Pose = {
208  head: [90, 84], sh: [70, 86], hip: [42, 86],
209  eN: [80, 86], hN: [94, 86], eF: [80, 87], hF: [94, 87],
210  kN: [26, 86], fN: [10, 86], kF: [26, 87], fF: [10, 87],
211}
212const SUPER_B = p({ head: [92, 72], sh: [70, 82], eN: [82, 76], hN: [96, 72], eF: [82, 77], hF: [96, 73], kN: [26, 82], fN: [10, 74], kF: [26, 82], fF: [10, 75] }, PRONE)
213const QUAD: Pose = {
214  head: [78, 56], sh: [66, 60], hip: [34, 60],
215  eN: [66, 74], hN: [66, 88], eF: [66, 74], hF: [66, 88],
216  kN: [34, 86], fN: [18, 88], kF: [34, 86], fF: [18, 88],
217}
218const BIRD_B = p({ hF: [96, 58], eF: [82, 59], fF: [6, 58], kF: [20, 59], head: [80, 54] }, QUAD)
219const CHILD_A = p(
220  { head: [64, 70], sh: [52, 70], hip: [24, 76], eN: [66, 78], hN: [82, 86], eF: [66, 79], hF: [82, 87], kN: [42, 88], fN: [14, 88], kF: [42, 88], fF: [14, 88] },
221  STAND,
222)
223const CHILD_B = p({ head: [70, 80], sh: [56, 78], eN: [74, 84], hN: [94, 88], eF: [74, 85], hF: [94, 88] }, CHILD_A)
224const DOG_A: Pose = {
225  head: [82, 70], sh: [74, 60], hip: [52, 48],
226  eN: [80, 76], hN: [84, 88], eF: [79, 76], hF: [83, 88],
227  kN: [41, 68], fN: [30, 88], kF: [41, 68], fF: [29, 88],
228}
229const DOG_B = p({ kN: [44, 66], fN: [34, 76] }, DOG_A)
230const KPU_A: Pose = {
231  head: [89, 57], sh: [78, 60], hip: [52, 72],
232  eN: [78, 74], hN: [78, 88], eF: [77, 74], hF: [77, 88],
233  kN: [34, 86], fN: [18, 76], kF: [34, 86], fF: [18, 76],
234}
235const KPU_B = p({ head: [89, 76], sh: [78, 78], hip: [52, 82], eN: [64, 84], eF: [63, 84], fN: [18, 80], fF: [18, 80] }, KPU_A)
236const TAP_B = p({ eN: [84, 66], hN: [80, 56] }, PUSH_UP)
237const INCH_A: Pose = {
238  head: [76, 72], sh: [68, 62], hip: [50, 40],
239  eN: [76, 76], hN: [84, 88], eF: [75, 76], hF: [83, 88],
240  kN: [48, 64], fN: [46, 88], kF: [48, 64], fF: [45, 88],
241}
242const HK_A = p({ kN: [76, 50], fN: [74, 66], eN: [54, 40], hN: [50, 50], eF: [66, 38], hF: [72, 44] }, STAND)
243const HK_B = p({ kF: [76, 50], fF: [74, 66], eF: [54, 40], hF: [50, 50], eN: [66, 38], hN: [72, 44] }, STAND)
244
245// with equipment
246const CURL_A = p({ hN: [61, 52], hF: [59, 52] }, STAND)
247const CURL_B = p({ eN: [62, 40], hN: [71, 31], eF: [60, 40], hF: [69, 31] }, STAND)
248const PRESS_A = p({ eN: [67, 38], hN: [67, 26], eF: [55, 38], hF: [55, 26] }, STAND)
249const GOBLET_A = p({ eN: [66, 38], hN: [69, 32], eF: [65, 39], hF: [68, 33] }, STAND)
250const GOBLET_B = p({ eN: [62, 52], hN: [69, 44], eF: [61, 53], hF: [68, 45] }, SQUAT)
251const ROW_A = p({ eN: [71, 56], hN: [72, 68], eF: [70, 56], hF: [71, 68] }, HINGE)
252const ROW_B = p({ eN: [58, 50], hN: [66, 52], eF: [57, 51], hF: [65, 53] }, HINGE)
253const RDL_A = p({ hN: [63, 54], hF: [62, 54] }, STAND)
254const RDL_B = p({ eN: [66, 60], hN: [62, 74], eF: [65, 61], hF: [61, 75] }, HINGE)
255const TRI_A = p({ eN: [64, 14], hN: [54, 20], eF: [62, 14], hF: [54, 22] }, STAND)
256const FLOOR_SIT: Pose = {
257  head: [35, 51], sh: [34, 62], hip: [34, 86],
258  eN: [46, 66], hN: [60, 70], eF: [46, 67], hF: [60, 71],
259  kN: [54, 86], fN: [74, 86], kF: [54, 87], fF: [74, 87],
260}
261const BAND_ROW_A = p({ eN: [52, 74], hN: [72, 80], eF: [52, 75], hF: [72, 81] }, FLOOR_SIT)
262const BAND_ROW_B = p({ eN: [26, 72], hN: [40, 74], eF: [26, 73], hF: [40, 75] }, FLOOR_SIT)
263const BAND_CURL_B = p({ eN: [62, 40], hN: [71, 31], eF: [60, 40], hF: [69, 31] }, STAND)
264const KB_A = p({ eN: [64, 62], hN: [60, 70], eF: [63, 62], hF: [59, 71] }, HINGE)
265const KB_B = p({ eN: [72, 30], hN: [84, 30], eF: [71, 31], hF: [83, 31] }, STAND)
266const HANG_A: Pose = {
267  head: [60, 22], sh: [60, 32], hip: [60, 58],
268  eN: [62, 20], hN: [62, 8], eF: [58, 20], hF: [58, 8],
269  kN: [60, 74], fN: [62, 88], kF: [60, 74], fF: [58, 88],
270}
271const PULL_B = p({ head: [60, 6], sh: [60, 16], hip: [60, 42], eN: [66, 12], hN: [63, 8], eF: [54, 12], hF: [57, 8], kN: [60, 58], fN: [62, 72], kF: [60, 58], fF: [58, 72] }, HANG_A)
272const HANG_B = p({ sh: [60, 29], head: [60, 20] }, HANG_A)
273
274type Held = { kind: 'dumbbell' | 'kettlebell'; joints: Joint[] }
275type Art = { a: Pose; b: Pose; scene: Scene; extra?: string; speed?: number; held?: Held; link?: [Joint, Joint] }
276
277const ART: Record<string, Art> = {
278  'chair-squat': { a: STAND, b: SQUAT, scene: 'chair' },
279  squat: { a: STAND, b: SQUAT, scene: 'none' },
280  'jump-squat': { a: SQUAT, b: JUMP, scene: 'none', speed: 1.4 },
281  'calf-raise': { a: STAND, b: CALF_UP, scene: 'none', speed: 1.4 },
282  'wall-sit': { a: WALL_STAND, b: WALL_SIT, scene: 'wall' },
283  'reverse-lunge': { a: STAND, b: LUNGE, scene: 'none' },
284  'glute-bridge-stand': { a: STAND, b: GLUTE, scene: 'none', speed: 1.5 },
285  'desk-pushup': { a: DESK_UP, b: DESK_DOWN, scene: 'desk' },
286  pushup: { a: PUSH_UP, b: PUSH_DOWN, scene: 'none' },
287  'diamond-pushup': { a: PUSH_UP, b: PUSH_DOWN, scene: 'none' },
288  'chair-dip': { a: DIP_UP, b: DIP_DOWN, scene: 'chair' },
289  'wall-angel': { a: ANGEL_DOWN, b: ANGEL_UP, scene: 'wall' },
290  plank: { a: PLANK, b: PLANK_HIGH, scene: 'none', speed: 3 },
291  'knee-plank': { a: KNEE_PLANK, b: KNEE_PLANK_HIGH, scene: 'none', speed: 3 },
292  'seated-knee-lift': { a: SIT, b: SIT_KNEE, scene: 'chair' },
293  'mountain-climber': { a: CLIMB_A, b: CLIMB_B, scene: 'none', speed: 0.6 },
294  'standing-crunch': { a: CRUNCH_A, b: CRUNCH_B, scene: 'none' },
295  'chair-twist': { a: SIT_TWIST_A, b: SIT_TWIST_B, scene: 'chair' },
296  'cat-cow-seated': { a: SIT_ARCH, b: SIT_ROUND, scene: 'chair' },
297  'hip-hinge': { a: HANDS_HIPS, b: HINGE, scene: 'none' },
298  'neck-roll': { a: NECK_FWD, b: NECK_BACK, scene: 'none', speed: 1.6 },
299  'chin-tuck': { a: TUCK_FWD, b: TUCK_BACK, scene: 'none', speed: 1.6 },
300  'shoulder-roll': { a: SHRUG_A, b: SHRUG_B, scene: 'none', speed: 1.4 },
301  'wrist-circles': { a: WRIST_UP, b: WRIST_DOWN, scene: 'none', speed: 0.8 },
302  'prayer-stretch': { a: PRAYER_A, b: PRAYER_B, scene: 'none', speed: 1.6 },
303  'eye-20': { a: EYE_NEAR, b: STAND, scene: 'far', speed: 2 },
304  'jumping-jacks': { a: STAND, b: WIDE_JACK, scene: 'none', speed: 0.6 },
305  march: { a: MARCH_A, b: MARCH_B, scene: 'none', speed: 0.7 },
306  burpee: { a: STAND, b: PUSH_UP, scene: 'none', speed: 1.5 },
307}
308
309type CueKind = 'arrow' | 'circle' | 'pulse'
310type Cue = { joint: Joint; kind: CueKind }
311
312/** What to point at for each move: an arrow along the motion, a circle for rotations, a pulse for holds. */
313const CUES: Record<string, Cue> = {
314  'neck-roll': { joint: 'head', kind: 'circle' },
315  'chin-tuck': { joint: 'head', kind: 'arrow' },
316  'shoulder-roll': { joint: 'sh', kind: 'circle' },
317  'wrist-circles': { joint: 'hN', kind: 'circle' },
318  'prayer-stretch': { joint: 'hN', kind: 'arrow' },
319  'calf-raise': { joint: 'head', kind: 'arrow' },
320  'glute-bridge-stand': { joint: 'hip', kind: 'pulse' },
321  plank: { joint: 'hip', kind: 'pulse' },
322  'knee-plank': { joint: 'hip', kind: 'pulse' },
323  'wall-sit': { joint: 'hip', kind: 'arrow' },
324  'wall-angel': { joint: 'hN', kind: 'arrow' },
325  march: { joint: 'kN', kind: 'arrow' },
326  'chair-twist': { joint: 'hN', kind: 'arrow' },
327  'cat-cow-seated': { joint: 'head', kind: 'arrow' },
328  'figure-four': { joint: 'hip', kind: 'pulse' },
329  'quad-stretch': { joint: 'kN', kind: 'pulse' },
330  'wall-chest-stretch': { joint: 'sh', kind: 'pulse' },
331  'overhead-reach': { joint: 'hN', kind: 'arrow' },
332  'calf-stretch': { joint: 'hN', kind: 'pulse' },
333  'scapula-squeeze': { joint: 'eN', kind: 'arrow' },
334  'y-raise': { joint: 'hN', kind: 'arrow' },
335  'childs-pose': { joint: 'hip', kind: 'pulse' },
336  'downward-dog': { joint: 'hip', kind: 'pulse' },
337  'dead-hang': { joint: 'sh', kind: 'pulse' },
338  'glute-bridge': { joint: 'hip', kind: 'arrow' },
339  superman: { joint: 'hN', kind: 'arrow' },
340  'bird-dog': { joint: 'hF', kind: 'arrow' },
341  'leg-raise': { joint: 'fN', kind: 'arrow' },
342  'sit-up': { joint: 'head', kind: 'arrow' },
343  crunch: { joint: 'head', kind: 'arrow' },
344  'bicycle-crunch': { joint: 'kN', kind: 'arrow' },
345}
346
347Object.assign(ART, {
348  // office
349  'seated-leg-extension': { a: SIT, b: LEG_OUT, scene: 'chair' },
350  'figure-four': { a: FOUR_A, b: FOUR_B, scene: 'chair', speed: 0.8 },
351  'quad-stretch': { a: STAND, b: QUAD_B, scene: 'none', speed: 0.8 },
352  'glute-kickback': { a: STAND, b: KICK_B, scene: 'none' },
353  'wall-pushup': { a: WALL_PU_A, b: WALL_PU_B, scene: 'wallR' },
354  'wall-chest-stretch': { a: CHEST_A, b: CHEST_B, scene: 'wallR', speed: 0.8 },
355  'overhead-reach': { a: STAND, b: REACH_B, scene: 'none', speed: 0.9 },
356  'calf-stretch': { a: CALF_A, b: CALF_B, scene: 'wallR', speed: 1.6 },
357  'scapula-squeeze': { a: SQUEEZE_A, b: SQUEEZE_B, scene: 'chair', speed: 1.2 },
358  'y-raise': { a: Y_A, b: Y_B, scene: 'none', speed: 1 },
359  // home
360  'glute-bridge': { a: SUPINE, b: BRIDGE_UP, scene: 'mat' },
361  crunch: { a: CRUNCH_A2, b: CRUNCH_B2, scene: 'mat' },
362  'bicycle-crunch': { a: BIKE_A, b: BIKE_B, scene: 'mat', speed: 1.2 },
363  'leg-raise': { a: LEGS_FLAT, b: LEGS_UP, scene: 'mat' },
364  'sit-up': { a: SITUP_A, b: SITUP_B, scene: 'mat' },
365  superman: { a: PRONE, b: SUPER_B, scene: 'mat' },
366  'bird-dog': { a: QUAD, b: BIRD_B, scene: 'mat' },
367  'childs-pose': { a: CHILD_A, b: CHILD_B, scene: 'mat', speed: 0.7 },
368  'downward-dog': { a: DOG_A, b: DOG_B, scene: 'mat', speed: 0.8 },
369  'knee-pushup': { a: KPU_A, b: KPU_B, scene: 'mat' },
370  'shoulder-taps': { a: PUSH_UP, b: TAP_B, scene: 'mat', speed: 1.4 },
371  inchworm: { a: INCH_A, b: PUSH_UP, scene: 'mat', speed: 0.8 },
372  'forward-lunge': { a: STAND, b: LUNGE, scene: 'none' },
373  'high-knees': { a: HK_A, b: HK_B, scene: 'none', speed: 2 },
374  // equipment
375  'db-curl': { a: CURL_A, b: CURL_B, scene: 'none', held: { kind: 'dumbbell', joints: ['hN', 'hF'] } },
376  'db-press': { a: PRESS_A, b: ARMS_UP, scene: 'none', held: { kind: 'dumbbell', joints: ['hN', 'hF'] } },
377  'db-goblet-squat': { a: GOBLET_A, b: GOBLET_B, scene: 'none', held: { kind: 'dumbbell', joints: ['hN'] } },
378  'db-row': { a: ROW_A, b: ROW_B, scene: 'none', held: { kind: 'dumbbell', joints: ['hN', 'hF'] } },
379  'db-rdl': { a: RDL_A, b: RDL_B, scene: 'none', held: { kind: 'dumbbell', joints: ['hN', 'hF'] } },
380  'db-tricep-ext': { a: TRI_A, b: ARMS_UP, scene: 'none', held: { kind: 'dumbbell', joints: ['hN'] } },
381  'band-row': { a: BAND_ROW_A, b: BAND_ROW_B, scene: 'mat', link: ['hN', 'fN'] },
382  'band-curl': { a: CURL_A, b: BAND_CURL_B, scene: 'none', link: ['hN', 'fN'] },
383  'kb-swing': { a: KB_A, b: KB_B, scene: 'none', speed: 1.6, held: { kind: 'kettlebell', joints: ['hN'] } },
384  pullup: { a: HANG_A, b: PULL_B, scene: 'bar' },
385  'dead-hang': { a: HANG_A, b: HANG_B, scene: 'bar', speed: 1.8 },
386} satisfies Record<string, Art>)
387
388const FALLBACK: Art = { a: STAND, b: ARMS_UP, scene: 'none' }
389
390export const hasArt = (id: string): boolean => id in ART
391
392/** The scene and gear of a move in words, for prompts to an image model. */
393export const describeSetting = (id: string): string[] => {
394  const art = ART[id]
395  const scene: Record<Scene, string | null> = {
396    none: null,
397    chair: 'a simple chair with a backrest',
398    desk: 'a desk edge at hip height',
399    wall: 'a plain wall',
400    wallR: 'a plain wall',
401    far: 'a distant hill on the horizon',
402    mat: 'an exercise mat on the floor',
403    bar: 'a horizontal pull-up bar above the head',
404  }
405  const out: string[] = []
406  const place = art ? scene[art.scene] : null
407
408  if (place) out.push(place)
409  if (art?.held) out.push(art.held.kind === 'kettlebell' ? 'a kettlebell' : 'a dumbbell in each hand')
410  if (art?.link) out.push('a resistance band stretched between the hands and the feet')
411
412  return out
413}
414
415/** Whether a hand-drawn illustration (from the image pipeline) exists for this move. */
416export const hasIllustration = (id: string): boolean => id in AI_ART
417
418// Monochrome by default. Colours are CSS variables defined inside every drawing,
419// switching with the viewer's light or dark scheme, so no drawing needs the theme passed in.
420const THEMES = {
421  signal: { body: 'var(--c-main)', limb: 'var(--c-limb)', shirt: 'var(--c-main)', accent: 'var(--c-sig)' },
422  mono: { body: 'var(--c-main)', limb: 'var(--c-limb)', shirt: 'var(--c-main)', accent: 'var(--c-strong)' },
423  teal: { body: '#14b8a6', limb: '#2dd4bf', shirt: '#0f766e', accent: '#f59e0b' },
424  violet: { body: '#8b5cf6', limb: '#a78bfa', shirt: '#6d28d9', accent: '#f59e0b' },
425  rose: { body: '#f43f5e', limb: '#fb7185', shirt: '#be123c', accent: '#14b8a6' },
426  blue: { body: '#3b82f6', limb: '#60a5fa', shirt: '#1d4ed8', accent: '#f59e0b' },
427  amber: { body: '#f59e0b', limb: '#fbbf24', shirt: '#b45309', accent: '#14b8a6' },
428  // plain black strokes on white, for the reference sheets sent to an image model (scripts/art)
429  ref: { body: '#000000', limb: '#222222', shirt: '#000000', accent: '#000000', prop: '#000000', soft: '#e5e5e5' },
430}
431
432const STYLE =
433  '<style>:root{color-scheme:light dark;--c-strong:#16140f;--c-main:#3a352b;--c-limb:#8c8473;--c-mute:#665f53;--c-soft:#8c847333;--c-on:#fff;--c-sig:#d9532a}' +
434  '@media (prefers-color-scheme:dark){:root{--c-strong:#f1ede3;--c-main:#d9d3c5;--c-limb:#8c8473;--c-mute:#a39c8d;--c-soft:#a39c8d33;--c-on:#12110e;--c-sig:#ff7a4d}}</style>'
435
436export type ThemeName = keyof typeof THEMES
437
438const COLORS = {
439  ...THEMES.signal,
440  prop: 'var(--c-mute)',
441  soft: 'var(--c-soft)',
442  on: 'var(--c-on)',
443}
444
445/** Picks the accent colour every later drawing uses. */
446export const setTheme = (name: string): void => {
447  Object.assign(COLORS, { prop: 'var(--c-mute)', soft: 'var(--c-soft)' }, THEMES[(name in THEMES ? name : 'signal') as ThemeName])
448}
449
450const n = (v: number): string => (Math.round(v * 10) / 10).toString()
451
452const scene = (s: Scene): string => {
453  const ground = `<line x1="4" y1="91" x2="116" y2="91" stroke="${COLORS.prop}" stroke-width="2" stroke-linecap="round"/>`
454  const chair = `<path d="M26 44 V90 M26 68 H50 M48 68 V90" stroke="${COLORS.prop}" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`
455  const desk = `<path d="M76 58 H114 M112 58 V90" stroke="${COLORS.prop}" stroke-width="3" fill="none" stroke-linecap="round"/>`
456  const wall = `<path d="M24 4 V90" stroke="${COLORS.prop}" stroke-width="3" stroke-linecap="round"/><path d="M18 14 l6 -6 M18 28 l6 -6 M18 42 l6 -6 M18 56 l6 -6 M18 70 l6 -6 M18 84 l6 -6" stroke="${COLORS.prop}" stroke-width="1.5" stroke-linecap="round" opacity=".6"/>`
457  const mat = `<rect x="6" y="88.5" width="108" height="3.5" rx="1.75" fill="${COLORS.soft}" stroke="${COLORS.prop}" stroke-width="1"/>`
458  const bar = `<path d="M24 6 H96 M24 6 V22 M96 6 V22" stroke="${COLORS.prop}" stroke-width="3" fill="none" stroke-linecap="round"/>`
459  const wallR = `<path d="M101 4 V90" stroke="${COLORS.prop}" stroke-width="3" stroke-linecap="round"/><path d="M107 14 l-6 6 M107 28 l-6 6 M107 42 l-6 6 M107 56 l-6 6 M107 70 l-6 6 M107 84 l-6 6" stroke="${COLORS.prop}" stroke-width="1.5" stroke-linecap="round" opacity=".6"/>`
460  const far = `<path d="M92 91 l10 -22 l8 12 l6 -8 l0 18 z" fill="${COLORS.soft}" stroke="${COLORS.prop}" stroke-width="1.5" stroke-linejoin="round"/>`
461
462  return ground + (s === 'chair' ? chair : s === 'desk' ? desk : s === 'wall' ? wall : s === 'far' ? far : s === 'mat' ? mat : s === 'bar' ? bar : s === 'wallR' ? wallR : '')
463}
464
465const BONES: [Joint, Joint, 'far' | 'near' | 'core'][] = [
466  ['sh', 'eF', 'far'], ['eF', 'hF', 'far'], ['hip', 'kF', 'far'], ['kF', 'fF', 'far'],
467  ['sh', 'hip', 'core'], ['sh', 'head', 'core'],
468  ['sh', 'eN', 'near'], ['eN', 'hN', 'near'], ['hip', 'kN', 'near'], ['kN', 'fN', 'near'],
469]
470
471const keys = 'calcMode="spline" keyTimes="0;.5;1" keySplines=".45 0 .55 1;.45 0 .55 1"'
472
473const anim = (attr: string, from: number, to: number, dur: number): string =>
474  from === to ? '' : `<animate attributeName="${attr}" values="${n(from)};${n(to)};${n(from)}" dur="${dur}s" repeatCount="indefinite" ${keys}/>`
475
476const cueSvg = (id: string, art: Art, dur: number): string => {
477  const cue = CUES[id] ?? { joint: AREA_JOINT[byId(id)?.area ?? ''] ?? 'hip', kind: 'arrow' as const }
478  const [x0, y0] = art.a[cue.joint]
479  const [x1, y1] = art.b[cue.joint]
480  const dist = Math.hypot(x1 - x0, y1 - y0)
481  const stroke = `stroke="${COLORS.accent}" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" fill="none"`
482
483  if (cue.kind === 'pulse') {
484    return (
485      `<circle cx="${n(x0)}" cy="${n(y0)}" r="9" ${stroke}><animate attributeName="r" values="8;14;8" dur="1.8s" repeatCount="indefinite"/>` +
486      `<animate attributeName="opacity" values="0.9;0;0.9" dur="1.8s" repeatCount="indefinite"/></circle>`
487    )
488  }
489
490  if (cue.kind === 'circle') {
491    const cx = (x0 + x1) / 2 + 2
492    const cy = (y0 + y1) / 2
493    const r = cue.joint === 'head' ? 14 : 12
494
495    const cxy = `${n(cx)} ${n(cy)}`
496
497    return (
498      `<g><path d="M${n(cx - r)} ${n(cy)} A${r} ${r} 0 1 1 ${n(cx)} ${n(cy + r)}" ${stroke} stroke-dasharray="3 3"/>` +
499      `<path d="M${n(cx - 3)} ${n(cy + r - 5)} L${n(cx + 1)} ${n(cy + r)} L${n(cx - 4)} ${n(cy + r + 3)}" ${stroke}/>` +
500      `<animateTransform attributeName="transform" type="rotate" values="0 ${cxy};360 ${cxy}" dur="${n(dur * 1.5)}s" repeatCount="indefinite"/></g>`
501    )
502  }
503
504  if (dist < 6) return ''
505
506  // an arrow from the first pose to the second, bowed a little so it reads as motion
507  const dx = (x1 - x0) / dist
508  const dy = (y1 - y0) / dist
509  const mx = (x0 + x1) / 2 - dy * 7
510  const my = (y0 + y1) / 2 + dx * 7
511  const hx = x1 - dx * 3
512  const hy = y1 - dy * 3
513  const a1 = `${n(hx - dx * 5 - dy * 4)} ${n(hy - dy * 5 + dx * 4)}`
514  const a2 = `${n(hx - dx * 5 + dy * 4)} ${n(hy - dy * 5 - dx * 4)}`
515
516  return (
517    `<path d="M${n(x0)} ${n(y0)} Q${n(mx)} ${n(my)} ${n(hx)} ${n(hy)}" ${stroke} stroke-dasharray="3 3" opacity=".8">` +
518    `<animate attributeName="stroke-dashoffset" values="0;-12" dur="1s" repeatCount="indefinite"/></path>` +
519    `<path d="M${a1} L${n(hx)} ${n(hy)} L${a2}" ${stroke} opacity=".8"/>`
520  )
521}
522
523/** Which bones do the work, by body area: the bold look paints them in the strong colour and the rest soft. */
524const HOT: Record<string, string[]> = {
525  legs: ['hip-kN', 'kN-fN', 'hip-kF', 'kF-fF'],
526  core: ['sh-hip'],
527  back: ['sh-hip'],
528  upper: ['sh-eN', 'eN-hN', 'sh-eF', 'eF-hF'],
529  wrists: ['eN-hN', 'eF-hF'],
530  neck: ['sh-head'],
531  eyes: ['sh-head'],
532  full: ['hip-kN', 'kN-fN', 'hip-kF', 'kF-fF', 'sh-hip', 'sh-eN', 'eN-hN', 'sh-eF', 'eF-hF'],
533}
534
535const AREA_JOINT: Record<string, Joint> = {
536  legs: 'kN', core: 'hip', upper: 'sh', back: 'sh', neck: 'head', wrists: 'hN', eyes: 'head', full: 'hip',
537}
538
539/** The pose halfway between two poses. */
540const midPose = (a: Pose, b: Pose): Pose =>
541  Object.fromEntries(J.map(j => [j, [(a[j][0] + b[j][0]) / 2, (a[j][1] + b[j][1]) / 2] as Pt])) as Pose
542
543/** Whether the line-art library has this move. */
544export const hasLineArt = (id: string): boolean => id in WG_ART
545
546/** Line art (Workout Guide, CC BY-SA 4.0): up to three frames played one, two, three, two, in the theme's strong colour. */
547const lineArtSvg = (id: string, width: number, frame: 0 | 0.5 | 1 | undefined, isAnimated: boolean): string => {
548  const frames = WG_ART[id]
549
550  if (!frames || frames.length === 0) return ''
551
552  const order = frames.length >= 3 ? [0, 1, 2, 1] : frames.map((_, i) => i)
553  const still = frame !== undefined || !isAnimated
554  const shown = frame === 1 ? frames.length - 1 : frame === 0.5 ? Math.floor(frames.length / 2) : 0
555  const paint = (d: string, k: number): string => {
556    const pulse = still
557      ? ''
558      : `<animate attributeName="opacity" calcMode="discrete" dur="${n(order.length * 0.7)}s" repeatCount="indefinite" ` +
559        `keyTimes="${order.map((_, i) => n(i / order.length)).join(';')}" values="${order.map(f => (f === k ? 1 : 0)).join(';')}"/>`
560
561    // the middle frame of the library is the original, bolder drawing: a thin outline on the others keeps the line weight even
562    const weight = k === 1 || WG_EVEN.includes(id) ? '' : ` stroke="${COLORS.accent}" stroke-width="2.5" stroke-linejoin="round"`
563
564    return `<path fill-rule="evenodd" fill="${COLORS.accent}"${weight} opacity="${still ? (k === shown ? 1 : 0) : k === order[0] ? 1 : 0}" d="${d}">${pulse}</path>`
565  }
566  // a still picture needs only the frame it shows; the others would just add weight
567  const paths = frames.map((d, k) => (still && k !== shown ? '' : paint(d, k))).join('')
568
569  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="${width}" height="${width}">${STYLE}${paths}</svg>`
570}
571
572/** Two hand-drawn poses (vector paths from the image pipeline) that fade into each other. */
573const illustrationSvg = (art: IllustrationArt, width: number, frame: 0 | 0.5 | 1 | undefined, isAnimated: boolean): string => {
574  const w = art.w
575  const h = art.h
576  const fill = `fill="${COLORS.accent}"`
577  const cycle = 'dur="2.4s" repeatCount="indefinite" calcMode="linear"'
578  const fade = (values: string) => `<animate attributeName="opacity" values="${values}" keyTimes="0;.38;.5;.88;1" ${cycle}/>`
579  const still = frame !== undefined || !isAnimated
580  const showB = frame === 1
581  const pa = `<path d="${art.a}" ${fill} opacity="${still && showB ? 0 : 1}">${still ? '' : fade('1;1;0;0;1')}</path>`
582  const pb = `<path d="${art.b}" ${fill} opacity="${still && showB ? 1 : 0}">${still ? '' : fade('0;0;1;1;0')}</path>`
583
584  return (
585    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${width}" height="${Math.round((width * h) / w)}">${STYLE}` +
586    `${pa}${pb}</svg>`
587  )
588}
589
590/** An animated skeleton for one exercise, `width` css pixels wide. */
591export const figureSvg = (id: string, width = 240, frame?: 0 | 0.5 | 1, isAnimated = true, style: 'drawn' | 'stick' | 'bold' | 'line' = 'drawn'): string => {
592  // line art: a move without a drawing shows none, on purpose
593  if (style === 'line') return lineArtSvg(id, width, frame, isAnimated)
594
595  const drawn = style === 'drawn' ? AI_ART[id] : undefined
596
597  if (drawn) return illustrationSvg(drawn, width, frame, isAnimated)
598
599  const art = ART[id] ?? FALLBACK
600  const dur = 2.2 / (art.speed ?? 1)
601  const move = isAnimated && frame === undefined
602  const a = frame === 1 ? art.b : frame === 0.5 ? midPose(art.a, art.b) : art.a
603  const b = move ? art.b : a
604  const a2 = (attr: string, from: number, to: number): string => (move ? anim(attr, from, to, dur) : '')
605
606  const lines = BONES.map(([from, to, side]) => {
607    const [x1, y1] = a[from]
608    const [x2, y2] = a[to]
609    const [bx1, by1] = b[from]
610    const [bx2, by2] = b[to]
611    const isTorso = from === 'sh' && to === 'hip'
612    const isBold = style === 'bold'
613    const hot = isBold && (HOT[byId(id)?.area ?? 'full'] ?? []).includes(`${from}-${to}`)
614    const width = isBold ? (isTorso ? 13 : side === 'core' ? 5 : 8.5) : isTorso ? 9 : side === 'core' ? 4 : 4.5
615    const color = isBold ? (hot ? COLORS.accent : COLORS.limb) : isTorso ? COLORS.shirt : side === 'core' ? COLORS.shirt : COLORS.limb
616    const opacity = side === 'far' ? (isBold ? ' opacity=".55"' : ' opacity=".5"') : ''
617
618    return (
619      `<line x1="${n(x1)}" y1="${n(y1)}" x2="${n(x2)}" y2="${n(y2)}" stroke="${color}" stroke-width="${width}" stroke-linecap="round"${opacity}>` +
620      a2('x1', x1, bx1) + a2('y1', y1, by1) + a2('x2', x2, bx2) + a2('y2', y2, by2) +
621      '</line>'
622    )
623  }).join('')
624
625  const shoe = (j: 'fN' | 'fF'): string => {
626    const [x, y] = a[j]
627    const [bx, by] = b[j]
628
629    return (
630      `<line x1="${n(x)}" y1="${n(y)}" x2="${n(x + 5)}" y2="${n(y)}" stroke="${style === 'bold' ? COLORS.limb : COLORS.accent}" stroke-width="${style === 'bold' ? 8.5 : 4.5}" stroke-linecap="round"${j === 'fF' ? ' opacity=".6"' : ''}>` +
631      a2('x1', x, bx) + a2('y1', y, by) + a2('x2', x + 5, bx + 5) + a2('y2', y, by) + '</line>'
632    )
633  }
634
635  // gear: dumbbells and kettlebells ride on the hands, a band is a line between two joints
636  const gear = (art.held?.joints ?? [])
637    .map((j, idx) => {
638      const [x0, y0] = a[j]
639      const [x1, y1] = b[j]
640      const shape =
641        art.held?.kind === 'kettlebell'
642          ? `<circle cx="0" cy="4" r="6" fill="${COLORS.accent}"/><path d="M-3.5 -1 Q0 -8 3.5 -1" fill="none" stroke="${COLORS.accent}" stroke-width="2.2" stroke-linecap="round"/>`
643          : `<rect x="-7" y="-3" width="14" height="6" rx="2" fill="${COLORS.accent}"/><rect x="-9.5" y="-5.5" width="3.5" height="11" rx="1.2" fill="${COLORS.accent}"/><rect x="6" y="-5.5" width="3.5" height="11" rx="1.2" fill="${COLORS.accent}"/>`
644      const moving =
645        move && (x0 !== x1 || y0 !== y1)
646          ? `<animateTransform attributeName="transform" type="translate" values="${n(x0)} ${n(y0)};${n(x1)} ${n(y1)};${n(x0)} ${n(y0)}" dur="${dur}s" repeatCount="indefinite" ${keys}/>`
647          : ''
648
649      return `<g transform="translate(${n(x0)} ${n(y0)})"${idx > 0 ? ' opacity=".6"' : ''}>${shape}${moving}</g>`
650    })
651    .join('')
652  const band = art.link
653    ? (() => {
654        const [j1, j2] = art.link
655        const [x1, y1] = a[j1]
656        const [x2, y2] = a[j2]
657        const [bx1, by1] = b[j1]
658        const [bx2, by2] = b[j2]
659
660        return (
661          `<line x1="${n(x1)}" y1="${n(y1)}" x2="${n(x2)}" y2="${n(y2)}" stroke="${COLORS.accent}" stroke-width="2" stroke-linecap="round" stroke-dasharray="1 0">` +
662          a2('x1', x1, bx1) + a2('y1', y1, by1) + a2('x2', x2, bx2) + a2('y2', y2, by2) + '</line>'
663        )
664      })()
665    : ''
666
667  const [hx, hy] = a.head
668  const [bhx, bhy] = b.head
669  const headHot = style === 'bold' && ['neck', 'eyes'].includes(byId(id)?.area ?? '')
670  const head =
671    `<circle cx="${n(hx)}" cy="${n(hy)}" r="${style === 'bold' ? 8 : 6.5}" fill="${style === 'bold' ? (headHot ? COLORS.accent : COLORS.limb) : COLORS.accent}">` + a2('cx', hx, bhx) + a2('cy', hy, bhy) + '</circle>'
672
673  const focus = AREA_JOINT[byId(id)?.area ?? ''] ?? 'hip'
674  const [fx, fy] = a[focus]
675  const [bfx, bfy] = b[focus]
676  const glow =
677    `<circle cx="${n(fx)}" cy="${n(fy)}" r="8" fill="${COLORS.accent}" opacity=".28">` +
678    a2('cx', fx, bfx) + a2('cy', fy, bfy) +
679    (move
680      ? `<animate attributeName="r" values="7;11;7" dur="1.6s" repeatCount="indefinite"/><animate attributeName="opacity" values=".35;.1;.35" dur="1.6s" repeatCount="indefinite"/>`
681      : '') +
682    '</circle>'
683
684  const eye =
685    id === 'eye-20'
686      ? `<line x1="${n(hx + 4)}" y1="${n(hy)}" x2="${n(hx + 20)}" y2="${n(hy + 14)}" stroke="${COLORS.accent}" stroke-width="1.5" stroke-dasharray="3 3" opacity=".9">` +
687        (move
688          ? `<animate attributeName="x2" values="${n(hx + 20)};118;${n(hx + 20)}" dur="${dur}s" repeatCount="indefinite" ${keys}/>` +
689            `<animate attributeName="y2" values="${n(hy + 14)};${n(hy)};${n(hy + 14)}" dur="${dur}s" repeatCount="indefinite" ${keys}/>`
690          : '') +
691        '</line>'
692      : ''
693
694  const mid = J.reduce((sum, j) => sum + a[j][0], 0) / J.length
695  const shadow = `<ellipse cx="${n(mid)}" cy="91" rx="24" ry="2.6" fill="${COLORS.prop}" opacity=".28"/>`
696
697  return (
698    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 100" width="${width}" height="${Math.round((width * 100) / 120)}">${STYLE}` +
699    scene(art.scene) + shadow + (style === 'bold' ? '' : glow) + lines + shoe('fF') + shoe('fN') + band + gear + head + eye + (move ? cueSvg(id, art, dur) : '') + '</svg>'
700  )
701}
702
703// ---------------------------------------------------------------- braille
704
705const BRAILLE_BITS = [
706  [0x01, 0x08],
707  [0x02, 0x10],
708  [0x04, 0x20],
709  [0x40, 0x80],
710]
711
712/** Two-frame text figure for terminals: `frame` 0 or 1, `cols` characters wide. */
713export const figureText = (id: string, frame: 0 | 1, cols = 34): string[] => {
714  const art = ART[id] ?? FALLBACK
715  const pose = frame === 0 ? art.a : art.b
716  const W = cols * 2
717  const H = Math.round((W * 100) / 120)
718  const rows = Math.ceil(H / 4)
719  const grid: number[][] = Array.from({ length: rows }, () => new Array(cols).fill(0))
720  const sx = W / 120
721  const sy = H / 100
722
723  const dot = (x: number, y: number) => {
724    const cx = Math.round(x)
725    const cy = Math.round(y)
726
727    if (cx < 0 || cy < 0 || cx >= W || cy >= H) return
728
729    const r = grid[cy >> 2]
730
731    if (r) r[cx >> 1] = (r[cx >> 1] ?? 0) | (BRAILLE_BITS[cy & 3]?.[cx & 1] ?? 0)
732  }
733
734  const line = (a: Pt, b: Pt, thick = false) => {
735    const steps = Math.max(Math.abs(b[0] - a[0]) * sx, Math.abs(b[1] - a[1]) * sy, 1) * 2
736
737    for (let i = 0; i <= steps; i++) {
738      const t = i / steps
739      const x = (a[0] + (b[0] - a[0]) * t) * sx
740      const y = (a[1] + (b[1] - a[1]) * t) * sy
741
742      dot(x, y)
743      if (thick) dot(x + 1, y)
744    }
745  }
746
747  line([4, 91], [116, 91])
748
749  if (art.scene === 'chair') {
750    line([26, 44], [26, 90]); line([26, 68], [50, 68]); line([48, 68], [48, 90])
751  } else if (art.scene === 'desk') {
752    line([76, 58], [114, 58]); line([112, 58], [112, 90])
753  } else if (art.scene === 'wall') {
754    line([24, 4], [24, 90])
755  } else if (art.scene === 'mat') {
756    line([6, 88], [114, 88])
757  } else if (art.scene === 'bar') {
758    line([24, 6], [96, 6]); line([24, 6], [24, 22]); line([96, 6], [96, 22])
759  } else if (art.scene === 'wallR') {
760    line([101, 4], [101, 90])
761  } else if (art.scene === 'far') {
762    line([92, 91], [102, 69]); line([102, 69], [110, 81]); line([110, 81], [116, 73])
763  }
764
765  for (const [from, to] of BONES) line(pose[from], pose[to], true)
766
767  for (let dy = -3; dy <= 3; dy++) {
768    for (let dx = -3; dx <= 3; dx++) {
769      if (dx * dx + dy * dy <= 10) dot(pose.head[0] * sx + dx, pose.head[1] * sy + dy)
770    }
771  }
772
773  return grid.map(r => r.map(bits => String.fromCharCode(0x2800 + bits)).join(''))
774}
775
776// ------------------------------------------------------------------ charts
777
778const font = 'font-family="system-ui, -apple-system, Segoe UI, sans-serif"'
779
780const MONO = 'font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"'
781const SEGMENT_GAP = 9 // degrees between the parts of the goal ring
782const MAX_SEGMENTS = 16 // above this the goal ring is one smooth arc
783
784const polar = (r: number, deg: number): string => `${n(60 + r * Math.cos((deg * Math.PI) / 180))} ${n(60 + r * Math.sin((deg * Math.PI) / 180))}`
785
786/** Daily goal ring, one part per set of the goal, with the set count in the middle. */
787export const ringSvg = (done: number, goal: number, size = 120, isAnimated = false): string => {
788  const r = 46
789  const total = Math.max(goal, 1)
790  const reached = done >= total
791  const parts = total > MAX_SEGMENTS || total === 1 ? 1 : total
792  const gap = parts > 1 ? SEGMENT_GAP : 0
793  const span = (360 - parts * gap) / parts
794  const filled = parts === 1 ? 0 : Math.min(done, parts)
795  const fraction = Math.min(done / total, 1)
796  const c = 2 * Math.PI * r
797
798  const arcs =
799    parts === 1
800      ? `<circle cx="60" cy="60" r="${r}" fill="none" stroke="${COLORS.soft}" stroke-width="10"/>` +
801        `<circle cx="60" cy="60" r="${r}" fill="none" stroke="${COLORS.accent}" stroke-width="10" stroke-dasharray="${n(c * fraction)} ${n(c)}" transform="rotate(-90 60 60)"/>`
802      : Array.from({ length: parts }, (_, i) => {
803          const from = -90 + gap / 2 + i * (span + gap)
804          const fade = isAnimated && i < filled ? `<animate attributeName="opacity" from="0" to="1" dur=".3s" begin="${n(i * 0.12)}s" fill="freeze"/>` : ''
805
806          return (
807            `<path d="M${polar(r, from)} A${r} ${r} 0 0 1 ${polar(r, from + span)}" fill="none" stroke-width="10" ` +
808            `stroke="${i < filled ? COLORS.accent : COLORS.soft}">${fade}</path>`
809          )
810        }).join('')
811
812  return (
813    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="${size}" height="${size}">${STYLE}` +
814    arcs +
815    `<text x="60" y="66" text-anchor="middle" font-size="${`${done}/${goal}`.length > 5 ? 19 : 26}" font-weight="700" fill="${COLORS.body}" ${MONO}>${done}/${goal}</text>` +
816    `<text x="60" y="84" text-anchor="middle" font-size="9" letter-spacing=".6" fill="${COLORS.prop}" ${font}>${reached ? 'GOAL DONE' : 'SETS TODAY'}</text>` +
817    '</svg>'
818  )
819}
820
821/** Big level number with a thin progress bar to the next level. `label` is the small caption above the number. */
822export const levelBarSvg = (level: number, frac: number, width = 170, label = 'LEVEL'): string => {
823  const f = Math.max(0, Math.min(frac, 1))
824
825  return (
826    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 170 64" width="${width}" height="${Math.round((width * 64) / 170)}">${STYLE}` +
827    `<text x="0" y="11" font-size="10" letter-spacing=".8" fill="${COLORS.prop}" ${font}>${label.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</text>` +
828    `<text x="0" y="46" font-size="34" font-weight="700" fill="${COLORS.body}" ${MONO}>${level}</text>` +
829    `<rect x="0" y="54" width="170" height="8" rx="4" fill="${COLORS.soft}"/>` +
830    `<rect x="0" y="54" width="${n(170 * f)}" height="8" rx="4" fill="${COLORS.accent}"/></svg>`
831  )
832}
833
834/** One dot per day: filled when the daily goal was reached that day. */
835export const weekDotsSvg = (hits: boolean[], dot = 10): string => {
836  const gap = 5
837  const w = Math.max(hits.length, 1) * (dot + gap) - gap
838  const body = hits
839    .map((hit, i) => {
840      const color = hit ? COLORS.accent : COLORS.limb
841
842      return `<circle cx="${n(i * (dot + gap) + dot / 2)}" cy="${n(dot / 2 + 1)}" r="${n(dot / 2 - 0.75)}" fill="${hit ? color : 'none'}" stroke="${color}" stroke-width="1.5"/>`
843    })
844    .join('')
845
846  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${dot + 2}" width="${w}" height="${dot + 2}">${STYLE}${body}</svg>`
847}
848
849/** Countdown ring with mm:ss in the middle. */
850export const timerSvg = (remaining: number, total: number, phase: 'ready' | 'running', size = 120): string => {
851  const r = 46
852  const c = 2 * Math.PI * r
853  const frac = phase === 'ready' ? 1 : Math.max(0, Math.min(remaining / Math.max(total, 1), 1))
854  const color = phase === 'running' && remaining <= 5 ? COLORS.accent : COLORS.body
855  const secs = Math.max(remaining, 0)
856  const label = `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`
857
858  return (
859    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="${size}" height="${size}">${STYLE}` +
860    `<circle cx="60" cy="60" r="${r}" fill="none" stroke="${COLORS.soft}" stroke-width="9"/>` +
861    `<circle cx="60" cy="60" r="${r}" fill="none" stroke="${color}" stroke-width="9" stroke-linecap="round" ` +
862    `stroke-dasharray="${n(c * frac)} ${n(c)}" transform="rotate(-90 60 60)"/>` +
863    `<text x="60" y="68" text-anchor="middle" font-size="26" font-weight="700" fill="${color}" ${font}>${label}</text>` +
864    `<text x="60" y="86" text-anchor="middle" font-size="9" fill="${COLORS.prop}" ${font}>${phase === 'ready' ? 'ready' : 'keep going'}</text>` +
865    '</svg>'
866  )
867}
868
869/** Bars for the last days; with `goal` above 0 a dashed goal line and the bars that reach it stand out. */
870export const barsSvg = (
871  days: { label: string; sets: number }[],
872  goal: number,
873  width = 300,
874  format: (v: number) => string = v => String(v),
875): string => {
876  const max = Math.max(goal, ...days.map(d => d.sets), 1)
877  const bw = 26
878  const gap = (280 - bw * days.length) / Math.max(days.length - 1, 1)
879  const top = 14
880  const base = 96
881  const y = (v: number) => base - (v / max) * (base - top)
882  const bars = days
883    .map((d, i) => {
884      const x = 10 + i * (bw + gap)
885      const hit = goal > 0 && d.sets >= goal
886
887      return (
888        `<rect x="${n(x)}" y="${n(y(d.sets))}" width="${bw}" height="${n(Math.max(base - y(d.sets), d.sets > 0 ? 2 : 0))}" rx="5" fill="${hit ? COLORS.accent : COLORS.limb}"/>` +
889        `<text x="${n(x + bw / 2)}" y="112" text-anchor="middle" font-size="9" fill="${COLORS.prop}" ${font}>${d.label}</text>` +
890        (d.sets > 0 ? `<text x="${n(x + bw / 2)}" y="${n(y(d.sets) - 3)}" text-anchor="middle" font-size="9" fill="${COLORS.prop}" ${font}>${format(d.sets)}</text>` : '')
891      )
892    })
893    .join('')
894
895  return (
896    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 120" width="${width}" height="${Math.round((width * 120) / 300)}">${STYLE}` +
897    (goal > 0 ? `<line x1="6" y1="${n(y(goal))}" x2="294" y2="${n(y(goal))}" stroke="${COLORS.accent}" stroke-width="1" stroke-dasharray="4 4"/>` : '') +
898    bars + '</svg>'
899  )
900}
901
902/** Horizontal bars: how much of the week's work each body region got (bar) against its goal share (tick). Regions well behind stand out. */
903export const regionBarsSvg = (
904  rows: { label: string; actual: number; target: number; isFocus: boolean }[],
905  hasWork: boolean,
906  width = 320,
907): string => {
908  const top = Math.max(...rows.flatMap(r => [r.actual, r.target]), 0.01) * 1.15
909  const x0 = 118
910  const span = 150
911  const h = 6 + rows.length * 22
912  const body = rows
913    .map((r, i) => {
914      const y = 8 + i * 22
915      const len = Math.max((r.actual / top) * span, r.actual > 0 ? 2 : 0)
916      const behind = hasWork && r.actual < r.target * 0.6
917      const tick = x0 + (r.target / top) * span
918
919      return (
920        `<text x="0" y="${y + 9}" font-size="11" fill="${COLORS.body}" ${font}${r.isFocus ? ' font-weight="700"' : ''}>${r.label.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</text>` +
921        `<rect x="${x0}" y="${y}" width="${span}" height="12" rx="6" fill="${COLORS.soft}"/>` +
922        `<rect x="${x0}" y="${y}" width="${n(len)}" height="12" rx="6" fill="${behind ? COLORS.accent : COLORS.limb}"/>` +
923        `<line x1="${n(tick)}" y1="${y - 2}" x2="${n(tick)}" y2="${y + 14}" stroke="${COLORS.prop}" stroke-width="1.5"/>` +
924        `<text x="${x0 + span + 8}" y="${y + 10}" font-size="10" fill="${COLORS.prop}" ${font}>${Math.round(r.actual * 100)}%</text>`
925      )
926    })
927    .join('')
928
929  return (
930    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 ${h}" width="${width}" height="${Math.round((width * h) / 300)}">${STYLE}` +
931    body + '</svg>'
932  )
933}
934
935const BADGE_GLYPH: Record<string, string> = {
936  'first-set': 'M60 30 l8 18 l20 2 l-15 13 l5 19 l-18 -10 l-18 10 l5 -19 l-15 -13 l20 -2 z',
937  'streak-3': 'M60 28 c10 14 20 20 20 36 a20 20 0 0 1 -40 0 c0 -10 6 -14 8 -22 c4 4 6 8 8 10 c2 -8 2 -16 4 -24 z',
938  'build-break': 'M44 36 h32 v8 h-8 v24 h8 v8 h-32 v-8 h8 v-24 h-8 z',
939}
940
941/** One badge medal with no label; it pops in when `isPopping`. */
942export const medalSvg = (id: string, size = 56, isPopping = true): string => {
943  const glyph = BADGE_GLYPH[id] ?? BADGE_GLYPH['first-set']
944  const pop = isPopping
945    ? `<animateTransform attributeName="transform" type="scale" values="0.2;1.18;1" keyTimes="0;.65;1" dur=".6s" repeatCount="1" additive="sum" calcMode="spline" keySplines=".2 .8 .2 1;.4 0 .6 1"/>`
946    : ''
947
948  return (
949    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="${size}" height="${size}">${STYLE}` +
950    `<g transform-origin="40 40"><circle cx="40" cy="40" r="30" fill="${COLORS.soft}" stroke="${COLORS.accent}" stroke-width="3.5"/>` +
951    `<path d="${glyph}" fill="${COLORS.accent}" transform="translate(40 40) scale(.6) translate(-60 -45)"/>${pop}</g></svg>`
952  )
953}
954
955/** A grid of badge medals: earned ones in colour, locked ones faded. */
956export const badgesSvg = (items: { id: string; name: string; earned: boolean }[], perRow = 4, width = 340): string => {
957  const cell = 80
958  const rows = Math.ceil(items.length / perRow)
959  const body = items
960    .map((b, i) => {
961      const x = (i % perRow) * cell
962      const y = Math.floor(i / perRow) * 92
963      const color = b.earned ? COLORS.accent : COLORS.prop
964      const glyph = BADGE_GLYPH[b.id] ?? BADGE_GLYPH['first-set']
965
966      return (
967        `<g transform="translate(${x} ${y})" opacity="${b.earned ? 1 : 0.4}">` +
968        `<circle cx="40" cy="34" r="26" fill="${COLORS.soft}" stroke="${color}" stroke-width="3"/>` +
969        `<path d="${glyph}" fill="${color}" transform="translate(40 34) scale(.55) translate(-60 -45)"/>` +
970        `<text x="40" y="80" text-anchor="middle" font-size="9" fill="${COLORS.prop}" ${font}>${b.name.replace(/&/g, '&amp;')}</text></g>`
971      )
972    })
973    .join('')
974
975  return (
976    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${cell * perRow} ${rows * 92}" width="${width}" height="${Math.round((width * rows * 92) / (cell * perRow))}">${STYLE}` +
977    body + '</svg>'
978  )
979}
980
981/** Podium for the top three of a board. */
982export const podiumSvg = (rows: { nickname: string; points: number }[], width = 300): string => {
983  const order = [rows[1], rows[0], rows[2]]
984  const heights = [44, 62, 32]
985  const medal = [COLORS.body, COLORS.accent, COLORS.limb]
986  const cells = order
987    .map((r, i) => {
988      if (!r) return ''
989
990      const h = heights[i] ?? 30
991      const x = 20 + i * 90
992      const name = r.nickname.length > 11 ? `${r.nickname.slice(0, 10)}…` : r.nickname
993
994      return (
995        `<rect x="${x}" y="${100 - h}" width="80" height="${h}" rx="6" fill="${i === 1 ? COLORS.accent : COLORS.soft}" stroke="${medal[i]}" stroke-width="2"/>` +
996        `<text x="${x + 40}" y="${100 - h + 20}" text-anchor="middle" font-size="16" font-weight="700" fill="${i === 1 ? COLORS.on : medal[i]}" ${MONO}>${i === 1 ? 1 : i === 0 ? 2 : 3}</text>` +
997        `<text x="${x + 40}" y="${100 - h - 14}" text-anchor="middle" font-size="10" font-weight="600" fill="${COLORS.body}" ${font}>${name.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</text>` +
998        `<text x="${x + 40}" y="${100 - h - 3}" text-anchor="middle" font-size="9" fill="${COLORS.prop}" ${font}>${r.points} pts</text>`
999      )
1000    })
1001    .join('')
1002
1003  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 108" width="${width}" height="${Math.round((width * 108) / 300)}">${STYLE}${cells}</svg>`
1004}
1005
1006/** Thin progress bar, `frac` from 0 to 1. */
1007export const xpBarSvg = (frac: number, width = 180): string => {
1008  const f = Math.max(0, Math.min(frac, 1))
1009
1010  return (
1011    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 10" width="${width}" height="${Math.round(width / 18)}">${STYLE}` +
1012    `<rect x="0" y="1" width="180" height="8" rx="4" fill="${COLORS.soft}"/>` +
1013    `<rect x="0" y="1" width="${n(180 * f)}" height="8" rx="4" fill="${COLORS.accent}"/></svg>`
1014  )
1015}
1016
1017/** A short burst of falling confetti for a reached goal. */
1018export const confettiSvg = (width = 300): string => {
1019  const palette = [COLORS.accent, COLORS.body, COLORS.limb, COLORS.prop]
1020  let seed = 7
1021  const rnd = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648
1022  const bits = Array.from({ length: 28 }, (_, i) => {
1023    const x = Math.round(rnd() * 290) + 5
1024    const size = 4 + Math.round(rnd() * 4)
1025    const delay = (rnd() * 1.2).toFixed(2)
1026    const dur = (1.6 + rnd() * 1.2).toFixed(2)
1027    const spin = Math.round(rnd() * 360)
1028
1029    return (
1030      `<rect x="${x}" y="-10" width="${size}" height="${size * 1.6}" rx="1" fill="${palette[i % palette.length]}">` +
1031      `<animateTransform attributeName="transform" type="translate" values="0 0;${Math.round(rnd() * 20 - 10)} 100" dur="${dur}s" begin="${delay}s" repeatCount="2"/>` +
1032      `<animate attributeName="opacity" values="1;1;0" dur="${dur}s" begin="${delay}s" repeatCount="2"/>` +
1033      `<animateTransform attributeName="transform" additive="sum" type="rotate" values="0;${spin}" dur="${dur}s" begin="${delay}s" repeatCount="2"/></rect>`
1034    )
1035  }).join('')
1036
1037  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 80" width="${width}" height="${Math.round((width * 80) / 300)}">${STYLE}${bits}</svg>`
1038}
1039
1040// ------------------------------------------------------------------- icons
1041
1042const ICONS = {
1043  flame: 'M12 2c1 4 6 6 6 12a6 6 0 0 1-12 0c0-3 2-5 3-8 1 1 2 2 3 4 1-2 1-5 0-8z',
1044  bolt: 'M13 2L4 14h7l-1 8 9-12h-7z',
1045  trophy: 'M7 4h10v6a5 5 0 0 1-10 0z M7 6H4v2a3 3 0 0 0 3 3 M17 6h3v2a3 3 0 0 1-3 3 M12 15v4 M8 21h8',
1046  chart: 'M5 21V11 M12 21V4 M19 21v-7',
1047  sliders: 'M4 7h9 M17 7h3 M4 17h3 M11 17h9 M13 7a2 2 0 1 0 4 0 2 2 0 1 0-4 0 M7 17a2 2 0 1 0 4 0 2 2 0 1 0-4 0',
1048  user: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M4 21a8 8 0 0 1 16 0',
1049  play: 'M7 4l13 8-13 8z',
1050  shuffle: 'M3 7h4c5 0 5 10 10 10h4 M3 17h4c2 0 3-1.5 4-3 M13 10c1-1.5 2-3 4-3h4 M18 4l3 3-3 3 M18 14l3 3-3 3',
1051  check: 'M5 12.5l4.5 4.5L19 7',
1052  clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z M12 7v5l3 2',
1053  heart: 'M12 20s-8-5-8-10.5A4.5 4.5 0 0 1 12 7a4.5 4.5 0 0 1 8 2.5C20 15 12 20 12 20z',
1054  target: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M12 12h.01',
1055  medal: 'M12 15a6 6 0 1 0 0-12 6 6 0 0 0 0 12z M8.5 14L7 21l5-3 5 3-1.5-7',
1056  speaker: 'M4 9v6h4l5 4V5L8 9z M16 9a4 4 0 0 1 0 6 M18.5 6.5a8 8 0 0 1 0 11',
1057  globe: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z M3 12h18 M12 3c3 3 3 15 0 18 M12 3c-3 3-3 15 0 18',
1058  shield: 'M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z M9 12l2 2 4-4',
1059}
1060
1061export type IconName = keyof typeof ICONS
1062
1063/** The same glyphs as text, for surfaces that cannot draw an Svg. */
1064export const ICON_TEXT: Record<IconName, string> = {
1065  flame: '🔥', bolt: '⚡', trophy: '🏆', chart: '▤', sliders: '⚙', user: '☺', play: '▶', shuffle: '⇄',
1066  check: '✓', clock: '◷', heart: '♥', target: '◎', medal: '★', speaker: '♪', globe: '◍', shield: '⛨',
1067}
1068
1069/** A 24x24 line icon in the current accent (or `color`); `slot` centres it in a larger square so icons line up. */
1070export const iconSvg = (name: IconName, size = 18, color?: string, slot?: number): string => {
1071  const box = slot ?? size
1072  const units = (box * 24) / size
1073  const d = (units - 24) / 2
1074
1075  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${n(-d)} ${n(-d)} ${n(units)} ${n(units)}" width="${box}" height="${box}" fill="none" stroke="${color ?? COLORS.body}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${STYLE}<path d="${ICONS[name]}"/></svg>`
1076}
1077
1078// -------------------------------------------------------- design components
1079
1080const hash = (text: string): number => [...text].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7)
1081
1082const AVATAR_COLORS = ['#4b5563', '#6b7280', '#57534e', '#52525b', '#64748b', '#71717a']
1083
1084/** A round avatar with the nickname's first letter, colour picked from the name. */
1085export const avatarSvg = (name: string, size = 28): string => {
1086  const color = AVATAR_COLORS[hash(name) % AVATAR_COLORS.length] ?? COLORS.body
1087  const letter = (name.trim()[0] ?? '?').toUpperCase().replace(/&/g, '&amp;').replace(/</g, '&lt;')
1088
1089  return (
1090    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="${size}" height="${size}">${STYLE}` +
1091    `<circle cx="20" cy="20" r="19" fill="${color}"/>` +
1092    `<text x="20" y="27" text-anchor="middle" font-size="19" font-weight="700" fill="#fff" ${font}>${letter}</text></svg>`
1093  )
1094}
1095
1096/** Rank marker: gold, silver and bronze discs for 1-3, a quiet number after. */
1097export const rankSvg = (rank: number, size = 24): string => {
1098  const medal = [COLORS.accent, COLORS.body, COLORS.limb][rank - 1]
1099
1100  return (
1101    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="${size}" height="${size}">${STYLE}` +
1102    (medal ? `<circle cx="20" cy="20" r="18" fill="${medal}"/>` : `<circle cx="20" cy="20" r="18" fill="none" stroke="${COLORS.prop}" stroke-width="2"/>`) +
1103    `<text x="20" y="26" text-anchor="middle" font-size="18" font-weight="700" fill="${medal ? COLORS.on : COLORS.prop}" ${font}>${rank}</text></svg>`
1104  )
1105}
1106
1107/** Shield with the level number; the ring around it fills with progress to the next level. */
1108export const levelBadgeSvg = (level: number, frac: number, size = 64): string => {
1109  const r = 28
1110  const c = 2 * Math.PI * r
1111
1112  return (
1113    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" width="${size}" height="${size}">${STYLE}` +
1114    `<circle cx="36" cy="36" r="${r}" fill="none" stroke="${COLORS.soft}" stroke-width="5"/>` +
1115    `<circle cx="36" cy="36" r="${r}" fill="none" stroke="${COLORS.accent}" stroke-width="5" stroke-linecap="round" stroke-dasharray="${n(c * Math.max(0, Math.min(frac, 1)))} ${n(c)}" transform="rotate(-90 36 36)"/>` +
1116    `<path d="M36 17l13 5v10c0 9-6 15-13 17-7-2-13-8-13-17V22z" fill="${COLORS.shirt}"/>` +
1117    `<text x="36" y="40" text-anchor="middle" font-size="16" font-weight="800" fill="${COLORS.on}" ${font}>${level}</text></svg>`
1118  )
1119}
1120
1121/** A flame that flickers; taller for longer streaks. */
1122export const flameSvg = (streak: number, size = 40, isAnimated = true, slot?: number): string => {
1123  const box = slot ?? size
1124  const units = (box * 48) / size
1125  const d = (units - 48) / 2
1126  const hot = streak > 0
1127  const outer = hot ? COLORS.body : COLORS.limb
1128  const inner = hot ? COLORS.on : COLORS.soft
1129  const flicker = isAnimated && hot
1130    ? `<animateTransform attributeName="transform" type="scale" values="1 1;1.04 1.08;0.98 1;1 1" dur="1.4s" repeatCount="indefinite" additive="sum"/>`
1131    : ''
1132
1133  return (
1134    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${n(-d)} ${n(-d)} ${n(units)} ${n(units)}" width="${box}" height="${box}">${STYLE}` +
1135    `<g transform-origin="24 44">` +
1136    `<path d="M24 3c2 8 14 12 14 26a14 14 0 0 1-28 0c0-7 4-10 6-16 3 3 5 6 5 10 2-5 3-12 3-20z" fill="${outer}">${flicker}</path>` +
1137    `<path d="M24 22c1 5 7 7 7 13a7 7 0 0 1-14 0c0-4 3-6 4-9 1 1 2 3 3 4z" fill="${inner}"/></g></svg>`
1138  )
1139}
1140
1141/** Dots for a flow of steps: done filled, current long pill, rest hollow. */
1142export const stepsSvg = (current: number, total: number, width = 160): string => {
1143  const gap = 18
1144  const items = Array.from({ length: total }, (_, i) => {
1145    const x = 6 + i * gap + (i > current ? 12 : 0)
1146
1147    return i === current
1148      ? `<rect x="${x}" y="3" width="24" height="8" rx="4" fill="${COLORS.body}"/>`
1149      : `<circle cx="${x + 4}" cy="7" r="4" ${i < current ? `fill="${COLORS.body}"` : `fill="none" stroke="${COLORS.prop}" stroke-width="1.5"`}/>`
1150  }).join('')
1151
1152  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${6 + total * gap + 16} 14" width="${width}" height="${Math.round((width * 14) / (6 + total * gap + 16))}">${STYLE}${items}</svg>`
1153}
1154
1155export type EmptyKind = 'board' | 'team' | 'offline' | 'rest' | 'new'
1156
1157/** Friendly illustration for a screen with nothing in it yet. */
1158export const emptySvg = (kind: EmptyKind, width = 200): string => {
1159  const a = COLORS.body
1160  const b = COLORS.accent
1161  const g = COLORS.prop
1162  const scene: Record<EmptyKind, string> = {
1163    board:
1164      `<rect x="40" y="62" width="30" height="28" rx="5" fill="${COLORS.soft}" stroke="${g}" stroke-width="2"/>` +
1165      `<rect x="85" y="44" width="30" height="46" rx="5" fill="${COLORS.soft}" stroke="${b}" stroke-width="2"/>` +
1166      `<rect x="130" y="72" width="30" height="18" rx="5" fill="${COLORS.soft}" stroke="${g}" stroke-width="2"/>` +
1167      `<path d="M100 14l4 9 10 1-7.5 6.5 2.5 10-9-5.5-9 5.5 2.5-10L86 24l10-1z" fill="${b}"><animateTransform attributeName="transform" type="translate" values="0 0;0 -3;0 0" dur="2s" repeatCount="indefinite"/></path>`,
1168    team:
1169      `<circle cx="70" cy="48" r="13" fill="${a}"/><path d="M44 90a26 26 0 0 1 52 0z" fill="${a}" opacity=".85"/>` +
1170      `<circle cx="130" cy="52" r="11" fill="${b}"/><path d="M108 90a22 22 0 0 1 44 0z" fill="${b}" opacity=".85"/>`,
1171    offline:
1172      `<path d="M52 78a20 20 0 0 1 4-39 28 28 0 0 1 53-4 22 22 0 0 1 3 43z" fill="${COLORS.soft}" stroke="${g}" stroke-width="2.5" stroke-linejoin="round"/>` +
1173      `<path d="M92 56l-14 14M78 56l14 14" stroke="${b}" stroke-width="3.5" stroke-linecap="round"/>`,
1174    rest:
1175      `<circle cx="100" cy="52" r="26" fill="${COLORS.soft}" stroke="${a}" stroke-width="3"/>` +
1176      `<path d="M100 38v16l10 6" stroke="${a}" stroke-width="3.5" stroke-linecap="round" fill="none"/>` +
1177      `<path d="M150 24h14l-14 14h14M168 12h10l-10 10h10" stroke="${b}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>`,
1178    new:
1179      `<circle cx="100" cy="52" r="30" fill="${COLORS.soft}" stroke="${a}" stroke-width="3"/>` +
1180      `<path d="M100 38v28M86 52h28" stroke="${a}" stroke-width="5" stroke-linecap="round"/>` +
1181      `<circle cx="56" cy="30" r="4" fill="${b}"/><circle cx="148" cy="76" r="5" fill="${b}"/><circle cx="150" cy="28" r="3" fill="${a}"/>`,
1182  }
1183
1184  return (
1185    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100" width="${width}" height="${Math.round(width / 2)}">${STYLE}` +
1186    `<line x1="20" y1="91" x2="180" y2="91" stroke="${g}" stroke-width="2" stroke-linecap="round"/>` + scene[kind] + '</svg>'
1187  )
1188}
1189
1190/** App mark: a stylised figure in a rounded square. */
1191export const logoSvg = (size = 28): string => {
1192  // five-part goal ring, three parts reached, around a figure with its arms up
1193  const ring = Array.from({ length: 5 }, (_, i) => {
1194    const from = -90 + 8 + i * 72
1195    const at = (deg: number): string => `${n(32 + 23 * Math.cos((deg * Math.PI) / 180))} ${n(32 + 23 * Math.sin((deg * Math.PI) / 180))}`
1196
1197    return `<path d="M${at(from)} A23 23 0 0 1 ${at(from + 56)}" fill="none" stroke-width="5.5" stroke="${i < 3 ? 'var(--c-sig)' : COLORS.on}"${i < 3 ? '' : ' stroke-opacity=".28"'}/>`
1198  }).join('')
1199
1200  return (
hooks/badges.ts 33 lines
1import type { Progress } from '../types'
2import { weekAreas } from './game'
3
4export type Badge = {
5  id: string
6  name: string
7  desc: string
8  test: (p: Progress, day: string) => boolean
9}
10
11export const BADGES: Badge[] = [
12  { id: 'first-set', name: 'First step', desc: 'Finish your first set', test: p => p.totalSets >= 1 },
13  { id: 'ten-sets', name: 'Warm engine', desc: '10 sets in total', test: p => p.totalSets >= 10 },
14  { id: 'hundred-sets', name: 'Century', desc: '100 sets in total', test: p => p.totalSets >= 100 },
15  { id: 'streak-3', name: 'On a roll', desc: '3 day streak', test: p => p.bestStreak >= 3 },
16  { id: 'streak-7', name: 'Full week', desc: '7 day streak', test: p => p.bestStreak >= 7 },
17  { id: 'streak-30', name: 'Habit formed', desc: '30 day streak', test: p => p.bestStreak >= 30 },
18  { id: 'build-break', name: 'Build break', desc: 'Do a set while Claude is working', test: p => p.turnSets >= 1 },
19  { id: 'compile-conquer', name: 'Compile and conquer', desc: '10 sets while Claude is working', test: p => p.turnSets >= 10 },
20  { id: 'all-rounder', name: 'All-rounder', desc: '5 different body areas in one week', test: (p, day) => Object.keys(weekAreas(p, day)).length >= 5 },
21  { id: 'early-bird', name: 'Early bird', desc: 'A set before 9 am', test: p => p.earlyBirds >= 1 },
22  { id: 'hour-power', name: 'Hour of power', desc: '60 active minutes in total', test: p => p.totalSeconds >= 3600 },
23]
24
25export const badgeName = (id: string): string => BADGES.find(b => b.id === id)?.name ?? id
26
27/** Adds every badge newly earned; returns the updated progress and the new ids. */
28export const award = (p: Progress, day: string): { progress: Progress; earned: string[] } => {
29  const earned = BADGES.filter(b => !p.badges.includes(b.id) && b.test(p, day)).map(b => b.id)
30
31  return earned.length === 0 ? { progress: p, earned } : { progress: { ...p, badges: [...p.badges, ...earned] }, earned }
32}
33
hooks/catalog.ts 110 lines
1import type { Exercise } from '../types'
2import { metFor } from './calories'
3import { effortOf, MUSCLES } from './muscles'
4
5const e = (
6  id: string,
7  name: string,
8  area: Exercise['area'],
9  level: Exercise['level'],
10  seconds: number,
11  howto: string,
12  o: Partial<Pick<Exercise, 'isQuiet' | 'isHighImpact' | 'needs' | 'avoidIf' | 'places' | 'equipment'>> = {},
13): Exercise => {
14  const [kind, pattern, main, assist] = MUSCLES[id] ?? ['strength', 'core', [], []]
15
16  return {
17  id,
18  name,
19  area,
20  level,
21  seconds,
22  howto,
23  isQuiet: o.isQuiet ?? true,
24  isHighImpact: o.isHighImpact ?? false,
25  needs: o.needs ?? [],
26  avoidIf: o.avoidIf ?? [],
27  places: o.places ?? ['office', 'home'],
28  equipment: o.equipment ?? [],
29  muscles: { main, assist },
30  kind,
31  pattern,
32  effort: effortOf(metFor(id)),
33  }
34}
35
36export const CATALOG: Exercise[] = [
37  e('chair-squat', 'Chair squats', 'legs', 'beginner', 40, 'Stand in front of your chair, sit back until you touch it, stand up. Keep your chest tall.', { avoidIf: ['knees'] }),
38  e('squat', 'Bodyweight squats', 'legs', 'intermediate', 40, 'Feet shoulder-width, sit back and down, thighs parallel, drive up through your heels.', { needs: ['squat'], avoidIf: ['knees'] }),
39  e('jump-squat', 'Jump squats', 'legs', 'advanced', 30, 'Squat, then explode up and land softly. Reset between reps.', { isQuiet: false, isHighImpact: true, needs: ['squat', 'jump'], avoidIf: ['knees', 'back'], places: ['home'] }),
40  e('calf-raise', 'Calf raises', 'legs', 'beginner', 40, 'Stand tall, rise onto your toes, lower slowly. Hold the desk for balance.'),
41  e('wall-sit', 'Wall sit', 'legs', 'intermediate', 30, 'Back flat on the wall, thighs parallel to the floor. Hold.', { avoidIf: ['knees'] }),
42  e('reverse-lunge', 'Reverse lunges', 'legs', 'intermediate', 40, 'Step back, lower the back knee toward the floor, push up. Alternate legs.', { needs: ['lunge'], avoidIf: ['knees'] }),
43  e('glute-bridge-stand', 'Standing glute squeezes', 'legs', 'beginner', 30, 'Stand tall, squeeze your glutes hard for 3 seconds, release. Repeat.'),
44  e('desk-pushup', 'Desk push-ups', 'upper', 'beginner', 30, 'Hands on the desk edge, body straight, lower your chest to the desk and press back.', { avoidIf: ['wrists', 'shoulders'] }),
45  e('pushup', 'Push-ups', 'upper', 'intermediate', 30, 'Hands under shoulders, body in one line, chest to the floor, press up.', { needs: ['pushup'], avoidIf: ['wrists', 'shoulders'], places: ['home'] }),
46  e('diamond-pushup', 'Diamond push-ups', 'upper', 'advanced', 30, 'Hands form a diamond under your chest. Lower slowly, press up.', { needs: ['pushup'], avoidIf: ['wrists', 'shoulders'], places: ['home'] }),
47  e('chair-dip', 'Chair dips', 'upper', 'intermediate', 30, 'Hands on the seat edge, legs forward, lower your hips by bending the elbows, press up.', { avoidIf: ['wrists', 'shoulders'] }),
48  e('wall-angel', 'Wall angels', 'upper', 'beginner', 40, 'Back and arms against a wall, slide your arms up and down like a snow angel.', { avoidIf: ['shoulders'] }),
49  e('plank', 'Plank', 'core', 'intermediate', 30, 'Forearms down, body in one straight line, brace your core and breathe.', { needs: ['plank'], avoidIf: ['shoulders'], places: ['home'] }),
50  e('knee-plank', 'Knee plank', 'core', 'beginner', 20, 'Same as a plank, but with knees on the floor. Keep hips in line.', { avoidIf: ['shoulders'], places: ['home'] }),
51  e('seated-knee-lift', 'Seated knee lifts', 'core', 'beginner', 40, 'Sit tall, hold the seat, lift both knees toward your chest, lower slowly.', { avoidIf: ['back'] }),
52  e('mountain-climber', 'Mountain climbers', 'core', 'advanced', 30, 'High plank, drive your knees to your chest in a quick run.', { isQuiet: false, isHighImpact: true, needs: ['plank'], avoidIf: ['wrists', 'shoulders'], places: ['home'] }),
53  e('standing-crunch', 'Standing crunches', 'core', 'beginner', 40, 'Hands behind your head, lift one knee and bring the opposite elbow to it. Alternate.'),
54  e('chair-twist', 'Seated twists', 'back', 'beginner', 40, 'Sit tall, rotate your torso gently left and right, hips facing forward.', { avoidIf: ['back'] }),
55  e('cat-cow-seated', 'Seated cat-cow', 'back', 'beginner', 40, 'Hands on knees, arch your back and look up, then round it and tuck your chin.'),
56  e('hip-hinge', 'Hip hinges', 'back', 'beginner', 40, 'Hands on hips, push your hips back with a flat spine, return upright.', { avoidIf: ['back'] }),
57  e('neck-roll', 'Neck rolls', 'neck', 'beginner', 30, 'Slowly drop your ear to each shoulder, then chin to chest. No full circles.', { avoidIf: ['neck'] }),
58  e('chin-tuck', 'Chin tucks', 'neck', 'beginner', 30, 'Draw your chin straight back as if making a double chin. Hold 3 seconds, release.', { avoidIf: ['neck'] }),
59  e('shoulder-roll', 'Shoulder rolls', 'upper', 'beginner', 30, 'Roll your shoulders big and slow, forward then backward.'),
60  e('wrist-circles', 'Wrist and finger stretch', 'wrists', 'beginner', 40, 'Circle your wrists both ways, then stretch fingers wide and make a soft fist.'),
61  e('prayer-stretch', 'Prayer stretch', 'wrists', 'beginner', 30, 'Palms together at chest height, lower your hands slowly until you feel the forearm stretch.'),
62  e('eye-20', '20-20-20 eye break', 'eyes', 'beginner', 20, 'Look at something 20 feet (6 m) away. Blink slowly and let your eyes relax.'),
63  e('jumping-jacks', 'Jumping jacks', 'full', 'intermediate', 30, 'Jump feet out while raising your arms, jump back in. Keep a steady rhythm.', { isQuiet: false, isHighImpact: true, needs: ['jump'], avoidIf: ['knees'], places: ['home'] }),
64  e('march', 'Marching in place', 'full', 'beginner', 40, 'Lift your knees to hip height and swing your arms. Keep it brisk.'),
65  e('burpee', 'Burpees', 'full', 'advanced', 30, 'Squat, kick back to plank, push-up, jump in, jump up.', { isQuiet: false, isHighImpact: true, needs: ['pushup', 'squat', 'jump'], avoidIf: ['knees', 'back', 'wrists', 'shoulders'], places: ['home'] }),
66
67  // ---- office: quiet, small, a chair, a desk or a wall is enough
68  e('seated-leg-extension', 'Seated leg extensions', 'legs', 'beginner', 40, 'Sit tall, straighten one leg until it is level with the hip, hold a second, lower. Switch legs.', { avoidIf: ['knees'] }),
69  e('figure-four', 'Seated figure-four stretch', 'legs', 'beginner', 40, 'Sit tall, rest one ankle on the opposite knee and lean forward gently until you feel the hip stretch. Switch sides.', { avoidIf: ['knees'] }),
70  e('quad-stretch', 'Standing quad stretch', 'legs', 'beginner', 40, 'Hold the desk for balance, pull one heel toward your glutes with your knees together. Switch legs.', { avoidIf: ['knees'] }),
71  e('glute-kickback', 'Standing glute kickbacks', 'legs', 'beginner', 40, 'Hold the desk, extend one leg straight back and squeeze your glute at the top. Switch legs.'),
72  e('wall-pushup', 'Wall push-ups', 'upper', 'beginner', 30, 'Hands on the wall at chest height, body in one line. Bend your elbows to bring your chest to the wall, then press back.', { avoidIf: ['wrists', 'shoulders'] }),
73  e('wall-chest-stretch', 'Wall chest stretch', 'upper', 'beginner', 30, 'Place one forearm on a wall or door frame and turn away until you feel your chest open. Switch arms.', { avoidIf: ['shoulders'] }),
74  e('calf-stretch', 'Wall calf stretch', 'legs', 'beginner', 40, 'Hands on a wall, one foot back with the heel down and the leg straight. Lean in until you feel the calf stretch. Switch legs.'),
75  e('scapula-squeeze', 'Shoulder-blade squeezes', 'back', 'beginner', 40, 'Sit tall with your elbows bent. Pull your elbows back and squeeze your shoulder blades together for 2 seconds, then release.'),
76  e('y-raise', 'Standing Y raises', 'back', 'beginner', 40, 'Stand tall with your arms low in front. Lift them overhead in a Y shape, thumbs up, squeezing your shoulder blades, then lower slowly.', { avoidIf: ['shoulders'] }),
77  e('overhead-reach', 'Overhead reach', 'back', 'beginner', 30, 'Interlace your fingers, press your palms to the ceiling and reach up tall. Breathe, lower and repeat.'),
78
79  // ---- home: floor work and bigger moves
80  e('glute-bridge', 'Glute bridges', 'legs', 'beginner', 40, 'Lie on your back, knees bent, feet flat. Press through your heels to lift your hips, squeeze, then lower slowly.', { places: ['home'] }),
81  e('crunch', 'Crunches', 'core', 'beginner', 40, 'Lie on your back, knees bent, hands behind your head. Curl your shoulders off the floor, then lower slowly.', { places: ['home'], avoidIf: ['neck'] }),
82  e('bicycle-crunch', 'Bicycle crunches', 'core', 'intermediate', 40, 'Lie back with your hands by your head. Bring one knee toward the opposite elbow while the other leg extends. Alternate smoothly.', { places: ['home'], avoidIf: ['neck', 'back'] }),
83  e('leg-raise', 'Leg raises', 'core', 'intermediate', 30, 'Lie on your back with straight legs. Lift them to vertical, then lower slowly without arching your back.', { places: ['home'], avoidIf: ['back'] }),
84  e('sit-up', 'Sit-ups', 'core', 'intermediate', 40, 'Lie on your back, knees bent. Curl all the way up to sitting, then lower with control.', { places: ['home'], avoidIf: ['back', 'neck'] }),
85  e('superman', 'Supermans', 'back', 'beginner', 30, 'Lie face down with your arms forward. Lift arms, chest and legs a little off the floor, hold, then lower.', { places: ['home'], avoidIf: ['back'] }),
86  e('bird-dog', 'Bird dogs', 'core', 'beginner', 40, 'On hands and knees, reach one arm forward and the opposite leg back. Hold a second, then switch. Keep your hips level.', { places: ['home'], avoidIf: ['wrists'] }),
87  e('childs-pose', "Child's pose", 'back', 'beginner', 40, 'Kneel, sit back on your heels and reach your arms forward with your forehead down. Breathe slowly.', { places: ['home'], avoidIf: ['knees'] }),
88  e('downward-dog', 'Downward dog', 'full', 'beginner', 40, 'Hands and feet on the floor, hips high in an upside-down V. Press the floor away and pedal your heels.', { places: ['home'], avoidIf: ['wrists', 'shoulders'] }),
89  e('knee-pushup', 'Knee push-ups', 'upper', 'beginner', 30, 'Hands under shoulders, knees on the floor, body in one line from knees to head. Lower your chest, then press up.', { places: ['home'], avoidIf: ['wrists', 'shoulders'] }),
90  e('shoulder-taps', 'Plank shoulder taps', 'core', 'intermediate', 30, 'High plank with feet wide. Tap one shoulder with the opposite hand while your hips stay still. Alternate.', { places: ['home'], needs: ['plank'], avoidIf: ['wrists', 'shoulders'] }),
91  e('inchworm', 'Inchworms', 'full', 'intermediate', 40, 'Stand, fold forward, walk your hands out to a plank, walk them back and stand up.', { places: ['home'], needs: ['plank'], avoidIf: ['wrists', 'shoulders', 'back'] }),
92  e('forward-lunge', 'Forward lunges', 'legs', 'intermediate', 40, 'Step forward, lower your back knee toward the floor, then push back to standing. Alternate legs.', { places: ['home'], needs: ['lunge'], avoidIf: ['knees'] }),
93  e('high-knees', 'High knees', 'full', 'intermediate', 30, 'Run in place, driving your knees to hip height and pumping your arms.', { places: ['home'], isQuiet: false, isHighImpact: true, needs: ['jump'], avoidIf: ['knees'] }),
94
95  // ---- with equipment (office or home, if you have it)
96  e('db-curl', 'Dumbbell curls', 'upper', 'beginner', 40, 'Stand tall with your elbows at your sides. Curl the dumbbells to your shoulders and lower slowly.', { equipment: ['dumbbell'], places: ['home'] }),
97  e('db-press', 'Dumbbell shoulder press', 'upper', 'intermediate', 40, 'Hold the dumbbells at shoulder height, press overhead until your arms are straight, then lower with control.', { equipment: ['dumbbell'], places: ['home'], avoidIf: ['shoulders'] }),
98  e('db-goblet-squat', 'Goblet squats', 'legs', 'intermediate', 40, 'Hold one dumbbell at your chest, sit down between your knees with your chest tall, then stand up.', { equipment: ['dumbbell'], places: ['home'], needs: ['squat'], avoidIf: ['knees'] }),
99  e('db-row', 'Bent-over dumbbell rows', 'back', 'intermediate', 40, 'Hinge forward with a flat back and the dumbbells hanging. Pull your elbows back and squeeze your shoulder blades.', { equipment: ['dumbbell'], places: ['home'], avoidIf: ['back'] }),
100  e('db-rdl', 'Dumbbell Romanian deadlifts', 'back', 'intermediate', 40, 'Hold the dumbbells in front of your thighs. Push your hips back with a flat spine, lower to mid-shin, then stand tall.', { equipment: ['dumbbell'], places: ['home'], avoidIf: ['back'] }),
101  e('db-tricep-ext', 'Overhead triceps extensions', 'upper', 'beginner', 40, 'Hold one dumbbell overhead with both hands. Bend your elbows to lower it behind your head, then extend.', { equipment: ['dumbbell'], places: ['home'], avoidIf: ['shoulders'] }),
102  e('band-row', 'Seated band rows', 'back', 'beginner', 40, 'Sit with your legs out and a band around your feet. Pull the band to your ribs, squeeze your shoulder blades, return slowly.', { equipment: ['band'] }),
103  e('band-curl', 'Band curls', 'upper', 'beginner', 40, 'Stand on the band with your elbows at your sides. Curl your hands to your shoulders and lower slowly.', { equipment: ['band'] }),
104  e('kb-swing', 'Kettlebell swings', 'full', 'advanced', 30, 'Hinge at the hips and hike the kettlebell back, then snap your hips forward to swing it to chest height.', { equipment: ['kettlebell'], places: ['home'], isQuiet: false, needs: ['squat'], avoidIf: ['back'] }),
105  e('pullup', 'Pull-ups', 'back', 'advanced', 30, 'Hang from the bar with straight arms. Pull until your chin passes the bar, then lower under control.', { equipment: ['bar'], places: ['home'], avoidIf: ['shoulders'] }),
106  e('dead-hang', 'Dead hang', 'back', 'beginner', 30, 'Hang from the bar with relaxed shoulders and a firm grip. Breathe and let your spine lengthen.', { equipment: ['bar'], places: ['home'], avoidIf: ['shoulders'] }),
107]
108
109export const byId = (id: string): Exercise | undefined => CATALOG.find(x => x.id === id)
110
hooks/calories.ts 115 lines
1// Active calories for a set, from MET values and the person's own body data.
2//
3// MET values: 2024 Adult Compendium of Physical Activities (pacompendium.com), conditioning exercise:
4//   02020 vigorous calisthenics (push-ups, sit-ups, jumping jacks, burpees) 7.5
5//   02022 moderate calisthenics (push-ups, sit-ups, pull-ups, lunges)       3.8
6//   02024 light calisthenics (curl-ups, crunches, plank)                    2.8
7//   02056 body weight resistance, general (squat, lunge, push-up, crunch)    3.0
8//   02057 body weight resistance, high intensity                             6.5
9//   02101 stretching, mild / 02150 yoga, hatha                               2.3
10//   02054 resistance training, multiple exercises, 8-15 reps (dumbbells, bands) 3.5
11//   02058 kettlebell swings                                                   9.8
12//   02064 home exercise, general                                              3.8
13// Marching in place is not listed there: 3.5 is an estimate from brisk walking.
14// Standing quietly counts as 1.3 (the eye break).
15//
16// Formula: active kcal = (MET - 1) x resting kcal per hour x hours.
17// Resting rate comes from the Mifflin-St Jeor equation; 1 MET is one person's resting rate, so
18// scaling by it fits the estimate to age, weight, height and sex. Without a height it falls
19// back to the standard 1 kcal per kg per hour. Estimates are good to roughly +/- 20-30%.
20import type { Profile } from '../types'
21
22const MET: Record<string, number> = {
23  'chair-squat': 3.0,
24  squat: 3.8,
25  'jump-squat': 6.5,
26  'calf-raise': 2.8,
27  'wall-sit': 3.0,
28  'reverse-lunge': 3.8,
29  'glute-bridge-stand': 2.8,
30  'desk-pushup': 3.0,
31  pushup: 3.8,
32  'diamond-pushup': 3.8,
33  'chair-dip': 3.0,
34  'wall-angel': 2.3,
35  plank: 2.8,
36  'knee-plank': 2.8,
37  'seated-knee-lift': 2.8,
38  'mountain-climber': 7.5,
39  'standing-crunch': 2.8,
40  'chair-twist': 2.3,
41  'cat-cow-seated': 2.3,
42  'hip-hinge': 2.8,
43  'neck-roll': 2.3,
44  'chin-tuck': 2.3,
45  'shoulder-roll': 2.3,
46  'wrist-circles': 2.3,
47  'prayer-stretch': 2.3,
48  'eye-20': 1.3,
49  'jumping-jacks': 7.5,
50  march: 3.5,
51  burpee: 7.5,
52  // office
53  'seated-leg-extension': 2.8,
54  'figure-four': 2.3,
55  'quad-stretch': 2.3,
56  'glute-kickback': 2.8,
57  'wall-pushup': 2.8,
58  'wall-chest-stretch': 2.3,
59  'overhead-reach': 2.3,
60  'calf-stretch': 2.3,
61  'scapula-squeeze': 2.8,
62  'y-raise': 2.8,
63  // home
64  'glute-bridge': 3.0,
65  crunch: 2.8,
66  'bicycle-crunch': 3.0,
67  'leg-raise': 2.8,
68  'sit-up': 3.8,
69  superman: 2.8,
70  'bird-dog': 2.8,
71  'childs-pose': 2.3,
72  'downward-dog': 2.3,
73  'knee-pushup': 3.0,
74  'shoulder-taps': 3.8,
75  inchworm: 3.8,
76  'forward-lunge': 3.8,
77  'high-knees': 7.5,
78  // equipment
79  'db-curl': 3.5,
80  'db-press': 3.5,
81  'db-goblet-squat': 3.5,
82  'db-row': 3.5,
83  'db-rdl': 3.5,
84  'db-tricep-ext': 3.5,
85  'band-row': 3.5,
86  'band-curl': 3.5,
87  'kb-swing': 9.8,
88  pullup: 3.8,
89  'dead-hang': 2.8,
90}
91
92export const metFor = (exerciseId: string): number => MET[exerciseId] ?? 2.8
93
94/** Resting energy use in kcal per hour (Mifflin-St Jeor); one hour at 1 MET. */
95export const restingKcalPerHour = (p: Profile): number => {
96  if (p.heightCm === null) return p.weightKg
97
98  const base = 10 * p.weightKg + 6.25 * p.heightCm - 5 * p.age
99  const offset = p.sex === 'male' ? 5 : p.sex === 'female' ? -161 : -78
100
101  return Math.max(base + offset, 800) / 24
102}
103
104/** Active kcal, above what the body would have burned resting anyway. */
105export const kcalFor = (exerciseId: string, seconds: number, p: Profile): number =>
106  Math.round(Math.max(metFor(exerciseId) - 1, 0) * restingKcalPerHour(p) * (seconds / 3600) * 100) / 100
107
108export const fmtKcal = (kcal: number): string => (kcal < 10 ? kcal.toFixed(1) : String(Math.round(kcal)))
109
110export const fmtMinutes = (seconds: number): string => {
111  const min = seconds / 60
112
113  return min < 10 ? min.toFixed(1) : String(Math.round(min))
114}
115
hooks/game.ts 170 lines
1import type { Area, Exercise, Progress, Region } from '../types'
2import { credit } from './muscles'
3
4const pad = (n: number) => String(n).padStart(2, '0')
5
6export const dayKey = (ms: number): string => {
7  const d = new Date(ms)
8
9  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
10}
11
12export const prevDay = (key: string): string => {
13  const [y = 1970, m = 1, d = 1] = key.split('-').map(Number)
14
15  return dayKey(new Date(y, m - 1, d - 1).getTime())
16}
17
18export const emptyProgress = (day: string): Progress => ({
19  xp: 0,
20  totalSets: 0,
21  totalSeconds: 0,
22  streak: 0,
23  bestStreak: 0,
24  lastGoalDay: null,
25  today: { day, sets: 0, seconds: 0 },
26  history: {},
27  recent: [],
28  areas: {},
29  pain: {},
30  tweak: 0,
31  badges: [],
32  turnSets: 0,
33  earlyBirds: 0,
34  totalKcal: 0,
35  kcalByDay: {},
36  secondsByDay: {},
37  regions: {},
38  loads: {},
39})
40
41/** Fills fields added by later versions into progress saved by an older one. */
42export const normalize = (saved: Partial<Progress> | undefined, day: string): Progress =>
43  rollDay({ ...emptyProgress(day), ...saved }, day)
44
45/** The last `n` day keys ending at `day`, oldest first. */
46export const lastDays = (day: string, n: number): string[] => {
47  const out = [day]
48
49  while (out.length < n) out.unshift(prevDay(out[0] ?? day))
50
51  return out
52}
53
54/** Sets per body area over the last seven days. */
55export const weekAreas = (p: Progress, day: string): Partial<Record<Area, number>> => {
56  const total: Partial<Record<Area, number>> = {}
57
58  for (const k of lastDays(day, 7)) {
59    for (const [a, n] of Object.entries(p.areas[k] ?? {})) {
60      total[a as Area] = (total[a as Area] ?? 0) + (n ?? 0)
61    }
62  }
63
64  return total
65}
66
67/** Starts a new day's counters when the date has changed. */
68export const rollDay = (p: Progress, day: string): Progress =>
69  p.today.day === day ? p : { ...p, today: { day, sets: 0, seconds: 0 } }
70
71/** The streak as it stands today: a missed day ends it. */
72export const liveStreak = (p: Progress, day: string): number =>
73  p.lastGoalDay === day || p.lastGoalDay === prevDay(day) ? p.streak : 0
74
75export const levelOf = (xp: number): number => 1 + Math.floor(Math.sqrt(xp / 50))
76export const xpForLevel = (level: number): number => (level - 1) ** 2 * 50
77
78export type RecordContext = { hour: number; isDuringTurn: boolean; kcal: number }
79
80export type Recorded = { progress: Progress; xpGained: number; isGoalReached: boolean }
81
82type RegionMap = Record<string, Partial<Record<Region, number>>>
83
84const addCredit = (map: RegionMap, day: string, ex: Exercise): RegionMap => {
85  const now = { ...map[day] }
86
87  for (const [r, n] of Object.entries(credit(ex.muscles))) now[r as Region] = (now[r as Region] ?? 0) + n
88
89  const next = { ...map, [day]: now }
90  const keep = Object.keys(next).sort().slice(-14)
91
92  return Object.fromEntries(keep.map(k => [k, next[k] ?? {}]))
93}
94
95/** Adds a finished move's credit to the region history. Strength and cardio also add to the load that needs rest. */
96export const withCredit = (p: Progress, ex: Exercise, day: string): Progress => ({
97  ...p,
98  regions: addCredit(p.regions, day, ex),
99  loads: ex.kind === 'strength' || ex.kind === 'cardio' ? addCredit(p.loads, day, ex) : p.loads,
100  recent: [...p.recent, ex.id].slice(-30),
101})
102
103/** Region credit over the last 7 days. */
104export const weekRegions = (p: Progress, day: string): Partial<Record<Region, number>> => {
105  const total: Partial<Record<Region, number>> = {}
106
107  for (const k of lastDays(day, 7)) {
108    for (const [r, n] of Object.entries(p.regions[k] ?? {})) total[r as Region] = (total[r as Region] ?? 0) + (n ?? 0)
109  }
110
111  return total
112}
113
114export const recordSet = (
115  before: Progress,
116  ex: Exercise,
117  seconds: number,
118  dailySets: number,
119  day: string,
120  ctx: RecordContext,
121): Recorded => {
122  const p = rollDay(before, day)
123  const sets = p.today.sets + 1
124  const isGoalReached = sets === dailySets
125  let streak = liveStreak(p, day)
126  let lastGoalDay = p.lastGoalDay
127
128  if (isGoalReached && lastGoalDay !== day) {
129    streak += 1
130    lastGoalDay = day
131  }
132
133  const xpGained = 10 + Math.round(seconds / 6) + (isGoalReached ? 25 : 0)
134  const history = { ...p.history, [day]: sets }
135  const keep = Object.keys(history).sort().slice(-60)
136  const areas = { ...p.areas, [day]: { ...p.areas[day], [ex.area]: (p.areas[day]?.[ex.area] ?? 0) + 1 } }
137  const keepAreas = Object.keys(areas).sort().slice(-14)
138  const kcalByDay = { ...p.kcalByDay, [day]: Math.round(((p.kcalByDay[day] ?? 0) + ctx.kcal) * 100) / 100 }
139  const credited = withCredit(p, ex, day)
140  const secondsByDay = { ...p.secondsByDay, [day]: (p.secondsByDay[day] ?? 0) + seconds }
141  const keepDays = Object.keys(secondsByDay).sort().slice(-60)
142
143  return {
144    xpGained,
145    isGoalReached,
146    progress: {
147      ...p,
148      xp: p.xp + xpGained,
149      totalSets: p.totalSets + 1,
150      totalSeconds: p.totalSeconds + seconds,
151      streak,
152      bestStreak: Math.max(p.bestStreak, streak),
153      lastGoalDay,
154      today: { day, sets, seconds: p.today.seconds + seconds },
155      history: Object.fromEntries(keep.map(k => [k, history[k] ?? 0])),
156      regions: credited.regions,
157      loads: credited.loads,
158      recent: credited.recent,
159      areas: Object.fromEntries(keepAreas.map(k => [k, areas[k] ?? {}])),
160      turnSets: p.turnSets + (ctx.isDuringTurn ? 1 : 0),
161      earlyBirds: p.earlyBirds + (ctx.hour < 9 ? 1 : 0),
162      totalKcal: Math.round((p.totalKcal + ctx.kcal) * 100) / 100,
163      kcalByDay: Object.fromEntries(keepDays.map(k => [k, kcalByDay[k] ?? 0])),
164      secondsByDay: Object.fromEntries(keepDays.map(k => [k, secondsByDay[k] ?? 0])),
165    },
166  }
167}
168
169export const clock = (s: number): string => `${Math.floor(s / 60)}:${pad(s % 60)}`
170
hooks/net.ts 158 lines
1// Request signing and recovery codes. Pure functions, no `$`: the hooks module
2// runs where crypto.subtle offers digest() only, so HMAC-SHA256 is built on it.
3// The server verifies with native HMAC; both produce the same bytes.
4
5const enc = new TextEncoder()
6
7export const toHex = (bytes: Uint8Array): string =>
8  Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
9
10export const fromHex = (s: string): Uint8Array =>
11  new Uint8Array((s.match(/../g) ?? []).map(h => parseInt(h, 16)))
12
13export const randomHex = (n: number): string => toHex(crypto.getRandomValues(new Uint8Array(n)))
14
15const sha256 = async (data: Uint8Array): Promise<Uint8Array> =>
16  new Uint8Array(await crypto.subtle.digest('SHA-256', data))
17
18export const sha256Hex = async (text: string): Promise<string> => toHex(await sha256(enc.encode(text)))
19
20export const hmacSha256Hex = async (secretHex: string, message: string): Promise<string> => {
21  const BLOCK = 64
22  let key = fromHex(secretHex)
23
24  if (key.length > BLOCK) key = await sha256(key)
25
26  const ipad = new Uint8Array(BLOCK)
27  const opad = new Uint8Array(BLOCK)
28
29  for (let i = 0; i < BLOCK; i++) {
30    const k = key[i] ?? 0
31
32    ipad[i] = k ^ 0x36
33    opad[i] = k ^ 0x5c
34  }
35
36  const msg = enc.encode(message)
37  const inner = new Uint8Array(BLOCK + msg.length)
38
39  inner.set(ipad)
40  inner.set(msg, BLOCK)
41
42  const innerHash = await sha256(inner)
43  const outer = new Uint8Array(BLOCK + innerHash.length)
44
45  outer.set(opad)
46  outer.set(innerHash, BLOCK)
47
48  return toHex(await sha256(outer))
49}
50
51export type SignedHeaders = Record<string, string>
52
53/** The four headers the server checks; `path` includes the query string. */
54export const signHeaders = async (
55  userId: string,
56  secretHex: string,
57  method: string,
58  path: string,
59  body: string,
60  ts: number,
61  nonce: string,
62): Promise<SignedHeaders> => {
63  const canonical = [method, path, ts, nonce, await sha256Hex(body)].join('\n')
64
65  return {
66    'x-user': userId,
67    'x-ts': String(ts),
68    'x-nonce': nonce,
69    'x-sig': await hmacSha256Hex(secretHex, canonical),
70  }
71}
72
73const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
74
75const base32 = (bytes: Uint8Array): string => {
76  let bits = 0
77  let value = 0
78  let out = ''
79
80  for (const b of bytes) {
81    value = (value << 8) | b
82    bits += 8
83
84    while (bits >= 5) {
85      out += B32[(value >>> (bits - 5)) & 31]
86      bits -= 5
87    }
88  }
89
90  if (bits > 0) out += B32[(value << (5 - bits)) & 31]
91
92  return out
93}
94
95const unbase32 = (text: string): Uint8Array | null => {
96  let bits = 0
97  let value = 0
98  const out: number[] = []
99
100  for (const c of text) {
101    const i = B32.indexOf(c)
102
103    if (i === -1) return null
104
105    value = (value << 5) | i
106    bits += 5
107
108    if (bits >= 8) {
109      out.push((value >>> (bits - 8)) & 255)
110      bits -= 8
111    }
112  }
113
114  return new Uint8Array(out)
115}
116
117const crc16 = (bytes: Uint8Array): number => {
118  let crc = 0xffff
119
120  for (const b of bytes) {
121    crc ^= b << 8
122
123    for (let i = 0; i < 8; i++) crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff
124  }
125
126  return crc
127}
128
129/** 8 bytes of user id + 32 bytes of secret + 2 bytes of checksum, as XXXX-XXXX-… */
130export const encodeRecovery = (userId: string, secretHex: string): string => {
131  const raw = new Uint8Array(40)
132
133  raw.set(fromHex(userId), 0)
134  raw.set(fromHex(secretHex), 8)
135
136  const sum = crc16(raw)
137  const all = new Uint8Array(42)
138
139  all.set(raw)
140  all[40] = sum >> 8
141  all[41] = sum & 255
142
143  return (base32(all).match(/.{1,4}/g) ?? []).join('-')
144}
145
146export const decodeRecovery = (code: string): { userId: string; secret: string } | null => {
147  const bytes = unbase32(code.toUpperCase().replace(/[^A-Z2-7]/g, ''))
148
149  if (!bytes || bytes.length < 42) return null
150
151  const raw = bytes.slice(0, 40)
152  const sum = crc16(raw)
153
154  if (bytes[40] !== sum >> 8 || bytes[41] !== (sum & 255)) return null
155
156  return { userId: toHex(raw.slice(0, 8)), secret: toHex(raw.slice(8, 40)) }
157}
158
hooks/i18n.ts 13 lines
1// Text in the person's language. The English text is the key (as in gettext): `tr('tr', 'Daily goal')`.
2// A `{name}` in the text is filled from `vars`. Text with no translation shows in English.
3import { TR } from './tr'
4
5export type Lang = 'en' | 'tr'
6export type Vars = Record<string, string | number>
7
8export const tr = (lang: Lang, text: string, vars?: Vars): string => {
9  const base = lang === 'tr' ? (TR[text] ?? text) : text
10
11  return vars ? base.replace(/\{(\w+)\}/g, (whole, key: string) => (key in vars ? String(vars[key]) : whole)) : base
12}
13
hooks/muscles.ts 134 lines
1// What each move works, how, and how hard. The planner balances body regions with this.
2//
3// Muscle lists follow the primary and secondary muscle groups of standard exercise references
4// (the Workout Guide manifest for the moves it covers, anatomy for the rest). A main muscle counts
5// fully, a helper half. `arms` is the upper arm (biceps, triceps); `wrists` is the forearm and grip;
6// `hips` is the hip flexors and the hip joint; `abs` also covers the obliques.
7import type { Kind, Muscle, Pattern, Region } from '../types'
8
9type Row = [Kind, Pattern, Muscle[], Muscle[]]
10
11export const MUSCLES: Record<string, Row> = {
12  // ---- legs and glutes
13  'chair-squat': ['strength', 'squat', ['quads', 'glutes'], ['hamstrings', 'abs']],
14  squat: ['strength', 'squat', ['quads', 'glutes'], ['hamstrings', 'abs', 'lowBack']],
15  'jump-squat': ['cardio', 'squat', ['quads', 'glutes'], ['calves', 'hamstrings']],
16  'calf-raise': ['strength', 'squat', ['calves'], ['quads']],
17  'wall-sit': ['strength', 'squat', ['quads'], ['glutes', 'abs']],
18  'reverse-lunge': ['strength', 'squat', ['quads', 'glutes'], ['hamstrings', 'calves', 'abs']],
19  'forward-lunge': ['strength', 'squat', ['quads', 'glutes'], ['hamstrings', 'calves']],
20  'glute-bridge-stand': ['strength', 'hinge', ['glutes'], ['hamstrings', 'lowBack']],
21  'glute-bridge': ['strength', 'hinge', ['glutes'], ['hamstrings', 'lowBack', 'abs']],
22  'glute-kickback': ['strength', 'hinge', ['glutes'], ['hamstrings', 'lowBack']],
23  'seated-leg-extension': ['strength', 'squat', ['quads'], ['hips']],
24  'figure-four': ['stretch', 'release', ['hips', 'glutes'], ['lowBack']],
25  'quad-stretch': ['stretch', 'release', ['quads'], ['hips']],
26  'hip-hinge': ['strength', 'hinge', ['glutes', 'hamstrings'], ['lowBack']],
27  // ---- chest, arms, shoulders
28  'desk-pushup': ['strength', 'push', ['chest', 'arms'], ['shoulders', 'abs']],
29  pushup: ['strength', 'push', ['chest', 'arms'], ['shoulders', 'abs']],
30  'diamond-pushup': ['strength', 'push', ['arms', 'chest'], ['shoulders', 'abs']],
31  'knee-pushup': ['strength', 'push', ['chest', 'arms'], ['shoulders', 'abs']],
32  'wall-pushup': ['strength', 'push', ['chest', 'arms'], ['shoulders']],
33  'chair-dip': ['strength', 'push', ['arms'], ['chest', 'shoulders']],
34  'wall-angel': ['mobility', 'pull', ['upperBack', 'shoulders'], ['neck']],
35  'wall-chest-stretch': ['stretch', 'release', ['chest'], ['shoulders']],
36  'shoulder-roll': ['mobility', 'release', ['shoulders'], ['upperBack', 'neck']],
37  'overhead-reach': ['stretch', 'release', ['shoulders', 'upperBack'], ['abs']],
38  'calf-stretch': ['stretch', 'release', ['calves'], ['hamstrings']],
39  'scapula-squeeze': ['strength', 'pull', ['upperBack'], ['shoulders', 'arms']],
40  'y-raise': ['strength', 'pull', ['upperBack', 'shoulders'], ['neck']],
41  // ---- core
42  plank: ['strength', 'core', ['abs'], ['shoulders', 'glutes', 'lowBack']],
43  'knee-plank': ['strength', 'core', ['abs'], ['shoulders', 'glutes', 'lowBack']],
44  'seated-knee-lift': ['strength', 'core', ['abs'], ['hips']],
45  'standing-crunch': ['strength', 'core', ['abs'], ['hips']],
46  crunch: ['strength', 'core', ['abs'], ['hips']],
47  'bicycle-crunch': ['strength', 'rotate', ['abs'], ['hips']],
48  'leg-raise': ['strength', 'core', ['abs', 'hips'], ['quads']],
49  'sit-up': ['strength', 'core', ['abs'], ['hips']],
50  'bird-dog': ['strength', 'core', ['abs', 'lowBack'], ['glutes', 'shoulders']],
51  'shoulder-taps': ['strength', 'core', ['abs', 'shoulders'], ['chest', 'arms']],
52  'mountain-climber': ['cardio', 'core', ['abs', 'shoulders'], ['quads', 'hips', 'chest']],
53  // ---- back and spine
54  'chair-twist': ['mobility', 'rotate', ['lowBack', 'abs'], ['upperBack']],
55  'cat-cow-seated': ['mobility', 'release', ['lowBack', 'upperBack'], ['abs', 'neck']],
56  superman: ['strength', 'hinge', ['lowBack', 'glutes'], ['upperBack', 'shoulders']],
57  'childs-pose': ['stretch', 'release', ['lowBack', 'hips'], ['shoulders', 'upperBack']],
58  'downward-dog': ['stretch', 'release', ['shoulders', 'hamstrings'], ['calves', 'upperBack', 'wrists']],
59  // ---- neck, wrists, eyes
60  'neck-roll': ['mobility', 'release', ['neck'], ['shoulders']],
61  'chin-tuck': ['mobility', 'release', ['neck'], ['upperBack']],
62  'wrist-circles': ['mobility', 'release', ['wrists'], ['arms']],
63  'prayer-stretch': ['stretch', 'release', ['wrists'], ['arms']],
64  'eye-20': ['mobility', 'release', ['eyes'], []],
65  // ---- whole body
66  'jumping-jacks': ['cardio', 'move', ['quads', 'shoulders'], ['calves', 'glutes']],
67  march: ['cardio', 'move', ['hips', 'quads'], ['calves', 'abs']],
68  burpee: ['cardio', 'move', ['quads', 'chest'], ['abs', 'shoulders', 'arms', 'glutes']],
69  'high-knees': ['cardio', 'move', ['hips', 'quads'], ['calves', 'abs']],
70  inchworm: ['mobility', 'move', ['hamstrings', 'abs'], ['shoulders', 'chest', 'calves']],
71  // ---- with gear
72  'db-curl': ['strength', 'pull', ['arms'], ['shoulders', 'wrists']],
73  'db-press': ['strength', 'push', ['shoulders', 'arms'], ['chest', 'abs']],
74  'db-goblet-squat': ['strength', 'squat', ['quads', 'glutes'], ['abs', 'upperBack', 'hamstrings']],
75  'db-row': ['strength', 'pull', ['upperBack', 'arms'], ['lowBack', 'shoulders']],
76  'db-rdl': ['strength', 'hinge', ['hamstrings', 'glutes'], ['lowBack', 'upperBack', 'wrists']],
77  'db-tricep-ext': ['strength', 'push', ['arms'], ['shoulders']],
78  'band-row': ['strength', 'pull', ['upperBack'], ['arms', 'shoulders']],
79  'band-curl': ['strength', 'pull', ['arms'], ['wrists']],
80  'kb-swing': ['strength', 'hinge', ['glutes', 'hamstrings'], ['lowBack', 'shoulders', 'abs']],
81  pullup: ['strength', 'pull', ['upperBack', 'arms'], ['shoulders', 'abs']],
82  'dead-hang': ['mobility', 'pull', ['wrists', 'shoulders'], ['upperBack', 'arms']],
83}
84
85export const REGIONS: Region[] = ['neck', 'shoulders', 'back', 'arms', 'core', 'hips', 'thighs', 'calves']
86
87const REGION_OF: Record<Muscle, Region> = {
88  neck: 'neck',
89  eyes: 'neck',
90  shoulders: 'shoulders',
91  chest: 'shoulders',
92  upperBack: 'back',
93  lowBack: 'back',
94  arms: 'arms',
95  wrists: 'arms',
96  abs: 'core',
97  hips: 'hips',
98  glutes: 'hips',
99  quads: 'thighs',
100  hamstrings: 'thighs',
101  calves: 'calves',
102}
103
104/** Credit a move gives each region: 1 for a main muscle, 0.5 for a helper, the best one per region. */
105export const credit = (muscles: { main: Muscle[]; assist: Muscle[] }): Partial<Record<Region, number>> => {
106  const out: Partial<Record<Region, number>> = {}
107
108  for (const m of muscles.assist) out[REGION_OF[m]] = Math.max(out[REGION_OF[m]] ?? 0, 0.5)
109  for (const m of muscles.main) out[REGION_OF[m]] = 1
110
111  return out
112}
113
114/** The region a move works most; ties go to the first main muscle. */
115export const topRegion = (muscles: { main: Muscle[]; assist: Muscle[] }): Region =>
116  REGION_OF[muscles.main[0] ?? muscles.assist[0] ?? 'abs']
117
118export const effortOf = (met: number): 1 | 2 | 3 | 4 | 5 => (met <= 2.3 ? 1 : met <= 3 ? 2 : met <= 3.8 ? 3 : met <= 7.5 ? 4 : 5)
119
120export const REGION_LABEL: Record<Region, string> = {
121  neck: 'Neck and eyes',
122  shoulders: 'Shoulders and chest',
123  back: 'Back',
124  arms: 'Arms',
125  core: 'Core',
126  hips: 'Hips and glutes',
127  thighs: 'Thighs',
128  calves: 'Calves',
129}
130
131/** The regions a move works fully, in order. */
132export const mainRegions = (muscles: { main: Muscle[]; assist: Muscle[] }): Region[] =>
133  REGIONS.filter(r => (credit(muscles)[r] ?? 0) >= 1)
134
hooks/planner.ts 267 lines
1import type { Ability, Equipment, Exercise, Goal, Kind, Level, Limit, Place, Profile, Progress, Region } from '../types'
2import { byId, CATALOG } from './catalog'
3import { dayKey, lastDays, weekRegions, withCredit } from './game'
4import { credit, REGIONS, topRegion } from './muscles'
5
6const LEVELS: Level[] = ['beginner', 'intermediate', 'advanced']
7const RANK: Record<Level, number> = { beginner: 0, intermediate: 1, advanced: 2 }
8
9// How the week's work should be spread over the body, per goal. Shares sum to 1.
10const SHARE: Record<Goal, Record<Region, number>> = {
11  //            neck  shoulders back  arms  core  hips  thighs calves
12  fitness: { neck: 0.06, shoulders: 0.14, back: 0.14, arms: 0.08, core: 0.18, hips: 0.14, thighs: 0.18, calves: 0.08 },
13  weight: { neck: 0.04, shoulders: 0.1, back: 0.1, arms: 0.06, core: 0.2, hips: 0.18, thighs: 0.26, calves: 0.06 },
14  strength: { neck: 0.04, shoulders: 0.18, back: 0.16, arms: 0.14, core: 0.16, hips: 0.12, thighs: 0.16, calves: 0.04 },
15  posture: { neck: 0.16, shoulders: 0.16, back: 0.22, arms: 0.08, core: 0.12, hips: 0.16, thighs: 0.06, calves: 0.04 },
16}
17
18// And how the sets should mix: hard work, heart rate, loosening up, stretching.
19const KINDS: Record<Goal, Record<Kind, number>> = {
20  fitness: { strength: 0.45, cardio: 0.15, mobility: 0.25, stretch: 0.15 },
21  weight: { strength: 0.35, cardio: 0.35, mobility: 0.15, stretch: 0.15 },
22  strength: { strength: 0.65, cardio: 0.05, mobility: 0.15, stretch: 0.15 },
23  posture: { strength: 0.25, cardio: 0.05, mobility: 0.4, stretch: 0.3 },
24}
25
26const FOCUS_BOOST = 1.8
27const NO_REPEAT = ['push', 'pull', 'squat', 'hinge', 'core', 'rotate']
28
29/** The goal's shares, with the regions the person asked for weighted up. */
30export const shares = (goal: Goal, focus: Region[]): Record<Region, number> => {
31  const raw = Object.fromEntries(REGIONS.map(r => [r, SHARE[goal][r] * (focus.includes(r) ? FOCUS_BOOST : 1)])) as Record<Region, number>
32  const sum = REGIONS.reduce((n, r) => n + raw[r], 0)
33
34  return Object.fromEntries(REGIONS.map(r => [r, raw[r] / sum])) as Record<Region, number>
35}
36
37/** Feedback pushes the level one step up or down once it is clear. */
38export const shiftOf = (tweak: number): number => (tweak >= 3 ? 1 : tweak <= -3 ? -1 : 0)
39
40export const levelFor = (p: Profile, tweak = 0): Level => {
41  const base = p.abilities.length >= 4 ? 2 : p.abilities.length >= 2 ? 1 : 0
42
43  return LEVELS[Math.min(2, Math.max(0, base + shiftOf(tweak)))] ?? 'beginner'
44}
45
46const bmi = (p: Profile): number | null =>
47  p.heightCm ? p.weightKg / (p.heightCm / 100) ** 2 : null
48
49/** Older users and higher body mass get lower impact and slightly shorter sets. */
50const isGentle = (p: Profile): boolean => p.age >= 55 || (bmi(p) ?? 0) >= 30
51
52export const secondsFor = (ex: Exercise, p: Profile): number =>
53  Math.max(15, Math.round((ex.seconds * (isGentle(p) ? 0.8 : 1)) / 5) * 5)
54
55const allowed = (ex: Exercise, p: Profile, prog: Progress, now: number, place: Place): boolean =>
56  ex.places.includes(place) &&
57  ex.equipment.every(q => p.equipment.includes(q)) &&
58  RANK[ex.level] <= RANK[levelFor(p, prog.tweak)] &&
59  ex.needs.every(n => p.abilities.includes(n)) &&
60  !ex.avoidIf.some(l => p.limits.includes(l)) &&
61  !(p.isQuiet && !ex.isQuiet) &&
62  !(isGentle(p) && ex.isHighImpact) &&
63  (prog.pain[ex.area] ?? 0) <= now
64
65const EFFORT_BASE: Record<Level, number> = { beginner: 2, intermediate: 3, advanced: 3.5 }
66
67const noise = (seed: string): number => {
68  let h = 2166136261
69
70  for (let i = 0; i < seed.length; i++) h = Math.imul(h ^ seed.charCodeAt(i), 16777619)
71
72  return ((h >>> 0) % 1000) / 1000
73}
74
75type Context = {
76  share: Record<Region, number>
77  kinds: Record<Kind, number>
78  actual: Record<Region, number>
79  kindNow: Record<Kind, number>
80  load: Partial<Record<Region, number>>
81  last: Exercise | undefined
82  pushPull: number
83  ideal: number
84  level: Level
85  /** While Claude works there is no time to catch your breath: hard moves are held back. */
86  isDuringTurn: boolean
87  /** 0 for a move not done lately, down to -0.8 for one done just now. */
88  fresh: (id: string) => number
89}
90
91const contextOf = (p: Profile, prog: Progress, now: number, focus: Region[], isDuringTurn = false): Context => {
92  const day = dayKey(now)
93  const week = weekRegions(prog, day)
94  const total = REGIONS.reduce((n, r) => n + (week[r] ?? 0), 0)
95  const recent = prog.recent.map(id => byId(id)).filter((x): x is Exercise => x !== undefined)
96  const last = recent[recent.length - 1]
97  const [yesterday = day, today = day] = lastDays(day, 2)
98  const kindNow = { strength: 0, cardio: 0, mobility: 0, stretch: 0 }
99
100  for (const x of recent) kindNow[x.kind] += 1 / Math.max(recent.length, 1)
101
102  const load: Partial<Record<Region, number>> = {}
103
104  for (const r of REGIONS) load[r] = (prog.loads[today]?.[r] ?? 0) + 0.5 * (prog.loads[yesterday]?.[r] ?? 0)
105
106  const pulls = recent.filter(x => x.pattern === 'pull').length
107  const pushes = recent.filter(x => x.pattern === 'push').length
108  const level = levelFor(p, prog.tweak)
109  const wave = last ? (last.effort >= 4 ? -1.5 : last.effort <= 1 ? 0.5 : 0) : 0
110
111  return {
112    share: shares(p.goal, focus),
113    kinds: KINDS[p.goal],
114    actual: Object.fromEntries(REGIONS.map(r => [r, total > 0 ? (week[r] ?? 0) / total : 0])) as Record<Region, number>,
115    kindNow,
116    load,
117    last,
118    pushPull: Math.max(-3, Math.min(3, pulls - pushes)),
119    fresh: id => {
120      const at = prog.recent.lastIndexOf(id)
121
122      return at === -1 ? 0.2 : -0.8 * (at / Math.max(prog.recent.length - 1, 1))
123    },
124    ideal: EFFORT_BASE[level] + wave + (prog.today.sets === 0 ? -1 : 0) - (isDuringTurn ? 1 : 0),
125    level,
126    isDuringTurn,
127  }
128}
129
130/** How far behind its share a region is this week; negative when it has had more than its share. */
131const gap = (c: Context, r: Region): number => c.share[r] - c.actual[r]
132
133const scoreOf = (ex: Exercise, c: Context, seed: string): number => {
134  const cr = credit(ex.muscles)
135  let need = 0
136  let tired = 0
137
138  for (const r of REGIONS) {
139    const n = cr[r] ?? 0
140
141    need += n * (Math.max(-0.15, gap(c, r)) * 5 + Math.max(-0.5, Math.min(1, gap(c, r) / Math.max(c.share[r], 0.05))) * 0.4)
142    tired += n * Math.min(c.load[r] ?? 0, 4)
143  }
144
145  const rest = ex.kind === 'strength' || ex.kind === 'cardio' ? tired * 0.35 : 0
146  const mix = (c.kinds[ex.kind] - c.kindNow[ex.kind]) * 3
147  const balance = ex.pattern === 'push' ? c.pushPull * 0.2 : ex.pattern === 'pull' ? -c.pushPull * 0.2 : 0
148  const effort = -Math.abs(ex.effort - c.ideal) * 0.35 - (c.isDuringTurn && ex.effort >= 4 ? 1 : 0)
149  const fit = (ex.level === c.level ? 0.4 : 0) + (ex.equipment.length > 0 ? 0.15 : 0)
150
151  return need - rest + mix + balance + effort + fit + c.fresh(ex.id) + noise(seed) * 0.5
152}
153
154export type Blocker =
155  | { kind: 'place'; place: Place }
156  | { kind: 'gear'; gear: Equipment[] }
157  | { kind: 'ability'; ability: Ability[] }
158  | { kind: 'limit'; limit: Limit[] }
159  | { kind: 'level' }
160  | { kind: 'quiet' }
161  | { kind: 'impact' }
162  | { kind: 'pain' }
163
164/** Why a move is not in this person's plan right now; empty when it is. */
165export const blockers = (ex: Exercise, p: Profile, prog: Progress, now: number, place: Place): Blocker[] => {
166  const out: Blocker[] = []
167  const gear = ex.equipment.filter(q => !p.equipment.includes(q))
168  const ability = ex.needs.filter(n => !p.abilities.includes(n))
169  const limit = ex.avoidIf.filter(l => p.limits.includes(l))
170
171  if (!ex.places.includes(place)) out.push({ kind: 'place', place: ex.places[0] ?? 'home' })
172  if (gear.length > 0) out.push({ kind: 'gear', gear })
173  if (RANK[ex.level] > RANK[levelFor(p, prog.tweak)]) out.push({ kind: 'level' })
174  if (ability.length > 0) out.push({ kind: 'ability', ability })
175  if (limit.length > 0) out.push({ kind: 'limit', limit })
176  if (p.isQuiet && !ex.isQuiet) out.push({ kind: 'quiet' })
177  if (isGentle(p) && ex.isHighImpact) out.push({ kind: 'impact' })
178  if ((prog.pain[ex.area] ?? 0) > now) out.push({ kind: 'pain' })
179
180  return out
181}
182
183export const pick = (
184  p: Profile,
185  prog: Progress,
186  now: number,
187  skip: string[] = [],
188  place: Place = 'office',
189  focus: Region[] = [],
190  isDuringTurn = false,
191): Exercise => {
192  const all = CATALOG.filter(ex => allowed(ex, p, prog, now, place))
193  const fresh = all.filter(ex => !skip.includes(ex.id))
194  const list = fresh.length > 0 ? fresh : all.length > 0 ? all : CATALOG.filter(ex => ex.id === 'eye-20')
195  const c = contextOf(p, prog, now, focus, isDuringTurn)
196  const recent = prog.recent.slice(-8)
197  const last = c.last
198
199  // From strict to loose: stop at the first rule set that leaves something to choose from.
200  const rules: ((ex: Exercise) => boolean)[] = [
201    ex => !recent.includes(ex.id) && !(last && NO_REPEAT.includes(ex.pattern) && ex.pattern === last.pattern) && !(last && topRegion(ex.muscles) === topRegion(last.muscles)),
202    ex => !recent.includes(ex.id) && !(last && topRegion(ex.muscles) === topRegion(last.muscles)),
203    ex => !recent.includes(ex.id),
204    ex => ex.id !== last?.id,
205    () => true,
206  ]
207  const pool = (rules.map(r => list.filter(r)).find(l => l.length > 0) ?? list)
208  const seed = `${dayKey(now)}|${prog.today.sets}|${skip.join(',')}`
209
210  let best = pool[0] ?? CATALOG[0]!
211  let bestScore = -Infinity
212
213  for (const ex of pool) {
214    const score = scoreOf(ex, c, `${seed}|${ex.id}`)
215
216    if (score > bestScore) {
217      best = ex
218      bestScore = score
219    }
220  }
221
222  return best
223}
224
225/** The next few moves in order, each planned as if the one before was done. */
226export const program = (
227  p: Profile,
228  prog: Progress,
229  now: number,
230  place: Place,
231  count: number,
232  focus: Region[] = [],
233): Exercise[] => {
234  const out: Exercise[] = []
235  const day = dayKey(now)
236  let cur = prog
237
238  for (let i = 0; i < count; i++) {
239    const ex = pick(p, cur, now, [], place, focus)
240
241    out.push(ex)
242    cur = { ...withCredit(cur, ex, day), today: { ...cur.today, sets: cur.today.sets + 1 } }
243  }
244
245  return out
246}
247
248export type Why = { text: string; region?: Region }
249
250/** One line on why this move, for the card. */
251export const why = (ex: Exercise, p: Profile, prog: Progress, now: number, focus: Region[] = []): Why => {
252  const c = contextOf(p, prog, now, focus)
253  const cr = credit(ex.muscles)
254  const mains = REGIONS.filter(r => (cr[r] ?? 0) >= 1)
255  const wanted = mains.find(r => focus.includes(r) && gap(c, r) > 0)
256
257  if (wanted) return { text: 'You asked for extra {region} work', region: wanted }
258
259  const behind = [...mains].sort((a, b) => gap(c, b) - gap(c, a))[0]
260
261  if (behind && gap(c, behind) > 0.05) return { text: 'Your {region} is behind this week', region: behind }
262  if (c.last && c.last.effort >= 4 && ex.effort <= 2) return { text: 'An easy one after the hard set' }
263  if (prog.today.sets === 0 && ex.effort <= 2) return { text: 'A gentle start to the day' }
264
265  return { text: 'Keeps your week balanced' }
266}
267
hooks/settings.ts 65 lines
1import type { DeskSettings } from '../types'
2
3export const DEFAULT_SETTINGS: DeskSettings = {
4  language: 'en',
5  units: 'metric',
6  nudges: true,
7  nudgeAfterSeconds: 20,
8  sound: true,
9  animations: true,
10  illustrations: 'line',
11  accent: 'signal',
12  toasts: 'all',
13  bandVisible: true,
14  place: 'office',
15  focus: [],
16  // the shared public board; the person can clear or replace it in Settings, an admin can set another with the plugin option
17  backendUrl: 'https://deskfit-api.atanur.dev',
18}
19
20export const LANGUAGES = [
21  { value: 'en', label: 'English' },
22  { value: 'tr', label: 'Türkçe' },
23]
24
25export const UNITS = [
26  { value: 'metric', label: 'Metric (kg, cm)' },
27  { value: 'imperial', label: 'Imperial (lb, in)' },
28]
29
30export const NUDGE_DELAYS = [10, 20, 30, 60].map(n => ({ value: String(n), label: `after ${n} seconds` }))
31
32export const ACCENTS = [
33  { value: 'signal', label: 'Signal' },
34  { value: 'mono', label: 'Monochrome' },
35  { value: 'teal', label: 'Teal' },
36  { value: 'violet', label: 'Violet' },
37  { value: 'rose', label: 'Rose' },
38  { value: 'blue', label: 'Blue' },
39  { value: 'amber', label: 'Amber' },
40]
41
42export const ILLUSTRATIONS = [
43  { value: 'line', label: 'Line art' },
44  { value: 'bold', label: 'Abstract' },
45  { value: 'stick', label: 'Stick figure' },
46]
47
48export const TOASTS = [
49  { value: 'all', label: 'All messages' },
50  { value: 'important', label: 'Only important' },
51  { value: 'none', label: 'None' },
52]
53
54export const DAILY_GOALS = [3, 5, 8, 12].map(n => ({ value: String(n), label: `${n} sets a day` }))
55
56/** Merges what was saved over the defaults; the plugin's `userConfig` seeds the defaults. */
57export const withDefaults = (saved: Partial<DeskSettings> | undefined, seed: Partial<DeskSettings>): DeskSettings => ({
58  ...DEFAULT_SETTINGS,
59  ...seed,
60  ...saved,
61})
62
63export const KG_PER_LB = 0.45359237
64export const CM_PER_IN = 2.54
65
hooks/sound.ts 74 lines
1// Short sound effects, synthesized as WAV so the mod ships no audio files.
2
3export type SoundKind = 'tick' | 'start' | 'done' | 'goal'
4
5const RATE = 22050
6
7const SEQUENCES: Record<SoundKind, { hz: number; ms: number }[]> = {
8  tick: [{ hz: 1100, ms: 45 }],
9  start: [{ hz: 587, ms: 90 }, { hz: 880, ms: 140 }],
10  done: [{ hz: 523, ms: 100 }, { hz: 659, ms: 100 }, { hz: 784, ms: 200 }],
11  goal: [{ hz: 523, ms: 110 }, { hz: 659, ms: 110 }, { hz: 784, ms: 110 }, { hz: 1047, ms: 380 }],
12}
13
14const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
15
16const base64 = (bytes: Uint8Array): string => {
17  let out = ''
18
19  for (let i = 0; i < bytes.length; i += 3) {
20    const a = bytes[i] ?? 0
21    const b = bytes[i + 1] ?? 0
22    const c = bytes[i + 2] ?? 0
23    const n = (a << 16) | (b << 8) | c
24
25    out += B64[(n >> 18) & 63] ?? ''
26    out += B64[(n >> 12) & 63] ?? ''
27    out += i + 1 < bytes.length ? (B64[(n >> 6) & 63] ?? '') : '='
28    out += i + 2 < bytes.length ? (B64[n & 63] ?? '') : '='
29  }
30
31  return out
32}
33
34const wav = (seq: { hz: number; ms: number }[]): string => {
35  const samples: number[] = []
36
37  for (const { hz, ms } of seq) {
38    const n = Math.round((RATE * ms) / 1000)
39
40    for (let i = 0; i < n; i++) {
41      const t = i / RATE
42      const attack = Math.min(i / 200, 1)
43      const decay = Math.exp((-3.2 * i) / n)
44
45      samples.push(Math.sin(2 * Math.PI * hz * t) * attack * decay * 0.55)
46    }
47  }
48
49  const data = new Uint8Array(44 + samples.length * 2)
50  const view = new DataView(data.buffer)
51  const str = (at: number, s: string) => [...s].forEach((ch, i) => view.setUint8(at + i, ch.charCodeAt(0)))
52
53  str(0, 'RIFF')
54  view.setUint32(4, 36 + samples.length * 2, true)
55  str(8, 'WAVE')
56  str(12, 'fmt ')
57  view.setUint32(16, 16, true)
58  view.setUint16(20, 1, true)
59  view.setUint16(22, 1, true)
60  view.setUint32(24, RATE, true)
61  view.setUint32(28, RATE * 2, true)
62  view.setUint16(32, 2, true)
63  view.setUint16(34, 16, true)
64  str(36, 'data')
65  view.setUint32(40, samples.length * 2, true)
66  samples.forEach((s, i) => view.setInt16(44 + i * 2, Math.max(-1, Math.min(1, s)) * 32767, true))
67
68  return base64(data)
69}
70
71const cache: Partial<Record<SoundKind, string>> = {}
72
73export const soundOf = (kind: SoundKind): string => (cache[kind] ??= wav(SEQUENCES[kind]))
74