# The ETA system — how ship estimates flow today

How the store computes, displays, and carries "when will this ship" from the
product page through the cart to the order. Written 2026-09-02 from the live
theme code + live Admin API data, as groundwork for the ETA overhaul
(localized-string metaobjects + product ETA overrides).

## The data (source of truth)

Everything hangs off **two per-variant metafields**, set variant-by-variant in
admin:

| Metafield | Type | Meaning | Example |
|---|---|---|---|
| `variant.metafields.store.ship_date` | date `YYYY-MM-DD` | machine-comparable projected ship date | `2026-09-14` |
| `variant.metafields.store.ship_msg` | text | human wording of that date | `2 weeks`, `June 2025`, `5-7 business days` |

Conventions in live data: placeholder `---` (renders as the page fallback),
`n/a`, `???`; far-future `ship_date` (`2029-11-30`) parks unreleased items; a
past `ship_date` means "in stock / ships now" (the configurator only shows an
"Estimated ship date" line when the effective date is **in the future**).

Supporting pieces:

- `variant.metafields.addon.ships_separately` (bool) — this addon does not
  drag the bundle's estimate; it gets its own "Ships separately" line instead.
- `variant.metafields.store.shipping_milestones` — list of `shipping_milestone`
  metaobjects (`title`, `sales_threshold`, `set_date`, `set_msg`). An
  automation outside the theme (Shopify Flow; only works when the variant
  tracks quantity) rewrites `ship_date`/`ship_msg` when a sales threshold
  passes. This is today's only "automated ETA" mechanism — live entries drive
  merch ("Merch: <5k stock -> 5-7 BD" / "0 stock -> 3-4 wks") and shells
  ("Shells: 2k -> 06/2025").
- `shop.metaobjects.product_misc.live.ship_msg` — page-level fallback text
  (currently `---`).
- `shop.metaobjects.cart.live.estimated_delivery` — the "Estimated Delivery"
  label used on both the product page and the cart drawer.
- Legacy, mostly dead: `product.metafields.shipping.projected_date_w1..w3`
  (BS1 shipping-waves JSON, read only by the old Builder liquid snippets) and
  `store.add_text` / `backorder_text` button-text overrides.

### Localization today (the pain point)

`ship_msg` is translated **per variant, per value** via Translate & Adapt
(metafield value translation). E.g. BS2E's `2 weeks` carries a JA translation
`2週間以内に出荷`. Consequences:

- Every variant with an ETA needs its own JA translation even when the wording
  is identical across a dozen variants ("5-7 business days" × every t-shirt
  size).
- Shopify keys translations to a digest of the source value: **any change to a
  variant's `ship_msg` (manual or milestone-automation) silently drops the JA
  translation** and the JA storefront falls back to English until someone
  re-translates.
- `ship_date` needs no translation (it is never shown raw to buyers).

## Product page / configurator (`sections/bs-product-configurator.liquid`)

Every active product uses this template (`shop-bs2`, or `shop-bs2-no-buy`
for the unbuyable ones). Liquid stamps each option input — the model variants
(line ~364) and every addon-card variant (line ~573, also
`snippets/bs-opt-card.liquid` ~79) — with:

    data-ship-date="{{ ...store.ship_date.value }}"      (YYYY-MM-DD)
    data-ship-msg="{{ ...store.ship_msg.value | escape }}" (locale-rendered)
    data-ships-separately="1"                             (when addon.ships_separately)

Because Liquid renders the *translated* metafield, `data-ship-msg` is already
in the visitor's locale.

**Displayed estimate** (`update()`, ~line 1316): candidates = the selected
model + every checked addon that is not `ships_separately`. The candidate with
the **latest `ship_date` wins** (plain string compare on YYYY-MM-DD) and its
`msg` is shown next to the "Estimated Delivery" label above the order button.
Fallback: `product_misc.live.ship_msg` (`---`).

## Add-to-cart: the line-item property contract (~line 1706)

On "Order now" the configurator POSTs `/cart/add.js` with one parent line
(the model) + one line per non-ephemeral addon, writing properties three
consumers depend on (keys starting `_` are hidden at checkout):

Parent line:

| Property | When | Value |
|---|---|---|
| `_ship_date` / `_ship_msg` | always (if set) | the model's own metafield values |
| `Estimated ship date` | effective date > today | **visible**; the bundle's effective msg (latest non-separate date among model+addons) |
| `_est_date` / `_est_msg` | an addon pushed the bundle past the model's own date | the effective date/msg |
| `_addon_variants` | addons exist | addon variant ids joined `-` (drawer bundle grouping) |
| `_shell_variant` / `_cushion_variant` / `_strap_variant` (per `addon_type.prop_key`) | | selected titles per group, for ops export |

Addon lines:

| Property | When | Value |
|---|---|---|
| `_addon_parent_variant` | always | parent variant id (drawer child-grouping) |
| `_ship_date` / `_ship_msg` | if set | the addon's own values |
| `Addon for` | always | **visible**; parent product title |
| `Ships separately` | ships_separately && date > today | **visible**; `'Estimated ' + msg` — note the hardcoded English `Estimated` prefix even on JA |
| `_est_date` / `_est_msg` | rides in a bundle with a later date | bundle's effective values |

The visible keys (`Estimated ship date`, `Addon for`, `Ships separately`) are
deliberately English-only: they are string-matched contract (the drawer greps
`Ships separately`) and they are what the buyer sees again at checkout and in
the order confirmation. The *values*, however, come from the localized
`data-ship-msg`, so a JA order carries JA text in `_ship_msg` /
`Estimated ship date` — ops/Arda tooling reading order exports sees
visitor-locale strings.

## Cart drawer (`sections/eg-cart-drawer.liquid` ~1189)

"Net ship date of the order," recomputed server-side in Liquid on every drawer
render:

1. Walk `cart.items`; consider lines with a `_ship_date` property, **skipping
   any line that has a `Ships separately` property**.
2. Track the latest `_ship_date` (also vs. today); keep its `_ship_msg`.
3. Fallbacks: any lone `_ship_msg` (guarding the literal string `"null"` the
   old Builder page used to write); special-case the Rx-inserts variant to
   read its metafield directly (its line is added by the Rx form, not the
   configurator, so it carries no props).
4. Footer renders `cart.live.estimated_delivery` label + that `ship_msg`.

So the drawer's estimate is only as good as the properties frozen at
add-to-cart time: **an admin ETA change does not update items already in
carts**, and a cart mixing configurator lines with prop-less lines can show a
stale or empty estimate. Per-line, the drawer also renders any visible
(non-underscore) properties — this is where `Ships separately: Estimated ...`
and `Addon for: ...` appear.

## Who reads what, downstream

- **Checkout / order confirmation**: the visible props only.
- **Order export / ops (Arda)**: hidden `_ship_*`, `_est_*`, and the
  per-group `*_variant` title arrays.
- **Nothing else**: the website repo (landing configurator) shows no ETA; it
  deep-links into this page.

## Current-system weaknesses (why the overhaul)

1. **Translation churn**: N variants × M wording variants each need individual
   JA translations, silently dropped on every value edit — incompatible with
   live automated ETA updates.
2. **Fan-out edits**: one real-world change ("shells slip two weeks") means
   visiting every affected variant's admin page (and its translation).
3. **No combo targeting**: an ETA that only applies to *Beyond 2e + Halo
   Mount* is inexpressible; the closest tool is per-variant dates + the
   latest-date-wins fold.
4. **Free-text `ship_msg`** has no structure an automation (bigsuite) can
   compose safely in two languages.
5. **Milestones** are sales-count-triggered only (not inventory-level), work
   only on qty-tracked variants, and rewrite the very metafields whose
   translations then break.
6. Assorted hardcodes: English `'Estimated '` prefix in the
   `Ships separately` value; `---`/`n/a`/`???` placeholder conventions;
   properties frozen at add-time go stale in long-lived carts.

## Prior art worth reusing

- `marquee_string` metaobject is already exactly the proposed
  localized-string shape (`title` + `content`, translated once, referenced
  from lists).
- The `addon_group`/`addon_type` metaobject pattern (typed references +
  variant lists driving the configurator) is the template for an
  `eta_override` object.

---

# The overhaul — settled design (2026-09-03)

Decisions from the design session (owner-confirmed). Vocabulary in
`CONTEXT.md` (ETA Part, ETA Override, Composition, Bundle estimate).

## New metaobject: `localized_string` (ETA Parts)

| Field | Type | Notes |
|---|---|---|
| `label` | text, required | admin-internal, never displayed or translated |
| `content` | text, translated | the fragment, per locale (`1-2` / `1〜2`) |
| `space_after` | text, translated | `yes` = emit a space between this part and the next; anything else = none |

- Publishable capability ON.
- `space_after` is a *text* field, not boolean, deliberately: boolean fields
  hold one store-wide value, but spacing is per-locale (EN `yes`, JA/zh `no`)
  and only text fields are translatable. A literal `" "` content value was
  rejected because Shopify trims whitespace-only text.
- **Translation fallback trap**: an unset translation falls back to the base
  (EN) value — so translating a part means setting BOTH `content` and
  `space_after` in the same Translate & Adapt pass, or a JA part inherits
  `yes` and gains a stray space.
- Composition: theme emits a space between part N and N+1 only when N's
  rendered `space_after` == `yes`; never after the last part.
- Seed set: ranges `1-2`, `2-3`, `3-4`, `5-7`, `2`; units
  `business days`/`営業日`, `weeks`/`週間後に出荷`. Seasons/quarters
  (`Summer 2026`/`2026年夏`) are ad-hoc one-off entries, not seed.
  Month wording never becomes a part — it renders from `eta_date`.

## New metaobject: `eta_override` (ETA Overrides)

| Field | Type | Notes |
|---|---|---|
| `label` | text, required | convention: `<model> [+ addons] -> <ETA>`, e.g. `2e + Halo -> Sep 2026` |
| `model_variant` | variant reference (single) | the model this targets. Single, not a list — a "2 + 2e" combined entry would ambush someone editing "2 only" |
| `addon_variants` | list.variant_reference | ALL listed must be selected (subset match: extra unlisted addons don't break the match). Empty = model-wide. Ships-separately addons count for matching |
| `eta_date` | date, optional | missing = treated as epoch (loses latest-date contests) |
| `eta_text` | list.metaobject_reference → localized_string | empty = render month from `eta_date` (translated strftime pattern) |

- Publishable capability ON: DRAFT = staged (invisible to the Liquid loop),
  ACTIVE = live. This is bigsuite's staging + kill switch.
- Empty `eta_text` AND no `eta_date` = unrenderable; theme skips the entry.
- Matching override **replaces** the bundle estimate outright (it does not
  join the latest-date fold). Multiple matches contend: most addons matched
  wins, ties broken by latest `eta_date`.

## Baseline (non-override) ETAs

- New variant metafield `store.eta_text`: list.metaobject_reference →
  localized_string. Replaces `ship_msg` as the display source.
- `store.ship_date` stays as-is (machine date, fold input, untranslated).
- `store.ship_msg` demoted to legacy fallback; existing translations left in
  place, no new ones authored.

**Resolution chain** (identical on product page and cart drawer):
matching ETA Override → variant `eta_text` (latest-`ship_date`-wins fold as
today) → legacy `ship_msg` → `---` fallback. The legacy tier keeps the
Flow/`shipping_milestone` automation nominally functional (it is functional
but effectively unused today); milestone-driven merch simply doesn't get
`eta_text` until that system retires (bigsuite era).

## Composition & helper strings

- Liquid pre-composes all ETA strings per locale at render; JS only selects
  among ready strings, never localizes.
- Override discovery: `{% for o in metaobjects.eta_override.values %}` —
  iterates ACTIVE entries, cap 50 per loop; page pre-filters to overrides
  whose `model_variant` belongs to the current product.
- New fields on the `cart` LIVE text collection:
  - `eta_month_format` — translated strftime pattern (EN `%B %Y`,
    JA `%Y年%-m月`) for month-from-date rendering.
  - a translated `Estimated` prefix string for the ships-separately line.
  (No joiner field — spacing lives on the parts, see `space_after`.)

## Line-item properties (new contract)

Hidden (machine/support layer):
- `_ship_date`, `_est_date` — unchanged, YYYY-MM-DD.
- `_ship_msg`, `_est_msg` — kept, visitor-locale natural language (support
  staff readability; JP-native staff + browser translation make locale fine).
- `_eta_parts` — comma-joined part handles of the composed text (structure
  for tooling).
- `_ships_separately` = `1` — NEW: the machine signal replacing the drawer's
  English string-match on the visible key.

Visible (buyer layer) — **keys localized now** (they exist to aid the
customer; the old English-key contract is replaced by hidden props):
- `Estimated ship date` (localized key + localized composed value) — written
  whenever composed text exists; the old future-date-only gate is dropped
  (it's the only itemized ETA read on a multi-item cart, and harmless for
  in-stock items).
- `Addon for`, `Ships separately` (localized keys; the `Estimated` prefix in
  the value comes from the new cart.live string).

## Cart drawer

- Live re-match every render: re-derive each bundle's estimate from cart
  contents (parent + `_addon_parent_variant` children) through the full
  resolution chain — admin/bigsuite ETA changes update open carts instantly.
  Frozen props remain only as checkout display + pre-migration fallback.
- The fold covers ALL lines, including prop-less ones (drawer upsell adds,
  Rx inserts, legacy carts) via live metafield reads — the hardcoded
  Rx-inserts special case is deleted.
- Ships-separately detection: `_ships_separately` prop, with the English
  `Ships separately` key match kept as legacy fallback (removable later).

## Out of scope / downstream

- bigsuite (inventory-monitoring automation writing overrides via API) —
  explicitly out of this session's scope; the design gives it: structured
  entries, DRAFT staging, `eta_date` + part handles, no translation churn.
- Website repo untouched (landing configurator shows no ETA).
- Builder-legacy liquid untouched (unused; all active products are on
  shop-bs2 templates).

## Migration order

1. ✅ 2026-09-03 — definitions created via Admin API: `localized_string`
   (gid …20229488857), `eta_override` (gid …20229521625), cart fields
   (`eta_month_format`, `eta_estimated_prefix`, `prop_addon_for`,
   `prop_ships_separately`), variant `store.eta_text` (def …224235258073).
   Wiki + CONTEXT.md updated same sitting. Mutation kit: `scripts/eta-overhaul/`.
2. ✅ 2026-09-03 — 8 parts seeded (ranges 1-2/2-3/3-4/5-7/2, business days,
   weeks, Summer 2026) + JA translations registered (content + space_after=no),
   cart.live values set (EN `%B %Y`/`Estimated `; JA `%Y年%-m月`/`予定: `,
   prop keys JA `アドオン対象`/`別途出荷` — JA wording pending owner review).
3. ✅ 2026-09-03 — theme code on dev (`cd4c894`): `snippets/bs-eta-msg.liquid`,
   configurator override matching + new props, drawer live resolution.
4. ✅ 2026-09-03 — `eta_text` backfilled on 16 variants (BS2/BS2E/BS2VRC,
   BS2U/BS2EU, BSBAHK→Summer 2026, BS1U, BSBAPC, BS1A, BS1S, BS1C, BS1J,
   BS2BOO, BS2S-O, BS2AAC, BS1R). Deliberately skipped: milestone-ref'd
   shells (BS2S-B/C/P), all merch, magic-value variants (`---`/`n/a`/`???`),
   test/courtesy products.
5. ✅ 2026-09-04 — owner verified on the dev preview theme and gave the
   publish go.
6. ✅ 2026-09-04 — dev → main merged and pushed (live theme deploy).
   Legacy `ship_msg` values + translations stay in place as the fallback tier.
