Files
k-tv/docs/superpowers/specs/2026-03-17-scheduling-v2-design.md

285 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Scheduling V2 — Design Spec
## Context
The current scheduler is a 48h rolling window with a flat block list per channel. This works as MVP but has two major gaps for everyday use:
1. **No weekly patterns** — users can't say "Monday runs X, weekends run Y"; all blocks repeat identically every day.
2. **No history or recovery** — overwriting a channel config loses the previous setup forever; a bug that resets a sequential series (e.g. Sopranos resets from S3E4 to S1E1) has no recovery path.
This spec covers two features: **weekly scheduling** and **schedule history**.
---
## Feature 1: Weekly Scheduling (7-day grid)
### Data model
`ScheduleConfig` changes from a flat block list to a day-keyed map:
```rust
// BEFORE
pub struct ScheduleConfig {
pub blocks: Vec<ProgrammingBlock>,
}
// AFTER
pub struct ScheduleConfig {
pub day_blocks: HashMap<Weekday, Vec<ProgrammingBlock>>,
}
pub enum Weekday {
Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday,
}
```
`ProgrammingBlock` is otherwise unchanged. Block IDs remain UUIDs; each day has its own independent Vec, so the same "show" on Mon and Wed has two separate block entries (different IDs, independent continuity tracking).
### Migration (transparent, zero-downtime)
Existing `channels.schedule_config` stores `{"blocks":[...]}`. Use `#[serde(untagged)]` deserialization:
```rust
#[serde(untagged)]
enum ScheduleConfigCompat {
V2(ScheduleConfig), // {"day_blocks": {"monday": [...], ...}}
V1(OldScheduleConfig), // {"blocks": [...]}
}
```
V1→V2 conversion: clone the blocks Vec into all 7 days. The first `PUT /channels/:id` after deploy saves V2 format. Channels never touched continue to deserialize via V1 path indefinitely.
**Edge case**: if a payload has both `blocks` and `day_blocks` keys (e.g. partially migrated export), `#[serde(untagged)]` tries V2 first and succeeds — `day_blocks` is used and `blocks` is silently ignored. This is acceptable; the alternative (error on ambiguity) would break more use cases.
### ScheduleConfig helper methods
Three methods on `ScheduleConfig` must be updated:
- **`find_block_at(weekday: Weekday, time: NaiveTime) -> Option<&ProgrammingBlock>`** — searches `day_blocks[weekday]` for the block whose window contains `time`.
- **`next_block_start_after(weekday: Weekday, time: NaiveTime) -> Option<NaiveTime>`** — searches that day's vec; returns `None` if no block starts after `time` on that day (day-rollover is the caller's responsibility).
- **`earliest_block_start() -> Option<NaiveTime>`** — **iterates all days, returns the global earliest start time across the entire week**. This is the form needed by the background scheduler (which needs to know when any content starts). Empty day = no contribution; all days empty = `None`.
**Call-site update pattern for `broadcast.rs` (lines 64, 171):**
```rust
// derive weekday from slot start_at in channel timezone
let tz: chrono_tz::Tz = channel.timezone.parse().unwrap_or(chrono_tz::UTC);
let local_dt = slot.start_at.with_timezone(&tz);
let weekday = Weekday::from_chrono(local_dt.weekday()); // new From impl
let block = channel.schedule_config.find_block_at(weekday, local_dt.time());
```
The same derivation applies to `dto.rs` (`ScheduledSlotResponse::with_block_access`).
### MCP crate
`mcp/src/tools/channels.rs` manipulates `schedule_config.blocks` directly. After V2:
- The MCP `add_block` tool must accept a `day: Weekday` parameter (required). It pushes the new block to `day_blocks[day]`.
- The MCP `remove_block` tool must iterate all days' vecs (remove by block ID across all days, since block IDs are unique per entry).
- `mcp/src/server.rs` `set_schedule_config` must accept a `day_blocks` map. The old `blocks_json` string parameter is replaced with `day_blocks_json: String` (JSON object keyed by weekday name).
These are breaking changes to the MCP API — acceptable since MCP tools are internal/developer-facing.
### Generation engine
- Window: `valid_from + 7 days` (was 48h). Update `GeneratedSchedule` doc comment accordingly.
- Day iteration: already walks calendar days; now walks 7 days, looks up `day_blocks[weekday]` for each day.
- **Empty day**: if `day_blocks[weekday]` is empty or the key is absent, that day produces no slots — valid, not an error.
- Continuity (`find_last_slot_per_block`): unchanged.
### Files changed (backend)
- `domain/src/value_objects.rs` — add `Weekday` enum with `From<chrono::Weekday>` impl
- `domain/src/entities.rs``ScheduleConfig`, `OldScheduleConfig` compat struct, update helper method signatures, update `GeneratedSchedule` doc comment
- `domain/src/services.rs` — 7-day window, `day_blocks[weekday]` lookup per day
- `api/src/routes/channels/broadcast.rs` — update block lookups at lines 64 and 171 using weekday-derivation pattern above
- `api/src/dto.rs` — update `ScheduledSlotResponse::with_block_access` block lookup
- `mcp/src/tools/channels.rs``add_block` accepts `day` param; `remove_block` iterates all days
- `mcp/src/server.rs` — replace `blocks_json` with `day_blocks_json`
---
## Feature 2: Schedule History
### 2a. Config version history
Every `PUT /channels/:id` auto-snapshots the previous config before overwriting. Users can pin named checkpoints and restore any version.
**New DB migration:**
```sql
CREATE TABLE channel_config_snapshots (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
config_json TEXT NOT NULL,
version_num INTEGER NOT NULL,
label TEXT, -- NULL = auto-saved, non-NULL = pinned
created_at TEXT NOT NULL,
UNIQUE (channel_id, version_num)
);
CREATE INDEX idx_config_snapshots_channel ON channel_config_snapshots(channel_id, version_num DESC);
```
**`version_num` assignment**: computed inside the write transaction as `SELECT COALESCE(MAX(version_num), 0) + 1 FROM channel_config_snapshots WHERE channel_id = ?`. The transaction serializes concurrent writes naturally in SQLite (single writer). The `UNIQUE` constraint is a safety net only — no 409 is exposed to the client; the server retries within the transaction if needed (in practice impossible with SQLite's serialized writes).
**New API endpoints (all require auth + channel ownership — same auth middleware as existing channel routes):**
```
GET /channels/:id/config/history
→ [{id, version_num, label, created_at}] -- channel_id omitted (implicit from URL)
PATCH /channels/:id/config/history/:snap_id
body: {"label": "Before S3 switchover"}
→ 404 if snap_id not found or not owned by this channel
→ 200 {id, version_num, label, created_at}
POST /channels/:id/config/history/:snap_id/restore
→ snapshots current config first, then replaces channel config with target snapshot
→ 404 if snap_id not found or not owned by this channel
→ 200 {channel}
```
**Domain + infra changes:**
- `ChannelConfigSnapshot` entity (fields: id, channel_id, config, version_num, label, created_at)
- Extend `ChannelRepository` port: `save_config_snapshot`, `list_config_snapshots`, `get_config_snapshot`, `patch_config_snapshot_label`
- `ChannelService::update_channel` calls `save_config_snapshot` before writing new config
**Files changed (backend):**
- `domain/src/entities.rs` — add `ChannelConfigSnapshot`
- `domain/src/repositories.rs` — extend `ChannelRepository` port
- `infra/src/channel_repo.rs` — implement snapshot methods
- `migrations_sqlite/YYYYMMDD_add_config_snapshots.sql`
- `api/src/routes/channels.rs` — new history endpoints + DTOs for snapshot responses
### 2b. Generated schedule audit log
**Ownership check**: `get_schedule_by_id(channel_id, gen_id)` queries `generated_schedules WHERE id = :gen_id AND channel_id = :channel_id` — the `channel_id` column is the join, so no separate channel lookup is needed.
**New API endpoints (all require auth + channel ownership):**
```
GET /channels/:id/schedule/history
→ [{id, generation, valid_from, valid_until}] ordered by generation DESC
GET /channels/:id/schedule/history/:gen_id
→ full GeneratedSchedule with slots
→ 404 if gen_id not found or channel_id mismatch
POST /channels/:id/schedule/history/:gen_id/rollback
→ 404 if gen_id not found or channel_id mismatch
→ explicit two-step delete (no DB-level cascade from playback_records to generated_schedules):
1. DELETE FROM playback_records WHERE channel_id = ? AND generation > :target_generation
2. DELETE FROM generated_schedules WHERE channel_id = ? AND generation > :target_generation
(scheduled_slots cascade via FK from generated_schedules)
→ calls generate_schedule from now
→ 200 {new_schedule}
```
**Repository changes:**
- `list_schedule_history(channel_id)` — headers only
- `get_schedule_by_id(channel_id, gen_id)` — full with slots
- `delete_schedules_after(channel_id, generation_num)` — two-step explicit delete as above
**Files changed (backend):**
- `domain/src/repositories.rs` — extend `ScheduleRepository`
- `infra/src/schedule_repo.rs` — implement list, get-by-id, delete-after
- `api/src/routes/channels.rs` — new history and rollback endpoints
---
## Frontend
### Weekly grid editor (`edit-channel-sheet.tsx`)
Replace the flat block list with a tabbed weekly grid:
- 7 day tabs (MonSun), each showing that day's block list
- Blocks within a day: same card UI as current (drag to reorder, edit, delete)
- "Copy to →" dropdown per tab: duplicates block entries with new UUIDs into target day(s)
- "+ Add block for [Day]" button per tab
- "🕐 Config history" button in sheet footer → opens config history panel
### Config history panel (`config-history-sheet.tsx` — new)
- List of snapshots: version_num, timestamp, label (if pinned)
- Current version highlighted
- Pin button on current version (opens label input)
- Restore button on any past version (confirm dialog)
### Schedule audit log (`schedule-history-dialog.tsx` — new)
- Lists past generations: gen#, date range
- "Rollback to here" button with confirm dialog
### Types (`lib/types.ts`)
```ts
type Weekday = 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday'
const WEEKDAYS: Weekday[] = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday']
interface ScheduleConfig {
day_blocks: Record<Weekday, ProgrammingBlock[]>
}
interface ConfigSnapshot {
id: string
version_num: number
label: string | null
created_at: string
// channel_id intentionally omitted — always accessed via /channels/:id/config/history
}
interface ScheduleHistoryEntry {
id: string
generation: number
valid_from: string
valid_until: string
}
```
### Zod schema (`lib/schemas.ts`)
```ts
const weekdaySchema = z.enum(['monday','tuesday','wednesday','thursday','friday','saturday','sunday'])
// replace blocks: z.array(blockSchema) with:
day_blocks: z.record(weekdaySchema, z.array(blockSchema)).default(
() => Object.fromEntries(WEEKDAYS.map(d => [d, []])) as Record<Weekday, ProgrammingBlock[]>
)
// A missing day key is valid (treated as empty). The default initializes all days to [].
```
### Channel export (`lib/channel-export.ts`)
Export format after V2: `day_blocks` map as-is (no flattening). The export JSON shape mirrors `ScheduleConfig` directly. Re-import reads via the same `ScheduleConfigCompat` deserialization path, so V1 exports remain importable indefinitely.
### New hooks (`hooks/use-channels.ts`)
- `useConfigHistory(channelId)`
- `useRestoreConfig()`
- `usePinSnapshot()`
- `useScheduleHistory(channelId)`
- `useScheduleGeneration(channelId, genId)` (lazy, for detail view)
- `useRollbackSchedule()`
### Files changed (frontend)
- `lib/types.ts`
- `lib/schemas.ts`
- `lib/channel-export.ts`
- `hooks/use-channels.ts`
- `dashboard/components/edit-channel-sheet.tsx`
- `dashboard/components/config-history-sheet.tsx` (new)
- `dashboard/components/schedule-history-dialog.tsx` (new)
- `app/(main)/dashboard/page.tsx` — wire new dialog triggers
---
## Verification
| Scenario | Expected |
|---|---|
| Load channel with old `{blocks:[...]}` config | Blocks appear on all 7 day tabs |
| `PUT /channels/:id` on old-format channel | Config saved as V2 `{day_blocks:{...}}`; snapshot v1 created |
| Channel with Mon+Sat blocks only → generate | Slots only on Mondays and Saturdays in 7-day window |
| Day with empty block list | No slots that day, no error |
| `PUT /channels/:id` twice | `GET /config/history` returns 2 entries with incrementing version_num |
| Pin snapshot | Label persists in history list |
| Restore snapshot | Config reverts; new snapshot created at top of history |
| `GET /schedule/history/:bad_id` | 404 |
| Generate 3 schedules → rollback to gen#1 | gen#2+3 deleted (schedules + playback_records); new generation resumes from gen#1 continuity |
| Sequential block at S4E2 → rollback → regenerate | New schedule starts at correct episode |
| Payload with both `blocks` and `day_blocks` keys | `day_blocks` used, `blocks` silently ignored |
| V1 export file re-imported after V2 deploy | Deserializes correctly via compat path |