Skip to content

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 lifecycleordered_at defaults to today on save (UC-2 + locked decisions); strung_at is 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=search so 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) when is_self_for_stringer = TRUE exists.
  • 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_text
  • main_tension
  • main_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_price
  • cross_* 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

  • 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 on md+) listing rackets owned by this client, ordered by MAX(orders.strung_at) DESC per 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 Racket row with owner_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: −1 button (48 × 48 px), tension display (read-only number + "kg"), +1 button (48 × 48 px).
  • Tapping −1 decrements main_tension by 1; tapping +1 increments 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 = today on 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_at was 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 has role="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 is aria-describedby linked to the price input so screen-reader users know the price is excluded from the total.
  • Total: <output for="main_price cross_price labor"> with aria-live="polite" so screen readers announce the running total as values change.
  • Error summary: the top-of-form summary has role="alert" and aria-live="assertive". Inline errors are aria-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-700 on white = 6.13:1 (well above 4.5:1).
  • Saved-pref-wins for locale: the entire form renders in Stringer.default_locale per i18n architecture. Browser Accept-Language is 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 with hx-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-trigger swap 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)

  1. 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.
  2. 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).
  3. "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.
  4. 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.
  5. "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.
  6. 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").
  7. 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.
  8. 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