TWiT-Ads v2 Rebuild

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)

Domain decisions (corrected by Leo, 2026-07-06):

  1. 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.
  2. 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.
  3. 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:

Interfaces:

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
-- +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;
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)
	}
}

Task 2: Episode date generator (pure function)

Files:

Interfaces:

// 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
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)
			}
		})
	}
}

Task 3: Episode store — idempotent generation, computed numbering

Files:

Interfaces:

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.)


Task 4: Move & cancel

Files:

Interfaces:

// 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.


Task 5: Continuity role middleware

Files:

Interfaces:


Task 6: Shows & episodes UI

Files:

Routes:

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

Task 8: Close out


Decisions & deviations (executed 2026-07-06 by Kenobi/Fable 5)

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.