also looking at this
LSU's own coaching surface, and what auditing the site turned up
#12
86 files
+3943
-1190
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -0,0 +1,118 @@ | ||
| 1 | /** | |
| 2 | * The coaching modules, in LSU's own words. | |
| 3 | * | |
| 4 | * HQ publishes eight composable training modules and a navigation list for the | |
| 5 | * brand. This site does not simply print HQ's labels: a runner arriving from | |
| 6 | * /format should read the same voice here as everywhere else, so the label, | |
| 7 | * the page heading and the one-line summary are LSU's. HQ remains the only | |
| 8 | * source of the data inside each panel, and of whether a module is enabled at | |
| 9 | * all - `enabledModules()` intersects this list with the published brand. | |
| 10 | * | |
| 11 | * `scope` is the part worth keeping straight: | |
| 12 | * 'public' - readable without an HQ account. The storefront. | |
| 13 | * 'athlete' - needs a signed-in HQ subscriber. Noindex, and said plainly on | |
| 14 | * the page rather than discovered after a click. | |
| 15 | * 'private' - as athlete, and additionally account-scoped inside HQ: the | |
| 16 | * athlete's own health metrics and messages, which a brand never | |
| 17 | * sees. LSU must never imply it can read them. | |
| 18 | */ | |
| 19 | ||
| 20 | export type ModuleScope = 'public' | 'athlete' | 'private' | |
| 21 | ||
| 22 | export interface CoachingModule { | |
| 23 | /** HQ's module key. Also this site's URL segment, under /coaching/. */ | |
| 24 | slug: string | |
| 25 | /** Nav word. LSU's, not HQ's. */ | |
| 26 | label: string | |
| 27 | /** Page heading. */ | |
| 28 | title: string | |
| 29 | /** One line, in the site's voice, saying what the panel actually does. */ | |
| 30 | summary: string | |
| 31 | scope: ModuleScope | |
| 32 | } | |
| 33 | ||
| 34 | export const coachingModules: CoachingModule[] = [ | |
| 35 | { | |
| 36 | slug: 'plans', | |
| 37 | label: 'Tiers', | |
| 38 | title: 'What is open', | |
| 39 | summary: 'The tiers a coach has published, what each one covers and what it costs. Nothing here is set by this site.', | |
| 40 | scope: 'public', | |
| 41 | }, | |
| 42 | { | |
| 43 | slug: 'calendar', | |
| 44 | label: 'The week', | |
| 45 | title: 'Your week', | |
| 46 | summary: 'The sessions your method has given you, sized to the days you have. Log the sets, the time and how hard it was.', | |
| 47 | scope: 'athlete', | |
| 48 | }, | |
| 49 | { | |
| 50 | slug: 'feedback', | |
| 51 | label: 'Say how it felt', | |
| 52 | title: 'Say how it felt', | |
| 53 | summary: 'Record a voice message after a session. You read the transcript back and send it yourself; next week moves inside the method.', | |
| 54 | scope: 'athlete', | |
| 55 | }, | |
| 56 | { | |
| 57 | slug: 'progress', | |
| 58 | label: 'Progress', | |
| 59 | title: 'What you have done', | |
| 60 | summary: 'Sessions given against sessions finished, across every tier you are paying for.', | |
| 61 | scope: 'athlete', | |
| 62 | }, | |
| 63 | { | |
| 64 | slug: 'library', | |
| 65 | label: 'The method', | |
| 66 | title: 'The method you are in', | |
| 67 | summary: 'The coach behind your tier and the published summary of how they train people. The private methodology stays with them.', | |
| 68 | scope: 'athlete', | |
| 69 | }, | |
| 70 | { | |
| 71 | slug: 'health', | |
| 72 | label: 'Health', | |
| 73 | title: 'Your health data', | |
| 74 | summary: 'Sleep, heart rate and the rest, read from your own account. This site never sees it and neither does the race.', | |
| 75 | scope: 'private', | |
| 76 | }, | |
| 77 | { | |
| 78 | slug: 'messages', | |
| 79 | label: 'Messages', | |
| 80 | title: 'Your messages', | |
| 81 | summary: 'Your own inbox, read from your own account. Not a channel to the race, and not something the race can read.', | |
| 82 | scope: 'private', | |
| 83 | }, | |
| 84 | { | |
| 85 | slug: 'account', | |
| 86 | label: 'Account', | |
| 87 | title: 'Billing and renewal', | |
| 88 | summary: 'Cancel, resume a renewal, or open billing. Payment is handled where your account lives, never on this site.', | |
| 89 | scope: 'athlete', | |
| 90 | }, | |
| 91 | ] | |
| 92 | ||
| 93 | const bySlug = new Map(coachingModules.map(module => [module.slug, module])) | |
| 94 | ||
| 95 | export function coachingModule(slug: string): CoachingModule | undefined { | |
| 96 | return bySlug.get(slug) | |
| 97 | } | |
| 98 | ||
| 99 | /** The URL this site serves a module at. */ | |
| 100 | export function modulePath(slug: string): string { | |
| 101 | return `/coaching/${slug}` | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * The modules to show, in this site's order, limited to what the published | |
| 106 | * brand enables. An unreachable brand means the storefront only: linking a | |
| 107 | * runner to a panel that cannot load is worse than not offering it. | |
| 108 | */ | |
| 109 | export function enabledModules(published: readonly string[] | undefined): CoachingModule[] { | |
| 110 | if (!published || published.length === 0) | |
| 111 | return coachingModules.filter(module => module.scope === 'public') | |
| 112 | return coachingModules.filter(module => published.includes(module.slug)) | |
| 113 | } | |
| 114 | ||
| 115 | /** Only `public` modules may be indexed; the rest need an account to show anything. */ | |
| 116 | export function indexableModulePaths(): string[] { | |
| 117 | return coachingModules.filter(module => module.scope === 'public').map(module => modulePath(module.slug)) | |
| 118 | } | |
| @@ -0,0 +1,61 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { coachingModule, coachingModules, enabledModules, indexableModulePaths, modulePath } from '../resources/data/coaching' | |
| 3 | ||
| 4 | describe('the coaching module registry', () => { | |
| 5 | it('uses each HQ module key once, as its own URL segment', () => { | |
| 6 | const slugs = coachingModules.map(module => module.slug) | |
| 7 | expect(new Set(slugs).size).toBe(slugs.length) | |
| 8 | for (const slug of slugs) { | |
| 9 | expect(slug).toMatch(/^[a-z]+$/) | |
| 10 | expect(modulePath(slug)).toBe(`/coaching/${slug}`) | |
| 11 | } | |
| 12 | }) | |
| 13 | ||
| 14 | it('covers the eight modules HQ publishes, and nothing invented', () => { | |
| 15 | expect(coachingModules.map(module => module.slug).sort()).toEqual( | |
| 16 | ['account', 'calendar', 'feedback', 'health', 'library', 'messages', 'plans', 'progress'], | |
| 17 | ) | |
| 18 | }) | |
| 19 | ||
| 20 | it('shows only the storefront when no brand is published', () => { | |
| 21 | // An unreachable or draft brand must not advertise panels that cannot load. | |
| 22 | expect(enabledModules(undefined).map(module => module.slug)).toEqual(['plans']) | |
| 23 | expect(enabledModules([]).map(module => module.slug)).toEqual(['plans']) | |
| 24 | }) | |
| 25 | ||
| 26 | it('keeps this site\'s order rather than the order the brand happens to list', () => { | |
| 27 | expect(enabledModules(['account', 'plans', 'calendar']).map(module => module.slug)) | |
| 28 | .toEqual(['plans', 'calendar', 'account']) | |
| 29 | }) | |
| 30 | ||
| 31 | it('ignores a module the brand names that this site does not serve', () => { | |
| 32 | expect(enabledModules(['plans', 'whiteboard']).map(module => module.slug)).toEqual(['plans']) | |
| 33 | }) | |
| 34 | ||
| 35 | it('indexes only what a visitor without an account can actually read', () => { | |
| 36 | expect(indexableModulePaths()).toEqual(['/coaching/plans']) | |
| 37 | for (const module of coachingModules) { | |
| 38 | if (module.scope !== 'public') | |
| 39 | expect(indexableModulePaths()).not.toContain(modulePath(module.slug)) | |
| 40 | } | |
| 41 | }) | |
| 42 | ||
| 43 | it('marks health and messages as account-private, because a brand never sees them', () => { | |
| 44 | expect(coachingModule('health')?.scope).toBe('private') | |
| 45 | expect(coachingModule('messages')?.scope).toBe('private') | |
| 46 | }) | |
| 47 | ||
| 48 | it('says what each panel does in a full sentence', () => { | |
| 49 | for (const module of coachingModules) { | |
| 50 | expect(module.summary.length).toBeGreaterThan(40) | |
| 51 | expect(module.summary.endsWith('.')).toBe(true) | |
| 52 | // The site's one hard typographic rule. | |
| 53 | expect(module.summary).not.toContain('—') | |
| 54 | expect(module.title).not.toContain('—') | |
| 55 | } | |
| 56 | }) | |
| 57 | ||
| 58 | it('does not know about a module that is not registered', () => { | |
| 59 | expect(coachingModule('nope')).toBeUndefined() | |
| 60 | }) | |
| 61 | }) | |