Repository navigation
WQL Block Efforts Mapping
title: "WQL Block-Effort Mapping & Composer Integration Specification" date: 2026-09-27 status: architectural-spec tags:
- wql
- block-efforts
- composer
- seed
- storage
- architecture
This specification establishes the architectural plan for:
- Mapping exercises/efforts to blocks on save (for user notes) and during compilation (for seed wods).
-
The
block_effortsrelational index in IndexedDB to support$O(1)$ exercise containment queries. -
Updating the WQL engine & query composer to support dynamic typed tags (
domain,format,equipment,quality,intent) and exercise containment (effort) with interactive typeahead.
When a workout note is saved today:
-
IndexedDBContentProvider.saveEntry()andupdateEntry()invokeStorageService.rebuildBlockIndexForNote(noteId). -
StorageServicedeletes and recreates rows inblock_index, storing onlyblockContentId(a SHA-256 hash of the block text) and basic metadata. -
The Missing Seam: The parser never records which exercises were in the block.
- Searching for workouts containing
"thruster"requires regex matching over raw text or joining against historical workout results (sessions/events). - For unexecuted seed workouts (e.g. 662 benchmark/zombiefit wods), historical events do not exist, rendering effort queries impossible without full corpus text scans.
- Searching for workouts containing
Extract canonical effort slugs from workout ASTs (parseScript) at save/compile time and index them in a dedicated block_efforts cross table:
Add a new object store block_efforts to IndexedDBStorage.ts, InMemoryStorage.ts, and SeedImportStorage.ts:
export interface BlockEffort {
/** Deterministic compound key: `${noteId}:${blockId}:${effortSlug}` */
id: string;
/** Parent note UUID */
noteId: string;
/** Positional section ID within the note (e.g. 'time-2-ab12cd34') */
blockId: string;
/** Content-stable hash of the block (from section.scriptBlock.contentId) */
blockContentId: string;
/** Canonical exercise slug (e.g. 'thruster', 'pull-up', 'clean-and-jerk') */
effortSlug: string;
/** Static corpus provenance flag */
isStatic?: boolean;
/** Creation epoch timestamp (ms) */
createdAt: number;
}-
by-effort(keyPath: 'effortSlug'): Fast resolution offind:note{effort:...}andfind:block{effort:...}. -
by-note(keyPath: 'noteId'): Fast cleanup/replacement on note edit, and reverse lookup of all exercises in a note. -
by-block(keyPath: 'blockContentId'): Aggregation and telemetry joins by structural block identity.
When a user creates or edits a note in the editor, IndexedDBContentProvider calls rebuildBlockIndexForNote(noteId). We extend this transaction to extract and map efforts simultaneously.
[User Edits / Saves Note]
│
▼
IndexedDBContentProvider.updateEntry(noteId, rawContent)
│
▼
StorageService.rebuildBlockIndexForNote(noteId)
┌─────────────────────────────────────────────────────────────────┐
│ Transaction stores: ['block_index', 'block_efforts'] │
│ │
│ 1. Clear existing records for noteId: │
│ - delete from block_index where noteId == noteId │
│ - delete from block_efforts where noteId == noteId │
│ │
│ 2. Iterate latest segments for noteId: │
│ a. Write BlockIndexRow to block_index │
│ b. If segment.dataType === 'wod' (or runnable section): │
│ - script = parseScript(segment.rawContent) │
│ - effortSlugs = extractEffortSlugs(script.statements) │
│ - For each slug: │
│ put BlockEffort into block_efforts: │
│ { │
│ id: `${noteId}:${segment.id}:${slug}`, │
│ noteId, │
│ blockId: segment.id, │
│ blockContentId: segment.data?.contentId, │
│ effortSlug: slug, │
│ createdAt: segment.createdAt │
│ } │
└─────────────────────────────────────────────────────────────────┘
A lightweight extraction function in @bitcobblers/wod-wiki-core or @bitcobblers/wod-wiki-lang:
export function extractEffortSlugs(statements: ICodeStatement[]): string[] {
const slugs = new Set<string>();
for (const statement of statements) {
if (statement.exerciseId) {
slugs.add(statement.exerciseId);
} else if (statement.text) {
// Fallback: extract movement identifier token from statement text
const clean = statement.text
.replace(/^\s*\+?\s*/, '')
.split(/[\d\n\r\t?]/)[0]
.trim();
const slug = clean.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
if (slug) slugs.add(slug);
}
}
return Array.from(slugs);
}For bundled corpus files (markdown/collections/**, markdown/feeds/**), the same containment relationships are precomputed at build time:
-
Compiler (
scripts/generate-seed.ts): DuringbuildBlockIndexRows(), when iterating sections of collection and feed files:for (const section of sections) { if (section.type === 'time' || section.type === 'log') { const script = parseScript(section.rawContent); const efforts = extractEffortSlugs(script.statements); for (const slug of efforts) { effortRows.push({ id: `static:${noteId}:${section.id}:${slug}`, noteId, blockId: section.id, blockContentId: section.scriptBlock?.contentId, effortSlug: slug, isStatic: true, createdAt, }); } } }
-
Chunk Emission:
Emits
public/seed/chunks/block-efforts.<n>.<sha256-8>.json(partitioned alongsideblock-index). -
Importer (
SeedImporter.ts): When readingblock-effortschunks, writes rows directly intoblock_effortsin the same transaction asblock_index.
Promote canonical tag keys and effort filters to first-class citizens:
export const WQL_TAG_KEYS = [
'domain', 'format', 'equipment', 'quality', 'intent',
'effort', 'discipline', 'intensity', 'grade', 'note', 'page', 'origin',
'grain', 'metric', 'block', 'result', 'tags',
] as const;Update runFind to resolve effort containment and typed tags through IDB indexes:
// 1. Resolve effort containment on find:note
if (filter.key === 'effort') {
const effortMatches = new Set<string>();
for (const v of filter.values) {
const rows = await this.storage.readonly('block_efforts').getAllFromIndex('by-effort', v.value);
for (const r of rows) effortMatches.add(r.noteId);
}
candidateNotes = candidateNotes.filter(n => effortMatches.has(n.id));
}
// 2. Resolve effort containment on find:block
if (filter.key === 'effort' && parsed.target === 'block') {
const blockMatches = new Set<string>();
for (const v of filter.values) {
const rows = await this.storage.readonly('block_efforts').getAllFromIndex('by-effort', v.value);
for (const r of rows) blockMatches.add(r.blockContentId);
}
candidateBlocks = candidateBlocks.filter(b => blockMatches.has(b.blockContentId));
}
// 3. Resolve dynamic typed tags (domain, format, equipment, quality, intent)
if (isKnownTagType(filter.key)) {
const tagMatches = new Set<string>();
for (const v of filter.values) {
// Look up tag where label == v.value AND type == filter.key
const tags = await this.storage.readonly('tags').getAllFromIndex('by-label', v.value);
const typedTag = tags.find(t => t.type === filter.key);
if (typedTag) {
const noteTags = await this.storage.readonly('note_tags').getAllFromIndex('by-tag', typedTag.id);
for (const nt of noteTags) tagMatches.add(nt.noteId);
}
}
candidateNotes = candidateNotes.filter(n => tagMatches.has(n.id));
}The WQL Composer translates visually between pills and WQL text.
[ Source: collections ] [ Domain: crossfit ] [ Effort: thruster ] [ Format: for-time ]
└───────────────────▲─────────────────────────────────────────┘
Active Filter Pills
-
Extend
ClauseType:export type ClauseType = | 'source' | 'domain' | 'format' | 'equipment' | 'quality' | 'intent' | 'effort' | 'text' | 'catalog' | 'tag' | 'time' | ... ;
-
Update
CONTENT_FILTER_TYPES:export const CONTENT_FILTER_TYPES: ReadonlySet<ClauseType> = new Set([ 'domain', 'format', 'equipment', 'quality', 'intent', 'effort', 'catalog', 'text', 'tag', 'time', ]);
-
Pill Metadata & Icons:
-
domain: IconCompass, hint"crossfit, parkour, swimming…" -
format: IconTimer, hint"for-time, amrap, emom, intervals…" -
equipment: IconDumbbell, hint"kettlebell, barbell, clubs, rings…" -
quality: IconZap, hint"strength, conditioning, endurance…" -
intent: IconTarget, hint"benchmark, competition, sport…" -
effort: IconActivity, hint"thruster, pull-up, snatch…"
-
Ensure flawless two-way synchronization:
const PILL_KEY: Record<string, string> = {
domain: 'domain',
format: 'format',
equipment: 'equipment',
quality: 'quality',
intent: 'intent',
effort: 'effort',
text: 'text',
catalog: 'catalog',
tag: 'tags',
// ...
};Typing directly into the composer input matches filter keys instantly:
- Typing
"eff"$\to$ proposes+ Add Effort filter - Typing
"eq"$\to$ proposes+ Add Equipment filter - Typing
"fo"$\to$ proposes+ Add Format filter - Typing
"dom"$\to$ proposes+ Add Domain filter
When a pill is focused, suggestions load dynamically from IndexedDB:
-
equipment:tagsStore.getAllFromIndex('by-type', 'equipment') -
format:tagsStore.getAllFromIndex('by-type', 'format') -
domain:tagsStore.getAllFromIndex('by-type', 'domain') -
intent:tagsStore.getAllFromIndex('by-type', 'intent') -
effort:effortsStore.getAll()(returns all bundled and custom exercises)
graph TD
A[Step 1: Storage Layer] -->|Add block_efforts store| B[Step 2: Save Seam & Compiler]
B -->|Sync efforts on save + seed| C[Step 3: QueryService Engine]
C -->|O 1 effort & tag queries| D[Step 4: WQL Composer UI]
D -->|Pills & Typeahead| E[Step 5: View Integration]
- Define
BlockEffortinterface inapps/playground/src/types/storage.ts. - Add
block_effortsstore withby-effort,by-note, andby-blockindexes to:IndexedDBStorage.tsInMemoryStorage.tsIStorage.ts
- Update
StorageService.rebuildBlockIndexForNoteto extract efforts fromsegment.rawContentand writeblock_effortsin the same transaction. - Update
scripts/generate-seed.tsto emitblock-efforts.<n>.jsonchunks. - Update
SeedImportStorage.tsandSeedImporter.tsto applyblock-effortschunks.
- Add canonical keys to
vocabulary.tsinpackages/wql. - Update
QueryService.tsto resolveeffort:and typed tags usingblock_effortsandnote_tags. - Update CodeMirror autocompletion in
packages/wql/src/language.ts.
- Update
queryClauses.ts,queryAst.ts, andfilterTypeahead.tsinpackages/ui/src/composer. - Wire dynamic suggestions in
suggestionSources.ts. - Update composer unit tests in
packages/ui/test/composer.test.tsx.
- Verify search in
StreamQueryBar.tsx, Library, Collections, and Journal views. - Test compound queries:
find:note{effort:thruster, format:for-time, intent:benchmark}.