The backlog of helpers that might belong in @versable-git/toolkit. Filing is
deliberately cheaper than building: an agent who notices a generalizable helper
in an app writes six lines here and moves on, and the whole list is processed at
once at a wave boundary. Design: docs/plan/62-toolkit-proposals-design.md.
Why the toolkit has no consumers yet, and what unblocks that:
docs/plan/43-toolkit-rca.md.
The toolkit is published (@versable-git/toolkit 0.1.0, private: false),
so an app can import it today; what each accepted proposal waits on is a real
call site in an app, since nothing is built here speculatively (the README's
"Adoption is zero"). Every accepted entry below was reworded on 2026-08-19 to say so.
How to file#
Grep this file for the helper's name first.
- Add. No match: append an entry under
## Openwith statusopen, and stop. Whether it is a good idea is the processing run's call, not the filer's. - Update. A match: edit that entry in place and add your call site to its
Sourceline. Never open a second entry for one helper. - Snooze. Move it to
## Snoozedwith aWhy:line and a revisit condition that names an event ("when the toolkit publishes"), never a date. - Discard. Move it to
## Discardedwith aWhy:line. The commonest honest reason is that the helper carries domain vocabulary, so it belongs to the app.
Entries append to the end of their section and are never renumbered or deleted;
a discarded proposal is the record that stops the next agent re-proposing it.
Ids are TP-YYYYMMDD-NN, the date you file plus a two-digit counter for that day.
An entry is six lines:
### TP-20260817-01 · pluralize with word forms- **Source:** `walmart-mvp/frontend/src/pages/Jobs.tsx:166`, `speedway/app/components/WorkflowStageChain.tsx:137`- **Today:** `n === 1 ? "job" : "jobs"`, inline- **Generalizes because:** no domain vocabulary, no framework import, the output is decidable from the signature- **Wanted:** `pluralize(n: number, one: string, many?: string): string`, covering y/ies and is/are- **Status:** openWanted is one line of what the shared version should look like, not a design.
Processing (the main agent, at a wave boundary) reads ## Open in one sitting,
gives every entry one of the four outcomes, and reconciles the app copies of an
accepted one on paper (one signature, every behavioural difference named and
decided) before any code moves. An accepted entry moves to ## Landed with its
export path once the code exists.
Open#
TP-20260818-01 · formatCount: exact grouped-thousands number with a pinned locale#
- Source:
speedway/app/lib/format.ts:13-15(num(), locale pinned to en-US to dodge a server/browser hydration gap), 20+ bare.toLocaleString()calls acrosswalmart-mvp/frontend/src/pages/Jobs.tsx,Home.tsx,Review2Tab.tsx,Review4Tab.tsx,Workspace.tsx,Admin.tsx(gcp-opus hand-roll audit, 2026-08-18,~/Code/Versable/gcp/.claude/output/20260818-v6-planning/handroll-audit.md) - Today:
number.getHumanReadableNumberabbreviates (1.2k) and does not cover the exact case; every app re-rollstoLocaleString, and any SSR consumer inherits the runtime locale and risks a hydration mismatch (the same class the kit'sTimestampfixed on 2026-08-18 withDEFAULT_LOCALE) - Generalizes because: two apps, no domain vocabulary, decidable from the signature; the pinned locale is the whole point
- Wanted:
formatCount(n: number | null | undefined, opts?: { locale?: string; fallback?: string })returning grouped thousands withen-USpinned by default,fallbackfor null (default an empty string); one place for the pin, shared with the kit's constant when the toolkit is a kit dependency, otherwise its own - Status: filed 2026-08-18; builds when the console (the first SSR consumer) asks for it, per this file's rule
TP-20260817-01 · pluralize with word forms#
- Source:
walmart-mvp/frontend/src/pages/Jobs.tsx:166,speedway/app/components/WorkflowStageChain.tsx:137,walmart-mvp/frontend/src/pages/ErrorManagement.tsx:704,walmart-mvp/frontend/src/pages/Home.tsx:190,walmart-mvp/frontend/src/pages/Settings.tsx:516,walmart-mvp/frontend/src/features/catalog/ReviewTab.tsx:1446,walmart-mvp/frontend/src/features/catalog/PartTypeGroups.tsx:318,speedway/app/routes/workspaces/jobs/job.tsx:242,speedway/app/routes/workspaces/jobs/jobs.tsx:282,speedway/app/routes/workspaces/review/review.tsx:708,speedway/app/routes/admin/usage.tsx:302,speedway/app/lib/modules/scrape.server.ts:583,speedway/app/lib/ingest.server.ts:562(and roughly 65 more sites across both apps; every count-driven noun in both codebases follows this shape) - Today:
n === 1 ? "" : "s"appended inline to a noun at both sites; the toolkit'stext.pluralize(value, word)(packages/toolkit/src/text.ts:7) only appendss, so it cannot express y/ies or is/are - Generalizes because: no domain vocabulary, no framework import, the output is decidable from the signature
- Wanted: extend
pluralizeto(n: number | undefined, one: string, many?: string)with the two-argument call unchanged - Status: accepted, processed 2026-08-17 (wave 2 tail). Reconciled:
pluralize(value: number | undefined, word: string, many?: string); the two-argument call keeps appendings;manyis used verbatim whenvalue !== 1(covers y/ies and is/are); a falsyvaluestays plural ("0 jobs"), which is what every inline site already renders. The toolkit is published (2026-08-18); builds at the first real call site; walmart and speedway adopt it at their next deliberate kit pass.
TP-20260817-02 · abbreviated relative-time with date fallback#
- Source:
walmart-mvp/frontend/src/lib/utils.ts:22,walmart-mvp/frontend/src/features/catalog/pipeline.tsx:84 - Today: two byte-identical
formatRelative(iso: string): stringfunctions inside walmart-mvp itself, producing "12m ago" / "2h ago" / "3d ago" then falling back to a locale date string past a week; toolkit'sgetHowLongAgo(packages/toolkit/src/date.ts:88) andgetRelativeTime(date.ts:128) both exist but neither abbreviates the unit nor falls back to a plain date - Generalizes because: no domain vocabulary, no framework import, the output is decidable from the signature
- Wanted: a
formatRelative(iso: string, now?: Date | number): stringproducing the abbreviated form with the week-plus date fallback, or an option ongetHowLongAgofor the same - Status: accepted, processed 2026-08-17 (wave 2 tail). Reconciled: extend
getHowLongAgo(date, now?, opts?: { short?: boolean; fallbackAfterDays?: number });shortgives "12m ago" / "2h ago" / "3d ago", pastfallbackAfterDaysit returnsgetHumanReadableDate(date); falsy input returns "" (the toolkit guard wins over the kit ladder that prints the epoch,docs/plan/43-toolkit-rca.md:128-137); walmart's two copies are byte-identical so nothing else diverges. Builds when the toolkit publishes, in the same move that retires the kit's own ladder.
TP-20260817-04 · avatar initials from a name-or-fallback string#
- Source:
walmart-mvp/frontend/src/App.tsx:210,walmart-mvp/frontend/src/pages/Settings.tsx:481 - Today:
(user.full_name || user.email).slice(0, 2).toUpperCase()inline at both sites; the toolkit'sgetNameInitials(packages/toolkit/src/text.ts:49) takes the first letter of each space-separated word instead, so it returns a different result for a single-word value and has no fallback-chain argument - Generalizes because: no domain vocabulary, no framework import, the output is decidable from the signature
- Wanted:
getInitialsFromValue(value: string, length = 2): stringreturningvalue.slice(0, length).toUpperCase(), called asgetInitialsFromValue(user.full_name || user.email) - Status: accepted as an extension, processed 2026-08-17 (wave 2 tail). Reconciled:
getNameInitials(name, opts?: { singleTokenLength?: number }); multi-word names keep the first letter of the first two words ("Aakarsh Chopra" is "AC", the walmartslice(0, 2)reading "AA" is the bug the RCA already recorded); a single token (an email fallback) takes its firstsingleTokenLengthcharacters upper-cased so the avatar is never one letter. The fallback chain (full_name || email) stays at the call site.
TP-20260817-05 · uppercase first letter, leave the rest untouched#
- Source:
speedway/app/routes/account/accounts.tsx:50,speedway/app/routes/workspaces/jobs/run.tsx:420,speedway/app/routes/admin/team.tsx:37,speedway/app/components/ShellCrumbs.tsx:123,speedway/app/lib/extractor-results.server.ts:130 - Today:
s.charAt(0).toUpperCase() + s.slice(1)duplicated at all five sites; speedway already has a localucfirst(speedway/app/lib/text.ts:7) with the identical body but it is never imported anywhere. The toolkit'sawareCapitalize/capitalizeAll(packages/toolkit/src/text.ts:26) lowercase the remainder of the string via lodash'scapitalize, a different result for an already mixed-case tail - Generalizes because: no domain vocabulary, no framework import, the output is decidable from the signature
- Wanted:
ucfirst(s: string): string, first character uppercased, rest untouched - Status: accepted, processed 2026-08-17 (wave 2 tail). Reconciled:
ucfirst(s: string): string, first character upper-cased, the rest untouched; a different function fromawareCapitalize, which lower-cases the tail, so both live intext. speedway's unused localucfirst(speedway/app/lib/text.ts:7) is the body.
TP-20260817-06 · slugify#
- Source:
speedway/app/lib/workspaces.server.ts:11, called atspeedway/app/lib/workspaces.server.ts:37andspeedway/app/lib/orgs.server.ts:33 - Today:
name.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "workspace"; not in the toolkit today - Generalizes because: no domain vocabulary in the signature, no framework import, the output is decidable from the signature
- Wanted:
slugify(value: string, maxLength?: number, fallback?: string): string - Status: accepted, processed 2026-08-17 (wave 2 tail). Reconciled:
slugify(value: string, opts?: { maxLength?: number; fallback?: string })withmaxLengthdefaulting to 48 andfallbackto ""; speedway's|| "workspace"is domain and moves to the call site.
TP-20260817-07 · trigger a download from an already-built Blob#
- Source:
walmart-mvp/frontend/src/api.ts:245,walmart-mvp/frontend/src/pages/ErrorManagement.tsx:754,speedway/app/lib/download.ts:32 - Today: all three build a
Blob, then repeatURL.createObjectURL+ create<a>+ sethref/download+ click +URL.revokeObjectURL; the toolkit'sdownloadUrlAsFile(packages/toolkit/src/download.ts:7) already does the create-click-revoke part but only accepts a URL string, anddownloadDataAsFile(download.ts:25) builds its own Blob from a string, so neither takes aBlobthe caller already has - Generalizes because: no domain vocabulary, no framework import, the output is decidable from the signature
- Wanted:
downloadBlobAsFile({ blob, filename }: { blob: Blob; filename: string }): void, wrappingdownloadUrlAsFilewithURL.createObjectURL(blob) - Status: accepted, processed 2026-08-17 (wave 2 tail). Reconciled:
downloadBlobAsFile({ blob, filename }: { blob: Blob; filename: string }): void,URL.createObjectURLthendownloadUrlAsFilethen revoke; the three app copies are the same create-click-revoke sequence, nothing diverges.
TP-20260817-08 · sum / sumBy#
- Source: plain sum at
walmart-mvp/frontend/src/pages/Jobs.tsx:568,speedway/app/lib/storage.server.ts:202; selector form atspeedway/app/routes/workspaces/workspace.tsx:91,speedway/app/routes/workspaces/review/log.tsx:111,walmart-mvp/frontend/src/pages/Workspace.tsx:53, and 10+ more.reduce((n, x) => n + x.field, 0)sites across both apps - Today:
arr.reduce((a, b) => a + b, 0)for raw number arrays, andarr.reduce((n, item) => n + item.someField, 0)for a selected field; the toolkit'saverage(packages/toolkit/src/iter.ts:89) computes a sum internally with the same reduce but does not export it - Generalizes because: no domain vocabulary in the signature, no framework import, the output is decidable from the signature
- Wanted:
sum(arr: number[]): numberandsumBy<T>(arr: T[], selector: (item: T) => number): number, withaveragecallingsuminternally - Status: accepted, processed 2026-08-17 (wave 2 tail). Reconciled:
sum(values: number[]): numberandsumBy<T>(items: T[], select: (item: T) => number): number, withaveragecallingsum; the app copies differ only in the selector, which is the second signature.
Snoozed#
Landed#
Discarded#
TP-20260817-03 · error message with status-prefix strip#
- Source:
walmart-mvp/frontend/src/api.ts:205, consumed at 40+ call sites acrosswalmart-mvp/frontend/src/pages/*.tsxandfeatures/**(e.g.pages/Jobs.tsx:763,features/catalog/CatalogDetail.tsx:302) - Today:
errorMessage(err)doeserr instanceof Error ? err.message : String(err)then strips a leading\d{3}:\s*status prefix that walmart's own fetch wrapper prepends; the toolkit'sgetApiError(packages/toolkit/src/result.ts:20) covers the Error/axios-shape extraction but has no such prefix strip, and returns a generic default instead ofString(err)for other values - Generalizes because: no domain vocabulary, no framework import, the output is decidable from the signature
- Wanted:
getApiErrorgains an option to strip a leadingNNN:status prefix, or the two shapes are reconciled into one; walmart adopts whichever wins instead of its local copy - Status: discarded, processed 2026-08-17 (wave 2 tail). Why: the
NNN:prefix is walmart's own fetch wrapper convention, so the strip belongs towalmart-mvp/frontend/src/api.ts, not to a shared helper; the rest oferrorMessageisgetApiErrorand is an adoption fact for walmart, not a proposal.