diff --git a/src/hooks/useTimelineUrlState.ts b/src/hooks/useTimelineUrlState.ts index c7b1fac9..641fb063 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 TimeFilter = TimelineSearch["time"]; @@ -16,6 +17,7 @@ export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") { day: search.day, time: search.time, stages: search.stages, + votes: search.votes, }), structuralSharing: true, }); @@ -54,6 +56,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: ".", @@ -66,9 +79,11 @@ export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") { day: state.day, time: state.time, stages: state.stages, + votes: state.votes, updateDay, updateTime, updateStages, + updateVotes, clearFilters, }; } diff --git a/src/lib/scheduleFilter.test.ts b/src/lib/scheduleFilter.test.ts index 5d0571c9..e2b0d4f7 100644 --- a/src/lib/scheduleFilter.test.ts +++ b/src/lib/scheduleFilter.test.ts @@ -193,6 +193,200 @@ 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("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({ + 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 d610c3a6..1b67b6ba 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,18 @@ 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. 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; } function matchesTimeOfDay( @@ -42,6 +52,23 @@ function matchesTimeOfDay( } } +function matchesVoteTypes( + set: ScheduleSet, + voteTypes: VoteType[] | undefined, + 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]; + 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) purely to mirror the @@ -65,8 +92,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.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 17334de7..31c07e05 100644 --- a/src/lib/searchSchemas.ts +++ b/src/lib/searchSchemas.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { VOTES_TYPES, type VoteType } from "@/lib/voteConfig"; export const sortOptionSchema = z.enum([ "name-asc", @@ -38,6 +39,16 @@ 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([]), + // Viewer's own selected vote types, OR-ed; unknown entries drop one by + // one 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), + ), + ), scrollTo: z.string().optional().catch(undefined), }); @@ -47,4 +58,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..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"; @@ -35,14 +36,21 @@ 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 - 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, stages, + votes, updateDay, updateTime, updateStages, @@ -50,7 +58,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 + + (user ? votes.length : 0); 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 13463368..8ab9ea62 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 { @@ -36,7 +37,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..4856afca --- /dev/null +++ b/tests/e2e/schedule-vote-chips.spec.ts @@ -0,0 +1,125 @@ +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("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, + }) => { + 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); + }); +});