screentinker/shared/schedule-vectors.json
ScreenTinker 2ccf3264a9
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
feat(scheduling): per-item schedule blocks (#74 dayparting, #75 auto-expire)
Each playlist item can carry schedule blocks (active days, start/end
time-of-day, optional start/end dates). An item plays when the screen's
local "now" matches at least one block; an item with no blocks always
plays. #74 covers time-of-day/day-of-week windows including overnight
wrap; #75 covers inclusive date ranges (auto-expiry). Evaluation is
on-device, so dayparting and expiry work offline.

- Shared evaluator contract: shared/schedule-vectors.json (39 vectors —
  DST US+AU, overnight-wrap anchoring, timezone correctness, date
  boundaries). Canonical JS evaluator in server/lib/schedule-eval.js;
  Kotlin and Tizen ports kept in lockstep by drift guards (Tizen byte-diff
  test, Kotlin JUnit reads the shared JSON, new android-test CI job).
- All three players (web, Android, Tizen) filter by schedule against their
  own clock, idle with a "Nothing scheduled" message + 30s re-check when
  everything is filtered, and fail open on any evaluator error.
- Editor: per-item schedule modal + row badge in the playlist editor;
  client validation mirrors the server; editing marks the playlist draft.
- Part B (behaviour change): device/group schedule overrides now evaluate
  in each device's effective timezone instead of server-local time.
- Device detail shows the reported timezone + a clock-skew warning.
- i18n for en/es/fr/de/pt across all new strings (namespaced itemsched.*
  to avoid colliding with the device-schedule calendar's schedule.*).
- CHANGELOG documents the feature, the Part B change, the fail-open
  guarantee, and the scheduled-single-video re-render tradeoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:46:41 -05:00

53 lines
12 KiB
JSON

{
"_about": "Conformance vectors for per-playlist-item schedule evaluation. The contract: every implementation (JS server/web, Kotlin, Tizen JS) must agree with every vector. If an implementation disagrees, the implementation is wrong.",
"_conventions": {
"days": "active days as a set of integers 0-6, where 0=Sunday .. 6=Saturday (matches JS Date.getDay()).",
"time": "start/end are local wall-clock HH:MM (24h). A window is [start, end): start inclusive, end exclusive (so adjacent dayparts 07:00-11:00 and 11:00-14:00 do not overlap at 11:00). end may be \"24:00\" meaning end-of-day (whole day when paired with 00:00).",
"overnight": "if start > end the window crosses midnight: active [start,24:00) on the start day and [00:00,end) on the next day. The day-of-week AND date-range tests apply to the day the window STARTED (a Fri 22:00-02:00 block is active Sat 01:00 because it anchors to Friday).",
"dates": "start_date/end_date are local YYYY-MM-DD, inclusive on both ends, evaluated against the device-LOCAL calendar date. null start_date = no lower bound; null end_date = no upper bound.",
"block_match": "within a block, day AND date AND time must all pass. blocks OR together. >=1 matching block = active. zero blocks = always active.",
"evaluation": "take utc_now, convert to device-local via the IANA timezone, then test the wall-clock block(s)."
},
"vectors": [
{ "description": "plain daytime window, inside (Syd Fri 08:00 in Mon-Fri 07:00-11:00)", "utc_now": "2026-06-11T22:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [1,2,3,4,5], "start": "07:00", "end": "11:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "plain daytime window, outside after end (Syd Thu 12:00)", "utc_now": "2026-06-11T02:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [1,2,3,4,5], "start": "07:00", "end": "11:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "time start is INCLUSIVE (Syd Fri 07:00 exactly)", "utc_now": "2026-06-11T21:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [1,2,3,4,5], "start": "07:00", "end": "11:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "time end is EXCLUSIVE (Syd Fri 11:00 exactly -> out)", "utc_now": "2026-06-12T01:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [1,2,3,4,5], "start": "07:00", "end": "11:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "overnight wrap, before midnight on the start day (Syd Fri 22:30, Fri 22:00-02:00)", "utc_now": "2026-06-12T12:30:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [5], "start": "22:00", "end": "02:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "overnight wrap, after midnight anchored to start day (Syd Sat 01:30 belongs to Fri block)", "utc_now": "2026-06-12T15:30:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [5], "start": "22:00", "end": "02:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "overnight wrap, after midnight but anchor day (yesterday=Sat) not active (Syd Sun 01:30, block Fri)", "utc_now": "2026-06-13T15:30:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [5], "start": "22:00", "end": "02:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "overnight wrap, end is exclusive at 02:00 (Syd Sat 02:00 exactly -> out)", "utc_now": "2026-06-12T16:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [5], "start": "22:00", "end": "02:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "overnight wrap, before midnight on a non-active day (Syd Thu 22:30, block Fri)", "utc_now": "2026-06-11T12:30:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [5], "start": "22:00", "end": "02:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "date range: day before start_date (Syd local 06-11, range 06-12..06-14)", "utc_now": "2026-06-11T02:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "00:00", "end": "24:00", "start_date": "2026-06-12", "end_date": "2026-06-14" }], "expected": false },
{ "description": "date range: on start_date inclusive (Syd local 06-12)", "utc_now": "2026-06-12T02:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "00:00", "end": "24:00", "start_date": "2026-06-12", "end_date": "2026-06-14" }], "expected": true },
{ "description": "date range: on end_date inclusive (Syd local 06-14)", "utc_now": "2026-06-14T02:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "00:00", "end": "24:00", "start_date": "2026-06-12", "end_date": "2026-06-14" }], "expected": true },
{ "description": "date range: day after end_date (Syd local 06-15)", "utc_now": "2026-06-15T02:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "00:00", "end": "24:00", "start_date": "2026-06-12", "end_date": "2026-06-14" }], "expected": false },
{ "description": "null start_date = no lower bound (Syd local 2026-01-01, end 06-14)", "utc_now": "2026-01-01T01:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "00:00", "end": "24:00", "start_date": null, "end_date": "2026-06-14" }], "expected": true },
{ "description": "null end_date = no upper bound (Syd local 2026-12-31, start 06-12)", "utc_now": "2026-12-31T01:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "00:00", "end": "24:00", "start_date": "2026-06-12", "end_date": null }], "expected": true },
{ "description": "both dates null = unbounded (always, given day/time ok)", "utc_now": "2026-06-11T02:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "00:00", "end": "24:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "date in range but wrong weekday (Syd Fri 06-12, block Mon only)", "utc_now": "2026-06-12T02:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [1], "start": "00:00", "end": "24:00", "start_date": "2026-06-12", "end_date": "2026-06-14" }], "expected": false },
{ "description": "right weekday but date out of range (Syd Fri 06-19, block Fri but range 06-12..06-12)", "utc_now": "2026-06-19T02:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [5], "start": "00:00", "end": "24:00", "start_date": "2026-06-12", "end_date": "2026-06-12" }], "expected": false },
{ "description": "multiple blocks OR: second block matches (Syd Sat 10:00)", "utc_now": "2026-06-13T00:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [1,2,3,4,5], "start": "07:00", "end": "11:00", "start_date": null, "end_date": null }, { "days": [0,6], "start": "09:00", "end": "13:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "multiple blocks OR: none match (Syd Sat 14:00)", "utc_now": "2026-06-13T04:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [1,2,3,4,5], "start": "07:00", "end": "11:00", "start_date": null, "end_date": null }, { "days": [0,6], "start": "09:00", "end": "13:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "zero blocks = always on", "utc_now": "2026-06-11T02:00:00Z", "timezone": "Australia/Sydney", "blocks": [], "expected": true },
{ "description": "tz correctness: same UTC, Sydney -> 09:00 in window", "utc_now": "2026-06-11T23:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "09:00", "end": "17:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "tz correctness: same UTC, Berlin -> 01:00 out of window", "utc_now": "2026-06-11T23:00:00Z", "timezone": "Europe/Berlin", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "09:00", "end": "17:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "tz correctness: same UTC, Chicago -> 18:00 out of window", "utc_now": "2026-06-11T23:00:00Z", "timezone": "America/Chicago", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "09:00", "end": "17:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "tz correctness: different UTC, Chicago -> 11:00 in window", "utc_now": "2026-06-11T16:00:00Z", "timezone": "America/Chicago", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "09:00", "end": "17:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "tz correctness: different UTC, Berlin -> 18:00 out of window", "utc_now": "2026-06-11T16:00:00Z", "timezone": "Europe/Berlin", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "09:00", "end": "17:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "tz correctness: different UTC, Sydney -> 02:00 out of window", "utc_now": "2026-06-11T16:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "09:00", "end": "17:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "DST US spring-forward: after the gap, local 03:00 CDT in 03:00-04:00", "utc_now": "2026-03-08T08:00:00Z", "timezone": "America/Chicago", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "03:00", "end": "04:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "DST US spring-forward: before the gap, local 01:30 CST out of 03:00-04:00", "utc_now": "2026-03-08T07:30:00Z", "timezone": "America/Chicago", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "03:00", "end": "04:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "DST US spring-forward: window inside the 02:00-03:00 hole is unreachable (local 03:00)", "utc_now": "2026-03-08T08:00:00Z", "timezone": "America/Chicago", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "02:15", "end": "02:45", "start_date": null, "end_date": null }], "expected": false },
{ "description": "DST US fall-back: first 01:30 (CDT) in 01:00-02:00", "utc_now": "2026-11-01T06:30:00Z", "timezone": "America/Chicago", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "01:00", "end": "02:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "DST US fall-back: repeated 01:30 (CST) still in 01:00-02:00", "utc_now": "2026-11-01T07:30:00Z", "timezone": "America/Chicago", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "01:00", "end": "02:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "DST US fall-back: after the repeated hour, local 02:30 out of 01:00-02:00", "utc_now": "2026-11-01T08:30:00Z", "timezone": "America/Chicago", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "01:00", "end": "02:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "DST AU spring-forward: after the gap, Sydney local 03:30 AEDT in 03:00-04:00", "utc_now": "2026-10-03T16:30:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "03:00", "end": "04:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "DST AU spring-forward: before the gap, Sydney local 01:30 AEST out of 03:00-04:00", "utc_now": "2026-10-03T15:30:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "03:00", "end": "04:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "DST AU fall-back: first 02:30 (AEDT) in 02:00-03:00", "utc_now": "2026-04-04T15:30:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "02:00", "end": "03:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "DST AU fall-back: repeated 02:30 (AEST) still in 02:00-03:00", "utc_now": "2026-04-04T16:30:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "02:00", "end": "03:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "device-local date AFTER utc date (UTC 06-11 15:00 = Syd 06-12 01:00); date range tests local date", "utc_now": "2026-06-11T15:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "00:00", "end": "06:00", "start_date": "2026-06-12", "end_date": "2026-06-12" }], "expected": true },
{ "description": "device-local date BEFORE utc date (UTC 06-12 03:00 = Chicago 06-11 22:00); date range tests local date", "utc_now": "2026-06-12T03:00:00Z", "timezone": "America/Chicago", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "20:00", "end": "23:00", "start_date": "2026-06-11", "end_date": "2026-06-11" }], "expected": true }
]
}