diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index b7ff53a5..3b417e28 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as CookiesRouteImport } from './routes/cookies' import { Route as AdminRouteImport } from './routes/admin' import { Route as IndexRouteImport } from './routes/index' import { Route as GroupsIndexRouteImport } from './routes/groups/index' +import { Route as PrototypeActiveScopeRouteImport } from './routes/prototype/active-scope' import { Route as GroupsGroupSlugRouteImport } from './routes/groups/$groupSlug' import { Route as FestivalsFestivalSlugRouteImport } from './routes/festivals/$festivalSlug' import { Route as AdminFestivalsRouteImport } from './routes/admin/festivals' @@ -71,6 +72,11 @@ const GroupsIndexRoute = GroupsIndexRouteImport.update({ path: '/groups/', getParentRoute: () => rootRouteImport, } as any) +const PrototypeActiveScopeRoute = PrototypeActiveScopeRouteImport.update({ + id: '/prototype/active-scope', + path: '/prototype/active-scope', + getParentRoute: () => rootRouteImport, +} as any) const GroupsGroupSlugRoute = GroupsGroupSlugRouteImport.update({ id: '/groups/$groupSlug', path: '/groups/$groupSlug', @@ -227,6 +233,7 @@ export interface FileRoutesByFullPath { '/admin/festivals': typeof AdminFestivalsRouteWithChildren '/festivals/$festivalSlug': typeof FestivalsFestivalSlugRouteWithChildren '/groups/$groupSlug': typeof GroupsGroupSlugRoute + '/prototype/active-scope': typeof PrototypeActiveScopeRoute '/groups': typeof GroupsIndexRoute '/admin/artists/duplicates': typeof AdminArtistsDuplicatesRoute '/admin/festivals/$festivalSlug': typeof AdminFestivalsFestivalSlugRouteWithChildren @@ -259,6 +266,7 @@ export interface FileRoutesByTo { '/admin/artists': typeof AdminArtistsRouteWithChildren '/admin/festivals': typeof AdminFestivalsRouteWithChildren '/groups/$groupSlug': typeof GroupsGroupSlugRoute + '/prototype/active-scope': typeof PrototypeActiveScopeRoute '/groups': typeof GroupsIndexRoute '/admin/artists/duplicates': typeof AdminArtistsDuplicatesRoute '/admin/festivals/$festivalSlug': typeof AdminFestivalsFestivalSlugRouteWithChildren @@ -292,6 +300,7 @@ export interface FileRoutesById { '/admin/festivals': typeof AdminFestivalsRouteWithChildren '/festivals/$festivalSlug': typeof FestivalsFestivalSlugRouteWithChildren '/groups/$groupSlug': typeof GroupsGroupSlugRoute + '/prototype/active-scope': typeof PrototypeActiveScopeRoute '/groups/': typeof GroupsIndexRoute '/admin/artists/duplicates': typeof AdminArtistsDuplicatesRoute '/admin/festivals/$festivalSlug': typeof AdminFestivalsFestivalSlugRouteWithChildren @@ -327,6 +336,7 @@ export interface FileRouteTypes { | '/admin/festivals' | '/festivals/$festivalSlug' | '/groups/$groupSlug' + | '/prototype/active-scope' | '/groups' | '/admin/artists/duplicates' | '/admin/festivals/$festivalSlug' @@ -359,6 +369,7 @@ export interface FileRouteTypes { | '/admin/artists' | '/admin/festivals' | '/groups/$groupSlug' + | '/prototype/active-scope' | '/groups' | '/admin/artists/duplicates' | '/admin/festivals/$festivalSlug' @@ -391,6 +402,7 @@ export interface FileRouteTypes { | '/admin/festivals' | '/festivals/$festivalSlug' | '/groups/$groupSlug' + | '/prototype/active-scope' | '/groups/' | '/admin/artists/duplicates' | '/admin/festivals/$festivalSlug' @@ -421,6 +433,7 @@ export interface RootRouteChildren { TermsRoute: typeof TermsRoute FestivalsFestivalSlugRoute: typeof FestivalsFestivalSlugRouteWithChildren GroupsGroupSlugRoute: typeof GroupsGroupSlugRoute + PrototypeActiveScopeRoute: typeof PrototypeActiveScopeRoute GroupsIndexRoute: typeof GroupsIndexRoute } @@ -468,6 +481,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GroupsIndexRouteImport parentRoute: typeof rootRouteImport } + '/prototype/active-scope': { + id: '/prototype/active-scope' + path: '/prototype/active-scope' + fullPath: '/prototype/active-scope' + preLoaderRoute: typeof PrototypeActiveScopeRouteImport + parentRoute: typeof rootRouteImport + } '/groups/$groupSlug': { id: '/groups/$groupSlug' path: '/groups/$groupSlug' @@ -815,6 +835,7 @@ const rootRouteChildren: RootRouteChildren = { TermsRoute: TermsRoute, FestivalsFestivalSlugRoute: FestivalsFestivalSlugRouteWithChildren, GroupsGroupSlugRoute: GroupsGroupSlugRoute, + PrototypeActiveScopeRoute: PrototypeActiveScopeRoute, GroupsIndexRoute: GroupsIndexRoute, } export const routeTree = rootRouteImport diff --git a/src/routes/prototype/NOTES.md b/src/routes/prototype/NOTES.md new file mode 100644 index 00000000..12eb8d73 --- /dev/null +++ b/src/routes/prototype/NOTES.md @@ -0,0 +1,51 @@ +# Prototype: active-scope + +Route: `/prototype/active-scope?variant=A|B|C` + +## Question + +Does the "scope pin lives in Settings, header dropdown is a transient override" +model feel right, before touching `profiles` schema or real app code? + +Background: PR #273 (issue #124) patched a bug where `profiles.active_group_id` +being `NULL` was overloaded to mean both "never chosen" and "explicitly +Everyone." The grill session (see conversation) converged on a bigger reframe: +Active Group and the future Vote Scope toggle (#125) are the same underlying +concept — a flat "scope" with entries `{your groups... | Everyone | Me}` — and +the fix should be root-cause, not another flag. Votes have no `group_id` +(confirmed in `supabase/migrations/20250620065433_create_artists_table.sql`), +so groups are purely a viewing/aggregation lens, never an identity a vote is +recorded against. + +Product constraint from the user: the app should still centralize around "your +crew" as the default/sticky experience (that's the whole point of this epic), +so friction should sit on the _pin_ action (in Settings), not on casual +switching in the header. + +## Variants + +- **A — Star-marked dropdown**: one flat dropdown, pinned entry gets a star, + a "back to X" pill appears next to the trigger when overridden. +- **B — Two-row split**: "Your default" (always-visible, one click home) is + separated from a "Browse" dropdown for everything else. +- **C — Segmented + drawer**: a two-way segmented control (pinned vs. "More"), + with other scopes revealed as chips only on demand — makes the pinned crew + the single most prominent affordance, at the cost of one extra click to + reach Everyone/Me. + +## Verdict + +**Variant A won** — a single flat dropdown, plus the two independent Settings +controls (Active group, Active scope). Refinement on top of the original A: +the pinned scope now always sorts to the **top of the dropdown list** (still +starred), so reverting to it never requires hunting through groups/Everyone/Me +in their natural order — it's always the first row. Variants B and C did not +win; their code is left in place for reference only, not as live alternatives. + +## Cleanup + +Delete this whole `src/routes/prototype/` directory once the winning +interaction (Variant A + pinned-to-top ordering + the two Settings controls) +is folded into the real `ActiveGroupSwitcher` + a new Settings section, per +PR #273's branch. PR #288 (this prototype) is throwaway and will not be +merged to `main`. diff --git a/src/routes/prototype/active-scope.tsx b/src/routes/prototype/active-scope.tsx new file mode 100644 index 00000000..8e96d368 --- /dev/null +++ b/src/routes/prototype/active-scope.tsx @@ -0,0 +1,525 @@ +/** + * PROTOTYPE — throwaway. Not linked from any nav; visit /prototype/active-scope directly. + * Answers: does the "two settings (active group + active scope), compact mobile-first + * dropdown grouped groups-then-everyone/me" model feel right? + * Delete this whole route once a variant wins or the question is answered. + * + * Mock data only — no Supabase calls. State lives in memory (useState), reset on reload. + */ +import { + createFileRoute, + useNavigate, + useSearch, +} from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { z } from "zod"; +import { cn } from "@/lib/utils"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + ChevronDown, + Star, + X, + Users, + Globe, + User as UserIcon, + ChevronLeft, + ChevronRight, +} from "lucide-react"; + +const searchSchema = z.object({ + variant: z.enum(["A", "B", "C"]).catch("A"), +}); + +export const Route = createFileRoute("/prototype/active-scope")({ + component: ActiveScopePrototype, + validateSearch: searchSchema, +}); + +type ScopeKind = "group" | "everyone" | "me"; + +type Scope = + | { kind: "group"; id: string; name: string } + | { kind: "everyone" } + | { kind: "me" }; + +const MOCK_GROUPS = [ + { id: "g1", name: "Desert Crew" }, + { id: "g2", name: "Berlin Squad" }, +]; + +function scopeKey(s: Scope) { + return s.kind === "group" ? `group:${s.id}` : s.kind; +} + +function scopeLabel(s: Scope) { + if (s.kind === "group") return s.name; + if (s.kind === "everyone") return "Everyone"; + return "Me"; +} + +function scopeIcon(s: Scope) { + if (s.kind === "group") return Users; + if (s.kind === "everyone") return Globe; + return UserIcon; +} + +function groupScope(id: string): Scope { + const g = MOCK_GROUPS.find((g) => g.id === id) ?? MOCK_GROUPS[0]; + return { kind: "group", id: g.id, name: g.name }; +} + +/** + * Two independent settings, per the model: + * - activeGroupId: "Active group" — which of your groups, regardless of scope. + * - pinnedKind: "Active scope" — group / everyone / me. When "group", the + * pinned scope resolves through activeGroupId. + * `current` is the header's transient, session-only override of the pinned scope. + */ +function useMockScopeState() { + const [activeGroupId, setActiveGroupId] = useState(MOCK_GROUPS[0].id); + const [pinnedKind, setPinnedKind] = useState("group"); + + const pinned: Scope = + pinnedKind === "group" ? groupScope(activeGroupId) : { kind: pinnedKind }; + + const [current, setCurrent] = useState(pinned); + + function selectScope(s: Scope) { + setCurrent(s); + } + function returnToDefault() { + setCurrent(pinned); + } + function setActiveGroup(id: string) { + setActiveGroupId(id); + if (pinnedKind === "group") setCurrent(groupScope(id)); + } + function setPinnedScope(kind: ScopeKind) { + setPinnedKind(kind); + setCurrent(kind === "group" ? groupScope(activeGroupId) : { kind }); + } + + const isOverridden = scopeKey(current) !== scopeKey(pinned); + + return { + activeGroupId, + pinned, + current, + isOverridden, + selectScope, + returnToDefault, + setActiveGroup, + setPinnedScope, + }; +} + +type ScopeState = ReturnType; + +function ActiveScopePrototype() { + const { variant } = useSearch({ from: "/prototype/active-scope" }); + const navigate = useNavigate({ from: "/prototype/active-scope" }); + const state = useMockScopeState(); + + function setVariant(v: "A" | "B" | "C") { + navigate({ search: { variant: v } }); + } + + return ( +
+
+ Prototype — Active Group / Scope switcher · fake header + fake settings, + no real data +
+ + {variant === "A" && } + {variant === "B" && } + {variant === "C" && } + + +
+ ); +} + +/** + * Shared dropdown body. WINNER (per user verdict): the pinned scope always + * sits first — starred, at the top — so reverting to it never requires + * hunting through the list. Ordered: + * pinned scope (starred) + * ------- + * remaining groups + * ---- + * remaining of everyone/me + * Kept intentionally compact/flat (no nested submenus) for mobile. + */ +function ScopeMenuBody({ + pinned, + current, + onSelect, +}: { + pinned: Scope; + current: Scope; + onSelect: (s: Scope) => void; +}) { + function Row({ s }: { s: Scope }) { + const ItemIcon = scopeIcon(s); + const isPinned = scopeKey(s) === scopeKey(pinned); + const isActive = scopeKey(s) === scopeKey(current); + return ( + onSelect(s)} + className={cn("flex items-center gap-2", isActive && "bg-accent")} + > + + {scopeLabel(s)} + {isPinned && ( + + )} + + ); + } + + const otherGroups = MOCK_GROUPS.filter( + (g) => !(pinned.kind === "group" && pinned.id === g.id), + ).map((g): Scope => ({ kind: "group", id: g.id, name: g.name })); + const otherScopes = (["everyone", "me"] as const) + .filter((kind) => pinned.kind !== kind) + .map((kind): Scope => ({ kind })); + + return ( + <> + + + {otherGroups.map((s) => ( + + ))} + {otherGroups.length > 0 && otherScopes.length > 0 && ( + + )} + {otherScopes.map((s) => ( + + ))} + + ); +} + +/* ---------------- Variant A ---------------- + * Standard, compact header dropdown. Trigger shows current scope only (small footprint + * on mobile). "x back to default" pill appears next to the trigger when overridden. + */ +function VariantA({ state }: { state: ScopeState }) { + const { pinned, current, isOverridden, selectScope, returnToDefault } = state; + const Icon = scopeIcon(current); + + return ( +
+
+ Header: + + + + + + + + + + {isOverridden && ( + + )} +
+ + +
+ ); +} + +/* ---------------- Variant B ---------------- + * Two-row split: top row = "Your default" (pinned, always shown, click to jump straight + * back — no dropdown needed for the common case). Second row = compact dropdown for + * everything else, same grouped ordering as variant A. + */ +function VariantB({ state }: { state: ScopeState }) { + const { pinned, current, isOverridden, selectScope, returnToDefault } = state; + const PinnedIcon = scopeIcon(pinned); + + return ( +
+
+
+ + Your default + + +
+ +
+ + Browse + + + + + + + + + +
+
+ + +
+ ); +} + +/* ---------------- Variant C ---------------- + * Segmented control for the pinned group vs "other" (Everyone/Me/other groups) tucked + * behind a single "More" affordance — makes the group-pinned case the primary, + * always-visible control, at the cost of one extra tap to reach Everyone/Me. + */ +function VariantC({ state }: { state: ScopeState }) { + const { pinned, current, isOverridden, selectScope, returnToDefault } = state; + const [moreOpen, setMoreOpen] = useState(false); + const otherGroups = MOCK_GROUPS.filter( + (g) => !(pinned.kind === "group" && pinned.id === g.id), + ).map((g): Scope => ({ kind: "group", id: g.id, name: g.name })); + const others: Scope[] = [ + ...otherGroups, + { kind: "everyone" }, + { kind: "me" }, + ]; + + return ( +
+
+
+ + +
+ + {moreOpen && ( +
+ {others.map((s) => { + const ItemIcon = scopeIcon(s); + return ( + + ); + })} +
+ )} +
+ + +
+ ); +} + +/** + * Mocked Settings section — two independent controls per the model: + * "Active group" (which group, always settable) and "Active scope" + * (group / everyone / me — determines what the pin resolves to). + */ +function SettingsCard({ state }: { state: ScopeState }) { + const { activeGroupId, pinned, setActiveGroup, setPinnedScope } = state; + + return ( +
+

Settings (mock)

+ +
+

+ Active group — which of your groups +

+
+ {MOCK_GROUPS.map((g) => ( + + ))} +
+
+ +
+

+ Active scope — your default steady-state view +

+
+ {[ + { kind: "group" as const, label: "Group" }, + { kind: "everyone" as const, label: "Everyone" }, + { kind: "me" as const, label: "Me" }, + ].map((opt) => { + const isPinned = pinned.kind === opt.kind; + return ( + + ); + })} +
+
+
+ ); +} + +function PrototypeSwitcher({ + variants, + current, + onChange, +}: { + variants: { key: "A" | "B" | "C"; label: string }[]; + current: "A" | "B" | "C"; + onChange: (v: "A" | "B" | "C") => void; +}) { + const idx = variants.findIndex((v) => v.key === current); + + function cycle(dir: 1 | -1) { + const next = variants[(idx + dir + variants.length) % variants.length]; + onChange(next.key); + } + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + const target = e.target as HTMLElement | null; + if ( + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable) + ) { + return; + } + if (e.key === "ArrowLeft") cycle(-1); + if (e.key === "ArrowRight") cycle(1); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [idx, variants, onChange]); + + return ( +
+ + + {current} — {variants[idx]?.label} + + +
+ ); +}