A3 — Shows & Episodes Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Shows with per-weekday recurrence (any set of days, up to daily) and slot counts; episodes generated 24 months ahead with computed numbers, publish + record dates, and TD assignment; safe move/cancel with audit; admin/continuity UI.
Architecture: A shows table holds per-show config (weekday set, slots, record offset + window, default TD, numbering seed). A pure-function generator computes missing episode dates; a store persists them idempotently (unique constraint = the safety net). Episode numbers are never stored — they're computed as seed + position among non-cancelled episodes ordered by publish date, so cancels and moves can never desync numbering (Leo: "numbering reflects only published episodes"). Move/cancel are status changes with audit rows. UI: shows CRUD (admin), per-show episode list with generate/move/cancel (continuity + admin).
Tech Stack: unchanged from A2. No new dependencies.
Global Constraints (same as A1/A2 — read ~/Projects/twit-ads/CLAUDE.md first)
- Execute ONLY stage A3. Spec §3 (show, episode) + the rundown findings (record day, TD, episode numbers). STOP and ask Leo on ambiguity.
- TDD red/green; one change at a time; frequent commits; bash for all commands.
- New migration is
00003_shows_episodes.sql— never edit applied migrations. Migration files ARE tracked (repo .gitignore negates the global*.sqlignore — don't remove that rule). - Civil dates in America/Los_Angeles; store as Postgres
date/timetypes. Dates in Go aretime.Timetruncated to midnight UTC used as opaque calendar dates. - Every mutation writes an
audit.Log.Recordrow from the handlers (stores stay pure data). - Role rules: shows CRUD = admin only. Episode generate/move/cancel = continuity or admin.
Domain decisions (corrected by Leo, 2026-07-06):
- Recurrence = a set of weekdays per show (shows run 7 days a week across the lineup; a show may air one day or several, up to daily). One episode per listed weekday.
- Record date may precede publish date. Per-show
record_offset_days(default 0) sets record_date = publish_date − offset at generation; editable per episode. Publish date is what the sales system keys on — inventory/availability (B-phase) uses publish_date only; record date exists for the rundown. - Cancelled episodes are not numbered — numbering reflects only published episodes. Numbers are COMPUTED (window function), never stored:
number = show.first_episode_number + rank among non-cancelled episodes by publish_date − 1. Cancelling an episode automatically shifts every later number down; there is no renumber operation.
Task 1: Migration + shows store
Files:
- Create:
app/internal/db/migrations/00003_shows_episodes.sql - Create:
app/internal/shows/shows.go - Test:
app/internal/shows/shows_test.go,app/internal/shows/testhelper_test.go
Interfaces:
- Produces:
package shows
type Show struct {
ID int64
Title string // "Security Now"
Abbrev string // "SN"
Weekdays []int // 0=Sunday … 6=Saturday; at least one; sorted unique
MaxAds int // slots per episode
RecordOffsetDays int // record_date = publish_date - offset (>= 0)
RecordStart string // "13:30" — HH:MM, America/Los_Angeles
RecordEnd string // "15:30"
DefaultTD string // "Leo", "John A.", may be ""
FirstEpisodeNumber int // number of the earliest non-cancelled episode
Notes string // hosts line for order forms
Active bool
}
type Store struct{ Pool *pgxpool.Pool }
func (s *Store) Create(ctx, sh Show) (Show, error) // validates: len(Weekdays)>0, each 0..6, RecordOffsetDays>=0
func (s *Store) Update(ctx, sh Show) error // by ID; all fields
func (s *Store) Get(ctx, id int64) (Show, error)
func (s *Store) List(ctx) ([]Show, error) // active first, then title
- Step 1: Write the migration
-- +goose Up
CREATE TABLE shows (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL UNIQUE,
abbrev text NOT NULL UNIQUE,
weekdays int[] NOT NULL,
max_ads int NOT NULL DEFAULT 3 CHECK (max_ads >= 0),
record_offset_days int NOT NULL DEFAULT 0 CHECK (record_offset_days >= 0),
record_start time NOT NULL DEFAULT '12:00',
record_end time NOT NULL DEFAULT '14:00',
default_td text NOT NULL DEFAULT '',
first_episode_number int NOT NULL DEFAULT 1,
notes text NOT NULL DEFAULT '',
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (cardinality(weekdays) > 0)
);
CREATE TYPE episode_status AS ENUM ('scheduled', 'moved', 'cancelled');
-- NOTE: no number column — episode numbers are computed (see episodeNumberSQL).
CREATE TABLE episodes (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
show_id bigint NOT NULL REFERENCES shows(id),
publish_date date NOT NULL,
record_date date NOT NULL,
record_start time NOT NULL,
record_end time NOT NULL,
td text NOT NULL DEFAULT '',
status episode_status NOT NULL DEFAULT 'scheduled',
original_date date,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (show_id, publish_date)
);
CREATE INDEX episodes_record_date_idx ON episodes (record_date);
-- +goose Down
DROP TABLE episodes;
DROP TYPE episode_status;
DROP TABLE shows;
Step 2: Copy the testPool helper pattern from
internal/auth/testhelper_test.gointointernal/shows/testhelper_test.go(packageshows; truncateepisodes, shows, users, sessions, audit_logwith RESTART IDENTITY CASCADE).Step 3: Write failing shows-store tests
package shows
import (
"context"
"slices"
"testing"
)
func TestShowLifecycle(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := &Store{Pool: pool}
sh, err := s.Create(ctx, Show{Title: "Security Now", Abbrev: "SN", Weekdays: []int{2}, MaxAds: 5,
RecordOffsetDays: 0, RecordStart: "13:30", RecordEnd: "15:30", DefaultTD: "Leo",
FirstEpisodeNumber: 1080, Active: true})
if err != nil {
t.Fatalf("create: %v", err)
}
if sh.ID == 0 || sh.Abbrev != "SN" || sh.MaxAds != 5 || sh.RecordStart != "13:30" || !slices.Equal(sh.Weekdays, []int{2}) {
t.Errorf("bad show: %+v", sh)
}
sh.MaxAds = 6
sh.Weekdays = []int{2, 4} // now airs Tuesday AND Thursday
sh.RecordOffsetDays = 2 // records two days before publish
if err := s.Update(ctx, sh); err != nil {
t.Fatalf("update: %v", err)
}
got, err := s.Get(ctx, sh.ID)
if err != nil || got.MaxAds != 6 || !slices.Equal(got.Weekdays, []int{2, 4}) || got.RecordOffsetDays != 2 {
t.Errorf("update not applied: %+v (%v)", got, err)
}
}
func TestWeekdayValidation(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := &Store{Pool: pool}
if _, err := s.Create(ctx, Show{Title: "Bad", Abbrev: "B1", Weekdays: nil, MaxAds: 3, RecordStart: "10:00", RecordEnd: "11:00", Active: true}); err == nil {
t.Error("empty weekdays must error")
}
if _, err := s.Create(ctx, Show{Title: "Bad2", Abbrev: "B2", Weekdays: []int{7}, MaxAds: 3, RecordStart: "10:00", RecordEnd: "11:00", Active: true}); err == nil {
t.Error("weekday 7 must error")
}
}
func TestDuplicateTitleRejected(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := &Store{Pool: pool}
if _, err := s.Create(ctx, Show{Title: "TWiT", Abbrev: "TWIT", Weekdays: []int{0}, MaxAds: 5, RecordStart: "14:00", RecordEnd: "17:00", Active: true}); err != nil {
t.Fatal(err)
}
if _, err := s.Create(ctx, Show{Title: "TWiT", Abbrev: "TW2", Weekdays: []int{0}, MaxAds: 5, RecordStart: "14:00", RecordEnd: "17:00", Active: true}); err == nil {
t.Error("duplicate title must error")
}
}
func TestListActiveFirst(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := &Store{Pool: pool}
mustCreate := func(sh Show) {
t.Helper()
if _, err := s.Create(ctx, sh); err != nil {
t.Fatal(err)
}
}
mustCreate(Show{Title: "Retired Show", Abbrev: "RS", Weekdays: []int{1}, MaxAds: 3, RecordStart: "10:00", RecordEnd: "11:00", Active: false})
mustCreate(Show{Title: "MacBreak Weekly", Abbrev: "MBW", Weekdays: []int{2}, MaxAds: 4, RecordStart: "11:00", RecordEnd: "13:00", Active: true})
list, err := s.List(ctx)
if err != nil || len(list) != 2 {
t.Fatalf("list: %v (%d)", err, len(list))
}
if !list[0].Active || list[0].Title != "MacBreak Weekly" {
t.Errorf("active shows must sort first: %+v", list)
}
}
Step 4: Red (
go vet ./internal/shows/), implementshows.go— pgx patterns asinternal/auth/users.go(const cols, scan helper, wrapped errors).weekdays int[]maps to[]int32via pgx natively (convert to/from[]int; validate 0..6 + non-empty in Go before insert). Times:to_char(record_start,'HH24:MI')in SELECTs, pass "HH:MM" strings on writes.Step 5: Green via
just test-db, commitfeat(shows): shows store with weekday-set recurrence config.
Task 2: Episode date generator (pure function)
Files:
- Create:
app/internal/shows/generate.go - Test:
app/internal/shows/generate_test.go
Interfaces:
- Produces:
// NextDates returns every date strictly after `after` through `until`
// (inclusive) whose weekday is in wds. Pure; no DB, no clock.
// Dates are time.Time at midnight UTC used as opaque calendar dates.
func NextDates(wds []int, after, until time.Time) []time.Time
- Step 1: Failing tests — table-driven:
package shows
import (
"slices"
"testing"
"time"
)
func TestNextDates(t *testing.T) {
d := func(s string) time.Time {
tt, err := time.Parse("2006-01-02", s)
if err != nil {
t.Fatal(err)
}
return tt
}
cases := []struct {
name string
wds []int
after, until string
want []string
}{
{"july tuesdays", []int{2}, "2026-07-01", "2026-07-31", []string{"2026-07-07", "2026-07-14", "2026-07-21", "2026-07-28"}},
{"after-day excluded", []int{2}, "2026-07-07", "2026-07-14", []string{"2026-07-14"}},
{"empty range", []int{1}, "2026-07-01", "2026-07-01", nil},
{"year boundary", []int{3}, "2026-12-29", "2027-01-07", []string{"2026-12-30", "2027-01-06"}},
{"sundays", []int{0}, "2026-06-30", "2026-07-13", []string{"2026-07-05", "2026-07-12"}},
{"tue+thu multi-day", []int{2, 4}, "2026-07-05", "2026-07-12", []string{"2026-07-07", "2026-07-09"}},
{"daily show", []int{0, 1, 2, 3, 4, 5, 6}, "2026-07-05", "2026-07-08", []string{"2026-07-06", "2026-07-07", "2026-07-08"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := NextDates(c.wds, d(c.after), d(c.until))
var gs []string
for _, g := range got {
gs = append(gs, g.Format("2006-01-02"))
}
if !slices.Equal(gs, c.want) {
t.Errorf("got %v want %v", gs, c.want)
}
})
}
}
- Step 2: Red, implement (walk day-by-day from after+1d through until; membership check against a [7]bool set), green (
go test ./internal/shows/ -run TestNextDates), commitfeat(shows): pure multi-weekday date generator.
Task 3: Episode store — idempotent generation, computed numbering
Files:
- Create:
app/internal/shows/episodes.go - Test:
app/internal/shows/episodes_test.go
Interfaces:
- Produces:
type Episode struct {
ID int64
ShowID int64
Number int // computed; 0 when Status == "cancelled"
PublishDate time.Time // what the sales system keys on
RecordDate time.Time
RecordStart string
RecordEnd string
TD string
Status string // scheduled|moved|cancelled
OriginalDate *time.Time
}
// Generate creates missing episodes for the show through `until`.
// Idempotent: existing publish_dates are skipped. Defaults per episode:
// record_date = publish_date - show.RecordOffsetDays, window/TD from show.
func (s *Store) Generate(ctx, showID int64, from, until time.Time) (int, error)
func (s *Store) Episodes(ctx, showID int64, from, until time.Time) ([]Episode, error) // by publish_date, all statuses, numbers computed
func (s *Store) GetEpisode(ctx, id int64) (Episode, error)
The numbering SQL (shared const, used by both queries):
SELECT e.*, CASE WHEN e.status = 'cancelled' THEN 0
ELSE s.first_episode_number - 1 + (
SELECT count(*) FROM episodes e2
WHERE e2.show_id = e.show_id AND e2.status <> 'cancelled'
AND e2.publish_date <= e.publish_date)
END AS number
FROM episodes e JOIN shows s ON s.id = e.show_id
(Fine at this scale: ~50 shows × ~104 episodes/yr; correlated subquery on an indexed column.)
- Step 1: Failing tests —
- generate SN (Tuesdays, seed 1086, offset 0) July 2026 → 4 episodes numbered 1086–1089, record==publish;
- second Generate same range → 0 new, still 4;
- extend range → only the tail added, numbering continues;
- a show with
RecordOffsetDays: 2generates record_date two days before publish; - cancel the second episode (direct SQL
UPDATE episodes SET status='cancelled') → its Number is 0 and the two later episodes renumber down to 1087, 1088 (the load-bearing computed-numbering test).
- Step 2: Red, implement. Generation: compute candidate dates with
NextDates(show.Weekdays, from, until),INSERT … ON CONFLICT (show_id, publish_date) DO NOTHING, count viaRETURNING 1, single transaction (pgx.BeginFunc). - Step 3: Green via
just test-db, commitfeat(shows): idempotent generation + computed episode numbering.
Task 4: Move & cancel
Files:
- Modify:
app/internal/shows/episodes.go - Test: append to
app/internal/shows/episodes_test.go
Interfaces:
- Produces:
// MoveEpisode changes dates/times/TD. Sets status=moved and original_date
// (first move only) when publish_date changes. Publish-date collisions error.
func (s *Store) MoveEpisode(ctx, id int64, publish, record time.Time, recStart, recEnd, td string) error
func (s *Store) CancelEpisode(ctx, id int64) error // status=cancelled; later numbers shift automatically (computed)
No renumber operation exists — numbering is always derived.
- Step 1: Failing tests — move sets status=moved + original_date survives a second move; move onto an occupied publish_date errors; cancelled episode returns Number 0 via GetEpisode and later episodes shift (already covered in Task 3, re-assert via CancelEpisode here); a moved episode's number reflects its NEW publish-date position (move episode 3 before episode 2 → their numbers swap).
- Step 2: Red, implement, green, commit
feat(shows): episode move/cancel with derived numbering.
Task 5: Continuity role middleware
Files:
- Modify:
app/internal/server/middleware.go - Test: append to
app/internal/server/middleware_test.go
Interfaces:
Produces:
requireContinuity— allowscontinuityANDadmin, 403 otherwise.Step 1: Failing tests (continuity 200, admin 200, sales 403 — same fake pattern as A2), Step 2: implement (mirror
requireAdmin), green, commitfeat(server): continuity role gate.
Task 6: Shows & episodes UI
Files:
- Create:
app/internal/server/admin_shows.go,templates/shows.html,templates/show_form.html,templates/episodes.html,templates/episode_form.html - Modify:
app/internal/server/server.go(Deps gainsShows ShowStore— interface over exactly the store methods used, for handler fakes),templates/home.html(nav link "Shows" for all logged-in roles) - Test:
app/internal/server/admin_shows_test.go
Routes:
GET /shows(requireUser) — list: title, abbrev, weekday names, slots, activeGET /admin/shows/new,POST /admin/shows(requireAdmin) — create; auditshow/createGET /admin/shows/{id},POST /admin/shows/{id}(requireAdmin) — edit; auditshow/updateGET /shows/{id}/episodes?from=&until=(requireUser) — default today→+3 months: number (— for cancelled), publish, record date+window, TD, status (moved rows show "was YYYY-MM-DD"); edit links for continuityPOST /shows/{id}/generate(requireContinuity) — through today+24 months; auditshowactiongeneratewith count; redirect backGET /episodes/{id},POST /episodes/{id}(requireContinuity) — move form (publish/record dates, window, TD); auditepisode/updatewith before/after detailPOST /episodes/{id}/cancel(requireContinuity) — auditepisode/cancel
Weekdays in the show form: seven labeled checkboxes (Sunday…Saturday), at least one required (server-side validation error re-renders form).
Steps: failing handler tests first (fake ShowStore; assert role gates, generate horizon = today+24 months, update audits before/after), red, implement templates + handlers (follow users.html idioms), green, commit feat(shows): admin UI for shows, episode schedule, generate/move/cancel.
Task 7: Wire, rebuild, E2E
- Update
main.goDeps withShows: &shows.Store{Pool: pool}. -
go vet ./... && just test && just test-db— all green. -
docker compose up -d --build --wait. - E2E with curl (cookie jar, admin login): create "Security Now" (Tuesdays, 5 slots, 13:30–15:30, TD Leo, seed 1086) → generate → list shows Tuesdays 24 months out from 1086 → move one +2 days (status moved, "was" shown, numbering still consistent) → cancel one → verify the next episode's number dropped by one → audit shows show/create, generate, episode/update, episode/cancel. Leave the data for Leo to correct/extend at verification.
Task 8: Close out
-
Plans/README.mdA3 row → executed, awaiting verification. Append## Decisions & deviationshere. - Commit
chore(a3): stage complete; republish team site (bun run scripts/publish-site.ts --deploy). - STOP. Leo verifies in the browser: enter the real lineup across the week, generate all, spot-check dates and numbering against reality (current episode numbers of SN/MBW/TWiT anchor the seeds), do one day swap, cancel a future episode and watch numbering close the gap, confirm the audit trail.
Decisions & deviations (executed 2026-07-06 by Kenobi/Fable 5)
- Test suite serialization:
go test ./...runs packages in parallel against the one shared test DB, and the auth + shows test helpers truncate each other's tables —just test-dbnow passes-p 1. (Caught live: an auth test failed only when the shows package ran alongside it.) - My test-arithmetic bug, not a code bug: the "extend range" expectation said 1 new episode; extending through 2026-08-11 correctly creates two (Tuesdays 8/4 + 8/11). Test fixed.
- One premature commit mid-Task-3 (the
&&-chain didn't guard on test success); the follow-up commit contains the fixes — history is coherent, tree was never green-claimed while red. weekdayNamestemplate func registered viatemplate.New().Funcs()(plan didn't specify the func-map wiring).- E2E left real-ish data in the dev DB: show "Security Now" (Tuesdays, 5 slots, seed 1086) with 105 generated episodes, one moved (7/14→7/16, TD John A.), one cancelled (7/21 — its number correctly reassigned to 7/28). Leo corrects/extends at verification. The e2e admin account
e2ea3is deactivated. Team users and their audit history preserved (Veva had already logged in). - All green: 19 server unit tests, 6 packages via
just test-db, live E2E through the real stack.
Addendum (same night): episode reinstate
Leo asked how to un-cancel an episode — there was no path. Added ReinstateEpisode (store, TDD incl. moved-episode case), POST /episodes/{id}/reinstate (continuity gate, audited), and a Reinstate button on cancelled rows. Verified live.