Add-Stringjob Flow¶
The V2 hero workflow. The screen Stefan reaches 50+ times a year. Owned by Mira. Cross-cuts: V2 scope M9 + M11, use cases — UC-2, order lifecycle, receipt content, data model — Order, client identity & sharing — identity matching, i18n architecture.
Source requirement¶
- M9 — "Add Stringjob" flow: search-client → select → "copy last" (or auto-prefill) → adjust → save. Editable price.
- M11 — Per-side BYO flag.
- UC-2 — Natural sequence Player → Racket → String for fresh entry; copy-of-previous for the common path. Tension nudge ±1 kg. Cross defaults to "same as main" (89% of cases).
- Order lifecycle —
ordered_atdefaults to today on save (UC-2 + locked decisions);strung_atis set by a separate "mark strung" action (state Ordered = the new-record default for client jobs). - Receipt content — every field on the receipt must be capturable here.
- Client identity — adding a new client uses verified-email-only auto-match; no fuzzy matching.
Goal¶
The 90% happy path is "same client, same racket, same string spec as last time, slight tension tweak". Optimise the flow for that path: one tap to pick the client, one tap to load the last setup, one tap-target adjustment, one save. Target: < 30 seconds and < 8 taps from CTA to saved Ordered job when the client is recognised and copy-last is accepted unchanged.
The 10% divergent paths (new client, different racket, different string, BYO toggle, comments, self-fields) must be reachable without a separate flow — but they hide until needed.
Three-stage flow¶
┌──────────────┐ ┌──────────────────┐ ┌────────────────────┐
│ 1. Pick │ → │ 2. Form │ → │ 3. Save │
│ Client │ │ (prefilled if │ │ (creates Order in │
│ │ │ previous exists)│ │ Ordered or Strung │
└──────────────┘ └──────────────────┘ │ state) │
└────────────────────┘
Each stage is a distinct screen on sm (one thing per screen, mobile-first); they collapse into a single scroll on lg for stringers using a desktop. The transitions are HTMX swaps when JS is available, full page loads otherwise.
Stage 1 — Pick client¶
Viewport sm (375 px)¶
┌───────────────────────────────────────┐
│ ← Add Stringjob Cancel │ Header: back + cancel
├───────────────────────────────────────┤
│ │
│ Who's this for? │ H1
│ │
│ ┌───────────────────────────────────┐ │
│ │ 🔍 Search clients │ │ Search input (autofocus)
│ └───────────────────────────────────┘ │
│ │
│ Recent │ Lightweight section
│ ┌───────────────────────────────────┐ │
│ │ 👤 Lukas Müller │ │
│ │ Last strung 2026-04-30 │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ 👤 Anna Bauer │ │
│ │ Last strung 2026-04-28 │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ 👤 Stefan (myself) Self │ │ Self-ClientProfile pinned
│ └───────────────────────────────────┘ │
│ ⋯ │
│ │
│ Or │
│ ┌───────────────────────────────────┐ │
│ │ + New client │ │ Inline action
│ └───────────────────────────────────┘ │
│ │
└───────────────────────────────────────┘
As Stefan types¶
The "Recent" list filters live as Stefan types (HTMX hx-trigger="keyup changed delay:150ms"). Match is case-insensitive, against Person.display_first_name + display_last_name AND against ClientProfile.nickname (Stefan's private label, e.g. "the lefty kid"). The search input is autofocused on stage entry.
Components¶
- Header — back arrow: returns to dashboard. Cancel = same effect, friendlier label.
- Search input: placeholder "Search clients" (
stringjob.client.search.placeholder). Hit target ≥ 48 px tall. Type=searchso iOS/Android show the search keyboard. Clear button (×) on the right when non-empty. - Recent list: by default, the stringer's clients sorted by
MAX(orders.strung_at)desc. Cap at 10 in Round 1. Self-ClientProfile is pinned to the top of the list (with a "Self" chip) whenis_self_for_stringer = TRUEexists. - Client row: avatar circle (initials on slate-200 background), full name (display_first_name + display_last_name), and a metadata line "Last strung YYYY-MM-DD" or "No previous jobs" for fresh ClientProfiles. Whole row is the tap target.
- + New client: inline action at the bottom of the recent list. Opens the new-client mini-flow.
New-client mini-flow¶
Tapping "+ New client" reveals a small inline form (HTMX hx-swap="innerHTML" of the recent-list region):
┌───────────────────────────────────────┐
│ New client │
│ │
│ First name * │
│ ┌───────────────────────────────────┐ │
│ │ │ │
│ └───────────────────────────────────┘ │
│ Last name * │
│ ┌───────────────────────────────────┐ │
│ │ │ │
│ └───────────────────────────────────┘ │
│ Email (optional) │
│ ┌───────────────────────────────────┐ │
│ │ │ │
│ └───────────────────────────────────┘ │
│ ⓘ If your client signs up later, an │
│ email lets us link their record. No │
│ email = stringer-only record. │
│ │
│ ┌───────────┐ ┌──────────────────┐ │
│ │ Cancel │ │ Save & continue │ │
│ └───────────┘ └──────────────────┘ │
└───────────────────────────────────────┘
- First name + last name are required.
- Email is optional, per data model — Email required vs. optional. The hint clarifies the trade-off.
- No fuzzy matching at submit time. Per client identity & sharing — identity matching, only verified-email match. If the entered email matches an existing verified Person, we surface a dialog: "A client with this email already exists. Add them to your client list?" (Yes → creates a new ClientProfile pointing at the existing Person; No → cancel). If no match (or no email entered), a fresh draft Person + ClientProfile is created.
- Save & continue advances to Stage 2 with the new ClientProfile pre-selected.
Self-ClientProfile shortcut¶
If is_self_for_stringer = TRUE exists for this stringer, tapping the "Self" row jumps directly to Stage 2 with ordered_at = NULL and the form in self-mode (lifecycle dates de-emphasised; optional self-fields surfaced; receipt auto-email suppressed by default per order-lifecycle self-job differences).
Stage 2 — The form¶
The body of the spec. Mobile-first, single-column, prefilled-from-previous when one exists.
Viewport sm (375 px) — happy path with prefill¶
┌───────────────────────────────────────┐
│ ← Add Stringjob Cancel │
├───────────────────────────────────────┤
│ │
│ For Lukas Müller │ H1 + client subhead
│ Last strung 2026-04-30 · CHF 48.00 │
│ │
│ ┌─────────────────────────────────┐ │
│ │ ⤴ Copy last setup │ │ Big primary action
│ └─────────────────────────────────┘ │
│ Or fill in below. │
│ │
│ ───── Racket ───── │
│ ┌───────────────────────────────────┐ │
│ │ 🎾 Babolat Pure Aero 98 │ │ Picker (tap to change)
│ │ Strung 3× · last 2026-04-30 ▾ │ │
│ └───────────────────────────────────┘ │
│ │
│ ───── Strings ───── │
│ Main │
│ ┌───────────────────────────────────┐ │
│ │ Luxilon ALU Power 1.25 ▾ │ │ String picker
│ └───────────────────────────────────┘ │
│ Tension │
│ ┌─────┐ ┌──────────┐ ┌─────┐ │
│ │ −1 │ │ 24 kg │ │ +1 │ │ Tension nudge (48×48 px)
│ └─────┘ └──────────┘ └─────┘ │
│ Price │
│ ┌───────────────────────────────────┐ │
│ │ CHF 18.00 │ │
│ └───────────────────────────────────┘ │
│ ☐ BYO (client brought their own) │
│ │
│ Cross │
│ ◉ Same as main │ Default ON
│ ◯ Different │
│ Tension │
│ ┌─────┐ ┌──────────┐ ┌─────┐ │
│ │ −1 │ │ 24 kg │ │ +1 │ │
│ └─────┘ └──────────┘ └─────┘ │
│ │
│ ───── Pricing ───── │
│ Labor │
│ ┌───────────────────────────────────┐ │
│ │ CHF 25.00 │ │
│ └───────────────────────────────────┘ │
│ │
│ Subtotal (strings) CHF 18.00 │
│ Total CHF 43.00 │ Live-computed
│ │
│ ▼ More options │ Collapsed by default
│ │
│ ───── Lifecycle ───── │
│ Ordered 2026-05-02 (today) │ Editable date pill
│ ☐ Mark strung now (sets Strung → │
│ today, emits receipt) │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Save │ │ Sticky-bottom CTA
│ └─────────────────────────────────┘ │
└───────────────────────────────────────┘
The "Copy last setup" button is a single tap that does what the form already mostly shows — it's mostly there for fresh clients (no prefill) where the previous-job copy isn't already loaded. When prefill is automatic (the common case), the button is rendered as a confirmation cue ("This is from your last job — adjust below") with no separate "copy" action.
Layout decisions per UC-2 and the locked decisions¶
Prefill source (the heart of UC-2's "kill the spreadsheet dance"):
When a ClientProfile has >= 1 Order with strung_at IS NOT NULL, the form prefills from the most recent Strung order for that ClientProfile (not the most recent Order regardless of state — we want a confirmed setup, not a half-saved Draft). Prefilled fields:
- Racket FK (most recent Strung-order's racket)
main_string_id/main_string_textmain_tensionmain_byo(carries over — Stefan accepts the rare wrong default for the 89% common case)main_color(carried, since self-fields are nullable everywhere; absent for client orders is fine)main_pricecross_*mirrors of all the above; cross always renders as "Same as main" when the prefilled values are equal across sides (the 89% case), even if the previous order was stored with cross fields explicitly populated.labor
When no previous order exists (new ClientProfile), the form renders empty with reasonable defaults: tension 24 kg (a Stefan-personal default; configurable per stringer in a future round), labor 25 CHF (V1 baseline).
Component breakdown¶
Page header¶
- Back arrow → Stage 1 (re-pick client). Confirmation dialog if any field has been touched (prevents data loss).
- Cancel → dashboard. Same confirmation if dirty.
Client subhead¶
H1 "For {first} {last}". Subhead "Last strung {date} · {total}" (or "First job for this client" when none). Tapping the H1 → re-pick client (alternative path to the back arrow; useful on lg where back may be off-thumb).
Racket picker¶
- Visible label above: none — the section header "Racket" is enough.
- Default selection = the racket from the prefill source. Disclosure caret (▾) opens the picker.
- Picker behaviour: opens a modal sheet (full-width on
sm, centered modal onmd+) listing rackets owned by this client, ordered byMAX(orders.strung_at) DESCper UC-2. Each row shows make + model + version + an instance disambiguator (year, serial, or "(2)") when the same model exists multiple times. - "Add new racket for this client" is a fixed bottom row in the picker, always reachable. Opens an inline mini-form (make + model + version, optional serial). Submit creates a
Racketrow withowner_client_profile_id = current ClientProfile,visibility = catalogue-private, and selects it. - Display in the form (after pick): make + model + version on line 1; "Strung Nx · last YYYY-MM-DD" on line 2. The "Strung 3×" count is per-racket history (a UC-7 nice-to-have surfaced cheap here).
Main string section¶
- String picker opens a modal listing the catalogue-shared strings + the stringer's catalogue-private strings, ordered by recent use. Search field at top (filters manufacturer + model). "Add custom string" inline action at the bottom — opens a mini-form for one-off / catalogue-private creation, with a "request shared" checkbox for catalogue submission per UC-7.
- Tension nudge: the centerpiece of UC-2's UX requirement.
- Three-element row:
−1button (48 × 48 px), tension display (read-only number + "kg"),+1button (48 × 48 px). - Tapping
−1decrementsmain_tensionby 1; tapping+1increments by 1. Holding the button auto-repeats at 200 ms intervals after a 500 ms delay (long-press accelerator). - Direct edit: tapping the tension display reveals a numeric input (one-tap-to-edit, with
inputmode="decimal"to surface the numeric keyboard). - Range guard: 12 kg ≤ tension ≤ 35 kg (a sanity range; configurable later if Stefan ever strings outside it). Buttons disable at the bounds.
- Visual: the display uses tabular-nums; nudge buttons are slate-200 background, slate-900 text, indigo-700 on press. ARIA label: "Tension — currently 24 kilograms. Use minus and plus buttons to adjust by 1 kilogram."
- Price input: numeric,
inputmode="decimal", prefix "CHF". The label is "Price"; placeholder "0.00". - BYO toggle: native checkbox styled as a switch. 48 × 48 px hit area (the checkbox itself can be 24 px visually; the touch target is the whole label area). Label: "BYO (client brought their own)". When checked, the price input gets a subtle annotation "(not charged)" — the price stays editable and visible per BL-4 / BR-4 so the receipt still shows the line, marked as not-charged.
Cross string section¶
- Default mode: "Same as main" (radio button, default ON). When selected, no cross fields are rendered; on save the cross fields are populated from the main values.
- "Different" mode: reveals a full mirror of the main section (string picker, tension nudge, price input, BYO toggle). The cross tension defaults to the same value as main when entering Different mode (per UC-2).
- This collapses 89% of orders to a single decision (don't render cross at all). The remaining 11% pay one tap.
Pricing section¶
- Labor input: same shape as the price inputs.
- Subtotal (strings): computed live:
(main_byo ? 0 : main_price) + (cross_byo ? 0 : cross_price). Read-only. - Total: computed live:
subtotal + labor. Read-only, but typographically prominent (text-h2, slate-900, tabular-nums) — Stefan glances at this number before saving.
"More options" disclosure (collapsed by default)¶
Per locked decision 2 (optional self-fields nullable on every order). Collapsed by default to keep the client-job happy path uncluttered; expanded by default in self-mode.
▼ More options
Comments
┌───────────────────────────────────┐
│ │
│ │
└───────────────────────────────────┘
Main color [____________]
Cross color [____________]
Method [Standard ▾]
DT after [____ ]
- Comments is the most-used "more option" — surfaces as a free-text textarea. Per receipt-content C-2, comments DO appear on the customer receipt; the input has a hint "Visible to the client on the receipt."
- Main color / Cross color: free-text inputs. No catalogue (V1 stored these as raw strings).
- Method: a small select, default "Standard". Free entry per V1 (Stefan can type anything).
- DT after: numeric, kg. Optional, mostly self-fields.
Lifecycle section¶
───── Lifecycle ─────
Ordered 2026-05-02 (today)
☐ Mark strung now (sets Strung → today, emits receipt)
- Ordered date pill: displays today's date by default. Tapping reveals a date picker; the pill goes back to a "(today)" hint when set to today's actual date.
- "Mark strung now" checkbox: when checked, the form sets
strung_at = todayon save. Per order-lifecycle T2, this triggers receipt render + email at save time. The checkbox label includes the side-effect explicitly so Stefan knows what he's signing up for. - Self-mode variation: for self-orders (
client_profile.is_self_for_stringer = TRUE), the Ordered field is suppressed (not rendered), and "Mark strung now" is the default checked state — self-orders typically jump Draft → Strung directly.
Save CTA¶
- Sticky-bottom on
sm, full-width minus 16 px page padding. Label: "Save". md+ behaviour: the CTA moves into the lower form area but stays visible (the form is short enough that scrolling-out-of-view doesn't happen at typical content density).- Saving state: button label switches to "Saving…" with a 16 px spinner. Disabled while in flight. On error, button re-enables and an inline error toast appears (see Interaction states).
- On success: redirect to dashboard with a success toast: "Saved — Lukas Müller's order is in the queue." (Or "...and the receipt was sent to Lukas." when
strung_atwas set.) The toast auto-dismisses after 4 seconds; manual dismiss button always available.
Viewport md (768 px)¶
Single column at 720 px max width, centered. Sections gain a little vertical breathing room (space-8 between sections instead of space-6). Save CTA un-stickies — it's just below the form. Otherwise identical to sm.
Viewport lg (1280 px)¶
Two-column layout for the form: Racket + Strings on the left (~60% width), Lifecycle + More options + Total on the right (~40% width, sticky). The "Total" line in the right column is always visible as Stefan types prices, satisfying the desktop-stringer's preference for live-totals visibility.
┌───────────────────────────────────────────────────────────────────┐
│ ← Add Stringjob — For Lukas Müller Cancel │
├──────────────────────────────────┬────────────────────────────────┤
│ │ │
│ ⤴ Copy last setup │ Lifecycle │
│ │ Ordered 2026-05-02 (today) │
│ Racket │ ☐ Mark strung now │
│ 🎾 Babolat Pure Aero 98 ▾ │ │
│ │ Pricing │
│ Main │ Labor CHF 25.00 │
│ Luxilon ALU Power 1.25 ▾ │ Subtotal CHF 18.00 │
│ Tension [−][24 kg][+] │ Total CHF 43.00 │
│ Price CHF 18.00 │ │
│ ☐ BYO │ ▼ More options │
│ │ │
│ Cross │ ┌──────────────────────┐ │
│ ◉ Same as main │ │ Save │ │
│ ◯ Different │ └──────────────────────┘ │
│ │ │
└──────────────────────────────────┴────────────────────────────────┘
Stage 3 — Save¶
No separate UI surface. The Stage-2 Save button POSTs to /orders and the response is either a redirect to dashboard (success) or the form re-rendered with inline errors (failure).
What the save does¶
| Field | Source |
|---|---|
stringer_id |
Current session. |
client_profile_id |
Stage-1 selection. |
racket_id |
Stage-2 racket picker. |
main_string_id / main_string_text |
Stage-2 picker; one is set, the other NULL. |
main_tension |
Stage-2 nudge. |
main_byo |
Stage-2 toggle. |
main_price |
Stage-2 input. |
main_color |
More options (nullable). |
cross_* |
"Same as main" → mirror main values. "Different" → cross-section values. |
labor |
Stage-2 input. |
comments, method, dynamic_tension_after |
More options (nullable). |
ordered_at |
Stage-2 lifecycle (default today, editable). NULL for self-orders unless user opts in. |
strung_at |
NULL by default; today if "Mark strung now" checked. |
returned_at, paid_at |
NULL. |
The derived fields (strings_subtotal, total) are computed server-side; the client-side preview is for UX only — Pax owns the canonical computation.
Receipt emission¶
If strung_at is set at save time, the save handler triggers receipt emission per order-lifecycle T2 and M14. The success toast notes the email was sent.
If strung_at is null, no receipt yet — the order is in Ordered state and the receipt will emit when Stefan marks it Strung from the order detail page (out of Round-1 scope).
Interaction states¶
| State | What renders |
|---|---|
| Loading Stage 1 | Server-rendered Recent list. |
| Searching (typing) | Recent list filters live (HTMX). 200 ms debounce. |
| Loading Stage 2 (with prefill) | Form fully rendered server-side with prefilled values. The "Copy last setup" button is rendered as a confirmation hint, not an action. |
| Loading Stage 2 (no prefill) | Form rendered with default values (24 kg, 25 CHF labor). "Copy last setup" hidden (nothing to copy). |
| Validating (on Save submit) | Server validates: required fields filled, tension in range, prices non-negative, dates causally ordered (per order-lifecycle A2). |
| Validation error | Form re-renders with inline error messages under the offending fields, and a top-of-form summary "Please check the highlighted fields." Save button re-enabled. Focus jumps to the first error field. |
| Server error (5xx) | Toast at the top: "Something went wrong on our side. Try Save again." Form values preserved. |
| Network drop | HTMX swap fails → toast "You appear to be offline. Your work is saved on this device — try again when you reconnect." Form values held in localStorage as a safety net (out of Round-1 scope; flagged as Round-2 polish). |
| Saved successfully | Redirect to dashboard, toast "Saved — Lukas Müller's order is in the queue." (or " ... and the receipt was sent to Lukas." with strung_at set). |
| Saved with strung-now + email-bounced | Toast "Saved — but the receipt email to Lukas bounced. [Resend]." (V2.x polish; minimal stub in Round 1.) |
Validation rules (UI surface; canonical server-side)¶
| Rule | Inline message |
|---|---|
| First name required (new-client mini-flow) | "First name is required." |
| Last name required (new-client mini-flow) | "Last name is required." |
| Email format if provided | "Please enter a valid email address." |
| Email matches existing verified Person | (Dialog) "A client with this email already exists. Add them to your client list?" |
| Tension out of range (< 12 or > 35 kg) | "Tension must be between 12 and 35 kg." (Buttons hard-stop at the bounds; this fires only on direct entry.) |
| Negative price | "Price can't be negative." |
ordered_at after strung_at |
"Ordered date can't be after Strung date." (Per order-lifecycle A2.) |
| Required: at least one string spec (main_string_id OR main_string_text) | "Pick a main string or enter one manually." |
| Required: racket | "Pick a racket." |
Server-side validation is canonical (per Theo's chokepoint model); the UI strings above are the user-facing message; the Pydantic / handler validators on Pax's side return the same key.
Accessibility¶
- Form-control labels: every input has a programmatic
<label for=...>association. Visual labels are above the input, never inside as placeholder-only. - Tension nudge buttons:
aria-label="Decrease tension by 1 kilogram"/aria-label="Increase tension by 1 kilogram". The display hasrole="status"so its value updates are announced. - BYO toggle: native
<input type="checkbox">with the full row as the touchable label. The(not charged)annotation isaria-describedbylinked to the price input so screen-reader users know the price is excluded from the total. - Total:
<output for="main_price cross_price labor">witharia-live="polite"so screen readers announce the running total as values change. - Error summary: the top-of-form summary has
role="alert"andaria-live="assertive". Inline errors arearia-describedby-linked to their fields. - Modal pickers (racket, string): focus-trapped while open, restore focus to the trigger on close, ESC closes.
- Hit targets: every interactive element ≥ 44 × 44 px. Tension nudge buttons + BYO toggle row + Save CTA are 48 × 48 px (the most-pressed; per design-tokens — hit targets).
- Color contrast: every text/background pair meets WCAG 2.1 AA. Validation error text is
red-700on white = 6.13:1 (well above 4.5:1). - Saved-pref-wins for locale: the entire form renders in
Stringer.default_localeper i18n architecture. BrowserAccept-Languageis ignored. prefers-reduced-motion: the long-press accelerator on the tension nudge stays as-is (it's a holding gesture, not an animation); HTMX swap fades disable.
HTMX / progressive-enhancement seams¶
- Stage 1 client search:
hx-get="/orders/new/_clients?q=..." hx-trigger="keyup changed delay:200ms" hx-swap="innerHTML"against the recent-list region. - Stage 1 → Stage 2 transition: ideally an HTMX swap of the page body. Without JS: a regular form POST to
/orders/new?client_profile_id=...returns the Stage-2 form. - Racket picker / String picker: server-rendered modal sheet, opened via
hx-get="/orders/new/_racket-picker?client_profile_id=..."and inserted into a portal element. Without JS: the picker becomes a separate page (GET /orders/new/racket-picker) that POSTs the selection back into the form's session state. (Round-1 spec accepts a slight degradation without JS — the without-JS path is a regular-form-submit dance, not a single-page modal.) - Tension nudge: native
<button>elements withhx-post="/orders/new/_tension/{main|cross}/{up|down}"returning the updated tension display. Without JS: the button is a form-submit that round-trips the whole form; tension updates after a full reload. Slow but functional. - Cross "same as main" radio: plain native radio. The reveal of the "Different" sub-form is
hx-triggerswap of the cross subsection. Without JS: server re-renders the subsection on next form submit. - More options disclosure: native
<details>/<summary>— no HTMX needed. - Save: native form POST. The HTMX upgrade returns a 303 redirect that HTMX follows; without JS, the browser follows it normally. End state is identical.
The form's fundamental contract is "one HTML form, one POST". HTMX swaps are surgical optimisations on top — Stefan with a flaky tournament-venue connection still gets a working flow.
i18n affordance¶
| String | Type | Catalogue key |
|---|---|---|
| "Add Stringjob" (page title) | {% trans %} |
stringjob.title |
| "Cancel" | {% trans %} |
common.cancel |
| "Who's this for?" (Stage 1 H1) | {% trans %} |
stringjob.client.h1 |
| "Search clients" (placeholder) | {% trans %} |
stringjob.client.search.placeholder |
| "Recent" | {% trans %} |
stringjob.client.recent |
| "Last strung {date}" | {% trans %} (Babel) |
stringjob.client.last_strung |
| "No previous jobs" | {% trans %} |
stringjob.client.no_previous |
| "Self" (chip) | {% trans %} |
stringjob.client.self_chip |
| "+ New client" | {% trans %} |
stringjob.client.new |
| "First name", "Last name", "Email (optional)" | {% trans %} |
stringjob.newclient.{first,last,email} |
| "If your client signs up later..." (hint) | {% trans %} |
stringjob.newclient.email_hint |
| "Save & continue" | {% trans %} |
stringjob.newclient.submit |
| "For {first} {last}" (Stage 2 H1) | {% trans %} (Babel) |
stringjob.h1 |
| "Last strung {date} · {currency}" | {% trans %} |
stringjob.client.subhead_with_last |
| "First job for this client" | {% trans %} |
stringjob.client.subhead_no_last |
| "Copy last setup" | {% trans %} |
stringjob.copy_last |
| "This is from your last job — adjust below" | {% trans %} |
stringjob.prefill_hint |
| "Or fill in below." | {% trans %} |
stringjob.prefill_or_fill |
| Section headers ("Racket", "Strings", "Main", "Cross", "Pricing", "Lifecycle") | {% trans %} |
stringjob.section.{racket,strings,main,cross,pricing,lifecycle} |
| "Same as main" / "Different" | {% trans %} |
stringjob.cross.{same,different} |
| "Tension" / "Price" / "Labor" / "Subtotal (strings)" / "Total" | {% trans %} |
stringjob.field.{tension,price,labor,subtotal,total} |
| "BYO (client brought their own)" | {% trans %} |
stringjob.field.byo |
| "(not charged)" | {% trans %} |
stringjob.field.byo.not_charged |
| "More options" | {% trans %} |
stringjob.more_options |
| "Comments" / "Visible to the client on the receipt." | {% trans %} |
stringjob.field.{comments,comments_hint} |
| "Main color" / "Cross color" / "Method" / "DT after" | {% trans %} |
stringjob.field.{main_color,cross_color,method,dt_after} |
| "Standard" (default method) | {% trans %} |
stringjob.method.standard |
| "Ordered" / "(today)" | {% trans %} |
stringjob.lifecycle.{ordered,today} |
| "Mark strung now (sets Strung → today, emits receipt)" | {% trans %} |
stringjob.lifecycle.mark_strung_now |
| "Save" / "Saving…" | {% trans %} |
stringjob.{save,saving} |
| Toast: "Saved — {first} {last}'s order is in the queue." | {% trans %} (Babel) |
stringjob.toast.saved.queued |
| Toast: "Saved — {first} {last}'s order is in the queue and the receipt was sent." | {% trans %} |
stringjob.toast.saved.strung |
| Validation messages | {% trans %} |
stringjob.error.<rule> |
| Client name (data) | Data | n/a |
| Racket make/model/version (data) | Data | n/a |
| String manufacturer/model/gauge (data) | Data | n/a |
| Tension "24 kg" | Format (Babel format_unit) |
n/a |
| Currency "CHF 18.00" | Format (Babel format_currency) |
n/a |
| Date "2026-05-02" | Format ISO YYYY-MM-DD per receipt-content OQ-R-2 default |
n/a |
DE strings are Iris's later pass.
DE width budget (designer note)¶
DE labels on this form are typically 20–30% longer than EN. Specific watch-outs:
- "Add Stringjob" → "Bespannungsauftrag hinzufügen" (≈ 2× longer) — the EN form's title bar must reserve width.
- "Mark strung now" → "Jetzt als bespannt markieren" (~ 1.6× longer) — the inline checkbox label wraps to two lines on
sm; the wireframe reserves the vertical room. - "Comments" → "Anmerkungen" (similar length).
- "Same as main" → "Gleich wie Hauptsaite" (≈ 1.7× longer) — the radio label is the longest single string in the form; reserve a full row.
The form's column-friendly layout naturally handles wrapping; no special DE-specific layout tweaks needed.
Open questions for Stefan (with proposed defaults)¶
- Default tension when no previous job exists: Proposed default 24 kg. Stefan strings most clients between 22–26 kg per V1; 24 is a comfortable centroid. Alternative: configurable per-stringer (a "default tension" setting on the stringer profile) — flagged as a follow-up for the stringer-onboarding round.
- Default labor when no previous job exists: Proposed default CHF 25. Matches the V1 baseline default (the "free-restring at 25 CHF to not break stats" pattern from UC-8).
- "Copy last setup" button vs auto-prefill: Proposed default: auto-prefill is the primary mode; the button is rendered as a confirmation hint, not an action. Saves a tap. The button reappears as a real action only when no prefill is available (no previous Strung order for this ClientProfile) — there it is hidden, since there's nothing to copy. Net effect: the button is rarely visible. Alternative: always render the button as a tappable action, even when prefill is automatic — gives a re-prefill path if Stefan accidentally edited a field. Could ship as Round-2 polish.
- Tension nudge step size: Proposed default ±1 kg. Per UC-2 explicitly. Half-kg precision is theoretically possible (the data model is numeric) — flagged for a future round if Stefan ever asks.
- "Mark strung now" default state: Proposed default off for client orders, on for self orders. Self orders typically jump Draft → Strung directly per the self-job lifecycle; client orders typically wait. Stefan strings live ~30% of the time per his own description; we accept those 30% will check the box manually.
- Self-fields in "More options" — collapsed or expanded for self-mode: Proposed default: expanded for self-mode, collapsed for client-mode. Per locked decision 2 surface heuristic ("UI surfaces them prominently on self-orders, collapses them behind 'advanced' on client orders").
- Verified-email match dialog wording: "A client with this email already exists. Add them to your client list?" with Yes/No. The language is intentionally vague about whose client they are (we don't leak which other stringer has them). Alternative: "Someone on the platform already uses this email — would you like to link to that record?" — same privacy posture, more verbose. Proposed default: the shorter form.
- Unsaved-changes warning: Cancel + Back-arrow open a confirmation dialog when the form is dirty. Proposed default: on. Stefan losing 90 seconds of typing because of a stray tap is a worse failure mode than the friction of a one-tap "Discard / Keep editing" prompt.
Cross-references¶
- Source requirement: V2 scope M9 + M11, use cases UC-2, order lifecycle.
- Data model: Order, Racket, String, ClientProfile, Person.
- Receipt contract: every saved field must satisfy receipt-content. Re-emit-on-edit triggers per order-lifecycle re-emit.
- Identity matching: client identity & sharing — identity matching.
- Linked screens: stringer-dashboard (the entry point), design-tokens.
- i18n strategy: i18n architecture.
- Issue tracking: racket-book#95.