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}`,
+ );
+ }
+}