Improve flag config validation and remove settings completion tracking (#390)

* Fix mobile league settings: prevent sports scroll and remove completion checkmarks

- Add overflow-hidden to sport label text span so flex truncation is properly contained
- Add min-w-0 to SettingsSection header inner flex item to prevent title/description overflow
- Remove completion check marks from mobile grid nav buttons (league settings has no completion concept)
- Remove "X of Y set" counter from mobile nav header
- Remove isComplete field from SettingsGridSection type and all data

https://claude.ai/code/session_01T7iTb9YZLuWJFtKV753tdB

* Fix race window in user creation and restore FlagSvg safety fallback

auth.server.ts: switch generateId to application-generated UUIDs so the
ID is known before the insert, then move flagConfig assignment from
create.after (a separate UPDATE) to create.before so it lands in the
initial INSERT with no race window.

FlagSvg.tsx: validate the config with parseFlagConfig before rendering;
corrupt or missing data falls back to a neutral gray triband flag rather
than silently rendering blank SVG shapes.

https://claude.ai/code/session_01T7iTb9YZLuWJFtKV753tdB

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-07 10:09:45 -07:00 committed by GitHub
parent d342f1ad25
commit 6df4f86920
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 38 additions and 46 deletions

View file

@ -1,6 +1,6 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import type { LucideProps } from "lucide-react"; import type { LucideProps } from "lucide-react";
import { ArrowLeft, Check } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
type SettingsNavSection = { type SettingsNavSection = {
@ -11,28 +11,22 @@ type SettingsNavSection = {
export type SettingsGridSection = SettingsNavSection & { export type SettingsGridSection = SettingsNavSection & {
icon: ComponentType<LucideProps>; icon: ComponentType<LucideProps>;
subtitle: string; subtitle: string;
isComplete: boolean;
isDanger?: boolean; isDanger?: boolean;
}; };
export function SettingsMobileGridNav({ export function SettingsMobileGridNav({
sections, sections,
completedCount,
onSectionChange, onSectionChange,
}: { }: {
sections: readonly SettingsGridSection[]; sections: readonly SettingsGridSection[];
completedCount: number;
onSectionChange: (sectionId: string) => void; onSectionChange: (sectionId: string) => void;
}) { }) {
return ( return (
<div className="mb-5 lg:hidden"> <div className="mb-5 lg:hidden">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Manage Manage
</p> </p>
<p className="text-xs text-muted-foreground">
{completedCount} of {sections.length} set
</p>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{sections.map((section) => ( {sections.map((section) => (
@ -41,15 +35,10 @@ export function SettingsMobileGridNav({
type="button" type="button"
onClick={() => onSectionChange(section.id)} onClick={() => onSectionChange(section.id)}
className={cn( className={cn(
"relative flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-primary/40 active:scale-[0.98]", "flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-primary/40 active:scale-[0.98]",
section.isDanger && "border-destructive/40" section.isDanger && "border-destructive/40"
)} )}
> >
{section.isComplete && (
<div className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check className="h-3 w-3" strokeWidth={3} />
</div>
)}
<div <div
className={cn( className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg", "flex h-10 w-10 items-center justify-center rounded-lg",

View file

@ -50,7 +50,7 @@ export function SettingsSection({
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<GradientIcon icon={icon} className="mt-1 h-6 w-6 shrink-0" /> <GradientIcon icon={icon} className="mt-1 h-6 w-6 shrink-0" />
<div> <div className="min-w-0">
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">{title}</h2> <h2 className="text-2xl font-bold tracking-tight sm:text-3xl">{title}</h2>
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">{description}</p> <p className="mt-2 max-w-2xl text-sm text-muted-foreground">{description}</p>
</div> </div>

View file

@ -72,7 +72,7 @@ export function SportsSection({
disabled={!canEditSports} disabled={!canEditSports}
/> />
<SportIcon sport={ss.sport} /> <SportIcon sport={ss.sport} />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1 overflow-hidden">
<span className="block truncate font-medium"> <span className="block truncate font-medium">
{ss.sport.slug === BRACKT_SLUG ? "Brackt" : `${ss.sport.name} - ${ss.name} (${ss.year})`} {ss.sport.slug === BRACKT_SLUG ? "Brackt" : `${ss.sport.name} - ${ss.name} (${ss.year})`}
</span> </span>

View file

@ -1,5 +1,5 @@
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import type { FlagConfig } from "~/lib/flag-types"; import { parseFlagConfig, type FlagConfig } from "~/lib/flag-types";
interface FlagSvgProps { interface FlagSvgProps {
config: FlagConfig; config: FlagConfig;
@ -8,8 +8,11 @@ interface FlagSvgProps {
title?: string; title?: string;
} }
const FALLBACK_FLAG: FlagConfig = { pattern: "triband-h", colors: ["#9ca3af", "#6b7280", "#4b5563"] };
export function FlagSvg({ config, size = 48, className, title }: FlagSvgProps) { export function FlagSvg({ config, size = 48, className, title }: FlagSvgProps) {
const [c0 = "", c1 = "", c2 = ""] = config.colors; const safeConfig = parseFlagConfig(config) ?? FALLBACK_FLAG;
const [c0, c1, c2] = safeConfig.colors;
return ( return (
<svg <svg
@ -21,28 +24,28 @@ export function FlagSvg({ config, size = 48, className, title }: FlagSvgProps) {
className={cn("block shrink-0 overflow-hidden", className)} className={cn("block shrink-0 overflow-hidden", className)}
> >
{title ? <title>{title}</title> : null} {title ? <title>{title}</title> : null}
{config.pattern === "triband-h" && ( {safeConfig.pattern === "triband-h" && (
<> <>
<rect width="100" height="34" fill={c0} /> <rect width="100" height="34" fill={c0} />
<rect y="33" width="100" height="34" fill={c1} /> <rect y="33" width="100" height="34" fill={c1} />
<rect y="66" width="100" height="34" fill={c2} /> <rect y="66" width="100" height="34" fill={c2} />
</> </>
)} )}
{config.pattern === "triband-v" && ( {safeConfig.pattern === "triband-v" && (
<> <>
<rect width="34" height="100" fill={c0} /> <rect width="34" height="100" fill={c0} />
<rect x="33" width="34" height="100" fill={c1} /> <rect x="33" width="34" height="100" fill={c1} />
<rect x="66" width="34" height="100" fill={c2} /> <rect x="66" width="34" height="100" fill={c2} />
</> </>
)} )}
{config.pattern === "diagonal" && ( {safeConfig.pattern === "diagonal" && (
<> <>
<rect width="100" height="100" fill={c0} /> <rect width="100" height="100" fill={c0} />
<polygon points="0,0 100,0 0,100" fill={c1} /> <polygon points="0,0 100,0 0,100" fill={c1} />
<polygon points="100,100 100,28 28,100" fill={c2} /> <polygon points="100,100 100,28 28,100" fill={c2} />
</> </>
)} )}
{config.pattern === "nordic-cross" && ( {safeConfig.pattern === "nordic-cross" && (
<> <>
<rect width="100" height="100" fill={c0} /> <rect width="100" height="100" fill={c0} />
<rect x="30" width="18" height="100" fill={c1} /> <rect x="30" width="18" height="100" fill={c1} />
@ -51,7 +54,7 @@ export function FlagSvg({ config, size = 48, className, title }: FlagSvgProps) {
<rect y="46" width="100" height="6" fill={c2} /> <rect y="46" width="100" height="6" fill={c2} />
</> </>
)} )}
{config.pattern === "cross" && ( {safeConfig.pattern === "cross" && (
<> <>
<rect width="100" height="100" fill={c0} /> <rect width="100" height="100" fill={c0} />
<rect x="40" width="20" height="100" fill={c1} /> <rect x="40" width="20" height="100" fill={c1} />
@ -60,28 +63,28 @@ export function FlagSvg({ config, size = 48, className, title }: FlagSvgProps) {
<rect y="46" width="100" height="8" fill={c2} /> <rect y="46" width="100" height="8" fill={c2} />
</> </>
)} )}
{config.pattern === "canton" && ( {safeConfig.pattern === "canton" && (
<> <>
<rect width="100" height="100" fill={c0} /> <rect width="100" height="100" fill={c0} />
<rect width="50" height="50" fill={c1} /> <rect width="50" height="50" fill={c1} />
<rect x="12" y="12" width="26" height="26" fill={c2} /> <rect x="12" y="12" width="26" height="26" fill={c2} />
</> </>
)} )}
{config.pattern === "triangle" && ( {safeConfig.pattern === "triangle" && (
<> <>
<rect width="100" height="100" fill={c0} /> <rect width="100" height="100" fill={c0} />
<polygon points="0,0 64,50 0,100" fill={c1} /> <polygon points="0,0 64,50 0,100" fill={c1} />
<polygon points="0,22 36,50 0,78" fill={c2} /> <polygon points="0,22 36,50 0,78" fill={c2} />
</> </>
)} )}
{config.pattern === "chevron" && ( {safeConfig.pattern === "chevron" && (
<> <>
<rect width="100" height="100" fill={c0} /> <rect width="100" height="100" fill={c0} />
<polygon points="0,0 56,50 0,100 28,100 84,50 28,0" fill={c1} /> <polygon points="0,0 56,50 0,100 28,100 84,50 28,0" fill={c1} />
<polygon points="0,22 32,50 0,78 0,62 14,50 0,38" fill={c2} /> <polygon points="0,22 32,50 0,78 0,62 14,50 0,38" fill={c2} />
</> </>
)} )}
{config.pattern === "quartered" && ( {safeConfig.pattern === "quartered" && (
<> <>
<rect width="50" height="50" fill={c0} /> <rect width="50" height="50" fill={c0} />
<rect x="50" width="50" height="50" fill={c1} /> <rect x="50" width="50" height="50" fill={c1} />
@ -89,21 +92,21 @@ export function FlagSvg({ config, size = 48, className, title }: FlagSvgProps) {
<rect x="50" y="50" width="50" height="50" fill={c2} /> <rect x="50" y="50" width="50" height="50" fill={c2} />
</> </>
)} )}
{config.pattern === "bordered" && ( {safeConfig.pattern === "bordered" && (
<> <>
<rect width="100" height="100" fill={c0} /> <rect width="100" height="100" fill={c0} />
<rect x="12" y="12" width="76" height="76" fill={c1} /> <rect x="12" y="12" width="76" height="76" fill={c1} />
<rect x="24" y="24" width="52" height="52" fill={c2} /> <rect x="24" y="24" width="52" height="52" fill={c2} />
</> </>
)} )}
{config.pattern === "circle" && ( {safeConfig.pattern === "circle" && (
<> <>
<rect width="100" height="100" fill={c0} /> <rect width="100" height="100" fill={c0} />
<circle cx="50" cy="50" r="30" fill={c1} /> <circle cx="50" cy="50" r="30" fill={c1} />
<circle cx="50" cy="50" r="15" fill={c2} /> <circle cx="50" cy="50" r="15" fill={c2} />
</> </>
)} )}
{config.pattern === "star" && ( {safeConfig.pattern === "star" && (
<> <>
<rect width="100" height="100" fill={c0} /> <rect width="100" height="100" fill={c0} />
<polygon <polygon

View file

@ -32,11 +32,13 @@ export const auth = betterAuth({
databaseHooks: { databaseHooks: {
user: { user: {
create: { create: {
after: async (user) => { before: async (data) => {
await db return {
.update(schema.users) data: {
.set({ flagConfig: generateFlagConfig(user.id) }) ...data,
.where(eq(schema.users.id, user.id)); flagConfig: generateFlagConfig(data.id),
} as typeof data,
};
}, },
}, },
update: { update: {
@ -63,7 +65,7 @@ export const auth = betterAuth({
}, },
advanced: { advanced: {
database: { database: {
generateId: false, generateId: () => crypto.randomUUID(),
}, },
}, },
user: { user: {

View file

@ -307,16 +307,15 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
}; };
const sectionGridData: SettingsGridSection[] = [ const sectionGridData: SettingsGridSection[] = [
{ id: "league-basics", label: "League Basics", icon: Shield, subtitle: "Name, teams, join", isComplete: true }, { id: "league-basics", label: "League Basics", icon: Shield, subtitle: "Name, teams, join" },
{ id: "draft-setup", label: "Draft Settings", icon: Swords, subtitle: "Format, timing, rounds", isComplete: !!(draftDate && draftTime) }, { id: "draft-setup", label: "Draft Settings", icon: Swords, subtitle: "Format, timing, rounds" },
{ id: "draft-order", label: "Draft Order", icon: ListOrdered, subtitle: "Pick order, randomize", isComplete: draftOrderSet }, { id: "draft-order", label: "Draft Order", icon: ListOrdered, subtitle: "Pick order, randomize" },
{ id: "sports", label: "Sports", icon: Trophy, subtitle: `${selectedSports.size} season${selectedSports.size !== 1 ? "s" : ""} selected`, isComplete: selectedSports.size > 0 }, { id: "sports", label: "Sports", icon: Trophy, subtitle: `${selectedSports.size} season${selectedSports.size !== 1 ? "s" : ""} selected` },
{ id: "scoring", label: "Scoring", icon: TrendingUp, subtitle: "Place values, ties", isComplete: true }, { id: "scoring", label: "Scoring", icon: TrendingUp, subtitle: "Place values, ties" },
{ id: "notifications", label: "Notifications", icon: Bell, subtitle: "Discord alerts", isComplete: !!league.discordWebhookUrl }, { id: "notifications", label: "Notifications", icon: Bell, subtitle: "Discord alerts" },
{ id: "people", label: "People", icon: Users, subtitle: `${teamsWithOwners} of ${teamCountValue} teams`, isComplete: false }, { id: "people", label: "People", icon: Users, subtitle: `${teamsWithOwners} of ${teamCountValue} teams` },
{ id: "history-danger", label: "History & Danger", icon: AlertTriangle, subtitle: "Audit log, dissolve", isComplete: false, isDanger: true }, { id: "history-danger", label: "History & Danger", icon: AlertTriangle, subtitle: "Audit log, dissolve", isDanger: true },
]; ];
const completedSectionCount = sectionGridData.filter((s) => s.isComplete).length;
return ( return (
<div className="container mx-auto max-w-5xl px-4 py-6 sm:py-8"> <div className="container mx-auto max-w-5xl px-4 py-6 sm:py-8">
@ -328,7 +327,6 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
<div className="lg:hidden"> <div className="lg:hidden">
<SettingsMobileGridNav <SettingsMobileGridNav
sections={sectionGridData} sections={sectionGridData}
completedCount={completedSectionCount}
onSectionChange={(id) => handleSectionChange(id as SettingsSectionId, { mobile: true })} onSectionChange={(id) => handleSectionChange(id as SettingsSectionId, { mobile: true })}
/> />
</div> </div>