From afb14b2cbb0d16da4557e2b6940bdf628b16d2b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 19:07:06 +0000 Subject: [PATCH 1/3] feat: my-vote chips to filter Schedule by viewer's own votes Adds Must Go / Interested / Won't Go chips (OR-ed, URL-persisted) next to the Filters trigger on both Timeline and List, filtering sets through the shared filterScheduleDays predicate keyed to the viewer's own votes. Hidden entirely when logged out; selection counts toward the filter badge. Closes #196 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- src/hooks/useTimelineUrlState.ts | 15 ++ src/lib/scheduleFilter.test.ts | 142 ++++++++++++++++++ src/lib/scheduleFilter.ts | 32 +++- src/lib/searchSchemas.ts | 6 + .../tabs/ScheduleTab/ScheduleFilterSheet.tsx | 10 +- .../tabs/ScheduleTab/VoteButtons.tsx | 3 +- .../tabs/ScheduleTab/VoteFilterChips.tsx | 71 +++++++++ .../tabs/ScheduleTab/horizontal/Timeline.tsx | 15 +- .../horizontal/TimelineToolbar.tsx | 12 +- .../tabs/ScheduleTab/list/ListSchedule.tsx | 15 +- .../tabs/ScheduleTab/list/ListTab.tsx | 2 + tests/e2e/schedule-vote-chips.spec.ts | 106 +++++++++++++ 12 files changed, 414 insertions(+), 15 deletions(-) create mode 100644 src/pages/EditionView/tabs/ScheduleTab/VoteFilterChips.tsx create mode 100644 tests/e2e/schedule-vote-chips.spec.ts diff --git a/src/hooks/useTimelineUrlState.ts b/src/hooks/useTimelineUrlState.ts index f611812a..d5ec07e2 100644 --- a/src/hooks/useTimelineUrlState.ts +++ b/src/hooks/useTimelineUrlState.ts @@ -1,6 +1,7 @@ import { useCallback } from "react"; import { useSearch, useNavigate } from "@tanstack/react-router"; import type { TimelineSearch } from "@/lib/searchSchemas"; +import type { VoteType } from "@/lib/voteConfig"; export type TimelineView = TimelineSearch["view"]; export type TimeFilter = TimelineSearch["time"]; @@ -18,6 +19,7 @@ export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") { day: search.day, time: search.time, stages: search.stages, + votes: search.votes, }), structuralSharing: true, }); @@ -67,6 +69,17 @@ export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") { [navigate], ); + const updateVotes = useCallback( + (votes: VoteType[]) => { + navigate({ + to: ".", + search: (prev) => ({ ...prev, votes }), + replace: true, + }); + }, + [navigate], + ); + const clearFilters = useCallback(() => { navigate({ to: ".", @@ -80,10 +93,12 @@ export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") { day: state.day, time: state.time, stages: state.stages, + votes: state.votes, updateView, updateDay, updateTime, updateStages, + updateVotes, clearFilters, }; } diff --git a/src/lib/scheduleFilter.test.ts b/src/lib/scheduleFilter.test.ts index e08939cf..b4145745 100644 --- a/src/lib/scheduleFilter.test.ts +++ b/src/lib/scheduleFilter.test.ts @@ -226,6 +226,148 @@ describe("filterScheduleDays", () => { }); }); + describe("vote-type predicate (my-vote chips)", () => { + it("keeps all sets when no vote types are selected", () => { + const days = [ + makeDay({ + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 1, + sets: [makeSet({ id: "set-1" }), makeSet({ id: "set-2" })], + }, + ], + }), + ]; + + const result = filterScheduleDays( + days, + baseCriteria({ voteTypes: [], userVotes: { "set-1": 2 } }), + TIMEZONE, + ); + + expect(result[0].stages[0].sets.map((s) => s.id)).toEqual([ + "set-1", + "set-2", + ]); + }); + + it("keeps only sets matching a single selected vote type", () => { + const days = [ + makeDay({ + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 1, + sets: [makeSet({ id: "must-go-set" }), makeSet({ id: "interested-set" })], + }, + ], + }), + ]; + + const result = filterScheduleDays( + days, + baseCriteria({ + voteTypes: ["mustGo"], + userVotes: { "must-go-set": 2, "interested-set": 1 }, + }), + TIMEZONE, + ); + + expect(result[0].stages[0].sets.map((s) => s.id)).toEqual([ + "must-go-set", + ]); + }); + + it("OR-s together a union of selected vote types", () => { + const days = [ + makeDay({ + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 1, + sets: [ + makeSet({ id: "must-go-set" }), + makeSet({ id: "interested-set" }), + makeSet({ id: "wont-go-set" }), + ], + }, + ], + }), + ]; + + const result = filterScheduleDays( + days, + baseCriteria({ + voteTypes: ["mustGo", "interested"], + userVotes: { + "must-go-set": 2, + "interested-set": 1, + "wont-go-set": -1, + }, + }), + TIMEZONE, + ); + + expect(result[0].stages[0].sets.map((s) => s.id)).toEqual([ + "must-go-set", + "interested-set", + ]); + }); + + it("excludes a set with no vote when the filter is active", () => { + const days = [ + makeDay({ + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 1, + sets: [makeSet({ id: "unvoted-set" })], + }, + ], + }), + ]; + + const result = filterScheduleDays( + days, + baseCriteria({ voteTypes: ["mustGo"], userVotes: {} }), + TIMEZONE, + ); + + expect(result[0].stages[0].sets).toHaveLength(0); + }); + + it("excludes a set missing from the votes map entirely", () => { + const days = [ + makeDay({ + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 1, + sets: [makeSet({ id: "not-in-map" })], + }, + ], + }), + ]; + + const result = filterScheduleDays( + days, + baseCriteria({ + voteTypes: ["mustGo"], + userVotes: { "some-other-set": 2 }, + }), + TIMEZONE, + ); + + expect(result[0].stages[0].sets).toHaveLength(0); + }); + }); + describe("combinations", () => { it("applies day, time and stage predicates together", () => { const days = [ diff --git a/src/lib/scheduleFilter.ts b/src/lib/scheduleFilter.ts index 56fbe61c..8bda0377 100644 --- a/src/lib/scheduleFilter.ts +++ b/src/lib/scheduleFilter.ts @@ -1,5 +1,6 @@ import { getFestivalHour } from "@/lib/timeUtils"; import type { TimelineSearch } from "@/lib/searchSchemas"; +import { getVoteConfig, type VoteType } from "@/lib/voteConfig"; import type { ScheduleDay, ScheduleSet, @@ -15,9 +16,14 @@ export interface ScheduleFilterCriteria { time: ScheduleTimeFilter; // Stage ids to include; an empty array means all stages. stages: string[]; - // Future (PRD #188 - vote-type filtering): an optional field such as - // `voteTypes?: VoteType[]`, OR-ed against the viewer's own votes on each - // set, will slot in here without reshaping this interface. + // My-vote chips (PRD #188): selected vote types, OR-ed together. Empty or + // omitted turns the filter off (all sets kept, `userVotes` unconsulted). + // Always the viewer's own votes - group-vote scopes are out of scope here. + voteTypes?: VoteType[]; + // The viewer's own votes, keyed by set id -> vote value (same shape as + // useUserVotes' return). Passed in, never fetched inside - keeps this + // function pure. + userVotes?: Record; } function matchesTimeOfDay( @@ -42,6 +48,20 @@ function matchesTimeOfDay( } } +function matchesVoteTypes( + set: ScheduleSet, + voteTypes: VoteType[] | undefined, + userVotes: Record | undefined, +): boolean { + if (!voteTypes || voteTypes.length === 0) return true; + + const voteValue = userVotes?.[set.id]; + if (voteValue === undefined) return false; + + const voteType = getVoteConfig(voteValue); + return voteType !== undefined && voteTypes.includes(voteType); +} + // Applies the day / time-of-day / stage predicates shared by both Schedule // views (Timeline and List). A day that doesn't match `criteria.day` is kept // as an entry with empty stages (rather than dropped), matching the @@ -63,8 +83,10 @@ export function filterScheduleDays( ) .map((stage) => ({ ...stage, - sets: stage.sets.filter((set) => - matchesTimeOfDay(set, criteria.time, timezone), + sets: stage.sets.filter( + (set) => + matchesTimeOfDay(set, criteria.time, timezone) && + matchesVoteTypes(set, criteria.voteTypes, criteria.userVotes), ), })); diff --git a/src/lib/searchSchemas.ts b/src/lib/searchSchemas.ts index a5667417..51852d77 100644 --- a/src/lib/searchSchemas.ts +++ b/src/lib/searchSchemas.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { VOTES_TYPES } from "@/lib/voteConfig"; export const sortOptionSchema = z.enum([ "name-asc", @@ -40,6 +41,10 @@ export const timelineSearchSchema = z.object({ day: z.string().catch("all"), time: z.enum(["all", "morning", "afternoon", "evening"]).catch("all"), stages: z.array(z.string()).catch([]), + // My-vote chips (PRD #188): selected vote types (mustGo/interested/wontGo), + // OR-ed together. Empty means the filter is off. Always the viewer's own + // votes, never a group-vote scope. + votes: z.array(z.enum(VOTES_TYPES)).catch([]), // The moment (ISO datetime) centered in the timeline viewport. Absent by // default; only written once the user scrolls. See useTimelineScrollSync. scrollTo: z.string().optional().catch(undefined), @@ -52,4 +57,5 @@ export const timelineSearchDefaults: TimelineSearch = { day: "all", time: "all", stages: [], + votes: [], }; diff --git a/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx b/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx index 1e63c093..ed90fc2f 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx @@ -35,7 +35,9 @@ interface ScheduleFilterSheetProps { * * Active-filter count: `day` and `time` each count 1 when away from "all", * and every selected stage counts 1 of its own (so picking two stages adds - * 2). Vote-type chips will fold into this same count in a later ticket. + * 2). My-vote chips (rendered next to this trigger, not inside the sheet - + * see VoteFilterChips) follow the same per-item rule: every selected vote + * type counts 1 of its own, so picking Must Go + Interested adds 2. */ export function ScheduleFilterSheet({ tab }: ScheduleFilterSheetProps) { const [open, setOpen] = useState(false); @@ -43,6 +45,7 @@ export function ScheduleFilterSheet({ tab }: ScheduleFilterSheetProps) { day, time, stages, + votes, updateDay, updateTime, updateStages, @@ -50,7 +53,10 @@ export function ScheduleFilterSheet({ tab }: ScheduleFilterSheetProps) { } = useTimelineUrlState(tab); const activeFilterCount = - (day !== "all" ? 1 : 0) + (time !== "all" ? 1 : 0) + stages.length; + (day !== "all" ? 1 : 0) + + (time !== "all" ? 1 : 0) + + stages.length + + votes.length; const hasActiveFilters = activeFilterCount > 0; return ( diff --git a/src/pages/EditionView/tabs/ScheduleTab/VoteButtons.tsx b/src/pages/EditionView/tabs/ScheduleTab/VoteButtons.tsx index 447ffc8e..403feb81 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/VoteButtons.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/VoteButtons.tsx @@ -47,7 +47,7 @@ export function VoteButtons({ set }: VoteButtonsProps) { }, [set.votes]); return ( -
+
{VOTES_TYPES.map((voteType) => { return ( onVote()} > diff --git a/src/pages/EditionView/tabs/ScheduleTab/VoteFilterChips.tsx b/src/pages/EditionView/tabs/ScheduleTab/VoteFilterChips.tsx new file mode 100644 index 00000000..3ed41a1d --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/VoteFilterChips.tsx @@ -0,0 +1,71 @@ +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { VOTES_TYPES, VOTE_CONFIG, type VoteType } from "@/lib/voteConfig"; +import { useAuth } from "@/contexts/AuthContext"; +import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; + +interface VoteFilterChipsProps { + tab: "timeline" | "list"; +} + +/** + * Compact icon-only chips for the three Vote types (Must Go / Interested / + * Won't Go), letting the viewer filter both Schedule views down to sets + * they voted on themselves. Pairs with the ScheduleFilterSheet trigger + * (rendered as a sibling, not inside the sheet) in both the Timeline + * toolbar and the List view's filter row. + * + * Selection is OR-ed and lives in the shared URL state + * (`useTimelineUrlState`), so it counts toward ScheduleFilterSheet's badge + * and stays in sync across views. Always the viewer's own votes - group-vote + * scopes are out of scope. + * + * Hidden entirely for logged-out visitors: no disabled state, no login + * teaser, just absent. + */ +export function VoteFilterChips({ tab }: VoteFilterChipsProps) { + const { user } = useAuth(); + const { votes, updateVotes } = useTimelineUrlState(tab); + + if (!user) { + return null; + } + + return ( +
+ {VOTES_TYPES.map((voteType) => { + const config = VOTE_CONFIG[voteType]; + const Icon = config.icon; + const isSelected = votes.includes(voteType); + + return ( + + ); + })} +
+ ); + + function handleToggle(voteType: VoteType) { + const next = votes.includes(voteType) + ? votes.filter((selected) => selected !== voteType) + : [...votes, voteType]; + updateVotes(next); + } +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx index 0056fd87..77d80991 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx @@ -12,6 +12,8 @@ import { filterScheduleDays } from "@/lib/scheduleFilter"; import { stagesByEditionQuery } from "@/api/stages/useStagesByEdition"; import { useScheduleReveal } from "@/hooks/useScheduleReveal"; import { ScheduleNotRevealedPlaceholder } from "../ScheduleNotRevealedPlaceholder"; +import { useAuth } from "@/contexts/AuthContext"; +import { useUserVotes } from "@/api/voting/useUserVotes"; export function Timeline() { const { festival } = useFestivalEdition(); @@ -22,6 +24,8 @@ export function Timeline() { const { data: editionSets = [], isLoading: setsLoading } = useEditionSetsQuery(edition.id); const { data: stages } = useSuspenseQuery(stagesByEditionQuery(edition.id)); + const { user } = useAuth(); + const { data: userVotes } = useUserVotes(user?.id); const { scheduleDays, loading, error } = useScheduleData({ sets: editionSets, @@ -32,6 +36,7 @@ export function Timeline() { day: selectedDay, time: selectedTime, stages: selectedStages, + votes: selectedVotes, } = useTimelineUrlState("timeline"); const timelineData = useMemo(() => { @@ -41,7 +46,13 @@ export function Timeline() { const filteredScheduleDays = filterScheduleDays( scheduleDays, - { day: selectedDay, time: selectedTime, stages: selectedStages }, + { + day: selectedDay, + time: selectedTime, + stages: selectedStages, + voteTypes: selectedVotes, + userVotes, + }, festival.timezone, ); @@ -57,6 +68,8 @@ export function Timeline() { selectedDay, selectedTime, selectedStages, + selectedVotes, + userVotes, stages, festival.timezone, ]); diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx index d08f4042..58c85035 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx @@ -1,5 +1,6 @@ import { DayJumpButtons } from "./DayJumpButtons"; import { ScheduleFilterSheet } from "../ScheduleFilterSheet"; +import { VoteFilterChips } from "../VoteFilterChips"; import type { ScheduleDay } from "@/hooks/useScheduleData"; interface TimelineToolbarProps { @@ -10,10 +11,10 @@ interface TimelineToolbarProps { } /** - * Sticky toolbar above the Timeline strip. Hosts day-jump buttons and the - * Filters trigger (bottom sheet) inline in the same row - the Filters - * trigger never renders on its own line. The Now pill and "Show overview" - * toggle (upcoming stacked tickets) will join them here too. + * Sticky toolbar above the Timeline strip. Hosts day-jump buttons, the + * my-vote chips, and the Filters trigger (bottom sheet) inline in the same + * row - the Filters trigger never renders on its own line. The Now pill and + * "Show overview" toggle (upcoming stacked tickets) will join them here too. * * Day-jump buttons live inside their own `timeline-day-buttons` group so * tests (and future additions to this row) can target them without also @@ -48,7 +49,8 @@ export function TimelineToolbar({ />
)} -
+
+
diff --git a/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx b/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx index 78bba4bf..c6746fe9 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx @@ -12,6 +12,8 @@ import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; import { stagesByEditionQuery } from "@/api/stages/useStagesByEdition"; import { useScheduleReveal } from "@/hooks/useScheduleReveal"; import { ScheduleNotRevealedPlaceholder } from "../ScheduleNotRevealedPlaceholder"; +import { useAuth } from "@/contexts/AuthContext"; +import { useUserVotes } from "@/api/voting/useUserVotes"; interface TimeSlot { time: Date; @@ -27,6 +29,8 @@ export function ListSchedule() { const { data: editionSets = [], isLoading: setsLoading } = useEditionSetsQuery(edition.id); const { data: stages } = useSuspenseQuery(stagesByEditionQuery(edition.id)); + const { user } = useAuth(); + const { data: userVotes } = useUserVotes(user?.id); const { scheduleDays, loading, error } = useScheduleData({ sets: editionSets, stages, @@ -36,6 +40,7 @@ export function ListSchedule() { day: selectedDay, time: selectedTime, stages: selectedStages, + votes: selectedVotes, } = useTimelineUrlState("list"); const timeSlots = useMemo(() => { @@ -43,7 +48,13 @@ export function ListSchedule() { const filteredScheduleDays = filterScheduleDays( scheduleDays, - { day: selectedDay, time: selectedTime, stages: selectedStages }, + { + day: selectedDay, + time: selectedTime, + stages: selectedStages, + voteTypes: selectedVotes, + userVotes, + }, festival.timezone, ); @@ -101,6 +112,8 @@ export function ListSchedule() { selectedDay, selectedTime, selectedStages, + selectedVotes, + userVotes, stages, festival.timezone, ]); diff --git a/src/pages/EditionView/tabs/ScheduleTab/list/ListTab.tsx b/src/pages/EditionView/tabs/ScheduleTab/list/ListTab.tsx index d1bf6114..3f761644 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/list/ListTab.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/list/ListTab.tsx @@ -1,6 +1,7 @@ import { FestivalTimeBadge } from "../FestivalTimeBadge"; import { ListSchedule } from "./ListSchedule"; import { ScheduleFilterSheet } from "../ScheduleFilterSheet"; +import { VoteFilterChips } from "../VoteFilterChips"; import { FilterContainer } from "@/components/filters/FilterContainer"; export function ScheduleTabList() { @@ -12,6 +13,7 @@ export function ScheduleTabList() {

Filters

+
diff --git a/tests/e2e/schedule-vote-chips.spec.ts b/tests/e2e/schedule-vote-chips.spec.ts new file mode 100644 index 00000000..79006a03 --- /dev/null +++ b/tests/e2e/schedule-vote-chips.spec.ts @@ -0,0 +1,106 @@ +import { test, expect } from "@playwright/test"; +import { TestHelpers } from "../utils/test-helpers"; + +// Seeded in supabase/seed.sql: festival slug "test", edition slug "2025". +const TIMELINE_PATH = "/festivals/test/editions/2025/schedule/timeline"; +const LIST_PATH = "/festivals/test/editions/2025/schedule/list"; + +// Sets seeded on Friday July 12, 2025 (see `public.sets` inserts in seed.sql). +const MAYA_SET_ID = "11111111-1111-1111-1111-111111111111"; +const BEN_SET_ID = "22222222-2222-2222-2222-222222222222"; +const KIARA_SET_ID = "33333333-3333-3333-3333-333333333333"; + +async function signInOrSkip(page: import("@playwright/test").Page) { + const testHelpers = new TestHelpers(page); + try { + await testHelpers.signIn(); + } catch { + // Ignore - handled by the isAuthenticated check below. + } + + const authenticated = await testHelpers.isAuthenticated(); + test.skip( + !authenticated, + "Seeded environment can't authenticate a voting user", + ); +} + +test.describe("My-vote chips", () => { + test("do not render for logged-out visitors on either view", async ({ + page, + }) => { + await page.goto(TIMELINE_PATH); + + const scrollContainer = page.getByTestId("timeline-scroll-container"); + if (!(await scrollContainer.isVisible().catch(() => false))) { + test.skip(true, "Schedule not revealed in this environment"); + } + + await expect(page.getByTestId("vote-filter-chips")).toHaveCount(0); + + await page.goto(LIST_PATH); + const listSchedule = page.getByTestId("list-schedule"); + if (!(await listSchedule.isVisible().catch(() => false))) { + test.skip(true, "Schedule not revealed in this environment"); + } + + await expect(page.getByTestId("vote-filter-chips")).toHaveCount(0); + }); + + test("two-tap 'my schedule': Must Go + Interested filters both views to the viewer's own votes", async ({ + page, + }) => { + await signInOrSkip(page); + + await page.goto(LIST_PATH); + const listSchedule = page.getByTestId("list-schedule"); + if (!(await listSchedule.isVisible().catch(() => false))) { + test.skip(true, "Schedule not revealed in this environment"); + } + + // Vote Must Go on one set and Interested on another, leaving a third + // (Kiara Scuro) unvoted. + await page + .getByTestId(`vote-buttons-${MAYA_SET_ID}`) + .getByTestId("vote-button-mustGo") + .click(); + await page + .getByTestId(`vote-buttons-${BEN_SET_ID}`) + .getByTestId("vote-button-interested") + .click(); + + await expect(page.getByTestId("vote-filter-chips")).toBeVisible(); + + // Two taps: Must Go, then Interested. + await page.getByTestId("vote-filter-chip-mustGo").click(); + await page.getByTestId("vote-filter-chip-interested").click(); + + await expect(page).toHaveURL(/votes=/); + await expect(page.getByTestId("schedule-filters-badge")).toHaveText("2"); + + await expect( + page.getByTestId(`vote-buttons-${MAYA_SET_ID}`), + ).toBeVisible(); + await expect(page.getByTestId(`vote-buttons-${BEN_SET_ID}`)).toBeVisible(); + await expect( + page.getByTestId(`vote-buttons-${KIARA_SET_ID}`), + ).toHaveCount(0); + + // Shared URL state: the Timeline view sees the same selection and badge. + const url = new URL(page.url()); + await page.goto(`${TIMELINE_PATH}${url.search}`); + + const scrollContainer = page.getByTestId("timeline-scroll-container"); + if (!(await scrollContainer.isVisible().catch(() => false))) { + test.skip(true, "Schedule not revealed in this environment"); + } + + await expect(page.getByTestId("schedule-filters-badge")).toHaveText("2"); + await expect( + page.getByTestId(`vote-buttons-${MAYA_SET_ID}`), + ).toBeVisible(); + await expect( + page.getByTestId(`vote-buttons-${KIARA_SET_ID}`), + ).toHaveCount(0); + }); +}); From f7d8be1d35299ddc16a25db070843e4ef362efd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 19:14:03 +0000 Subject: [PATCH 2/3] fix: make vote filter inert when logged out and parse votes tolerantly A shared ?votes= link no longer dead-ends logged-out visitors: without a viewer identity the vote predicate passes every set and the filter badge skips the vote count. Malformed vote entries in the URL are dropped individually instead of discarding the whole selection. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- src/lib/scheduleFilter.test.ts | 52 +++++++++++++++++++ src/lib/scheduleFilter.ts | 11 +++- src/lib/searchSchemas.test.ts | 34 ++++++++++++ src/lib/searchSchemas.ts | 15 ++++-- .../tabs/ScheduleTab/ScheduleFilterSheet.tsx | 9 +++- tests/e2e/schedule-vote-chips.spec.ts | 19 +++++++ 6 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 src/lib/searchSchemas.test.ts diff --git a/src/lib/scheduleFilter.test.ts b/src/lib/scheduleFilter.test.ts index b4145745..2219265a 100644 --- a/src/lib/scheduleFilter.test.ts +++ b/src/lib/scheduleFilter.test.ts @@ -341,6 +341,58 @@ describe("filterScheduleDays", () => { expect(result[0].stages[0].sets).toHaveLength(0); }); + it("is inert when userVotes is undefined (no viewer identity)", () => { + const days = [ + makeDay({ + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 1, + sets: [makeSet({ id: "set-1" }), makeSet({ id: "set-2" })], + }, + ], + }), + ]; + + const result = filterScheduleDays( + days, + baseCriteria({ voteTypes: ["mustGo"], userVotes: undefined }), + TIMEZONE, + ); + + expect(result[0].stages[0].sets.map((s) => s.id)).toEqual([ + "set-1", + "set-2", + ]); + }); + + it("excludes a set whose vote value is unrecognized", () => { + const days = [ + makeDay({ + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 1, + sets: [makeSet({ id: "weird-vote-set" })], + }, + ], + }), + ]; + + const result = filterScheduleDays( + days, + baseCriteria({ + voteTypes: ["mustGo"], + userVotes: { "weird-vote-set": 0 }, + }), + TIMEZONE, + ); + + expect(result[0].stages[0].sets).toHaveLength(0); + }); + it("excludes a set missing from the votes map entirely", () => { const days = [ makeDay({ diff --git a/src/lib/scheduleFilter.ts b/src/lib/scheduleFilter.ts index 8bda0377..f3714436 100644 --- a/src/lib/scheduleFilter.ts +++ b/src/lib/scheduleFilter.ts @@ -22,7 +22,11 @@ export interface ScheduleFilterCriteria { voteTypes?: VoteType[]; // The viewer's own votes, keyed by set id -> vote value (same shape as // useUserVotes' return). Passed in, never fetched inside - keeps this - // function pure. + // function pure. The absent/empty distinction matters: `undefined` means + // there is no viewer identity (logged out), so vote filtering is INERT and + // every set passes even with `voteTypes` selected (a shared `?votes=` link + // must never dead-end a logged-out visitor); an empty object means a + // logged-in viewer with no votes, so an active filter excludes everything. userVotes?: Record; } @@ -54,8 +58,11 @@ function matchesVoteTypes( userVotes: Record | undefined, ): boolean { if (!voteTypes || voteTypes.length === 0) return true; + // No viewer identity (logged out): the vote filter is inert - see the + // absent-vs-empty note on ScheduleFilterCriteria.userVotes. + if (userVotes === undefined) return true; - const voteValue = userVotes?.[set.id]; + const voteValue = userVotes[set.id]; if (voteValue === undefined) return false; const voteType = getVoteConfig(voteValue); diff --git a/src/lib/searchSchemas.test.ts b/src/lib/searchSchemas.test.ts new file mode 100644 index 00000000..8977eb94 --- /dev/null +++ b/src/lib/searchSchemas.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { timelineSearchSchema } from "./searchSchemas"; + +describe("timelineSearchSchema", () => { + describe("votes (my-vote chips)", () => { + it("keeps valid vote types", () => { + const parsed = timelineSearchSchema.parse({ + votes: ["mustGo", "interested", "wontGo"], + }); + + expect(parsed.votes).toEqual(["mustGo", "interested", "wontGo"]); + }); + + it("drops only the invalid entries, keeping valid ones", () => { + const parsed = timelineSearchSchema.parse({ + votes: ["mustGo", "bogus"], + }); + + expect(parsed.votes).toEqual(["mustGo"]); + }); + + it("falls back to an empty selection when votes is not an array", () => { + const parsed = timelineSearchSchema.parse({ votes: "junk" }); + + expect(parsed.votes).toEqual([]); + }); + + it("defaults to an empty selection when votes is absent", () => { + const parsed = timelineSearchSchema.parse({}); + + expect(parsed.votes).toEqual([]); + }); + }); +}); diff --git a/src/lib/searchSchemas.ts b/src/lib/searchSchemas.ts index 51852d77..015dfbb0 100644 --- a/src/lib/searchSchemas.ts +++ b/src/lib/searchSchemas.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { VOTES_TYPES } from "@/lib/voteConfig"; +import { VOTES_TYPES, type VoteType } from "@/lib/voteConfig"; export const sortOptionSchema = z.enum([ "name-asc", @@ -43,8 +43,17 @@ export const timelineSearchSchema = z.object({ stages: z.array(z.string()).catch([]), // My-vote chips (PRD #188): selected vote types (mustGo/interested/wontGo), // OR-ed together. Empty means the filter is off. Always the viewer's own - // votes, never a group-vote scope. - votes: z.array(z.enum(VOTES_TYPES)).catch([]), + // votes, never a group-vote scope. Unknown entries are dropped one by one + // (not the whole array), so a partially malformed shared link keeps its + // valid selections. + votes: z + .array(z.string()) + .catch([]) + .transform((votes) => + votes.filter((vote): vote is VoteType => + (VOTES_TYPES as readonly string[]).includes(vote), + ), + ), // The moment (ISO datetime) centered in the timeline viewport. Absent by // default; only written once the user scrolls. See useTimelineScrollSync. scrollTo: z.string().optional().catch(undefined), diff --git a/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx b/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx index ed90fc2f..e48c2c65 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx @@ -16,6 +16,7 @@ import { DayFilterSelect } from "./DayFilterSelect"; import { TimeFilterSelect } from "./TimeFilterSelect"; import { StageFilterButtons } from "./StageFilterButtons"; import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; +import { useAuth } from "@/contexts/AuthContext"; interface ScheduleFilterSheetProps { tab: "timeline" | "list"; @@ -37,10 +38,14 @@ interface ScheduleFilterSheetProps { * and every selected stage counts 1 of its own (so picking two stages adds * 2). My-vote chips (rendered next to this trigger, not inside the sheet - * see VoteFilterChips) follow the same per-item rule: every selected vote - * type counts 1 of its own, so picking Must Go + Interested adds 2. + * type counts 1 of its own, so picking Must Go + Interested adds 2 - but + * only while a viewer is logged in. Logged out, the vote filter is inert + * (see ScheduleFilterCriteria.userVotes), so the badge must not advertise + * it; "Clear all" still clears the `votes` param regardless. */ export function ScheduleFilterSheet({ tab }: ScheduleFilterSheetProps) { const [open, setOpen] = useState(false); + const { user } = useAuth(); const { day, time, @@ -56,7 +61,7 @@ export function ScheduleFilterSheet({ tab }: ScheduleFilterSheetProps) { (day !== "all" ? 1 : 0) + (time !== "all" ? 1 : 0) + stages.length + - votes.length; + (user ? votes.length : 0); const hasActiveFilters = activeFilterCount > 0; return ( diff --git a/tests/e2e/schedule-vote-chips.spec.ts b/tests/e2e/schedule-vote-chips.spec.ts index 79006a03..4856afca 100644 --- a/tests/e2e/schedule-vote-chips.spec.ts +++ b/tests/e2e/schedule-vote-chips.spec.ts @@ -47,6 +47,25 @@ test.describe("My-vote chips", () => { await expect(page.getByTestId("vote-filter-chips")).toHaveCount(0); }); + test("a shared ?votes= link is inert for logged-out visitors - sets still show", async ({ + page, + }) => { + await page.goto(`${LIST_PATH}?votes=mustGo`); + + const listSchedule = page.getByTestId("list-schedule"); + if (!(await listSchedule.isVisible().catch(() => false))) { + test.skip(true, "Schedule not revealed in this environment"); + } + + // No viewer identity, so the vote filter must not empty the schedule. + await expect( + page.getByTestId(`vote-buttons-${MAYA_SET_ID}`), + ).toBeVisible(); + await expect(page.getByTestId("vote-filter-chips")).toHaveCount(0); + // The badge must not advertise the inert vote filter. + await expect(page.getByTestId("schedule-filters-badge")).toHaveCount(0); + }); + test("two-tap 'my schedule': Must Go + Interested filters both views to the viewer's own votes", async ({ page, }) => { From ecb18b6d80f9f6a5b083b8eb86d0e6f0e4a4a665 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:48:03 +0000 Subject: [PATCH 3/3] test(schedule): move filter test helpers below cases Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- src/lib/scheduleFilter.test.ts | 74 +++++++++++++++++----------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/src/lib/scheduleFilter.test.ts b/src/lib/scheduleFilter.test.ts index 2219265a..ccd26cfc 100644 --- a/src/lib/scheduleFilter.test.ts +++ b/src/lib/scheduleFilter.test.ts @@ -4,43 +4,6 @@ import type { ScheduleDay, ScheduleSet } from "@/hooks/useScheduleData"; const TIMEZONE = "Europe/Lisbon"; -function makeSet(overrides: Partial = {}): ScheduleSet { - return { - id: "set-1", - name: "A set", - artists: [], - startTime: new Date("2024-07-15T10:00:00Z"), - ...overrides, - }; -} - -function makeDay(overrides: Partial = {}): ScheduleDay { - return { - date: "2024-07-15", - displayDate: "Monday, Jul 15", - stages: [ - { - id: "stage-1", - name: "Main Stage", - stage_order: 1, - sets: [makeSet()], - }, - ], - ...overrides, - }; -} - -function baseCriteria( - overrides: Partial = {}, -): ScheduleFilterCriteria { - return { - day: "all", - time: "all", - stages: [], - ...overrides, - }; -} - describe("filterScheduleDays", () => { describe("day predicate", () => { it("keeps all days when day is 'all'", () => { @@ -490,3 +453,40 @@ describe("filterScheduleDays", () => { }); }); }); + +function makeSet(overrides: Partial = {}): ScheduleSet { + return { + id: "set-1", + name: "A set", + artists: [], + startTime: new Date("2024-07-15T10:00:00Z"), + ...overrides, + }; +} + +function makeDay(overrides: Partial = {}): ScheduleDay { + return { + date: "2024-07-15", + displayDate: "Monday, Jul 15", + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 1, + sets: [makeSet()], + }, + ], + ...overrides, + }; +} + +function baseCriteria( + overrides: Partial = {}, +): ScheduleFilterCriteria { + return { + day: "all", + time: "all", + stages: [], + ...overrides, + }; +}