B3 — Order Editor: Episode Lines Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: superpowers:test-driven-development on every task, plus superpowers:executing-plans (or subagent-driven-development) to run task-by-task. Checkbox steps. Read
~/Projects/twit-ads/CLAUDE.md, the Specification (§3 domain model, §"Order math", §"Availability & the sea of red", §4 workflow W1, §8 B3 bullet),Plans/phase-a-review.md,Plans/b-phase-handoff.md, andPlans/B2-orders-list-crud.md's "Notes for the executor" (theorder_lines/order_line_datesschema note written specifically for this stage) first.
Goal: Add episode-ad lines to an order: pick a show, see its episode dates colored white (open) / red (this proposal's date, now sold out elsewhere) / grey (full, unclickable) via B1's already-built pure classifier, multi-select dates, override the auto-filled rate/downloads per line, watch the order's running totals update live — no full-page reload. This is "W1 front half" (spec's own phrase): everything up through "rates auto-fill from card, she bumps CPM down on one line," stopping before billboard/newsletter/value-add-marking/discount, which are B4's job.
Architecture: Extends internal/orders (built in B2) with OrderLine/OrderLineDate types and store methods — same package, same *Store, no new package (mirrors internal/shows holding both Show and Episode in one package/store). New migration 00007_order_lines.sql. This is also the stage that introduces htmx into the codebase for the first time (zero hx-* attributes exist anywhere today) — vendored as a single static JS file, embedded via Go's embed.FS exactly like templates/*.html already is, serving /static/ for the first time. The date-picker toggle and live-totals recompute use htmx's out-of-band swap (hx-swap-oob) to update two independent regions of the page from one POST response — this codebase's first such pattern, so it's spelled out in full below rather than left to inference.
Tech Stack: Go 1.25, pgx, stdlib net/http+html/template, htmx 2.0.10 (vendored, pinned — do not fetch "latest," pin exactly this version), goose migration 00007_order_lines.sql.
Global Constraints
- Money = integer cents, whole-dollar multiples,
internal/money. TDD red/green, one change at a time.go vet ./...+just test(+just test-dbwhere DB touched) green at every commit. - Audit log entry for every mutation (add line, delete line, toggle date, override rate) via
d.Audit.Record. - Migrations forward-only, numbered. Next number is
00007(last is00006_orders.sql). - No sentinel values — nullable columns/enums (e.g.
ShowID *int64,ProductID *int64, tagged byKind). - No "broadcast" in UI text. All displayed dates MM/DD/YYYY via
mdy. html/template'seq/ne/comparison builtins do NOT auto-dereference pointer arguments (B2's own hard-won lesson — seePlans/B2-orders-list-crud.md's Task 4 decision log). Any template comparing a nullable*int64/*intfield must either compare through a nil-safe helper func (like B2'seqPtrI64) or be restructured to avoid the comparison. This stage has several nullable FK fields (ShowID,ProductID) — watch for this specifically during template writing, not just during review.
Domain decisions (flag to Leo if wrong)
- No
placementstable in B3 —SoldCountis a deliberate, honestly-labeled stub returning 0. Spec: "sold counts FINALIZED placements only." Placements don't exist until B5, which is explicitly the highest-risk stage in this project (phase-a-review.md: "Fable reviews the B5 design before implementation"; the oversell-impossible-by-construction unique constraint is its central design decision). Pre-building that table now, before B5's own careful design pass, risks locking in a schema B5 has to work around. Consequence: in the live app, every date picker shows every date as open (white) until B5 lands — there is no way to demo a genuinely oversold/grey date before finalize exists to consume inventory. The classifier wiring itself (ordermath.ClassifyDatecalled correctly with realmaxAds/selectedand a real, if-currently-always-zero,soldsource) is proven correct by HTTP-level tests that inject arbitrary sold counts throughfakeOrderStore'sSoldCount, not by anything reachable in the live app pre-B5. B3's own "oversold dates unclickable" acceptance criterion is therefore verified by test, not by a live demo — say so plainly at the B3 gate rather than trying to manufacture a real sold-out episode. order_lines.kindis a 6-value enum declared in full now (episode_ad,billboard_week,newsletter,social_post,banner,custom) — matching every other enum in this codebase (order_statusdeclared all 3 values in B2 though onlypendingwas reachable;order_termsdeclared all 7 though the UI only needed a subset at the time). B3 only ever writeskind='episode_ad'. B4 populates the rest. ACHECKconstraint enforces the tagged-union shape (kind='episode_ad'⟺show_idset +product_idnull; catalog kinds ⟺product_idset +show_idnull;custom⟺ both null) at the DB level, matching "no sentinel values."- No stored
quantitycolumn onorder_lines. Forepisode_ad(and B4'sbillboard_week/banner) lines, quantity IS the count of that line'sorder_line_datesrows — storing it separately would let it drift out of sync with the actual date selections, violating computed-over-stored (the same principle that keeps episode numbers computed, never stored).Store.Linescomputes it via a batched count query. B4 will needALTER TABLE order_lines ADD COLUMN quantity intfornewsletter/social_post/customlines, which have no per-date breakdown to count — a legitimate, forward-only, additive migration then, not a defect now. - Rate/downloads are resolved "as of today" at the moment a show is added to the order, not backdated to the order's original creation date. Spec: "the chosen rate is copied onto the order line at booking" — "booking" is read here as "when this specific line is added," since a pending order can sit for weeks and a salesperson adding a show later should get the rate that's actually current, not one that's gone stale. Uses the existing
today()helper (admin_shows.go). - The date picker defaults to a 12-week window from today, extendable via
?from=&until=query params (bothYYYY-MM-DD). Episodes are generated up to 24 months out (A3), so showing all of them by default would be an unusably long picker; 12 weeks covers W1's "picks 4 dates" scenario and any near-term proposal without pagination machinery this stage doesn't need. Not spec-mandated — a scoping call, flagged here rather than silently assumed. - Adding the same show twice to one order creates a second, independent line — no merge-into-existing-line logic. A salesperson might legitimately want two batches of dates on the same show at two different negotiated rates (e.g. 2 episodes at card rate, 2 more at a bumped-down rate); silently merging would make that impossible without a separate "split line" feature this stage doesn't build. If Lisa/Debi find this surprising in practice, it's cheap to add a "you already have a line for this show — add to it or start a new one?" prompt later.
order_line_dates.order_line_idFK isON DELETE CASCADE. Deleting a line should delete its date selections — a date selection has no meaning independent of its line (unlike deleting an advertiser that finalized orders still reference, which staysRESTRICT-guarded per A4'sTODO(B5)). WithoutCASCADE,DeleteLineon a line with any selected dates would fail with an FK violation.is_value_addexists onorder_linesfrom day one (defaultfalse) but B3 exposes no UI to toggle it. Per the spec's own staged-plan bullet, "value-add marking" is explicitly B4's line item, and W1's workflow narrative places "marks two IM episodes value-add" after "adds a billboard week + newsletter" (B4 territory) — B3's own acceptance criterion is "W1 front half," B4's is "W1 complete." Adding the column now (unused by B3's UI) is the same cheap-now pattern as B2'sfinalized_at/makegood_for_order_id.- htmx is vendored at exactly 2.0.10, fetched from
https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.jsand committed into the repo (internal/server/static/htmx.min.js) — not loaded from a CDN at runtime, matching the spec's explicit "htmx (vendored static file)" wording and the "must still run as plain docker-compose on Framework forever" design guardrail (no external-network dependency at request time). - Rate card prices are suggestions, not fixed amounts (Leo, 2026-07-07). "The actual rate will come from the order. The order will initially populate from the rate card but it should be editable. CPM should also be editable... we sometimes want to offer a flat rate." This is why
order_linesstoresCPMCentsas its own editable column (not just derived/computed fromRateCents ÷ ImpressionsPerUnit), alongsideRateCentsandImpressionsPerUnit— mirroringshow_rates' existing three-field shape exactly. The line-rate-override handler (Task 5) reusesrates_ui.go's existingparseShowRateFormverbatim: CPM × downloads ÷ 1000 computes the cost by default, but an explicit cost overrides that computation — which is exactly what a flat-rate deal needs, using logic that's already written, tested, and in production via A5, not reinvented here. Any calculation the app performs (rate resolution at booking, live totals) starts from the rate card, but nothing about the rate card is treated as final after that — the order's own stored numbers are authoritative from the moment a line exists. Per Leo: "the numbers are not finalized until the ads have run" — actual delivery reconciliation (Uniblab/true-up) stays a manual, out-of-v1-scope process (spec §6), not something this stage or B4 needs to build toward. - Line edits are NOT gated on order status — including finalized orders (Leo, 2026-07-07): "Even finalized orders need to be editable."
UpdateLineRate(Task 5),ToggleLineDate(Task 3), andDeleteLine(Task 2) have noorder.Statuscheck anywhere in this plan, and that's deliberate, not an oversight: this matches spec §3's binding B2–B5 requirement #2 ("amend-in-place for future dates; the past is immutable — as an invariant, not a convention"). B3 provides the base editability; B5 is where the CAREFUL version of this — audited, role-gated, past-immutable, slot-release-on-removal — actually gets built, per the spec's binding requirements andphase-a-review.md's standing note that B5 needs its own design review before implementation. B3 should not attempt to partially pre-build that invariant; it just shouldn't accidentally block basic editability that B5 will need to build on top of.
Task 1: Vendor htmx + static asset embedding
Files:
Create:
app/internal/server/static/htmx.min.js(vendored, not authored)Create:
app/internal/server/static.goModify:
app/internal/server/server.go(register the static route)Modify:
app/internal/server/templates/layout.html(load the script)Create:
app/internal/server/static_test.goStep 1: Vendor the file.
curl -fsS https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js -o app/internal/server/static/htmx.min.js
Verify it's non-empty and actually htmx (head -c 200 app/internal/server/static/htmx.min.js should show a version comment mentioning 2.0.10). This is a vendored third-party file — do not hand-edit it, do not reflow/reformat it.
- Step 2: Write the failing test (
static_test.go):
package server
import (
"net/http/httptest"
"strings"
"testing"
)
func TestStaticServesHtmx(t *testing.T) {
h, _ := testServer(t)
req := httptest.NewRequest("GET", "/static/htmx.min.js", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("got %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "htmx") {
t.Error("body doesn't look like htmx")
}
}
(testServer(t) already exists, login_test.go, returns (http.Handler, *fakeAudit) — reuse it directly, no new server-constructor needed for this smoke test.)
- Step 3: Run — expect FAIL (404, since nothing serves
/static/yet).
Run: cd app && go test ./internal/server/... -run TestStaticServesHtmx -v
Expected: FAIL, got 404, want 200.
- Step 4: Implement
static.go:
package server
import (
"embed"
"fmt"
"io/fs"
"net/http"
)
//go:embed static/*
var staticFS embed.FS
// staticHandler strips the embedded "static/" root (go:embed keeps the
// directory prefix in the FS; StripPrefix on the URL alone isn't enough —
// http.FileServerFS would otherwise look for "htmx.min.js" at the FS root
// and never find it under "static/htmx.min.js").
func staticHandler() http.Handler {
sub, err := fs.Sub(staticFS, "static")
if err != nil {
// Only fails if the embed directive itself is wrong — a build-time
// condition, not a runtime one. Fail loud and immediately (fail-all
// validation), not a silent 404 for every static asset forever.
panic(fmt.Sprintf("static assets: %v", err))
}
return http.StripPrefix("/static/", http.FileServerFS(sub))
}
Add to server.go's New(), alongside the other route registrations (before the register* calls is fine — order doesn't matter for http.ServeMux pattern registration):
mux.Handle("GET /static/", staticHandler())
- Step 5: Wire the script tag.
templates/layout.html, immediately before</body>:
<script src="/static/htmx.min.js"></script>
</body>
- Step 6: Run — expect PASS.
Run: cd app && go vet ./... && go test ./internal/server/... -run TestStaticServesHtmx -v
Expected: PASS.
- Step 7: Commit.
git add app/internal/server/static/ app/internal/server/static.go app/internal/server/static_test.go app/internal/server/templates/layout.html
git commit -m "feat(server): vendor htmx 2.0.10, embed + serve /static/"
Task 2: Migration + OrderLine store (episode_ad only)
Files:
- Create:
app/internal/db/migrations/00007_order_lines.sql - Modify:
app/internal/orders/orders.go - Modify:
app/internal/orders/orders_test.go - Modify:
app/internal/orders/testhelper_test.go(extendseedFixtures-style helpers with a show + rate seed)
Migration:
-- +goose Up
CREATE TYPE order_line_kind AS ENUM ('episode_ad', 'billboard_week', 'newsletter', 'social_post', 'banner', 'custom');
CREATE TABLE order_lines (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders(id),
kind order_line_kind NOT NULL,
show_id bigint REFERENCES shows(id),
product_id bigint REFERENCES products(id),
custom_label text NOT NULL DEFAULT '',
cpm_cents bigint NOT NULL DEFAULT 0 CHECK (cpm_cents >= 0),
rate_cents bigint NOT NULL CHECK (rate_cents >= 0),
impressions_per_unit int NOT NULL DEFAULT 0 CHECK (impressions_per_unit >= 0),
is_value_add boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (
(kind = 'episode_ad' AND show_id IS NOT NULL AND product_id IS NULL) OR
(kind IN ('billboard_week','newsletter','social_post','banner') AND product_id IS NOT NULL AND show_id IS NULL) OR
(kind = 'custom' AND show_id IS NULL AND product_id IS NULL)
)
);
CREATE INDEX order_lines_order_idx ON order_lines (order_id);
CREATE TABLE order_line_dates (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_line_id bigint NOT NULL REFERENCES order_lines(id) ON DELETE CASCADE,
episode_id bigint REFERENCES episodes(id),
week_start_date date,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK ((episode_id IS NOT NULL) <> (week_start_date IS NOT NULL)),
UNIQUE (order_line_id, episode_id),
UNIQUE (order_line_id, week_start_date)
);
CREATE INDEX order_line_dates_line_idx ON order_line_dates (order_line_id);
-- +goose Down
DROP TABLE order_line_dates;
DROP TABLE order_lines;
DROP TYPE order_line_kind;
Interfaces (add to app/internal/orders/orders.go):
// OrderLine is one line of an order. B3 only ever writes Kind="episode_ad".
// Quantity is computed (count of this line's OrderLineDate rows), never
// stored — same computed-over-stored principle as episode numbering.
type OrderLine struct {
ID int64
OrderID int64
Kind string // episode_ad|billboard_week|newsletter|social_post|banner|custom
ShowID *int64 // episode_ad only
ProductID *int64 // catalog kinds only (B4)
CustomLabel string // custom only (B4)
CPMCents int64 // negotiable; RateCents = CPMCents*ImpressionsPerUnit/1000 by default, but RateCents can be
RateCents int64 // overridden independently for a flat-rate deal — see Domain Decision #10
ImpressionsPerUnit int
IsValueAdd bool // B3 always false; B4 adds the toggle
Quantity int // computed: count of this line's dates
}
func (s *Store) AddEpisodeLine(ctx context.Context, orderID, showID int64, cpmCents, rateCents int64, impressionsPerUnit int) (OrderLine, error)
func (s *Store) Lines(ctx context.Context, orderID int64) ([]OrderLine, error)
func (s *Store) DeleteLine(ctx context.Context, lineID int64) error
func (s *Store) LineDates(ctx context.Context, lineID int64) ([]int64, error) // episode IDs selected on this line
func (s *Store) ToggleLineDate(ctx context.Context, lineID, episodeID int64) (selected bool, err error)
func (s *Store) SoldCount(ctx context.Context, episodeID int64) (int, error) // stub: always 0, see Domain Decision #1
- Step 1: Extend
testhelper_test.gowith show/rate seeding (append to the existing file, reuseseedCounter):
// seedShowWithRate creates one active show + a show_rate effective today,
// returning the show and its resolved rate — the FK dependency AddEpisodeLine
// needs. Each call gets a unique show title/abbrev (shows.title/abbrev UNIQUE).
func seedShowWithRate(t *testing.T, pool *pgxpool.Pool) (shows.Show, rates.ShowRate) {
t.Helper()
seedCounter++
n := seedCounter
ctx := context.Background()
sh, err := (&shows.Store{Pool: pool}).Create(ctx, shows.Show{
Title: fmt.Sprintf("Show %d", n), Abbrev: fmt.Sprintf("SH%d", n),
Weekdays: []int{2}, MaxAds: 4, RecordStart: "13:00", RecordEnd: "14:00", Active: true,
})
if err != nil {
t.Fatalf("seed show: %v", err)
}
if _, err := (&shows.Store{Pool: pool}).Generate(ctx, sh.ID, today(), today().AddDate(0, 3, 0)); err != nil {
t.Fatalf("generate episodes: %v", err)
}
rate, err := (&rates.Store{Pool: pool}).SetShowRate(ctx, rates.ShowRate{
ShowID: sh.ID, EffectiveDate: today().AddDate(-1, 0, 0),
DownloadsPerEpisode: 40000, CPMCents: 6500, CostPerEpisodeCents: 260000,
})
if err != nil {
t.Fatalf("seed rate: %v", err)
}
return sh, rate
}
Add "twit.tv/twitads/internal/rates" and "twit.tv/twitads/internal/shows" imports. today() is package-private to server, NOT available here — use time.Now() truncated to midnight UTC instead, matching the test-local pattern already used in rates/testhelper_test.go's date(t, "2026-01-01") helper: add a local func today() time.Time { y, m, d := time.Now().Date(); return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) } to testhelper_test.go (test-local, not the server package's today() — this package has no reason to import server).
- Step 2: Write the failing tests (append to
orders_test.go):
func TestAddEpisodeLineAndList(t *testing.T) {
pool := testPool(t)
advID, _, userID := seedFixtures(t, pool)
sh, rate := seedShowWithRate(t, pool)
s := &Store{Pool: pool}
ctx := context.Background()
ord, err := s.Create(ctx, Order{Title: "W1", AdvertiserID: advID, SalespersonID: userID, Year: 2026})
if err != nil {
t.Fatal(err)
}
line, err := s.AddEpisodeLine(ctx, ord.ID, sh.ID, rate.CPMCents, rate.CostPerEpisodeCents, rate.DownloadsPerEpisode)
if err != nil {
t.Fatalf("add line: %v", err)
}
if line.Kind != "episode_ad" || *line.ShowID != sh.ID || line.ProductID != nil {
t.Fatalf("line wrong: %+v", line)
}
if line.CPMCents != rate.CPMCents {
t.Errorf("CPM not copied from rate card: got %d, want %d", line.CPMCents, rate.CPMCents)
}
if line.Quantity != 0 {
t.Errorf("fresh line quantity = %d, want 0", line.Quantity)
}
lines, err := s.Lines(ctx, ord.ID)
if err != nil || len(lines) != 1 || lines[0].ID != line.ID {
t.Fatalf("list: %+v (%v)", lines, err)
}
}
func TestDeleteLineCascadesDates(t *testing.T) {
pool := testPool(t)
advID, _, userID := seedFixtures(t, pool)
sh, rate := seedShowWithRate(t, pool)
s := &Store{Pool: pool}
ctx := context.Background()
ord, _ := s.Create(ctx, Order{Title: "W1", AdvertiserID: advID, SalespersonID: userID, Year: 2026})
line, _ := s.AddEpisodeLine(ctx, ord.ID, sh.ID, rate.CPMCents, rate.CostPerEpisodeCents, rate.DownloadsPerEpisode)
eps, err := (&shows.Store{Pool: pool}).Episodes(ctx, sh.ID, today(), today().AddDate(0, 1, 0))
if err != nil || len(eps) == 0 {
t.Fatalf("no episodes generated: %v", err)
}
if _, err := s.ToggleLineDate(ctx, line.ID, eps[0].ID); err != nil {
t.Fatalf("toggle: %v", err)
}
if err := s.DeleteLine(ctx, line.ID); err != nil {
t.Fatalf("delete line (must cascade its dates, not FK-violate): %v", err)
}
lines, _ := s.Lines(ctx, ord.ID)
if len(lines) != 0 {
t.Errorf("line still present after delete: %+v", lines)
}
}
Add "twit.tv/twitads/internal/shows" to orders_test.go's import block (it currently has context/slices/testing/errors, carried over from B2's Task 1 and Task 2) — TestDeleteLineCascadesDates calls (&shows.Store{Pool: pool}).Episodes(...) directly. today() does NOT need importing here — it's the test-local package-level helper added to testhelper_test.go in Step 1 above, visible across every file in package orders.
- Step 3: Run — expect FAIL (undefined symbols; migration not applied).
Run: cd app && just test-db
Expected: FAIL to compile / migration missing.
- Step 4: Implement. Add to
orders.go:
const orderLineCols = `id, order_id, kind, show_id, product_id, custom_label, cpm_cents, rate_cents, impressions_per_unit, is_value_add`
func scanOrderLine(row pgx.Row) (OrderLine, error) {
var l OrderLine
err := row.Scan(&l.ID, &l.OrderID, &l.Kind, &l.ShowID, &l.ProductID, &l.CustomLabel, &l.CPMCents, &l.RateCents, &l.ImpressionsPerUnit, &l.IsValueAdd)
return l, err
}
func (s *Store) AddEpisodeLine(ctx context.Context, orderID, showID int64, cpmCents, rateCents int64, impressionsPerUnit int) (OrderLine, error) {
row := s.Pool.QueryRow(ctx,
`INSERT INTO order_lines (order_id, kind, show_id, cpm_cents, rate_cents, impressions_per_unit)
VALUES ($1, 'episode_ad', $2, $3, $4, $5) RETURNING `+orderLineCols,
orderID, showID, cpmCents, rateCents, impressionsPerUnit)
l, err := scanOrderLine(row)
if err != nil {
return OrderLine{}, fmt.Errorf("add episode line to order %d: %w", orderID, err)
}
return l, nil // Quantity zero-value: correct — a fresh line has no dates yet
}
// Lines lists an order's lines with Quantity computed via one batched
// count query (not N+1 — a per-line subquery in the main SELECT would work
// too, but a correlated subquery inside an INSERT...RETURNING is unreliable
// across pgx/Postgres versions, so both paths use this same batched shape).
func (s *Store) Lines(ctx context.Context, orderID int64) ([]OrderLine, error) {
rows, err := s.Pool.Query(ctx, `SELECT `+orderLineCols+` FROM order_lines WHERE order_id=$1 ORDER BY id`, orderID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []OrderLine
for rows.Next() {
l, err := scanOrderLine(rows)
if err != nil {
return nil, err
}
out = append(out, l)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(out) == 0 {
return out, nil
}
ids := make([]int64, len(out))
for i, l := range out {
ids[i] = l.ID
}
countRows, err := s.Pool.Query(ctx,
`SELECT order_line_id, count(*) FROM order_line_dates WHERE order_line_id = ANY($1) GROUP BY order_line_id`, ids)
if err != nil {
return nil, err
}
defer countRows.Close()
counts := map[int64]int{}
for countRows.Next() {
var lineID int64
var n int
if err := countRows.Scan(&lineID, &n); err != nil {
return nil, err
}
counts[lineID] = n
}
if err := countRows.Err(); err != nil {
return nil, err
}
for i := range out {
out[i].Quantity = counts[out[i].ID]
}
return out, nil
}
func (s *Store) DeleteLine(ctx context.Context, lineID int64) error {
tag, err := s.Pool.Exec(ctx, `DELETE FROM order_lines WHERE id=$1`, lineID)
if err != nil {
return fmt.Errorf("delete order line %d: %w", lineID, err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("delete order line %d: not found", lineID)
}
return nil
}
func (s *Store) LineDates(ctx context.Context, lineID int64) ([]int64, error) {
rows, err := s.Pool.Query(ctx, `SELECT episode_id FROM order_line_dates WHERE order_line_id=$1 AND episode_id IS NOT NULL`, lineID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
func (s *Store) ToggleLineDate(ctx context.Context, lineID, episodeID int64) (selected bool, err error) {
txErr := pgx.BeginFunc(ctx, s.Pool, func(tx pgx.Tx) error {
tag, delErr := tx.Exec(ctx, `DELETE FROM order_line_dates WHERE order_line_id=$1 AND episode_id=$2`, lineID, episodeID)
if delErr != nil {
return delErr
}
if tag.RowsAffected() > 0 {
selected = false
return nil
}
if _, insErr := tx.Exec(ctx, `INSERT INTO order_line_dates (order_line_id, episode_id) VALUES ($1, $2)`, lineID, episodeID); insErr != nil {
return insErr
}
selected = true
return nil
})
if txErr != nil {
return false, fmt.Errorf("toggle line date (line %d, episode %d): %w", lineID, episodeID, txErr)
}
return selected, nil
}
// SoldCount always returns 0 — see Plans/B3-episode-lines.md Domain Decision #1.
// TODO(B5): count real FINALIZED placements once that table exists.
func (s *Store) SoldCount(ctx context.Context, episodeID int64) (int, error) {
return 0, nil
}
- Step 5: Run — expect PASS.
Run: cd app && just test-db
Expected: internal/orders green (5 total tests: 3 from B2 + 2 new), all packages green.
- Step 6: Commit.
git add app/internal/db/migrations/00007_order_lines.sql app/internal/orders/
git commit -m "feat(orders): order_lines + order_line_dates (episode_ad only)"
Task 3: server.go wiring + "add show" handler
Files:
- Modify:
app/internal/server/server.go(extendOrderStoreinterface) - Modify:
app/internal/server/orders_ui.go - Create:
app/internal/server/templates/order_line_new.html - Modify:
app/internal/server/templates/order_form.html(add a "+ Add show" link once the order exists — not on theIsNewcreate form, only on edit) - Modify:
app/internal/server/orders_ui_test.go
Interfaces:
// server.go — OrderStore gains:
AddEpisodeLine(ctx context.Context, orderID, showID int64, cpmCents, rateCents int64, impressionsPerUnit int) (orders.OrderLine, error)
Lines(ctx context.Context, orderID int64) ([]orders.OrderLine, error)
DeleteLine(ctx context.Context, lineID int64) error
LineDates(ctx context.Context, lineID int64) ([]int64, error)
ToggleLineDate(ctx context.Context, lineID, episodeID int64) (bool, error)
SoldCount(ctx context.Context, episodeID int64) (int, error)
Deps also needs a Shows ShowStore and Rates RateStore reference where they aren't already reachable — both already exist on Deps since A3/A5; orders_ui.go just needs to start calling d.Shows.List/d.Rates.RateForShow (it hasn't needed either until now).
Routes (new):
GET /orders/{orderID}/lines/new — show picker (which show to add)
POST /orders/{orderID}/lines — creates the episode_ad line, redirects to its editor (Task 5)
POST /orders/{orderID}/lines/{lineID}/delete
- Step 1: Write the failing test (append to
orders_ui_test.go— extendfakeOrderStorewith the five new interface methods first, in-memory, mirroring the shape of the existing five):
func (f *fakeOrderStore) AddEpisodeLine(ctx context.Context, orderID, showID int64, cpmCents, rateCents int64, impressionsPerUnit int) (orders.OrderLine, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.nextLineID++
l := orders.OrderLine{ID: f.nextLineID, OrderID: orderID, Kind: "episode_ad", ShowID: &showID, CPMCents: cpmCents, RateCents: rateCents, ImpressionsPerUnit: impressionsPerUnit}
f.lines[l.ID] = l
return l, nil
}
func (f *fakeOrderStore) Lines(ctx context.Context, orderID int64) ([]orders.OrderLine, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []orders.OrderLine
for _, l := range f.lines {
if l.OrderID == orderID {
l.Quantity = len(f.lineDates[l.ID]) // mirror the real store's computed-over-stored Quantity
out = append(out, l)
}
}
return out, nil
}
func (f *fakeOrderStore) DeleteLine(ctx context.Context, lineID int64) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.lines[lineID]; !ok {
return errors.New("not found")
}
delete(f.lines, lineID)
return nil
}
func (f *fakeOrderStore) LineDates(ctx context.Context, lineID int64) ([]int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.lineDates[lineID], nil
}
func (f *fakeOrderStore) ToggleLineDate(ctx context.Context, lineID, episodeID int64) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
dates := f.lineDates[lineID]
for i, e := range dates {
if e == episodeID {
f.lineDates[lineID] = append(dates[:i], dates[i+1:]...)
return false, nil
}
}
f.lineDates[lineID] = append(dates, episodeID)
return true, nil
}
func (f *fakeOrderStore) SoldCount(ctx context.Context, episodeID int64) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.soldByEpisode[episodeID], nil // tests set this directly to exercise conflict/full states
}
Extend fakeOrderStore's struct (B2 added mu sync.Mutex guarding it in B2's Task 6 — every field below is covered by that same lock, hence the f.mu.Lock() in each new method above) and its constructor:
type fakeOrderStore struct {
mu sync.Mutex
byID map[int64]orders.Order
nextID int64
lines map[int64]orders.OrderLine
nextLineID int64
lineDates map[int64][]int64 // lineID -> selected episode IDs
soldByEpisode map[int64]int // tests set this directly to exercise full/conflict states
}
func newFakeOrderStore() *fakeOrderStore {
return &fakeOrderStore{
byID: map[int64]orders.Order{}, nextID: 1,
lines: map[int64]orders.OrderLine{}, nextLineID: 1,
lineDates: map[int64][]int64{}, soldByEpisode: map[int64]int{},
}
}
First, modify ordersServer itself (defined in B2's Task 3, orders_ui_test.go) to wire in a rates fake — this is the first task that makes orders_ui.go call d.Rates, so ordersServer's Deps{...} literal never needed one until now:
func ordersServer(t *testing.T) (http.Handler, *fakeOrderStore, *fakeAudit) {
t.Helper()
store := newFakeOrderStore()
aud := &fakeAudit{}
sponsorStore := newFakeSponsorStore()
rateStore := newFakeRateStore() // internal/server/rates_ui_test.go, same package
rateStore.showRates[1] = rates.ShowRate{ // matches showsServer's fixture show, ID 1
ID: 1, ShowID: 1, DownloadsPerEpisode: 40000, CPMCents: 6500, CostPerEpisodeCents: 260000,
}
h := New(Deps{
Env: "test",
DB: fakePinger{},
Sessions: &fakeSessionStore{
fakeSessions: fakeSessions{users: map[string]auth.User{
"sales": {ID: 3, Username: "lisa", Role: auth.RoleSales, Active: true},
}},
created: map[int64]string{},
},
Users: fakeUserStore{},
Audit: aud,
Shows: newFakeShowStore(),
Sponsors: sponsorStore,
Orders: store,
Rates: rateStore,
})
return h, store, aud
}
(Add "twit.tv/twitads/internal/rates" to orders_ui_test.go's imports. newFakeShowStore()'s default fixture — confirmed via admin_shows_test.go's showsServer — seeds show ID: 1 with MaxAds: 5 and episode ID: 5; this pre-seeded rate matches that same show ID so RateForShow(ctx, 1, today()) resolves without every test having to seed it individually.)
func TestAddShowCreatesLineAndRedirects(t *testing.T) {
h, store, aud := ordersServer(t)
store.byID[1] = orders.Order{ID: 1, Title: "W1", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1}
rec := adminPost(h, "/orders/1/lines", "sales", url.Values{"show_id": {"1"}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("got %d, want 303", rec.Code)
}
if !strings.Contains(rec.Header().Get("Location"), "/orders/1/lines/1") {
t.Errorf("Location = %q, want the new line's editor", rec.Header().Get("Location"))
}
lines, _ := store.Lines(context.Background(), 1)
if len(lines) != 1 || lines[0].Kind != "episode_ad" || lines[0].RateCents != 260000 {
t.Errorf("line not created with the resolved rate: %+v", lines)
}
if aud.actions[len(aud.actions)-1] != "add_line" {
t.Errorf("audit = %v", aud.actions)
}
}
- Step 2: Run — expect FAIL.
Run: cd app && go test ./internal/server/... -run TestAddShow -v
Expected: FAIL (undefined methods / route not registered).
- Step 3: Implement. Extend
OrderStoreinserver.gowith the six new methods (listed in Interfaces above). Add toregisterOrders:
mux.Handle("GET /orders/{orderID}/lines/new", mw.requireUser(http.HandlerFunc(d.lineNew)))
mux.Handle("POST /orders/{orderID}/lines", mw.requireUser(http.HandlerFunc(d.lineCreate)))
mux.Handle("POST /orders/{orderID}/lines/{lineID}/delete", mw.requireUser(http.HandlerFunc(d.lineDelete)))
Handlers in orders_ui.go (mirror sponsors_ui.go's create/delete shape exactly — w, _ := CurrentUser, resolve path params, call the store, audit, redirect):
func (d Deps) lineNew(w http.ResponseWriter, r *http.Request) {
u, _ := CurrentUser(r.Context())
orderID, _ := strconv.ParseInt(r.PathValue("orderID"), 10, 64)
shows, err := d.Shows.List(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, "order_line_new.html", pageData{User: u, Data: map[string]any{"OrderID": orderID, "Shows": shows}})
}
func (d Deps) lineCreate(w http.ResponseWriter, r *http.Request) {
actor, _ := CurrentUser(r.Context())
orderID, _ := strconv.ParseInt(r.PathValue("orderID"), 10, 64)
showID, _ := strconv.ParseInt(r.FormValue("show_id"), 10, 64)
rate, err := d.Rates.RateForShow(r.Context(), showID, today())
if err != nil {
http.Redirect(w, r, fmt.Sprintf("/orders/%d/lines/new?err=%s", orderID, urlQueryEscape(err)), http.StatusSeeOther)
return
}
line, err := d.Orders.AddEpisodeLine(r.Context(), orderID, showID, rate.CPMCents, rate.CostPerEpisodeCents, rate.DownloadsPerEpisode)
if err != nil {
http.Redirect(w, r, fmt.Sprintf("/orders/%d/lines/new?err=%s", orderID, urlQueryEscape(err)), http.StatusSeeOther)
return
}
_ = d.Audit.Record(r.Context(), &actor, "order_line", fmt.Sprint(line.ID), "add_line",
map[string]any{"order_id": orderID, "show_id": showID, "cpm_cents": rate.CPMCents, "rate_cents": rate.CostPerEpisodeCents})
http.Redirect(w, r, fmt.Sprintf("/orders/%d/lines/%d", orderID, line.ID), http.StatusSeeOther)
}
func (d Deps) lineDelete(w http.ResponseWriter, r *http.Request) {
actor, _ := CurrentUser(r.Context())
orderID, _ := strconv.ParseInt(r.PathValue("orderID"), 10, 64)
lineID, _ := strconv.ParseInt(r.PathValue("lineID"), 10, 64)
if err := d.Orders.DeleteLine(r.Context(), lineID); err != nil {
http.Redirect(w, r, fmt.Sprintf("/orders/%d?err=%s", orderID, urlQueryEscape(err)), http.StatusSeeOther)
return
}
_ = d.Audit.Record(r.Context(), &actor, "order_line", fmt.Sprint(lineID), "delete_line", map[string]any{"order_id": orderID})
http.Redirect(w, r, fmt.Sprintf("/orders/%d", orderID), http.StatusSeeOther)
}
templates/order_line_new.html:
{{define "title"}}Add Show — TWiT Ads{{end}}
{{define "content"}}
<p><a href="/orders/{{.Data.OrderID}}">← Order</a></p>
<h2>Add a show</h2>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="/orders/{{.Data.OrderID}}/lines">
<label>Show
<select name="show_id" required>
{{range .Data.Shows}}{{if .Active}}<option value="{{.ID}}">{{.Title}}</option>{{end}}{{end}}
</select>
</label>
<p><button>Add</button></p>
</form>
{{end}}
order_form.html — add, right after the closing </form> and only when NOT .Data.IsNew (the create form has no order ID to link lines against yet):
{{if not .Data.IsNew}}<p><a href="/orders/{{.Data.O.ID}}/lines/new">+ Add show</a></p>{{end}}
- Step 4: Run — expect PASS.
Run: cd app && go vet ./... && go test ./internal/server/... -v
Expected: PASS.
- Step 5: Commit.
git add app/internal/server/server.go app/internal/server/orders_ui.go app/internal/server/orders_ui_test.go \
app/internal/server/templates/order_line_new.html app/internal/server/templates/order_form.html
git commit -m "feat(orders): add-show handler creates an episode_ad line"
Task 4: The line editor page — date picker, live totals, htmx wiring
This is the stage's central task: GET /orders/{orderID}/lines/{lineID} renders the date picker (white/red/grey per B1's classifier) and the order's live totals for the FIRST time. Every date button click is an htmx hx-post to a toggle endpoint that swaps the button in place AND the totals block out-of-band — one round trip, two updated regions, no full reload.
Files:
- Modify:
app/internal/server/orders_ui.go - Create:
app/internal/server/templates/order_line_edit.html - Create:
app/internal/server/templates/_date_button.htmland_totals.html(small{{define}}-only partials, included by both the full page and the htmx fragment response) - Modify:
app/internal/server/templates.go(new template funcs:dateClass, mappingordermath.DateClass→ a CSS class string, since B1 confirmed no such helper exists yet) - Modify:
app/internal/server/orders_ui_test.go
Interfaces:
// orders_ui.go — the shared view-builder every line-editor response goes through
type lineEditorView struct {
OrderID int64
Line orders.OrderLine
ShowTitle string
DateButtons []dateButtonView
Totals ordermath.Totals
WindowFrom time.Time // the picker's [from, until) window, echoed back so the
WindowUntil time.Time // "different date range" link can round-trip it in the query string
}
type dateButtonView struct {
Episode shows.Episode
Selected bool
Class ordermath.DateClass
}
func (d Deps) buildLineEditorView(ctx context.Context, orderID, lineID int64, from, until time.Time) (lineEditorView, error)
func linesToOrdermath(order orders.Order, lines []orders.OrderLine, agencyBP int) ordermath.Order
- Step 1: Write the failing tests (append to
orders_ui_test.go):
func TestLineEditorRendersDatePicker(t *testing.T) {
h, store, _ := ordersServer(t)
store.byID[1] = orders.Order{ID: 1, Title: "W1", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1}
line, _ := store.AddEpisodeLine(context.Background(), 1, 1, 6500, 260000, 40000)
// fakeShowStore's episode fixture (ID 5, ShowID 1) from admin_shows_test.go's showsServer setup is reused via ordersServer.
rec := getAs(h, fmt.Sprintf("/orders/1/lines/%d", line.ID), "sales")
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "date-open") {
t.Error("expected at least one open (white) date button")
}
}
func TestLineEditorShowsFullAndConflictDates(t *testing.T) {
h, store, _ := ordersServer(t)
store.byID[1] = orders.Order{ID: 1, Title: "W1", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1}
line, _ := store.AddEpisodeLine(context.Background(), 1, 1, 6500, 260000, 40000)
store.soldByEpisode[5] = 5 // fixture show has MaxAds: 5 (showsServer's show 1) — fully sold
rec := getAs(h, fmt.Sprintf("/orders/1/lines/%d", line.ID), "sales")
if !strings.Contains(rec.Body.String(), "date-full") {
t.Error("expected a grey (full) date button when sold == maxAds")
}
store.lineDates[line.ID] = []int64{5} // this proposal already holds date 5, which is now full
rec = getAs(h, fmt.Sprintf("/orders/1/lines/%d", line.ID), "sales")
if !strings.Contains(rec.Body.String(), "date-conflict") {
t.Error("expected a red (conflict) date button for this proposal's now-full date")
}
}
func TestLineDateToggleSwapsButtonAndTotals(t *testing.T) {
h, store, _ := ordersServer(t)
store.byID[1] = orders.Order{ID: 1, Title: "W1", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1}
line, _ := store.AddEpisodeLine(context.Background(), 1, 1, 6500, 260000, 40000)
rec := adminPost(h, fmt.Sprintf("/orders/1/lines/%d/dates/5/toggle", line.ID), "sales", url.Values{})
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200 (htmx fragment, not a redirect)", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `id="date-5"`) {
t.Error("response missing the in-place-swapped date button")
}
if !strings.Contains(body, `id="live-totals"`) || !strings.Contains(body, `hx-swap-oob`) {
t.Error("response missing the out-of-band totals swap")
}
if !strings.Contains(body, "2,600") { // RateCents 260000 = $2,600; 1 date selected -> quantity 1 -> gross subtotal $2,600, formatted via `money`
t.Error("totals fragment doesn't reflect the newly-toggled date")
}
dates, _ := store.LineDates(context.Background(), line.ID)
if len(dates) != 1 || dates[0] != 5 {
t.Errorf("date not actually toggled in the store: %v", dates)
}
}
- Step 2: Run — expect FAIL.
Run: cd app && go test ./internal/server/... -run 'TestLineEditor|TestLineDateToggle' -v
Expected: FAIL (routes/handlers/templates don't exist).
- Step 3: Implement. Add the template func to
templates.go'stemplateFuncsmap:
"dateClass": func(c ordermath.DateClass) string {
switch c {
case ordermath.DateConflict:
return "date-conflict"
case ordermath.DateFull:
return "date-full"
default:
return "date-open"
}
},
(Add "twit.tv/twitads/internal/ordermath" to templates.go's imports.)
Critical fix to the existing render() function. order_line_edit.html calls {{template "date_button" ...}} and {{template "totals" ...}}, which are defined in the SEPARATE files _date_button.html/_totals.html (created in this task) — but render()'s current ParseFS call (templates.go, unchanged since A2) only parses templates/layout.html and the one requested page template:
t, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templateFS, "templates/layout.html", "templates/"+name)
Any page using the new partials would panic/error at execute time with "template: no template associated with template name" — the partial files are never in that template set. Fix render() itself (it's shared by every page in the app, so this fixes it for all of them, not just this one page) to also glob every underscore-prefixed partial:
t, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templateFS, "templates/layout.html", "templates/_*.html", "templates/"+name)
This is a one-line change to an existing function — every other page's render call is unaffected (an unused {{define}} block in a parsed template set is not an error in Go's html/template), and it makes any future underscore-prefixed partial automatically available everywhere without another render() edit.
Add to orders_ui.go:
// linesToOrdermath maps stored facts to B1's pure computation input.
// Quantity is already computed by Store.Lines (count of that line's dates).
func linesToOrdermath(lines []orders.OrderLine, agencyBP int) ordermath.Order {
out := ordermath.Order{AgencyCommissionBP: agencyBP}
for _, l := range lines {
out.Lines = append(out.Lines, ordermath.Line{
RateCents: l.RateCents, ImpressionsPerUnit: l.ImpressionsPerUnit,
Quantity: l.Quantity, IsValueAdd: l.IsValueAdd,
})
}
return out
}
type dateButtonView struct {
Episode shows.Episode
Selected bool
Class ordermath.DateClass
}
type lineEditorView struct {
OrderID int64
Line orders.OrderLine
ShowTitle string
DateButtons []dateButtonView
Totals ordermath.Totals
WindowFrom time.Time // the picker's [from, until) window, echoed back so the
WindowUntil time.Time // "different date range" link can round-trip it in the query string
}
func (d Deps) buildLineEditorView(ctx context.Context, orderID, lineID int64, from, until time.Time) (lineEditorView, error) {
order, err := d.Orders.Get(ctx, orderID)
if err != nil {
return lineEditorView{}, err
}
lines, err := d.Orders.Lines(ctx, orderID)
if err != nil {
return lineEditorView{}, err
}
var line orders.OrderLine
for _, l := range lines {
if l.ID == lineID {
line = l
}
}
if line.ID == 0 {
return lineEditorView{}, fmt.Errorf("line %d not found on order %d", lineID, orderID)
}
sh, err := d.Shows.Get(ctx, *line.ShowID)
if err != nil {
return lineEditorView{}, err
}
episodes, err := d.Shows.Episodes(ctx, sh.ID, from, until)
if err != nil {
return lineEditorView{}, err
}
selectedDates, err := d.Orders.LineDates(ctx, lineID)
if err != nil {
return lineEditorView{}, err
}
selected := map[int64]bool{}
for _, id := range selectedDates {
selected[id] = true
}
var buttons []dateButtonView
for _, ep := range episodes {
if ep.Status == "cancelled" {
continue // spec: sold-slot picker never offers a cancelled episode
}
sold, err := d.Orders.SoldCount(ctx, ep.ID)
if err != nil {
return lineEditorView{}, err
}
isSel := selected[ep.ID]
buttons = append(buttons, dateButtonView{
Episode: ep, Selected: isSel,
Class: ordermath.ClassifyDate(sh.MaxAds, sold, isSel),
})
}
agencyBP := 0
if order.AgencyID != nil {
ag, err := d.Sponsors.GetAgency(ctx, *order.AgencyID)
if err != nil {
return lineEditorView{}, err
}
agencyBP = ag.CommissionBP
}
totals := ordermath.Compute(linesToOrdermath(lines, agencyBP))
return lineEditorView{
OrderID: orderID, Line: line, ShowTitle: sh.Title, DateButtons: buttons, Totals: totals,
WindowFrom: from, WindowUntil: until,
}, nil
}
func lineEditorWindow(r *http.Request) (time.Time, time.Time) {
from, until := today(), today().AddDate(0, 0, 84) // 12 weeks
if v := r.URL.Query().Get("from"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
from = t
}
}
if v := r.URL.Query().Get("until"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
until = t
}
}
return from, until
}
func (d Deps) lineEdit(w http.ResponseWriter, r *http.Request) {
u, _ := CurrentUser(r.Context())
orderID, _ := strconv.ParseInt(r.PathValue("orderID"), 10, 64)
lineID, _ := strconv.ParseInt(r.PathValue("lineID"), 10, 64)
from, until := lineEditorWindow(r)
view, err := d.buildLineEditorView(r.Context(), orderID, lineID, from, until)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, "order_line_edit.html", pageData{User: u, Data: view})
}
func (d Deps) lineDateToggle(w http.ResponseWriter, r *http.Request) {
actor, _ := CurrentUser(r.Context())
orderID, _ := strconv.ParseInt(r.PathValue("orderID"), 10, 64)
lineID, _ := strconv.ParseInt(r.PathValue("lineID"), 10, 64)
episodeID, _ := strconv.ParseInt(r.PathValue("episodeID"), 10, 64)
selected, err := d.Orders.ToggleLineDate(r.Context(), lineID, episodeID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = d.Audit.Record(r.Context(), &actor, "order_line_date", fmt.Sprintf("%d/%d", lineID, episodeID), "toggle",
map[string]any{"order_id": orderID, "selected": selected})
from, until := lineEditorWindow(r)
view, err := d.buildLineEditorView(r.Context(), orderID, lineID, from, until)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Find the just-toggled button for the in-place swap; buildLineEditorView
// already recomputed Class/Selected for it against the fresh DB state.
var toggled dateButtonView
for _, b := range view.DateButtons {
if b.Episode.ID == episodeID {
toggled = b
}
}
renderFragment(w, "line_toggle_fragment.html", map[string]any{
"OrderID": orderID, "LineID": lineID, "Button": toggled, "Totals": view.Totals,
})
}
Add routes to registerOrders:
mux.Handle("GET /orders/{orderID}/lines/{lineID}", mw.requireUser(http.HandlerFunc(d.lineEdit)))
mux.Handle("POST /orders/{orderID}/lines/{lineID}/dates/{episodeID}/toggle", mw.requireUser(http.HandlerFunc(d.lineDateToggle)))
Templates. templates/_date_button.html (a reusable {{define}} block, included by both the full page and the toggle fragment):
{{define "date_button"}}<button id="date-{{.Episode.ID}}" class="{{dateClass .Class}}"
{{if ne (dateClass .Class) "date-full"}}hx-post="/orders/{{.OrderID}}/lines/{{.LineID}}/dates/{{.Episode.ID}}/toggle" hx-swap="outerHTML"{{end}}
{{if eq (dateClass .Class) "date-full"}}disabled{{end}}>{{mdy .Episode.PublishDate}}{{if .Selected}} ✓{{end}}</button>{{end}}
Comparing dateClass .Class (a plain string) against the string literal "date-full", rather than comparing .Class against a raw numeric literal (ordermath.DateFull's iota ordinal) — eq/ne on two strings is unambiguous, and this sidesteps ever depending on DateFull's position in B1's const block. (For the record: .Class vs. a numeric literal would actually have worked too — ordermath.DateClass and an untyped template integer both bucket into the same reflect "int kind" in eq's implementation, unlike B2's *int64-vs-int64 case where one side had no valid comparison bucket at all. But routing through the already-necessary dateClass string mapping is strictly more robust for zero extra cost, so that's what's used here — no need to rely on that reasoning holding.)
Since _date_button.html is referenced from two different top-level templates (order_line_edit.html for the full page, line_toggle_fragment.html for the htmx response), and {{template "name" pipeline}} sets . (dot) to pipeline for the invoked template's execution — but whether $ also resets across that boundary is genuinely ambiguous without checking, so _date_button.html's own body uses only plain . (.OrderID, .LineID, .Episode, never $.something), sidestepping the question entirely. That means every CALLER must pass everything the button needs — including OrderID/LineID, which don't live on a dateButtonView — bundled into one dict argument. Wrap the call site, not the definition:
{{range .Data.DateButtons}}{{template "date_button" (dict "OrderID" $.Data.OrderID "LineID" $.Data.Line.ID "Episode" .Episode "Selected" .Selected "Class" .Class)}}{{end}}
This needs a dict template func (Go's html/template has no built-in map-literal construction inside a template) — add to templateFuncs:
"dict": func(pairs ...any) (map[string]any, error) {
if len(pairs)%2 != 0 {
return nil, fmt.Errorf("dict: odd number of arguments")
}
m := make(map[string]any, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
key, ok := pairs[i].(string)
if !ok {
return nil, fmt.Errorf("dict: key %v is not a string", pairs[i])
}
m[key] = pairs[i+1]
}
return m, nil
},
templates/_totals.html:
{{define "totals"}}<div id="live-totals"{{if .OOB}} hx-swap-oob="true"{{end}}>
<table>
<tr><td>Gross subtotal</td><td class="num">${{money .Totals.GrossSubtotalCents}}</td></tr>
<tr><td>Guaranteed impressions</td><td class="num">{{commas .Totals.GuaranteedImpressions}}</td></tr>
<tr><td>Commission</td><td class="num">${{money .Totals.CommissionCents}}</td></tr>
<tr><td><strong>Net to TWiT</strong></td><td class="num"><strong>${{money .Totals.NetToTWiTCents}}</strong></td></tr>
</table>
</div>{{end}}
(No discount row yet — B4 adds it once discount UI exists; Totals.DiscountCents is always 0 here since B3 never sets ordermath.Discount.)
templates/order_line_edit.html:
{{define "title"}}Edit Line — TWiT Ads{{end}}
{{define "content"}}
<p><a href="/orders/{{.Data.OrderID}}">← Order</a></p>
<h2>{{.Data.ShowTitle}}</h2>
<div id="date-picker">
{{range .Data.DateButtons}}{{template "date_button" (dict "OrderID" $.Data.OrderID "LineID" $.Data.Line.ID "Episode" .Episode "Selected" .Selected "Class" .Class)}}{{end}}
</div>
<p>Showing {{mdy .Data.WindowFrom}}–{{mdy .Data.WindowUntil}} (next 12 weeks by default).</p>
<form method="get">
<label>From <input type="date" name="from" value="{{.Data.WindowFrom.Format "2006-01-02"}}"></label>
<label>Until <input type="date" name="until" value="{{.Data.WindowUntil.Format "2006-01-02"}}"></label>
<button>Update range</button>
</form>
{{template "totals" (dict "Totals" .Data.Totals "OOB" false)}}
<form method="post" action="/orders/{{.Data.OrderID}}/lines/{{.Data.Line.ID}}/delete"><button>Remove this line</button></form>
{{end}}
templates/line_toggle_fragment.html (the htmx response — no layout/content blocks, this is a raw fragment, so it is NOT parsed through render()'s layout.html wrapper; write a small dedicated render path):
{{template "date_button" (dict "OrderID" .OrderID "LineID" .LineID "Episode" .Button.Episode "Selected" .Button.Selected "Class" .Button.Class)}}
{{template "totals" (dict "Totals" .Totals "OOB" true)}}
Since this fragment must NOT go through render() (which always wraps in layout.html), add a small sibling render function to templates.go:
func renderFragment(w http.ResponseWriter, name string, data any) {
t, err := template.New(name).Funcs(templateFuncs).ParseFS(templateFS, "templates/_*.html", "templates/"+name)
if err != nil {
slog.Error("fragment parse", "name", name, "err", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, name, data); err != nil {
slog.Error("fragment exec", "name", name, "err", err)
}
}
lineDateToggle calls renderFragment(w, "line_toggle_fragment.html", map[string]any{"OrderID": orderID, "LineID": lineID, "Button": toggled, "Totals": view.Totals}) instead of render(...).
- Step 4: Run — expect PASS.
Run: cd app && go vet ./... && go test ./internal/server/... -v
Expected: PASS.
- Step 5: Commit.
git add app/internal/server/orders_ui.go app/internal/server/orders_ui_test.go app/internal/server/templates.go \
app/internal/server/templates/order_line_edit.html app/internal/server/templates/_date_button.html \
app/internal/server/templates/_totals.html app/internal/server/templates/line_toggle_fragment.html
git commit -m "feat(orders): date picker + live totals via htmx (open/full/conflict wiring)"
Task 5: Per-line CPM/rate/download override (negotiable, flat-rate-capable)
Everything on a line is negotiable — the rate card only suggests a starting point (Domain Decision #10). This task reuses rates_ui.go's existing parseShowRateForm (package-private but same package server, directly callable) verbatim: CPM and downloads are the primary inputs, cost defaults to CPM × downloads ÷ 1000 when left blank, but an explicit cost overrides that computation entirely — exactly what a flat-rate deal needs, with zero new parsing logic to get subtly wrong.
Files:
- Modify:
app/internal/server/orders_ui.go - Modify:
app/internal/server/server.go(addUpdateLineRateto theOrderStoreinterface — without this,d.Orders.UpdateLineRate(...)won't compile) - Modify:
app/internal/server/templates/order_line_edit.html - Modify:
app/internal/server/orders_ui_test.go - Modify:
app/internal/orders/orders.go+orders_test.go(a smallUpdateLineRatestore method — no optimistic locking here, unlike the order header; a line-rate edit isn't the same multi-user-conflict risk class as editing an order's header fields, and the spec doesn't call for it. Also no order-status check — Domain Decision #11: editing a line's rate must work on finalized orders too, not just pending ones.)
Interfaces:
// orders.go
func (s *Store) UpdateLineRate(ctx context.Context, lineID int64, cpmCents, rateCents int64, impressionsPerUnit int) error
// server.go — add this one line to the OrderStore interface block (built in Task 3)
UpdateLineRate(ctx context.Context, lineID int64, cpmCents, rateCents int64, impressionsPerUnit int) error
Route: POST /orders/{orderID}/lines/{lineID}/rate
- Step 1: Write the failing tests. Store-level (
orders_test.go):
func TestUpdateLineRate(t *testing.T) {
pool := testPool(t)
advID, _, userID := seedFixtures(t, pool)
sh, rate := seedShowWithRate(t, pool)
s := &Store{Pool: pool}
ctx := context.Background()
ord, _ := s.Create(ctx, Order{Title: "W1", AdvertiserID: advID, SalespersonID: userID, Year: 2026})
line, _ := s.AddEpisodeLine(ctx, ord.ID, sh.ID, rate.CPMCents, rate.CostPerEpisodeCents, rate.DownloadsPerEpisode)
if err := s.UpdateLineRate(ctx, line.ID, 4500, 240000, 38000); err != nil {
t.Fatalf("update rate: %v", err)
}
lines, _ := s.Lines(ctx, ord.ID)
if lines[0].CPMCents != 4500 || lines[0].RateCents != 240000 || lines[0].ImpressionsPerUnit != 38000 {
t.Errorf("rate override didn't persist: %+v", lines[0])
}
}
Handler-level (orders_ui_test.go) — first add the method itself to fakeOrderStore:
func (f *fakeOrderStore) UpdateLineRate(ctx context.Context, lineID int64, cpmCents, rateCents int64, impressionsPerUnit int) error {
f.mu.Lock()
defer f.mu.Unlock()
l, ok := f.lines[lineID]
if !ok {
return errors.New("not found")
}
l.CPMCents, l.RateCents, l.ImpressionsPerUnit = cpmCents, rateCents, impressionsPerUnit
f.lines[lineID] = l
return nil
}
Then two tests — one for the CPM-driven auto-compute path, one for the flat-rate override path (blank cost):
func TestLineRateOverrideRecomputesCostFromCPM(t *testing.T) {
h, store, _ := ordersServer(t)
store.byID[1] = orders.Order{ID: 1, Title: "W1", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1}
line, _ := store.AddEpisodeLine(context.Background(), 1, 1, 6500, 260000, 40000)
store.lineDates[line.ID] = []int64{5}
// CPM $45, 38,000 downloads, cost left blank -> 45*38000/1000 = $1,710
rec := adminPost(h, fmt.Sprintf("/orders/1/lines/%d/rate", line.ID), "sales",
url.Values{"cpm": {"45"}, "downloads": {"38000"}, "cost": {""}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("got %d, want 303", rec.Code)
}
lines, _ := store.Lines(context.Background(), 1)
if lines[0].CPMCents != 4500 || lines[0].ImpressionsPerUnit != 38000 || lines[0].RateCents != 171000 {
t.Errorf("CPM-driven recompute wrong: %+v", lines[0])
}
}
func TestLineRateOverrideAcceptsFlatRate(t *testing.T) {
h, store, _ := ordersServer(t)
store.byID[1] = orders.Order{ID: 1, Title: "W1", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1}
line, _ := store.AddEpisodeLine(context.Background(), 1, 1, 6500, 260000, 40000)
// A negotiated flat $2,000/episode regardless of what CPM x downloads implies.
rec := adminPost(h, fmt.Sprintf("/orders/1/lines/%d/rate", line.ID), "sales",
url.Values{"cpm": {"65"}, "downloads": {"40000"}, "cost": {"2000"}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("got %d, want 303", rec.Code)
}
lines, _ := store.Lines(context.Background(), 1)
if lines[0].RateCents != 200000 {
t.Errorf("flat-rate override not honored: %+v", lines[0])
}
}
func TestLineRateOverrideWorksOnFinalizedOrder(t *testing.T) {
h, store, _ := ordersServer(t)
store.byID[1] = orders.Order{ID: 1, Title: "W1", AdvertiserID: 1, SalespersonID: 3, Status: "finalized", Year: 2026, Version: 1}
line, _ := store.AddEpisodeLine(context.Background(), 1, 1, 6500, 260000, 40000)
rec := adminPost(h, fmt.Sprintf("/orders/1/lines/%d/rate", line.ID), "sales",
url.Values{"cpm": {"65"}, "downloads": {"40000"}, "cost": {"2000"}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("finalized-order line edit: got %d, want 303 (rate changes must not be blocked by status)", rec.Code)
}
}
- Step 2: Run — expect FAIL.
Run: cd app && just test-db (store test needs real Postgres) and go test ./internal/server/... -run TestLineRateOverride -v (handler tests, fake store)
Expected: FAIL — undefined method / route.
- Step 3: Implement.
orders.go:
func (s *Store) UpdateLineRate(ctx context.Context, lineID int64, cpmCents, rateCents int64, impressionsPerUnit int) error {
tag, err := s.Pool.Exec(ctx,
`UPDATE order_lines SET cpm_cents=$2, rate_cents=$3, impressions_per_unit=$4, updated_at=now() WHERE id=$1`,
lineID, cpmCents, rateCents, impressionsPerUnit)
if err != nil {
return fmt.Errorf("update line %d rate: %w", lineID, err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("update line %d rate: not found", lineID)
}
return nil
}
orders_ui.go handler — reuses parseShowRateForm from rates_ui.go directly (same package, same signature (downloads int, cpmCents, costCents int64, err error), already handles the blank-cost-means-compute / explicit-cost-means-override logic):
func (d Deps) lineRateUpdate(w http.ResponseWriter, r *http.Request) {
actor, _ := CurrentUser(r.Context())
orderID, _ := strconv.ParseInt(r.PathValue("orderID"), 10, 64)
lineID, _ := strconv.ParseInt(r.PathValue("lineID"), 10, 64)
downloads, cpmCents, costCents, err := parseShowRateForm(r)
if err != nil {
http.Redirect(w, r, fmt.Sprintf("/orders/%d/lines/%d?err=%s", orderID, lineID, urlQueryEscape(err)), http.StatusSeeOther)
return
}
if err := d.Orders.UpdateLineRate(r.Context(), lineID, cpmCents, costCents, downloads); err != nil {
http.Redirect(w, r, fmt.Sprintf("/orders/%d/lines/%d?err=%s", orderID, lineID, urlQueryEscape(err)), http.StatusSeeOther)
return
}
_ = d.Audit.Record(r.Context(), &actor, "order_line", fmt.Sprint(lineID), "update_rate",
map[string]any{"order_id": orderID, "cpm_cents": cpmCents, "rate_cents": costCents, "impressions_per_unit": downloads})
http.Redirect(w, r, fmt.Sprintf("/orders/%d/lines/%d", orderID, lineID), http.StatusSeeOther)
}
Route: mux.Handle("POST /orders/{orderID}/lines/{lineID}/rate", mw.requireUser(http.HandlerFunc(d.lineRateUpdate))). Deliberately not gated on order status — see Domain Decision #11.
order_line_edit.html — add above the date picker, field names matching show_rates.html's existing add-rate form exactly (downloads/cpm/cost, so parseShowRateForm needs no changes):
<form method="post" action="/orders/{{.Data.OrderID}}/lines/{{.Data.Line.ID}}/rate">
<label>Downloads per episode <input name="downloads" type="number" min="0" value="{{.Data.Line.ImpressionsPerUnit}}" required></label>
<label>CPM $ <input name="cpm" value="{{dollars .Data.Line.CPMCents}}" required></label>
<label>Cost per episode $ (leave blank to compute CPM × downloads ÷ 1000) <input name="cost" placeholder="{{dollars .Data.Line.RateCents}}"></label>
<button>Update rate</button>
</form>
- Step 4: Run — expect PASS.
Run: cd app && go vet ./... && just test-db
Expected: PASS.
- Step 5: Commit.
git add app/internal/orders/orders.go app/internal/orders/orders_test.go app/internal/server/orders_ui.go \
app/internal/server/orders_ui_test.go app/internal/server/templates/order_line_edit.html
git commit -m "feat(orders): per-line rate/downloads override"
Task 6: Whole-repo verification & close-out
- Step 1: Full suite, twice.
Run: cd app && go vet ./... && just test && just test-db && just test-db
Expected: every package green both times.
- Step 2: Purity spot-check — confirm nothing in
internal/server's new code accidentally importsordermath's pure packages incorrectly, and thatinternal/ordersstill has nonet/httpimport (it's a store package, not a handler package — the line/date logic added this stage stays pure-store, same as B2):
Run: cd app && go list -deps ./internal/orders | grep -E 'net/http' || echo "PURE: internal/orders has no net/http"
Expected: PURE: internal/orders has no net/http.
- Step 3: Compose smoke.
Run: cd app && docker compose up -d --build && sleep 3 && curl -fsS localhost:8730/healthz && curl -fsS localhost:8730/static/htmx.min.js | head -c 100 && docker compose logs --tail=20 app
Expected: healthz OK, migrations report v7, htmx.min.js serves real content. Spot-check over the tailnet: open an order, add a show, click a few dates (confirm the button toggles and totals update without a page reload — open the browser devtools Network tab and confirm the toggle is an XHR/fetch, not a full navigation), override a rate, confirm totals reflect it, remove the line.
Step 4: Tick the checklist below, append "Decisions & deviations," update
Plans/README.md, commit, republish.Step 5: STOP for Leo's gate. Do not start B4. Per
phase-a-review.md's standing recommendation, this stage introduced the most new machinery since B1 (a new schema shape, htmx, static-asset embedding) — a Fable review pass before B4 is worth asking for even if not strictly required.
Acceptance checklist
-
order_lines/order_line_datesmigrated (00007_order_lines.sql); tagged-unionCHECKconstraints enforce the kind/FK shape;ON DELETE CASCADEfrom dates to their line. - Store:
AddEpisodeLine/Lines(computedQuantity)/DeleteLine(cascades dates)/LineDates/ToggleLineDate/UpdateLineRate/SoldCount(stub, documented); integration tests green viajust test-db, twice. - htmx 2.0.10 vendored and served from
/static/; zero external-network dependency at request time. - Date picker: white/red/grey per
ordermath.ClassifyDate, cancelled episodes excluded, moved episodes included at their current date, grey buttons carry nohx-post(genuinely unclickable, not just visually disabled). - Clicking a date toggles it via one htmx POST that swaps the button in place AND the totals block out-of-band — no full-page reload for either update.
- Live totals reflect
ordermath.Computeover the order's current lines + its agency's commission bp (no discount yet — that's B4). - Per-line CPM/rate/downloads override persists and is reflected in the next totals recompute; blank cost recomputes from CPM×downloads÷1000, an explicit cost overrides it (flat-rate deals); works identically on pending and finalized orders (no status gate).
- Remove-line works and cascades its date selections.
-
go vet ./...,just test,just test-db(×2) all green;internal/ordersstill has nonet/httpimport; compose smoke OK. Live/orderswalkthrough (add show → pick dates → override rate → see totals) deferred to Leo, same pattern as B1/B2.
Notes for the executor
- The date-picker template compares
dateClass .Class(a string) against"date-full", not.Classagainst a raw numeric literal — deliberately, to avoid ever depending onordermath.DateFull's position in B1'sconstblock (DateOpen=0, DateConflict=1, DateFull=2today). A template-level test (TestLineEditorShowsFullAndConflictDates) still exists to catch any future drift, but the string-comparison design means there's nothing to drift into in the first place. eq/neon two plain non-pointer values (ints, strings) work fine — B2's bug was specifically about a nullable pointer field compared without dereferencing (*int64vsint64,reflect.Ptrhas no valid comparison bucket at all). Nothing in this stage's templates compares a pointer directly; where a nullable field likeOrderLine.ShowID *int64is used, it's dereferenced in Go (*line.ShowID) before ever reaching a template. Don't over-correct by routing every comparison through a nil-safe helper — only actual pointer comparisons need that treatment.SoldCountalways returning 0 is not a bug to "fix" during this stage — see Domain Decision #1. If a reviewer flags it as suspicious, point them here rather than wiring in a premature placements query.- No optimistic locking on
UpdateLineRate— deliberate, unlike the order header'sUpdate. A line-rate edit isn't the same concurrent-conflict risk the order header has (spec doesn't call for it, and it's a much narrower blast radius if two edits race — last-write-wins on a single line's rate is an acceptable risk here, matching every other non-order store'sUpdatein the codebase). CPMCents/RateCents/ImpressionsPerUnitare three independently-stored columns, not two-stored-one-derived.parseShowRateFormcomputesRateCentsfromCPMCents × ImpressionsPerUnit ÷ 1000only when the form'scostfield is blank — an explicit cost is stored verbatim, which can leaveRateCentsmathematically inconsistent withCPMCents × ImpressionsPerUnit ÷ 1000by design (a flat-rate deal has a real CPM-equivalent that just isn't what the math would imply). Don't "fix" this by re-derivingRateCentson every read —RateCentsis whatordermath.Computeactually uses,CPMCentsis negotiation/display context. This exactly mirrorsshow_rates' existing behavior on the rate card itself (A5) — not a new inconsistency introduced here.- Line edits work on finalized orders — verify this doesn't regress. If B5 (not yet planned) adds any status check to shared code this stage's handlers call through, re-confirm
TestLineRateOverrideWorksOnFinalizedOrderstill passes; that test exists specifically to catch a future accidental status gate, not just to prove today's code. renderFragmentis deliberately separate fromrender— htmx partial responses must never go through thelayout.htmlwrapper (that would send a whole extra<html>/<head>/nav for every date click). Don't be tempted to unify them.buildLineEditorViewunconditionally dereferencesline.ShowID(d.Shows.Get(ctx, *line.ShowID)), which is safe today only because B3 never creates a line withShowID == nil(every line this stage makes iskind="episode_ad"). This will panic the moment B4 adds catalog lines (billboard_week/newsletter/etc., which haveShowID == nilby the tagged-union design) iflineEdit/lineDateToggleare reused unchanged for them. B4 needs either akind-based branch here or its own separate editor view for catalog lines — flag this explicitly in the B4 plan rather than discovering it via a crash.- Copy
sponsors_ui.go's andrates_ui.go's idioms for anything not spelled out explicitly here (form re-render on error,?err=redirect pattern,money.ParseDollarsfor dollar-input parsing).
Decisions & deviations
Executed subagent-driven across 6 tasks (implementers + reviewers all Opus 4.8, Fable 5 orchestrating). Base HEAD ce088b7; final HEAD after Task 5 was 1e543e7. Full per-task review ledger: .superpowers/sdd/progress.md. All acceptance-checklist items above verified in Task 6 by go vet ./... + just test + just test-db (×2) all green, internal/orders confirmed free of net/http, and a docker-compose smoke (healthz OK, migrations report v7, /static/htmx.min.js serves real vendored content). The live /orders browser walkthrough is deferred to Leo per the Lifecycle in Plans/README.md (verification is Leo's step), same pattern as B1/B2.
Deviations from the plan's exact code
- Task 3 — genuine ID-numbering contradiction fixed in the brief's own fake-store test code. The brief's fake
AddEpisodeLinesnippet and the test that asserted the post-create redirect URL disagreed about the new line's ID. The implementer caught this and corrected the test to match the real store'sGENERATED ALWAYS AS IDENTITY-starts-at-1 behavior (verified against the real store, not guessed). Test-only fix; production code unaffected. - Task 4 —
linesToOrdermathis 2-arg, not the 3-arg signature sketched in the plan's Interfaces section. The plan's Interfaces block (line ~737) listedlinesToOrdermath(order orders.Order, lines []orders.OrderLine, agencyBP int), but the plan's own implementation body (line ~833) and the call site both used the 2-arglinesToOrdermath(lines []orders.OrderLine, agencyBP int). This was a plan-internal inconsistency, correctly resolved in favor of the actually-specified implementation (theorderargument was never used by the body — commission bp is passed in directly asagencyBP). The shipped code (internal/server/orders_ui.go:328, called at:412) matches the 2-arg form.
Open concerns to carry forward to B4
buildLineEditorViewunconditionally dereferencesline.ShowID(d.Shows.Get(ctx, *line.ShowID),internal/server/orders_ui.go:373). This is safe today only because B3 exclusively createskind="episode_ad"lines, which always haveshow_idset. It will panic the moment B4 introduces catalog lines (billboard_week/newsletter/social_post/banner, which haveShowID == nilby the tagged-union design) iflineEdit/lineDateToggle/buildLineEditorVieware reused unchanged. B4 must add akind-based branch here (or a separate editor view for catalog lines) — this is a known landmine, not something to rediscover via a crash. Flag it explicitly in the B4 plan.SoldCountis still a stub returning 0 (Domain Decision #1) — every date renders as open (white) in the live app until B5 buildsplacements. The oversold/conflict wiring is proven by test only, not by a live demo. Say this plainly at the B3 gate.- Fable review pass before B4 recommended. This stage introduced the most new machinery since B1 (a new schema shape, htmx, static-asset embedding). Per
phase-a-review.md's standing note, a Fable design review before B4 is worth requesting even if not strictly required. - Note on execution: the dedicated task-reviewer subagent dispatches for Tasks 2–5 returned only idle-notification pings with no report body (possible harness relay issue); the controller performed those task reviews directly against each diff/report/brief. If this repeats, it's a platform issue to flag to Leo, not a plan issue.