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

<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"> <img src="site/public/assets/screen-set.png" width="260" alt="A set in progress: a line-art figure and a large timer"> <img src="site/public/assets/screen-stats.png" width="260" alt="Stats: sets per day and work by body region">
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.
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.
| Command | What it does |
|---|---|
/workout | Open DeskFit (or start the setup the first time) |
/workout moves | Browse all moves, filter by region and place, try one |
/workout stats | Level, streak, sets per day, work by body region, badges |
/workout board | The leaderboard, your team and your account |
/workout settings | Goal, gear, language, look, sound, your data |
/workout focus 2h | Pause suggestions for a while (focus off ends it) |
/workout reset | Erase 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.
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.
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.
Three looks, picked under Settings -> Look and sound -> Illustrations:
art-extra/) and share that licence. Every move has one.claude --plugin-dir . # load the plugin from this folder npm install --legacy-peer-deps
| Check | What it does |
|---|---|
npm run ui | Renders 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 plan | Plays 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 muscles | Checks the muscle table against the Workout Guide manifest and that no office move needs home-only gear |
npm run i18n | Lists texts missing a Turkish translation or with mismatched {placeholders} |
npm run e2e | Runs the API checks against a running server (see server/README.md) |
npm run preview, npm run mockups | Preview 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%.
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.
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.
hooks/register.tsx 2316 lines1import { 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 }
1200hooks/art.ts 1225 lines1// 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, '&').replace(/</g, '<')}</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, '&').replace(/</g, '<')}</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, '&')}</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, '&').replace(/</g, '<')}</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, '&').replace(/</g, '<')
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 lines1import 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}
33hooks/catalog.ts 110 lines1import 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)
110hooks/calories.ts 115 lines1// 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}
115hooks/game.ts 170 lines1import 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)}`
170hooks/net.ts 158 lines1// 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}
158hooks/i18n.ts 13 lines1// 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}
13hooks/muscles.ts 134 lines1// 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)
134hooks/planner.ts 267 lines1import 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}
267hooks/settings.ts 65 lines1import 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
65hooks/sound.ts 74 lines1// 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