From 1a3b189fc346ab1ced50402c157ab6645ddcb734 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 30 Jul 2026 15:44:01 +0000
Subject: [PATCH 01/23] feat(groups): add persistent Active Group switcher +
Vote Perspective toggle
Turns the header's Groups indicator into a switcher listing Everyone plus
every group the user belongs to; selecting an entry persists it as the
profile's active_group_id and takes effect everywhere without a reload.
Retires the Artists-tab-only groupId URL param and its dropdown, replacing
it with a Vote Perspective toggle (Everyone vs Active Group) that re-scores
and re-sorts sets without hiding any. Extracts the ad hoc group-membership
vote filter into a shared, pure resolveVotesForScope function (Everyone/Me/
Group) so the next issue's Schedule tab scope can reuse it.
Closes #124
---
src/api/groups/useSetActiveGroupMutation.ts | 41 ++++++
.../layout/AppHeader/GroupsIndicator.tsx | 97 +++++++++++----
src/hooks/useUrlState.ts | 1 -
src/lib/searchSchemas.ts | 1 -
src/lib/voteScope.test.ts | 83 +++++++++++++
src/lib/voteScope.ts | 37 ++++++
.../tabs/VoteTab/FilteredSetsPanel.tsx | 117 ++++++++++++++----
.../VoteTab/filters/FilterSortControls.tsx | 22 +++-
.../VoteTab/filters/GroupFilterDropdown.tsx | 93 --------------
.../VoteTab/filters/VotePerspectiveToggle.tsx | 44 +++++++
.../tabs/VoteTab/useSetFiltering.ts | 26 ++--
.../editions/$editionSlug/sets/index.tsx | 25 +---
12 files changed, 402 insertions(+), 185 deletions(-)
create mode 100644 src/api/groups/useSetActiveGroupMutation.ts
create mode 100644 src/lib/voteScope.test.ts
create mode 100644 src/lib/voteScope.ts
delete mode 100644 src/pages/EditionView/tabs/VoteTab/filters/GroupFilterDropdown.tsx
create mode 100644 src/pages/EditionView/tabs/VoteTab/filters/VotePerspectiveToggle.tsx
diff --git a/src/api/groups/useSetActiveGroupMutation.ts b/src/api/groups/useSetActiveGroupMutation.ts
new file mode 100644
index 00000000..89c2aeb9
--- /dev/null
+++ b/src/api/groups/useSetActiveGroupMutation.ts
@@ -0,0 +1,41 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useToast } from "@/hooks/use-toast";
+import { supabase } from "@/integrations/supabase/client";
+import { profileKeys } from "@/api/auth/types";
+
+async function setActiveGroup(variables: {
+ userId: string;
+ groupId: string | null;
+}) {
+ const { userId, groupId } = variables;
+
+ const { error } = await supabase
+ .from("profiles")
+ .update({ active_group_id: groupId })
+ .eq("id", userId);
+
+ if (error) {
+ throw new Error("Failed to update active group");
+ }
+}
+
+export function useSetActiveGroupMutation() {
+ const queryClient = useQueryClient();
+ const { toast } = useToast();
+
+ return useMutation({
+ mutationFn: setActiveGroup,
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: profileKeys.detail(variables.userId),
+ });
+ },
+ onError: (error) => {
+ toast({
+ title: "Error",
+ description: error?.message || "Failed to update active group",
+ variant: "destructive",
+ });
+ },
+ });
+}
diff --git a/src/components/layout/AppHeader/GroupsIndicator.tsx b/src/components/layout/AppHeader/GroupsIndicator.tsx
index ac50f985..4596b0ec 100644
--- a/src/components/layout/AppHeader/GroupsIndicator.tsx
+++ b/src/components/layout/AppHeader/GroupsIndicator.tsx
@@ -1,9 +1,17 @@
import { Suspense } from "react";
import { Link } from "@tanstack/react-router";
-import { UserPlus, Users } from "lucide-react";
+import { UserPlus, Users, ChevronDown } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { useAuth } from "@/contexts/AuthContext";
import { useActiveGroup } from "@/hooks/useActiveGroup";
+import { useSetActiveGroupMutation } from "@/api/groups/useSetActiveGroupMutation";
import { cn } from "@/lib/utils";
import { TooltipButton } from "./TooltipButton";
@@ -40,29 +48,13 @@ function GroupsIndicatorContent({
isMobile: boolean;
userId: string;
}) {
- const { activeGroup, hasGroups } = useActiveGroup(userId);
+ const { activeGroup, activeGroupId, groups, hasGroups } =
+ useActiveGroup(userId);
+ const setActiveGroupMutation = useSetActiveGroupMutation();
- return (
-
- {hasGroups ? (
-
-
- {!isMobile && (
- {activeGroup?.name || "Groups"}
- )}
-
- ) : (
+ if (!hasGroups) {
+ return (
+
{!isMobile && Create/Join a Group }
- )}
-
+
+ );
+ }
+
+ function handleSelect(groupId: string | null) {
+ if (groupId === (activeGroupId ?? null)) {
+ return;
+ }
+ setActiveGroupMutation.mutate({ userId, groupId });
+ }
+
+ return (
+
+
+
+
+ {!isMobile && (
+
+ {activeGroup?.name || "Everyone"}
+
+
+ )}
+
+
+
+ handleSelect(null)}
+ className={cn(
+ "text-purple-100 hover:bg-purple-600/30",
+ !activeGroupId && "bg-purple-600/20",
+ )}
+ >
+ Everyone
+
+ {groups.map((group) => (
+ handleSelect(group.id)}
+ className={cn(
+ "text-purple-100 hover:bg-purple-600/30",
+ activeGroupId === group.id && "bg-purple-600/20",
+ )}
+ >
+ {group.name}
+
+ ))}
+
+
);
}
diff --git a/src/hooks/useUrlState.ts b/src/hooks/useUrlState.ts
index 598677e2..915e2340 100644
--- a/src/hooks/useUrlState.ts
+++ b/src/hooks/useUrlState.ts
@@ -30,7 +30,6 @@ export function useUrlState() {
to: ".",
search: (prev) => ({
invite: prev.invite,
- groupId: prev.groupId,
}),
replace: true,
});
diff --git a/src/lib/searchSchemas.ts b/src/lib/searchSchemas.ts
index 73af05b6..8008b6ea 100644
--- a/src/lib/searchSchemas.ts
+++ b/src/lib/searchSchemas.ts
@@ -18,7 +18,6 @@ export const filterSortSearchSchema = z.object({
minRating: z.coerce.number().catch(0),
timelineView: timelineViewSchema.catch("list"),
use24Hour: z.boolean().catch(true),
- groupId: z.string().optional(),
invite: z.string().optional(),
sortLocked: z.boolean().catch(false),
});
diff --git a/src/lib/voteScope.test.ts b/src/lib/voteScope.test.ts
new file mode 100644
index 00000000..1f8ffc92
--- /dev/null
+++ b/src/lib/voteScope.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from "vitest";
+import { resolveVotesForScope } from "./voteScope";
+
+const votes = [
+ { user_id: "user-1", vote_type: 2 },
+ { user_id: "user-2", vote_type: 1 },
+ { user_id: "user-3", vote_type: -1 },
+];
+
+describe("resolveVotesForScope", () => {
+ it("returns every vote for the everyone scope", () => {
+ expect(
+ resolveVotesForScope({
+ votes,
+ scope: "everyone",
+ groupMemberIds: [],
+ }),
+ ).toEqual(votes);
+ });
+
+ it("returns only the current user's votes for the me scope", () => {
+ expect(
+ resolveVotesForScope({
+ votes,
+ scope: "me",
+ groupMemberIds: [],
+ currentUserId: "user-2",
+ }),
+ ).toEqual([votes[1]]);
+ });
+
+ it("returns votes from group members, including the current user when they are a member", () => {
+ expect(
+ resolveVotesForScope({
+ votes,
+ scope: "group",
+ groupMemberIds: ["user-1", "user-2"],
+ currentUserId: "user-1",
+ }),
+ ).toEqual([votes[0], votes[1]]);
+ });
+
+ it("excludes the current user's own vote from the group scope when they are not a member", () => {
+ expect(
+ resolveVotesForScope({
+ votes,
+ scope: "group",
+ groupMemberIds: ["user-2", "user-3"],
+ currentUserId: "user-1",
+ }),
+ ).toEqual([votes[1], votes[2]]);
+ });
+
+ it("returns an empty array for a set with no votes, regardless of scope", () => {
+ expect(
+ resolveVotesForScope({
+ votes: [],
+ scope: "group",
+ groupMemberIds: ["user-1"],
+ }),
+ ).toEqual([]);
+ });
+
+ it("returns an empty array when every vote belongs to a user outside the group", () => {
+ expect(
+ resolveVotesForScope({
+ votes,
+ scope: "group",
+ groupMemberIds: ["user-4", "user-5"],
+ }),
+ ).toEqual([]);
+ });
+
+ it("accepts group member ids as a Set", () => {
+ expect(
+ resolveVotesForScope({
+ votes,
+ scope: "group",
+ groupMemberIds: new Set(["user-3"]),
+ }),
+ ).toEqual([votes[2]]);
+ });
+});
diff --git a/src/lib/voteScope.ts b/src/lib/voteScope.ts
new file mode 100644
index 00000000..af1bc8a3
--- /dev/null
+++ b/src/lib/voteScope.ts
@@ -0,0 +1,37 @@
+export const VOTE_SCOPES = ["everyone", "me", "group"] as const;
+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;
+
+interface ScopedVote {
+ user_id: string;
+ vote_type: number;
+}
+
+interface ResolveVotesForScopeParams {
+ votes: TVote[];
+ scope: VoteScope;
+ groupMemberIds: Set | string[];
+ currentUserId?: string;
+}
+
+export function resolveVotesForScope({
+ votes,
+ scope,
+ groupMemberIds,
+ currentUserId,
+}: ResolveVotesForScopeParams): TVote[] {
+ if (scope === "everyone") {
+ return votes;
+ }
+
+ if (scope === "me") {
+ return votes.filter((vote) => vote.user_id === currentUserId);
+ }
+
+ const memberIds =
+ groupMemberIds instanceof Set ? groupMemberIds : new Set(groupMemberIds);
+
+ return votes.filter((vote) => memberIds.has(vote.user_id));
+}
diff --git a/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx b/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx
index c4f23138..af7764cb 100644
--- a/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx
+++ b/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx
@@ -1,53 +1,112 @@
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import { SetsPanel } from "@/pages/EditionView/tabs/VoteTab/SetsPanel";
import { useSetFiltering } from "@/pages/EditionView/tabs/VoteTab/useSetFiltering";
+import { FilterSortControls } from "@/pages/EditionView/tabs/VoteTab/filters/FilterSortControls";
import { groupMembersQuery } from "@/api/groups/useGroupMembers";
+import { useAuth } from "@/contexts/AuthContext";
+import { useActiveGroup } from "@/hooks/useActiveGroup";
import type { FestivalSet } from "@/api/sets/types";
import type { FilterSortState } from "@/hooks/useUrlState";
+import type { BinaryVoteScope, VoteScope } from "@/lib/voteScope";
-const EMPTY_GROUP_MEMBER_IDS = new Set();
+const NO_MEMBERS = new Set();
interface FilteredSetsPanelProps {
sets: FestivalSet[];
urlState: FilterSortState;
updateUrlState: (updates: Partial) => void;
+ clearFilters: () => void;
+ editionId: string;
}
-// Gates whether the group-members query is mounted at all, so selecting a
-// group filter for the first time suspends this section rather than
-// requiring a separate loading state.
-export function FilteredSetsPanel({
- sets,
- urlState,
- updateUrlState,
-}: FilteredSetsPanelProps) {
- if (urlState.groupId) {
+export function FilteredSetsPanel(props: FilteredSetsPanelProps) {
+ const { user } = useAuth();
+
+ if (!user) {
return (
-
+ <>
+
+
+
+
+ >
);
}
+ return ;
+}
+
+// Preference between "everyone" and "group"; the active scope used for
+// filtering falls back to "everyone" whenever there is no Active Group.
+function AuthedFilteredSetsPanel(
+ props: FilteredSetsPanelProps & { userId: string },
+) {
+ const { activeGroupId, activeGroup } = useActiveGroup(props.userId);
+ const [perspective, setPerspective] = useState("group");
+
+ const voteScope: VoteScope =
+ perspective === "group" && activeGroupId ? "group" : "everyone";
+
return (
-
+ <>
+
+
+
+ {voteScope === "group" && activeGroupId ? (
+
+ ) : (
+
+ )}
+
+ >
);
}
-function GroupFilteredSetsPanel({
+function GroupScopedSetsPanel({
sets,
urlState,
updateUrlState,
groupId,
-}: FilteredSetsPanelProps & { groupId: string }) {
+}: Pick & {
+ groupId: string;
+}) {
const { data: members } = useSuspenseQuery(groupMembersQuery(groupId));
const groupMemberIds = useMemo(
() => new Set(members.map((member) => member.user_id)),
@@ -59,6 +118,7 @@ function GroupFilteredSetsPanel({
sets={sets}
urlState={urlState}
updateUrlState={updateUrlState}
+ voteScope="group"
groupMemberIds={groupMemberIds}
/>
);
@@ -68,11 +128,16 @@ function SetsPanelContent({
sets,
urlState,
updateUrlState,
- groupMemberIds = EMPTY_GROUP_MEMBER_IDS,
-}: FilteredSetsPanelProps & { groupMemberIds?: Set }) {
+ voteScope,
+ groupMemberIds,
+}: Pick & {
+ voteScope: VoteScope;
+ groupMemberIds: Set;
+}) {
const { filteredAndSortedSets, lockCurrentOrder } = useSetFiltering(
sets,
urlState,
+ voteScope,
groupMemberIds,
);
diff --git a/src/pages/EditionView/tabs/VoteTab/filters/FilterSortControls.tsx b/src/pages/EditionView/tabs/VoteTab/filters/FilterSortControls.tsx
index b8ef8336..5da56744 100644
--- a/src/pages/EditionView/tabs/VoteTab/filters/FilterSortControls.tsx
+++ b/src/pages/EditionView/tabs/VoteTab/filters/FilterSortControls.tsx
@@ -5,16 +5,24 @@ import { genresQuery } from "@/api/genres/useGenres";
import { SortControls } from "./SortControls";
import { MobileFilters } from "./MobileFilters";
import { DesktopFilters } from "./DesktopFilters";
-import { GroupFilterDropdown } from "./GroupFilterDropdown";
+import { VotePerspectiveToggle } from "./VotePerspectiveToggle";
import { FilterToggle } from "@/components/filters/FilterToggle";
import { FilterContainer } from "@/components/filters/FilterContainer";
import { RefreshButton } from "./RefreshButton";
+import type { BinaryVoteScope } from "@/lib/voteScope";
+
+interface VotePerspectiveProps {
+ scope: BinaryVoteScope;
+ onScopeChange: (scope: BinaryVoteScope) => void;
+ groupName: string;
+}
interface FilterSortControlsProps {
state: FilterSortState;
onStateChange: (updates: Partial) => void;
onClear: () => void;
editionId: string;
+ votePerspective?: VotePerspectiveProps;
}
export function FilterSortControls({
@@ -22,6 +30,7 @@ export function FilterSortControls({
onStateChange,
onClear,
editionId,
+ votePerspective,
}: FilterSortControlsProps) {
const [isFiltersExpanded, setIsFiltersExpanded] = useState(false);
const [isMobile, setIsMobile] = useState(false);
@@ -64,10 +73,13 @@ export function FilterSortControls({
)}
- onStateChange({ groupId })}
- />
+ {votePerspective && (
+
+ )}
setIsFiltersExpanded(!isFiltersExpanded)}
diff --git a/src/pages/EditionView/tabs/VoteTab/filters/GroupFilterDropdown.tsx b/src/pages/EditionView/tabs/VoteTab/filters/GroupFilterDropdown.tsx
deleted file mode 100644
index dee02372..00000000
--- a/src/pages/EditionView/tabs/VoteTab/filters/GroupFilterDropdown.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import { useSuspenseQuery } from "@tanstack/react-query";
-import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { Users, ChevronDown } from "lucide-react";
-import { useAuth } from "@/contexts/AuthContext";
-import { userGroupsQuery } from "@/api/groups/useUserGroups";
-
-interface GroupFilterDropdownProps {
- selectedGroupId?: string;
- onGroupChange: (groupId: string | undefined) => void;
-}
-
-export function GroupFilterDropdown({
- selectedGroupId,
- onGroupChange,
-}: GroupFilterDropdownProps) {
- const { user } = useAuth();
-
- if (!user) {
- return null;
- }
-
- return (
-
- );
-}
-
-function GroupFilterDropdownContent({
- userId,
- selectedGroupId,
- onGroupChange,
-}: GroupFilterDropdownProps & { userId: string }) {
- const { data: groups } = useSuspenseQuery(userGroupsQuery(userId));
-
- const hasActiveGroupFilter = selectedGroupId;
- const currentGroup = groups.find((g) => g.id === selectedGroupId);
- const groupDisplayText = currentGroup ? currentGroup.name : "All Votes";
-
- if (groups.length === 0) {
- return null;
- }
-
- return (
-
-
-
-
- {groupDisplayText}
-
-
-
-
- onGroupChange(undefined)}
- className={`text-purple-100 hover:bg-purple-600/30 ${!selectedGroupId ? "bg-purple-600/20" : ""}`}
- >
- All Votes
-
- {groups.map((group) => (
- onGroupChange(group.id)}
- className={`text-purple-100 hover:bg-purple-600/30 ${selectedGroupId === group.id ? "bg-purple-600/20" : ""}`}
- >
- {group.name}
- {group.member_count && (
-
- ({group.member_count})
-
- )}
-
- ))}
-
-
- );
-}
diff --git a/src/pages/EditionView/tabs/VoteTab/filters/VotePerspectiveToggle.tsx b/src/pages/EditionView/tabs/VoteTab/filters/VotePerspectiveToggle.tsx
new file mode 100644
index 00000000..cb4ca474
--- /dev/null
+++ b/src/pages/EditionView/tabs/VoteTab/filters/VotePerspectiveToggle.tsx
@@ -0,0 +1,44 @@
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+import type { BinaryVoteScope } from "@/lib/voteScope";
+
+interface VotePerspectiveToggleProps {
+ scope: BinaryVoteScope;
+ onScopeChange: (scope: BinaryVoteScope) => void;
+ groupName: string;
+}
+
+export function VotePerspectiveToggle({
+ scope,
+ onScopeChange,
+ groupName,
+}: VotePerspectiveToggleProps) {
+ return (
+ {
+ if (value === "everyone" || value === "group") {
+ onScopeChange(value);
+ }
+ }}
+ className="rounded-md border border-purple-400/30 p-0.5"
+ >
+
+ Everyone
+
+
+ {groupName}
+
+
+ );
+}
diff --git a/src/pages/EditionView/tabs/VoteTab/useSetFiltering.ts b/src/pages/EditionView/tabs/VoteTab/useSetFiltering.ts
index 3f34c6e9..d11ab705 100644
--- a/src/pages/EditionView/tabs/VoteTab/useSetFiltering.ts
+++ b/src/pages/EditionView/tabs/VoteTab/useSetFiltering.ts
@@ -1,10 +1,12 @@
import { useEffect, useState, useMemo, useCallback } from "react";
import type { FilterSortState } from "@/hooks/useUrlState";
import { FestivalSet } from "@/api/sets/types";
+import { resolveVotesForScope, type VoteScope } from "@/lib/voteScope";
export function useSetFiltering(
sets: FestivalSet[],
filterSortState: FilterSortState,
+ voteScope: VoteScope,
groupMemberIds: Set,
) {
const [lockedOrder, setLockedOrder] = useState([]);
@@ -37,20 +39,14 @@ export function useSetFiltering(
if (!filterSortState) return sets;
const filtered = sets
- .map((set) => {
- // Filter votes by group if groupId is selected
- let filteredVotes = set.votes || [];
- if (filterSortState.groupId && groupMemberIds.size > 0) {
- filteredVotes = filteredVotes.filter((vote) =>
- groupMemberIds.has(vote.user_id),
- );
- }
-
- return {
- ...set,
- votes: filteredVotes,
- };
- })
+ .map((set) => ({
+ ...set,
+ votes: resolveVotesForScope({
+ votes: set.votes || [],
+ scope: voteScope,
+ groupMemberIds,
+ }),
+ }))
.filter((set) => {
// Filter out sets without artists for voting tab
if (!set.artists || set.artists.length === 0) {
@@ -143,7 +139,7 @@ export function useSetFiltering(
}
return filtered;
- }, [sets, filterSortState, groupMemberIds, lockedOrder]);
+ }, [sets, filterSortState, voteScope, groupMemberIds, lockedOrder]);
// Update locked order when sort is unlocked
useEffect(() => {
diff --git a/src/routes/festivals/$festivalSlug/editions/$editionSlug/sets/index.tsx b/src/routes/festivals/$festivalSlug/editions/$editionSlug/sets/index.tsx
index 42a3621a..5d371a6e 100644
--- a/src/routes/festivals/$festivalSlug/editions/$editionSlug/sets/index.tsx
+++ b/src/routes/festivals/$festivalSlug/editions/$editionSlug/sets/index.tsx
@@ -1,9 +1,7 @@
import { createFileRoute, stripSearchParams } from "@tanstack/react-router";
-import { FilterSortControls } from "@/pages/EditionView/tabs/VoteTab/filters/FilterSortControls";
import { FilteredSetsPanel } from "@/pages/EditionView/tabs/VoteTab/FilteredSetsPanel";
import { useUrlState } from "@/hooks/useUrlState";
import { useSetsByEditionQuery } from "@/api/sets/useSetsByEdition";
-import { groupMembersQuery } from "@/api/groups/useGroupMembers";
import { useFestivalEdition } from "@/contexts/FestivalEditionContext";
import { PageTitle } from "@/components/PageTitle/PageTitle";
import {
@@ -20,12 +18,8 @@ export const Route = createFileRoute(
search: {
middlewares: [stripSearchParams(filterSortSearchDefaults)],
},
- loaderDeps: ({ search }) => ({ groupId: search.groupId }),
- loader: async ({ context, deps }) => {
+ loader: async ({ context }) => {
void context.queryClient.ensureQueryData(genresQuery());
- if (deps.groupId) {
- void context.queryClient.ensureQueryData(groupMembersQuery(deps.groupId));
- }
},
});
@@ -52,20 +46,13 @@ function VoteTab() {
<>
>
);
From 2e2b9a0649d10099d61394e28cc80b3a8a7e1dff Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 31 Jul 2026 14:04:29 +0000
Subject: [PATCH 02/23] fix(e2e): update active-group test for the new
switcher's button role
The header's active-group indicator is now a DropdownMenu-driven
switcher (a native ), not a plain link to /groups, so the
role assertion needs to match.
---
tests/e2e/active-group.spec.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/e2e/active-group.spec.ts b/tests/e2e/active-group.spec.ts
index 9859174e..48a2000c 100644
--- a/tests/e2e/active-group.spec.ts
+++ b/tests/e2e/active-group.spec.ts
@@ -15,7 +15,7 @@ test.describe("Active group", () => {
await page.reload();
await expect(
- page.getByRole("link", { name: new RegExp(groupName, "i") }),
+ page.getByRole("button", { name: new RegExp(groupName, "i") }),
).toBeVisible({ timeout: 15000 });
});
});
From d577ba3f3b38a631dbc633fd7e086f01e92aa241 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 1 Aug 2026 13:55:31 +0000
Subject: [PATCH 03/23] refactor(groups): address PR review feedback
- Extract the group-switcher dropdown out of GroupsIndicator into its
own ActiveGroupSwitcher component.
- Narrow resolveVotesForScope's groupMemberIds param to Set
only, since every real caller already passes a Set; drops the
unused array-input overload and its dedicated test.
---
.../layout/AppHeader/ActiveGroupSwitcher.tsx | 86 +++++++++++++++++++
.../layout/AppHeader/GroupsIndicator.tsx | 72 +++-------------
src/lib/voteScope.test.ts | 22 ++---
src/lib/voteScope.ts | 7 +-
4 files changed, 104 insertions(+), 83 deletions(-)
create mode 100644 src/components/layout/AppHeader/ActiveGroupSwitcher.tsx
diff --git a/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx b/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx
new file mode 100644
index 00000000..ade492c5
--- /dev/null
+++ b/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx
@@ -0,0 +1,86 @@
+import { ChevronDown, Users } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { useSetActiveGroupMutation } from "@/api/groups/useSetActiveGroupMutation";
+import { cn } from "@/lib/utils";
+import type { Group } from "@/api/groups/types";
+
+interface ActiveGroupSwitcherProps {
+ isMobile: boolean;
+ userId: string;
+ activeGroupId: string | undefined;
+ activeGroup: Group | undefined;
+ groups: Group[];
+ className: string;
+}
+
+export function ActiveGroupSwitcher({
+ isMobile,
+ userId,
+ activeGroupId,
+ activeGroup,
+ groups,
+ className,
+}: ActiveGroupSwitcherProps) {
+ const setActiveGroupMutation = useSetActiveGroupMutation();
+
+ function handleSelect(groupId: string | null) {
+ if (groupId === (activeGroupId ?? null)) {
+ return;
+ }
+ setActiveGroupMutation.mutate({ userId, groupId });
+ }
+
+ return (
+
+
+
+
+ {!isMobile && (
+
+ {activeGroup?.name || "Everyone"}
+
+
+ )}
+
+
+
+ handleSelect(null)}
+ className={cn(
+ "text-purple-100 hover:bg-purple-600/30",
+ !activeGroupId && "bg-purple-600/20",
+ )}
+ >
+ Everyone
+
+ {groups.map((group) => (
+ handleSelect(group.id)}
+ className={cn(
+ "text-purple-100 hover:bg-purple-600/30",
+ activeGroupId === group.id && "bg-purple-600/20",
+ )}
+ >
+ {group.name}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/layout/AppHeader/GroupsIndicator.tsx b/src/components/layout/AppHeader/GroupsIndicator.tsx
index 4596b0ec..618592bb 100644
--- a/src/components/layout/AppHeader/GroupsIndicator.tsx
+++ b/src/components/layout/AppHeader/GroupsIndicator.tsx
@@ -1,19 +1,12 @@
import { Suspense } from "react";
import { Link } from "@tanstack/react-router";
-import { UserPlus, Users, ChevronDown } from "lucide-react";
+import { UserPlus } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
-import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
import { useAuth } from "@/contexts/AuthContext";
import { useActiveGroup } from "@/hooks/useActiveGroup";
-import { useSetActiveGroupMutation } from "@/api/groups/useSetActiveGroupMutation";
import { cn } from "@/lib/utils";
import { TooltipButton } from "./TooltipButton";
+import { ActiveGroupSwitcher } from "./ActiveGroupSwitcher";
const groupsButtonClassName =
"bg-transparent border-purple-400/50 text-purple-300 hover:bg-purple-600 hover:text-white hover:border-purple-600 transition-colors";
@@ -50,7 +43,6 @@ function GroupsIndicatorContent({
}) {
const { activeGroup, activeGroupId, groups, hasGroups } =
useActiveGroup(userId);
- const setActiveGroupMutation = useSetActiveGroupMutation();
if (!hasGroups) {
return (
@@ -70,58 +62,14 @@ function GroupsIndicatorContent({
);
}
- function handleSelect(groupId: string | null) {
- if (groupId === (activeGroupId ?? null)) {
- return;
- }
- setActiveGroupMutation.mutate({ userId, groupId });
- }
-
return (
-
-
-
-
- {!isMobile && (
-
- {activeGroup?.name || "Everyone"}
-
-
- )}
-
-
-
- handleSelect(null)}
- className={cn(
- "text-purple-100 hover:bg-purple-600/30",
- !activeGroupId && "bg-purple-600/20",
- )}
- >
- Everyone
-
- {groups.map((group) => (
- handleSelect(group.id)}
- className={cn(
- "text-purple-100 hover:bg-purple-600/30",
- activeGroupId === group.id && "bg-purple-600/20",
- )}
- >
- {group.name}
-
- ))}
-
-
+
);
}
diff --git a/src/lib/voteScope.test.ts b/src/lib/voteScope.test.ts
index 1f8ffc92..2d5c2bd9 100644
--- a/src/lib/voteScope.test.ts
+++ b/src/lib/voteScope.test.ts
@@ -13,7 +13,7 @@ describe("resolveVotesForScope", () => {
resolveVotesForScope({
votes,
scope: "everyone",
- groupMemberIds: [],
+ groupMemberIds: new Set(),
}),
).toEqual(votes);
});
@@ -23,7 +23,7 @@ describe("resolveVotesForScope", () => {
resolveVotesForScope({
votes,
scope: "me",
- groupMemberIds: [],
+ groupMemberIds: new Set(),
currentUserId: "user-2",
}),
).toEqual([votes[1]]);
@@ -34,7 +34,7 @@ describe("resolveVotesForScope", () => {
resolveVotesForScope({
votes,
scope: "group",
- groupMemberIds: ["user-1", "user-2"],
+ groupMemberIds: new Set(["user-1", "user-2"]),
currentUserId: "user-1",
}),
).toEqual([votes[0], votes[1]]);
@@ -45,7 +45,7 @@ describe("resolveVotesForScope", () => {
resolveVotesForScope({
votes,
scope: "group",
- groupMemberIds: ["user-2", "user-3"],
+ groupMemberIds: new Set(["user-2", "user-3"]),
currentUserId: "user-1",
}),
).toEqual([votes[1], votes[2]]);
@@ -56,7 +56,7 @@ describe("resolveVotesForScope", () => {
resolveVotesForScope({
votes: [],
scope: "group",
- groupMemberIds: ["user-1"],
+ groupMemberIds: new Set(["user-1"]),
}),
).toEqual([]);
});
@@ -66,18 +66,8 @@ describe("resolveVotesForScope", () => {
resolveVotesForScope({
votes,
scope: "group",
- groupMemberIds: ["user-4", "user-5"],
+ groupMemberIds: new Set(["user-4", "user-5"]),
}),
).toEqual([]);
});
-
- it("accepts group member ids as a Set", () => {
- expect(
- resolveVotesForScope({
- votes,
- scope: "group",
- groupMemberIds: new Set(["user-3"]),
- }),
- ).toEqual([votes[2]]);
- });
});
diff --git a/src/lib/voteScope.ts b/src/lib/voteScope.ts
index af1bc8a3..83e20574 100644
--- a/src/lib/voteScope.ts
+++ b/src/lib/voteScope.ts
@@ -12,7 +12,7 @@ interface ScopedVote {
interface ResolveVotesForScopeParams {
votes: TVote[];
scope: VoteScope;
- groupMemberIds: Set | string[];
+ groupMemberIds: Set;
currentUserId?: string;
}
@@ -30,8 +30,5 @@ export function resolveVotesForScope({
return votes.filter((vote) => vote.user_id === currentUserId);
}
- const memberIds =
- groupMemberIds instanceof Set ? groupMemberIds : new Set(groupMemberIds);
-
- return votes.filter((vote) => memberIds.has(vote.user_id));
+ return votes.filter((vote) => groupMemberIds.has(vote.user_id));
}
From 74988bd791ddcb85549029ab0a143c281f04ee19 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 1 Aug 2026 14:04:02 +0000
Subject: [PATCH 04/23] Fix: selecting Everyone in the switcher didn't stick
for single-group users
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The single-group auto-activation from #123 stores "no active group set
yet" as active_group_id = NULL, which is indistinguishable from an
explicit "Everyone" choice made through the new switcher — so choosing
Everyone was silently overridden back to the sole group. Add a
active_group_selected flag so an explicit selection is trusted as-is,
while auto-activation still applies for users who've never touched the
switcher.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01VGSj8prZCB1qAtR2Wz3qKL
---
src/api/groups/useSetActiveGroupMutation.ts | 2 +-
src/hooks/useActiveGroup.ts | 1 +
src/integrations/supabase/types.ts | 3 +++
src/lib/activeGroup.test.ts | 22 ++++++++++++++++++-
src/lib/activeGroup.ts | 6 +++++
..._add_active_group_selected_to_profiles.sql | 5 +++++
6 files changed, 37 insertions(+), 2 deletions(-)
create mode 100644 supabase/migrations/20260801140000_add_active_group_selected_to_profiles.sql
diff --git a/src/api/groups/useSetActiveGroupMutation.ts b/src/api/groups/useSetActiveGroupMutation.ts
index 89c2aeb9..ecd65337 100644
--- a/src/api/groups/useSetActiveGroupMutation.ts
+++ b/src/api/groups/useSetActiveGroupMutation.ts
@@ -11,7 +11,7 @@ async function setActiveGroup(variables: {
const { error } = await supabase
.from("profiles")
- .update({ active_group_id: groupId })
+ .update({ active_group_id: groupId, active_group_selected: true })
.eq("id", userId);
if (error) {
diff --git a/src/hooks/useActiveGroup.ts b/src/hooks/useActiveGroup.ts
index 7001bece..c30e1cf8 100644
--- a/src/hooks/useActiveGroup.ts
+++ b/src/hooks/useActiveGroup.ts
@@ -17,6 +17,7 @@ export function useActiveGroup(userId: string): ActiveGroupState {
const activeGroupId = resolveActiveGroupId({
profileActiveGroupId: profile?.active_group_id,
+ hasExplicitSelection: profile?.active_group_selected ?? false,
groupIds: groups.map((group) => group.id),
});
diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts
index ffa2abcd..99d29c74 100644
--- a/src/integrations/supabase/types.ts
+++ b/src/integrations/supabase/types.ts
@@ -523,6 +523,7 @@ export type Database = {
profiles: {
Row: {
active_group_id: string | null;
+ active_group_selected: boolean;
completed_onboarding: boolean | null;
created_at: string;
email: string | null;
@@ -531,6 +532,7 @@ export type Database = {
};
Insert: {
active_group_id?: string | null;
+ active_group_selected?: boolean;
completed_onboarding?: boolean | null;
created_at?: string;
email?: string | null;
@@ -539,6 +541,7 @@ export type Database = {
};
Update: {
active_group_id?: string | null;
+ active_group_selected?: boolean;
completed_onboarding?: boolean | null;
created_at?: string;
email?: string | null;
diff --git a/src/lib/activeGroup.test.ts b/src/lib/activeGroup.test.ts
index c8b0b676..378b328d 100644
--- a/src/lib/activeGroup.test.ts
+++ b/src/lib/activeGroup.test.ts
@@ -4,7 +4,11 @@ import { resolveActiveGroupId } from "./activeGroup";
describe("resolveActiveGroupId", () => {
it("returns undefined when the user has no groups", () => {
expect(
- resolveActiveGroupId({ profileActiveGroupId: null, groupIds: [] }),
+ resolveActiveGroupId({
+ profileActiveGroupId: null,
+ hasExplicitSelection: false,
+ groupIds: [],
+ }),
).toBeUndefined();
});
@@ -12,6 +16,7 @@ describe("resolveActiveGroupId", () => {
expect(
resolveActiveGroupId({
profileActiveGroupId: null,
+ hasExplicitSelection: false,
groupIds: ["group-1"],
}),
).toBe("group-1");
@@ -21,6 +26,7 @@ describe("resolveActiveGroupId", () => {
expect(
resolveActiveGroupId({
profileActiveGroupId: null,
+ hasExplicitSelection: false,
groupIds: ["group-1", "group-2"],
}),
).toBeUndefined();
@@ -30,6 +36,7 @@ describe("resolveActiveGroupId", () => {
expect(
resolveActiveGroupId({
profileActiveGroupId: "group-2",
+ hasExplicitSelection: true,
groupIds: ["group-1", "group-2"],
}),
).toBe("group-2");
@@ -39,6 +46,7 @@ describe("resolveActiveGroupId", () => {
expect(
resolveActiveGroupId({
profileActiveGroupId: "group-3",
+ hasExplicitSelection: true,
groupIds: ["group-1", "group-2"],
}),
).toBeUndefined();
@@ -48,6 +56,7 @@ describe("resolveActiveGroupId", () => {
expect(
resolveActiveGroupId({
profileActiveGroupId: "group-3",
+ hasExplicitSelection: true,
groupIds: ["group-1"],
}),
).toBe("group-1");
@@ -57,8 +66,19 @@ describe("resolveActiveGroupId", () => {
expect(
resolveActiveGroupId({
profileActiveGroupId: "group-1",
+ hasExplicitSelection: true,
groupIds: ["group-1"],
}),
).toBe("group-1");
});
+
+ it("respects an explicit Everyone selection even when exactly one group remains", () => {
+ expect(
+ resolveActiveGroupId({
+ profileActiveGroupId: null,
+ hasExplicitSelection: true,
+ groupIds: ["group-1"],
+ }),
+ ).toBeUndefined();
+ });
});
diff --git a/src/lib/activeGroup.ts b/src/lib/activeGroup.ts
index c10198e8..da9d2b42 100644
--- a/src/lib/activeGroup.ts
+++ b/src/lib/activeGroup.ts
@@ -1,16 +1,22 @@
interface ResolveActiveGroupIdParams {
profileActiveGroupId: string | null | undefined;
+ hasExplicitSelection: boolean;
groupIds: string[];
}
export function resolveActiveGroupId({
profileActiveGroupId,
+ hasExplicitSelection,
groupIds,
}: ResolveActiveGroupIdParams): string | undefined {
if (profileActiveGroupId && groupIds.includes(profileActiveGroupId)) {
return profileActiveGroupId;
}
+ if (profileActiveGroupId === null && hasExplicitSelection) {
+ return undefined;
+ }
+
if (groupIds.length === 1) {
return groupIds[0];
}
diff --git a/supabase/migrations/20260801140000_add_active_group_selected_to_profiles.sql b/supabase/migrations/20260801140000_add_active_group_selected_to_profiles.sql
new file mode 100644
index 00000000..bc37cd5f
--- /dev/null
+++ b/supabase/migrations/20260801140000_add_active_group_selected_to_profiles.sql
@@ -0,0 +1,5 @@
+-- Distinguishes "never touched the switcher" from "explicitly selected
+-- Everyone" — both persist active_group_id as NULL, but only the latter
+-- should stop the single-group auto-activation from overriding it.
+ALTER TABLE public.profiles
+ADD COLUMN active_group_selected BOOLEAN NOT NULL DEFAULT false;
From b9b3c5f46d1ff142f1592438e5fe47ab4e63b0cb Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 1 Aug 2026 14:09:03 +0000
Subject: [PATCH 05/23] Add a Manage groups link to the Active Group switcher
There was no way to reach /groups once a user already belonged to a
group, since the header CTA only shows for the zero-groups case.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01VGSj8prZCB1qAtR2Wz3qKL
---
.../layout/AppHeader/ActiveGroupSwitcher.tsx | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx b/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx
index ade492c5..1e69b5b2 100644
--- a/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx
+++ b/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx
@@ -1,9 +1,11 @@
-import { ChevronDown, Users } from "lucide-react";
+import { ChevronDown, Settings, Users } from "lucide-react";
+import { Link } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useSetActiveGroupMutation } from "@/api/groups/useSetActiveGroupMutation";
@@ -80,6 +82,16 @@ export function ActiveGroupSwitcher({
{group.name}
))}
+
+
+
+
+ Manage groups
+
+
);
From 9184370c6d0de8f4564afbdfc184f948a95ed697 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 1 Aug 2026 15:04:37 +0000
Subject: [PATCH 06/23] Land the Active Group domain docs from the stale design
branch
Epic #122 references CONTEXT.md vocabulary and docs/adr/0003-active-group-model.md
as settled, but they were only ever written on claude/group-feature-design-f0nhvn,
which never merged (and is now too stale to merge as-is, predating Festival
Phase and Retrospective Rating). Reapply just the Active Group / Vote
Perspective / Vote Scope vocabulary and the ADR (renumbered 0005, since 0003
and 0004 are now taken) onto current CONTEXT.md, updated to describe the
active_group_selected flag actually shipped and to flag that its
Everyone-persistence behavior is under active reconsideration.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01VGSj8prZCB1qAtR2Wz3qKL
---
CONTEXT.md | 18 +++++++++++++++---
docs/adr/0005-active-group-model.md | 28 ++++++++++++++++++++++++++++
2 files changed, 43 insertions(+), 3 deletions(-)
create mode 100644 docs/adr/0005-active-group-model.md
diff --git a/CONTEXT.md b/CONTEXT.md
index c5af9f7d..927720e2 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -37,7 +37,7 @@ A named venue/space within an edition where sets take place.
_Avoid_: Venue, room
**Vote**:
-A user's reaction to an artist within a group context. Three values: "Must Go" (+2), "Interested" (+1), "Won't Go" (-1). Anticipatory — answers "will I go." See Retrospective rating for the after-the-fact counterpart.
+A user's reaction to an artist. Three values: "Must Go" (+2), "Interested" (+1), "Won't Go" (-1). A vote belongs to the voting user alone — it is never scoped to a Group; Groups only change whose votes are being looked at, never which votes exist. Anticipatory — answers "will I go." See Retrospective rating for the after-the-fact counterpart.
_Avoid_: Rating, like
**Retrospective rating**:
@@ -45,8 +45,20 @@ A user's after-the-fact reaction to a **set** — "how was it" — recorded once
_Avoid_: Vote, score
**Group**:
-A collection of users who share votes and notes for collaborative decision-making within an edition.
-_Avoid_: Team, party
+A festival-agnostic crew of users who share votes and notes for collaborative decision-making. Not tied to any festival or edition — the same group carries over to whatever editions its members attend. A user can belong to several Groups at once (e.g. a standing group of friends across festivals, plus a group formed just for one edition's crew).
+_Avoid_: Team, party, edition group
+
+**Active Group**:
+The one Group a user is currently viewing the app "as" — the group whose votes feed Vote Perspective and Vote Scope on any given screen. Global to the user (not per-edition), persisted, and defaults to the user's only Group when they have exactly one. Choosing a different Group anywhere always replaces it — there is no separate, non-persisting "peek" mode. A user with no Groups has no Active Group. See ADR-0005.
+_Avoid_: Selected group, current group, group filter
+
+**Vote Perspective**:
+On the Artists tab, which votes are aggregated into a set's rating and popularity score: Everyone, or the Active Group. Perspective re-scores and re-sorts; it never hides sets. See ADR-0005.
+_Avoid_: Group filter, rating scope
+
+**Vote Scope**:
+On the Schedule tab, whose votes the vote-type filter chips (Must Go / Interested / Won't Go) match against: Me, or the Active Group. Under Me, a chip matches the current user's own vote. Under the Active Group, a chip matches if *any* member of the Active Group (the current user included) cast that vote type. Scope filters — it hides sets that don't match a selected chip. Distinct from Vote Perspective: each tab keeps its own Me/Group(Everyone) choice independently, both drawing on the same Active Group. See ADR-0005.
+_Avoid_: Group filter, vote filter
**Core Team**:
Admin users who curate editions, manage the lineup, and import the schedule.
diff --git a/docs/adr/0005-active-group-model.md b/docs/adr/0005-active-group-model.md
new file mode 100644
index 00000000..4b049d39
--- /dev/null
+++ b/docs/adr/0005-active-group-model.md
@@ -0,0 +1,28 @@
+# Active Group model for group-scoped votes
+
+Status: accepted
+
+We needed a way for users to view sets/artists filtered or scored by a Group's votes, on both the Schedule and Artists tabs, without re-selecting a group on every screen. We considered scoping group choice per festival edition (since a user's group might differ per festival), and considered a transient "peek at another group" mode that wouldn't overwrite the user's normal default. We rejected both: per-edition storage adds a second axis of state for a case we're not confident is common enough to justify, and a transient peek mode is a second concept (session-only vs. persisted) for uncertain benefit. Instead there is exactly one **Active Group** per user, global across editions, stored on `profiles.active_group_id`, defaulted automatically when the user has exactly one Group and has never made an explicit choice. Picking a different entry in the switcher — including "Everyone" — always overwrites it; there is no non-persisting alternative.
+
+The Active Group feeds two independent, tab-local toggles rather than one shared switch: **Vote Perspective** (Artists tab, Everyone ↔ Active Group, re-scores without hiding) and **Vote Scope** (Schedule tab, Me ↔ Active Group, hides non-matching sets). These are kept separate because they do different jobs — a user may want the group's aggregate popularity on Artists while checking only their own Must-Gos on Schedule — and collapsing them into one global toggle would force those two intents to always move together. Both toggles resolve their votes through one shared, framework-free function, `resolveVotesForScope` (`src/lib/voteScope.ts`), rather than each re-implementing group-membership filtering.
+
+Group-scoped vote matching on the Schedule tab uses "any member of the Active Group cast that vote type," not a majority or average threshold — chosen for a first version because it's the simplest mental model and reuses the client-side membership-filter pattern already proven in the Artists tab, rather than introducing new aggregate math. This can be revisited once real usage shows whether "any member" over- or under-includes sets for larger groups.
+
+## Explicit "Everyone" vs. never-chosen (profiles.active_group_selected)
+
+Auto-activation and an explicit "Everyone" selection both need to persist `active_group_id = NULL`, but they mean opposite things: "never touched the switcher yet, so auto-activate my one Group" vs. "I explicitly chose to see everyone's votes, don't auto-activate." A single nullable column can't distinguish them — implemented naively, a user with exactly one Group could never actually select "Everyone," because auto-activation would immediately override it back. `profiles.active_group_selected` (boolean, defaults `false`) resolves this: `resolveActiveGroupId` (`src/lib/activeGroup.ts`) only auto-activates the sole Group while the flag is `false`; any switcher interaction sets it `true`, after which a `NULL` `active_group_id` is trusted to mean Everyone.
+
+**This sub-decision is under active reconsideration.** Durable, cross-device persistence of "Everyone" (the behavior above) is what's shipped and what this ADR's parent decision — "every switcher selection persists, no preview mode" — implies. But it's an open question whether users actually want "Everyone" to be *that* sticky, versus a lighter, session-scoped choice. See the handoff for that discussion; whatever this resolves to should also apply to Vote Scope's Me/Group choice, since both read the same `useActiveGroup` seam.
+
+## Considered Options
+
+- **Single `active_group_id` column, "never chosen" and "explicitly Everyone" both `NULL` (rejected in practice).** Simplest schema, but silently breaks "Everyone" for any single-Group user — auto-activation always wins. This shipped briefly and was caught by manual testing.
+- **`active_group_id` + `active_group_selected` boolean (chosen).** One extra column distinguishes intent without overloading `NULL`, and keeps auto-activation, explicit-Everyone, and explicit-Group-choice as three cleanly distinguishable states.
+- **Drop single-Group auto-activation entirely.** Simpler schema (no flag needed), but regresses the "zero-setup default" guarantee for single-Group users — the case #123 was written to guarantee in the first place.
+- **Track "explicitly Everyone" client-side only (session/local storage), no new column.** Avoids a migration, but breaks cross-device/cross-session persistence for the Everyone case specifically, which the switcher's other entries don't do — a user's choice would behave inconsistently depending on which entry they picked.
+
+## Consequences
+
+- `src/lib/activeGroup.ts`'s `resolveActiveGroupId` takes `hasExplicitSelection` alongside `profileActiveGroupId` and `groupIds`; any caller resolving Active Group must thread the new field through (`useActiveGroup.ts` reads it off `profile.active_group_selected`).
+- `useSetActiveGroupMutation` sets `active_group_selected: true` on every switcher pick, including "Everyone" — there's no code path that writes `active_group_id` without also marking the selection explicit.
+- Any future Vote Scope work (Schedule tab, Me ↔ Active Group) reuses this same Active Group state; it does not need its own auto-activation or explicit-selection tracking.
From ffa67952b1877e5b7a7e06bef0ec23b60d0b1940 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 1 Aug 2026 15:05:53 +0000
Subject: [PATCH 07/23] Fix Prettier formatting on the newly added domain docs
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01VGSj8prZCB1qAtR2Wz3qKL
---
CONTEXT.md | 2 +-
docs/adr/0005-active-group-model.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/CONTEXT.md b/CONTEXT.md
index 927720e2..c0e3abc1 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -57,7 +57,7 @@ On the Artists tab, which votes are aggregated into a set's rating and popularit
_Avoid_: Group filter, rating scope
**Vote Scope**:
-On the Schedule tab, whose votes the vote-type filter chips (Must Go / Interested / Won't Go) match against: Me, or the Active Group. Under Me, a chip matches the current user's own vote. Under the Active Group, a chip matches if *any* member of the Active Group (the current user included) cast that vote type. Scope filters — it hides sets that don't match a selected chip. Distinct from Vote Perspective: each tab keeps its own Me/Group(Everyone) choice independently, both drawing on the same Active Group. See ADR-0005.
+On the Schedule tab, whose votes the vote-type filter chips (Must Go / Interested / Won't Go) match against: Me, or the Active Group. Under Me, a chip matches the current user's own vote. Under the Active Group, a chip matches if _any_ member of the Active Group (the current user included) cast that vote type. Scope filters — it hides sets that don't match a selected chip. Distinct from Vote Perspective: each tab keeps its own Me/Group(Everyone) choice independently, both drawing on the same Active Group. See ADR-0005.
_Avoid_: Group filter, vote filter
**Core Team**:
diff --git a/docs/adr/0005-active-group-model.md b/docs/adr/0005-active-group-model.md
index 4b049d39..7a1c6c2f 100644
--- a/docs/adr/0005-active-group-model.md
+++ b/docs/adr/0005-active-group-model.md
@@ -12,7 +12,7 @@ Group-scoped vote matching on the Schedule tab uses "any member of the Active Gr
Auto-activation and an explicit "Everyone" selection both need to persist `active_group_id = NULL`, but they mean opposite things: "never touched the switcher yet, so auto-activate my one Group" vs. "I explicitly chose to see everyone's votes, don't auto-activate." A single nullable column can't distinguish them — implemented naively, a user with exactly one Group could never actually select "Everyone," because auto-activation would immediately override it back. `profiles.active_group_selected` (boolean, defaults `false`) resolves this: `resolveActiveGroupId` (`src/lib/activeGroup.ts`) only auto-activates the sole Group while the flag is `false`; any switcher interaction sets it `true`, after which a `NULL` `active_group_id` is trusted to mean Everyone.
-**This sub-decision is under active reconsideration.** Durable, cross-device persistence of "Everyone" (the behavior above) is what's shipped and what this ADR's parent decision — "every switcher selection persists, no preview mode" — implies. But it's an open question whether users actually want "Everyone" to be *that* sticky, versus a lighter, session-scoped choice. See the handoff for that discussion; whatever this resolves to should also apply to Vote Scope's Me/Group choice, since both read the same `useActiveGroup` seam.
+**This sub-decision is under active reconsideration.** Durable, cross-device persistence of "Everyone" (the behavior above) is what's shipped and what this ADR's parent decision — "every switcher selection persists, no preview mode" — implies. But it's an open question whether users actually want "Everyone" to be _that_ sticky, versus a lighter, session-scoped choice. See the handoff for that discussion; whatever this resolves to should also apply to Vote Scope's Me/Group choice, since both read the same `useActiveGroup` seam.
## Considered Options
From 6f373933f4db4304261be930a523105e1d693a0f Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 7 Aug 2026 18:34:56 +0000
Subject: [PATCH 08/23] Rework Active Group into two independent settings:
Active group + Active scope
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Following further design work (grill session + prototype, see docs/adr/0005),
the active_group_selected flag from the previous fix papered over a modeling
bug rather than fixing it: "which group is mine" and "which lens am I
viewing through" were conflated into one nullable column. Root-cause instead:
- profiles.active_group_id: which group is yours (unchanged meaning, now
scope-independent).
- profiles.active_scope (new): group/everyone/me, the durable Settings-level
pin. Both are set explicitly only from a new Settings page.
- The header switcher no longer writes either column — picking an entry
there is a transient, in-memory override for the current session only,
with a "back to X" affordance when it diverges from the pin. Pinned entry
always sorts first and is starred.
This is asymmetric by design: real group picks stay sticky/default with no
friction (the epic's whole point), while Everyone/Me default to a temporary
lens, with friction pushed onto the explicit Settings pin instead of every
header click.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01VGSj8prZCB1qAtR2Wz3qKL
---
CONTEXT.md | 6 +-
docs/adr/0005-active-group-model.md | 40 ++-
src/api/groups/useSetActiveGroupMutation.ts | 7 +-
src/api/groups/useSetActiveScopeMutation.ts | 41 ++++
.../layout/AppHeader/ActiveGroupSwitcher.tsx | 227 ++++++++++++------
.../layout/AppHeader/GroupsIndicator.tsx | 43 +---
src/components/layout/AppHeader/UserMenu.tsx | 8 +-
src/contexts/ActiveScopeContext.tsx | 152 ++++++++++++
src/hooks/useActiveGroup.ts | 30 ---
src/integrations/supabase/types.ts | 7 +-
src/lib/activeGroup.test.ts | 101 ++++++--
src/lib/activeGroup.ts | 47 +++-
.../tabs/VoteTab/FilteredSetsPanel.tsx | 16 +-
src/pages/Settings/ActiveGroupSetting.tsx | 34 +++
src/pages/Settings/ActiveScopeSetting.tsx | 50 ++++
src/pages/Settings/SettingsPage.tsx | 46 ++++
src/pages/groups/Groups/SignInRequired.tsx | 8 +-
src/routeTree.gen.ts | 21 ++
src/routes/__root.tsx | 5 +-
src/routes/settings.tsx | 6 +
...804060000_add_active_scope_to_profiles.sql | 24 ++
21 files changed, 721 insertions(+), 198 deletions(-)
create mode 100644 src/api/groups/useSetActiveScopeMutation.ts
create mode 100644 src/contexts/ActiveScopeContext.tsx
delete mode 100644 src/hooks/useActiveGroup.ts
create mode 100644 src/pages/Settings/ActiveGroupSetting.tsx
create mode 100644 src/pages/Settings/ActiveScopeSetting.tsx
create mode 100644 src/pages/Settings/SettingsPage.tsx
create mode 100644 src/routes/settings.tsx
create mode 100644 supabase/migrations/20260804060000_add_active_scope_to_profiles.sql
diff --git a/CONTEXT.md b/CONTEXT.md
index c0e3abc1..9e6cbc2a 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -49,9 +49,13 @@ A festival-agnostic crew of users who share votes and notes for collaborative de
_Avoid_: Team, party, edition group
**Active Group**:
-The one Group a user is currently viewing the app "as" — the group whose votes feed Vote Perspective and Vote Scope on any given screen. Global to the user (not per-edition), persisted, and defaults to the user's only Group when they have exactly one. Choosing a different Group anywhere always replaces it — there is no separate, non-persisting "peek" mode. A user with no Groups has no Active Group. See ADR-0005.
+Which Group is the user's, independent of what they're currently viewing. Global to the user (not per-edition), persisted in Settings, and defaults to the user's only Group when they have exactly one. A user with no Groups has no Active Group. See ADR-0005.
_Avoid_: Selected group, current group, group filter
+**Active Scope**:
+The user's durable, Settings-level default lens: Group, Everyone, or Me. When set to Group, it resolves through the Active Group. Defaults to Group when the user has exactly one Group and has never chosen, else Everyone. The header switcher can temporarily override this per-session without changing the pin — see ADR-0005.
+_Avoid_: Active group (the pin covers both which Group and which lens; "Active Group" alone is only the former)
+
**Vote Perspective**:
On the Artists tab, which votes are aggregated into a set's rating and popularity score: Everyone, or the Active Group. Perspective re-scores and re-sorts; it never hides sets. See ADR-0005.
_Avoid_: Group filter, rating scope
diff --git a/docs/adr/0005-active-group-model.md b/docs/adr/0005-active-group-model.md
index 7a1c6c2f..3addda83 100644
--- a/docs/adr/0005-active-group-model.md
+++ b/docs/adr/0005-active-group-model.md
@@ -2,27 +2,41 @@
Status: accepted
-We needed a way for users to view sets/artists filtered or scored by a Group's votes, on both the Schedule and Artists tabs, without re-selecting a group on every screen. We considered scoping group choice per festival edition (since a user's group might differ per festival), and considered a transient "peek at another group" mode that wouldn't overwrite the user's normal default. We rejected both: per-edition storage adds a second axis of state for a case we're not confident is common enough to justify, and a transient peek mode is a second concept (session-only vs. persisted) for uncertain benefit. Instead there is exactly one **Active Group** per user, global across editions, stored on `profiles.active_group_id`, defaulted automatically when the user has exactly one Group and has never made an explicit choice. Picking a different entry in the switcher — including "Everyone" — always overwrites it; there is no non-persisting alternative.
+We needed a way for users to view sets/artists filtered or scored by a Group's votes, on both the Schedule and Artists tabs, without re-selecting a group on every screen. We considered scoping group choice per festival edition (since a user's group might differ per festival), and considered a transient "peek at another group" mode that wouldn't overwrite the user's normal default. We rejected both: per-edition storage adds a second axis of state for a case we're not confident is common enough to justify, and a transient peek mode is a second concept (session-only vs. persisted) for uncertain benefit. Instead there is exactly one **Active Group** per user, global across editions, defaulted automatically when the user has exactly one Group and has never made an explicit choice.
-The Active Group feeds two independent, tab-local toggles rather than one shared switch: **Vote Perspective** (Artists tab, Everyone ↔ Active Group, re-scores without hiding) and **Vote Scope** (Schedule tab, Me ↔ Active Group, hides non-matching sets). These are kept separate because they do different jobs — a user may want the group's aggregate popularity on Artists while checking only their own Must-Gos on Schedule — and collapsing them into one global toggle would force those two intents to always move together. Both toggles resolve their votes through one shared, framework-free function, `resolveVotesForScope` (`src/lib/voteScope.ts`), rather than each re-implementing group-membership filtering.
+Votes have no `group_id` (confirmed: `votes` belongs to the user alone — see `supabase/migrations/20250620065433_create_artists_table.sql`); Groups are purely a viewing/aggregation lens, never an identity a vote is recorded against.
-Group-scoped vote matching on the Schedule tab uses "any member of the Active Group cast that vote type," not a majority or average threshold — chosen for a first version because it's the simplest mental model and reuses the client-side membership-filter pattern already proven in the Artists tab, rather than introducing new aggregate math. This can be revisited once real usage shows whether "any member" over- or under-includes sets for larger groups.
+## Two independent settings, not one flat pin target
-## Explicit "Everyone" vs. never-chosen (profiles.active_group_selected)
+An earlier version of this model stored a single nullable `active_group_id` and used `NULL` to mean both "never chosen" and "explicitly Everyone" — this shipped briefly, broke "Everyone" for any single-Group user (auto-activation always won), and was caught by manual testing. Root-causing rather than patching around it again: "which group is mine" and "which lens am I viewing through" are two independent questions.
-Auto-activation and an explicit "Everyone" selection both need to persist `active_group_id = NULL`, but they mean opposite things: "never touched the switcher yet, so auto-activate my one Group" vs. "I explicitly chose to see everyone's votes, don't auto-activate." A single nullable column can't distinguish them — implemented naively, a user with exactly one Group could never actually select "Everyone," because auto-activation would immediately override it back. `profiles.active_group_selected` (boolean, defaults `false`) resolves this: `resolveActiveGroupId` (`src/lib/activeGroup.ts`) only auto-activates the sole Group while the flag is `false`; any switcher interaction sets it `true`, after which a `NULL` `active_group_id` is trusted to mean Everyone.
+- **`profiles.active_group_id`** — which of the user's Groups is theirs. A mostly-static, membership-like choice. `NULL` unambiguously means "no group chosen"; auto-derives to the sole Group when the user has exactly one.
+- **`profiles.active_scope`** (`group` | `everyone` | `me`, nullable) — which lens is applied by default. When `group`, it resolves through `active_group_id`. `NULL` means "never explicitly chosen" — auto-derives the same way `active_group_id` used to: the sole Group when there's exactly one, else Everyone.
-**This sub-decision is under active reconsideration.** Durable, cross-device persistence of "Everyone" (the behavior above) is what's shipped and what this ADR's parent decision — "every switcher selection persists, no preview mode" — implies. But it's an open question whether users actually want "Everyone" to be _that_ sticky, versus a lighter, session-scoped choice. See the handoff for that discussion; whatever this resolves to should also apply to Vote Scope's Me/Group choice, since both read the same `useActiveGroup` seam.
+Both are set explicitly only from **Settings** (`src/pages/Settings/`) — this is the only "make it permanent" action; there is no separate per-pick "pin" button elsewhere.
+
+## Asymmetric by design: header is a transient override, Settings holds the pin
+
+The header switcher (`ActiveGroupSwitcher.tsx`) no longer writes either column directly. Selecting an entry there sets a **transient, in-memory override** (`ActiveScopeContext`, plain `useState`, not persisted) — it reverts to the Settings pin on a fresh visit/reload. A "back to {pinned}" affordance appears next to the trigger whenever the current view diverges from the pin.
+
+This is intentionally asymmetric, not symmetric: real Group picks are meant to stay sticky/default with no friction (that's the whole point of this epic — centralizing the app around "your crew"), while Everyone/Me are meant to default to being a temporary lens. Putting the friction on the Settings-level pin (not on casual switching in the header) serves that goal; a uniform "every header pick is a commit" model — the original #124 shape — does not.
+
+The header dropdown lists the pinned entry first (starred), then remaining Groups, then remaining of Everyone/Me — so reverting to the pin never requires scanning the list.
+
+## Unifies with the future Schedule-tab Vote Scope
+
+The three-way scope (`group` / `everyone` / `me`) is written to serve both the Artists tab's Vote Perspective (Everyone ↔ Group, no Me) and the not-yet-built Schedule tab's Vote Scope (Me ↔ Group, no Everyone — issue #125) from one seam, rather than each inventing its own resolution/auto-activation logic. Vote Perspective and Vote Scope remain independent, tab-local _toggles_ — a user's Everyone/Group choice on Artists and Me/Group choice on Schedule can still differ at once — but both read their default from the same shared `ActiveScopeContext`, and "Me" simply isn't meaningful to Vote Perspective's rating aggregation, so it's treated as Everyone there.
## Considered Options
-- **Single `active_group_id` column, "never chosen" and "explicitly Everyone" both `NULL` (rejected in practice).** Simplest schema, but silently breaks "Everyone" for any single-Group user — auto-activation always wins. This shipped briefly and was caught by manual testing.
-- **`active_group_id` + `active_group_selected` boolean (chosen).** One extra column distinguishes intent without overloading `NULL`, and keeps auto-activation, explicit-Everyone, and explicit-Group-choice as three cleanly distinguishable states.
-- **Drop single-Group auto-activation entirely.** Simpler schema (no flag needed), but regresses the "zero-setup default" guarantee for single-Group users — the case #123 was written to guarantee in the first place.
-- **Track "explicitly Everyone" client-side only (session/local storage), no new column.** Avoids a migration, but breaks cross-device/cross-session persistence for the Everyone case specifically, which the switcher's other entries don't do — a user's choice would behave inconsistently depending on which entry they picked.
+- **Single `active_group_id` column, "never chosen" and "explicitly Everyone" both `NULL`.** Rejected: the modeling bug this ADR fixes.
+- **`active_group_id` + a boolean "has explicitly chosen" flag on the same nullable column.** Considered and briefly shipped (`active_group_selected`). Rejected in favor of the two-setting model below: it papered over the ambiguity rather than removing it, and gave "Active group" and "Active scope" no separate existence — a user could not pin "my crew" as a standing identity while defaulting their day-to-day view to Everyone.
+- **Two independent settings, header as a durable pin for both (chosen for `active_group_id`, rejected as-is for the header/scope relationship).** Symmetric treatment of every switcher entry (uniform pin-on-select) was the original #124 shape. Rejected once reframed against the epic's actual goal: it puts equal friction on picking your crew and picking a one-off "everyone" peek, when the two should not have equal friction.
+- **Two independent settings + asymmetric pin-in-Settings, transient header override (chosen).** Prototyped as three UI variants (`chiptus/UpLine#288`, throwaway, never merged) before picking the flat starred-dropdown shape described above.
## Consequences
-- `src/lib/activeGroup.ts`'s `resolveActiveGroupId` takes `hasExplicitSelection` alongside `profileActiveGroupId` and `groupIds`; any caller resolving Active Group must thread the new field through (`useActiveGroup.ts` reads it off `profile.active_group_selected`).
-- `useSetActiveGroupMutation` sets `active_group_selected: true` on every switcher pick, including "Everyone" — there's no code path that writes `active_group_id` without also marking the selection explicit.
-- Any future Vote Scope work (Schedule tab, Me ↔ Active Group) reuses this same Active Group state; it does not need its own auto-activation or explicit-selection tracking.
+- `src/lib/activeGroup.ts` exports `resolveActiveGroupId` (unchanged shape, now scope-independent) and `resolvePinnedScope` (new), both pure and framework-free.
+- `src/contexts/ActiveScopeContext.tsx` is the single seam every scope-aware surface reads from: `pinned` (durable), `current` (transient), `isOverridden`, and the two Settings-only mutators `setActiveGroup` / `setActiveScope`.
+- `src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx`'s Vote Perspective toggle seeds its default from `current`, mapping `me` to `everyone` since Vote Perspective has no Me option.
+- Any future Vote Scope work (Schedule tab, Me ↔ Active Group, issue #125) reuses `ActiveScopeContext` directly; it does not need its own auto-activation or override tracking.
diff --git a/src/api/groups/useSetActiveGroupMutation.ts b/src/api/groups/useSetActiveGroupMutation.ts
index ecd65337..20e39985 100644
--- a/src/api/groups/useSetActiveGroupMutation.ts
+++ b/src/api/groups/useSetActiveGroupMutation.ts
@@ -3,15 +3,12 @@ import { useToast } from "@/hooks/use-toast";
import { supabase } from "@/integrations/supabase/client";
import { profileKeys } from "@/api/auth/types";
-async function setActiveGroup(variables: {
- userId: string;
- groupId: string | null;
-}) {
+async function setActiveGroup(variables: { userId: string; groupId: string }) {
const { userId, groupId } = variables;
const { error } = await supabase
.from("profiles")
- .update({ active_group_id: groupId, active_group_selected: true })
+ .update({ active_group_id: groupId })
.eq("id", userId);
if (error) {
diff --git a/src/api/groups/useSetActiveScopeMutation.ts b/src/api/groups/useSetActiveScopeMutation.ts
new file mode 100644
index 00000000..ab3e79a7
--- /dev/null
+++ b/src/api/groups/useSetActiveScopeMutation.ts
@@ -0,0 +1,41 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useToast } from "@/hooks/use-toast";
+import { supabase } from "@/integrations/supabase/client";
+import { profileKeys } from "@/api/auth/types";
+
+async function setActiveScope(variables: {
+ userId: string;
+ scope: "group" | "everyone" | "me";
+}) {
+ const { userId, scope } = variables;
+
+ const { error } = await supabase
+ .from("profiles")
+ .update({ active_scope: scope })
+ .eq("id", userId);
+
+ if (error) {
+ throw new Error("Failed to update active scope");
+ }
+}
+
+export function useSetActiveScopeMutation() {
+ const queryClient = useQueryClient();
+ const { toast } = useToast();
+
+ return useMutation({
+ mutationFn: setActiveScope,
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: profileKeys.detail(variables.userId),
+ });
+ },
+ onError: (error) => {
+ toast({
+ title: "Error",
+ description: error?.message || "Failed to update active scope",
+ variant: "destructive",
+ });
+ },
+ });
+}
diff --git a/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx b/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx
index 1e69b5b2..bab8b334 100644
--- a/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx
+++ b/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx
@@ -1,4 +1,12 @@
-import { ChevronDown, Settings, Users } from "lucide-react";
+import {
+ ChevronDown,
+ Globe,
+ Settings,
+ Star,
+ User as UserIcon,
+ Users,
+ X,
+} from "lucide-react";
import { Link } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import {
@@ -8,91 +16,174 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import { useSetActiveGroupMutation } from "@/api/groups/useSetActiveGroupMutation";
+import { useActiveScope } from "@/contexts/ActiveScopeContext";
import { cn } from "@/lib/utils";
+import type { PinnedScope } from "@/lib/activeGroup";
import type { Group } from "@/api/groups/types";
interface ActiveGroupSwitcherProps {
isMobile: boolean;
- userId: string;
- activeGroupId: string | undefined;
- activeGroup: Group | undefined;
- groups: Group[];
className: string;
}
+function scopeKey(scope: PinnedScope): string {
+ return scope.kind === "group" ? `group:${scope.groupId}` : scope.kind;
+}
+
+function scopeLabel(scope: PinnedScope, groups: Group[]): string {
+ if (scope.kind === "everyone") {
+ return "Everyone";
+ }
+ if (scope.kind === "me") {
+ return "Me";
+ }
+ return groups.find((group) => group.id === scope.groupId)?.name ?? "Group";
+}
+
+function scopeIcon(scope: PinnedScope) {
+ if (scope.kind === "everyone") {
+ return Globe;
+ }
+ if (scope.kind === "me") {
+ return UserIcon;
+ }
+ return Users;
+}
+
export function ActiveGroupSwitcher({
isMobile,
- userId,
- activeGroupId,
- activeGroup,
- groups,
className,
}: ActiveGroupSwitcherProps) {
- const setActiveGroupMutation = useSetActiveGroupMutation();
+ const {
+ groups,
+ pinned,
+ current,
+ isOverridden,
+ selectScope,
+ returnToDefault,
+ } = useActiveScope();
- function handleSelect(groupId: string | null) {
- if (groupId === (activeGroupId ?? null)) {
- return;
- }
- setActiveGroupMutation.mutate({ userId, groupId });
- }
+ const CurrentIcon = scopeIcon(current);
+ const currentLabel = scopeLabel(current, groups);
return (
-
-
-
-
- {!isMobile && (
-
- {activeGroup?.name || "Everyone"}
-
-
- )}
-
-
-
- handleSelect(null)}
- className={cn(
- "text-purple-100 hover:bg-purple-600/30",
- !activeGroupId && "bg-purple-600/20",
- )}
- >
- Everyone
-
- {groups.map((group) => (
- handleSelect(group.id)}
- className={cn(
- "text-purple-100 hover:bg-purple-600/30",
- activeGroupId === group.id && "bg-purple-600/20",
+
+
+
+
+
+ {!isMobile && (
+
+ {currentLabel}
+
+
)}
+
+
+
+
+
+
- {group.name}
+
+
+ Manage groups
+
- ))}
-
-
+
+
+ {isOverridden && (
+
-
-
- Manage groups
-
-
-
-
+
+ {!isMobile && back to {scopeLabel(pinned, groups)} }
+
+ )}
+
+ );
+}
+
+/**
+ * Pinned entry always sorts first (starred), so reverting to it never
+ * requires hunting through the list; then remaining groups, then Everyone/Me.
+ */
+function ScopeMenuBody({
+ groups,
+ pinned,
+ current,
+ onSelect,
+}: {
+ groups: Group[];
+ pinned: PinnedScope;
+ current: PinnedScope;
+ onSelect: (scope: PinnedScope) => void;
+}) {
+ function Row({ scope, label }: { scope: PinnedScope; label: string }) {
+ const Icon = scopeIcon(scope);
+ const isPinned = scopeKey(scope) === scopeKey(pinned);
+ const isActive = scopeKey(scope) === scopeKey(current);
+ return (
+ onSelect(scope)}
+ className={cn(
+ "flex items-center gap-2 text-purple-100 hover:bg-purple-600/30",
+ isActive && "bg-purple-600/20",
+ )}
+ >
+
+ {label}
+ {isPinned && (
+
+ )}
+
+ );
+ }
+
+ const pinnedGroupId = pinned.kind === "group" ? pinned.groupId : undefined;
+ const otherGroups = groups.filter((group) => group.id !== pinnedGroupId);
+ const otherScopeKinds = (["everyone", "me"] as const).filter(
+ (kind) => pinned.kind !== kind,
+ );
+
+ return (
+ <>
+
+
+ {otherGroups.map((group) => (
+
+ ))}
+ {otherGroups.length > 0 && otherScopeKinds.length > 0 && (
+
+ )}
+ {otherScopeKinds.map((kind) => (
+
+ ))}
+ >
);
}
diff --git a/src/components/layout/AppHeader/GroupsIndicator.tsx b/src/components/layout/AppHeader/GroupsIndicator.tsx
index 618592bb..29707dec 100644
--- a/src/components/layout/AppHeader/GroupsIndicator.tsx
+++ b/src/components/layout/AppHeader/GroupsIndicator.tsx
@@ -1,9 +1,8 @@
-import { Suspense } from "react";
import { Link } from "@tanstack/react-router";
import { UserPlus } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/contexts/AuthContext";
-import { useActiveGroup } from "@/hooks/useActiveGroup";
+import { useActiveScope } from "@/contexts/ActiveScopeContext";
import { cn } from "@/lib/utils";
import { TooltipButton } from "./TooltipButton";
import { ActiveGroupSwitcher } from "./ActiveGroupSwitcher";
@@ -13,36 +12,22 @@ const groupsButtonClassName =
export function GroupsIndicator({ isMobile }: { isMobile: boolean }) {
const { user } = useAuth();
+ const { isLoading, hasGroups } = useActiveScope();
if (!user) {
return null;
}
- return (
-
- }
- >
-
-
- );
-}
-
-function GroupsIndicatorContent({
- isMobile,
- userId,
-}: {
- isMobile: boolean;
- userId: string;
-}) {
- const { activeGroup, activeGroupId, groups, hasGroups } =
- useActiveGroup(userId);
+ if (isLoading) {
+ return (
+
+ );
+ }
if (!hasGroups) {
return (
@@ -65,10 +50,6 @@ function GroupsIndicatorContent({
return (
);
diff --git a/src/components/layout/AppHeader/UserMenu.tsx b/src/components/layout/AppHeader/UserMenu.tsx
index 793429de..a00db88d 100644
--- a/src/components/layout/AppHeader/UserMenu.tsx
+++ b/src/components/layout/AppHeader/UserMenu.tsx
@@ -81,11 +81,13 @@ export function UserMenu({
-
- Settings
+
+
+ Settings
+
{isAdmin && (
diff --git a/src/contexts/ActiveScopeContext.tsx b/src/contexts/ActiveScopeContext.tsx
new file mode 100644
index 00000000..8ef64115
--- /dev/null
+++ b/src/contexts/ActiveScopeContext.tsx
@@ -0,0 +1,152 @@
+import { createContext, useContext, useMemo, useState } from "react";
+import type { ReactNode } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useAuth } from "@/contexts/AuthContext";
+import { userGroupsQuery } from "@/api/groups/useUserGroups";
+import { useSetActiveGroupMutation } from "@/api/groups/useSetActiveGroupMutation";
+import { useSetActiveScopeMutation } from "@/api/groups/useSetActiveScopeMutation";
+import { resolveActiveGroupId, resolvePinnedScope } from "@/lib/activeGroup";
+import type { PinnedScope } from "@/lib/activeGroup";
+import type { Group } from "@/api/groups/types";
+
+interface ActiveScopeContextValue {
+ isLoading: boolean;
+ groups: Group[];
+ hasGroups: boolean;
+ /** Which group is "yours" — independent of active scope (group/everyone/me). */
+ activeGroupId: string | undefined;
+ pinned: PinnedScope;
+ current: PinnedScope;
+ isOverridden: boolean;
+ selectScope: (scope: PinnedScope) => void;
+ returnToDefault: () => void;
+ setActiveGroup: (groupId: string) => void;
+ setActiveScope: (scope: "group" | "everyone" | "me") => void;
+}
+
+const EVERYONE_SCOPE: PinnedScope = { kind: "everyone" };
+
+const ANONYMOUS_VALUE: ActiveScopeContextValue = {
+ isLoading: false,
+ groups: [],
+ hasGroups: false,
+ activeGroupId: undefined,
+ pinned: EVERYONE_SCOPE,
+ current: EVERYONE_SCOPE,
+ isOverridden: false,
+ selectScope: () => {},
+ returnToDefault: () => {},
+ setActiveGroup: () => {},
+ setActiveScope: () => {},
+};
+
+const ActiveScopeContext = createContext(
+ undefined,
+);
+
+export function ActiveScopeProvider({ children }: { children: ReactNode }) {
+ const { user } = useAuth();
+
+ if (!user) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+function scopeEquals(a: PinnedScope, b: PinnedScope): boolean {
+ if (a.kind !== b.kind) {
+ return false;
+ }
+ if (a.kind === "group" && b.kind === "group") {
+ return a.groupId === b.groupId;
+ }
+ return true;
+}
+
+function AuthedActiveScopeProvider({
+ userId,
+ children,
+}: {
+ userId: string;
+ children: ReactNode;
+}) {
+ const { profile } = useAuth();
+ const { data: groups = [], isLoading } = useQuery(userGroupsQuery(userId));
+ const [override, setOverride] = useState(null);
+
+ const groupIds = useMemo(() => groups.map((group) => group.id), [groups]);
+
+ const activeGroupId = resolveActiveGroupId({
+ activeGroupId: profile?.active_group_id,
+ groupIds,
+ });
+
+ const pinned = resolvePinnedScope({
+ activeGroupId: profile?.active_group_id,
+ activeScope: profile?.active_scope,
+ groupIds,
+ });
+
+ const current = override ?? pinned;
+ const isOverridden = override !== null && !scopeEquals(override, pinned);
+
+ const setActiveGroupMutation = useSetActiveGroupMutation();
+ const setActiveScopeMutation = useSetActiveScopeMutation();
+
+ function selectScope(scope: PinnedScope) {
+ setOverride(scopeEquals(scope, pinned) ? null : scope);
+ }
+
+ function returnToDefault() {
+ setOverride(null);
+ }
+
+ function setActiveGroup(groupId: string) {
+ setActiveGroupMutation.mutate({ userId, groupId });
+ setOverride(null);
+ }
+
+ function setActiveScope(scope: "group" | "everyone" | "me") {
+ setActiveScopeMutation.mutate({ userId, scope });
+ setOverride(null);
+ }
+
+ const value: ActiveScopeContextValue = {
+ isLoading,
+ groups,
+ hasGroups: groups.length > 0,
+ activeGroupId,
+ pinned,
+ current,
+ isOverridden,
+ selectScope,
+ returnToDefault,
+ setActiveGroup,
+ setActiveScope,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useActiveScope(): ActiveScopeContextValue {
+ const context = useContext(ActiveScopeContext);
+ if (context === undefined) {
+ throw new Error(
+ "useActiveScope must be used within an ActiveScopeProvider",
+ );
+ }
+ return context;
+}
diff --git a/src/hooks/useActiveGroup.ts b/src/hooks/useActiveGroup.ts
deleted file mode 100644
index c30e1cf8..00000000
--- a/src/hooks/useActiveGroup.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { useSuspenseQuery } from "@tanstack/react-query";
-import { useAuth } from "@/contexts/AuthContext";
-import { userGroupsQuery } from "@/api/groups/useUserGroups";
-import { resolveActiveGroupId } from "@/lib/activeGroup";
-import type { Group } from "@/api/groups/types";
-
-interface ActiveGroupState {
- activeGroupId: string | undefined;
- activeGroup: Group | undefined;
- groups: Group[];
- hasGroups: boolean;
-}
-
-export function useActiveGroup(userId: string): ActiveGroupState {
- const { profile } = useAuth();
- const { data: groups } = useSuspenseQuery(userGroupsQuery(userId));
-
- const activeGroupId = resolveActiveGroupId({
- profileActiveGroupId: profile?.active_group_id,
- hasExplicitSelection: profile?.active_group_selected ?? false,
- groupIds: groups.map((group) => group.id),
- });
-
- return {
- activeGroupId,
- activeGroup: groups.find((group) => group.id === activeGroupId),
- groups,
- hasGroups: groups.length > 0,
- };
-}
diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts
index 99d29c74..54918072 100644
--- a/src/integrations/supabase/types.ts
+++ b/src/integrations/supabase/types.ts
@@ -523,7 +523,7 @@ export type Database = {
profiles: {
Row: {
active_group_id: string | null;
- active_group_selected: boolean;
+ active_scope: Database["public"]["Enums"]["active_scope"] | null;
completed_onboarding: boolean | null;
created_at: string;
email: string | null;
@@ -532,7 +532,7 @@ export type Database = {
};
Insert: {
active_group_id?: string | null;
- active_group_selected?: boolean;
+ active_scope?: Database["public"]["Enums"]["active_scope"] | null;
completed_onboarding?: boolean | null;
created_at?: string;
email?: string | null;
@@ -541,7 +541,7 @@ export type Database = {
};
Update: {
active_group_id?: string | null;
- active_group_selected?: boolean;
+ active_scope?: Database["public"]["Enums"]["active_scope"] | null;
completed_onboarding?: boolean | null;
created_at?: string;
email?: string | null;
@@ -910,6 +910,7 @@ export type Database = {
};
};
Enums: {
+ active_scope: "group" | "everyone" | "me";
admin_role: "super_admin" | "admin" | "moderator";
festival_phase: "pre-schedule" | "planning" | "live" | "post-festival";
link_type: "website" | "tickets" | "custom";
diff --git a/src/lib/activeGroup.test.ts b/src/lib/activeGroup.test.ts
index 378b328d..3ad17a01 100644
--- a/src/lib/activeGroup.test.ts
+++ b/src/lib/activeGroup.test.ts
@@ -1,32 +1,23 @@
import { describe, expect, it } from "vitest";
-import { resolveActiveGroupId } from "./activeGroup";
+import { resolveActiveGroupId, resolvePinnedScope } from "./activeGroup";
describe("resolveActiveGroupId", () => {
it("returns undefined when the user has no groups", () => {
expect(
- resolveActiveGroupId({
- profileActiveGroupId: null,
- hasExplicitSelection: false,
- groupIds: [],
- }),
+ resolveActiveGroupId({ activeGroupId: null, groupIds: [] }),
).toBeUndefined();
});
it("auto-activates the single group when no active group is set", () => {
expect(
- resolveActiveGroupId({
- profileActiveGroupId: null,
- hasExplicitSelection: false,
- groupIds: ["group-1"],
- }),
+ resolveActiveGroupId({ activeGroupId: null, groupIds: ["group-1"] }),
).toBe("group-1");
});
it("does not auto-activate when the user belongs to multiple groups", () => {
expect(
resolveActiveGroupId({
- profileActiveGroupId: null,
- hasExplicitSelection: false,
+ activeGroupId: null,
groupIds: ["group-1", "group-2"],
}),
).toBeUndefined();
@@ -35,8 +26,7 @@ describe("resolveActiveGroupId", () => {
it("returns the persisted active group when it is still a membership", () => {
expect(
resolveActiveGroupId({
- profileActiveGroupId: "group-2",
- hasExplicitSelection: true,
+ activeGroupId: "group-2",
groupIds: ["group-1", "group-2"],
}),
).toBe("group-2");
@@ -45,8 +35,7 @@ describe("resolveActiveGroupId", () => {
it("ignores a persisted active group the user is no longer a member of", () => {
expect(
resolveActiveGroupId({
- profileActiveGroupId: "group-3",
- hasExplicitSelection: true,
+ activeGroupId: "group-3",
groupIds: ["group-1", "group-2"],
}),
).toBeUndefined();
@@ -55,8 +44,7 @@ describe("resolveActiveGroupId", () => {
it("falls back to auto-activation when the stale active group leaves exactly one membership", () => {
expect(
resolveActiveGroupId({
- profileActiveGroupId: "group-3",
- hasExplicitSelection: true,
+ activeGroupId: "group-3",
groupIds: ["group-1"],
}),
).toBe("group-1");
@@ -65,20 +53,81 @@ describe("resolveActiveGroupId", () => {
it("prefers the persisted active group over auto-activation when only one group remains", () => {
expect(
resolveActiveGroupId({
- profileActiveGroupId: "group-1",
- hasExplicitSelection: true,
+ activeGroupId: "group-1",
groupIds: ["group-1"],
}),
).toBe("group-1");
});
+});
- it("respects an explicit Everyone selection even when exactly one group remains", () => {
+describe("resolvePinnedScope", () => {
+ it("auto-derives to the sole group when no scope has ever been chosen", () => {
expect(
- resolveActiveGroupId({
- profileActiveGroupId: null,
- hasExplicitSelection: true,
+ resolvePinnedScope({
+ activeGroupId: null,
+ activeScope: null,
groupIds: ["group-1"],
}),
- ).toBeUndefined();
+ ).toEqual({ kind: "group", groupId: "group-1" });
+ });
+
+ it("auto-derives to Everyone when no scope has been chosen and there are multiple groups", () => {
+ expect(
+ resolvePinnedScope({
+ activeGroupId: null,
+ activeScope: null,
+ groupIds: ["group-1", "group-2"],
+ }),
+ ).toEqual({ kind: "everyone" });
+ });
+
+ it("auto-derives to Everyone when no scope has been chosen and there are no groups", () => {
+ expect(
+ resolvePinnedScope({
+ activeGroupId: null,
+ activeScope: null,
+ groupIds: [],
+ }),
+ ).toEqual({ kind: "everyone" });
+ });
+
+ it("respects an explicit Everyone scope even when exactly one group exists", () => {
+ expect(
+ resolvePinnedScope({
+ activeGroupId: "group-1",
+ activeScope: "everyone",
+ groupIds: ["group-1"],
+ }),
+ ).toEqual({ kind: "everyone" });
+ });
+
+ it("respects an explicit Me scope regardless of group membership", () => {
+ expect(
+ resolvePinnedScope({
+ activeGroupId: "group-1",
+ activeScope: "me",
+ groupIds: ["group-1", "group-2"],
+ }),
+ ).toEqual({ kind: "me" });
+ });
+
+ it("resolves an explicit group scope through the active group id", () => {
+ expect(
+ resolvePinnedScope({
+ activeGroupId: "group-2",
+ activeScope: "group",
+ groupIds: ["group-1", "group-2"],
+ }),
+ ).toEqual({ kind: "group", groupId: "group-2" });
+ });
+
+ it("falls back to Everyone when scope is explicitly group but the active group id is stale and ambiguous", () => {
+ expect(
+ resolvePinnedScope({
+ activeGroupId: "group-3",
+ activeScope: "group",
+ groupIds: ["group-1", "group-2"],
+ }),
+ ).toEqual({ kind: "everyone" });
});
});
diff --git a/src/lib/activeGroup.ts b/src/lib/activeGroup.ts
index da9d2b42..f25db711 100644
--- a/src/lib/activeGroup.ts
+++ b/src/lib/activeGroup.ts
@@ -1,20 +1,19 @@
+export type PinnedScope =
+ | { kind: "group"; groupId: string }
+ | { kind: "everyone" }
+ | { kind: "me" };
+
interface ResolveActiveGroupIdParams {
- profileActiveGroupId: string | null | undefined;
- hasExplicitSelection: boolean;
+ activeGroupId: string | null | undefined;
groupIds: string[];
}
export function resolveActiveGroupId({
- profileActiveGroupId,
- hasExplicitSelection,
+ activeGroupId,
groupIds,
}: ResolveActiveGroupIdParams): string | undefined {
- if (profileActiveGroupId && groupIds.includes(profileActiveGroupId)) {
- return profileActiveGroupId;
- }
-
- if (profileActiveGroupId === null && hasExplicitSelection) {
- return undefined;
+ if (activeGroupId && groupIds.includes(activeGroupId)) {
+ return activeGroupId;
}
if (groupIds.length === 1) {
@@ -23,3 +22,31 @@ export function resolveActiveGroupId({
return undefined;
}
+
+interface ResolvePinnedScopeParams {
+ activeGroupId: string | null | undefined;
+ activeScope: "group" | "everyone" | "me" | null | undefined;
+ groupIds: string[];
+}
+
+export function resolvePinnedScope({
+ activeGroupId,
+ activeScope,
+ groupIds,
+}: ResolvePinnedScopeParams): PinnedScope {
+ if (activeScope === "everyone") {
+ return { kind: "everyone" };
+ }
+
+ if (activeScope === "me") {
+ return { kind: "me" };
+ }
+
+ const resolvedGroupId = resolveActiveGroupId({ activeGroupId, groupIds });
+
+ if (resolvedGroupId) {
+ return { kind: "group", groupId: resolvedGroupId };
+ }
+
+ return { kind: "everyone" };
+}
diff --git a/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx b/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx
index af7764cb..0942da00 100644
--- a/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx
+++ b/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx
@@ -5,7 +5,7 @@ import { useSetFiltering } from "@/pages/EditionView/tabs/VoteTab/useSetFilterin
import { FilterSortControls } from "@/pages/EditionView/tabs/VoteTab/filters/FilterSortControls";
import { groupMembersQuery } from "@/api/groups/useGroupMembers";
import { useAuth } from "@/contexts/AuthContext";
-import { useActiveGroup } from "@/hooks/useActiveGroup";
+import { useActiveScope } from "@/contexts/ActiveScopeContext";
import type { FestivalSet } from "@/api/sets/types";
import type { FilterSortState } from "@/hooks/useUrlState";
import type { BinaryVoteScope, VoteScope } from "@/lib/voteScope";
@@ -49,11 +49,17 @@ export function FilteredSetsPanel(props: FilteredSetsPanelProps) {
}
// Preference between "everyone" and "group"; the active scope used for
-// filtering falls back to "everyone" whenever there is no Active Group.
+// filtering falls back to "everyone" whenever there is no active Group in
+// the header's current scope (including when it's pinned/overridden to Me,
+// which isn't a meaningful Vote Perspective on this tab).
function AuthedFilteredSetsPanel(
props: FilteredSetsPanelProps & { userId: string },
) {
- const { activeGroupId, activeGroup } = useActiveGroup(props.userId);
+ const { current, groups } = useActiveScope();
+ const activeGroupId = current.kind === "group" ? current.groupId : undefined;
+ const activeGroupName = activeGroupId
+ ? groups.find((group) => group.id === activeGroupId)?.name
+ : undefined;
const [perspective, setPerspective] = useState("group");
const voteScope: VoteScope =
@@ -67,11 +73,11 @@ function AuthedFilteredSetsPanel(
onClear={props.clearFilters}
editionId={props.editionId}
votePerspective={
- activeGroupId && activeGroup
+ activeGroupId && activeGroupName
? {
scope: voteScope,
onScopeChange: setPerspective,
- groupName: activeGroup.name,
+ groupName: activeGroupName,
}
: undefined
}
diff --git a/src/pages/Settings/ActiveGroupSetting.tsx b/src/pages/Settings/ActiveGroupSetting.tsx
new file mode 100644
index 00000000..4f6ccf76
--- /dev/null
+++ b/src/pages/Settings/ActiveGroupSetting.tsx
@@ -0,0 +1,34 @@
+import { Users } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { useActiveScope } from "@/contexts/ActiveScopeContext";
+
+export function ActiveGroupSetting() {
+ const { groups, activeGroupId, setActiveGroup } = useActiveScope();
+
+ return (
+
+
Active group
+
+ Which of your groups is yours by default, whenever your scope is set to
+ a group.
+
+
+ {groups.map((group) => (
+ setActiveGroup(group.id)}
+ className={cn(
+ "flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm text-purple-100 transition-colors",
+ activeGroupId === group.id
+ ? "border-purple-400 bg-purple-600/20 font-medium"
+ : "border-purple-400/30 hover:bg-purple-600/10",
+ )}
+ >
+
+ {group.name}
+
+ ))}
+
+
+ );
+}
diff --git a/src/pages/Settings/ActiveScopeSetting.tsx b/src/pages/Settings/ActiveScopeSetting.tsx
new file mode 100644
index 00000000..e1723b6a
--- /dev/null
+++ b/src/pages/Settings/ActiveScopeSetting.tsx
@@ -0,0 +1,50 @@
+import { Globe, Star, User as UserIcon, Users } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { useActiveScope } from "@/contexts/ActiveScopeContext";
+
+const SCOPE_OPTIONS = [
+ { kind: "group" as const, label: "Group", icon: Users },
+ { kind: "everyone" as const, label: "Everyone", icon: Globe },
+ { kind: "me" as const, label: "Me", icon: UserIcon },
+];
+
+export function ActiveScopeSetting() {
+ const { hasGroups, pinned, setActiveScope } = useActiveScope();
+
+ const options = hasGroups
+ ? SCOPE_OPTIONS
+ : SCOPE_OPTIONS.filter((option) => option.kind !== "group");
+
+ return (
+
+
Active scope
+
+ Your default steady-state view. The header switcher can override this
+ temporarily, but it always reverts back here on a fresh visit.
+
+
+ {options.map(({ kind, label, icon: Icon }) => {
+ const isPinned = pinned.kind === kind;
+ return (
+ setActiveScope(kind)}
+ className={cn(
+ "flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm text-purple-100 transition-colors",
+ isPinned
+ ? "border-purple-400 bg-purple-600/20 font-medium"
+ : "border-purple-400/30 hover:bg-purple-600/10",
+ )}
+ >
+
+ {label}
+ {isPinned && (
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/pages/Settings/SettingsPage.tsx b/src/pages/Settings/SettingsPage.tsx
new file mode 100644
index 00000000..9af4f729
--- /dev/null
+++ b/src/pages/Settings/SettingsPage.tsx
@@ -0,0 +1,46 @@
+import { Link } from "@tanstack/react-router";
+import { TopBar } from "@/components/layout/TopBar";
+import { useAuth } from "@/contexts/AuthContext";
+import { useActiveScope } from "@/contexts/ActiveScopeContext";
+import { SignInRequired } from "@/pages/groups/Groups/SignInRequired";
+import { ActiveGroupSetting } from "./ActiveGroupSetting";
+import { ActiveScopeSetting } from "./ActiveScopeSetting";
+
+export function SettingsPage() {
+ const { user } = useAuth();
+
+ if (!user) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function SettingsContent() {
+ const { hasGroups } = useActiveScope();
+
+ return (
+
+ {!hasGroups && (
+
+
+ Create or join a group
+ {" "}
+ to set an Active group.
+
+ )}
+ {hasGroups &&
}
+
+
+ );
+}
diff --git a/src/pages/groups/Groups/SignInRequired.tsx b/src/pages/groups/Groups/SignInRequired.tsx
index 83fb0ea1..17b432cf 100644
--- a/src/pages/groups/Groups/SignInRequired.tsx
+++ b/src/pages/groups/Groups/SignInRequired.tsx
@@ -8,13 +8,17 @@ import {
import { Button } from "@/components/ui/button";
import { Link } from "@tanstack/react-router";
-export function SignInRequired() {
+export function SignInRequired({
+ description = "Please sign in to manage groups",
+}: {
+ description?: string;
+}) {
return (
Sign in required
- Please sign in to manage groups
+ {description}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index b7ff53a5..275de3f0 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as TermsRouteImport } from './routes/terms'
+import { Route as SettingsRouteImport } from './routes/settings'
import { Route as PrivacyRouteImport } from './routes/privacy'
import { Route as CookiesRouteImport } from './routes/cookies'
import { Route as AdminRouteImport } from './routes/admin'
@@ -46,6 +47,11 @@ const TermsRoute = TermsRouteImport.update({
path: '/terms',
getParentRoute: () => rootRouteImport,
} as any)
+const SettingsRoute = SettingsRouteImport.update({
+ id: '/settings',
+ path: '/settings',
+ getParentRoute: () => rootRouteImport,
+} as any)
const PrivacyRoute = PrivacyRouteImport.update({
id: '/privacy',
path: '/privacy',
@@ -220,6 +226,7 @@ export interface FileRoutesByFullPath {
'/admin': typeof AdminRouteWithChildren
'/cookies': typeof CookiesRoute
'/privacy': typeof PrivacyRoute
+ '/settings': typeof SettingsRoute
'/terms': typeof TermsRoute
'/admin/admins': typeof AdminAdminsRoute
'/admin/analytics': typeof AdminAnalyticsRoute
@@ -253,6 +260,7 @@ export interface FileRoutesByTo {
'/admin': typeof AdminRouteWithChildren
'/cookies': typeof CookiesRoute
'/privacy': typeof PrivacyRoute
+ '/settings': typeof SettingsRoute
'/terms': typeof TermsRoute
'/admin/admins': typeof AdminAdminsRoute
'/admin/analytics': typeof AdminAnalyticsRoute
@@ -285,6 +293,7 @@ export interface FileRoutesById {
'/admin': typeof AdminRouteWithChildren
'/cookies': typeof CookiesRoute
'/privacy': typeof PrivacyRoute
+ '/settings': typeof SettingsRoute
'/terms': typeof TermsRoute
'/admin/admins': typeof AdminAdminsRoute
'/admin/analytics': typeof AdminAnalyticsRoute
@@ -320,6 +329,7 @@ export interface FileRouteTypes {
| '/admin'
| '/cookies'
| '/privacy'
+ | '/settings'
| '/terms'
| '/admin/admins'
| '/admin/analytics'
@@ -353,6 +363,7 @@ export interface FileRouteTypes {
| '/admin'
| '/cookies'
| '/privacy'
+ | '/settings'
| '/terms'
| '/admin/admins'
| '/admin/analytics'
@@ -384,6 +395,7 @@ export interface FileRouteTypes {
| '/admin'
| '/cookies'
| '/privacy'
+ | '/settings'
| '/terms'
| '/admin/admins'
| '/admin/analytics'
@@ -418,6 +430,7 @@ export interface RootRouteChildren {
AdminRoute: typeof AdminRouteWithChildren
CookiesRoute: typeof CookiesRoute
PrivacyRoute: typeof PrivacyRoute
+ SettingsRoute: typeof SettingsRoute
TermsRoute: typeof TermsRoute
FestivalsFestivalSlugRoute: typeof FestivalsFestivalSlugRouteWithChildren
GroupsGroupSlugRoute: typeof GroupsGroupSlugRoute
@@ -433,6 +446,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TermsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/settings': {
+ id: '/settings'
+ path: '/settings'
+ fullPath: '/settings'
+ preLoaderRoute: typeof SettingsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/privacy': {
id: '/privacy'
path: '/privacy'
@@ -812,6 +832,7 @@ const rootRouteChildren: RootRouteChildren = {
AdminRoute: AdminRouteWithChildren,
CookiesRoute: CookiesRoute,
PrivacyRoute: PrivacyRoute,
+ SettingsRoute: SettingsRoute,
TermsRoute: TermsRoute,
FestivalsFestivalSlugRoute: FestivalsFestivalSlugRouteWithChildren,
GroupsGroupSlugRoute: GroupsGroupSlugRoute,
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index 2c112fb6..74795229 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -16,6 +16,7 @@ import { TanStackRouterDevtools } from "@tanstack/router-devtools";
import { HelmetProvider } from "react-helmet-async";
import { AuthProvider } from "@/contexts/AuthContext";
import { useAuth } from "@/contexts/AuthContext";
+import { ActiveScopeProvider } from "@/contexts/ActiveScopeContext";
import { useInviteValidation } from "@/components/invite/useInviteValidation";
import { InviteLandingPage } from "@/components/invite/InviteLandingPage";
import { OnboardingDialog } from "@/components/onboarding/OnboardingDialog";
@@ -65,7 +66,9 @@ function RootComponent() {
-
+
+
+
diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx
new file mode 100644
index 00000000..91497619
--- /dev/null
+++ b/src/routes/settings.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { SettingsPage } from "@/pages/Settings/SettingsPage";
+
+export const Route = createFileRoute("/settings")({
+ component: SettingsPage,
+});
diff --git a/supabase/migrations/20260804060000_add_active_scope_to_profiles.sql b/supabase/migrations/20260804060000_add_active_scope_to_profiles.sql
new file mode 100644
index 00000000..b2edcfe1
--- /dev/null
+++ b/supabase/migrations/20260804060000_add_active_scope_to_profiles.sql
@@ -0,0 +1,24 @@
+-- Replace the active_group_selected flag with a proper Active scope model.
+-- See docs/adr/0005-active-group-model.md.
+--
+-- The original active_group_id-only model overloaded NULL to mean both
+-- "never chosen" and "explicitly Everyone", which active_group_selected
+-- patched around. Root cause instead: "which group is mine" (active_group_id)
+-- and "which lens am I pinned to by default" (active_scope) are two
+-- independent settings. active_group_id keeps its original meaning; NULL now
+-- unambiguously means "no group chosen".
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'active_scope') THEN
+ CREATE TYPE public.active_scope AS ENUM ('group', 'everyone', 'me');
+ END IF;
+END$$;
+
+ALTER TABLE public.profiles
+ ADD COLUMN IF NOT EXISTS active_scope public.active_scope;
+
+COMMENT ON COLUMN public.profiles.active_scope IS
+ 'Durable, Settings-level pin for the default scope: which of your groups (via active_group_id), Everyone, or Me. NULL means never explicitly chosen — auto-derives to the sole group when the user has exactly one, else Everyone.';
+
+ALTER TABLE public.profiles
+ DROP COLUMN IF EXISTS active_group_selected;
From d0d35d8fbe4495e3d44be567d34012776767b483 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 9 Aug 2026 08:56:02 +0000
Subject: [PATCH 09/23] Decompose ActiveGroupSwitcher.tsx into smaller files
Split the 189-line switcher into three focused files per review feedback:
scopeDisplay.ts (pure label/icon/key helpers), ScopeMenuBody.tsx (the
dropdown list), and the trimmed-down ActiveGroupSwitcher.tsx (trigger +
back-to-default pill).
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01VGSj8prZCB1qAtR2Wz3qKL
---
.../layout/AppHeader/ActiveGroupSwitcher.tsx | 106 +----------------
.../layout/AppHeader/ScopeMenuBody.tsx | 111 ++++++++++++++++++
.../layout/AppHeader/scopeDisplay.ts | 27 +++++
3 files changed, 141 insertions(+), 103 deletions(-)
create mode 100644 src/components/layout/AppHeader/ScopeMenuBody.tsx
create mode 100644 src/components/layout/AppHeader/scopeDisplay.ts
diff --git a/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx b/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx
index bab8b334..2ef91dc4 100644
--- a/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx
+++ b/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx
@@ -1,12 +1,4 @@
-import {
- ChevronDown,
- Globe,
- Settings,
- Star,
- User as UserIcon,
- Users,
- X,
-} from "lucide-react";
+import { ChevronDown, Settings, X } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import {
@@ -17,39 +9,14 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useActiveScope } from "@/contexts/ActiveScopeContext";
-import { cn } from "@/lib/utils";
-import type { PinnedScope } from "@/lib/activeGroup";
-import type { Group } from "@/api/groups/types";
+import { scopeIcon, scopeLabel } from "./scopeDisplay";
+import { ScopeMenuBody } from "./ScopeMenuBody";
interface ActiveGroupSwitcherProps {
isMobile: boolean;
className: string;
}
-function scopeKey(scope: PinnedScope): string {
- return scope.kind === "group" ? `group:${scope.groupId}` : scope.kind;
-}
-
-function scopeLabel(scope: PinnedScope, groups: Group[]): string {
- if (scope.kind === "everyone") {
- return "Everyone";
- }
- if (scope.kind === "me") {
- return "Me";
- }
- return groups.find((group) => group.id === scope.groupId)?.name ?? "Group";
-}
-
-function scopeIcon(scope: PinnedScope) {
- if (scope.kind === "everyone") {
- return Globe;
- }
- if (scope.kind === "me") {
- return UserIcon;
- }
- return Users;
-}
-
export function ActiveGroupSwitcher({
isMobile,
className,
@@ -120,70 +87,3 @@ export function ActiveGroupSwitcher({
);
}
-
-/**
- * Pinned entry always sorts first (starred), so reverting to it never
- * requires hunting through the list; then remaining groups, then Everyone/Me.
- */
-function ScopeMenuBody({
- groups,
- pinned,
- current,
- onSelect,
-}: {
- groups: Group[];
- pinned: PinnedScope;
- current: PinnedScope;
- onSelect: (scope: PinnedScope) => void;
-}) {
- function Row({ scope, label }: { scope: PinnedScope; label: string }) {
- const Icon = scopeIcon(scope);
- const isPinned = scopeKey(scope) === scopeKey(pinned);
- const isActive = scopeKey(scope) === scopeKey(current);
- return (
- onSelect(scope)}
- className={cn(
- "flex items-center gap-2 text-purple-100 hover:bg-purple-600/30",
- isActive && "bg-purple-600/20",
- )}
- >
-
- {label}
- {isPinned && (
-
- )}
-
- );
- }
-
- const pinnedGroupId = pinned.kind === "group" ? pinned.groupId : undefined;
- const otherGroups = groups.filter((group) => group.id !== pinnedGroupId);
- const otherScopeKinds = (["everyone", "me"] as const).filter(
- (kind) => pinned.kind !== kind,
- );
-
- return (
- <>
-
-
- {otherGroups.map((group) => (
-
- ))}
- {otherGroups.length > 0 && otherScopeKinds.length > 0 && (
-
- )}
- {otherScopeKinds.map((kind) => (
-
- ))}
- >
- );
-}
diff --git a/src/components/layout/AppHeader/ScopeMenuBody.tsx b/src/components/layout/AppHeader/ScopeMenuBody.tsx
new file mode 100644
index 00000000..9b7b0bc5
--- /dev/null
+++ b/src/components/layout/AppHeader/ScopeMenuBody.tsx
@@ -0,0 +1,111 @@
+import { Star } from "lucide-react";
+import {
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+} from "@/components/ui/dropdown-menu";
+import { cn } from "@/lib/utils";
+import { scopeIcon, scopeKey, scopeLabel } from "./scopeDisplay";
+import type { PinnedScope } from "@/lib/activeGroup";
+import type { Group } from "@/api/groups/types";
+
+/**
+ * Pinned entry always sorts first (starred), so reverting to it never
+ * requires hunting through the list; then remaining groups, then Everyone/Me.
+ */
+export function ScopeMenuBody({
+ groups,
+ pinned,
+ current,
+ onSelect,
+}: {
+ groups: Group[];
+ pinned: PinnedScope;
+ current: PinnedScope;
+ onSelect: (scope: PinnedScope) => void;
+}) {
+ const pinnedGroupId = pinned.kind === "group" ? pinned.groupId : undefined;
+ const otherGroups = groups.filter((group) => group.id !== pinnedGroupId);
+ const otherScopeKinds = (["everyone", "me"] as const).filter(
+ (kind) => pinned.kind !== kind,
+ );
+
+ function isPinned(scope: PinnedScope) {
+ return scopeKey(scope) === scopeKey(pinned);
+ }
+ function isActive(scope: PinnedScope) {
+ return scopeKey(scope) === scopeKey(current);
+ }
+
+ return (
+ <>
+
+
+ {otherGroups.map((group) => {
+ const scope: PinnedScope = { kind: "group", groupId: group.id };
+ return (
+
+ );
+ })}
+ {otherGroups.length > 0 && otherScopeKinds.length > 0 && (
+
+ )}
+ {otherScopeKinds.map((kind) => {
+ const scope: PinnedScope = { kind };
+ return (
+
+ );
+ })}
+ >
+ );
+}
+
+function ScopeMenuRow({
+ scope,
+ label,
+ isPinned,
+ isActive,
+ onSelect,
+}: {
+ scope: PinnedScope;
+ label: string;
+ isPinned: boolean;
+ isActive: boolean;
+ onSelect: (scope: PinnedScope) => void;
+}) {
+ const Icon = scopeIcon(scope);
+ return (
+ onSelect(scope)}
+ className={cn(
+ "flex items-center gap-2 text-purple-100 hover:bg-purple-600/30",
+ isActive && "bg-purple-600/20",
+ )}
+ >
+
+ {label}
+ {isPinned && (
+
+ )}
+
+ );
+}
diff --git a/src/components/layout/AppHeader/scopeDisplay.ts b/src/components/layout/AppHeader/scopeDisplay.ts
new file mode 100644
index 00000000..b44e7930
--- /dev/null
+++ b/src/components/layout/AppHeader/scopeDisplay.ts
@@ -0,0 +1,27 @@
+import { Globe, User as UserIcon, Users } from "lucide-react";
+import type { PinnedScope } from "@/lib/activeGroup";
+import type { Group } from "@/api/groups/types";
+
+export function scopeKey(scope: PinnedScope): string {
+ return scope.kind === "group" ? `group:${scope.groupId}` : scope.kind;
+}
+
+export function scopeLabel(scope: PinnedScope, groups: Group[]): string {
+ if (scope.kind === "everyone") {
+ return "Everyone";
+ }
+ if (scope.kind === "me") {
+ return "Me";
+ }
+ return groups.find((group) => group.id === scope.groupId)?.name ?? "Group";
+}
+
+export function scopeIcon(scope: PinnedScope) {
+ if (scope.kind === "everyone") {
+ return Globe;
+ }
+ if (scope.kind === "me") {
+ return UserIcon;
+ }
+ return Users;
+}
From bbf96bcd146c2ebddcd2ae9c3de7520952394024 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 9 Aug 2026 09:25:38 +0000
Subject: [PATCH 10/23] Drop the back-to-default pill from the Active Group
switcher
Per review feedback: the pinned entry already sorts first (starred) in
the dropdown, so a separate "back to X" affordance next to the trigger
was redundant and added an extra jumping element to the header.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01VGSj8prZCB1qAtR2Wz3qKL
---
docs/adr/0005-active-group-model.md | 6 +-
.../layout/AppHeader/ActiveGroupSwitcher.tsx | 92 +++++++------------
src/contexts/ActiveScopeContext.tsx | 11 ---
3 files changed, 38 insertions(+), 71 deletions(-)
diff --git a/docs/adr/0005-active-group-model.md b/docs/adr/0005-active-group-model.md
index 3addda83..e2110e64 100644
--- a/docs/adr/0005-active-group-model.md
+++ b/docs/adr/0005-active-group-model.md
@@ -17,11 +17,11 @@ Both are set explicitly only from **Settings** (`src/pages/Settings/`) — this
## Asymmetric by design: header is a transient override, Settings holds the pin
-The header switcher (`ActiveGroupSwitcher.tsx`) no longer writes either column directly. Selecting an entry there sets a **transient, in-memory override** (`ActiveScopeContext`, plain `useState`, not persisted) — it reverts to the Settings pin on a fresh visit/reload. A "back to {pinned}" affordance appears next to the trigger whenever the current view diverges from the pin.
+The header switcher (`ActiveGroupSwitcher.tsx`) no longer writes either column directly. Selecting an entry there sets a **transient, in-memory override** (`ActiveScopeContext`, plain `useState`, not persisted) — it reverts to the Settings pin on a fresh visit/reload.
This is intentionally asymmetric, not symmetric: real Group picks are meant to stay sticky/default with no friction (that's the whole point of this epic — centralizing the app around "your crew"), while Everyone/Me are meant to default to being a temporary lens. Putting the friction on the Settings-level pin (not on casual switching in the header) serves that goal; a uniform "every header pick is a commit" model — the original #124 shape — does not.
-The header dropdown lists the pinned entry first (starred), then remaining Groups, then remaining of Everyone/Me — so reverting to the pin never requires scanning the list.
+The header dropdown lists the pinned entry first (starred), then remaining Groups, then remaining of Everyone/Me — so reverting to the pin is always the first item in the list, one open + one click. There's deliberately no separate "back to default" affordance next to the trigger: it would just add a second way to do what the starred, always-first entry already does.
## Unifies with the future Schedule-tab Vote Scope
@@ -37,6 +37,6 @@ The three-way scope (`group` / `everyone` / `me`) is written to serve both the A
## Consequences
- `src/lib/activeGroup.ts` exports `resolveActiveGroupId` (unchanged shape, now scope-independent) and `resolvePinnedScope` (new), both pure and framework-free.
-- `src/contexts/ActiveScopeContext.tsx` is the single seam every scope-aware surface reads from: `pinned` (durable), `current` (transient), `isOverridden`, and the two Settings-only mutators `setActiveGroup` / `setActiveScope`.
+- `src/contexts/ActiveScopeContext.tsx` is the single seam every scope-aware surface reads from: `pinned` (durable), `current` (transient), and the two Settings-only mutators `setActiveGroup` / `setActiveScope`.
- `src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx`'s Vote Perspective toggle seeds its default from `current`, mapping `me` to `everyone` since Vote Perspective has no Me option.
- Any future Vote Scope work (Schedule tab, Me ↔ Active Group, issue #125) reuses `ActiveScopeContext` directly; it does not need its own auto-activation or override tracking.
diff --git a/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx b/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx
index 2ef91dc4..931e9ce1 100644
--- a/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx
+++ b/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx
@@ -1,4 +1,4 @@
-import { ChevronDown, Settings, X } from "lucide-react";
+import { ChevronDown, Settings } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import {
@@ -21,69 +21,47 @@ export function ActiveGroupSwitcher({
isMobile,
className,
}: ActiveGroupSwitcherProps) {
- const {
- groups,
- pinned,
- current,
- isOverridden,
- selectScope,
- returnToDefault,
- } = useActiveScope();
+ const { groups, pinned, current, selectScope } = useActiveScope();
const CurrentIcon = scopeIcon(current);
const currentLabel = scopeLabel(current, groups);
return (
-
-
-
-
-
- {!isMobile && (
-
- {currentLabel}
-
-
- )}
-
-
-
-
-
-
-
-
- Manage groups
-
-
-
-
-
- {isOverridden && (
+
+
-
- {!isMobile && back to {scopeLabel(pinned, groups)} }
+
+ {!isMobile && (
+
+ {currentLabel}
+
+
+ )}
- )}
-
+
+
+
+
+
+
+
+ Manage groups
+
+
+
+
);
}
diff --git a/src/contexts/ActiveScopeContext.tsx b/src/contexts/ActiveScopeContext.tsx
index 8ef64115..370bfd61 100644
--- a/src/contexts/ActiveScopeContext.tsx
+++ b/src/contexts/ActiveScopeContext.tsx
@@ -17,9 +17,7 @@ interface ActiveScopeContextValue {
activeGroupId: string | undefined;
pinned: PinnedScope;
current: PinnedScope;
- isOverridden: boolean;
selectScope: (scope: PinnedScope) => void;
- returnToDefault: () => void;
setActiveGroup: (groupId: string) => void;
setActiveScope: (scope: "group" | "everyone" | "me") => void;
}
@@ -33,9 +31,7 @@ const ANONYMOUS_VALUE: ActiveScopeContextValue = {
activeGroupId: undefined,
pinned: EVERYONE_SCOPE,
current: EVERYONE_SCOPE,
- isOverridden: false,
selectScope: () => {},
- returnToDefault: () => {},
setActiveGroup: () => {},
setActiveScope: () => {},
};
@@ -97,7 +93,6 @@ function AuthedActiveScopeProvider({
});
const current = override ?? pinned;
- const isOverridden = override !== null && !scopeEquals(override, pinned);
const setActiveGroupMutation = useSetActiveGroupMutation();
const setActiveScopeMutation = useSetActiveScopeMutation();
@@ -106,10 +101,6 @@ function AuthedActiveScopeProvider({
setOverride(scopeEquals(scope, pinned) ? null : scope);
}
- function returnToDefault() {
- setOverride(null);
- }
-
function setActiveGroup(groupId: string) {
setActiveGroupMutation.mutate({ userId, groupId });
setOverride(null);
@@ -127,9 +118,7 @@ function AuthedActiveScopeProvider({
activeGroupId,
pinned,
current,
- isOverridden,
selectScope,
- returnToDefault,
setActiveGroup,
setActiveScope,
};
From b042adeae386501204efc5a17a67c62f382c78b2 Mon Sep 17 00:00:00 2001
From: Chaim Lev-Ari
Date: Fri, 14 Aug 2026 07:30:33 +0200
Subject: [PATCH 11/23] update migrations time
---
...l => 20260814140000_add_active_group_selected_to_profiles.sql} | 0
...ofiles.sql => 20260814150000_add_active_scope_to_profiles.sql} | 0
2 files changed, 0 insertions(+), 0 deletions(-)
rename supabase/migrations/{20260801140000_add_active_group_selected_to_profiles.sql => 20260814140000_add_active_group_selected_to_profiles.sql} (100%)
rename supabase/migrations/{20260804060000_add_active_scope_to_profiles.sql => 20260814150000_add_active_scope_to_profiles.sql} (100%)
diff --git a/supabase/migrations/20260801140000_add_active_group_selected_to_profiles.sql b/supabase/migrations/20260814140000_add_active_group_selected_to_profiles.sql
similarity index 100%
rename from supabase/migrations/20260801140000_add_active_group_selected_to_profiles.sql
rename to supabase/migrations/20260814140000_add_active_group_selected_to_profiles.sql
diff --git a/supabase/migrations/20260804060000_add_active_scope_to_profiles.sql b/supabase/migrations/20260814150000_add_active_scope_to_profiles.sql
similarity index 100%
rename from supabase/migrations/20260804060000_add_active_scope_to_profiles.sql
rename to supabase/migrations/20260814150000_add_active_scope_to_profiles.sql
From 115ee17a8f6cb3f6edb8ec6f13ea9046eb8c8b2f Mon Sep 17 00:00:00 2001
From: Chaim Lev-Ari
Date: Fri, 14 Aug 2026 08:07:06 +0200
Subject: [PATCH 12/23] remove comments
---
src/components/layout/AppHeader/ScopeMenuBody.tsx | 4 ----
src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx | 4 ----
2 files changed, 8 deletions(-)
diff --git a/src/components/layout/AppHeader/ScopeMenuBody.tsx b/src/components/layout/AppHeader/ScopeMenuBody.tsx
index 9b7b0bc5..5a2a2975 100644
--- a/src/components/layout/AppHeader/ScopeMenuBody.tsx
+++ b/src/components/layout/AppHeader/ScopeMenuBody.tsx
@@ -8,10 +8,6 @@ import { scopeIcon, scopeKey, scopeLabel } from "./scopeDisplay";
import type { PinnedScope } from "@/lib/activeGroup";
import type { Group } from "@/api/groups/types";
-/**
- * Pinned entry always sorts first (starred), so reverting to it never
- * requires hunting through the list; then remaining groups, then Everyone/Me.
- */
export function ScopeMenuBody({
groups,
pinned,
diff --git a/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx b/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx
index 0942da00..4d8e73d1 100644
--- a/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx
+++ b/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx
@@ -48,10 +48,6 @@ export function FilteredSetsPanel(props: FilteredSetsPanelProps) {
return ;
}
-// Preference between "everyone" and "group"; the active scope used for
-// filtering falls back to "everyone" whenever there is no active Group in
-// the header's current scope (including when it's pinned/overridden to Me,
-// which isn't a meaningful Vote Perspective on this tab).
function AuthedFilteredSetsPanel(
props: FilteredSetsPanelProps & { userId: string },
) {
From 11c40b885392e8b198bd5c15ca810ffbda768467 Mon Sep 17 00:00:00 2001
From: Chaim Lev-Ari
Date: Fri, 14 Aug 2026 09:15:42 +0200
Subject: [PATCH 13/23] fix: use scope-independent activeGroupId for Vote
Perspective toggle
Deriving activeGroupId from the header's transient current scope hid
the toggle whenever a user with an Active Group switched away from
"group". Also seed the initial perspective from current per ADR 0005
instead of hardcoding "group".
Co-Authored-By: Claude Sonnet 5
---
src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx b/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx
index 4d8e73d1..b949ced2 100644
--- a/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx
+++ b/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx
@@ -51,12 +51,13 @@ export function FilteredSetsPanel(props: FilteredSetsPanelProps) {
function AuthedFilteredSetsPanel(
props: FilteredSetsPanelProps & { userId: string },
) {
- const { current, groups } = useActiveScope();
- const activeGroupId = current.kind === "group" ? current.groupId : undefined;
+ const { current, groups, activeGroupId } = useActiveScope();
const activeGroupName = activeGroupId
? groups.find((group) => group.id === activeGroupId)?.name
: undefined;
- const [perspective, setPerspective] = useState("group");
+ const [perspective, setPerspective] = useState(
+ current.kind === "group" ? "group" : "everyone",
+ );
const voteScope: VoteScope =
perspective === "group" && activeGroupId ? "group" : "everyone";
From 9954b9e8ea7e1662ed51c8030861e2fd25ad477f Mon Sep 17 00:00:00 2001
From: Chaim Lev-Ari
Date: Fri, 14 Aug 2026 09:19:10 +0200
Subject: [PATCH 14/23] refactor: use ToggleGroup for Active group/scope
settings
Replace hand-rolled + manual state classes with the
shadcn ToggleGroup pattern already used by VotePerspectiveToggle
for the same single-select choice shape.
Co-Authored-By: Claude Sonnet 5
---
src/pages/Settings/ActiveGroupSetting.tsx | 29 +++++++------
src/pages/Settings/ActiveScopeSetting.tsx | 50 ++++++++++++-----------
2 files changed, 43 insertions(+), 36 deletions(-)
diff --git a/src/pages/Settings/ActiveGroupSetting.tsx b/src/pages/Settings/ActiveGroupSetting.tsx
index 4f6ccf76..0986d14e 100644
--- a/src/pages/Settings/ActiveGroupSetting.tsx
+++ b/src/pages/Settings/ActiveGroupSetting.tsx
@@ -1,5 +1,5 @@
import { Users } from "lucide-react";
-import { cn } from "@/lib/utils";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useActiveScope } from "@/contexts/ActiveScopeContext";
export function ActiveGroupSetting() {
@@ -12,23 +12,28 @@ export function ActiveGroupSetting() {
Which of your groups is yours by default, whenever your scope is set to
a group.
-
+ {
+ if (value) {
+ setActiveGroup(value);
+ }
+ }}
+ className="flex-wrap justify-start gap-2"
+ >
{groups.map((group) => (
- setActiveGroup(group.id)}
- className={cn(
- "flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm text-purple-100 transition-colors",
- activeGroupId === group.id
- ? "border-purple-400 bg-purple-600/20 font-medium"
- : "border-purple-400/30 hover:bg-purple-600/10",
- )}
+ value={group.id}
+ className="gap-1.5 rounded-md border border-purple-400/30 px-3 py-1.5 text-sm text-purple-100 data-[state=on]:border-purple-400 data-[state=on]:bg-purple-600/20 data-[state=on]:font-medium data-[state=on]:text-purple-100 hover:bg-purple-600/10"
+ aria-label={`Set ${group.name} as active group`}
>
{group.name}
-
+
))}
-
+
);
}
diff --git a/src/pages/Settings/ActiveScopeSetting.tsx b/src/pages/Settings/ActiveScopeSetting.tsx
index e1723b6a..b3831eab 100644
--- a/src/pages/Settings/ActiveScopeSetting.tsx
+++ b/src/pages/Settings/ActiveScopeSetting.tsx
@@ -1,5 +1,5 @@
import { Globe, Star, User as UserIcon, Users } from "lucide-react";
-import { cn } from "@/lib/utils";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useActiveScope } from "@/contexts/ActiveScopeContext";
const SCOPE_OPTIONS = [
@@ -22,29 +22,31 @@ export function ActiveScopeSetting() {
Your default steady-state view. The header switcher can override this
temporarily, but it always reverts back here on a fresh visit.
-
- {options.map(({ kind, label, icon: Icon }) => {
- const isPinned = pinned.kind === kind;
- return (
- setActiveScope(kind)}
- className={cn(
- "flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm text-purple-100 transition-colors",
- isPinned
- ? "border-purple-400 bg-purple-600/20 font-medium"
- : "border-purple-400/30 hover:bg-purple-600/10",
- )}
- >
-
- {label}
- {isPinned && (
-
- )}
-
- );
- })}
-
+ {
+ if (value === "group" || value === "everyone" || value === "me") {
+ setActiveScope(value);
+ }
+ }}
+ className="flex-wrap justify-start gap-2"
+ >
+ {options.map(({ kind, label, icon: Icon }) => (
+
+
+ {label}
+ {pinned.kind === kind && (
+
+ )}
+
+ ))}
+
);
}
From 3baa05cfa1baa8eb67de2179d1c58b1d13c49798 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 14 Aug 2026 08:18:24 +0000
Subject: [PATCH 15/23] refactor: split FilteredSetsPanel and dedupe
active-scope mutations
Decompose FilteredSetsPanel.tsx (154 lines, four mixed concerns) into
AuthedFilteredSetsPanel/GroupScopedSetsPanel/SetsPanelContent, matching
the earlier ActiveGroupSwitcher/ScopeMenuBody split. This also removes
the duplicated "everyone" SetsPanelContent call between the anonymous
and authed-non-group branches.
Also collapse useSetActiveGroupMutation/useSetActiveScopeMutation into
a shared useProfileFieldMutation, and dedupe the ToggleGroupItem
className repeated in ActiveGroupSetting/ActiveScopeSetting.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_012hH41h18hKSvYxWAw17nhd
---
src/api/groups/useProfileFieldMutation.ts | 46 +++++++
src/api/groups/useSetActiveGroupMutation.ts | 38 ------
src/api/groups/useSetActiveScopeMutation.ts | 41 ------
src/contexts/ActiveScopeContext.tsx | 17 ++-
.../tabs/VoteTab/AuthedFilteredSetsPanel.tsx | 59 +++++++++
.../tabs/VoteTab/FilteredSetsPanel.tsx | 122 +-----------------
.../tabs/VoteTab/GroupScopedSetsPanel.tsx | 26 ++++
.../tabs/VoteTab/SetsPanelContent.tsx | 46 +++++++
src/pages/Settings/ActiveGroupSetting.tsx | 3 +-
src/pages/Settings/ActiveScopeSetting.tsx | 3 +-
.../Settings/settingsToggleItemClassName.ts | 2 +
11 files changed, 198 insertions(+), 205 deletions(-)
create mode 100644 src/api/groups/useProfileFieldMutation.ts
delete mode 100644 src/api/groups/useSetActiveGroupMutation.ts
delete mode 100644 src/api/groups/useSetActiveScopeMutation.ts
create mode 100644 src/pages/EditionView/tabs/VoteTab/AuthedFilteredSetsPanel.tsx
create mode 100644 src/pages/EditionView/tabs/VoteTab/GroupScopedSetsPanel.tsx
create mode 100644 src/pages/EditionView/tabs/VoteTab/SetsPanelContent.tsx
create mode 100644 src/pages/Settings/settingsToggleItemClassName.ts
diff --git a/src/api/groups/useProfileFieldMutation.ts b/src/api/groups/useProfileFieldMutation.ts
new file mode 100644
index 00000000..5551a1fc
--- /dev/null
+++ b/src/api/groups/useProfileFieldMutation.ts
@@ -0,0 +1,46 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useToast } from "@/hooks/use-toast";
+import { supabase } from "@/integrations/supabase/client";
+import { profileKeys } from "@/api/auth/types";
+import type { Database } from "@/integrations/supabase/types";
+
+type ProfileUpdate = Database["public"]["Tables"]["profiles"]["Update"];
+
+export function useProfileFieldMutation({
+ column,
+ errorMessage,
+}: {
+ column: K;
+ errorMessage: string;
+}) {
+ const queryClient = useQueryClient();
+ const { toast } = useToast();
+
+ return useMutation({
+ mutationFn: async (variables: {
+ userId: string;
+ value: ProfileUpdate[K];
+ }) => {
+ const { error } = await supabase
+ .from("profiles")
+ .update({ [column]: variables.value } as ProfileUpdate)
+ .eq("id", variables.userId);
+
+ if (error) {
+ throw new Error(errorMessage);
+ }
+ },
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: profileKeys.detail(variables.userId),
+ });
+ },
+ onError: (error) => {
+ toast({
+ title: "Error",
+ description: error?.message || errorMessage,
+ variant: "destructive",
+ });
+ },
+ });
+}
diff --git a/src/api/groups/useSetActiveGroupMutation.ts b/src/api/groups/useSetActiveGroupMutation.ts
deleted file mode 100644
index 20e39985..00000000
--- a/src/api/groups/useSetActiveGroupMutation.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { useToast } from "@/hooks/use-toast";
-import { supabase } from "@/integrations/supabase/client";
-import { profileKeys } from "@/api/auth/types";
-
-async function setActiveGroup(variables: { userId: string; groupId: string }) {
- const { userId, groupId } = variables;
-
- const { error } = await supabase
- .from("profiles")
- .update({ active_group_id: groupId })
- .eq("id", userId);
-
- if (error) {
- throw new Error("Failed to update active group");
- }
-}
-
-export function useSetActiveGroupMutation() {
- const queryClient = useQueryClient();
- const { toast } = useToast();
-
- return useMutation({
- mutationFn: setActiveGroup,
- onSuccess: (_data, variables) => {
- queryClient.invalidateQueries({
- queryKey: profileKeys.detail(variables.userId),
- });
- },
- onError: (error) => {
- toast({
- title: "Error",
- description: error?.message || "Failed to update active group",
- variant: "destructive",
- });
- },
- });
-}
diff --git a/src/api/groups/useSetActiveScopeMutation.ts b/src/api/groups/useSetActiveScopeMutation.ts
deleted file mode 100644
index ab3e79a7..00000000
--- a/src/api/groups/useSetActiveScopeMutation.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { useToast } from "@/hooks/use-toast";
-import { supabase } from "@/integrations/supabase/client";
-import { profileKeys } from "@/api/auth/types";
-
-async function setActiveScope(variables: {
- userId: string;
- scope: "group" | "everyone" | "me";
-}) {
- const { userId, scope } = variables;
-
- const { error } = await supabase
- .from("profiles")
- .update({ active_scope: scope })
- .eq("id", userId);
-
- if (error) {
- throw new Error("Failed to update active scope");
- }
-}
-
-export function useSetActiveScopeMutation() {
- const queryClient = useQueryClient();
- const { toast } = useToast();
-
- return useMutation({
- mutationFn: setActiveScope,
- onSuccess: (_data, variables) => {
- queryClient.invalidateQueries({
- queryKey: profileKeys.detail(variables.userId),
- });
- },
- onError: (error) => {
- toast({
- title: "Error",
- description: error?.message || "Failed to update active scope",
- variant: "destructive",
- });
- },
- });
-}
diff --git a/src/contexts/ActiveScopeContext.tsx b/src/contexts/ActiveScopeContext.tsx
index 370bfd61..b0690f39 100644
--- a/src/contexts/ActiveScopeContext.tsx
+++ b/src/contexts/ActiveScopeContext.tsx
@@ -3,8 +3,7 @@ import type { ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/contexts/AuthContext";
import { userGroupsQuery } from "@/api/groups/useUserGroups";
-import { useSetActiveGroupMutation } from "@/api/groups/useSetActiveGroupMutation";
-import { useSetActiveScopeMutation } from "@/api/groups/useSetActiveScopeMutation";
+import { useProfileFieldMutation } from "@/api/groups/useProfileFieldMutation";
import { resolveActiveGroupId, resolvePinnedScope } from "@/lib/activeGroup";
import type { PinnedScope } from "@/lib/activeGroup";
import type { Group } from "@/api/groups/types";
@@ -94,20 +93,26 @@ function AuthedActiveScopeProvider({
const current = override ?? pinned;
- const setActiveGroupMutation = useSetActiveGroupMutation();
- const setActiveScopeMutation = useSetActiveScopeMutation();
+ const setActiveGroupMutation = useProfileFieldMutation({
+ column: "active_group_id",
+ errorMessage: "Failed to update active group",
+ });
+ const setActiveScopeMutation = useProfileFieldMutation({
+ column: "active_scope",
+ errorMessage: "Failed to update active scope",
+ });
function selectScope(scope: PinnedScope) {
setOverride(scopeEquals(scope, pinned) ? null : scope);
}
function setActiveGroup(groupId: string) {
- setActiveGroupMutation.mutate({ userId, groupId });
+ setActiveGroupMutation.mutate({ userId, value: groupId });
setOverride(null);
}
function setActiveScope(scope: "group" | "everyone" | "me") {
- setActiveScopeMutation.mutate({ userId, scope });
+ setActiveScopeMutation.mutate({ userId, value: scope });
setOverride(null);
}
diff --git a/src/pages/EditionView/tabs/VoteTab/AuthedFilteredSetsPanel.tsx b/src/pages/EditionView/tabs/VoteTab/AuthedFilteredSetsPanel.tsx
new file mode 100644
index 00000000..d7172569
--- /dev/null
+++ b/src/pages/EditionView/tabs/VoteTab/AuthedFilteredSetsPanel.tsx
@@ -0,0 +1,59 @@
+import { useState } from "react";
+import { FilterSortControls } from "@/pages/EditionView/tabs/VoteTab/filters/FilterSortControls";
+import { GroupScopedSetsPanel } from "@/pages/EditionView/tabs/VoteTab/GroupScopedSetsPanel";
+import { EveryoneSetsPanel } from "@/pages/EditionView/tabs/VoteTab/SetsPanelContent";
+import { useActiveScope } from "@/contexts/ActiveScopeContext";
+import type { FilteredSetsPanelProps } from "@/pages/EditionView/tabs/VoteTab/FilteredSetsPanel";
+import type { BinaryVoteScope, VoteScope } from "@/lib/voteScope";
+
+export function AuthedFilteredSetsPanel(
+ props: FilteredSetsPanelProps & { userId: string },
+) {
+ const { current, groups, activeGroupId } = useActiveScope();
+ const activeGroupName = activeGroupId
+ ? groups.find((group) => group.id === activeGroupId)?.name
+ : undefined;
+ const [perspective, setPerspective] = useState(
+ current.kind === "group" ? "group" : "everyone",
+ );
+
+ const voteScope: VoteScope =
+ perspective === "group" && activeGroupId ? "group" : "everyone";
+
+ return (
+ <>
+
+
+
+ {voteScope === "group" && activeGroupId ? (
+
+ ) : (
+
+ )}
+
+ >
+ );
+}
diff --git a/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx b/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx
index b949ced2..d4d71aae 100644
--- a/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx
+++ b/src/pages/EditionView/tabs/VoteTab/FilteredSetsPanel.tsx
@@ -1,18 +1,11 @@
-import { useMemo, useState } from "react";
-import { useSuspenseQuery } from "@tanstack/react-query";
-import { SetsPanel } from "@/pages/EditionView/tabs/VoteTab/SetsPanel";
-import { useSetFiltering } from "@/pages/EditionView/tabs/VoteTab/useSetFiltering";
+import { AuthedFilteredSetsPanel } from "@/pages/EditionView/tabs/VoteTab/AuthedFilteredSetsPanel";
+import { EveryoneSetsPanel } from "@/pages/EditionView/tabs/VoteTab/SetsPanelContent";
import { FilterSortControls } from "@/pages/EditionView/tabs/VoteTab/filters/FilterSortControls";
-import { groupMembersQuery } from "@/api/groups/useGroupMembers";
import { useAuth } from "@/contexts/AuthContext";
-import { useActiveScope } from "@/contexts/ActiveScopeContext";
import type { FestivalSet } from "@/api/sets/types";
import type { FilterSortState } from "@/hooks/useUrlState";
-import type { BinaryVoteScope, VoteScope } from "@/lib/voteScope";
-const NO_MEMBERS = new Set();
-
-interface FilteredSetsPanelProps {
+export interface FilteredSetsPanelProps {
sets: FestivalSet[];
urlState: FilterSortState;
updateUrlState: (updates: Partial) => void;
@@ -33,12 +26,10 @@ export function FilteredSetsPanel(props: FilteredSetsPanelProps) {
editionId={props.editionId}
/>
-
>
@@ -47,108 +38,3 @@ export function FilteredSetsPanel(props: FilteredSetsPanelProps) {
return ;
}
-
-function AuthedFilteredSetsPanel(
- props: FilteredSetsPanelProps & { userId: string },
-) {
- const { current, groups, activeGroupId } = useActiveScope();
- const activeGroupName = activeGroupId
- ? groups.find((group) => group.id === activeGroupId)?.name
- : undefined;
- const [perspective, setPerspective] = useState(
- current.kind === "group" ? "group" : "everyone",
- );
-
- const voteScope: VoteScope =
- perspective === "group" && activeGroupId ? "group" : "everyone";
-
- return (
- <>
-
-
-
- {voteScope === "group" && activeGroupId ? (
-
- ) : (
-
- )}
-
- >
- );
-}
-
-function GroupScopedSetsPanel({
- sets,
- urlState,
- updateUrlState,
- groupId,
-}: Pick & {
- groupId: string;
-}) {
- const { data: members } = useSuspenseQuery(groupMembersQuery(groupId));
- const groupMemberIds = useMemo(
- () => new Set(members.map((member) => member.user_id)),
- [members],
- );
-
- return (
-
- );
-}
-
-function SetsPanelContent({
- sets,
- urlState,
- updateUrlState,
- voteScope,
- groupMemberIds,
-}: Pick & {
- voteScope: VoteScope;
- groupMemberIds: Set;
-}) {
- const { filteredAndSortedSets, lockCurrentOrder } = useSetFiltering(
- sets,
- urlState,
- voteScope,
- groupMemberIds,
- );
-
- return (
- lockCurrentOrder(updateUrlState)}
- />
- );
-}
diff --git a/src/pages/EditionView/tabs/VoteTab/GroupScopedSetsPanel.tsx b/src/pages/EditionView/tabs/VoteTab/GroupScopedSetsPanel.tsx
new file mode 100644
index 00000000..b5f14a32
--- /dev/null
+++ b/src/pages/EditionView/tabs/VoteTab/GroupScopedSetsPanel.tsx
@@ -0,0 +1,26 @@
+import { useMemo } from "react";
+import { useSuspenseQuery } from "@tanstack/react-query";
+import { groupMembersQuery } from "@/api/groups/useGroupMembers";
+import {
+ SetsPanelContent,
+ type SetsPanelInputs,
+} from "@/pages/EditionView/tabs/VoteTab/SetsPanelContent";
+
+export function GroupScopedSetsPanel({
+ groupId,
+ ...panelInputs
+}: SetsPanelInputs & { groupId: string }) {
+ const { data: members } = useSuspenseQuery(groupMembersQuery(groupId));
+ const groupMemberIds = useMemo(
+ () => new Set(members.map((member) => member.user_id)),
+ [members],
+ );
+
+ return (
+
+ );
+}
diff --git a/src/pages/EditionView/tabs/VoteTab/SetsPanelContent.tsx b/src/pages/EditionView/tabs/VoteTab/SetsPanelContent.tsx
new file mode 100644
index 00000000..ca0d66b7
--- /dev/null
+++ b/src/pages/EditionView/tabs/VoteTab/SetsPanelContent.tsx
@@ -0,0 +1,46 @@
+import { SetsPanel } from "@/pages/EditionView/tabs/VoteTab/SetsPanel";
+import { useSetFiltering } from "@/pages/EditionView/tabs/VoteTab/useSetFiltering";
+import type { FestivalSet } from "@/api/sets/types";
+import type { FilterSortState } from "@/hooks/useUrlState";
+import type { VoteScope } from "@/lib/voteScope";
+
+export const NO_MEMBERS = new Set();
+
+export interface SetsPanelInputs {
+ sets: FestivalSet[];
+ urlState: FilterSortState;
+ updateUrlState: (updates: Partial) => void;
+}
+
+export function SetsPanelContent({
+ sets,
+ urlState,
+ updateUrlState,
+ voteScope,
+ groupMemberIds,
+}: SetsPanelInputs & { voteScope: VoteScope; groupMemberIds: Set }) {
+ const { filteredAndSortedSets, lockCurrentOrder } = useSetFiltering(
+ sets,
+ urlState,
+ voteScope,
+ groupMemberIds,
+ );
+
+ return (
+ lockCurrentOrder(updateUrlState)}
+ />
+ );
+}
+
+export function EveryoneSetsPanel(props: SetsPanelInputs) {
+ return (
+
+ );
+}
diff --git a/src/pages/Settings/ActiveGroupSetting.tsx b/src/pages/Settings/ActiveGroupSetting.tsx
index 0986d14e..e03ae234 100644
--- a/src/pages/Settings/ActiveGroupSetting.tsx
+++ b/src/pages/Settings/ActiveGroupSetting.tsx
@@ -1,6 +1,7 @@
import { Users } from "lucide-react";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useActiveScope } from "@/contexts/ActiveScopeContext";
+import { settingsToggleItemClassName } from "@/pages/Settings/settingsToggleItemClassName";
export function ActiveGroupSetting() {
const { groups, activeGroupId, setActiveGroup } = useActiveScope();
@@ -26,7 +27,7 @@ export function ActiveGroupSetting() {
diff --git a/src/pages/Settings/ActiveScopeSetting.tsx b/src/pages/Settings/ActiveScopeSetting.tsx
index b3831eab..63d73312 100644
--- a/src/pages/Settings/ActiveScopeSetting.tsx
+++ b/src/pages/Settings/ActiveScopeSetting.tsx
@@ -1,6 +1,7 @@
import { Globe, Star, User as UserIcon, Users } from "lucide-react";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useActiveScope } from "@/contexts/ActiveScopeContext";
+import { settingsToggleItemClassName } from "@/pages/Settings/settingsToggleItemClassName";
const SCOPE_OPTIONS = [
{ kind: "group" as const, label: "Group", icon: Users },
@@ -36,7 +37,7 @@ export function ActiveScopeSetting() {
diff --git a/src/pages/Settings/settingsToggleItemClassName.ts b/src/pages/Settings/settingsToggleItemClassName.ts
new file mode 100644
index 00000000..49f57188
--- /dev/null
+++ b/src/pages/Settings/settingsToggleItemClassName.ts
@@ -0,0 +1,2 @@
+export const settingsToggleItemClassName =
+ "gap-1.5 rounded-md border border-purple-400/30 px-3 py-1.5 text-sm text-purple-100 data-[state=on]:border-purple-400 data-[state=on]:bg-purple-600/20 data-[state=on]:font-medium data-[state=on]:text-purple-100 hover:bg-purple-600/10";
From 52ae67a9272fba4f2e22a425f15d5faebb5f4ef6 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 14 Aug 2026 14:12:17 +0000
Subject: [PATCH 16/23] refactor: move group switcher files into a
GroupSwitcher folder
ActiveGroupSwitcher.tsx, ScopeMenuBody.tsx, and scopeDisplay.ts are
used exclusively by the group switcher, so group them together
instead of leaving them loose alongside unrelated AppHeader files.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_012hH41h18hKSvYxWAw17nhd
---
.../AppHeader/{ => GroupSwitcher}/ActiveGroupSwitcher.tsx | 0
.../layout/AppHeader/{ => GroupSwitcher}/ScopeMenuBody.tsx | 0
.../layout/AppHeader/{ => GroupSwitcher}/scopeDisplay.ts | 0
src/components/layout/AppHeader/GroupsIndicator.tsx | 2 +-
4 files changed, 1 insertion(+), 1 deletion(-)
rename src/components/layout/AppHeader/{ => GroupSwitcher}/ActiveGroupSwitcher.tsx (100%)
rename src/components/layout/AppHeader/{ => GroupSwitcher}/ScopeMenuBody.tsx (100%)
rename src/components/layout/AppHeader/{ => GroupSwitcher}/scopeDisplay.ts (100%)
diff --git a/src/components/layout/AppHeader/ActiveGroupSwitcher.tsx b/src/components/layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx
similarity index 100%
rename from src/components/layout/AppHeader/ActiveGroupSwitcher.tsx
rename to src/components/layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx
diff --git a/src/components/layout/AppHeader/ScopeMenuBody.tsx b/src/components/layout/AppHeader/GroupSwitcher/ScopeMenuBody.tsx
similarity index 100%
rename from src/components/layout/AppHeader/ScopeMenuBody.tsx
rename to src/components/layout/AppHeader/GroupSwitcher/ScopeMenuBody.tsx
diff --git a/src/components/layout/AppHeader/scopeDisplay.ts b/src/components/layout/AppHeader/GroupSwitcher/scopeDisplay.ts
similarity index 100%
rename from src/components/layout/AppHeader/scopeDisplay.ts
rename to src/components/layout/AppHeader/GroupSwitcher/scopeDisplay.ts
diff --git a/src/components/layout/AppHeader/GroupsIndicator.tsx b/src/components/layout/AppHeader/GroupsIndicator.tsx
index 29707dec..5604cf58 100644
--- a/src/components/layout/AppHeader/GroupsIndicator.tsx
+++ b/src/components/layout/AppHeader/GroupsIndicator.tsx
@@ -5,7 +5,7 @@ import { useAuth } from "@/contexts/AuthContext";
import { useActiveScope } from "@/contexts/ActiveScopeContext";
import { cn } from "@/lib/utils";
import { TooltipButton } from "./TooltipButton";
-import { ActiveGroupSwitcher } from "./ActiveGroupSwitcher";
+import { ActiveGroupSwitcher } from "./GroupSwitcher/ActiveGroupSwitcher";
const groupsButtonClassName =
"bg-transparent border-purple-400/50 text-purple-300 hover:bg-purple-600 hover:text-white hover:border-purple-600 transition-colors";
From 4be492cc07cb29510c521fdeb2a67c0cb7dd23d9 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 14 Aug 2026 15:54:59 +0000
Subject: [PATCH 17/23] refactor: collapse profile mutation to a single
instance
column/value/errorMessage now travel as mutate() arguments instead of
useProfileFieldMutation generics, so ActiveScopeContext only needs one
mutation instance for both active_group_id and active_scope instead
of two.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_012hH41h18hKSvYxWAw17nhd
---
src/api/groups/useProfileFieldMutation.ts | 21 +++++++++------------
src/contexts/ActiveScopeContext.tsx | 23 +++++++++++++----------
2 files changed, 22 insertions(+), 22 deletions(-)
diff --git a/src/api/groups/useProfileFieldMutation.ts b/src/api/groups/useProfileFieldMutation.ts
index 5551a1fc..f66c9ebf 100644
--- a/src/api/groups/useProfileFieldMutation.ts
+++ b/src/api/groups/useProfileFieldMutation.ts
@@ -5,29 +5,26 @@ import { profileKeys } from "@/api/auth/types";
import type { Database } from "@/integrations/supabase/types";
type ProfileUpdate = Database["public"]["Tables"]["profiles"]["Update"];
+type ScopeColumn = "active_group_id" | "active_scope";
-export function useProfileFieldMutation({
- column,
- errorMessage,
-}: {
- column: K;
- errorMessage: string;
-}) {
+export function useProfileFieldMutation() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async (variables: {
userId: string;
- value: ProfileUpdate[K];
+ column: ScopeColumn;
+ value: ProfileUpdate[ScopeColumn];
+ errorMessage: string;
}) => {
const { error } = await supabase
.from("profiles")
- .update({ [column]: variables.value } as ProfileUpdate)
+ .update({ [variables.column]: variables.value } as ProfileUpdate)
.eq("id", variables.userId);
if (error) {
- throw new Error(errorMessage);
+ throw new Error(variables.errorMessage);
}
},
onSuccess: (_data, variables) => {
@@ -35,10 +32,10 @@ export function useProfileFieldMutation({
queryKey: profileKeys.detail(variables.userId),
});
},
- onError: (error) => {
+ onError: (error, variables) => {
toast({
title: "Error",
- description: error?.message || errorMessage,
+ description: error?.message || variables.errorMessage,
variant: "destructive",
});
},
diff --git a/src/contexts/ActiveScopeContext.tsx b/src/contexts/ActiveScopeContext.tsx
index b0690f39..97541bca 100644
--- a/src/contexts/ActiveScopeContext.tsx
+++ b/src/contexts/ActiveScopeContext.tsx
@@ -93,26 +93,29 @@ function AuthedActiveScopeProvider({
const current = override ?? pinned;
- const setActiveGroupMutation = useProfileFieldMutation({
- column: "active_group_id",
- errorMessage: "Failed to update active group",
- });
- const setActiveScopeMutation = useProfileFieldMutation({
- column: "active_scope",
- errorMessage: "Failed to update active scope",
- });
+ const profileMutation = useProfileFieldMutation();
function selectScope(scope: PinnedScope) {
setOverride(scopeEquals(scope, pinned) ? null : scope);
}
function setActiveGroup(groupId: string) {
- setActiveGroupMutation.mutate({ userId, value: groupId });
+ profileMutation.mutate({
+ userId,
+ column: "active_group_id",
+ value: groupId,
+ errorMessage: "Failed to update active group",
+ });
setOverride(null);
}
function setActiveScope(scope: "group" | "everyone" | "me") {
- setActiveScopeMutation.mutate({ userId, value: scope });
+ profileMutation.mutate({
+ userId,
+ column: "active_scope",
+ value: scope,
+ errorMessage: "Failed to update active scope",
+ });
setOverride(null);
}
From 760db888d0a70ea46e1fabe9b901d5b3fedbe6b3 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 14 Aug 2026 15:55:41 +0000
Subject: [PATCH 18/23] refactor: move ScopeMenuBody helper fns below the
return
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_012hH41h18hKSvYxWAw17nhd
---
.../AppHeader/GroupSwitcher/ScopeMenuBody.tsx | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/src/components/layout/AppHeader/GroupSwitcher/ScopeMenuBody.tsx b/src/components/layout/AppHeader/GroupSwitcher/ScopeMenuBody.tsx
index 5a2a2975..5fcf5e3c 100644
--- a/src/components/layout/AppHeader/GroupSwitcher/ScopeMenuBody.tsx
+++ b/src/components/layout/AppHeader/GroupSwitcher/ScopeMenuBody.tsx
@@ -25,13 +25,6 @@ export function ScopeMenuBody({
(kind) => pinned.kind !== kind,
);
- function isPinned(scope: PinnedScope) {
- return scopeKey(scope) === scopeKey(pinned);
- }
- function isActive(scope: PinnedScope) {
- return scopeKey(scope) === scopeKey(current);
- }
-
return (
<>
);
+
+ function isPinned(scope: PinnedScope) {
+ return scopeKey(scope) === scopeKey(pinned);
+ }
+ function isActive(scope: PinnedScope) {
+ return scopeKey(scope) === scopeKey(current);
+ }
}
function ScopeMenuRow({
From b1aed689e98c6605cd8ecd24a96b1dace093cd8b Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 14 Aug 2026 15:56:16 +0000
Subject: [PATCH 19/23] refactor: make SignInRequired's description a required
prop
SignInRequired has two callers (Groups, Settings) with different
copy; a groups-specific default was wrong for the other one.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_012hH41h18hKSvYxWAw17nhd
---
src/pages/groups/Groups/SignInRequired.tsx | 6 +-----
src/routes/groups/index.tsx | 2 +-
2 files changed, 2 insertions(+), 6 deletions(-)
diff --git a/src/pages/groups/Groups/SignInRequired.tsx b/src/pages/groups/Groups/SignInRequired.tsx
index 17b432cf..2479ce20 100644
--- a/src/pages/groups/Groups/SignInRequired.tsx
+++ b/src/pages/groups/Groups/SignInRequired.tsx
@@ -8,11 +8,7 @@ import {
import { Button } from "@/components/ui/button";
import { Link } from "@tanstack/react-router";
-export function SignInRequired({
- description = "Please sign in to manage groups",
-}: {
- description?: string;
-}) {
+export function SignInRequired({ description }: { description: string }) {
return (
diff --git a/src/routes/groups/index.tsx b/src/routes/groups/index.tsx
index e1dcdbe1..1bbe0886 100644
--- a/src/routes/groups/index.tsx
+++ b/src/routes/groups/index.tsx
@@ -28,7 +28,7 @@ function Groups() {
const { user } = useRouteContext({ from: "/groups/" });
if (!user) {
- return ;
+ return ;
}
return ;
From aad81db3ee13b1b26a57f89b120d4aaa9d14f3ab Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 14 Aug 2026 15:57:09 +0000
Subject: [PATCH 20/23] refactor: turn settingsToggleItemClassName into a
component
ActiveGroupSetting/ActiveScopeSetting used the shared className
identically on a ToggleGroupItem; encapsulate that as a
SettingsToggleItem component instead of exporting a bare string.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_012hH41h18hKSvYxWAw17nhd
---
src/pages/Settings/ActiveGroupSetting.tsx | 11 +++++-----
src/pages/Settings/ActiveScopeSetting.tsx | 11 +++++-----
src/pages/Settings/SettingsToggleItem.tsx | 22 +++++++++++++++++++
.../Settings/settingsToggleItemClassName.ts | 2 --
4 files changed, 32 insertions(+), 14 deletions(-)
create mode 100644 src/pages/Settings/SettingsToggleItem.tsx
delete mode 100644 src/pages/Settings/settingsToggleItemClassName.ts
diff --git a/src/pages/Settings/ActiveGroupSetting.tsx b/src/pages/Settings/ActiveGroupSetting.tsx
index e03ae234..70f39a14 100644
--- a/src/pages/Settings/ActiveGroupSetting.tsx
+++ b/src/pages/Settings/ActiveGroupSetting.tsx
@@ -1,7 +1,7 @@
import { Users } from "lucide-react";
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+import { ToggleGroup } from "@/components/ui/toggle-group";
import { useActiveScope } from "@/contexts/ActiveScopeContext";
-import { settingsToggleItemClassName } from "@/pages/Settings/settingsToggleItemClassName";
+import { SettingsToggleItem } from "@/pages/Settings/SettingsToggleItem";
export function ActiveGroupSetting() {
const { groups, activeGroupId, setActiveGroup } = useActiveScope();
@@ -24,15 +24,14 @@ export function ActiveGroupSetting() {
className="flex-wrap justify-start gap-2"
>
{groups.map((group) => (
-
{group.name}
-
+
))}
diff --git a/src/pages/Settings/ActiveScopeSetting.tsx b/src/pages/Settings/ActiveScopeSetting.tsx
index 63d73312..cce7a156 100644
--- a/src/pages/Settings/ActiveScopeSetting.tsx
+++ b/src/pages/Settings/ActiveScopeSetting.tsx
@@ -1,7 +1,7 @@
import { Globe, Star, User as UserIcon, Users } from "lucide-react";
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+import { ToggleGroup } from "@/components/ui/toggle-group";
import { useActiveScope } from "@/contexts/ActiveScopeContext";
-import { settingsToggleItemClassName } from "@/pages/Settings/settingsToggleItemClassName";
+import { SettingsToggleItem } from "@/pages/Settings/SettingsToggleItem";
const SCOPE_OPTIONS = [
{ kind: "group" as const, label: "Group", icon: Users },
@@ -34,18 +34,17 @@ export function ActiveScopeSetting() {
className="flex-wrap justify-start gap-2"
>
{options.map(({ kind, label, icon: Icon }) => (
-
{label}
{pinned.kind === kind && (
)}
-
+
))}
diff --git a/src/pages/Settings/SettingsToggleItem.tsx b/src/pages/Settings/SettingsToggleItem.tsx
new file mode 100644
index 00000000..0910c60a
--- /dev/null
+++ b/src/pages/Settings/SettingsToggleItem.tsx
@@ -0,0 +1,22 @@
+import { ToggleGroupItem } from "@/components/ui/toggle-group";
+import type { ReactNode } from "react";
+
+export function SettingsToggleItem({
+ value,
+ ariaLabel,
+ children,
+}: {
+ value: string;
+ ariaLabel: string;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/pages/Settings/settingsToggleItemClassName.ts b/src/pages/Settings/settingsToggleItemClassName.ts
deleted file mode 100644
index 49f57188..00000000
--- a/src/pages/Settings/settingsToggleItemClassName.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const settingsToggleItemClassName =
- "gap-1.5 rounded-md border border-purple-400/30 px-3 py-1.5 text-sm text-purple-100 data-[state=on]:border-purple-400 data-[state=on]:bg-purple-600/20 data-[state=on]:font-medium data-[state=on]:text-purple-100 hover:bg-purple-600/10";
From 5193d1b4fbbbd7e61f5c8b36aa3bff7bf07d21f2 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 14 Aug 2026 15:57:59 +0000
Subject: [PATCH 21/23] refactor: squash the active_group_selected migration
into active_scope
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
20260814140000 added active_group_selected only for the very next
migration to drop it — both land in the same unmerged PR, so fold the
net-zero column into the active_scope migration directly instead of
shipping a column that's never actually used.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_012hH41h18hKSvYxWAw17nhd
---
...0000_add_active_group_selected_to_profiles.sql | 5 -----
...0260814150000_add_active_scope_to_profiles.sql | 15 +++++----------
2 files changed, 5 insertions(+), 15 deletions(-)
delete mode 100644 supabase/migrations/20260814140000_add_active_group_selected_to_profiles.sql
diff --git a/supabase/migrations/20260814140000_add_active_group_selected_to_profiles.sql b/supabase/migrations/20260814140000_add_active_group_selected_to_profiles.sql
deleted file mode 100644
index bc37cd5f..00000000
--- a/supabase/migrations/20260814140000_add_active_group_selected_to_profiles.sql
+++ /dev/null
@@ -1,5 +0,0 @@
--- Distinguishes "never touched the switcher" from "explicitly selected
--- Everyone" — both persist active_group_id as NULL, but only the latter
--- should stop the single-group auto-activation from overriding it.
-ALTER TABLE public.profiles
-ADD COLUMN active_group_selected BOOLEAN NOT NULL DEFAULT false;
diff --git a/supabase/migrations/20260814150000_add_active_scope_to_profiles.sql b/supabase/migrations/20260814150000_add_active_scope_to_profiles.sql
index b2edcfe1..39542c5d 100644
--- a/supabase/migrations/20260814150000_add_active_scope_to_profiles.sql
+++ b/supabase/migrations/20260814150000_add_active_scope_to_profiles.sql
@@ -1,12 +1,10 @@
--- Replace the active_group_selected flag with a proper Active scope model.
--- See docs/adr/0005-active-group-model.md.
+-- Add the Active scope model. See docs/adr/0005-active-group-model.md.
--
-- The original active_group_id-only model overloaded NULL to mean both
--- "never chosen" and "explicitly Everyone", which active_group_selected
--- patched around. Root cause instead: "which group is mine" (active_group_id)
--- and "which lens am I pinned to by default" (active_scope) are two
--- independent settings. active_group_id keeps its original meaning; NULL now
--- unambiguously means "no group chosen".
+-- "never chosen" and "explicitly Everyone". Root cause: "which group is
+-- mine" (active_group_id) and "which lens am I pinned to by default"
+-- (active_scope) are two independent settings. active_group_id keeps its
+-- original meaning; NULL now unambiguously means "no group chosen".
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'active_scope') THEN
@@ -19,6 +17,3 @@ ALTER TABLE public.profiles
COMMENT ON COLUMN public.profiles.active_scope IS
'Durable, Settings-level pin for the default scope: which of your groups (via active_group_id), Everyone, or Me. NULL means never explicitly chosen — auto-derives to the sole group when the user has exactly one, else Everyone.';
-
-ALTER TABLE public.profiles
- DROP COLUMN IF EXISTS active_group_selected;
From c777b4aaabc5d56e7f2993fc5ebebb2da97caea7 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 17 Aug 2026 07:04:32 +0000
Subject: [PATCH 22/23] refactor: shrink ActiveScopeContext to pure scope
resolution
Two follow-ups from PR review:
- ActiveScopeContext no longer exposes the raw groups list, isLoading,
or the setActiveGroup/setActiveScope mutators. It still loads groups
internally (needed to resolve activeGroupId/pinned), but stays a
plain useQuery so it never suspends the whole app tree from its
root-level mount point.
- GroupsIndicator and Settings now own their own groups fetch via
useSuspenseQuery + a local Suspense boundary, passing groups down to
ActiveGroupSwitcher/ActiveGroupSetting as needed. AuthedFilteredSetsPanel
does the same for the one group name it displays.
- setActiveGroup/setActiveScope move to where they're actually called
(ActiveGroupSetting/ActiveScopeSetting), calling useProfileFieldMutation
directly. The context exposes a new clearOverride() so Settings can
still drop the transient header override after a durable pin change.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_012hH41h18hKSvYxWAw17nhd
---
.../GroupSwitcher/ActiveGroupSwitcher.tsx | 5 ++-
.../layout/AppHeader/GroupsIndicator.tsx | 42 ++++++++++++------
src/contexts/ActiveScopeContext.tsx | 43 +++----------------
.../tabs/VoteTab/AuthedFilteredSetsPanel.tsx | 5 ++-
src/pages/Settings/ActiveGroupSetting.tsx | 23 +++++++++-
src/pages/Settings/ActiveScopeSetting.tsx | 22 +++++++++-
src/pages/Settings/SettingsPage.tsx | 26 ++++++++---
7 files changed, 104 insertions(+), 62 deletions(-)
diff --git a/src/components/layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx b/src/components/layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx
index 931e9ce1..81b5e927 100644
--- a/src/components/layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx
+++ b/src/components/layout/AppHeader/GroupSwitcher/ActiveGroupSwitcher.tsx
@@ -11,17 +11,20 @@ import {
import { useActiveScope } from "@/contexts/ActiveScopeContext";
import { scopeIcon, scopeLabel } from "./scopeDisplay";
import { ScopeMenuBody } from "./ScopeMenuBody";
+import type { Group } from "@/api/groups/types";
interface ActiveGroupSwitcherProps {
+ groups: Group[];
isMobile: boolean;
className: string;
}
export function ActiveGroupSwitcher({
+ groups,
isMobile,
className,
}: ActiveGroupSwitcherProps) {
- const { groups, pinned, current, selectScope } = useActiveScope();
+ const { pinned, current, selectScope } = useActiveScope();
const CurrentIcon = scopeIcon(current);
const currentLabel = scopeLabel(current, groups);
diff --git a/src/components/layout/AppHeader/GroupsIndicator.tsx b/src/components/layout/AppHeader/GroupsIndicator.tsx
index 5604cf58..42348e6b 100644
--- a/src/components/layout/AppHeader/GroupsIndicator.tsx
+++ b/src/components/layout/AppHeader/GroupsIndicator.tsx
@@ -1,8 +1,10 @@
+import { Suspense } from "react";
import { Link } from "@tanstack/react-router";
import { UserPlus } from "lucide-react";
+import { useSuspenseQuery } from "@tanstack/react-query";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/contexts/AuthContext";
-import { useActiveScope } from "@/contexts/ActiveScopeContext";
+import { userGroupsQuery } from "@/api/groups/useUserGroups";
import { cn } from "@/lib/utils";
import { TooltipButton } from "./TooltipButton";
import { ActiveGroupSwitcher } from "./GroupSwitcher/ActiveGroupSwitcher";
@@ -12,24 +14,37 @@ const groupsButtonClassName =
export function GroupsIndicator({ isMobile }: { isMobile: boolean }) {
const { user } = useAuth();
- const { isLoading, hasGroups } = useActiveScope();
if (!user) {
return null;
}
- if (isLoading) {
- return (
-
- );
- }
+ return (
+
+ }
+ >
+
+
+ );
+}
+
+function GroupsIndicatorContent({
+ isMobile,
+ userId,
+}: {
+ isMobile: boolean;
+ userId: string;
+}) {
+ const { data: groups } = useSuspenseQuery(userGroupsQuery(userId));
- if (!hasGroups) {
+ if (groups.length === 0) {
return (
diff --git a/src/contexts/ActiveScopeContext.tsx b/src/contexts/ActiveScopeContext.tsx
index 97541bca..418535af 100644
--- a/src/contexts/ActiveScopeContext.tsx
+++ b/src/contexts/ActiveScopeContext.tsx
@@ -3,36 +3,27 @@ import type { ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/contexts/AuthContext";
import { userGroupsQuery } from "@/api/groups/useUserGroups";
-import { useProfileFieldMutation } from "@/api/groups/useProfileFieldMutation";
import { resolveActiveGroupId, resolvePinnedScope } from "@/lib/activeGroup";
import type { PinnedScope } from "@/lib/activeGroup";
-import type { Group } from "@/api/groups/types";
interface ActiveScopeContextValue {
- isLoading: boolean;
- groups: Group[];
- hasGroups: boolean;
/** Which group is "yours" — independent of active scope (group/everyone/me). */
activeGroupId: string | undefined;
pinned: PinnedScope;
current: PinnedScope;
selectScope: (scope: PinnedScope) => void;
- setActiveGroup: (groupId: string) => void;
- setActiveScope: (scope: "group" | "everyone" | "me") => void;
+ /** Drops the transient header override so the durable Settings pin applies immediately. */
+ clearOverride: () => void;
}
const EVERYONE_SCOPE: PinnedScope = { kind: "everyone" };
const ANONYMOUS_VALUE: ActiveScopeContextValue = {
- isLoading: false,
- groups: [],
- hasGroups: false,
activeGroupId: undefined,
pinned: EVERYONE_SCOPE,
current: EVERYONE_SCOPE,
selectScope: () => {},
- setActiveGroup: () => {},
- setActiveScope: () => {},
+ clearOverride: () => {},
};
const ActiveScopeContext = createContext(
@@ -75,7 +66,7 @@ function AuthedActiveScopeProvider({
children: ReactNode;
}) {
const { profile } = useAuth();
- const { data: groups = [], isLoading } = useQuery(userGroupsQuery(userId));
+ const { data: groups = [] } = useQuery(userGroupsQuery(userId));
const [override, setOverride] = useState(null);
const groupIds = useMemo(() => groups.map((group) => group.id), [groups]);
@@ -93,42 +84,20 @@ function AuthedActiveScopeProvider({
const current = override ?? pinned;
- const profileMutation = useProfileFieldMutation();
-
function selectScope(scope: PinnedScope) {
setOverride(scopeEquals(scope, pinned) ? null : scope);
}
- function setActiveGroup(groupId: string) {
- profileMutation.mutate({
- userId,
- column: "active_group_id",
- value: groupId,
- errorMessage: "Failed to update active group",
- });
- setOverride(null);
- }
-
- function setActiveScope(scope: "group" | "everyone" | "me") {
- profileMutation.mutate({
- userId,
- column: "active_scope",
- value: scope,
- errorMessage: "Failed to update active scope",
- });
+ function clearOverride() {
setOverride(null);
}
const value: ActiveScopeContextValue = {
- isLoading,
- groups,
- hasGroups: groups.length > 0,
activeGroupId,
pinned,
current,
selectScope,
- setActiveGroup,
- setActiveScope,
+ clearOverride,
};
return (
diff --git a/src/pages/EditionView/tabs/VoteTab/AuthedFilteredSetsPanel.tsx b/src/pages/EditionView/tabs/VoteTab/AuthedFilteredSetsPanel.tsx
index d7172569..0859697b 100644
--- a/src/pages/EditionView/tabs/VoteTab/AuthedFilteredSetsPanel.tsx
+++ b/src/pages/EditionView/tabs/VoteTab/AuthedFilteredSetsPanel.tsx
@@ -1,15 +1,18 @@
import { useState } from "react";
+import { useSuspenseQuery } from "@tanstack/react-query";
import { FilterSortControls } from "@/pages/EditionView/tabs/VoteTab/filters/FilterSortControls";
import { GroupScopedSetsPanel } from "@/pages/EditionView/tabs/VoteTab/GroupScopedSetsPanel";
import { EveryoneSetsPanel } from "@/pages/EditionView/tabs/VoteTab/SetsPanelContent";
import { useActiveScope } from "@/contexts/ActiveScopeContext";
+import { userGroupsQuery } from "@/api/groups/useUserGroups";
import type { FilteredSetsPanelProps } from "@/pages/EditionView/tabs/VoteTab/FilteredSetsPanel";
import type { BinaryVoteScope, VoteScope } from "@/lib/voteScope";
export function AuthedFilteredSetsPanel(
props: FilteredSetsPanelProps & { userId: string },
) {
- const { current, groups, activeGroupId } = useActiveScope();
+ const { current, activeGroupId } = useActiveScope();
+ const { data: groups } = useSuspenseQuery(userGroupsQuery(props.userId));
const activeGroupName = activeGroupId
? groups.find((group) => group.id === activeGroupId)?.name
: undefined;
diff --git a/src/pages/Settings/ActiveGroupSetting.tsx b/src/pages/Settings/ActiveGroupSetting.tsx
index 70f39a14..db583003 100644
--- a/src/pages/Settings/ActiveGroupSetting.tsx
+++ b/src/pages/Settings/ActiveGroupSetting.tsx
@@ -1,10 +1,29 @@
import { Users } from "lucide-react";
import { ToggleGroup } from "@/components/ui/toggle-group";
import { useActiveScope } from "@/contexts/ActiveScopeContext";
+import { useProfileFieldMutation } from "@/api/groups/useProfileFieldMutation";
import { SettingsToggleItem } from "@/pages/Settings/SettingsToggleItem";
+import type { Group } from "@/api/groups/types";
-export function ActiveGroupSetting() {
- const { groups, activeGroupId, setActiveGroup } = useActiveScope();
+export function ActiveGroupSetting({
+ userId,
+ groups,
+}: {
+ userId: string;
+ groups: Group[];
+}) {
+ const { activeGroupId, clearOverride } = useActiveScope();
+ const mutation = useProfileFieldMutation();
+
+ function setActiveGroup(groupId: string) {
+ mutation.mutate({
+ userId,
+ column: "active_group_id",
+ value: groupId,
+ errorMessage: "Failed to update active group",
+ });
+ clearOverride();
+ }
return (
diff --git a/src/pages/Settings/ActiveScopeSetting.tsx b/src/pages/Settings/ActiveScopeSetting.tsx
index cce7a156..abacb578 100644
--- a/src/pages/Settings/ActiveScopeSetting.tsx
+++ b/src/pages/Settings/ActiveScopeSetting.tsx
@@ -1,6 +1,7 @@
import { Globe, Star, User as UserIcon, Users } from "lucide-react";
import { ToggleGroup } from "@/components/ui/toggle-group";
import { useActiveScope } from "@/contexts/ActiveScopeContext";
+import { useProfileFieldMutation } from "@/api/groups/useProfileFieldMutation";
import { SettingsToggleItem } from "@/pages/Settings/SettingsToggleItem";
const SCOPE_OPTIONS = [
@@ -9,13 +10,30 @@ const SCOPE_OPTIONS = [
{ kind: "me" as const, label: "Me", icon: UserIcon },
];
-export function ActiveScopeSetting() {
- const { hasGroups, pinned, setActiveScope } = useActiveScope();
+export function ActiveScopeSetting({
+ userId,
+ hasGroups,
+}: {
+ userId: string;
+ hasGroups: boolean;
+}) {
+ const { pinned, clearOverride } = useActiveScope();
+ const mutation = useProfileFieldMutation();
const options = hasGroups
? SCOPE_OPTIONS
: SCOPE_OPTIONS.filter((option) => option.kind !== "group");
+ function setActiveScope(scope: "group" | "everyone" | "me") {
+ mutation.mutate({
+ userId,
+ column: "active_scope",
+ value: scope,
+ errorMessage: "Failed to update active scope",
+ });
+ clearOverride();
+ }
+
return (
Active scope
diff --git a/src/pages/Settings/SettingsPage.tsx b/src/pages/Settings/SettingsPage.tsx
index 9af4f729..ad946e2b 100644
--- a/src/pages/Settings/SettingsPage.tsx
+++ b/src/pages/Settings/SettingsPage.tsx
@@ -1,7 +1,9 @@
+import { Suspense } from "react";
import { Link } from "@tanstack/react-router";
+import { useSuspenseQuery } from "@tanstack/react-query";
import { TopBar } from "@/components/layout/TopBar";
import { useAuth } from "@/contexts/AuthContext";
-import { useActiveScope } from "@/contexts/ActiveScopeContext";
+import { userGroupsQuery } from "@/api/groups/useUserGroups";
import { SignInRequired } from "@/pages/groups/Groups/SignInRequired";
import { ActiveGroupSetting } from "./ActiveGroupSetting";
import { ActiveScopeSetting } from "./ActiveScopeSetting";
@@ -20,14 +22,17 @@ export function SettingsPage() {
Settings
-
+ }>
+
+
);
}
-function SettingsContent() {
- const { hasGroups } = useActiveScope();
+function SettingsContent({ userId }: { userId: string }) {
+ const { data: groups } = useSuspenseQuery(userGroupsQuery(userId));
+ const hasGroups = groups.length > 0;
return (
@@ -39,8 +44,17 @@ function SettingsContent() {
to set an Active group.
)}
- {hasGroups &&
}
-
+ {hasGroups &&
}
+
+
+ );
+}
+
+function SettingsContentSkeleton() {
+ return (
+
);
}
From e987cffc8dcb7e028519850fa4c25ce8eecacbe3 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 17 Aug 2026 14:14:19 +0000
Subject: [PATCH 23/23] refactor: move setActiveGroup/setActiveScope below
their return
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_012hH41h18hKSvYxWAw17nhd
---
src/pages/Settings/ActiveGroupSetting.tsx | 20 ++++++++++----------
src/pages/Settings/ActiveScopeSetting.tsx | 20 ++++++++++----------
2 files changed, 20 insertions(+), 20 deletions(-)
diff --git a/src/pages/Settings/ActiveGroupSetting.tsx b/src/pages/Settings/ActiveGroupSetting.tsx
index db583003..30030994 100644
--- a/src/pages/Settings/ActiveGroupSetting.tsx
+++ b/src/pages/Settings/ActiveGroupSetting.tsx
@@ -15,16 +15,6 @@ export function ActiveGroupSetting({
const { activeGroupId, clearOverride } = useActiveScope();
const mutation = useProfileFieldMutation();
- function setActiveGroup(groupId: string) {
- mutation.mutate({
- userId,
- column: "active_group_id",
- value: groupId,
- errorMessage: "Failed to update active group",
- });
- clearOverride();
- }
-
return (
Active group
@@ -55,4 +45,14 @@ export function ActiveGroupSetting({
);
+
+ function setActiveGroup(groupId: string) {
+ mutation.mutate({
+ userId,
+ column: "active_group_id",
+ value: groupId,
+ errorMessage: "Failed to update active group",
+ });
+ clearOverride();
+ }
}
diff --git a/src/pages/Settings/ActiveScopeSetting.tsx b/src/pages/Settings/ActiveScopeSetting.tsx
index abacb578..dc8fb549 100644
--- a/src/pages/Settings/ActiveScopeSetting.tsx
+++ b/src/pages/Settings/ActiveScopeSetting.tsx
@@ -24,16 +24,6 @@ export function ActiveScopeSetting({
? SCOPE_OPTIONS
: SCOPE_OPTIONS.filter((option) => option.kind !== "group");
- function setActiveScope(scope: "group" | "everyone" | "me") {
- mutation.mutate({
- userId,
- column: "active_scope",
- value: scope,
- errorMessage: "Failed to update active scope",
- });
- clearOverride();
- }
-
return (
Active scope
@@ -67,4 +57,14 @@ export function ActiveScopeSetting({
);
+
+ function setActiveScope(scope: "group" | "everyone" | "me") {
+ mutation.mutate({
+ userId,
+ column: "active_scope",
+ value: scope,
+ errorMessage: "Failed to update active scope",
+ });
+ clearOverride();
+ }
}