From c10adc9e2b9cd81b5b13234613471beb7d147108 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 07:44:26 +0000 Subject: [PATCH 1/8] feat(schedule): add Vote Scope toggle (Me / Active Group) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Me ↔ Active Group toggle alongside the Schedule tab's vote-type filter chips, independent of the Artists tab's Vote Perspective. Reuses resolveVotesForScope on each set's full vote list (already fetched with sets) instead of the prior current-user-only votes query, and adds a voteScope URL param so shared filter links preserve the choice. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012xAfdayZzwNNJ8EGap3Rix --- src/hooks/useScheduleVoteScope.ts | 55 +++++++ src/hooks/useTimelineUrlState.ts | 15 ++ src/lib/scheduleFilter.test.ts | 141 +++++++++++++++--- src/lib/scheduleFilter.ts | 35 ++++- src/lib/searchSchemas.ts | 6 + src/lib/voteScope.ts | 1 + .../tabs/ScheduleTab/ScheduleFilterSheet.tsx | 6 +- .../ScheduleTab/ScheduleVoteScopeToggle.tsx | 28 ++++ .../tabs/ScheduleTab/VoteScopeToggle.tsx | 44 ++++++ .../horizontal/TimelineToolbar.tsx | 4 +- .../tabs/ScheduleTab/list/ListDayGroup.tsx | 4 +- .../editions/$editionSlug/schedule/list.tsx | 12 +- .../$editionSlug/schedule/timeline.tsx | 12 +- 13 files changed, 323 insertions(+), 40 deletions(-) create mode 100644 src/hooks/useScheduleVoteScope.ts create mode 100644 src/pages/EditionView/tabs/ScheduleTab/ScheduleVoteScopeToggle.tsx create mode 100644 src/pages/EditionView/tabs/ScheduleTab/VoteScopeToggle.tsx diff --git a/src/hooks/useScheduleVoteScope.ts b/src/hooks/useScheduleVoteScope.ts new file mode 100644 index 00000000..cf426ef4 --- /dev/null +++ b/src/hooks/useScheduleVoteScope.ts @@ -0,0 +1,55 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useAuth } from "@/contexts/AuthContext"; +import { useActiveScope } from "@/contexts/ActiveScopeContext"; +import { userGroupsQuery } from "@/api/groups/useUserGroups"; +import { groupMembersQuery } from "@/api/groups/useGroupMembers"; +import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; +import type { MeGroupVoteScope } from "@/lib/voteScope"; + +/** + * Resolves the Schedule tab's Vote Scope (Me / Active Group): the URL choice + * when one was made, else Active Group when the user has one, else Me. + * Also resolves the Active Group's member ids for group-scope filtering, + * `undefined` while loading (or when there's no active group). + */ +export function useScheduleVoteScope(tab: "timeline" | "list") { + const { user } = useAuth(); + const { activeGroupId } = useActiveScope(); + const { voteScope: urlVoteScope, updateVoteScope } = useTimelineUrlState(tab); + const { data: groups } = useQuery({ + ...userGroupsQuery(user?.id ?? ""), + enabled: !!user, + }); + const { data: members } = useQuery({ + ...groupMembersQuery(activeGroupId ?? ""), + enabled: !!activeGroupId, + }); + + const groupName = activeGroupId + ? groups?.find((group) => group.id === activeGroupId)?.name + : undefined; + + const groupMemberIds = useMemo( + () => + members ? new Set(members.map((member) => member.user_id)) : undefined, + [members], + ); + + const voteScope: MeGroupVoteScope = + urlVoteScope === "group" && activeGroupId + ? "group" + : urlVoteScope === "me" + ? "me" + : activeGroupId + ? "group" + : "me"; + + return { + voteScope, + activeGroupId, + groupName, + groupMemberIds: voteScope === "group" ? groupMemberIds : undefined, + updateVoteScope, + }; +} diff --git a/src/hooks/useTimelineUrlState.ts b/src/hooks/useTimelineUrlState.ts index 8f5e7206..3e586383 100644 --- a/src/hooks/useTimelineUrlState.ts +++ b/src/hooks/useTimelineUrlState.ts @@ -2,6 +2,7 @@ import { useCallback } from "react"; import { useSearch, useNavigate } from "@tanstack/react-router"; import type { TimelineSearch } from "@/lib/searchSchemas"; import type { VoteType } from "@/lib/voteConfig"; +import type { MeGroupVoteScope } from "@/lib/voteScope"; export type TimeFilter = TimelineSearch["time"]; @@ -15,6 +16,7 @@ export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") { time: search.time, stages: search.stages, votes: search.votes, + voteScope: search.voteScope, }), }); const navigate = useNavigate({ from: route }); @@ -63,6 +65,17 @@ export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") { [navigate], ); + const updateVoteScope = useCallback( + (voteScope: MeGroupVoteScope) => { + navigate({ + to: ".", + search: (prev) => ({ ...prev, voteScope }), + replace: true, + }); + }, + [navigate], + ); + const clearFilters = useCallback(() => { navigate({ to: ".", @@ -76,10 +89,12 @@ export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") { time: state.time, stages: state.stages, votes: state.votes, + voteScope: state.voteScope, updateDay, updateTime, updateStages, updateVotes, + updateVoteScope, clearFilters, }; } diff --git a/src/lib/scheduleFilter.test.ts b/src/lib/scheduleFilter.test.ts index b255ee42..19c01d51 100644 --- a/src/lib/scheduleFilter.test.ts +++ b/src/lib/scheduleFilter.test.ts @@ -202,7 +202,13 @@ describe("filterScheduleDays", () => { id: "stage-1", name: "Main Stage", stage_order: 1, - sets: [makeSet({ id: "set-1" }), makeSet({ id: "set-2" })], + sets: [ + makeSet({ + id: "set-1", + votes: [{ user_id: "me", vote_type: 2 }], + }), + makeSet({ id: "set-2" }), + ], }, ], }), @@ -210,7 +216,7 @@ describe("filterScheduleDays", () => { const result = filterScheduleDays( days, - baseCriteria({ voteTypes: [], userVotes: { "set-1": 2 } }), + baseCriteria({ voteTypes: [], currentUserId: "me" }), TIMEZONE, ); @@ -220,7 +226,7 @@ describe("filterScheduleDays", () => { ]); }); - it("keeps only sets matching a single selected vote type", () => { + it("keeps only sets matching a single selected vote type (me scope)", () => { const days = [ makeDay({ stages: [ @@ -229,8 +235,14 @@ describe("filterScheduleDays", () => { name: "Main Stage", stage_order: 1, sets: [ - makeSet({ id: "must-go-set" }), - makeSet({ id: "interested-set" }), + makeSet({ + id: "must-go-set", + votes: [{ user_id: "me", vote_type: 2 }], + }), + makeSet({ + id: "interested-set", + votes: [{ user_id: "me", vote_type: 1 }], + }), ], }, ], @@ -241,7 +253,8 @@ describe("filterScheduleDays", () => { days, baseCriteria({ voteTypes: ["mustGo"], - userVotes: { "must-go-set": 2, "interested-set": 1 }, + voteScope: "me", + currentUserId: "me", }), TIMEZONE, ); @@ -260,9 +273,18 @@ describe("filterScheduleDays", () => { name: "Main Stage", stage_order: 1, sets: [ - makeSet({ id: "must-go-set" }), - makeSet({ id: "interested-set" }), - makeSet({ id: "wont-go-set" }), + makeSet({ + id: "must-go-set", + votes: [{ user_id: "me", vote_type: 2 }], + }), + makeSet({ + id: "interested-set", + votes: [{ user_id: "me", vote_type: 1 }], + }), + makeSet({ + id: "wont-go-set", + votes: [{ user_id: "me", vote_type: -1 }], + }), ], }, ], @@ -273,11 +295,7 @@ describe("filterScheduleDays", () => { days, baseCriteria({ voteTypes: ["mustGo", "interested"], - userVotes: { - "must-go-set": 2, - "interested-set": 1, - "wont-go-set": -1, - }, + currentUserId: "me", }), TIMEZONE, ); @@ -304,14 +322,14 @@ describe("filterScheduleDays", () => { const result = filterScheduleDays( days, - baseCriteria({ voteTypes: ["mustGo"], userVotes: {} }), + baseCriteria({ voteTypes: ["mustGo"], currentUserId: "me" }), TIMEZONE, ); expect(result[0].stages[0].sets).toHaveLength(0); }); - it("is inert when userVotes is undefined (no viewer identity)", () => { + it("is inert when currentUserId is undefined (no viewer identity)", () => { const days = [ makeDay({ stages: [ @@ -327,7 +345,7 @@ describe("filterScheduleDays", () => { const result = filterScheduleDays( days, - baseCriteria({ voteTypes: ["mustGo"], userVotes: undefined }), + baseCriteria({ voteTypes: ["mustGo"], currentUserId: undefined }), TIMEZONE, ); @@ -345,7 +363,12 @@ describe("filterScheduleDays", () => { id: "stage-1", name: "Main Stage", stage_order: 1, - sets: [makeSet({ id: "weird-vote-set" })], + sets: [ + makeSet({ + id: "weird-vote-set", + votes: [{ user_id: "me", vote_type: 0 }], + }), + ], }, ], }), @@ -355,7 +378,7 @@ describe("filterScheduleDays", () => { days, baseCriteria({ voteTypes: ["mustGo"], - userVotes: { "weird-vote-set": 0 }, + currentUserId: "me", }), TIMEZONE, ); @@ -371,7 +394,12 @@ describe("filterScheduleDays", () => { id: "stage-1", name: "Main Stage", stage_order: 1, - sets: [makeSet({ id: "not-in-map" })], + sets: [ + makeSet({ + id: "not-in-map", + votes: [{ user_id: "someone-else", vote_type: 2 }], + }), + ], }, ], }), @@ -381,13 +409,83 @@ describe("filterScheduleDays", () => { days, baseCriteria({ voteTypes: ["mustGo"], - userVotes: { "some-other-set": 2 }, + currentUserId: "me", }), TIMEZONE, ); expect(result[0].stages[0].sets).toHaveLength(0); }); + + it("under group scope, matches a set voted on by any group member", () => { + const days = [ + makeDay({ + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 1, + sets: [ + makeSet({ + id: "group-must-go", + votes: [{ user_id: "teammate", vote_type: 2 }], + }), + makeSet({ + id: "outsider-must-go", + votes: [{ user_id: "stranger", vote_type: 2 }], + }), + ], + }, + ], + }), + ]; + + const result = filterScheduleDays( + days, + baseCriteria({ + voteTypes: ["mustGo"], + voteScope: "group", + currentUserId: "me", + groupMemberIds: new Set(["me", "teammate"]), + }), + TIMEZONE, + ); + + expect(result[0].stages[0].sets.map((s) => s.id)).toEqual([ + "group-must-go", + ]); + }); + + it("is inert under group scope when groupMemberIds is undefined (still loading)", () => { + 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"], + voteScope: "group", + currentUserId: "me", + groupMemberIds: undefined, + }), + TIMEZONE, + ); + + expect(result[0].stages[0].sets.map((s) => s.id)).toEqual([ + "set-1", + "set-2", + ]); + }); }); describe("combinations", () => { @@ -466,6 +564,7 @@ function makeSet(overrides: Partial = {}): ScheduleSet { id: "set-1", name: "A set", artists: [], + votes: [], startTime: new Date("2024-07-15T10:00:00Z"), ...overrides, }; diff --git a/src/lib/scheduleFilter.ts b/src/lib/scheduleFilter.ts index 59a25f7a..9dd5cb8d 100644 --- a/src/lib/scheduleFilter.ts +++ b/src/lib/scheduleFilter.ts @@ -1,6 +1,7 @@ import { getFestivalHour } from "@/lib/timeUtils"; import type { TimelineSearch } from "@/lib/searchSchemas"; import { getVoteConfig, type VoteType } from "@/lib/voteConfig"; +import { resolveVotesForScope, type MeGroupVoteScope } from "@/lib/voteScope"; import type { ScheduleDay, ScheduleSet, @@ -14,8 +15,11 @@ export interface ScheduleFilterCriteria { time: ScheduleTimeFilter; stages: string[]; voteTypes?: VoteType[]; + voteScope?: MeGroupVoteScope; /** `undefined` (logged out) makes vote filtering inert, not exclusionary. */ - userVotes?: Record; + currentUserId?: string; + /** `undefined` (group members still loading, or no group) makes group-scope vote filtering inert. */ + groupMemberIds?: Set; } function matchesTimeOfDay( @@ -43,16 +47,25 @@ function matchesTimeOfDay( function matchesVoteTypes( set: ScheduleSet, voteTypes: VoteType[] | undefined, - userVotes: Record | undefined, + voteScope: MeGroupVoteScope | undefined, + currentUserId: string | undefined, + groupMemberIds: Set | undefined, ): boolean { if (!voteTypes || voteTypes.length === 0) return true; - if (userVotes === undefined) return true; + if (currentUserId === undefined) return true; + if (voteScope === "group" && groupMemberIds === undefined) return true; - const voteValue = userVotes[set.id]; - if (voteValue === undefined) return false; + const scopedVotes = resolveVotesForScope({ + votes: set.votes || [], + scope: voteScope ?? "me", + groupMemberIds: groupMemberIds ?? new Set(), + currentUserId, + }); - const voteType = getVoteConfig(voteValue); - return voteType !== undefined && voteTypes.includes(voteType); + return scopedVotes.some((vote) => { + const voteType = getVoteConfig(vote.vote_type); + return voteType !== undefined && voteTypes.includes(voteType); + }); } /** @@ -79,7 +92,13 @@ export function filterScheduleDays( sets: stage.sets.filter( (set) => matchesTimeOfDay(set, criteria.time, timezone) && - matchesVoteTypes(set, criteria.voteTypes, criteria.userVotes), + matchesVoteTypes( + set, + criteria.voteTypes, + criteria.voteScope, + criteria.currentUserId, + criteria.groupMemberIds, + ), ), })); diff --git a/src/lib/searchSchemas.ts b/src/lib/searchSchemas.ts index 8008b6ea..785b18b8 100644 --- a/src/lib/searchSchemas.ts +++ b/src/lib/searchSchemas.ts @@ -49,6 +49,12 @@ export const timelineSearchSchema = z.object({ ), ), ]), + /** + * `undefined` means "not explicitly chosen" — the effective default + * (Active Group when one exists, else Me) is resolved by the consuming + * hook, not baked into this schema. + */ + voteScope: z.enum(["me", "group"]).optional().catch(undefined), /** Viewport-centered moment; only written once the user scrolls. */ scrollTo: z.string().optional().catch(undefined), }); diff --git a/src/lib/voteScope.ts b/src/lib/voteScope.ts index 83e20574..cb4509c6 100644 --- a/src/lib/voteScope.ts +++ b/src/lib/voteScope.ts @@ -3,6 +3,7 @@ export type VoteScope = (typeof VOTE_SCOPES)[number]; /** The two-state subset used by toggles that don't offer a "me" option. */ export type BinaryVoteScope = Exclude; +export type MeGroupVoteScope = Exclude; interface ScopedVote { user_id: string; diff --git a/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx b/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx index 838728fb..a2a64aee 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 { VoteFilterChips } from "./VoteFilterChips"; +import { ScheduleVoteScopeToggle } from "./ScheduleVoteScopeToggle"; import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; import { useAuth } from "@/contexts/AuthContext"; @@ -107,7 +108,10 @@ export function ScheduleFilterSheet({ tab }: ScheduleFilterSheetProps) { - +
+ + +
)} diff --git a/src/pages/EditionView/tabs/ScheduleTab/ScheduleVoteScopeToggle.tsx b/src/pages/EditionView/tabs/ScheduleTab/ScheduleVoteScopeToggle.tsx new file mode 100644 index 00000000..dffdd96e --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/ScheduleVoteScopeToggle.tsx @@ -0,0 +1,28 @@ +import { useAuth } from "@/contexts/AuthContext"; +import { useScheduleVoteScope } from "@/hooks/useScheduleVoteScope"; +import { VoteScopeToggle } from "@/pages/EditionView/tabs/ScheduleTab/VoteScopeToggle"; + +interface ScheduleVoteScopeToggleProps { + tab: "timeline" | "list"; +} + +/** + * Me / Active Group toggle for the Schedule tab's vote-type filter chips. + * Hidden when logged out or when the user has no Active Group to filter by. + */ +export function ScheduleVoteScopeToggle({ tab }: ScheduleVoteScopeToggleProps) { + const { user } = useAuth(); + const { voteScope, groupName, updateVoteScope } = useScheduleVoteScope(tab); + + if (!user || !groupName) { + return null; + } + + return ( + + ); +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/VoteScopeToggle.tsx b/src/pages/EditionView/tabs/ScheduleTab/VoteScopeToggle.tsx new file mode 100644 index 00000000..2f6b3e9d --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/VoteScopeToggle.tsx @@ -0,0 +1,44 @@ +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import type { MeGroupVoteScope } from "@/lib/voteScope"; + +interface VoteScopeToggleProps { + scope: MeGroupVoteScope; + onScopeChange: (scope: MeGroupVoteScope) => void; + groupName: string; +} + +export function VoteScopeToggle({ + scope, + onScopeChange, + groupName, +}: VoteScopeToggleProps) { + return ( + { + if (value === "me" || value === "group") { + onScopeChange(value); + } + }} + className="rounded-md border border-purple-400/30 p-0.5" + > + + Me + + + {groupName} + + + ); +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx index 115708d9..e5c05b8d 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx @@ -5,6 +5,7 @@ import { NowButton } from "./NowButton"; import { Button } from "@/components/ui/button"; import { ScheduleFilterSheet } from "../ScheduleFilterSheet"; import { VoteFilterChips } from "../VoteFilterChips"; +import { ScheduleVoteScopeToggle } from "../ScheduleVoteScopeToggle"; import type { ScheduleDay } from "@/hooks/useScheduleData"; import { useScrollEdgeFade } from "./useScrollEdgeFade"; import { STICKY_TOP_BELOW_TOP_BAR_CLASS } from "@/lib/layout-constants"; @@ -98,7 +99,8 @@ export function TimelineToolbar({ {isOverviewExpanded ? "Hide overview" : "Show overview"} -
+
+
diff --git a/src/pages/EditionView/tabs/ScheduleTab/list/ListDayGroup.tsx b/src/pages/EditionView/tabs/ScheduleTab/list/ListDayGroup.tsx index db04dab3..6eb0a956 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/list/ListDayGroup.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/list/ListDayGroup.tsx @@ -4,6 +4,7 @@ import { STICKY_TOP_BELOW_TOP_BAR_CLASS } from "@/lib/layout-constants"; import { getFestivalDayLabel } from "@/lib/timeUtils"; import { ScheduleFilterSheet } from "../ScheduleFilterSheet"; import { VoteFilterChips } from "../VoteFilterChips"; +import { ScheduleVoteScopeToggle } from "../ScheduleVoteScopeToggle"; import { TimeSlotGroup } from "./TimeSlotGroup"; import type { ScheduleSet } from "@/hooks/useScheduleData"; @@ -34,7 +35,8 @@ export function ListDayGroup({ dayKey, slots, timezone }: ListDayGroupProps) {

{dayLabel}

-
+
+
diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/list.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/list.tsx index 69a03c2e..644341bd 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/list.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/list.tsx @@ -15,7 +15,7 @@ import { stagesByEditionQuery } from "@/api/stages/useStagesByEdition"; import { useScheduleReveal } from "@/hooks/useScheduleReveal"; import { ScheduleLineupView } from "@/pages/EditionView/tabs/ScheduleTab/lineup/ScheduleLineupView"; import { useAuth } from "@/contexts/AuthContext"; -import { useUserVotesQuery } from "@/api/voting/useUserVotesQuery"; +import { useScheduleVoteScope } from "@/hooks/useScheduleVoteScope"; import { timelineSearchDefaults, timelineSearchSchema, @@ -51,7 +51,7 @@ function ListSchedule() { useEditionSetsQuery(edition.id); const { data: stages } = useSuspenseQuery(stagesByEditionQuery(edition.id)); const { user } = useAuth(); - const { data: userVotes } = useUserVotesQuery(user?.id); + const { voteScope, groupMemberIds } = useScheduleVoteScope("list"); const { scheduleDays } = useScheduleData({ sets: editionSets, stages, @@ -74,7 +74,9 @@ function ListSchedule() { time: selectedTime, stages: selectedStages, voteTypes: selectedVotes, - userVotes, + voteScope, + currentUserId: user?.id, + groupMemberIds, }, festival.timezone, ); @@ -149,7 +151,9 @@ function ListSchedule() { selectedTime, selectedStages, selectedVotes, - userVotes, + voteScope, + user?.id, + groupMemberIds, stages, festival.timezone, ]); diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline.tsx index a29c4f29..0c0d705f 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline.tsx @@ -24,7 +24,7 @@ import { stagesByEditionQuery } from "@/api/stages/useStagesByEdition"; import { useScheduleReveal } from "@/hooks/useScheduleReveal"; import { ScheduleLineupView } from "@/pages/EditionView/tabs/ScheduleTab/lineup/ScheduleLineupView"; import { useAuth } from "@/contexts/AuthContext"; -import { useUserVotesQuery } from "@/api/voting/useUserVotesQuery"; +import { useScheduleVoteScope } from "@/hooks/useScheduleVoteScope"; export const Route = createFileRoute( "/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline", @@ -56,7 +56,7 @@ function TimelineContent() { useEditionSetsQuery(edition.id); const { data: stages } = useSuspenseQuery(stagesByEditionQuery(edition.id)); const { user } = useAuth(); - const { data: userVotes } = useUserVotesQuery(user?.id); + const { voteScope, groupMemberIds } = useScheduleVoteScope("timeline"); const { scheduleDays } = useScheduleData({ sets: editionSets, @@ -87,7 +87,9 @@ function TimelineContent() { time: selectedTime, stages: selectedStages, voteTypes: selectedVotes, - userVotes, + voteScope, + currentUserId: user?.id, + groupMemberIds, }, festival.timezone, ); @@ -105,7 +107,9 @@ function TimelineContent() { selectedTime, selectedStages, selectedVotes, - userVotes, + voteScope, + user?.id, + groupMemberIds, stages, festival.timezone, ]); From f2d754ac78ddcc96cd6f59d9bfc37e55f0c458e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 07:51:09 +0000 Subject: [PATCH 2/8] fix(schedule): address Copilot review on Vote Scope PR Rename mobile "My vote" label to scope-neutral "Vote", reuse a shared empty Set instead of allocating one per filtered set, and skip the group-members query when the resolved scope isn't "group". Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012xAfdayZzwNNJ8EGap3Rix --- src/hooks/useScheduleVoteScope.ts | 21 ++++++++++--------- src/lib/scheduleFilter.ts | 4 +++- .../tabs/ScheduleTab/ScheduleFilterSheet.tsx | 4 +--- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/hooks/useScheduleVoteScope.ts b/src/hooks/useScheduleVoteScope.ts index cf426ef4..793eb093 100644 --- a/src/hooks/useScheduleVoteScope.ts +++ b/src/hooks/useScheduleVoteScope.ts @@ -21,9 +21,19 @@ export function useScheduleVoteScope(tab: "timeline" | "list") { ...userGroupsQuery(user?.id ?? ""), enabled: !!user, }); + + const voteScope: MeGroupVoteScope = + urlVoteScope === "group" && activeGroupId + ? "group" + : urlVoteScope === "me" + ? "me" + : activeGroupId + ? "group" + : "me"; + const { data: members } = useQuery({ ...groupMembersQuery(activeGroupId ?? ""), - enabled: !!activeGroupId, + enabled: !!activeGroupId && voteScope === "group", }); const groupName = activeGroupId @@ -36,15 +46,6 @@ export function useScheduleVoteScope(tab: "timeline" | "list") { [members], ); - const voteScope: MeGroupVoteScope = - urlVoteScope === "group" && activeGroupId - ? "group" - : urlVoteScope === "me" - ? "me" - : activeGroupId - ? "group" - : "me"; - return { voteScope, activeGroupId, diff --git a/src/lib/scheduleFilter.ts b/src/lib/scheduleFilter.ts index 9dd5cb8d..5280b95b 100644 --- a/src/lib/scheduleFilter.ts +++ b/src/lib/scheduleFilter.ts @@ -10,6 +10,8 @@ import type { export type ScheduleTimeFilter = TimelineSearch["time"]; +const EMPTY_MEMBER_IDS: Set = new Set(); + export interface ScheduleFilterCriteria { day: string; time: ScheduleTimeFilter; @@ -58,7 +60,7 @@ function matchesVoteTypes( const scopedVotes = resolveVotesForScope({ votes: set.votes || [], scope: voteScope ?? "me", - groupMemberIds: groupMemberIds ?? new Set(), + groupMemberIds: groupMemberIds ?? EMPTY_MEMBER_IDS, currentUserId, }); diff --git a/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx b/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx index a2a64aee..b6e8f89e 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx @@ -105,9 +105,7 @@ export function ScheduleFilterSheet({ tab }: ScheduleFilterSheetProps) { {user && (
- +
From 37a0bf5c5840b776a4908189ed4a874454cf4613 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 10:19:47 +0000 Subject: [PATCH 3/8] refactor(schedule): drive vote-chip scope from the navbar switcher Removes the Schedule-tab-local Vote Scope toggle and voteScope URL param. The navbar's Active Scope switcher already offers Everyone / Me / Active Group globally, so the vote-type filter chips now read that scope directly instead of duplicating it with a narrower, Schedule-only Me/Group control that didn't respond to the navbar. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012xAfdayZzwNNJ8EGap3Rix --- src/hooks/useScheduleVoteScope.ts | 48 +++++-------------- src/hooks/useTimelineUrlState.ts | 15 ------ src/lib/scheduleFilter.test.ts | 35 ++++++++++++++ src/lib/scheduleFilter.ts | 6 +-- src/lib/searchSchemas.ts | 6 --- src/lib/voteScope.ts | 1 - .../tabs/ScheduleTab/ScheduleFilterSheet.tsx | 6 +-- .../ScheduleTab/ScheduleVoteScopeToggle.tsx | 28 ----------- .../tabs/ScheduleTab/VoteScopeToggle.tsx | 44 ----------------- .../horizontal/TimelineToolbar.tsx | 4 +- .../tabs/ScheduleTab/list/ListDayGroup.tsx | 4 +- .../editions/$editionSlug/schedule/list.tsx | 2 +- .../$editionSlug/schedule/timeline.tsx | 2 +- 13 files changed, 56 insertions(+), 145 deletions(-) delete mode 100644 src/pages/EditionView/tabs/ScheduleTab/ScheduleVoteScopeToggle.tsx delete mode 100644 src/pages/EditionView/tabs/ScheduleTab/VoteScopeToggle.tsx diff --git a/src/hooks/useScheduleVoteScope.ts b/src/hooks/useScheduleVoteScope.ts index 793eb093..5474b78a 100644 --- a/src/hooks/useScheduleVoteScope.ts +++ b/src/hooks/useScheduleVoteScope.ts @@ -1,56 +1,34 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; -import { useAuth } from "@/contexts/AuthContext"; import { useActiveScope } from "@/contexts/ActiveScopeContext"; -import { userGroupsQuery } from "@/api/groups/useUserGroups"; import { groupMembersQuery } from "@/api/groups/useGroupMembers"; -import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; -import type { MeGroupVoteScope } from "@/lib/voteScope"; +import type { VoteScope } from "@/lib/voteScope"; /** - * Resolves the Schedule tab's Vote Scope (Me / Active Group): the URL choice - * when one was made, else Active Group when the user has one, else Me. - * Also resolves the Active Group's member ids for group-scope filtering, - * `undefined` while loading (or when there's no active group). + * Resolves the Schedule tab's vote-chip scope from the global Active Scope + * (the navbar switcher) — Everyone / Me / Active Group — plus the Active + * Group's member ids for group-scope filtering, `undefined` while loading + * (or when the scope isn't "group"). */ -export function useScheduleVoteScope(tab: "timeline" | "list") { - const { user } = useAuth(); - const { activeGroupId } = useActiveScope(); - const { voteScope: urlVoteScope, updateVoteScope } = useTimelineUrlState(tab); - const { data: groups } = useQuery({ - ...userGroupsQuery(user?.id ?? ""), - enabled: !!user, - }); - - const voteScope: MeGroupVoteScope = - urlVoteScope === "group" && activeGroupId - ? "group" - : urlVoteScope === "me" - ? "me" - : activeGroupId - ? "group" - : "me"; +export function useScheduleVoteScope() { + const { current } = useActiveScope(); + const groupId = current.kind === "group" ? current.groupId : undefined; const { data: members } = useQuery({ - ...groupMembersQuery(activeGroupId ?? ""), - enabled: !!activeGroupId && voteScope === "group", + ...groupMembersQuery(groupId ?? ""), + enabled: !!groupId, }); - const groupName = activeGroupId - ? groups?.find((group) => group.id === activeGroupId)?.name - : undefined; - const groupMemberIds = useMemo( () => members ? new Set(members.map((member) => member.user_id)) : undefined, [members], ); + const voteScope: VoteScope = current.kind; + return { voteScope, - activeGroupId, - groupName, - groupMemberIds: voteScope === "group" ? groupMemberIds : undefined, - updateVoteScope, + groupMemberIds: current.kind === "group" ? groupMemberIds : undefined, }; } diff --git a/src/hooks/useTimelineUrlState.ts b/src/hooks/useTimelineUrlState.ts index 3e586383..8f5e7206 100644 --- a/src/hooks/useTimelineUrlState.ts +++ b/src/hooks/useTimelineUrlState.ts @@ -2,7 +2,6 @@ import { useCallback } from "react"; import { useSearch, useNavigate } from "@tanstack/react-router"; import type { TimelineSearch } from "@/lib/searchSchemas"; import type { VoteType } from "@/lib/voteConfig"; -import type { MeGroupVoteScope } from "@/lib/voteScope"; export type TimeFilter = TimelineSearch["time"]; @@ -16,7 +15,6 @@ export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") { time: search.time, stages: search.stages, votes: search.votes, - voteScope: search.voteScope, }), }); const navigate = useNavigate({ from: route }); @@ -65,17 +63,6 @@ export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") { [navigate], ); - const updateVoteScope = useCallback( - (voteScope: MeGroupVoteScope) => { - navigate({ - to: ".", - search: (prev) => ({ ...prev, voteScope }), - replace: true, - }); - }, - [navigate], - ); - const clearFilters = useCallback(() => { navigate({ to: ".", @@ -89,12 +76,10 @@ export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") { time: state.time, stages: state.stages, votes: state.votes, - voteScope: state.voteScope, updateDay, updateTime, updateStages, updateVotes, - updateVoteScope, clearFilters, }; } diff --git a/src/lib/scheduleFilter.test.ts b/src/lib/scheduleFilter.test.ts index 19c01d51..a6ab8f43 100644 --- a/src/lib/scheduleFilter.test.ts +++ b/src/lib/scheduleFilter.test.ts @@ -417,6 +417,41 @@ describe("filterScheduleDays", () => { expect(result[0].stages[0].sets).toHaveLength(0); }); + it("under everyone scope, matches a set voted on by any user at all", () => { + const days = [ + makeDay({ + stages: [ + { + id: "stage-1", + name: "Main Stage", + stage_order: 1, + sets: [ + makeSet({ + id: "stranger-must-go", + votes: [{ user_id: "total-stranger", vote_type: 2 }], + }), + makeSet({ id: "unvoted" }), + ], + }, + ], + }), + ]; + + const result = filterScheduleDays( + days, + baseCriteria({ + voteTypes: ["mustGo"], + voteScope: "everyone", + currentUserId: "me", + }), + TIMEZONE, + ); + + expect(result[0].stages[0].sets.map((s) => s.id)).toEqual([ + "stranger-must-go", + ]); + }); + it("under group scope, matches a set voted on by any group member", () => { const days = [ makeDay({ diff --git a/src/lib/scheduleFilter.ts b/src/lib/scheduleFilter.ts index 5280b95b..c92f48ac 100644 --- a/src/lib/scheduleFilter.ts +++ b/src/lib/scheduleFilter.ts @@ -1,7 +1,7 @@ import { getFestivalHour } from "@/lib/timeUtils"; import type { TimelineSearch } from "@/lib/searchSchemas"; import { getVoteConfig, type VoteType } from "@/lib/voteConfig"; -import { resolveVotesForScope, type MeGroupVoteScope } from "@/lib/voteScope"; +import { resolveVotesForScope, type VoteScope } from "@/lib/voteScope"; import type { ScheduleDay, ScheduleSet, @@ -17,7 +17,7 @@ export interface ScheduleFilterCriteria { time: ScheduleTimeFilter; stages: string[]; voteTypes?: VoteType[]; - voteScope?: MeGroupVoteScope; + voteScope?: VoteScope; /** `undefined` (logged out) makes vote filtering inert, not exclusionary. */ currentUserId?: string; /** `undefined` (group members still loading, or no group) makes group-scope vote filtering inert. */ @@ -49,7 +49,7 @@ function matchesTimeOfDay( function matchesVoteTypes( set: ScheduleSet, voteTypes: VoteType[] | undefined, - voteScope: MeGroupVoteScope | undefined, + voteScope: VoteScope | undefined, currentUserId: string | undefined, groupMemberIds: Set | undefined, ): boolean { diff --git a/src/lib/searchSchemas.ts b/src/lib/searchSchemas.ts index 785b18b8..8008b6ea 100644 --- a/src/lib/searchSchemas.ts +++ b/src/lib/searchSchemas.ts @@ -49,12 +49,6 @@ export const timelineSearchSchema = z.object({ ), ), ]), - /** - * `undefined` means "not explicitly chosen" — the effective default - * (Active Group when one exists, else Me) is resolved by the consuming - * hook, not baked into this schema. - */ - voteScope: z.enum(["me", "group"]).optional().catch(undefined), /** Viewport-centered moment; only written once the user scrolls. */ scrollTo: z.string().optional().catch(undefined), }); diff --git a/src/lib/voteScope.ts b/src/lib/voteScope.ts index cb4509c6..83e20574 100644 --- a/src/lib/voteScope.ts +++ b/src/lib/voteScope.ts @@ -3,7 +3,6 @@ export type VoteScope = (typeof VOTE_SCOPES)[number]; /** The two-state subset used by toggles that don't offer a "me" option. */ export type BinaryVoteScope = Exclude; -export type MeGroupVoteScope = Exclude; interface ScopedVote { user_id: string; diff --git a/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx b/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx index b6e8f89e..aae69305 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx @@ -16,7 +16,6 @@ import { DayFilterSelect } from "./DayFilterSelect"; import { TimeFilterSelect } from "./TimeFilterSelect"; import { StageFilterButtons } from "./StageFilterButtons"; import { VoteFilterChips } from "./VoteFilterChips"; -import { ScheduleVoteScopeToggle } from "./ScheduleVoteScopeToggle"; import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; import { useAuth } from "@/contexts/AuthContext"; @@ -106,10 +105,7 @@ export function ScheduleFilterSheet({ tab }: ScheduleFilterSheetProps) { {user && (
-
- - -
+
)} diff --git a/src/pages/EditionView/tabs/ScheduleTab/ScheduleVoteScopeToggle.tsx b/src/pages/EditionView/tabs/ScheduleTab/ScheduleVoteScopeToggle.tsx deleted file mode 100644 index dffdd96e..00000000 --- a/src/pages/EditionView/tabs/ScheduleTab/ScheduleVoteScopeToggle.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useAuth } from "@/contexts/AuthContext"; -import { useScheduleVoteScope } from "@/hooks/useScheduleVoteScope"; -import { VoteScopeToggle } from "@/pages/EditionView/tabs/ScheduleTab/VoteScopeToggle"; - -interface ScheduleVoteScopeToggleProps { - tab: "timeline" | "list"; -} - -/** - * Me / Active Group toggle for the Schedule tab's vote-type filter chips. - * Hidden when logged out or when the user has no Active Group to filter by. - */ -export function ScheduleVoteScopeToggle({ tab }: ScheduleVoteScopeToggleProps) { - const { user } = useAuth(); - const { voteScope, groupName, updateVoteScope } = useScheduleVoteScope(tab); - - if (!user || !groupName) { - return null; - } - - return ( - - ); -} diff --git a/src/pages/EditionView/tabs/ScheduleTab/VoteScopeToggle.tsx b/src/pages/EditionView/tabs/ScheduleTab/VoteScopeToggle.tsx deleted file mode 100644 index 2f6b3e9d..00000000 --- a/src/pages/EditionView/tabs/ScheduleTab/VoteScopeToggle.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import type { MeGroupVoteScope } from "@/lib/voteScope"; - -interface VoteScopeToggleProps { - scope: MeGroupVoteScope; - onScopeChange: (scope: MeGroupVoteScope) => void; - groupName: string; -} - -export function VoteScopeToggle({ - scope, - onScopeChange, - groupName, -}: VoteScopeToggleProps) { - return ( - { - if (value === "me" || value === "group") { - onScopeChange(value); - } - }} - className="rounded-md border border-purple-400/30 p-0.5" - > - - Me - - - {groupName} - - - ); -} diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx index e5c05b8d..115708d9 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx @@ -5,7 +5,6 @@ import { NowButton } from "./NowButton"; import { Button } from "@/components/ui/button"; import { ScheduleFilterSheet } from "../ScheduleFilterSheet"; import { VoteFilterChips } from "../VoteFilterChips"; -import { ScheduleVoteScopeToggle } from "../ScheduleVoteScopeToggle"; import type { ScheduleDay } from "@/hooks/useScheduleData"; import { useScrollEdgeFade } from "./useScrollEdgeFade"; import { STICKY_TOP_BELOW_TOP_BAR_CLASS } from "@/lib/layout-constants"; @@ -99,8 +98,7 @@ export function TimelineToolbar({ {isOverviewExpanded ? "Hide overview" : "Show overview"} -
- +
diff --git a/src/pages/EditionView/tabs/ScheduleTab/list/ListDayGroup.tsx b/src/pages/EditionView/tabs/ScheduleTab/list/ListDayGroup.tsx index 6eb0a956..db04dab3 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/list/ListDayGroup.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/list/ListDayGroup.tsx @@ -4,7 +4,6 @@ import { STICKY_TOP_BELOW_TOP_BAR_CLASS } from "@/lib/layout-constants"; import { getFestivalDayLabel } from "@/lib/timeUtils"; import { ScheduleFilterSheet } from "../ScheduleFilterSheet"; import { VoteFilterChips } from "../VoteFilterChips"; -import { ScheduleVoteScopeToggle } from "../ScheduleVoteScopeToggle"; import { TimeSlotGroup } from "./TimeSlotGroup"; import type { ScheduleSet } from "@/hooks/useScheduleData"; @@ -35,8 +34,7 @@ export function ListDayGroup({ dayKey, slots, timezone }: ListDayGroupProps) {

{dayLabel}

-
- +
diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/list.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/list.tsx index 644341bd..d49178e3 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/list.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/list.tsx @@ -51,7 +51,7 @@ function ListSchedule() { useEditionSetsQuery(edition.id); const { data: stages } = useSuspenseQuery(stagesByEditionQuery(edition.id)); const { user } = useAuth(); - const { voteScope, groupMemberIds } = useScheduleVoteScope("list"); + const { voteScope, groupMemberIds } = useScheduleVoteScope(); const { scheduleDays } = useScheduleData({ sets: editionSets, stages, diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline.tsx index 0c0d705f..9f286eb5 100644 --- a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline.tsx +++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline.tsx @@ -56,7 +56,7 @@ function TimelineContent() { useEditionSetsQuery(edition.id); const { data: stages } = useSuspenseQuery(stagesByEditionQuery(edition.id)); const { user } = useAuth(); - const { voteScope, groupMemberIds } = useScheduleVoteScope("timeline"); + const { voteScope, groupMemberIds } = useScheduleVoteScope(); const { scheduleDays } = useScheduleData({ sets: editionSets, From 813f7b934541c0901223fb31975ad668d1ad16be Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 15:01:32 +0000 Subject: [PATCH 4/8] test(e2e): cover Schedule vote-chip scope across two group members Two signed-in users join one group; the voter casts a Must Go vote, then the viewer confirms the Must Go chip surfaces that set under Group scope, hides it under Me scope, and shows it again under Everyone scope, all via the navbar Active Scope switcher. Adds a data-testid to the switcher trigger and an addMemberToGroup test helper for multi-member group setup. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012xAfdayZzwNNJ8EGap3Rix --- .../GroupSwitcher/ActiveGroupSwitcher.tsx | 1 + tests/e2e/schedule-vote-scope.spec.ts | 108 ++++++++++++++++++ tests/utils/groups.ts | 23 ++++ 3 files changed, 132 insertions(+) create mode 100644 tests/e2e/schedule-vote-scope.spec.ts diff --git a/src/components/layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx b/src/components/layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx index 81b5e927..27d3be8b 100644 --- a/src/components/layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx +++ b/src/components/layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx @@ -36,6 +36,7 @@ export function ActiveGroupSwitcher({ variant="outline" size={isMobile ? "sm" : "default"} className={className} + data-testid="active-scope-switcher" aria-label={isMobile ? `Active scope: ${currentLabel}` : undefined} > diff --git a/tests/e2e/schedule-vote-scope.spec.ts b/tests/e2e/schedule-vote-scope.spec.ts new file mode 100644 index 00000000..cb1e0065 --- /dev/null +++ b/tests/e2e/schedule-vote-scope.spec.ts @@ -0,0 +1,108 @@ +import { test, expect, type BrowserContext, type Page } from "@playwright/test"; +import { signIn, generateTestEmail } from "../utils/login"; +import { createGroupWithMember, addMemberToGroup } from "../utils/groups"; + +// Seeded in supabase/seed.sql: festival slug "test", edition slug "2025". +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_NAME = "Maya Jane Coles"; + +function voteGroup(page: Page, setName: string) { + return page.getByRole("group", { name: `Vote for ${setName}` }); +} + +function listSchedule(page: Page) { + return page.getByRole("region", { name: "Schedule by day" }); +} + +// Every sticky day header carries its own copy of the filter controls, so +// list-view assertions have to name a single day group. +function firstDayHeader(page: Page) { + return listSchedule(page).getByRole("region").first(); +} + +function scopeSwitcher(page: Page) { + return page.getByTestId("active-scope-switcher"); +} + +async function selectScope(page: Page, label: string) { + await scopeSwitcher(page).click(); + await page.getByRole("menuitem", { name: label, exact: true }).click(); +} + +// Below md the chips live inside the filter sheet, not the day header. +async function selectMustGoChip(page: Page) { + let chips = firstDayHeader(page).getByRole("group", { + name: "Filter by my vote", + }); + + if (!(await chips.isVisible())) { + await firstDayHeader(page).getByTestId("schedule-filters-trigger").click(); + chips = page + .getByRole("dialog") + .getByRole("group", { name: "Filter by my vote" }); + await expect(chips).toBeVisible(); + await chips.getByRole("button", { name: "Must Go" }).click(); + await page + .getByRole("dialog") + .getByRole("button", { name: "Done" }) + .click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + return; + } + + await chips.getByRole("button", { name: "Must Go" }).click(); +} + +test.describe("Schedule vote-chip scope follows the navbar Active Scope", () => { + test.describe.configure({ mode: "serial" }); + + let voterContext: BrowserContext; + let voterPage: Page; + let viewerContext: BrowserContext; + let viewerPage: Page; + let groupName: string; + + test.beforeAll(async ({ browser, baseURL, storageState }) => { + voterContext = await browser.newContext({ baseURL, storageState }); + voterPage = await voterContext.newPage(); + const voterEmail = await signIn(voterPage, generateTestEmail("voter")); + + viewerContext = await browser.newContext({ baseURL, storageState }); + viewerPage = await viewerContext.newPage(); + const viewerEmail = await signIn(viewerPage, generateTestEmail("viewer")); + + const group = await createGroupWithMember(voterEmail); + groupName = group.groupName; + await addMemberToGroup(group.groupId, viewerEmail); + + // The voter casts a Must Go vote that only the viewer's group scope + // (not their own "me" vote) should surface. + await voterPage.goto(LIST_PATH); + await expect(listSchedule(voterPage)).toBeVisible(); + await voteGroup(voterPage, MAYA_SET_NAME) + .getByRole("button", { name: "Must Go" }) + .click(); + }); + + test.afterAll(async () => { + await voterContext?.close(); + await viewerContext?.close(); + }); + + test("group scope shows a teammate's vote, me scope hides it, everyone scope shows it", async () => { + await viewerPage.goto(LIST_PATH); + await expect(listSchedule(viewerPage)).toBeVisible(); + + await selectScope(viewerPage, groupName); + await selectMustGoChip(viewerPage); + await expect(voteGroup(viewerPage, MAYA_SET_NAME)).toBeVisible(); + + await selectScope(viewerPage, "Me"); + await expect(voteGroup(viewerPage, MAYA_SET_NAME)).toHaveCount(0); + + await selectScope(viewerPage, "Everyone"); + await expect(voteGroup(viewerPage, MAYA_SET_NAME)).toBeVisible(); + }); +}); diff --git a/tests/utils/groups.ts b/tests/utils/groups.ts index 66a29b10..8e648d28 100644 --- a/tests/utils/groups.ts +++ b/tests/utils/groups.ts @@ -71,3 +71,26 @@ export async function createGroupWithMember( return { groupId: group.id, groupName }; } + +// Adds an existing group's membership for another user. +export async function addMemberToGroup( + groupId: string, + email: string, +): Promise { + const userId = await getUserIdByEmail(email); + + const memberResponse = await fetch( + `${TEST_CONFIG.SUPABASE_URL}/rest/v1/group_members`, + { + method: "POST", + headers: { ...ADMIN_HEADERS, Prefer: "return=minimal" }, + body: JSON.stringify({ group_id: groupId, user_id: userId }), + }, + ); + + if (!memberResponse.ok) { + throw new Error( + `Failed to add test group member: ${memberResponse.status}`, + ); + } +} From 6dd9d8241f72a06c0db379b5791f9fb1afe223c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 15:56:03 +0000 Subject: [PATCH 5/8] fix(test): stop schedule-vote-scope e2e picking mobile path on desktop The desktop/mobile branch in selectMustGoChip used a one-shot isVisible() check right after a dropdown-menu interaction, which lost the race against the closing dropdown's transition and picked the mobile filter-sheet path on desktop, where the chips group doesn't exist. Swap it for a retrying waitFor(). Also drop the fixed "voter"/"viewer" email suffixes, which collided with themselves on Playwright's automatic retry and masked the real failure behind a 422 from the admin user-creation API. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012xAfdayZzwNNJ8EGap3Rix --- tests/e2e/schedule-vote-scope.spec.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/e2e/schedule-vote-scope.spec.ts b/tests/e2e/schedule-vote-scope.spec.ts index cb1e0065..3acdb71f 100644 --- a/tests/e2e/schedule-vote-scope.spec.ts +++ b/tests/e2e/schedule-vote-scope.spec.ts @@ -32,18 +32,25 @@ async function selectScope(page: Page, label: string) { } // Below md the chips live inside the filter sheet, not the day header. +// Uses a retrying wait (not a one-shot isVisible check) so a just-closed +// dropdown's transition can't be mistaken for the mobile layout. async function selectMustGoChip(page: Page) { - let chips = firstDayHeader(page).getByRole("group", { + const headerChips = firstDayHeader(page).getByRole("group", { name: "Filter by my vote", }); - if (!(await chips.isVisible())) { + const isDesktop = await headerChips + .waitFor({ state: "visible", timeout: 5000 }) + .then(() => true) + .catch(() => false); + + if (!isDesktop) { await firstDayHeader(page).getByTestId("schedule-filters-trigger").click(); - chips = page + const sheetChips = page .getByRole("dialog") .getByRole("group", { name: "Filter by my vote" }); - await expect(chips).toBeVisible(); - await chips.getByRole("button", { name: "Must Go" }).click(); + await expect(sheetChips).toBeVisible(); + await sheetChips.getByRole("button", { name: "Must Go" }).click(); await page .getByRole("dialog") .getByRole("button", { name: "Done" }) @@ -52,7 +59,7 @@ async function selectMustGoChip(page: Page) { return; } - await chips.getByRole("button", { name: "Must Go" }).click(); + await headerChips.getByRole("button", { name: "Must Go" }).click(); } test.describe("Schedule vote-chip scope follows the navbar Active Scope", () => { @@ -67,11 +74,11 @@ test.describe("Schedule vote-chip scope follows the navbar Active Scope", () => test.beforeAll(async ({ browser, baseURL, storageState }) => { voterContext = await browser.newContext({ baseURL, storageState }); voterPage = await voterContext.newPage(); - const voterEmail = await signIn(voterPage, generateTestEmail("voter")); + const voterEmail = await signIn(voterPage, generateTestEmail()); viewerContext = await browser.newContext({ baseURL, storageState }); viewerPage = await viewerContext.newPage(); - const viewerEmail = await signIn(viewerPage, generateTestEmail("viewer")); + const viewerEmail = await signIn(viewerPage, generateTestEmail()); const group = await createGroupWithMember(voterEmail); groupName = group.groupName; From ec664ab6d2eeb052ee08db4e9e6412d2ca6c84cf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 16:14:26 +0000 Subject: [PATCH 6/8] fix(test): raise schedule-vote-scope e2e timeout for slower browsers Three scope switches plus a chip selection and several assertions in one test exceeded Playwright's 30s CI default under firefox, which tore the page down mid-click and surfaced a misleading "context has been closed" error instead of a plain timeout. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012xAfdayZzwNNJ8EGap3Rix --- tests/e2e/schedule-vote-scope.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/e2e/schedule-vote-scope.spec.ts b/tests/e2e/schedule-vote-scope.spec.ts index 3acdb71f..f34042b1 100644 --- a/tests/e2e/schedule-vote-scope.spec.ts +++ b/tests/e2e/schedule-vote-scope.spec.ts @@ -99,6 +99,10 @@ test.describe("Schedule vote-chip scope follows the navbar Active Scope", () => }); test("group scope shows a teammate's vote, me scope hides it, everyone scope shows it", async () => { + // Three scope switches plus chip selection and assertions comfortably + // exceed the 30s CI default under slower browsers (e.g. firefox). + test.setTimeout(60_000); + await viewerPage.goto(LIST_PATH); await expect(listSchedule(viewerPage)).toBeVisible(); From 7991578174b2d5718eca3c701fa05ffd5fbe88de Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 16:27:47 +0000 Subject: [PATCH 7/8] fix(test): wait for scope dropdown open/close in schedule-vote-scope e2e selectScope clicked the trigger and menuitem back-to-back across three consecutive scope switches. Reopening the dropdown before the previous instance finished closing could detach the target menuitem mid-click, exhausting Playwright's action retries. Wait for the menu to actually open before clicking and close before moving on. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012xAfdayZzwNNJ8EGap3Rix --- tests/e2e/schedule-vote-scope.spec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/e2e/schedule-vote-scope.spec.ts b/tests/e2e/schedule-vote-scope.spec.ts index f34042b1..b170ebd2 100644 --- a/tests/e2e/schedule-vote-scope.spec.ts +++ b/tests/e2e/schedule-vote-scope.spec.ts @@ -26,9 +26,15 @@ function scopeSwitcher(page: Page) { return page.getByTestId("active-scope-switcher"); } +// Waits for the dropdown to fully open/close around each step so a menu +// still mid-close from the previous selection can't detach the item +// this click targets. async function selectScope(page: Page, label: string) { await scopeSwitcher(page).click(); - await page.getByRole("menuitem", { name: label, exact: true }).click(); + const menu = page.getByRole("menu"); + await expect(menu).toBeVisible(); + await menu.getByRole("menuitem", { name: label, exact: true }).click(); + await expect(menu).toHaveCount(0); } // Below md the chips live inside the filter sheet, not the day header. From bd0e8d9c6e7a860091432fd7498ac4160ef1f97a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 16:58:22 +0000 Subject: [PATCH 8/8] refactor(test): target the scope switcher by role, not a test id Per review feedback, drop data-testid="active-scope-switcher" and locate the button by its accessible name instead. Makes the aria-label unconditional (previously mobile-only) so the name is stable and available on desktop too. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012xAfdayZzwNNJ8EGap3Rix --- .../layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx | 3 +-- tests/e2e/schedule-vote-scope.spec.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx b/src/components/layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx index 27d3be8b..a55deb43 100644 --- a/src/components/layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx +++ b/src/components/layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx @@ -36,8 +36,7 @@ export function ActiveGroupSwitcher({ variant="outline" size={isMobile ? "sm" : "default"} className={className} - data-testid="active-scope-switcher" - aria-label={isMobile ? `Active scope: ${currentLabel}` : undefined} + aria-label={`Active scope: ${currentLabel}`} > {!isMobile && ( diff --git a/tests/e2e/schedule-vote-scope.spec.ts b/tests/e2e/schedule-vote-scope.spec.ts index b170ebd2..b709a208 100644 --- a/tests/e2e/schedule-vote-scope.spec.ts +++ b/tests/e2e/schedule-vote-scope.spec.ts @@ -23,7 +23,7 @@ function firstDayHeader(page: Page) { } function scopeSwitcher(page: Page) { - return page.getByTestId("active-scope-switcher"); + return page.getByRole("button", { name: /^Active scope:/ }); } // Waits for the dropdown to fully open/close around each step so a menu