diff --git a/src/components/layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx b/src/components/layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx index 81b5e927..a55deb43 100644 --- a/src/components/layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx +++ b/src/components/layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx @@ -36,7 +36,7 @@ export function ActiveGroupSwitcher({ variant="outline" size={isMobile ? "sm" : "default"} className={className} - aria-label={isMobile ? `Active scope: ${currentLabel}` : undefined} + aria-label={`Active scope: ${currentLabel}`} > {!isMobile && ( diff --git a/src/hooks/useScheduleVoteScope.ts b/src/hooks/useScheduleVoteScope.ts new file mode 100644 index 00000000..5474b78a --- /dev/null +++ b/src/hooks/useScheduleVoteScope.ts @@ -0,0 +1,34 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useActiveScope } from "@/contexts/ActiveScopeContext"; +import { groupMembersQuery } from "@/api/groups/useGroupMembers"; +import type { VoteScope } from "@/lib/voteScope"; + +/** + * 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() { + const { current } = useActiveScope(); + const groupId = current.kind === "group" ? current.groupId : undefined; + + const { data: members } = useQuery({ + ...groupMembersQuery(groupId ?? ""), + enabled: !!groupId, + }); + + const groupMemberIds = useMemo( + () => + members ? new Set(members.map((member) => member.user_id)) : undefined, + [members], + ); + + const voteScope: VoteScope = current.kind; + + return { + voteScope, + groupMemberIds: current.kind === "group" ? groupMemberIds : undefined, + }; +} diff --git a/src/lib/scheduleFilter.test.ts b/src/lib/scheduleFilter.test.ts index b255ee42..a6ab8f43 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,118 @@ describe("filterScheduleDays", () => { days, baseCriteria({ voteTypes: ["mustGo"], - userVotes: { "some-other-set": 2 }, + currentUserId: "me", }), TIMEZONE, ); 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({ + 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 +599,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..c92f48ac 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 VoteScope } from "@/lib/voteScope"; import type { ScheduleDay, ScheduleSet, @@ -9,13 +10,18 @@ import type { export type ScheduleTimeFilter = TimelineSearch["time"]; +const EMPTY_MEMBER_IDS: Set = new Set(); + export interface ScheduleFilterCriteria { day: string; time: ScheduleTimeFilter; stages: string[]; voteTypes?: VoteType[]; + voteScope?: VoteScope; /** `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 +49,25 @@ function matchesTimeOfDay( function matchesVoteTypes( set: ScheduleSet, voteTypes: VoteType[] | undefined, - userVotes: Record | undefined, + voteScope: VoteScope | 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 ?? EMPTY_MEMBER_IDS, + 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 +94,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/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx b/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx index 838728fb..aae69305 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/ScheduleFilterSheet.tsx @@ -104,9 +104,7 @@ export function ScheduleFilterSheet({ tab }: ScheduleFilterSheetProps) { {user && (
- +
)} diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/list.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug/schedule/list.tsx index 69a03c2e..d49178e3 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(); 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..9f286eb5 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(); 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, ]); diff --git a/tests/e2e/schedule-vote-scope.spec.ts b/tests/e2e/schedule-vote-scope.spec.ts new file mode 100644 index 00000000..b709a208 --- /dev/null +++ b/tests/e2e/schedule-vote-scope.spec.ts @@ -0,0 +1,125 @@ +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.getByRole("button", { name: /^Active scope:/ }); +} + +// 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(); + 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. +// 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) { + const headerChips = firstDayHeader(page).getByRole("group", { + name: "Filter by my vote", + }); + + const isDesktop = await headerChips + .waitFor({ state: "visible", timeout: 5000 }) + .then(() => true) + .catch(() => false); + + if (!isDesktop) { + await firstDayHeader(page).getByTestId("schedule-filters-trigger").click(); + const sheetChips = page + .getByRole("dialog") + .getByRole("group", { name: "Filter by my vote" }); + await expect(sheetChips).toBeVisible(); + await sheetChips.getByRole("button", { name: "Must Go" }).click(); + await page + .getByRole("dialog") + .getByRole("button", { name: "Done" }) + .click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + return; + } + + await headerChips.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()); + + viewerContext = await browser.newContext({ baseURL, storageState }); + viewerPage = await viewerContext.newPage(); + const viewerEmail = await signIn(viewerPage, generateTestEmail()); + + 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 () => { + // 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(); + + 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}`, + ); + } +}