diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/correction-dark-details.png b/apps/web/e2e/__screenshots__/desktop-chromium/correction-dark-details.png new file mode 100644 index 000000000..0e56069c4 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/correction-dark-details.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/correction-dark-lineup.png b/apps/web/e2e/__screenshots__/desktop-chromium/correction-dark-lineup.png new file mode 100644 index 000000000..c409a45d4 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/correction-dark-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/correction-dark-review.png b/apps/web/e2e/__screenshots__/desktop-chromium/correction-dark-review.png new file mode 100644 index 000000000..7a2f0f819 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/correction-dark-review.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/correction-light-details.png b/apps/web/e2e/__screenshots__/desktop-chromium/correction-light-details.png new file mode 100644 index 000000000..8f7013c4f Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/correction-light-details.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/correction-light-lineup.png b/apps/web/e2e/__screenshots__/desktop-chromium/correction-light-lineup.png new file mode 100644 index 000000000..ae4f19301 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/correction-light-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/correction-light-review.png b/apps/web/e2e/__screenshots__/desktop-chromium/correction-light-review.png new file mode 100644 index 000000000..7be8d61f8 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/correction-light-review.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/create-dark-details.png b/apps/web/e2e/__screenshots__/desktop-chromium/create-dark-details.png new file mode 100644 index 000000000..7055467de Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/create-dark-details.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/create-dark-lineup.png b/apps/web/e2e/__screenshots__/desktop-chromium/create-dark-lineup.png new file mode 100644 index 000000000..d6106b736 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/create-dark-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/create-dark-review.png b/apps/web/e2e/__screenshots__/desktop-chromium/create-dark-review.png new file mode 100644 index 000000000..458a94a27 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/create-dark-review.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/create-light-details.png b/apps/web/e2e/__screenshots__/desktop-chromium/create-light-details.png new file mode 100644 index 000000000..11e47782d Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/create-light-details.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/create-light-lineup.png b/apps/web/e2e/__screenshots__/desktop-chromium/create-light-lineup.png new file mode 100644 index 000000000..2dc8ff91a Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/create-light-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/create-light-review.png b/apps/web/e2e/__screenshots__/desktop-chromium/create-light-review.png new file mode 100644 index 000000000..56307ee4d Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/create-light-review.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/dst-gap.png b/apps/web/e2e/__screenshots__/desktop-chromium/dst-gap.png new file mode 100644 index 000000000..c40e61c80 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/dst-gap.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/dst-repeat-and-end.png b/apps/web/e2e/__screenshots__/desktop-chromium/dst-repeat-and-end.png new file mode 100644 index 000000000..43d94ced9 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/dst-repeat-and-end.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-create-details.png b/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-create-details.png index 772eb5277..084d970ae 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-create-details.png and b/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-create-details.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-create-lineup.png b/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-create-lineup.png index 5455a14fe..7485bbde1 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-create-lineup.png and b/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-create-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-create-review.png b/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-create-review.png index 90ab01ac3..9eaf8d296 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-create-review.png and b/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-create-review.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-edit-details.png b/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-edit-details.png index 5566c6307..1f112615e 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-edit-details.png and b/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-edit-details.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-edit-lineup.png b/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-edit-lineup.png index b774e9629..21ca6a0ab 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-edit-lineup.png and b/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-edit-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-edit-review.png b/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-edit-review.png index aa361ba27..1df8a3a62 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-edit-review.png and b/apps/web/e2e/__screenshots__/desktop-chromium/event-editor-edit-review.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/event-lineup-editor.png b/apps/web/e2e/__screenshots__/desktop-chromium/event-lineup-editor.png index e43952f3f..79f89b397 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/event-lineup-editor.png and b/apps/web/e2e/__screenshots__/desktop-chromium/event-lineup-editor.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/import-error.png b/apps/web/e2e/__screenshots__/desktop-chromium/import-error.png new file mode 100644 index 000000000..30010239b Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/import-error.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/import-open.png b/apps/web/e2e/__screenshots__/desktop-chromium/import-open.png new file mode 100644 index 000000000..c868d1b14 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/import-open.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/resumed-dark-details.png b/apps/web/e2e/__screenshots__/desktop-chromium/resumed-dark-details.png new file mode 100644 index 000000000..1522e48ef Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/resumed-dark-details.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/resumed-dark-lineup.png b/apps/web/e2e/__screenshots__/desktop-chromium/resumed-dark-lineup.png new file mode 100644 index 000000000..5479f6f15 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/resumed-dark-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/resumed-dark-review.png b/apps/web/e2e/__screenshots__/desktop-chromium/resumed-dark-review.png new file mode 100644 index 000000000..f42d0aac5 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/resumed-dark-review.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/resumed-light-details.png b/apps/web/e2e/__screenshots__/desktop-chromium/resumed-light-details.png new file mode 100644 index 000000000..0c0137c6c Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/resumed-light-details.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/resumed-light-lineup.png b/apps/web/e2e/__screenshots__/desktop-chromium/resumed-light-lineup.png new file mode 100644 index 000000000..31cb3a0ec Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/resumed-light-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/resumed-light-review.png b/apps/web/e2e/__screenshots__/desktop-chromium/resumed-light-review.png new file mode 100644 index 000000000..f4fbc7ef2 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/resumed-light-review.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-insertion.png b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-insertion.png new file mode 100644 index 000000000..ec26845c6 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-insertion.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-lineup-conflict.png b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-lineup-conflict.png new file mode 100644 index 000000000..71a77a448 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-lineup-conflict.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-reset.png b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-reset.png new file mode 100644 index 000000000..7820bd79e Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-reset.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-review-conflict.png b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-review-conflict.png new file mode 100644 index 000000000..8ca0e2e24 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-review-conflict.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-review.png b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-review.png new file mode 100644 index 000000000..c94c614bc Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-review.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-setup.png b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-setup.png new file mode 100644 index 000000000..3a1898d15 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-setup.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-timezone.png b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-timezone.png new file mode 100644 index 000000000..f7146e3c5 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-dark-timezone.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-insertion.png b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-insertion.png new file mode 100644 index 000000000..83df6e441 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-insertion.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-lineup-conflict.png b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-lineup-conflict.png new file mode 100644 index 000000000..7821283d3 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-lineup-conflict.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-reset.png b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-reset.png new file mode 100644 index 000000000..887639c0b Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-reset.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-review-conflict.png b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-review-conflict.png new file mode 100644 index 000000000..c1d2bfc26 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-review-conflict.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-review.png b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-review.png new file mode 100644 index 000000000..03977a314 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-review.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-setup.png b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-setup.png new file mode 100644 index 000000000..43c963a88 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-setup.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-timezone.png b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-timezone.png new file mode 100644 index 000000000..7f0052ca7 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/schedule-light-timezone.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/staff-create-dark-details.png b/apps/web/e2e/__screenshots__/desktop-chromium/staff-create-dark-details.png new file mode 100644 index 000000000..bd08f38b4 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/staff-create-dark-details.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/staff-create-dark-lineup.png b/apps/web/e2e/__screenshots__/desktop-chromium/staff-create-dark-lineup.png new file mode 100644 index 000000000..ab6625ad8 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/staff-create-dark-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/staff-create-dark-review.png b/apps/web/e2e/__screenshots__/desktop-chromium/staff-create-dark-review.png new file mode 100644 index 000000000..5c42314c4 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/staff-create-dark-review.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/staff-create-light-details.png b/apps/web/e2e/__screenshots__/desktop-chromium/staff-create-light-details.png new file mode 100644 index 000000000..084d970ae Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/staff-create-light-details.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/staff-create-light-lineup.png b/apps/web/e2e/__screenshots__/desktop-chromium/staff-create-light-lineup.png new file mode 100644 index 000000000..7485bbde1 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/staff-create-light-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/staff-create-light-review.png b/apps/web/e2e/__screenshots__/desktop-chromium/staff-create-light-review.png new file mode 100644 index 000000000..9eaf8d296 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/staff-create-light-review.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/staff-edit-dark-details.png b/apps/web/e2e/__screenshots__/desktop-chromium/staff-edit-dark-details.png new file mode 100644 index 000000000..1f112615e Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/staff-edit-dark-details.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/staff-edit-dark-lineup.png b/apps/web/e2e/__screenshots__/desktop-chromium/staff-edit-dark-lineup.png new file mode 100644 index 000000000..21ca6a0ab Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/staff-edit-dark-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/staff-edit-dark-review.png b/apps/web/e2e/__screenshots__/desktop-chromium/staff-edit-dark-review.png new file mode 100644 index 000000000..1df8a3a62 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/staff-edit-dark-review.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/staff-edit-light-details.png b/apps/web/e2e/__screenshots__/desktop-chromium/staff-edit-light-details.png new file mode 100644 index 000000000..91e190b8c Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/staff-edit-light-details.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/staff-edit-light-lineup.png b/apps/web/e2e/__screenshots__/desktop-chromium/staff-edit-light-lineup.png new file mode 100644 index 000000000..f22c6feec Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/staff-edit-light-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/staff-edit-light-review.png b/apps/web/e2e/__screenshots__/desktop-chromium/staff-edit-light-review.png new file mode 100644 index 000000000..5d980360f Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/staff-edit-light-review.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/correction-dark-details.png b/apps/web/e2e/__screenshots__/mobile-chromium/correction-dark-details.png new file mode 100644 index 000000000..e1f43d665 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/correction-dark-details.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/correction-dark-lineup.png b/apps/web/e2e/__screenshots__/mobile-chromium/correction-dark-lineup.png new file mode 100644 index 000000000..57f217ffc Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/correction-dark-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/correction-dark-review.png b/apps/web/e2e/__screenshots__/mobile-chromium/correction-dark-review.png new file mode 100644 index 000000000..b3fdc6d36 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/correction-dark-review.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/correction-light-details.png b/apps/web/e2e/__screenshots__/mobile-chromium/correction-light-details.png new file mode 100644 index 000000000..7101ed662 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/correction-light-details.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/correction-light-lineup.png b/apps/web/e2e/__screenshots__/mobile-chromium/correction-light-lineup.png new file mode 100644 index 000000000..4a2bae2f6 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/correction-light-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/correction-light-review.png b/apps/web/e2e/__screenshots__/mobile-chromium/correction-light-review.png new file mode 100644 index 000000000..683694d64 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/correction-light-review.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/create-dark-details.png b/apps/web/e2e/__screenshots__/mobile-chromium/create-dark-details.png new file mode 100644 index 000000000..311f1665c Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/create-dark-details.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/create-dark-lineup.png b/apps/web/e2e/__screenshots__/mobile-chromium/create-dark-lineup.png new file mode 100644 index 000000000..7d4ff0496 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/create-dark-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/create-dark-review.png b/apps/web/e2e/__screenshots__/mobile-chromium/create-dark-review.png new file mode 100644 index 000000000..97aeb1d87 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/create-dark-review.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/create-light-details.png b/apps/web/e2e/__screenshots__/mobile-chromium/create-light-details.png new file mode 100644 index 000000000..3dfb03f39 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/create-light-details.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/create-light-lineup.png b/apps/web/e2e/__screenshots__/mobile-chromium/create-light-lineup.png new file mode 100644 index 000000000..872ae42b3 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/create-light-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/create-light-review.png b/apps/web/e2e/__screenshots__/mobile-chromium/create-light-review.png new file mode 100644 index 000000000..a9e93ef70 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/create-light-review.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/dst-gap.png b/apps/web/e2e/__screenshots__/mobile-chromium/dst-gap.png new file mode 100644 index 000000000..102b06d4e Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/dst-gap.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/dst-repeat-and-end.png b/apps/web/e2e/__screenshots__/mobile-chromium/dst-repeat-and-end.png new file mode 100644 index 000000000..1d138d1ba Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/dst-repeat-and-end.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-create-details.png b/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-create-details.png index a74c8717d..e3a176ce0 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-create-details.png and b/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-create-details.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-create-lineup.png b/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-create-lineup.png index 5a2f78909..c6be99b7a 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-create-lineup.png and b/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-create-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-create-review.png b/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-create-review.png index d9ab9fcf2..7f30ee1a4 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-create-review.png and b/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-create-review.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-edit-details.png b/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-edit-details.png index bb7a3cb9a..46b7fecc1 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-edit-details.png and b/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-edit-details.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-edit-lineup.png b/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-edit-lineup.png index 185e18d3b..94bdeb989 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-edit-lineup.png and b/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-edit-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-edit-review.png b/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-edit-review.png index cb63abae2..5c49a0326 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-edit-review.png and b/apps/web/e2e/__screenshots__/mobile-chromium/event-editor-edit-review.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/event-lineup-editor.png b/apps/web/e2e/__screenshots__/mobile-chromium/event-lineup-editor.png index 08e1db0bb..fd81e7db4 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/event-lineup-editor.png and b/apps/web/e2e/__screenshots__/mobile-chromium/event-lineup-editor.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/import-error.png b/apps/web/e2e/__screenshots__/mobile-chromium/import-error.png new file mode 100644 index 000000000..9360eec50 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/import-error.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/import-open.png b/apps/web/e2e/__screenshots__/mobile-chromium/import-open.png new file mode 100644 index 000000000..bbc7e2a07 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/import-open.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/resumed-dark-details.png b/apps/web/e2e/__screenshots__/mobile-chromium/resumed-dark-details.png new file mode 100644 index 000000000..58a382719 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/resumed-dark-details.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/resumed-dark-lineup.png b/apps/web/e2e/__screenshots__/mobile-chromium/resumed-dark-lineup.png new file mode 100644 index 000000000..beb6ea8e5 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/resumed-dark-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/resumed-dark-review.png b/apps/web/e2e/__screenshots__/mobile-chromium/resumed-dark-review.png new file mode 100644 index 000000000..68301d3f2 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/resumed-dark-review.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/resumed-light-details.png b/apps/web/e2e/__screenshots__/mobile-chromium/resumed-light-details.png new file mode 100644 index 000000000..22c19acc1 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/resumed-light-details.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/resumed-light-lineup.png b/apps/web/e2e/__screenshots__/mobile-chromium/resumed-light-lineup.png new file mode 100644 index 000000000..9e46b30b6 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/resumed-light-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/resumed-light-review.png b/apps/web/e2e/__screenshots__/mobile-chromium/resumed-light-review.png new file mode 100644 index 000000000..1558b5c35 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/resumed-light-review.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-insertion.png b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-insertion.png new file mode 100644 index 000000000..a9435032c Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-insertion.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-lineup-conflict.png b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-lineup-conflict.png new file mode 100644 index 000000000..012f21b1b Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-lineup-conflict.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-reset.png b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-reset.png new file mode 100644 index 000000000..3fed51c22 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-reset.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-review-conflict.png b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-review-conflict.png new file mode 100644 index 000000000..e01c12f0c Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-review-conflict.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-review.png b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-review.png new file mode 100644 index 000000000..d042d41f0 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-review.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-setup.png b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-setup.png new file mode 100644 index 000000000..1fb2a9d06 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-setup.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-timezone.png b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-timezone.png new file mode 100644 index 000000000..4fc3c20cb Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-dark-timezone.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-insertion.png b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-insertion.png new file mode 100644 index 000000000..c6a557545 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-insertion.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-lineup-conflict.png b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-lineup-conflict.png new file mode 100644 index 000000000..2529e85ec Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-lineup-conflict.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-reset.png b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-reset.png new file mode 100644 index 000000000..92b5c99c0 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-reset.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-review-conflict.png b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-review-conflict.png new file mode 100644 index 000000000..7fdc49d69 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-review-conflict.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-review.png b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-review.png new file mode 100644 index 000000000..a53668aff Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-review.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-setup.png b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-setup.png new file mode 100644 index 000000000..44ea4888c Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-setup.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-timezone.png b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-timezone.png new file mode 100644 index 000000000..58b5e4acb Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/schedule-light-timezone.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/staff-create-dark-details.png b/apps/web/e2e/__screenshots__/mobile-chromium/staff-create-dark-details.png new file mode 100644 index 000000000..131b28dae Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/staff-create-dark-details.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/staff-create-dark-lineup.png b/apps/web/e2e/__screenshots__/mobile-chromium/staff-create-dark-lineup.png new file mode 100644 index 000000000..0aeda1688 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/staff-create-dark-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/staff-create-dark-review.png b/apps/web/e2e/__screenshots__/mobile-chromium/staff-create-dark-review.png new file mode 100644 index 000000000..a0a7e6e97 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/staff-create-dark-review.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/staff-create-light-details.png b/apps/web/e2e/__screenshots__/mobile-chromium/staff-create-light-details.png new file mode 100644 index 000000000..e3a176ce0 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/staff-create-light-details.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/staff-create-light-lineup.png b/apps/web/e2e/__screenshots__/mobile-chromium/staff-create-light-lineup.png new file mode 100644 index 000000000..c6be99b7a Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/staff-create-light-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/staff-create-light-review.png b/apps/web/e2e/__screenshots__/mobile-chromium/staff-create-light-review.png new file mode 100644 index 000000000..7f30ee1a4 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/staff-create-light-review.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/staff-edit-dark-details.png b/apps/web/e2e/__screenshots__/mobile-chromium/staff-edit-dark-details.png new file mode 100644 index 000000000..46b7fecc1 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/staff-edit-dark-details.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/staff-edit-dark-lineup.png b/apps/web/e2e/__screenshots__/mobile-chromium/staff-edit-dark-lineup.png new file mode 100644 index 000000000..94bdeb989 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/staff-edit-dark-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/staff-edit-dark-review.png b/apps/web/e2e/__screenshots__/mobile-chromium/staff-edit-dark-review.png new file mode 100644 index 000000000..5c49a0326 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/staff-edit-dark-review.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/staff-edit-light-details.png b/apps/web/e2e/__screenshots__/mobile-chromium/staff-edit-light-details.png new file mode 100644 index 000000000..77698100f Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/staff-edit-light-details.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/staff-edit-light-lineup.png b/apps/web/e2e/__screenshots__/mobile-chromium/staff-edit-light-lineup.png new file mode 100644 index 000000000..df0d40b73 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/staff-edit-light-lineup.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/staff-edit-light-review.png b/apps/web/e2e/__screenshots__/mobile-chromium/staff-edit-light-review.png new file mode 100644 index 000000000..2fc7aad66 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/staff-edit-light-review.png differ diff --git a/apps/web/e2e/event-contribution.flow.spec.ts b/apps/web/e2e/event-contribution.flow.spec.ts index 82e9a158c..d950f482b 100644 --- a/apps/web/e2e/event-contribution.flow.spec.ts +++ b/apps/web/e2e/event-contribution.flow.spec.ts @@ -8,8 +8,9 @@ for (const mode of ["draft", "correction"] as const) { await expect(navigation.getByRole("button")).toHaveText(["Details", "Lineup", "Review"]); if (mode === "correction") await expect(page.getByRole("button", { name: "Import poster or text", exact: true })).toHaveCount(0); await page.getByLabel("Time TBA", { exact: true }).uncheck(); + await page.getByRole("button", { name: "Time zone", exact: true }).click(); await page.getByRole("combobox", { name: "Time zone", exact: true }).fill("UTC"); - await page.getByRole("option", { name: /^UTC / }).click(); + await page.getByRole("option", { name: /^UTC(?: |$)/ }).click(); await page.getByLabel("Starts", { exact: true }).fill("2027-07-15T23:00"); await page.getByText("Add end time", { exact: true }).click(); const endInput = page.getByLabel("Ends (optional)", { exact: true }); @@ -57,6 +58,7 @@ for (const mode of ["draft", "correction"] as const) { const navigation = page.getByRole("navigation", { name: "Event editor" }); await navigation.getByRole("button", { name: "Details", exact: true }).click(); await page.getByLabel("Time TBA", { exact: true }).uncheck(); + await page.getByRole("button", { name: "Time zone", exact: true }).click(); await page.getByRole("combobox", { name: "Time zone", exact: true }).fill("America/New_York"); await page.getByRole("option").first().click(); await page.getByLabel("Starts", { exact: true }).fill("2026-11-01T01:15"); @@ -162,14 +164,17 @@ test("owner timezone keyboard search stores a region, not EST @flow @fixture", a await page.goto("/playwright/event-editor"); await page.getByRole("navigation", { name: "Event editor" }).getByRole("button", { name: "Details", exact: true }).click(); await page.getByLabel("Starts", { exact: true }).fill("2026-07-15T20:00"); + await page.getByRole("button", { name: "Time zone", exact: true }).click(); const timezone = page.getByRole("combobox", { name: "Time zone" }); await timezone.fill("EST"); - await expect(page.getByRole("option", { name: /New York.*UTC-04:00/ })).toBeVisible(); + await expect(page.getByRole("option", { name: /Eastern.*UTC-04:00/ })).toBeVisible(); await timezone.press("ArrowDown"); await timezone.press("Enter"); await expect(page.locator('input[name="timezone"]')).toHaveValue("America/New_York"); + await page.getByRole("button", { name: "Time zone", exact: true }).click(); await timezone.fill("not a zone"); - await expect(page.locator('input[name="timezone"]')).toHaveValue(""); + await expect(page.locator('input[name="timezone"]')).toHaveValue("America/New_York"); + await timezone.press("Escape"); }); test("direct intake auth preserves community and draft @flow @fixture", async ({ page }) => { @@ -211,6 +216,7 @@ test("intake draft resumes, date-only publishes directly, and has no mobile over test("ambiguous start requires occurrence and gap is refused @flow @fixture", async ({ page }) => { await page.goto("/playwright/event-intake"); await page.getByLabel("Event title").fill("Autumn gathering"); + await page.getByRole("button", { name: "Time zone", exact: true }).click(); const timezone = page.getByRole("combobox", { name: "Time zone" }); await timezone.fill("EST"); await timezone.press("ArrowDown"); await timezone.press("Enter"); await page.getByLabel("Starts", { exact: true }).fill("2026-11-01T01:30"); @@ -406,6 +412,7 @@ test("combined controls preserve dates, reversible TBA and overnight times @flow await page.getByLabel("Starts", { exact: true }).fill("2026-10-10T23:00"); await page.getByText("Add end time", { exact: true }).click(); await page.getByLabel("Ends (optional)", { exact: true }).fill("2026-10-11T02:00"); + await page.getByRole("button", { name: "Time zone", exact: true }).click(); await page.getByRole("combobox", { name: "Time zone" }).fill("UTC"); await page.getByRole("combobox", { name: "Time zone" }).press("ArrowDown"); await page.getByRole("combobox", { name: "Time zone" }).press("Enter"); diff --git a/apps/web/e2e/event-editor-end.flow.spec.ts b/apps/web/e2e/event-editor-end.flow.spec.ts index 93b755e42..6a7458047 100644 --- a/apps/web/e2e/event-editor-end.flow.spec.ts +++ b/apps/web/e2e/event-editor-end.flow.spec.ts @@ -81,25 +81,25 @@ test("staff edit applies an occurrence-only end change @fixture", async ({ page expect(payload.endAt).toBe(Date.UTC(2026, 10, 1, 5, 45)); }); -test("staff edit applies an occurrence-only start change @fixture", async ({ page }) => { +test("staff edit applies an occurrence-only start change while keeping the stored end fixed @fixture", async ({ page }) => { await editWithRepeatedEnd(page); await page.getByLabel("Start occurrence").selectOption("later"); - await expect(page.getByLabel("Ends (optional)", { exact: true })).toHaveValue("2026-11-01T02:45"); - await expect(page.getByText("Nov 1, 7:45 AM", { exact: false })).toBeVisible(); + await expect(page.getByLabel("Ends (optional)", { exact: true })).toHaveValue("2026-11-01T01:45"); + await expect(page.getByText("Nov 1, 6:45 AM", { exact: false })).toBeVisible(); const payload = await saveEdit(page); expect(payload.startAt).toBe(Date.UTC(2026, 10, 1, 6, 15)); - expect(payload.endAt).toBe(storedEnd + 60 * 60_000); + expect(payload.endAt).toBe(storedEnd); }); -test("staff edit reflects a shifted end in the earlier repeated hour @fixture", async ({ page }) => { +test("staff edit keeps the later repeated-hour end when the start moves earlier @fixture", async ({ page }) => { await editWithRepeatedEnd(page); await page.getByLabel("Starts", { exact: true }).fill("2026-11-01T00:15"); await expect(page.getByLabel("Ends (optional)", { exact: true })).toHaveValue("2026-11-01T01:45"); - await expect(page.getByLabel("End occurrence")).toHaveValue("earlier"); - await expect(page.getByText("Nov 1, 5:45 AM", { exact: false })).toBeVisible(); + await expect(page.getByLabel("End occurrence")).toHaveValue("later"); + await expect(page.getByText("Nov 1, 6:45 AM", { exact: false })).toBeVisible(); const payload = await saveEdit(page); expect(payload.startAt).toBe(Date.UTC(2026, 10, 1, 4, 15)); - expect(payload.endAt).toBe(Date.UTC(2026, 10, 1, 5, 45)); + expect(payload.endAt).toBe(storedEnd); }); test("staff edit clears its authored end explicitly @fixture", async ({ page }) => { @@ -130,7 +130,6 @@ test("staff create omits an unknown end without slots @fixture", async ({ page } await page.getByRole("navigation", { name: "Event editor" }).getByRole("button", { name: "Details" }).click(); await page.getByLabel("Starts", { exact: true }).fill("2027-10-15T20:00"); await page.getByRole("navigation", { name: "Event editor" }).getByRole("button", { name: "Lineup" }).click(); - await page.getByLabel("Slots", { exact: true }).fill("0"); await page.getByRole("navigation", { name: "Event editor" }).getByRole("button", { name: "Review" }).click(); await page.getByRole("button", { name: "Save draft", exact: true }).click(); await expect.poll(() => page.evaluate(() => localStorage.getItem("event-lineup-fixture-v1-submission"))).not.toBeNull(); diff --git a/apps/web/e2e/event-editor.snapshots.spec.ts b/apps/web/e2e/event-editor.snapshots.spec.ts index 1a8d88b0c..3dcc92f60 100644 --- a/apps/web/e2e/event-editor.snapshots.spec.ts +++ b/apps/web/e2e/event-editor.snapshots.spec.ts @@ -14,9 +14,10 @@ async function setTheme(page: Page, value: "light" | "dark") { } async function capture(page: Page, path: string) { - expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true); await page.evaluate(() => scrollTo(0, 0)); - await page.screenshot({ path, animations: "disabled", caret: "hide", fullPage: true }); + await page.screenshot({ path, animations: "disabled", caret: "hide", fullPage: true, scale: "css" }); + await expect(page).toHaveScreenshot(path.split("/").pop()!.split("\\").pop()!, { animations: "disabled", caret: "hide", fullPage: true, scale: "css", maxDiffPixelRatio: 0.002, threshold: 0.2 }); } for (const editor of [ @@ -36,7 +37,7 @@ for (const editor of [ for (const step of ["Details", "Lineup", "Review"]) { await page.getByRole("navigation", { name: "Event editor" }).getByRole("button", { name: step }).click(); await waitForVisualReady(page); - expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true); await expect(page).toHaveScreenshot(`${editor.name}-${step.toLowerCase()}.png`, { animations: "disabled", caret: "hide", fullPage: true, maxDiffPixelRatio: 0.002, scale: "css", threshold: 0.2, @@ -45,13 +46,13 @@ for (const editor of [ }); } -for (const mode of ["create", "resumed", "correction", "staff-edit"] as const) { +for (const mode of ["create", "resumed", "correction", "staff-create", "staff-edit"] as const) { for (const color of ["light", "dark"] as const) { test(`${mode} ${color} editor evidence @snapshot @fixture`, async ({ page }, info) => { if (mode === "resumed") await page.addInitScript(() => sessionStorage.setItem("fixture-source-draft", JSON.stringify({ version: 1, fields: { communitySlug: "afterglow", title: "Resumed night", eventDate: "2027-10-15", timezone: "UTC", start: { time: "20:00" }, end: { time: "23:00" }, venueLabel: "Harbor" }, }))); - const path = mode === "staff-edit" ? "/playwright/event-editor/edit" + const path = mode === "staff-create" ? "/playwright/event-editor" : mode === "staff-edit" ? "/playwright/event-editor/edit" : mode === "correction" ? "/playwright/event-intake?revision=correction" : mode === "resumed" ? "/playwright/event-intake?source=text" : "/playwright/event-intake?revision=draft"; @@ -88,6 +89,7 @@ test("import dialog, failure, and DST states @snapshot @fixture", async ({ page await capture(page, info.outputPath("import-error.png")); await dialog.getByRole("button", { name: "Close" }).click(); await page.getByLabel("Time TBA", { exact: true }).uncheck(); + await page.getByRole("button", { name: "Time zone", exact: true }).click(); await page.getByRole("combobox", { name: "Time zone" }).fill("America/New_York"); await page.getByRole("option").first().click(); await page.getByLabel("Starts", { exact: true }).fill("2027-03-14T02:30"); @@ -100,3 +102,33 @@ test("import dialog, failure, and DST states @snapshot @fixture", async ({ page await expect(page.getByLabel("Ends (optional) occurrence")).toBeVisible(); await capture(page, info.outputPath("dst-repeat-and-end.png")); }); + +for (const color of ["light", "dark"] as const) test(`scheduling states ${color} @snapshot @fixture`, async ({ page }, info) => { + await page.addInitScript(() => sessionStorage.setItem("event-intake-fixture", JSON.stringify({ communitySlug: "playwright-afterglow-social", title: "Harbor night", eventDate: "2027-10-15", timezone: "UTC", start: { time: "20:00" }, end: { time: "21:00" } }))); + await page.goto("/playwright/event-intake"); await setTheme(page, color); + await expect(page.getByRole("button", { name: "Time zone", exact: true })).toHaveText(/^UTC\s*▾$/); + await page.getByRole("button", { name: "Time zone", exact: true }).click(); + await expect(page.getByRole("option").first()).not.toContainText(" · UTC"); + await expect(page.getByRole("combobox", { name: "Time zone" })).toBeFocused(); + const popoverBackground = await page.getByRole("listbox").evaluate(list => getComputedStyle(list.parentElement!).backgroundColor); + expect(popoverBackground).not.toContain("rgba"); + await capture(page, info.outputPath(`schedule-${color}-timezone.png`)); + await page.getByRole("combobox", { name: "Time zone" }).press("Escape"); + const nav = page.getByRole("navigation", { name: "Event editor" }); + await nav.getByRole("button", { name: "Lineup", exact: true }).click(); + await capture(page, info.outputPath(`schedule-${color}-setup.png`)); + await page.getByRole("button", { name: "Generate", exact: true }).click(); + for (const [index, row] of (await page.locator('[data-lineup-row]').all()).entries()) await row.getByRole("textbox", { name: "Performer", exact: true }).fill(["Aurora", "Neon Harbor", "Lumen", "Closing session"][index]); + await capture(page, info.outputPath(`schedule-${color}-lineup-conflict.png`)); + await page.getByRole("button", { name: "Add slot at position 2", exact: true }).click(); + await page.getByLabel("Slot duration", { exact: true }).fill("15"); + await capture(page, info.outputPath(`schedule-${color}-insertion.png`)); + await page.getByRole("button", { name: "Insert slot", exact: true }).click(); + await page.getByRole("button", { name: "Reset lineup", exact: true }).click(); + await capture(page, info.outputPath(`schedule-${color}-reset.png`)); + await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click(); + await nav.getByRole("button", { name: "Review", exact: true }).click(); + await capture(page, info.outputPath(`schedule-${color}-review-conflict.png`)); + await page.getByRole("button", { name: "Set event end to lineup end", exact: true }).click(); + await capture(page, info.outputPath(`schedule-${color}-review.png`)); +}); diff --git a/apps/web/e2e/event-intake-source.flow.spec.ts b/apps/web/e2e/event-intake-source.flow.spec.ts index 2cb3b8bbb..b346bac20 100644 --- a/apps/web/e2e/event-intake-source.flow.spec.ts +++ b/apps/web/e2e/event-intake-source.flow.spec.ts @@ -198,6 +198,7 @@ test("lineup uses local dates across midnight and matched avatars @flow @fixture await page.getByRole("textbox", { name: "Performer", exact: true }).fill("Aurora"); await page.getByLabel("Person profile", { exact: true }).fill("aurora"); await expect(page.getByRole("img", { name: "Aurora", exact: true }).locator("img")).toHaveAttribute("src", "/test-media/event-poster.png"); + await page.locator('[data-lineup-row]').first().locator("summary").click(); await page.getByLabel("Slot 1 start", { exact: true }).fill("2027-10-16T00:30"); await expect(page.getByText("Day offset", { exact: true })).toHaveCount(0); await page.screenshot({ path: info.outputPath("lineup.png"), fullPage: true, animations: "disabled" }); @@ -237,6 +238,7 @@ test("review routes missing fields back to details and repeated times require oc await page.getByLabel("Time TBA", { exact: true }).check(); await page.getByLabel("Starts", { exact: true }).fill("2027-11-07"); await page.getByLabel("Time TBA", { exact: true }).uncheck(); + await page.getByRole("button", { name: "Time zone", exact: true }).click(); await page.getByRole("combobox", { name: "Time zone", exact: true }).fill("America/New_York"); await page.getByRole("option").first().click(); await page.getByLabel("Starts", { exact: true }).fill("2027-11-07T01:30"); @@ -390,12 +392,14 @@ test("publish focuses invalid lineup time and hidden native field @flow @fixture await page.getByLabel("Time TBA", { exact: true }).check(); await page.getByLabel("Starts", { exact: true }).fill("2027-03-14"); await page.getByLabel("Time TBA", { exact: true }).uncheck(); + await page.getByRole("button", { name: "Time zone", exact: true }).click(); await page.getByRole("combobox", { name: "Time zone", exact: true }).fill("America/New_York"); await page.getByRole("option").first().click(); await page.getByLabel("Starts", { exact: true }).fill("2027-03-14T01:00"); await step(page, "Lineup"); await page.getByRole("button", { name: "Add performer" }).click(); await page.getByRole("textbox", { name: "Performer", exact: true }).fill("Guest DJ"); + await page.locator('[data-lineup-row]').first().locator("summary").click(); await page.getByLabel("Slot 1 start", { exact: true }).fill("2027-03-14T02:30"); await step(page, "Review"); await page.getByRole("button", { name: "Publish event", exact: true }).click(); @@ -529,6 +533,7 @@ test("Time TBA restores only surviving lineup keys and reload discards the backu await page.getByLabel("Time TBA", { exact: true }).check(); await step(page, "Lineup"); await page.getByRole("button", { name: "Remove slot", exact: true }).first().click(); + await page.getByRole("button", { name: "Add slot at position 2", exact: true }).click(); await page.getByRole("button", { name: "Add performer", exact: true }).click(); await page.getByRole("textbox", { name: "Performer", exact: true }).nth(1).fill("New performer"); await step(page, "Details"); @@ -569,6 +574,7 @@ test.describe("event timezone differs from browser timezone", () => { await page.getByLabel("Starts", { exact: true }).fill("2026-11-01T01:45"); await expect(page.getByLabel("Starts occurrence", { exact: true })).toHaveValue(""); await page.getByLabel("Starts occurrence", { exact: true }).selectOption("later"); + await page.getByRole("button", { name: "Time zone", exact: true }).click(); await page.getByRole("combobox", { name: "Time zone", exact: true }).fill("UTC"); await page.getByRole("option", { name: /^UTC / }).click(); await expect(page.getByLabel("Starts occurrence", { exact: true })).toHaveCount(0); @@ -593,10 +599,11 @@ for (const method of ["suggestion", "picker"] as const) { await page.getByLabel("Time TBA", { exact: true }).check(); if (method === "suggestion") await page.getByRole("button", { name: "Accept Time zone", exact: true }).click(); else { + await page.getByRole("button", { name: "Time zone", exact: true }).click(); await page.getByRole("combobox", { name: "Time zone", exact: true }).fill("America/Chicago"); await page.getByRole("option").first().click(); } - await expect(page.getByRole("combobox", { name: "Time zone", exact: true })).toHaveValue("America/Chicago"); + await expect(page.locator('input[name="timezone"]')).toHaveValue("America/Chicago"); await page.getByLabel("Time TBA", { exact: true }).uncheck(); await expect(page.getByLabel("Starts", { exact: true })).toHaveValue("2026-11-01T01:30"); for (const label of ["Starts", "Ends (optional)", "Doors open"]) await expect(page.getByLabel(`${label} occurrence`, { exact: true })).toHaveValue(""); @@ -625,6 +632,8 @@ const saveTimingDraft = async (page: Page) => { await expect(page.getByRole("status")).toHaveText("Draft saved"); }; const extractTimingProposal = async (page: Page, eventDate: string | null = "2026-10-11") => { + await openImport(page); + await page.getByRole("textbox", { name: "Source text", exact: true }).fill("October 11 overnight lineup"); await page.evaluate(eventDate => { const original = window.fetch; window.fetch = async (input, init) => { @@ -636,8 +645,6 @@ const extractTimingProposal = async (page: Page, eventDate: string | null = "202 return original(input, init); }; }, eventDate); - await openImport(page); - await page.getByRole("textbox", { name: "Source text", exact: true }).fill("October 11 overnight lineup"); await page.getByRole("button", { name: "Extract details", exact: true }).click(); await expect(page.getByRole("dialog")).toHaveCount(0); }; diff --git a/apps/web/e2e/event-lineup-editing.flow.spec.ts b/apps/web/e2e/event-lineup-editing.flow.spec.ts new file mode 100644 index 000000000..d6ad85867 --- /dev/null +++ b/apps/web/e2e/event-lineup-editing.flow.spec.ts @@ -0,0 +1,489 @@ +import { expect, test, type Page } from "@playwright/test"; +test.use({ timezoneId: "UTC" }); +const nav = (page: Page, step: string) => page.getByRole("navigation", { name: "Event editor" }).getByRole("button", { name: step, exact: true }).click(); +async function intake(page: Page, extra = {}) { + await page.addInitScript(extra => sessionStorage.setItem("event-intake-fixture", JSON.stringify({ communitySlug: "playwright-afterglow-social", title: "Slot night", eventDate: "2027-10-15", timezone: "UTC", start: { time: "20:00" }, end: { time: "00:00", dayOffset: 1 }, ...extra })), extra); + await page.goto("/playwright/event-intake"); +} +for (const editor of ["staff", "intake"]) { + test(`${editor} generates, inserts, commits duration once and resets @fixture`, async ({ page }, testInfo) => { + if (editor === "staff") { + await page.goto("/playwright/event-lineup?editor&mode=create"); + await nav(page, "Details"); + await page.getByLabel("Starts", { exact: true }).fill("2027-10-15T20:00"); + } else await intake(page); + await nav(page, "Lineup"); + await expect(page.getByLabel("Slots", { exact: true })).toHaveValue("4"); + await expect(page.locator('[data-lineup-row]')).toHaveCount(0); + await page.getByRole("button", { name: "Generate", exact: true }).click(); + await expect(page.locator('[data-lineup-row]')).toHaveCount(4); + await expect(page.getByLabel("Slots", { exact: true })).toHaveCount(0); + for (const [index, row] of (await page.locator('[data-lineup-row]').all()).entries()) { + await row.getByRole(editor === "staff" ? "combobox" : "textbox", { name: editor === "staff" ? "Person" : "Performer", exact: true }).fill(editor === "staff" ? "aurora" : `Guest ${index + 1}`); + } + await page.getByRole("button", { name: "Add slot at position 2", exact: true }).click(); + await page.getByLabel("Slot duration", { exact: true }).fill("15"); + await page.getByRole("button", { name: "Insert slot", exact: true }).click(); + const rows = page.locator('[data-lineup-row]'); + await expect(rows).toHaveCount(5); + await rows.nth(1).getByRole(editor === "staff" ? "combobox" : "textbox", { name: editor === "staff" ? "Person" : "Performer", exact: true }).fill(editor === "staff" ? "lumen" : "Inserted"); + await expect(rows.nth(2)).toContainText("21:15"); + const duration = rows.first().getByLabel("Duration", { exact: true }); + await duration.fill("45"); await duration.press("Enter"); await duration.press("Tab"); + await expect(rows.nth(2)).toContainText("21:00"); + await page.evaluate(() => window.scrollTo(0, 0)); + const layout = await page.evaluate(() => ({ viewport: window.innerWidth, clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, deviceScaleFactor: window.devicePixelRatio })); + expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth); + await testInfo.attach(`${editor}-layout`, { body: JSON.stringify(layout), contentType: "application/json" }); + await page.screenshot({ path: testInfo.outputPath(`${editor}-lineup.png`), fullPage: true, animations: "disabled" }); + await nav(page, "Review"); await page.getByRole("button", { name: "Save draft", exact: true }).click(); + const payload = editor === "staff" ? await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1-submission")!)) : await page.evaluate(() => JSON.parse(sessionStorage.getItem("event-intake-fixture")!)); + if (editor === "staff") { + expect(payload.slotLinks[2].startAt).toBe(Date.UTC(2027,9,15,21)); + expect(payload.slotLinks[1].personSlug).toBe("lumen"); + } else { expect(payload.lineup[2].start.time).toBe("21:00"); expect(payload.lineup[1].performerLabel).toBe("Inserted"); } + await nav(page, "Lineup"); + await rows.nth(1).getByRole("button", { name: "Remove slot", exact: true }).click(); + await expect(rows.nth(1)).toContainText("20:45"); + await page.getByRole("button", { name: "Add slot at position 1", exact: true }).click(); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(rows).toHaveCount(4); + await page.getByRole("button", { name: "Reset lineup", exact: true }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(rows).toHaveCount(4); + await page.getByRole("button", { name: "Reset lineup", exact: true }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Clear lineup", exact: true }).click(); + await expect(rows).toHaveCount(0); + await expect(page.getByLabel("Slots", { exact: true })).toHaveValue("4"); + await nav(page, "Details"); + await expect(page.getByLabel("Starts", { exact: true })).toHaveValue("2027-10-15T20:00"); + }); +} + +test("partial intake resumes, preserves overrun through draft save and resolves only the end @fixture", async ({ page }) => { + await intake(page, { end: { time: "21:00" }, lineup: [{ clientKey: "partial", position: 0, performerLabel: "Guest", start: { time: "22:00" } }] }); + await nav(page, "Lineup"); + await expect(page.getByRole("button", { name: "Generate", exact: true })).toHaveCount(0); + await expect(page.getByText("The lineup ends after the event.", { exact: true }).first()).toBeVisible(); + await expect(page.getByRole("button", { name: "Set event end to lineup end", exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "Save draft", exact: true }).click(); + await page.reload(); await nav(page, "Lineup"); + await expect(page.getByText("The lineup ends after the event.", { exact: true }).first()).toBeVisible(); + await page.locator('[data-lineup-row]').getByText("Details", { exact: true }).click(); + await page.getByLabel("Slot 1 end", { exact: true }).fill("2027-10-15T23:00"); + await page.getByRole("button", { name: "Set event end to lineup end", exact: true }).first().click(); + await page.getByRole("button", { name: "Save draft", exact: true }).click(); + const fields = await page.evaluate(() => JSON.parse(sessionStorage.getItem("event-intake-fixture")!)); + expect(fields.end).toEqual({ time: "23:00", dayOffset: 0 }); + expect(fields.lineup[0].clientKey).toBe("partial"); +}); + +test("Time TBA removal and reset never restore deleted intake rows @fixture", async ({ page }) => { + await intake(page, { lineup: [{ clientKey: "original", position: 0, performerLabel: "Original", start: { time: "20:00" }, end: { time: "21:00" } }] }); + await page.getByLabel("Time TBA", { exact: true }).check(); await nav(page, "Lineup"); + await page.getByRole("button", { name: "Add slot at position 2", exact: true }).click(); + await page.getByRole("button", { name: "Add performer", exact: true }).click(); + await page.locator('[data-lineup-row]').first().getByRole("button", { name: "Remove slot", exact: true }).click(); + await nav(page, "Details"); await page.getByLabel("Time TBA", { exact: true }).uncheck(); await nav(page, "Lineup"); + await expect(page.locator('[data-lineup-row]')).toHaveCount(1); + await expect(page.locator('[data-lineup-row]').getByRole("textbox", { name: "Performer", exact: true })).toHaveValue(""); + await page.getByRole("button", { name: "Reset lineup", exact: true }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Clear lineup", exact: true }).click(); + await nav(page, "Details"); await page.getByLabel("Time TBA", { exact: true }).check(); await page.getByLabel("Time TBA", { exact: true }).uncheck(); await nav(page, "Lineup"); + await expect(page.locator('[data-lineup-row]')).toHaveCount(0); +}); + +async function canonicalStaff(page: Page, partial = false) { + await page.addInitScript(partial => localStorage.setItem("event-lineup-fixture-v1", JSON.stringify({ + id: "precise", slug: "precise", title: "Precise night", usesCanonicalLineup: true, + startAt: Date.UTC(2027,9,15,20,0,10,123), endAt: Date.UTC(2027,9,15,21), timezone: "UTC", scheduleKind: "timed", status: "scheduled", communitySlug: "afterglow", publicationState: "published", + source: { sourceType: "contributor", label: "Afterglow" }, watchSurfaceEnabled: true, watchMode: "performer_sequence", + mediaLinks: [], authoredMediaLinks: [], worlds: [], participants: [], + slots: [{ clientKey: "exact-row", position: 0, startAt: Date.UTC(2027,9,15,20,0,10,123), endAt: partial ? undefined : Date.UTC(2027,9,15,21,0,30,456), displayLabel: "Aurora", roleLabel: "DJ", performer: { slug: "aurora", displayName: "Aurora", trustLabel: "claimed_verified" }, source: { sourceType: "contributor", label: "Original source" }, selectedStreamId: "vrcdn:aurora" }], + lineup: [], preservedParticipantAssociationIds: ["protected-person"], preservedSlotAssociationIds: ["protected-slot"], preservedWorldAssociationIds: ["protected-world"], + })), partial); + await page.goto("/playwright/event-lineup?editor"); +} + +test("canonical staff extends to the exact subminute end and retains assignments and protected snapshots @fixture", async ({ page }) => { + await canonicalStaff(page); await nav(page, "Review"); + await page.getByRole("button", { name: "Save changes", exact: true }).click(); + expect(await page.evaluate(() => localStorage.getItem("event-lineup-fixture-v1-submission"))).toBeNull(); + await expect(page.locator('[data-editor-step="Lineup"] [data-lineup-conflict]')).toBeVisible(); + await expect(page.locator('[data-editor-step="Lineup"] [data-lineup-conflict]')).toContainText("21:00:30.456"); + await page.getByRole("button", { name: "Set event end to lineup end", exact: true }).first().click(); + await nav(page, "Review"); await page.getByRole("button", { name: "Save changes", exact: true }).click(); + await expect.poll(() => page.evaluate(() => localStorage.getItem("event-lineup-fixture-v1-submission"))).not.toBeNull(); + const payload = await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1-submission")!)); + expect(payload.endAt).toBe(Date.UTC(2027,9,15,21,0,30,456)); + expect(payload.lineup).toBeUndefined(); + expect(payload.preservedSlotAssociationIds).toEqual(["protected-slot"]); + expect(payload.preservedParticipantAssociationIds).toEqual(["protected-person"]); +}); + +test("authored staff start edits cascade once and keep explicit end fixed @fixture", async ({ page }) => { + await canonicalStaff(page); + await page.getByLabel("Starts", { exact: true }).fill(""); + await page.getByLabel("Starts", { exact: true }).fill("2027-10-15T20:15"); + await expect(page.getByLabel("Ends (optional)", { exact: true })).toHaveValue("2027-10-15T21:00"); + await nav(page, "Lineup"); + await expect(page.locator('[data-lineup-row]')).toContainText("20:15"); + await page.getByRole("button", { name: "Set event end to lineup end", exact: true }).first().click(); + await nav(page, "Review"); await page.getByRole("button", { name: "Save changes", exact: true }).click(); + const payload = await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1-submission")!)); + expect(payload.lineup[0].startAt).toBe(Date.UTC(2027,9,15,20,15)); + expect(payload.lineup[0].endAt).toBe(Date.UTC(2027,9,15,21,15,20,333)); + expect(payload.lineup[0].clientKey).toBe("exact-row"); + expect(payload.lineup[0].selectedStreamId).toBe("vrcdn:aurora"); +}); + +test("intake start changes shift the lineup once and day-offset overflow retains original rows @fixture", async ({ page }) => { + await intake(page, { lineup: [{ clientKey: "a", position: 0, performerLabel: "A", start: { time: "20:00" }, end: { time: "21:00" } }] }); + await page.getByLabel("Starts", { exact: true }).fill(""); + await page.getByLabel("Starts", { exact: true }).fill("2027-10-15T20:15"); + await nav(page, "Lineup"); await expect(page.locator('[data-lineup-row]')).toContainText("20:15"); + await page.locator('[data-lineup-row]').getByText("Details", { exact: true }).click(); + await page.getByLabel("Slot 1 start", { exact: true }).fill("2027-10-22T23:00"); + await page.getByLabel("Slot 1 end", { exact: true }).fill("2027-10-22T23:45"); + await page.getByRole("button", { name: "Add slot at position 1", exact: true }).click(); + await page.getByLabel("Slot duration", { exact: true }).fill("60"); + await page.getByRole("button", { name: "Insert slot", exact: true }).click(); + await expect(page.locator('[data-lineup-row]')).toHaveCount(1); + await expect(page.getByLabel("Slot 1 start", { exact: true })).toHaveValue("2027-10-22T23:00"); + await expect(page.getByLabel("Slot 1 start", { exact: true })).toBeFocused(); +}); + +test("canonical Time TBA rows can be removed without returning on toggle and reset keeps explicit end @fixture", async ({ page }) => { + await canonicalStaff(page); + await page.getByLabel("Time TBA", { exact: true }).check(); await nav(page, "Lineup"); + await page.getByRole("button", { name: "Add slot at position 2", exact: true }).click(); + await page.getByRole("button", { name: "Add performer", exact: true }).click(); + await page.locator('[data-lineup-row]').first().getByRole("button", { name: "Remove slot", exact: true }).click(); + await nav(page, "Details"); await page.getByLabel("Time TBA", { exact: true }).uncheck(); await nav(page, "Lineup"); + await expect(page.locator('[data-lineup-row]')).toHaveCount(1); + await page.getByRole("button", { name: "Reset lineup", exact: true }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Clear lineup", exact: true }).click(); + await nav(page, "Details"); + await expect(page.getByLabel("Ends (optional)", { exact: true })).toHaveValue("2027-10-15T21:00"); + await expect(page.getByLabel("Event title", { exact: true })).toHaveValue("Precise night"); + await page.getByLabel("Time TBA", { exact: true }).check(); await page.getByLabel("Time TBA", { exact: true }).uncheck(); await nav(page, "Lineup"); + await expect(page.locator('[data-lineup-row]')).toHaveCount(0); +}); + + +test("canonical untimed insertion retains order in preview and submitted lineup @fixture", async ({ page }) => { + await canonicalStaff(page); await nav(page, "Lineup"); + await page.getByRole("button", { name: "Set event end to lineup end", exact: true }).first().click(); + await page.getByRole("button", { name: "Add slot at position 1", exact: true }).click(); + await page.getByRole("button", { name: "Add performer", exact: true }).click(); + await page.locator('[data-lineup-row]').first().getByRole("textbox", { name: "Performer", exact: true }).fill("First guest"); + const names = page.locator('aside').locator('div.border-t > div > p:first-child'); + await expect(names).toHaveText(["First guest", "Aurora"]); + await nav(page, "Review"); await page.getByRole("button", { name: "Save changes", exact: true }).click(); + const payload = await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1-submission")!)); + expect(payload.lineup.map((row: { performerLabel: string }) => row.performerLabel)).toEqual(["First guest", "Aurora"]); + expect(payload.lineup[0].startAt).toBeUndefined(); + expect(payload.lineup[1].clientKey).toBe("exact-row"); +}); + + +for (const insert of [false, true]) test(`legacy deferred save retains surviving keys with insertion=${insert} before a second save @fixture`, async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem("event-lineup-fixture-defer-save", "true"); + localStorage.setItem("event-lineup-fixture-v1", JSON.stringify({ + id: "legacy", slug: "legacy", title: "Legacy night", startAt: Date.UTC(2027,9,15,20), endAt: Date.UTC(2027,9,16,0), timezone: "UTC", scheduleKind: "timed", status: "scheduled", communitySlug: "afterglow", publicationState: "draft", + source: { sourceType: "community", label: "Afterglow" }, mediaLinks: [], authoredMediaLinks: [], worlds: [], participants: [], lineup: [], + slots: ["a", "b"].map((key, position) => ({ playbackKey: `slot-${key}`, position, startAt: Date.UTC(2027,9,15,20 + position), endAt: Date.UTC(2027,9,15,21 + position), displayLabel: key.toUpperCase(), roleLabel: "DJ", source: { sourceType: "community", label: key }, selectedStreamId: `private-${key}` })), + preservedParticipantAssociationIds: [], preservedSlotAssociationIds: ["slot-a", "slot-b"], preservedWorldAssociationIds: [], + })); + }); + await page.goto("/playwright/event-lineup?editor"); await nav(page, "Review"); + await page.getByRole("button", { name: "Save draft", exact: true }).click(); + await expect.poll(() => page.evaluate(() => localStorage.getItem("event-lineup-fixture-v1-submission"))).not.toBeNull(); + await nav(page, "Lineup"); + await page.locator('[data-lineup-row]').first().getByRole("button", { name: "Remove slot", exact: true }).click(); + if (insert) { + await page.getByRole("button", { name: "Add slot at position 1", exact: true }).click(); + await page.getByRole("button", { name: "Insert slot", exact: true }).click(); + } + await page.evaluate(() => { localStorage.removeItem("event-lineup-fixture-defer-save"); window.dispatchEvent(new Event("event-lineup-fixture-release-save")); }); + await nav(page, "Review"); await expect(page.getByRole("button", { name: "Save draft", exact: true })).toBeEnabled(); + await page.getByRole("button", { name: "Save draft", exact: true }).click(); + const payload = await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1-submission")!)); + expect(payload.storedSlotAssociations).toEqual([{ position: insert ? 1 : 0, slotId: "slot-b" }]); + expect(payload.slotLinks[insert ? 1 : 0].displayLabel).toBe("B"); + expect(payload.slotLinks[insert ? 1 : 0].selectedStreamId).toBe("private-b"); +}); + +for (const cascade of [false, true]) test(`advanced offset preserves exact duration after cascade=${cascade} and rejects invalid offset @fixture`, async ({ page }) => { + await canonicalStaff(page); + if (cascade) await page.getByLabel("Starts", { exact: true }).fill("2027-10-15T20:15"); + await nav(page, "Lineup"); + await page.locator('[data-lineup-row] summary').click(); + const offset = page.getByLabel("Slot offset minutes", { exact: true }); + await offset.fill("15"); + await expect(page.getByLabel("Duration", { exact: true })).toHaveValue(String(3_620_333 / 60_000)); + await page.getByRole("button", { name: "Set event end to lineup end", exact: true }).first().click(); + await offset.fill("bad"); + await nav(page, "Review"); await page.getByRole("button", { name: "Save changes", exact: true }).click(); + await expect(offset).toBeFocused(); + expect(await page.evaluate(() => localStorage.getItem("event-lineup-fixture-v1-submission"))).toBeNull(); + await offset.fill("15"); + await nav(page, "Review"); await page.getByRole("button", { name: "Save changes", exact: true }).click(); + await expect.poll(() => page.evaluate(() => localStorage.getItem("event-lineup-fixture-v1-submission"))).not.toBeNull(); + const payload = await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1-submission")!)); + expect(payload.lineup[0].endAt - payload.lineup[0].startAt).toBe(3_620_333); + expect(payload.lineup[0].startAt).toBe(cascade ? Date.UTC(2027,9,15,20,30) : Date.UTC(2027,9,15,20,15,10,123)); + expect(payload.lineup[0].clientKey).toBe("exact-row"); + expect(payload.lineup[0].selectedStreamId).toBe("vrcdn:aurora"); +}); + +test("legacy fixture allocates unique identities across repeated inserted saves @fixture", async ({ page }) => { + await page.addInitScript(() => localStorage.setItem("event-lineup-fixture-v1", JSON.stringify({ + id: "legacy", slug: "legacy", title: "Legacy night", startAt: Date.UTC(2027,9,15,20), endAt: Date.UTC(2027,9,16,0), timezone: "UTC", status: "scheduled", communitySlug: "afterglow", publicationState: "draft", + source: { sourceType: "community", label: "Afterglow" }, mediaLinks: [], authoredMediaLinks: [], worlds: [], participants: [], slots: [], preservedParticipantAssociationIds: [], preservedSlotAssociationIds: [], preservedWorldAssociationIds: [], + }))); + await page.goto("/playwright/event-lineup?editor"); + await nav(page, "Lineup"); await page.getByLabel("Slots", { exact: true }).fill("1"); await page.getByRole("button", { name: "Generate", exact: true }).click(); + const save = async () => { await nav(page, "Review"); await page.getByRole("button", { name: "Save draft", exact: true }).click(); await expect(page.getByRole("button", { name: "Save draft", exact: true })).toBeEnabled(); }; + const insert = async () => { await nav(page, "Lineup"); await page.getByRole("button", { name: "Add slot at position 2", exact: true }).click(); await page.getByRole("button", { name: "Insert slot", exact: true }).click(); }; + await save(); await insert(); await save(); + const firstIds: string[] = await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1")!).slots.map((slot: { playbackKey: string }) => slot.playbackKey)); + await insert(); await save(); + const nextIds: string[] = await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1")!).slots.map((slot: { playbackKey: string }) => slot.playbackKey)); + expect(new Set(nextIds).size).toBe(3); + expect(nextIds[0]).toBe(firstIds[0]); expect(nextIds[2]).toBe(firstIds[1]); + await save(); + expect(await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1-submission")!).storedSlotAssociations)).toEqual(nextIds.map((slotId, position) => ({ slotId, position }))); +}); + + +test("canonical scheduling requests surviving-match preservation on every save @fixture", async ({ page }) => { + await canonicalStaff(page); + page.on("dialog", dialog => dialog.accept()); + await page.getByLabel("Ends (optional)", { exact: true }).fill("2027-10-16T00:00"); + const save = async () => { + await page.evaluate(() => localStorage.removeItem("event-lineup-fixture-v1-submission")); + await nav(page, "Review"); + await page.getByRole("button", { name: /^(Unpublish and save draft|Save draft)$/ }).click(); + await expect(page.getByRole("button", { name: "Save draft", exact: true })).toBeEnabled(); + const payload = await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1-submission")!)); + expect(payload.preserveLineupConfirmation).toBe(true); + expect(payload.lineup.some((row: { clientKey: string; personSlug?: string }) => row.clientKey === "exact-row" && row.personSlug === "aurora")).toBe(true); + }; + await nav(page, "Lineup"); + const duration = page.locator('[data-lineup-row]').first().getByLabel("Duration", { exact: true }); + await duration.fill("45"); await duration.press("Enter"); + await save(); await save(); + await nav(page, "Lineup"); + await page.getByRole("button", { name: "Add slot at position 1", exact: true }).click(); + await page.getByRole("button", { name: "Insert slot", exact: true }).click(); + await save(); + await nav(page, "Lineup"); + await page.locator('[data-lineup-row]').first().getByRole("button", { name: "Remove slot", exact: true }).click(); + await save(); + await nav(page, "Details"); await page.getByLabel("Starts", { exact: true }).fill("2027-10-15T20:15"); + await save(); await save(); +}); + +for (const editor of ["staff", "intake"]) test(`${editor} retains the last start through a timezone change while Starts is empty @fixture`, async ({ page }) => { + if (editor === "staff") await canonicalStaff(page); + else await intake(page, { end: { time: "21:00" }, lineup: [{ clientKey: "a", position: 0, performerLabel: "A", start: { time: "20:00" }, end: { time: "21:00" } }] }); + await page.getByLabel("Starts", { exact: true }).fill(""); + await page.getByRole("button", { name: "Time zone", exact: true }).click(); + await page.getByRole("combobox", { name: "Time zone", exact: true }).fill("Tokyo"); + await page.getByRole("option").first().click(); + await page.getByLabel("Starts", { exact: true }).fill("2027-10-15T20:15"); + await nav(page, "Lineup"); + await expect(page.locator('[data-lineup-row]')).toContainText("20:15"); + await nav(page, "Details"); + await page.getByLabel("Starts", { exact: true }).fill("2027-10-15T20:30"); + await nav(page, "Lineup"); + await expect(page.locator('[data-lineup-row]')).toContainText("20:30"); + await page.getByRole("button", { name: "Set event end to lineup end", exact: true }).first().click(); + await nav(page, "Review"); + await page.getByRole("button", { name: editor === "staff" ? "Save changes" : "Save draft", exact: true }).click(); + if (editor === "staff") { + const payload = await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1-submission")!)); + expect(payload.lineup[0].startAt).toBe(Date.UTC(2027, 9, 15, 11, 30)); + expect(payload.lineup[0].endAt).toBe(Date.UTC(2027, 9, 15, 12, 30, 20, 333)); + } else { + const payload = await page.evaluate(() => JSON.parse(sessionStorage.getItem("event-intake-fixture")!)); + expect(payload.lineup[0].start).toEqual({ time: "20:30", dayOffset: 0 }); + } +}); + +for (const editor of ["staff", "intake"]) test(`${editor} rejected and cancelled durations restore committed timing @fixture`, async ({ page }, testInfo) => { + if (editor === "staff") await canonicalStaff(page); + else await intake(page, { lineup: [{ clientKey: "a", position: 0, performerLabel: "A", start: { time: "20:00" }, end: { time: "21:00" } }] }); + await nav(page, "Lineup"); + const duration = page.locator('[data-lineup-row]').first().getByLabel("Duration", { exact: true }); + const committed = editor === "staff" ? String(3_620_333 / 60_000) : "60"; + for (const invalid of ["45.5", "0", "abc"]) { + await duration.fill(invalid); await duration.press("Enter"); + await expect(duration).toHaveValue(committed); + await duration.fill(invalid); await duration.press("Tab"); + await expect(duration).toHaveValue(committed); + } + await duration.fill("45"); await duration.press("Escape"); await duration.press("Tab"); + await expect(duration).toHaveValue(committed); + await duration.fill("45"); await duration.press("Enter"); await duration.press("Tab"); + await expect(duration).toHaveValue("45"); + await expect(page.locator('[data-lineup-row]').first()).toContainText("20:45"); + await page.screenshot({ path: testInfo.outputPath(`${editor}-rejected-duration.png`), fullPage: true }); +}); + +test("advanced known duration commits one cascade and preserves precise clocks until edited @fixture", async ({ page }, testInfo) => { + await canonicalStaff(page); await nav(page, "Lineup"); + await page.getByRole("button", { name: "Add slot at position 2", exact: true }).click(); + await page.getByRole("button", { name: "Insert slot", exact: true }).click(); + const rows = page.locator('[data-lineup-row]'); + await rows.nth(1).getByRole("combobox", { name: "Person", exact: true }).fill("lumen"); + await rows.first().locator("summary").click(); + const duration = rows.first().getByLabel("Slot duration minutes", { exact: true }); + await duration.focus(); await duration.press("Tab"); + await expect(rows.first().getByLabel("Duration", { exact: true })).toHaveValue(String(3_620_333 / 60_000)); + await duration.fill("45"); + await expect(rows.first().getByLabel("Duration", { exact: true })).toHaveValue(String(3_620_333 / 60_000)); + await expect(rows.nth(1)).toContainText("21:00:30.456"); + await duration.press("Enter"); await duration.press("Tab"); + await expect(rows.nth(1)).toContainText("20:45:10.123"); + await expect(rows.first().getByLabel("Duration", { exact: true })).toHaveValue("45"); + await page.screenshot({ path: testInfo.outputPath("advanced-duration.png"), fullPage: true }); + await page.getByRole("button", { name: "Set event end to lineup end", exact: true }).first().click(); + await nav(page, "Review"); await page.getByRole("button", { name: "Save changes", exact: true }).click(); + const payload = await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1-submission")!)); + expect(payload.lineup[1].startAt).toBe(Date.UTC(2027, 9, 15, 20, 45, 10, 123)); + expect(payload.lineup[0].selectedStreamId).toBe("vrcdn:aurora"); +}); + +test("advanced partial duration establishes an end only on explicit commit @fixture", async ({ page }) => { + await canonicalStaff(page, true); await nav(page, "Lineup"); + const row = page.locator('[data-lineup-row]').first(); + await expect(row.getByLabel("Duration", { exact: true })).toBeDisabled(); + await row.locator("summary").click(); + const duration = row.getByLabel("Slot duration minutes", { exact: true }); + await duration.fill("45"); + await expect(row.getByLabel("Duration", { exact: true })).toBeDisabled(); + await duration.press("Enter"); await duration.press("Tab"); + await expect(row.getByLabel("Duration", { exact: true })).toHaveValue("45"); + await expect(row).toContainText("20:45:10.123"); + await nav(page, "Review"); await page.getByRole("button", { name: "Save changes", exact: true }).click(); + const payload = await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1-submission")!)); + expect(payload.lineup[0].endAt).toBe(Date.UTC(2027, 9, 15, 20, 45, 10, 123)); +}); + +test("unresolved offset recovery preserves exact duration through untouched, rejected and cancelled commits @fixture", async ({ page }) => { + await canonicalStaff(page); await nav(page, "Lineup"); + await page.locator('[data-lineup-row] summary').click(); + const offset = page.getByLabel("Slot offset minutes", { exact: true }); + const duration = page.getByLabel("Slot duration minutes", { exact: true }); + for (const action of ["untouched blur", "untouched Enter", "invalid", "cancelled"]) { + await offset.fill("bad"); + await expect(page.getByLabel("Duration", { exact: true })).toBeDisabled(); + await duration.focus(); + if (action === "invalid") await duration.fill("45.5"); + if (action === "cancelled") { await duration.fill("45"); await duration.press("Escape"); } + if (action !== "untouched blur") await duration.press("Enter"); + await duration.press("Tab"); + await offset.fill("15"); + await expect(page.getByLabel("Duration", { exact: true })).toHaveValue(String(3_620_333 / 60_000)); + } + await page.getByRole("button", { name: "Set event end to lineup end", exact: true }).first().click(); + await nav(page, "Review"); await page.getByRole("button", { name: "Save changes", exact: true }).click(); + const payload = await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1-submission")!)); + expect(payload.lineup[0].startAt).toBe(Date.UTC(2027, 9, 15, 20, 15, 10, 123)); + expect(payload.lineup[0].endAt).toBe(Date.UTC(2027, 9, 15, 21, 15, 30, 456)); +}); + +for (const minutes of [45, 60]) test(`unresolved offset accepts an explicit ${minutes}-minute duration before recovery @fixture`, async ({ page }) => { + await canonicalStaff(page); await nav(page, "Lineup"); + await page.locator('[data-lineup-row] summary').click(); + const offset = page.getByLabel("Slot offset minutes", { exact: true }); + const duration = page.getByLabel("Slot duration minutes", { exact: true }); + await offset.fill("bad"); + await duration.fill(""); await duration.fill(String(minutes)); + await duration.press("Enter"); await duration.press("Tab"); + await offset.fill("15"); + await expect(page.getByLabel("Duration", { exact: true })).toHaveValue(String(minutes)); + await page.getByRole("button", { name: "Set event end to lineup end", exact: true }).first().click(); + await nav(page, "Review"); await page.getByRole("button", { name: "Save changes", exact: true }).click(); + const payload = await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1-submission")!)); + expect(payload.lineup[0].startAt).toBe(Date.UTC(2027, 9, 15, 20, 15, 10, 123)); + expect(payload.lineup[0].endAt).toBe(minutes === 45 ? Date.UTC(2027, 9, 15, 21, 0, 10, 123) : Date.UTC(2027, 9, 15, 21, 15, 10, 123)); +}); + +for (const canonical of [true, false]) test(`${canonical ? "canonical" : "legacy"} staff clears only a slot end and preserves rows through two saves @fixture`, async ({ page }) => { + await page.addInitScript(canonical => localStorage.setItem("event-lineup-fixture-v1", JSON.stringify({ + id: "clear-end", slug: "clear-end", title: "Clear end night", usesCanonicalLineup: canonical, + startAt: Date.UTC(2027, 9, 15, 20, 0, 10, 123), endAt: Date.UTC(2027, 9, 15, 21), timezone: "UTC", scheduleKind: "timed", status: "scheduled", communitySlug: "afterglow", publicationState: "draft_private", + source: { sourceType: canonical ? "contributor" : "community", label: "Original source" }, watchSurfaceEnabled: true, watchMode: "performer_sequence", + mediaLinks: [], authoredMediaLinks: [], worlds: [], participants: [], lineup: [], + slots: [ + { clientKey: canonical ? "exact-row" : undefined, playbackKey: "stored-exact", position: 0, startAt: Date.UTC(2027, 9, 15, 20, 0, 10, 123), endAt: Date.UTC(2027, 9, 15, 21, 0, 30, 456), displayLabel: "Aurora", roleLabel: "DJ", performer: { slug: "aurora", displayName: "Aurora", trustLabel: "claimed_verified" }, source: { sourceType: "community", label: "Slot source" }, selectedStreamId: "vrcdn:aurora" }, + { clientKey: canonical ? "later-row" : undefined, playbackKey: "stored-later", position: 1, startAt: Date.UTC(2027, 9, 15, 21, 15, 30, 456), endAt: Date.UTC(2027, 9, 15, 22, 15, 30, 456), displayLabel: "Lumen", roleLabel: "DJ", performer: { slug: "lumen", displayName: "Lumen", trustLabel: "claimed_verified" }, source: { sourceType: "community", label: "Later source" } }, + ], preservedParticipantAssociationIds: ["protected-person"], preservedSlotAssociationIds: ["stored-exact", "stored-later"], preservedWorldAssociationIds: [], + })), canonical); + await page.goto("/playwright/event-lineup?editor"); await nav(page, "Lineup"); + const rows = page.locator('[data-lineup-row]'); + const firstKey = await rows.first().getAttribute("data-lineup-row"); + await rows.first().locator("summary").click(); + const duration = rows.first().getByLabel("Slot duration minutes", { exact: true }); + await duration.fill(""); await duration.press("Escape"); await duration.press("Tab"); + await expect(duration).toHaveValue(String(3_620_333 / 60_000)); + await duration.fill(""); + if (canonical) await duration.press("Enter"); + await duration.press("Tab"); + await expect(duration).toHaveValue(""); + await expect(rows.first().getByLabel("Duration", { exact: true })).toBeDisabled(); + await expect(rows).toHaveCount(2); + await expect(rows.first()).toHaveAttribute("data-lineup-row", firstKey!); + await expect(rows.nth(1)).toContainText("21:15:30.456 - 22:15:30.456"); + await expect(page.getByText("The lineup ends after the event.", { exact: true }).first()).toBeVisible(); + await expect(page.getByRole("button", { name: "Set event end to lineup end", exact: true })).toHaveCount(0); + await duration.focus(); await duration.press("Enter"); await duration.press("Tab"); + await nav(page, "Details"); + await expect(page.getByLabel("Ends (optional)", { exact: true })).toHaveValue("2027-10-15T21:00"); + await page.getByLabel("Ends (optional)", { exact: true }).fill("2027-10-15T22:30"); + for (const count of [1, 2]) { + await nav(page, "Review"); await page.getByRole("button", { name: "Save draft", exact: true }).click(); + await expect.poll(() => page.evaluate(() => Number(localStorage.getItem("event-lineup-fixture-command-count")))).toBe(count); + const payload = await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1-submission")!)); + const saved = canonical ? payload.lineup : payload.slotLinks; + expect(saved).toHaveLength(2); + expect(saved[0].endAt).toBeUndefined(); + expect(saved[0].startAt).toBe(Date.UTC(2027, 9, 15, 20, 0, 10, 123)); + expect(saved[0].personSlug).toBe("aurora"); + expect(saved[0].selectedStreamId).toBe("vrcdn:aurora"); + expect(saved[1].startAt).toBe(Date.UTC(2027, 9, 15, 21, 15, 30, 456)); + expect(saved[1].endAt).toBe(Date.UTC(2027, 9, 15, 22, 15, 30, 456)); + expect(payload.endAt).toBe(Date.UTC(2027, 9, 15, 22, 30)); + if (canonical) expect(saved[0].clientKey).toBe("exact-row"); + else expect(payload.storedSlotAssociations[0]).toEqual({ position: 0, slotId: "stored-exact" }); + } +}); + +test("clearing an end drops its precise duration backup and leaves automatic event end unknown @fixture", async ({ page }) => { + await canonicalStaff(page); await nav(page, "Lineup"); + const row = page.locator('[data-lineup-row]'); + await row.locator("summary").click(); + const offset = page.getByLabel("Slot offset minutes", { exact: true }); + const duration = page.getByLabel("Slot duration minutes", { exact: true }); + await offset.fill("bad"); + await duration.fill(""); await duration.press("Tab"); + await offset.fill("15"); + await expect(row.getByLabel("Duration", { exact: true })).toBeDisabled(); + await expect(duration).toHaveValue(""); + await nav(page, "Details"); + await page.getByLabel("Time TBA", { exact: true }).check(); + await page.getByLabel("Time TBA", { exact: true }).uncheck(); + await page.getByLabel("Ends (optional)", { exact: true }).fill(""); + await nav(page, "Lineup"); + await expect(page.getByLabel("Duration", { exact: true })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Set event end to lineup end", exact: true })).toHaveCount(0); + await nav(page, "Review"); await page.getByRole("button", { name: "Save changes", exact: true }).click(); + const payload = await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1-submission")!)); + expect(payload.lineup[0].startAt).toBe(Date.UTC(2027, 9, 15, 20, 15, 10, 123)); + expect(payload.lineup[0].endAt).toBeUndefined(); + expect(payload.endAt).toBeNull(); +}); diff --git a/apps/web/e2e/event-lineup-validation.spec.ts b/apps/web/e2e/event-lineup-validation.spec.ts index 6d29812d7..fe5bca041 100644 --- a/apps/web/e2e/event-lineup-validation.spec.ts +++ b/apps/web/e2e/event-lineup-validation.spec.ts @@ -2,7 +2,6 @@ import { expect, test } from "@playwright/test"; import { BACKEND_ERROR_COPY } from "../src/lib/error-copy"; for (const scenario of [ - { name: "unselected timezone", start: "2027-10-15T19:00", timezone: "Indianapolis", field: "Time zone", message: "Time zone must be a valid IANA time zone." }, { name: "DST gap", start: "2027-03-14T02:30", field: "Starts", message: "Local time does not exist in this timezone." }, { name: "DST fold", start: "2027-11-07T01:30", field: "Start occurrence", message: "Ambiguous local time requires an earlier or later occurrence." }, ]) test(`staff publish reveals and focuses ${scenario.name}`, async ({ page }, testInfo) => { @@ -10,7 +9,6 @@ for (const scenario of [ const navigation = page.getByRole("navigation", { name: "Event editor" }); await navigation.getByRole("button", { name: "Details" }).click(); await page.getByLabel("Starts", { exact: true }).fill(scenario.start); - if (scenario.timezone) await page.getByRole("combobox", { name: "Time zone", exact: true }).fill(scenario.timezone); await navigation.getByRole("button", { name: "Review" }).click(); await page.getByRole("button", { name: "Save changes", exact: true }).click(); await expect(page.getByText(scenario.message, { exact: true })).toBeVisible(); @@ -30,10 +28,11 @@ test("staff publish reveals and focuses an invalid mounted field", async ({ page expect(await page.evaluate(() => localStorage.getItem("event-lineup-fixture-v1-submission"))).toBeNull(); await page.getByLabel("Event title", { exact: true }).fill("Valid title"); await navigation.getByRole("button", { name: "Lineup" }).click(); - await page.getByLabel("Slots", { exact: true }).fill(""); + await page.locator('[data-lineup-row]').first().locator("summary").click(); + await page.getByLabel("Slot offset minutes", { exact: true }).first().fill("invalid"); await navigation.getByRole("button", { name: "Review" }).click(); await page.getByRole("button", { name: "Save changes", exact: true }).click(); - await expect(page.getByLabel("Slots", { exact: true })).toBeFocused(); + await expect(page.getByLabel("Slot offset minutes", { exact: true }).first()).toBeFocused(); }); for (const wrapped of [false, true]) test(`@fixture stale selected stream validation ${wrapped ? "wrapped" : "plain"}`, async ({page}, testInfo) => { @@ -63,3 +62,39 @@ test("@fixture stream validation phrase inside an unrelated error stays generic" await expect(page.getByText(BACKEND_ERROR_COPY,{exact:true})).toBeVisible(); await expect(page.getByText("Stream unavailable",{exact:true})).toHaveCount(0); }); + + +test("invalid timezone search preserves the selected event zone", async ({ page }) => { + await page.goto("/playwright/event-lineup?editor"); + await page.getByRole("navigation", { name: "Event editor" }).getByRole("button", { name: "Details" }).click(); + const selected = await page.locator('input[name="timezone"]').inputValue(); + await page.getByRole("button", { name: "Time zone", exact: true }).click(); + const search = page.getByRole("combobox", { name: "Time zone", exact: true }); + await search.fill("not a zone"); + await expect(page.getByText("No matches", { exact: true })).toBeVisible(); + await search.press("Escape"); + await expect(page.locator('input[name="timezone"]')).toHaveValue(selected); +}); + + +test("staff missing timezone reveals Details and focuses the picker trigger without submitting", async ({ page }, testInfo) => { + await page.addInitScript(() => localStorage.setItem("event-lineup-fixture-v1", JSON.stringify({ + id: "legacy-no-zone", slug: "legacy-no-zone", title: "Legacy event", startAt: Date.UTC(2027, 8, 12, 20), + scheduleKind: "timed", status: "scheduled", communitySlug: "afterglow", publicationState: "published", + source: { sourceType: "community", label: "Afterglow" }, watchSurfaceEnabled: false, + mediaLinks: [], authoredMediaLinks: [], worlds: [], participants: [], slots: [], lineup: [], + preservedParticipantAssociationIds: [], preservedSlotAssociationIds: [], preservedWorldAssociationIds: [], + }))); + await page.goto("/playwright/event-lineup?editor"); + const details = page.locator('[data-editor-step="Details"]'); + await expect(page.locator('input[name="timezone"]')).toHaveValue(""); + await page.getByRole("navigation", { name: "Event editor" }).getByRole("button", { name: "Review", exact: true }).click(); + await expect(details).toBeHidden(); + await page.getByRole("button", { name: "Save changes", exact: true }).click(); + await expect(page.getByText("Time zone must be a valid IANA time zone.", { exact: true })).toBeVisible(); + await expect(details).toBeVisible(); + await expect(page.getByRole("button", { name: "Time zone", exact: true })).toBeFocused(); + expect(await page.evaluate(() => localStorage.getItem("event-lineup-fixture-command"))).toBeNull(); + expect(await page.evaluate(() => localStorage.getItem("event-lineup-fixture-v1-submission"))).toBeNull(); + await page.screenshot({ path: testInfo.outputPath("staff-missing-zone-focus.png"), fullPage: true }); +}); diff --git a/apps/web/e2e/event-review-actions.flow.spec.ts b/apps/web/e2e/event-review-actions.flow.spec.ts new file mode 100644 index 000000000..de8cccdbd --- /dev/null +++ b/apps/web/e2e/event-review-actions.flow.spec.ts @@ -0,0 +1,131 @@ +import { expect, test, type Page } from "@playwright/test"; + +test.use({ timezoneId: "UTC" }); +const nav = (page: Page, step: string) => page.getByRole("navigation", { name: "Event editor" }).getByRole("button", { name: step, exact: true }).click(); +const staffEvent = { id: "review-event", slug: "review-night", title: "Review night", startAt: Date.UTC(2027, 9, 15, 20), timezone: "UTC", status: "scheduled", communitySlug: "afterglow", source: { sourceType: "community", label: "Afterglow" }, watchSurfaceEnabled: false, mediaLinks: [], authoredMediaLinks: [], worlds: [], participants: [], slots: [], preservedParticipantAssociationIds: [], preservedSlotAssociationIds: [], preservedWorldAssociationIds: [] }; + +for (const mode of ["create", "draft", "published"] as const) { + test(`staff ${mode} has one prominent Review action and sends its existing command @fixture`, async ({ page }) => { + await page.addInitScript(event => localStorage.setItem("event-lineup-fixture-v1", JSON.stringify(event)), { ...staffEvent, publicationState: mode === "published" ? "published" : "draft_private" }); + await page.goto(`/playwright/event-lineup?editor${mode === "create" ? "&mode=create" : ""}`); + if (mode === "create") { + await nav(page, "Details"); + await page.getByLabel("Event title", { exact: true }).fill("Review night"); + await page.getByLabel("Starts", { exact: true }).fill("2027-10-15T20:00"); + } + await nav(page, "Review"); + const label = mode === "create" ? "Publish event" : mode === "draft" ? "Save and publish" : "Save changes"; + const primary = page.locator('[data-editor-step="Review"]').getByRole("button", { name: label, exact: true }); + await expect(primary).toHaveCount(1); + await expect(page.getByRole("button", { name: label, exact: true })).toHaveCount(1); + await page.evaluate(() => scrollTo(0, 0)); + await expect(primary).toBeInViewport(); + await expect(primary).toHaveClass(/bg-accent/); + await expect(page.getByRole("button", { name: mode === "published" ? "Unpublish and save draft" : "Save draft", exact: true })).not.toHaveClass(/bg-accent/); + await page.getByRole("button", { name: "Back", exact: true }).click(); + await expect(page.locator('[data-editor-step="Lineup"]')).toBeVisible(); + expect(await page.evaluate(() => localStorage.getItem("event-lineup-fixture-v1-submission"))).toBeNull(); + await nav(page, "Review"); + await primary.click(); + await expect.poll(() => page.evaluate(() => localStorage.getItem("event-lineup-fixture-command"))).toBe(mode === "create" ? "events:createCommunityEvent" : "events:updateCommunityEvent"); + expect(await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1-submission")!).published)).toBe(true); + await expect(page).toHaveURL(/playwright-afterglow-social\/events\/playwright-afterglow-harbor-sessions/); + }); +} + +for (const correction of [false, true]) { + test(`contributor ${correction ? "correction" : "publication"} uses its single Review action @fixture`, async ({ page }) => { + await page.goto(`/playwright/event-intake?revision=${correction ? "correction" : "draft"}`); + if (correction) await page.getByRole("button", { name: "Correct event", exact: true }).click(); + if (correction) { await nav(page, "Details"); await page.getByLabel("Event title", { exact: true }).fill("Corrected title"); } + await nav(page, "Review"); + const label = correction ? "Save changes" : "Publish event"; + const primary = page.locator('[data-step="Review"]').getByRole("button", { name: label, exact: true }); + await expect(primary).toHaveCount(1); + await expect(page.getByRole("button", { name: label, exact: true })).toHaveCount(1); + await page.evaluate(() => scrollTo(0, 0)); + await expect(primary).toBeInViewport(); + await expect(primary).toHaveClass(/bg-accent/); + await primary.click(); + await expect.poll(() => page.evaluate(() => sessionStorage.getItem("event-intake-review-command"))).toBe(correction ? "eventCorrections:updateOwnContributedEvent" : "eventIntake:publishEventIntake"); + const args = await page.evaluate(() => JSON.parse(sessionStorage.getItem("event-intake-revision-submission")!)); + expect(args.patch.title).toBe(correction ? "Corrected title" : "Original title"); + if (correction) { expect(args.patch.communitySlug).toBeUndefined(); expect(args.patch.lineup).toBeUndefined(); await expect(primary).toHaveCount(0); } + else await expect(page).toHaveURL(/playwright-afterglow-social\/events\/playwright-afterglow-harbor-sessions/); + }); +} + +test("staff pending Enter and click send one command @fixture", async ({ page }) => { + await page.addInitScript(event => { localStorage.setItem("event-lineup-fixture-v1", JSON.stringify(event)); localStorage.setItem("event-lineup-fixture-defer-save", "true"); }, { ...staffEvent, publicationState: "draft_private" }); + await page.goto("/playwright/event-lineup?editor"); + await nav(page, "Review"); + const primary = page.getByRole("button", { name: "Save and publish", exact: true }); + await primary.focus(); await primary.press("Enter"); + await expect(page.getByRole("button", { name: "Saving...", exact: true })).toBeDisabled(); + await page.locator('form').evaluate((form: HTMLFormElement) => { const button = form.querySelector('[value="publish"]')!; button.click(); form.requestSubmit(button); }); + expect(await page.evaluate(() => Number(localStorage.getItem("event-lineup-fixture-command-count")))).toBe(1); + await page.evaluate(() => window.dispatchEvent(new Event("event-lineup-fixture-release-save"))); +}); + +test("contributor pending Enter and click send one correction command @fixture", async ({ page }) => { + await page.addInitScript(() => sessionStorage.setItem("event-intake-review-defer", "true")); + await page.goto("/playwright/event-intake?revision=correction"); + await page.getByRole("button", { name: "Correct event", exact: true }).click(); + await nav(page, "Details"); await page.getByLabel("Event title", { exact: true }).fill("Corrected title"); + await nav(page, "Review"); + const primary = page.getByRole("button", { name: "Save changes", exact: true }); + await primary.focus(); await primary.press("Enter"); + await expect(primary).toBeDisabled(); + await page.locator('form').evaluate((form: HTMLFormElement) => { const button = form.querySelector('[data-step="Review"] button[type="submit"]')!; button.click(); form.requestSubmit(button); }); + expect(await page.evaluate(() => Number(sessionStorage.getItem("event-intake-review-count")))).toBe(1); + await page.evaluate(() => window.dispatchEvent(new Event("event-intake-review-release"))); +}); + +for (const stage of ["Details", "Lineup"]) test(`native Enter on staff ${stage} keeps navigation separate from publication @fixture`, async ({ page }) => { + await page.goto("/playwright/event-lineup?editor&mode=create"); + await nav(page, "Details"); await page.getByLabel("Event title", { exact: true }).fill("Review night"); await page.getByLabel("Starts", { exact: true }).fill("2027-10-15T20:00"); + if (stage === "Details") await page.getByLabel("Event title", { exact: true }).press("Enter"); + else { await nav(page, "Lineup"); await page.locator('form').evaluate((form: HTMLFormElement) => form.requestSubmit()); } + await expect(page.locator(`[data-editor-step="${stage === "Details" ? "Lineup" : "Review"}"]`)).toBeVisible(); + expect(await page.evaluate(() => localStorage.getItem("event-lineup-fixture-v1-submission"))).toBeNull(); +}); + +for (const editor of ["staff", "contributor"]) test(`${editor} extends a Review overrun and publishes once @fixture`, async ({ page }) => { + if (editor === "staff") { + await page.addInitScript(event => localStorage.setItem("event-lineup-fixture-v1", JSON.stringify(event)), { ...staffEvent, endAt: Date.UTC(2027,9,15,21), publicationState: "published" }); + await page.goto("/playwright/event-lineup?editor"); + } else { + await page.addInitScript(() => sessionStorage.setItem("event-intake-fixture", JSON.stringify({ communitySlug: "afterglow", title: "Review night", eventDate: "2027-10-15", timezone: "UTC", start: { time: "20:00" }, end: { time: "21:00" } }))); + await page.goto("/playwright/event-intake"); + } + await nav(page, "Lineup"); await page.getByRole("button", { name: "Generate", exact: true }).click(); + if (editor === "contributor") for (const [index, row] of (await page.locator("[data-lineup-row]").all()).entries()) await row.getByRole("textbox", { name: "Performer", exact: true }).fill(`Guest ${index + 1}`); + await nav(page, "Review"); + const panel = page.locator(editor === "staff" ? '[data-editor-step="Review"]' : '[data-step="Review"]'); + await expect(panel.getByText("The lineup ends after the event.", { exact: true })).toBeVisible(); + await panel.getByRole("button", { name: "Set event end to lineup end", exact: true }).click(); + await expect(panel.getByText("The lineup ends after the event.", { exact: true })).toHaveCount(0); + await panel.getByRole("button", { name: editor === "staff" ? "Save changes" : "Publish event", exact: true }).click(); + await expect(page).toHaveURL(/playwright-afterglow-social\/events\/playwright-afterglow-harbor-sessions/); + if (editor === "staff") { + const args = await page.evaluate(() => JSON.parse(localStorage.getItem("event-lineup-fixture-v1-submission")!)); + expect(args.endAt).toBe(Date.UTC(2027,9,16,0)); expect(args.title).toBe("Review night"); + expect(await page.evaluate(() => Number(localStorage.getItem("event-lineup-fixture-command-count")))).toBe(1); + } else { + const args = await page.evaluate(() => JSON.parse(sessionStorage.getItem("event-intake-published")!)); + expect(args.end).toMatchObject({ time: "00:00", dayOffset: 1 }); expect(args.title).toBe("Review night"); + } +}); + +for (const stage of ["Details", "Lineup"] as const) for (const repeated of [false, true]) test(`contributor Review reveals ${stage} ${repeated ? "repeated occurrence" : "DST gap"} @fixture`, async ({ page }) => { + await page.addInitScript(({ stage, repeated }) => sessionStorage.setItem("event-intake-fixture", JSON.stringify({ + communitySlug: "afterglow", title: "DST night", timezone: "America/New_York", eventDate: repeated ? "2027-11-07" : "2027-03-14", start: { time: stage === "Details" ? repeated ? "01:30" : "02:30" : "00:30" }, + ...(stage === "Lineup" ? { lineup: [{ clientKey: "dst-slot", position: 0, performerLabel: "Aurora", start: { time: repeated ? "01:30" : "02:30" } }] } : {}), + })), { stage, repeated }); + await page.goto("/playwright/event-intake"); await nav(page, "Review"); + await page.getByRole("button", { name: "Publish event", exact: true }).click(); + const label = stage === "Details" ? "Starts" : "Slot 1 start"; + await expect(page.getByLabel(repeated ? `${label} occurrence` : label, { exact: true })).toBeFocused(); + await expect(page.locator(`[data-step="${stage}"]`)).toBeVisible(); + expect(await page.evaluate(() => sessionStorage.getItem("event-intake-published"))).toBeNull(); +}); diff --git a/apps/web/e2e/event-timezone-picker.flow.spec.ts b/apps/web/e2e/event-timezone-picker.flow.spec.ts new file mode 100644 index 000000000..ee7df310b --- /dev/null +++ b/apps/web/e2e/event-timezone-picker.flow.spec.ts @@ -0,0 +1,145 @@ +import { expect, test } from "@playwright/test"; + +test("search and cancellation preserve authored timezone and occurrences", async ({ page }, testInfo) => { + await page.addInitScript(() => sessionStorage.setItem("event-intake-fixture", JSON.stringify({ communitySlug: "playwright-afterglow-social", eventDate: "2026-11-01", timezone: "America/New_York", start: { time: "01:30", occurrence: "later" }, end: { time: "01:45", occurrence: "later" }, timeTba: false }))); + await page.goto("/playwright/event-intake"); + const hidden = page.locator('input[name="timezone"]'); + await expect(hidden).toHaveValue("America/New_York"); + await page.screenshot({ path: testInfo.outputPath("timezone-picker-closed.png"), fullPage: true }); + await page.getByRole("button", { name: "Time zone", exact: true }).click(); + const search = page.getByRole("combobox", { name: "Time zone" }); + await search.fill("London"); + await page.screenshot({ path: testInfo.outputPath("timezone-picker-open.png"), fullPage: true }); + await expect(hidden).toHaveValue("America/New_York"); + await search.press("Escape"); + await expect(hidden).toHaveValue("America/New_York"); + await expect(page.getByLabel("Starts occurrence")).toHaveValue("later"); + await page.getByRole("button", { name: "Time zone", exact: true }).click(); + await search.fill("London"); + await page.getByRole("option").first().click(); + await expect(hidden).toHaveValue("Europe/London"); + await expect(page.locator('[name="Starts"]')).toHaveValue("2026-11-01T01:30"); +}); + + +test("keyboard navigation scrolls active results into view and commits once", async ({ page }) => { + await page.goto("/playwright/event-intake?timezone=controls"); + const picker = page.getByTestId("picker-probe"); + await picker.getByRole("button", { name: "Time zone", exact: true }).click(); + const search = picker.getByRole("combobox"); + await search.fill("America"); + for (let index = 0; index < 22; index++) await search.press("ArrowDown"); + const activeId = await search.getAttribute("aria-activedescendant"); + const visible = await page.evaluate(id => { + const option = document.getElementById(id!); + const list = option?.parentElement; + if (!option || !list) return false; + const a = option.getBoundingClientRect(), b = list.getBoundingClientRect(); + return a.top >= b.top && a.bottom <= b.bottom && list.scrollTop > 0; + }, activeId); + expect(visible).toBe(true); + await expect(picker.getByTestId("selection-count")).toHaveText("0"); + await search.press("Enter"); + await expect(picker.getByTestId("selection-count")).toHaveText("1"); + await expect(picker.getByRole("button", { name: "Time zone", exact: true })).toBeFocused(); +}); + +test("late preferences yield to manual and imported zones", async ({ page }) => { + await page.goto("/playwright/event-intake?timezone=controls"); + const form = page.locator("form"); + await form.getByRole("button", { name: "Time zone", exact: true }).click(); + await form.getByRole("combobox", { name: "Time zone", exact: true }).fill("Tokyo"); + await form.getByRole("option").first().click(); + await page.evaluate(() => window.dispatchEvent(new CustomEvent("fixture-timezone-preference", { detail: "Europe/London" }))); + await expect(page.getByTestId("account-preference").locator('[name="defaultEventTimezone"]')).toHaveValue("Europe/London"); + await expect(form.locator('[name="timezone"]')).toHaveValue("Asia/Tokyo"); + await page.reload(); + await page.getByRole("button", { name: "Import poster or text", exact: true }).click(); + await page.getByLabel("Source text", { exact: true }).fill("Fixture Eastern event"); + await page.getByRole("button", { name: "Extract details", exact: true }).click(); + await expect(form.locator('[name="timezone"]')).toHaveValue("America/New_York"); + await page.evaluate(() => window.dispatchEvent(new CustomEvent("fixture-timezone-preference", { detail: "Europe/London" }))); + await expect(page.getByTestId("account-preference").locator('[name="defaultEventTimezone"]')).toHaveValue("Europe/London"); + await expect(form.locator('[name="timezone"]')).toHaveValue("America/New_York"); +}); + +test("account save survives reload and clear restores browser defaults", async ({ page, context }, testInfo) => { + await context.addInitScript(() => { sessionStorage.removeItem("event-intake-fixture"); }); + await page.goto("/playwright/event-intake?timezone=controls"); + const account = page.getByTestId("account-preference"); + await account.getByRole("button", { name: "Time zone", exact: true }).click(); + await account.getByRole("combobox").fill("New York"); + await account.getByRole("option").first().click(); + await account.getByRole("button", { name: "Save", exact: true }).click(); + await page.reload(); + await expect(account.locator('[name="defaultEventTimezone"]')).toHaveValue("America/New_York"); + await account.screenshot({ path: testInfo.outputPath("account-preference.png") }); + await expect(page.locator('form [name="timezone"]')).toHaveValue("America/New_York"); + await account.getByRole("button", { name: "Clear", exact: true }).click(); + await page.reload(); + const browserZone = await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone); + await expect(account.locator('[name="defaultEventTimezone"]')).toHaveValue(""); + await expect(page.locator('form [name="timezone"]')).toHaveValue(browserZone); +}); + + +test("a late preference cannot reinterpret an authored start", async ({ page }) => { + await page.goto("/playwright/event-intake?timezone=controls"); + const form = page.locator("form"); + await expect(form.locator('[name="timezone"]')).not.toHaveValue(""); + const zone = await form.locator('[name="timezone"]').inputValue(); + await form.getByLabel("Starts", { exact: true }).fill("2026-11-01T01:30"); + await page.evaluate(() => window.dispatchEvent(new CustomEvent("fixture-timezone-preference", { detail: "Asia/Tokyo" }))); + await expect(page.getByTestId("account-preference").locator('[name="defaultEventTimezone"]')).toHaveValue("Asia/Tokyo"); + await expect(form.locator('[name="timezone"]')).toHaveValue(zone); + await expect(form.getByLabel("Starts", { exact: true })).toHaveValue("2026-11-01T01:30"); +}); + + +test("staff creation initializes from preference and keeps authored times", async ({ page }) => { + await page.addInitScript(() => localStorage.setItem("fixture-timezone-preference", "America/New_York")); + await page.goto("/playwright/event-intake?timezone=staff"); + const form = page.locator("form"); + await expect(form.locator('[name="timezone"]')).toHaveValue("America/New_York"); + await form.getByLabel("Starts", { exact: true }).fill("2026-11-01T01:30"); + await form.getByLabel("Start occurrence").selectOption("later"); + await page.evaluate(() => window.dispatchEvent(new CustomEvent("fixture-timezone-preference", { detail: "Asia/Tokyo" }))); + await expect(page.getByTestId("account-preference").locator('[name="defaultEventTimezone"]')).toHaveValue("Asia/Tokyo"); + await expect(form.locator('[name="timezone"]')).toHaveValue("America/New_York"); + await expect(form.getByLabel("Start occurrence")).toHaveValue("later"); +}); + +test("persisted drafts without a zone preserve validation instead of defaulting", async ({ page }) => { + await page.goto("/playwright/event-intake?revision=draft"); + await expect(page.locator('input[name="timezone"]')).toHaveValue(""); +}); + + +test("accepting imported timing locks the browser zone against late defaults", async ({ page }) => { + await page.goto("/playwright/event-intake?timezone=controls"); + const form = page.locator("form"); + await expect(form.locator('[name="timezone"]')).not.toHaveValue(""); + const zone = await form.locator('[name="timezone"]').inputValue(); + await page.getByRole("button", { name: "Import poster or text", exact: true }).click(); + await page.getByLabel("Source text", { exact: true }).fill("Tentative timing"); + await page.getByRole("button", { name: "Extract details", exact: true }).click(); + await page.getByRole("button", { name: "Accept Date", exact: true }).click(); + await page.getByRole("button", { name: "Accept Start time", exact: true }).click(); + await page.evaluate(() => window.dispatchEvent(new CustomEvent("fixture-timezone-preference", { detail: "Asia/Tokyo" }))); + await expect(page.getByTestId("account-preference").locator('[name="defaultEventTimezone"]')).toHaveValue("Asia/Tokyo"); + await expect(form.locator('[name="timezone"]')).toHaveValue(zone); + await expect(form.getByLabel("Starts", { exact: true })).toHaveValue("2026-11-01T03:30"); +}); + + +test("saving a new partial draft preserves its persisted zone against late defaults", async ({ page }) => { + await page.goto("/playwright/event-intake?timezone=controls"); + const form = page.locator("form"); + await expect(form.locator('[name="timezone"]')).not.toHaveValue(""); + const zone = await form.locator('[name="timezone"]').inputValue(); + await form.getByRole("button", { name: "Save draft", exact: true }).click(); + await expect(form.getByRole("status")).toHaveText("Draft saved"); + await page.evaluate(() => window.dispatchEvent(new CustomEvent("fixture-timezone-preference", { detail: "Asia/Tokyo" }))); + await expect(page.getByTestId("account-preference").locator('[name="defaultEventTimezone"]')).toHaveValue("Asia/Tokyo"); + await expect(form.locator('[name="timezone"]')).toHaveValue(zone); +}); diff --git a/apps/web/e2e/public-routes.visual.spec.ts b/apps/web/e2e/public-routes.visual.spec.ts index 3148b6146..3351115af 100644 --- a/apps/web/e2e/public-routes.visual.spec.ts +++ b/apps/web/e2e/public-routes.visual.spec.ts @@ -72,47 +72,20 @@ test("event editor @visual", async ({ page }, testInfo) => { await captureRouteScreenshot(page, testInfo, "event-editor-details"); await steps.getByRole("button", { name: "Lineup", exact: true }).click(); + await expect(page.getByRole("heading", { name: /^Slot [1-4]$/ })).toHaveCount(0); + await page.getByLabel("Slots", { exact: true }).fill("81"); + await expect(page.getByRole("button", { name: "Generate", exact: true })).toBeDisabled(); + await page.getByLabel("Slots", { exact: true }).fill("4"); + await page.getByLabel("Minutes each", { exact: true }).fill("60.5"); + await expect(page.getByRole("button", { name: "Generate", exact: true })).toBeDisabled(); + await page.getByLabel("Minutes each", { exact: true }).fill("60"); + await page.getByRole("button", { name: "Generate", exact: true }).click(); await expect(page.getByRole("heading", { name: /^Slot [1-4]$/ })).toHaveCount(4); await page.locator('[data-editor-step="Lineup"] details').first().locator("summary").click(); await page.getByLabel("Display name").first().fill("Aurora"); - page.once("dialog", async (dialog) => { - expect(dialog.message()).toBe("Replace edited schedule?"); - await dialog.dismiss(); - }); - await page.getByLabel("Slots").fill("5"); + await page.getByRole("button", { name: "Reset lineup", exact: true }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click(); await expect(page.getByLabel("Display name").first()).toHaveValue("Aurora"); - await expect(page.getByRole("heading", { name: /^(Aurora|Slot [2-4])$/ })).toHaveCount(4); - - page.once("dialog", (dialog) => dialog.accept()); - await page.getByLabel("Slots").fill("0"); - await expect(page.getByRole("heading", { name: /^Slot \d+$/ })).toHaveCount(0); - await page.getByLabel("Slots").fill("4"); - await expect(page.getByRole("heading", { name: /^Slot [1-4]$/ })).toHaveCount(4); - - page.once("dialog", (dialog) => dialog.accept()); - await page.getByLabel("Slots").fill("5"); - await expect(page.getByRole("heading", { name: /^Slot [1-5]$/ })).toHaveCount(5); - await page.getByLabel("Slots").fill("4"); - await expect(page.getByRole("heading", { name: /^Slot [1-4]$/ })).toHaveCount(4); - await page.getByLabel("Slots").fill("81"); - await expect(page.getByLabel("Slots")).toHaveValue("80"); - await page.getByLabel("Slots").fill("4"); - await expect(page.getByRole("heading", { name: /^Slot [1-4]$/ })).toHaveCount(4); - await page.getByLabel("Slots").fill(""); - await steps.getByRole("button", { name: "Review", exact: true }).click(); - await page.getByRole("button", { name: "Publish event" }).click(); - await expect(page.getByLabel("Slots")).toBeFocused(); - await page.getByLabel("Slots").fill("4"); - await page.getByLabel("Minutes each").fill(""); - await steps.getByRole("button", { name: "Review", exact: true }).click(); - await page.getByRole("button", { name: "Save draft" }).click(); - await expect(page.getByLabel("Minutes each")).toBeVisible(); - await expect(page.getByLabel("Minutes each")).toHaveValue(""); - await page.getByLabel("Minutes each").fill("60.5"); - await steps.getByRole("button", { name: "Review", exact: true }).click(); - await page.getByRole("button", { name: "Publish event" }).click(); - await expect(page.getByLabel("Minutes each")).toBeFocused(); - await page.getByLabel("Minutes each").fill("60"); await captureRouteScreenshot(page, testInfo, "event-editor-lineup"); await steps.getByRole("button", { name: "Review", exact: true }).click(); await expect(page.getByRole("button", { name: "Publish event" })).toBeEnabled(); diff --git a/apps/web/src/app/_components/event-timezone-picker.tsx b/apps/web/src/app/_components/event-timezone-picker.tsx index 58a54e34f..2039758c8 100644 --- a/apps/web/src/app/_components/event-timezone-picker.tsx +++ b/apps/web/src/app/_components/event-timezone-picker.tsx @@ -1,29 +1,42 @@ "use client"; -import { useId, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/field"; -import { searchEventTimezones } from "@/lib/event-timezones"; +import { commonEventTimezones, eventTimezoneLabel, searchEventTimezones } from "@/lib/event-timezones"; -export function EventTimezonePicker({ value, date, onChange }: { value: string | null; date: string | null; onChange: (value: string | null) => void }) { +export function EventTimezonePicker({ value, date, instant, preferredValue, name = "timezone", onChange }: { + value: string | null; date: string | null; instant?: number; preferredValue?: string | null; name?: string; onChange: (value: string | null) => void; +}) { const id = useId(); - const [query, setQuery] = useState(null); + const root = useRef(null); + const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const [active, setActive] = useState(-1); - const options = searchEventTimezones(query ?? value ?? "", date).slice(0, 30); - function select(zone: string) { onChange(zone); setQuery(null); setOpen(false); setActive(-1); } - return
- = 0 ? `${id}-${active}` : undefined} - autoComplete="off" value={query ?? value ?? ""} placeholder="City, region or abbreviation" - onFocus={() => setOpen(true)} onBlur={() => setOpen(false)} - onChange={event => { setQuery(event.target.value); onChange(null); setActive(-1); setOpen(true); }} - onKeyDown={event => { - if (event.key === "Escape") { setOpen(false); return; } - if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); setOpen(true); setActive(index => Math.max(0, Math.min(options.length - 1, index + (event.key === "ArrowDown" ? 1 : -1)))); } - if (event.key === "Enter" && open) { event.preventDefault(); if (options[active]) select(options[active]!.value); } - }} /> - - {open ?
    - {options.map((option, index) =>
  • event.preventDefault()} onClick={() => select(option.value)}>{option.label}
  • )} - {!options.length ?
  • No matches
  • : null} -
: null} + const options = query.trim() ? searchEventTimezones(query, date, instant).slice(0, 30) + : [...new Set([value, preferredValue, ...commonEventTimezones].filter((zone): zone is string => Boolean(zone)))].flatMap(zone => searchEventTimezones(zone, date, instant).filter(option => option.value === zone)).slice(0, 30); + useEffect(() => { if (open) root.current?.querySelector('[role="combobox"]')?.focus(); }, [open]); + useEffect(() => { if (open && active >= 0) document.getElementById(`${id}-${active}`)?.scrollIntoView({ block: "nearest" }); }, [active, id, open]); + function close() { setOpen(false); setQuery(""); setActive(-1); root.current?.querySelector("button")?.focus(); } + function select(zone: string) { onChange(zone); close(); } + return
{ if (!event.currentTarget.contains(event.relatedTarget)) { setOpen(false); setQuery(""); setActive(-1); } }}> + + + {open ?
+
= 0 ? `${id}-${active}` : undefined} + autoComplete="off" value={query} placeholder="City, region or abbreviation" + onChange={event => { setQuery(event.target.value); setActive(-1); }} + onKeyDown={event => { + if (event.key === "Escape") { event.preventDefault(); close(); } + if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); setActive(index => Math.max(0, Math.min(options.length - 1, index + (event.key === "ArrowDown" ? 1 : -1)))); } + if (event.key === "Enter") { event.preventDefault(); const option = options[active]; if (option) select(option.value); } + }} />
+
    + {options.map((option, index) =>
  • event.preventDefault()} onClick={() => select(option.value)}>{option.label}
  • )} + {!options.length ?
  • No matches
  • : null} +
+
: null}
; } diff --git a/apps/web/src/app/account/account-panel.tsx b/apps/web/src/app/account/account-panel.tsx index 5fc0c50dc..244d107f7 100644 --- a/apps/web/src/app/account/account-panel.tsx +++ b/apps/web/src/app/account/account-panel.tsx @@ -11,6 +11,7 @@ import { Notice } from "@/components/ui/notice"; import { VerifiedTrustMark } from "@/components/ui/verified-trust-mark"; import { cn } from "@/lib/cn"; import { ownerProfileDestinationPath, profileClaimPath } from "@/lib/profile-claim"; +import { AccountEventTimezonePreference } from "./event-timezone-preference"; import { AccountSignOutControl } from "./sign-out-control"; import { ConnectedProfileVerificationStatus } from "./profile-verification-status"; @@ -109,6 +110,8 @@ function ConnectedAccountPanel() {
+ + {staffWorkspaces && (staffWorkspaces.workspaces.length > 0 || staffWorkspaces.hasMore) ? (

Staff clubs

diff --git a/apps/web/src/app/account/event-timezone-preference.tsx b/apps/web/src/app/account/event-timezone-preference.tsx new file mode 100644 index 000000000..bdc532dcf --- /dev/null +++ b/apps/web/src/app/account/event-timezone-preference.tsx @@ -0,0 +1,35 @@ +"use client"; +import { useEffect, useRef, useState } from "react"; +import { useMutation } from "convex/react"; +import { api } from "@convex-generated-api"; +import { Button } from "@/components/ui/button"; +import { BACKEND_ERROR_COPY } from "@/lib/error-copy"; +import { Notice } from "@/components/ui/notice"; +import { EventTimezonePicker } from "../_components/event-timezone-picker"; + +export function EventTimezonePreferenceControl({ value, onSave }: { value?: string; onSave: (timezone: string | null) => Promise }) { + const [selection, setSelection] = useState(value ?? null); + const edited = useRef(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + useEffect(() => { if (!edited.current) setSelection(value ?? null); }, [value]); + async function save(timezone: string | null) { + setPending(true); setError(""); + try { await onSave(timezone); edited.current = false; setSelection(timezone); } + catch { setError(BACKEND_ERROR_COPY); } + finally { setPending(false); } + } + return
+

Default event time zone

+
+ { edited.current = true; setSelection(zone); }} /> +
+
+ {error ? {error} : null} +
; +} + +export function AccountEventTimezonePreference({ value }: { value?: string }) { + const save = useMutation(api.accounts.setEventTimezonePreference); + return save({ timezone })} />; +} diff --git a/apps/web/src/app/events/event-editor-form.tsx b/apps/web/src/app/events/event-editor-form.tsx index 8bd27fd91..027693b6d 100644 --- a/apps/web/src/app/events/event-editor-form.tsx +++ b/apps/web/src/app/events/event-editor-form.tsx @@ -1,15 +1,16 @@ "use client"; +import { formatLineupTimeRange } from "@/lib/calendar/event-lineup-display"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { resolveEventTimezoneDefault } from "@/lib/event-timezone-default"; import { EventTimezonePicker } from "../_components/event-timezone-picker"; import { resolveEventLocalTime, selectEventLocalTime } from "../../../../../packages/api-contracts/src/event-intake"; import type { ChangeEvent, FormEvent, ReactNode } from "react"; -import { useEffect, useState, useTransition } from "react"; +import { Fragment, useEffect, useRef, useState, useTransition } from "react"; import { useConvexAuth, useMutation, useQuery } from "convex/react"; import { api } from "@convex-generated-api"; import type { Id } from "../../../../../convex/_generated/dataModel"; -import { EVENT_SLOT_MAX_COUNT } from "../../../../../convex/_eventSlots"; import type { PlaybackStream } from "../../../../../convex/_eventPlayback"; import type { PublicEvent } from "../_components/event-public-page"; import { buttonVariants, Button } from "@/components/ui/button"; @@ -24,6 +25,9 @@ import { browserTimeZone, formatZonedDateTimeInput, } from "@/lib/calendar/zoned-date-time"; +import { applyStaffLineupTimings, changeStaffSlotOffset, readStaffLineupTimings, type SlotFormRow } from "@/lib/calendar/event-lineup-adapters"; +import { changeLineupDuration, generateLineupTimings, getLineupEndAt, getLineupEndConflict, insertLineupSlot, removeLineupSlot, shiftLineupStart, type LineupEditResult, type LineupTimingRow } from "@/lib/calendar/event-lineup-timing"; +import { EventLineupSetup, EventLineupInsert, EventSlotDuration, EventLineupReset, EventLineupConflict } from "./event-lineup-controls"; import { resolveAuthoredEventEndAt } from "@/lib/calendar/event-end-time"; import { EventEditorSteps, type EventEditorStep } from "./event-editor-steps"; import { EventEditorPreview } from "./event-editor-preview"; @@ -73,18 +77,6 @@ type EventAssociationSnapshot = Pick< | "preservedWorldAssociationIds" >; -type SlotFormRow = { - position?: number; - selectedStreamId?: string; - streamChoices?: PlaybackStream[]; - id: string; - offsetMinutes: string; - durationMinutes: string; - personSlug: string; - displayLabel: string; - roleLabel: string; -}; - type VrcdnOutputAccountOption = { key: string; label: string; @@ -344,11 +336,11 @@ function parseParticipantLinks(value: string) { function parseSlotRows(rows: SlotFormRow[], eventStartAt: number) { return rows.map((row) => { - const offsetMinutes = parseInteger(row.offsetMinutes, "Slot offset minutes"); - const durationMinutes = optionalString(row.durationMinutes) === undefined + const offsetMinutes = row.startAtOverride === undefined ? parseInteger(row.offsetMinutes, "Slot offset minutes") : 0; + const durationMinutes = row.endAtOverride !== undefined || row.durationMsOverride !== undefined || optionalString(row.durationMinutes) === undefined ? undefined : parseInteger(row.durationMinutes, "Slot duration minutes"); - const startAt = eventStartAt + offsetMinutes * 60_000; + const startAt = row.startAtOverride ?? eventStartAt + offsetMinutes * 60_000; return { personSlug: optionalString(row.personSlug), @@ -359,7 +351,7 @@ function parseSlotRows(rows: SlotFormRow[], eventStartAt: number) { "", roleLabel: optionalString(row.roleLabel) ?? "Participant", startAt, - ...(durationMinutes === undefined ? {} : { endAt: startAt + durationMinutes * 60_000 }), + ...(row.endAtOverride !== undefined ? { endAt: row.endAtOverride } : row.durationMsOverride !== undefined ? { endAt: startAt + row.durationMsOverride } : durationMinutes === undefined ? {} : { endAt: startAt + durationMinutes * 60_000 }), }; }); } @@ -372,11 +364,13 @@ function serializeMediaLinks(event: PublicEvent | undefined): string { function initialSlotRows(event: EditableEvent | undefined): SlotFormRow[] { if (event === undefined) { - return createGeneratedSlotRows(4, 60); + return []; } return event.slots.map((slot, index) => ({ position: slot.position, + startAtOverride: slot.startAt, + endAtOverride: slot.endAt, id: slot.clientKey ?? slot.playbackKey ?? `stored-${slot.position}-${slot.startAt}-${index}`, offsetMinutes: String(Math.round((slot.startAt - (event.startAt ?? slot.startAt)) / 60_000)), durationMinutes: @@ -391,54 +385,6 @@ function initialSlotRows(event: EditableEvent | undefined): SlotFormRow[] { })); } -function initialSlotTemplate(event: EditableEvent | undefined) { - if (event === undefined) { - return { count: "4", duration: "60" }; - } - - const firstSlot = event.slots[0]; - const duration = firstSlot?.endAt === undefined - ? 60 - : Math.round((firstSlot.endAt - firstSlot.startAt) / 60_000); - - return { - count: String(event.slots.length), - duration: String(duration), - }; -} - -function isValidSlotTemplate(template: { count: string; duration: string }) { - if (template.count.trim() === "" || template.duration.trim() === "") { - return false; - } - - try { - const count = parseInteger(template.count, "Slot count"); - const duration = parseInteger(template.duration, "Slot duration minutes"); - - return count >= 0 && count <= EVENT_SLOT_MAX_COUNT && duration > 0; - } catch { - return false; - } -} - -function createGeneratedSlotRows( - count: number, - durationMinutes: number, -): SlotFormRow[] { - return Array.from({ length: count }, (_, index) => { - const offsetMinutes = index * durationMinutes; - return { - id: `generated-${index}`, - offsetMinutes: String(offsetMinutes), - durationMinutes: String(durationMinutes), - personSlug: "", - displayLabel: "", - roleLabel: "Participant", - }; - }); -} - function eventTargetValue(changeEvent: ChangeEvent): string { return changeEvent.currentTarget.value; } @@ -541,11 +487,13 @@ function ConnectedEventEditorForm({ demoMode = false, event, heading, + savedTimezonePreference, }: { communitySlug: string; demoMode?: boolean; event?: EditableEvent; heading?: ReactNode; + savedTimezonePreference?: string | null; }) { const router = useRouter(); const [activeStep, setActiveStep] = useState("Details"); @@ -575,7 +523,12 @@ function ConnectedEventEditorForm({ ); const [status, setStatus] = useState({ kind: "idle" }); const [vrcdnOutputStatus, setVrcdnOutputStatus] = useState({ kind: "idle" }); - const [timezone, setTimezone] = useState(event?.timezone ?? "UTC"); + const [timezone, setTimezone] = useState(event?.timezone ?? ""); + const timezoneAuthored = useRef(event !== undefined); + useEffect(() => { + if (timezoneAuthored.current) return; + setTimezone(resolveEventTimezoneDefault({ savedPreference: savedTimezonePreference, browserTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone })); + }, [savedTimezonePreference]); const [doorsOpenBefore, setDoorsOpenBefore] = useState(event?.doorsOpenAt !== undefined); const [doorsOpenMinutes, setDoorsOpenMinutes] = useState(() => event?.doorsOpenAt === undefined || event.startAt === undefined ? "15" @@ -585,11 +538,11 @@ function ConnectedEventEditorForm({ const [mediaLinksText, setMediaLinksText] = useState(() => serializeMediaLinks(event)); const [vrcdnOutput, setVrcdnOutput] = useState(() => createInitialVrcdnOutputForm(event)); const [slotRows, setSlotRows] = useState(() => initialSlotRows(event)); - const [slotRowsDirty, setSlotRowsDirty] = useState(Boolean(event?.slots.length)); const [lineupEdited, setLineupEdited] = useState(false); const [scheduleChanged, setScheduleChanged] = useState(false); - const [slotTemplate, setSlotTemplate] = useState(() => initialSlotTemplate(event)); - const slotTemplateIsValid = isValidSlotTemplate(slotTemplate); + const startAnchor = useRef(event?.startAt); + const [endAtOverride, setEndAtOverride] = useState(); + const formRef = useRef(null); const [cancellationReason, setCancellationReason] = useState(""); const [eventStatus, setEventStatus] = useState(event?.status); const [isPublished, setIsPublished] = useState(event?.publicationState === "published"); @@ -600,12 +553,6 @@ function ConnectedEventEditorForm({ }); const [, startTransition] = useTransition(); - useEffect(() => { - if (event === undefined) { - setTimezone(browserTimeZone()); - } - }, [event]); - useEffect(() => { if (vrcdnOutputAccounts === undefined || vrcdnOutputAccounts.length === 0) { return; @@ -620,41 +567,9 @@ function ConnectedEventEditorForm({ }); }, [vrcdnOutputAccounts]); - function onSlotTemplateChange(field: "count" | "duration", value: string) { - const nextTemplate = { ...slotTemplate, [field]: value }; - - if (value.trim() === "") { - setSlotTemplate(nextTemplate); - return; - } - - try { - const count = parseInteger(nextTemplate.count, "Slot count"); - const duration = parseInteger(nextTemplate.duration, "Slot duration minutes"); - - if (count < 0 || duration <= 0) { - setSlotTemplate(nextTemplate); - return; - } - - if (slotRowsDirty && !window.confirm("Replace edited schedule?")) { - return; - } - - const boundedCount = Math.min(count, EVENT_SLOT_MAX_COUNT); - setSlotTemplate({ ...nextTemplate, count: String(boundedCount) }); - setSlotRows(createGeneratedSlotRows(boundedCount, duration)); - setSlotRowsDirty(false); - setLineupEdited(true); - setScheduleChanged(true); - } catch { - setSlotTemplate(nextTemplate); - } - } - function updateSlotRows(updater: (rows: SlotFormRow[]) => SlotFormRow[]) { + timezoneAuthored.current = true; setSlotRows((rows) => updater(rows)); - setSlotRowsDirty(true); setLineupEdited(true); } @@ -717,12 +632,13 @@ function ConnectedEventEditorForm({ async function onSubmit(submitEvent: FormEvent) { submitEvent.preventDefault(); + if (status.kind === "submitting") return; const form = submitEvent.currentTarget; const formData = new FormData(form, (submitEvent.nativeEvent as SubmitEvent).submitter); const intent = stringField(formData.get("intent")); - if (intent === "continue") { setActiveStep(activeStep === "Details" ? "Lineup" : "Review"); return; } - function revealField(field: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement) { + if (intent === "continue" || (!intent && activeStep !== "Review")) { setActiveStep(activeStep === "Details" ? "Lineup" : "Review"); return; } + function revealField(field: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement) { const panel = field.closest("[data-editor-step]"); if (panel) setActiveStep(panel.dataset.editorStep as EventEditorStep); for (let parent = field.parentElement; parent && parent !== form; parent = parent.parentElement) { @@ -735,7 +651,7 @@ function ConnectedEventEditorForm({ revealField(invalid); return; } - if (!slotTemplateIsValid && !timeTba) { setActiveStep("Lineup"); return; } + if (endConflict) { setActiveStep("Lineup"); requestAnimationFrame(() => formRef.current?.querySelector('[data-lineup-conflict] button, [data-lineup-row] input')?.focus()); return; } if (event !== undefined && isPublished && intent === "draft" && !window.confirm("Unpublish and save draft")) { return; @@ -750,12 +666,10 @@ function ConnectedEventEditorForm({ const authoredStart = stringField(formData.get("startAt")); const startAt = timeTba ? undefined : resolveStaffStartAt(authoredStart, timeZoneForParsing, occurrence, event); const slotLinks = startAt === undefined ? [] : parseSlotRows(slotRows, startAt); - const derivedEndAt = slotLinks.length === 0 || slotLinks.some((slot) => slot.endAt === undefined) - ? undefined - : Math.max(...slotLinks.map((slot) => slot.endAt!)); + const derivedEndAt = getLineupEndAt(slotLinks.map((slot, index) => ({ ...slot, key: slotRows[index]!.id }))); timeErrorField = "end"; const authoredEndAt = timeTba || !endEdited ? undefined : !endLocal ? null - : event?.endAt !== undefined && endLocal === formatZonedDateTimeInput(event.endAt, event.timezone) && timezone === event.timezone && endOccurrence === storedTimeOccurrence(event.endAt, event.timezone) + : endAtOverride !== undefined ? endAtOverride : event?.endAt !== undefined && endLocal === formatZonedDateTimeInput(event.endAt, event.timezone) && timezone === event.timezone && endOccurrence === storedTimeOccurrence(event.endAt, event.timezone) ? event.endAt : selectEventLocalTime(endLocal.slice(0, 10), { time: endLocal.slice(11, 16), ...(endOccurrence ? { occurrence: endOccurrence } : {}) }, timeZoneForParsing); if (startAt !== undefined && typeof authoredEndAt === "number" && authoredEndAt <= startAt) { @@ -804,18 +718,23 @@ function ConnectedEventEditorForm({ participantLinks: parseParticipantLinks(stringField(formData.get("participantLinks"))), slotLinks, } : {}), }; + const submittedSlotKeys = slotRows.map(row => row.id); const result = event ? await updateEvent({ currentSlug: currentSlug!, preservedCommunityProfileId: event.preservedCommunityProfileId, ...associationSnapshot, + ...(!usesCanonicalLineup ? { storedSlotAssociations: timeTba ? [] : slotRows.flatMap((row, position) => { + const slotId = associationSnapshot.preservedSlotAssociationIds.find(id => id === row.id); + return slotId === undefined ? [] : [{ position, slotId }]; + }) } : {}), ...(intent === "publish" ? { published: true } : intent === "draft" ? { published: false } : {}), ...payload, - preserveLineupConfirmation: usesCanonicalLineup && !lineupEdited && startAt !== event.startAt, + preserveLineupConfirmation: usesCanonicalLineup, doorsOpenAt: doorsOffsetMinutes === undefined ? null : startAt! - doorsOffsetMinutes * 60_000, endAt: endAt ?? null, summary: payload.summary ?? null, @@ -836,6 +755,10 @@ function ConnectedEventEditorForm({ } if (event !== undefined) { setCurrentSlug(result.slug); + if ("storedSlotAssociations" in result && result.storedSlotAssociations) { + const storedIdsBySubmittedKey = new Map(result.storedSlotAssociations.map(item => [submittedSlotKeys[item.position], item.slotId])); + setSlotRows(rows => rows.map(row => ({ ...row, id: storedIdsBySubmittedKey.get(row.id) ?? row.id }))); + } if ("preservedParticipantAssociationIds" in result) { setAssociationSnapshot({ preservedParticipantAssociationIds: result.preservedParticipantAssociationIds, @@ -859,12 +782,14 @@ function ConnectedEventEditorForm({ ); } catch (error) { const message = eventEditorErrorMessage(error); - const selector = message === "Time zone must be a valid IANA time zone." ? '[role="combobox"][aria-label="Time zone"]' + const selector = message.startsWith("Slot offset minutes") ? '[data-slot-offset]' + : message.startsWith("Slot duration minutes") ? '[data-slot-duration]' + : message === "Time zone must be a valid IANA time zone." ? 'button[aria-label="Time zone"]' : message === "Local time does not exist in this timezone." ? `[name="${timeErrorField === "end" ? "endAt" : "startAt"}"]` : message === "Ambiguous local time requires an earlier or later occurrence." ? `[aria-label="${timeErrorField === "end" ? "End" : "Start"} occurrence"]` : message === "Event end time must be after the start time." ? '[name="endAt"]' : undefined; - const field = selector ? form.querySelector(selector) : null; + const field = selector ? form.querySelector(selector) : null; if (field) revealField(field); if (isUnavailableStreamError(error)) setActiveStep("Lineup"); startTransition(() => setStatus({ @@ -915,10 +840,7 @@ function ConnectedEventEditorForm({ let previewDerivedEndAt: number | undefined; try { if (!timeTba && previewStart !== undefined) { - const previewSlots = parseSlotRows(slotRows, previewStart); - if (previewSlots.length > 0 && previewSlots.every(slot => slot.endAt !== undefined)) { - previewDerivedEndAt = Math.max(...previewSlots.map(slot => slot.endAt!)); - } + previewDerivedEndAt = getLineupEndAt(readStaffLineupTimings(slotRows, previewStart)); } } catch { /* Incomplete slot rows do not provide a preview end. */ } const implicitEndAt = timeTba || previewStart === undefined ? undefined : resolveAuthoredEventEndAt({ @@ -938,7 +860,7 @@ function ConnectedEventEditorForm({ : displayedEndOccurrence === "later" ? endChoices.at(-1) : undefined; const previewEndAt = timeTba ? undefined : !endEdited ? implicitEndAt : !endLocal && previewStart !== undefined ? previewDerivedEndAt - : previewEnteredEndAt; + : endAtOverride ?? previewEnteredEndAt; const vrcdnOutputAccountOptions = vrcdnOutputAccounts ?? []; const vrcdnOutputAccountsLoading = vrcdnOutputAccounts === undefined; const canSaveVrcdnOutput = @@ -957,6 +879,80 @@ function ConnectedEventEditorForm({ setVrcdnOutput((current) => applyVrcdnOutputAccountDefaults(current, account)); } + const displayRows = [ + ...slotRows.map((row, index) => ({ kind: "timed" as const, row, position: row.position ?? index })), + ...(usesCanonicalLineup ? untimedRows.map(row => ({ kind: "untimed" as const, row, position: row.position })) : []), + ].sort((a, b) => a.position - b.position); + const staffTimings = readStaffLineupTimings(slotRows, previewStart ?? NaN); + const timings = displayRows.map(item => item.kind === "untimed" || timeTba ? { key: item.kind === "timed" ? item.row.id : item.row.clientKey } : staffTimings.find(timing => timing.key === item.row.id)!); + const endConflict = getLineupEndConflict(previewEndAt, timings); + function applyTimingEdit(result: LineupEditResult, newUntimedKey?: string) { + if (!result.ok) { requestAnimationFrame(() => formRef.current?.querySelector('[data-lineup-row] input')?.focus()); return; } + if (previewStart !== undefined) startAnchor.current = previewStart; + const untimedKeys = new Set(untimedRows.map(row => row.clientKey)); + if (newUntimedKey) untimedKeys.add(newUntimedKey); + const slots = result.rows.filter(row => !untimedKeys.has(row.key)); + // Time TBA leaves surviving stored clocks reversible in this open editor. + const nextSlots = timeTba ? slots.flatMap(timing => { const row = slotRows.find(row => row.id === timing.key); return row ? [row] : []; }) : applyStaffLineupTimings(slotRows, slots, previewStart ?? NaN); + updateSlotRows(() => nextSlots.map(row => ({ ...row, position: result.rows.findIndex(timing => timing.key === row.id) }))); + if (usesCanonicalLineup) updateUntimedRows(rows => result.rows.flatMap((timing, position) => { + if (!untimedKeys.has(timing.key)) return []; + const original = rows.find(row => row.clientKey === timing.key); + return [{ ...(original ?? { clientKey: timing.key, performerLabel: "", personSlug: "", roleLabel: "" }), position }]; + })); + setScheduleChanged(true); + } + function addUntimed(index = displayRows.length) { + if (slotRows.length + untimedRows.length >= 80) return; + const key = crypto.randomUUID(); + if (usesCanonicalLineup) applyTimingEdit({ ok: true, rows: [...timings.slice(0, index), { key }, ...timings.slice(index)] }, key); + else updateUntimedRows(rows => [...rows, { clientKey: key, position: rows.length, performerLabel: "", personSlug: "", roleLabel: "" }]); + requestAnimationFrame(() => formRef.current?.querySelector(`[data-lineup-row="${key}"] input`)?.focus()); + } + function changeStart(local: string, nextOccurrence: "earlier" | "later" | "") { + timezoneAuthored.current = true; + try { + const next = resolveStaffStartAt(local, timezone, nextOccurrence, event); + if (startAnchor.current !== undefined) { + const shifted = shiftLineupStart(readStaffLineupTimings(slotRows, startAnchor.current), next - startAnchor.current); + setSlotRows(applyStaffLineupTimings(slotRows, shifted, next).map((row, index) => ({ ...row, position: slotRows[index]?.position }))); + } + startAnchor.current = next; + setScheduleChanged(true); + } catch { /* Incomplete authored starts keep the last valid cascade anchor. */ } + setStartLocal(local); setEventDate(local.slice(0, 10)); setOccurrence(nextOccurrence); + } + function changeTimezone(value: string | null) { + timezoneAuthored.current = true; + const nextZone = value ?? ""; + try { + const next = resolveStaffStartAt(startLocal, nextZone, ""); + if (startAnchor.current !== undefined) setSlotRows(applyStaffLineupTimings(slotRows, shiftLineupStart(readStaffLineupTimings(slotRows, startAnchor.current), next - startAnchor.current), next).map((row, index) => ({ ...row, position: slotRows[index]?.position }))); + startAnchor.current = next; + } catch { /* Keep the last valid anchor while the authored start is incomplete. */ } + setTimezone(nextZone); setOccurrence(""); setEndOccurrence(""); setEndAtOverride(undefined); + if (displayedEndLocal) { setEndLocal(displayedEndLocal); setEndEdited(true); } + } + const insertion = (index: number) => = 80} disabled={timeTba || (index === 0 ? previewStart : timings[index - 1]?.endAt) === undefined} onAddUntimed={addUntimed} onInsert={(index, durationMinutes) => { + const key = crypto.randomUUID(); + applyTimingEdit(insertLineupSlot({ rows: timings, index, row: { key }, durationMinutes, eventStartAt: previewStart })); + requestAnimationFrame(() => formRef.current?.querySelector(`[data-lineup-row="${key}"] input`)?.focus()); + }} />; + const conflictNotice = endConflict && previewEndAt !== undefined ? { setEndAtOverride(endAt); setEndLocal(formatZonedDateTimeInput(endAt, timezone)); setEndOccurrence(storedTimeOccurrence(endAt, timezone)); setEndEdited(true); setEndOpen(true); }} /> : null; + function renderUntimed(row: (typeof untimedRows)[number]) { + return + {usesCanonicalLineup ? Performer updateUntimedRows(rows => rows.map(item => item.clientKey === row.clientKey ? { ...item, performerLabel: change.target.value } : item))} /> : null} + Person profile item.performer?.slug === row.personSlug)?.performer?.imageUrl ?? event?.participants.find(item => item.slug === row.personSlug)?.imageUrl} value={row.personSlug} onChange={personSlug => updateUntimedRows(rows => rows.map(item => item.clientKey === row.clientKey ? { ...item, personSlug } : item))} /> + Role updateUntimedRows(rows => rows.map(item => item.clientKey === row.clientKey ? { ...item, roleLabel: change.target.value } : item))} /> + {usesCanonicalLineup && !timeTba ?
DetailsSlot offset minutes { if (change.key === "Enter") { change.preventDefault(); change.currentTarget.blur(); } }} onBlur={change => { + const value = change.currentTarget.value; + if (previewStart === undefined || !value.trim() || !Number.isInteger(Number(value))) return; + updateSlotRows(rows => [...rows, { id: row.clientKey, position: row.position, personSlug: row.personSlug, displayLabel: row.performerLabel, roleLabel: row.roleLabel, offsetMinutes: value, durationMinutes: "", startAtOverride: previewStart + Number(value) * 60_000 }]); + updateUntimedRows(rows => rows.filter(item => item.clientKey !== row.clientKey)); + }} />
: null} + +
; + } function previewTime(timestamp: number) { const local = formatZonedDateTimeInput(timestamp, timezone); const instantOccurrence = storedTimeOccurrence(timestamp, timezone); @@ -967,13 +963,18 @@ function ConnectedEventEditorForm({ start: previewStart === undefined ? null : previewTime(previewStart), end: previewEndAt === undefined ? null : previewTime(previewEndAt), lineup: [ - ...slotRows.map(row => ({ clientKey: row.id, performerLabel: row.displayLabel || row.personSlug, ...(previewStart === undefined || timeTba ? {} : { start: previewTime(previewStart + Number(row.offsetMinutes) * 60_000) }) })), - ...untimedRows.map(row => ({ clientKey: row.clientKey, performerLabel: row.performerLabel || row.personSlug })), + ...displayRows.map((item, index) => { + const startAt = timings[index]?.startAt; + return item.kind === "untimed" + ? { clientKey: item.row.clientKey, performerLabel: item.row.performerLabel || item.row.personSlug } + : { clientKey: item.row.id, performerLabel: item.row.displayLabel || item.row.personSlug, ...(previewStart === undefined || startAt === undefined ? {} : { start: previewTime(startAt) }) }; + }), + ...(!usesCanonicalLineup ? untimedRows.map(row => ({ clientKey: row.clientKey, performerLabel: row.performerLabel || row.personSlug })) : []), ].filter(row => row.performerLabel).map((row, position) => ({ ...row, position })), }; return ( -
{ + { const data = new FormData(change.currentTarget); setPreview({ title: stringField(data.get("title")), summary: stringField(data.get("summary")), venueLabel: stringField(data.get("venueLabel")), artwork: stringField(data.get("posterImageUrl")) }); }}> @@ -1006,33 +1007,33 @@ function ConnectedEventEditorForm({ - {usesCanonicalLineup || event?.scheduleKind === "date_only" ? : null} + {usesCanonicalLineup || event?.scheduleKind === "date_only" ? : null}
- {timeTba ? Date setEventDate(change.target.value)} /> : <> + {timeTba ? Date { timezoneAuthored.current = true; setEventDate(change.target.value); }} /> : <>
Starts - { setStartLocal(change.target.value); setEventDate(change.target.value.slice(0, 10)); setOccurrence(""); }} /> + changeStart(change.target.value, "")} />
setEndOpen(change.currentTarget.open)}> Add end time
- Ends (optional) { setEndLocal(change.target.value); setEndOccurrence(""); setEndEdited(true); }} /> + Ends (optional) { timezoneAuthored.current = true; setEndAtOverride(undefined); setEndLocal(change.target.value); setEndOccurrence(""); setEndEdited(true); }} /> {displayedEndLocal && endChoices.length === 0 ?

This local time does not exist.

: null} - {endChoices.length > 1 ? Repeated time : null} + {endChoices.length > 1 ? Repeated time : null}
Time zone - { setTimezone(value ?? ""); setOccurrence(""); setEndOccurrence(""); if (displayedEndLocal) { setEndLocal(displayedEndLocal); setEndEdited(true); } }} /> + - {startChoices.length > 1 ? Repeated time : null} + {startChoices.length > 1 ? Repeated time : null}