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 ( + + + + + + 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 ( - - - - - - 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 + + + 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 ( - - - - - - 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 ( - - - - - - 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} + + + Manage groups + - ))} - - + + + {isOverridden && ( + + )} +
+ ); +} + +/** + * 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) => ( + + ))} +
+
+ ); +} 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 ( + + ); + })} +
+
+ ); +} 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 ( +
+ +
+

Settings

+ +
+
+ ); +} + +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}
); } - -/** - * 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 ( -
- - - - - - - - - - - Manage groups - - - - - - {isOverridden && ( + + - )} -
+ + + + + + + + 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 + ))} -
+ ); } 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 ( - - ); - })} -
+ { + 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(); + } }