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 { LucideProps } from "lucide-react";
import { ArrowLeft, Check } from "lucide-react";
import { ArrowLeft } from "lucide-react";
import { cn } from "~/lib/utils";
type SettingsNavSection = {
@ -11,28 +11,22 @@ type SettingsNavSection = {
export type SettingsGridSection = SettingsNavSection & {
icon: ComponentType<LucideProps>;
subtitle: string;
isComplete: boolean;
isDanger?: boolean;
};
export function SettingsMobileGridNav({
sections,
completedCount,
onSectionChange,
}: {
sections: readonly SettingsGridSection[];
completedCount: number;
onSectionChange: (sectionId: string) => void;
}) {
return (
<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">
Manage
</p>
<p className="text-xs text-muted-foreground">
{completedCount} of {sections.length} set
</p>
</div>
<div className="grid grid-cols-2 gap-3">
{sections.map((section) => (
@ -41,15 +35,10 @@ export function SettingsMobileGridNav({
type="button"
onClick={() => onSectionChange(section.id)}
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.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
className={cn(
"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 items-start gap-3">
<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>
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">{description}</p>
</div>

View file

@ -72,7 +72,7 @@ export function SportsSection({
disabled={!canEditSports}
/>
<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">
{ss.sport.slug === BRACKT_SLUG ? "Brackt" : `${ss.sport.name} - ${ss.name} (${ss.year})`}
</span>

View file

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

View file

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

View file

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