From 0fb27c8b831d5f4e0b854491443f8031177506c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 15:46:15 +0000 Subject: [PATCH 1/3] Add active group foundation: persisted profile field + auto-activation Adds a nullable active_group_id column on profiles (FK to groups, cleared on group deletion) and a resolveActiveGroupId helper that treats a user's sole group as active when no explicit choice has been persisted yet. A useActiveGroup hook exposes this derived state for future tickets, and the header's Groups button now surfaces the active group name or a create/join CTA when the user has no groups. Closes #123 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CKMx3D6cDTnUEYacpy9Mn6 --- src/api/auth/useUpdateProfile.ts | 6 +- .../layout/AppHeader/Navigation.tsx | 41 ++++++++---- src/hooks/useActiveGroup.ts | 31 +++++++++ src/integrations/supabase/types.ts | 13 +++- src/lib/activeGroup.test.ts | 64 +++++++++++++++++++ src/lib/activeGroup.ts | 19 ++++++ ...154342_add_active_group_id_to_profiles.sql | 7 ++ 7 files changed, 167 insertions(+), 14 deletions(-) create mode 100644 src/hooks/useActiveGroup.ts create mode 100644 src/lib/activeGroup.test.ts create mode 100644 src/lib/activeGroup.ts create mode 100644 supabase/migrations/20260710154342_add_active_group_id_to_profiles.sql diff --git a/src/api/auth/useUpdateProfile.ts b/src/api/auth/useUpdateProfile.ts index ea7b8f57..5bec97e5 100644 --- a/src/api/auth/useUpdateProfile.ts +++ b/src/api/auth/useUpdateProfile.ts @@ -6,7 +6,11 @@ import { profileKeys } from "./types"; // Mutation function async function updateProfile(variables: { userId: string; - updates: { username?: string | null; completed_onboarding?: boolean | null }; + updates: { + username?: string | null; + completed_onboarding?: boolean | null; + active_group_id?: string | null; + }; }) { const { userId, updates } = variables; diff --git a/src/components/layout/AppHeader/Navigation.tsx b/src/components/layout/AppHeader/Navigation.tsx index 26572e76..8a743716 100644 --- a/src/components/layout/AppHeader/Navigation.tsx +++ b/src/components/layout/AppHeader/Navigation.tsx @@ -1,5 +1,5 @@ import { Link, useRouter } from "@tanstack/react-router"; -import { ArrowLeft, Users } from "lucide-react"; +import { ArrowLeft, UserPlus, Users } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Tooltip, @@ -7,6 +7,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useAuth } from "@/contexts/AuthContext"; +import { useActiveGroup } from "@/hooks/useActiveGroup"; interface NavigationProps { showBackButton?: boolean; @@ -50,6 +51,7 @@ export function Navigation({ }: NavigationProps) { const { user } = useAuth(); const router = useRouter(); + const { activeGroup, hasGroups } = useActiveGroup(); return (
@@ -68,19 +70,34 @@ export function Navigation({ )} - {/* Groups Button */} + {/* Groups / Active Group Indicator */} {showGroupsButton && user && ( - - - {!isMobile && Groups} - + {hasGroups ? ( + + + {!isMobile && ( + {activeGroup?.name || "Groups"} + )} + + ) : ( + + + {!isMobile && Create/Join a Group} + + )} )}
diff --git a/src/hooks/useActiveGroup.ts b/src/hooks/useActiveGroup.ts new file mode 100644 index 00000000..b128ebcf --- /dev/null +++ b/src/hooks/useActiveGroup.ts @@ -0,0 +1,31 @@ +import { useAuth } from "@/contexts/AuthContext"; +import { useUserGroupsQuery } 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; + isLoading: boolean; +} + +export function useActiveGroup(): ActiveGroupState { + const { user, profile } = useAuth(); + const groupsQuery = useUserGroupsQuery(user?.id); + const groups = groupsQuery.data ?? []; + + const activeGroupId = resolveActiveGroupId({ + profileActiveGroupId: profile?.active_group_id, + groupIds: groups.map((group) => group.id), + }); + + return { + activeGroupId, + activeGroup: groups.find((group) => group.id === activeGroupId), + groups, + hasGroups: groups.length > 0, + isLoading: groupsQuery.isLoading, + }; +} diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index ab971e76..953a6dd7 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -519,6 +519,7 @@ export type Database = { }; profiles: { Row: { + active_group_id: string | null; completed_onboarding: boolean | null; created_at: string; email: string | null; @@ -526,6 +527,7 @@ export type Database = { username: string | null; }; Insert: { + active_group_id?: string | null; completed_onboarding?: boolean | null; created_at?: string; email?: string | null; @@ -533,13 +535,22 @@ export type Database = { username?: string | null; }; Update: { + active_group_id?: string | null; completed_onboarding?: boolean | null; created_at?: string; email?: string | null; id?: string; username?: string | null; }; - Relationships: []; + Relationships: [ + { + foreignKeyName: "profiles_active_group_id_fkey"; + columns: ["active_group_id"]; + isOneToOne: false; + referencedRelation: "groups"; + referencedColumns: ["id"]; + }, + ]; }; set_artists: { Row: { diff --git a/src/lib/activeGroup.test.ts b/src/lib/activeGroup.test.ts new file mode 100644 index 00000000..c8b0b676 --- /dev/null +++ b/src/lib/activeGroup.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { resolveActiveGroupId } from "./activeGroup"; + +describe("resolveActiveGroupId", () => { + it("returns undefined when the user has no groups", () => { + expect( + resolveActiveGroupId({ profileActiveGroupId: null, groupIds: [] }), + ).toBeUndefined(); + }); + + it("auto-activates the single group when no active group is set", () => { + expect( + resolveActiveGroupId({ + profileActiveGroupId: null, + groupIds: ["group-1"], + }), + ).toBe("group-1"); + }); + + it("does not auto-activate when the user belongs to multiple groups", () => { + expect( + resolveActiveGroupId({ + profileActiveGroupId: null, + groupIds: ["group-1", "group-2"], + }), + ).toBeUndefined(); + }); + + it("returns the persisted active group when it is still a membership", () => { + expect( + resolveActiveGroupId({ + profileActiveGroupId: "group-2", + groupIds: ["group-1", "group-2"], + }), + ).toBe("group-2"); + }); + + it("ignores a persisted active group the user is no longer a member of", () => { + expect( + resolveActiveGroupId({ + profileActiveGroupId: "group-3", + groupIds: ["group-1", "group-2"], + }), + ).toBeUndefined(); + }); + + it("falls back to auto-activation when the stale active group leaves exactly one membership", () => { + expect( + resolveActiveGroupId({ + profileActiveGroupId: "group-3", + groupIds: ["group-1"], + }), + ).toBe("group-1"); + }); + + it("prefers the persisted active group over auto-activation when only one group remains", () => { + expect( + resolveActiveGroupId({ + profileActiveGroupId: "group-1", + groupIds: ["group-1"], + }), + ).toBe("group-1"); + }); +}); diff --git a/src/lib/activeGroup.ts b/src/lib/activeGroup.ts new file mode 100644 index 00000000..c10198e8 --- /dev/null +++ b/src/lib/activeGroup.ts @@ -0,0 +1,19 @@ +interface ResolveActiveGroupIdParams { + profileActiveGroupId: string | null | undefined; + groupIds: string[]; +} + +export function resolveActiveGroupId({ + profileActiveGroupId, + groupIds, +}: ResolveActiveGroupIdParams): string | undefined { + if (profileActiveGroupId && groupIds.includes(profileActiveGroupId)) { + return profileActiveGroupId; + } + + if (groupIds.length === 1) { + return groupIds[0]; + } + + return undefined; +} diff --git a/supabase/migrations/20260710154342_add_active_group_id_to_profiles.sql b/supabase/migrations/20260710154342_add_active_group_id_to_profiles.sql new file mode 100644 index 00000000..62d0766b --- /dev/null +++ b/supabase/migrations/20260710154342_add_active_group_id_to_profiles.sql @@ -0,0 +1,7 @@ +-- Add active_group_id to profiles: the Group a user is currently viewing the +-- app "as". Global across festivals/editions. Nullable; cleared automatically +-- if the referenced group is deleted. +ALTER TABLE public.profiles +ADD COLUMN active_group_id UUID REFERENCES public.groups(id) ON DELETE SET NULL; + +CREATE INDEX idx_profiles_active_group_id ON public.profiles(active_group_id); From ded415ba71721c10859bf5b8902e3c03afa6c98f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 15:48:59 +0000 Subject: [PATCH 2/3] Address self-review: drop unused profile mutation field, dedupe nav styles Remove the speculative active_group_id case from useUpdateProfile.ts (no caller sets it yet; issue #124 will add explicit switching), and extract the shared Groups button className in Navigation.tsx to avoid duplication between the active-group and zero-group CTA branches. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CKMx3D6cDTnUEYacpy9Mn6 --- src/api/auth/useUpdateProfile.ts | 6 +----- src/components/layout/AppHeader/Navigation.tsx | 7 +++++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/api/auth/useUpdateProfile.ts b/src/api/auth/useUpdateProfile.ts index 5bec97e5..ea7b8f57 100644 --- a/src/api/auth/useUpdateProfile.ts +++ b/src/api/auth/useUpdateProfile.ts @@ -6,11 +6,7 @@ import { profileKeys } from "./types"; // Mutation function async function updateProfile(variables: { userId: string; - updates: { - username?: string | null; - completed_onboarding?: boolean | null; - active_group_id?: string | null; - }; + updates: { username?: string | null; completed_onboarding?: boolean | null }; }) { const { userId, updates } = variables; diff --git a/src/components/layout/AppHeader/Navigation.tsx b/src/components/layout/AppHeader/Navigation.tsx index 8a743716..f1870179 100644 --- a/src/components/layout/AppHeader/Navigation.tsx +++ b/src/components/layout/AppHeader/Navigation.tsx @@ -53,6 +53,9 @@ export function Navigation({ const router = useRouter(); const { activeGroup, hasGroups } = useActiveGroup(); + const groupsButtonClassName = + "border-purple-400/50 text-purple-300 hover:bg-purple-600 hover:text-white hover:border-purple-600 transition-colors"; + return (
{/* Back Navigation */} @@ -77,7 +80,7 @@ export function Navigation({ @@ -90,7 +93,7 @@ export function Navigation({ From c847a003c0507e887181e2fb27b6200e4c68974b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 15:53:59 +0000 Subject: [PATCH 3/3] Suppress zero-groups CTA flash while groups query is loading Addresses Copilot review comments on PR #148: the header briefly showed the "Create/Join a Group" CTA for users who do have groups while useUserGroupsQuery was still resolving. --- src/components/layout/AppHeader/Navigation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/layout/AppHeader/Navigation.tsx b/src/components/layout/AppHeader/Navigation.tsx index f1870179..7df7ddf9 100644 --- a/src/components/layout/AppHeader/Navigation.tsx +++ b/src/components/layout/AppHeader/Navigation.tsx @@ -51,7 +51,7 @@ export function Navigation({ }: NavigationProps) { const { user } = useAuth(); const router = useRouter(); - const { activeGroup, hasGroups } = useActiveGroup(); + const { activeGroup, hasGroups, isLoading: isGroupsLoading } = useActiveGroup(); const groupsButtonClassName = "border-purple-400/50 text-purple-300 hover:bg-purple-600 hover:text-white hover:border-purple-600 transition-colors"; @@ -76,7 +76,7 @@ export function Navigation({ {/* Groups / Active Group Indicator */} {showGroupsButton && user && ( - {hasGroups ? ( + {hasGroups || isGroupsLoading ? (