Address code review feedback on Discord ping feature

- Fix broken Account settings link in NotificationsSection: replace the
  non-functional ?section=account href with an onNavigateToAccount callback
  that drives the parent's useState-based section switcher
- Replace hand-rolled toggle button with the project's Switch component
  (Radix UI) for consistent sizing, accessibility, and dark-mode support
- Guard update-discord-ping action: return an error if the user attempts
  to enable pings without a Discord account linked
- Surface action error in NotificationsSection UI
- Cap allowed_mentions.users at 100 to avoid Discord rejecting the payload
  in large leagues
- Remove the showWinner alias in scoring-calculator; inline winnerScoreChanged
- Add comment to league settings test notification explaining why discordUserId
  is omitted
- Clarify database schema comment: displayName is write-only from BetterAuth

https://claude.ai/code/session_01NUv93WRrufHhpZSMyY4bjs
This commit is contained in:
Claude 2026-05-15 17:00:27 +00:00
parent f897e7ee05
commit b7aa1db04a
No known key found for this signature in database
6 changed files with 42 additions and 28 deletions

View file

@ -1,21 +1,25 @@
import { useFetcher, Link } from "react-router";
import { useFetcher } from "react-router";
import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label";
type Props = {
discordPingEnabled: boolean;
hasDiscordLinked: boolean;
success?: boolean;
error?: string;
onNavigateToAccount: () => void;
};
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, success }: Props) {
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, success, error, onNavigateToAccount }: Props) {
const fetcher = useFetcher();
const optimisticEnabled =
fetcher.state !== "idle"
? fetcher.formData?.get("discordPingEnabled") === "true"
: discordPingEnabled;
const handleToggle = () => {
const handleToggle = (checked: boolean) => {
fetcher.submit(
{ intent: "update-discord-ping", discordPingEnabled: String(!optimisticEnabled) },
{ intent: "update-discord-ping", discordPingEnabled: String(checked) },
{ method: "post" }
);
};
@ -35,41 +39,40 @@ export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, suc
{!hasDiscordLinked ? (
<p className="text-sm text-muted-foreground">
Link your Discord account in{" "}
<Link to="?section=account" className="text-primary underline-offset-4 hover:underline">
<button
type="button"
onClick={onNavigateToAccount}
className="text-primary underline-offset-4 hover:underline"
>
Account settings
</Link>{" "}
</button>{" "}
to enable Discord pings for league notifications.
</p>
) : (
<>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<p className="text-sm">Ping me in standings updates</p>
<Label htmlFor="discord-ping-switch" className="text-sm font-normal">
Ping me in standings updates
</Label>
<p className="text-xs text-muted-foreground">
When enabled, you'll be @mentioned in league Discord notifications and your
Discord display name will appear in place of your brackt username.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={optimisticEnabled}
onClick={handleToggle}
<Switch
id="discord-ping-switch"
checked={optimisticEnabled}
onCheckedChange={handleToggle}
disabled={fetcher.state !== "idle"}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
optimisticEnabled ? "bg-primary" : "bg-input"
}`}
>
<span
className={`pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform ${
optimisticEnabled ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
/>
</div>
{success && (
<p className="text-sm text-green-600">Notification preference saved.</p>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</>
)}
</div>

View file

@ -1507,14 +1507,13 @@ export async function recalculateAffectedLeagues(
const winnerScoreChanged = winnerTeamId ? changedTeamIds.has(winnerTeamId) : false;
const winnerOwnerId = lookupOwner(m.winnerId);
const loserOwnerId = lookupOwner(m.loserId);
const showWinner = winnerScoreChanged;
const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds);
return {
winnerName: m.winnerName ?? "",
loserName: m.loserName ?? "",
winnerUsername: showWinner && winnerOwnerId ? usernameByUserId.get(winnerOwnerId) : undefined,
winnerUsername: winnerScoreChanged && winnerOwnerId ? usernameByUserId.get(winnerOwnerId) : undefined,
loserUsername: showLoser && loserOwnerId ? usernameByUserId.get(loserOwnerId) : undefined,
winnerDiscordUserId: showWinner && winnerOwnerId ? discordIdByUserId.get(winnerOwnerId) : undefined,
winnerDiscordUserId: winnerScoreChanged && winnerOwnerId ? discordIdByUserId.get(winnerOwnerId) : undefined,
loserDiscordUserId: showLoser && loserOwnerId ? discordIdByUserId.get(loserOwnerId) : undefined,
};
});

View file

@ -254,6 +254,7 @@ export async function action(args: Route.ActionArgs) {
]),
eventName: "Test Notification",
scoredMatches: [
// discordUserId intentionally omitted — we don't know the commissioner's Discord ID at test time
{ winnerName: "Team Alpha", loserName: "Team Beta", winnerUsername: "manager1", loserUsername: "manager2" },
],
});

View file

@ -45,6 +45,7 @@ type ActionData =
| { intent: "update-profile"; success: true }
| { intent: "update-profile"; error: string }
| { intent: "update-discord-ping"; success: true }
| { intent: "update-discord-ping"; error: string }
| { intent: "request-data"; dataRequestSuccess: true }
| { intent: "request-data"; dataRequestError: string }
| { intent: string; error: string };
@ -130,6 +131,12 @@ export async function action(args: Route.ActionArgs): Promise<ActionData | Respo
if (intent === "update-discord-ping") {
const enabled = formData.get("discordPingEnabled") === "true";
if (enabled) {
const accounts = await findLinkedAccountsByUserId(session.user.id);
if (!accounts.some((a) => a.providerId === "discord")) {
return { intent: "update-discord-ping", error: "Link your Discord account first." };
}
}
await updateUser(session.user.id, { discordPingEnabled: enabled });
return { intent: "update-discord-ping", success: true };
}
@ -245,6 +252,8 @@ export default function SettingsPage({ loaderData, actionData }: Route.Component
discordPingEnabled={user.discordPingEnabled}
hasDiscordLinked={linkedAccounts.some((a) => a.providerId === "discord")}
success={ad?.intent === "update-discord-ping" && "success" in ad}
error={ad?.intent === "update-discord-ping" && "error" in ad ? ad.error : undefined}
onNavigateToAccount={() => handleSectionChange("account")}
/>
)}
{activeSection === "api" && <ApiSection />}

View file

@ -180,8 +180,10 @@ export async function sendStandingsUpdateNotification({
};
if (pingIds.length > 0) {
payload.content = pingIds.map((id) => `<@${id}>`).join(" ");
payload.allowed_mentions = { parse: [], users: pingIds };
// Discord caps allowed_mentions.users at 100; slice to avoid a rejected payload.
const cappedIds = pingIds.slice(0, 100);
payload.content = cappedIds.map((id) => `<@${id}>`).join(" ");
payload.allowed_mentions = { parse: [], users: cappedIds };
}
await sendDiscordWebhook(webhookUrl, payload);

View file

@ -7,7 +7,7 @@ export const users = pgTable("users", {
email: varchar("email", { length: 255 }).notNull(),
emailVerified: boolean("email_verified").notNull().default(false),
username: varchar("username", { length: 255 }),
displayName: varchar("display_name", { length: 255 }), // mapped as BetterAuth's "name" field
displayName: varchar("display_name", { length: 255 }), // written by BetterAuth on OAuth login ("name" field); not user-editable
imageUrl: varchar("image_url", { length: 512 }), // mapped as BetterAuth's "image" field
flagConfig: jsonb("flag_config").$type<{ pattern: string; colors: string[] }>(),
customAvatarUrl: varchar("custom_avatar_url", { length: 512 }),