Add on-the-clock email notifications and fix push notification modal

- Add `draft_email_notifications_enabled` column to users table
- New `sendOnTheClockEmail` service sends an email when a user's turn arrives
  in a draft; detects back-to-back picks and sends one combined email instead
  of two; skips notification if the team has autodraft enabled
- Email is triggered after every manual pick (make-pick route) and every
  timer-expiry pick (executeAutoPick) once the full autodraft chain settles,
  so the notified user is always the first human on the clock
- Add email notification toggle to the user settings Notifications section
- Fix push notification dropdown in the draft room: the bare absolute div is
  replaced with a Radix Popover so clicking outside dismisses it without
  toggling notifications off
- Rename draft room label from "Pick Notifications" to "Push Notifications"
  to distinguish from the new email notifications

https://claude.ai/code/session_01LMGxgYvtE3CF8Jf3u2pXgA
This commit is contained in:
Claude 2026-05-20 21:33:20 +00:00
parent 7809864674
commit 2ef75a1d44
No known key found for this signature in database
13 changed files with 6383 additions and 24 deletions

View file

@ -1,6 +1,8 @@
import { useState } from "react";
import { Switch } from "~/components/ui/switch"; import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group";
import { Popover, PopoverAnchor, PopoverContent } from "~/components/ui/popover";
import type { NotificationMode } from "~/hooks/useDraftNotifications"; import type { NotificationMode } from "~/hooks/useDraftNotifications";
interface NotificationSettingsProps { interface NotificationSettingsProps {
@ -20,21 +22,30 @@ export function NotificationSettings({
permissionState, permissionState,
switchOnly = false, switchOnly = false,
}: NotificationSettingsProps) { }: NotificationSettingsProps) {
const [popoverOpen, setPopoverOpen] = useState(false);
if (permissionState === "unsupported") { if (permissionState === "unsupported") {
return null; return null;
} }
if (switchOnly) { if (switchOnly) {
const handleEnabledChange = (val: boolean) => {
onEnabledChange(val);
setPopoverOpen(val);
};
return ( return (
<> <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<Switch <PopoverAnchor asChild>
aria-label="Enable notifications" <Switch
checked={enabled} aria-label="Enable push notifications"
onCheckedChange={onEnabledChange} checked={enabled}
disabled={permissionState === "denied"} onCheckedChange={handleEnabledChange}
/> disabled={permissionState === "denied"}
/>
</PopoverAnchor>
{enabled && ( {enabled && (
<div className="absolute top-full right-0 mt-2 bg-card border border-border rounded-lg shadow-lg p-3 z-50 min-w-[180px]"> <PopoverContent align="end" className="w-48 p-3">
<RadioGroup <RadioGroup
value={mode} value={mode}
onValueChange={(value) => onModeChange(value as NotificationMode)} onValueChange={(value) => onModeChange(value as NotificationMode)}
@ -53,9 +64,9 @@ export function NotificationSettings({
</Label> </Label>
</div> </div>
</RadioGroup> </RadioGroup>
</div> </PopoverContent>
)} )}
</> </Popover>
); );
} }

View file

@ -7,6 +7,8 @@ const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef< const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>, React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
@ -26,4 +28,4 @@ const PopoverContent = React.forwardRef<
)) ))
PopoverContent.displayName = PopoverPrimitive.Content.displayName PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent } export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent }

View file

@ -5,25 +5,40 @@ import { Label } from "~/components/ui/label";
type Props = { type Props = {
discordPingEnabled: boolean; discordPingEnabled: boolean;
hasDiscordLinked: boolean; hasDiscordLinked: boolean;
draftEmailNotificationsEnabled: boolean;
success?: boolean; success?: boolean;
error?: string; error?: string;
onNavigateToAccount: () => void; onNavigateToAccount: () => void;
}; };
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, success, error, onNavigateToAccount }: Props) { export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, draftEmailNotificationsEnabled, success, error, onNavigateToAccount }: Props) {
const fetcher = useFetcher(); const discordFetcher = useFetcher();
const optimisticEnabled = const emailFetcher = useFetcher();
fetcher.state !== "idle"
? fetcher.formData?.get("discordPingEnabled") === "true" const optimisticDiscordEnabled =
discordFetcher.state !== "idle"
? discordFetcher.formData?.get("discordPingEnabled") === "true"
: discordPingEnabled; : discordPingEnabled;
const handleToggle = (checked: boolean) => { const optimisticEmailEnabled =
fetcher.submit( emailFetcher.state !== "idle"
? emailFetcher.formData?.get("draftEmailNotificationsEnabled") === "true"
: draftEmailNotificationsEnabled;
const handleDiscordToggle = (checked: boolean) => {
discordFetcher.submit(
{ intent: "update-discord-ping", discordPingEnabled: String(checked) }, { intent: "update-discord-ping", discordPingEnabled: String(checked) },
{ method: "post" } { method: "post" }
); );
}; };
const handleEmailToggle = (checked: boolean) => {
emailFetcher.submit(
{ intent: "update-email-draft-notifications", draftEmailNotificationsEnabled: String(checked) },
{ method: "post" }
);
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
@ -33,6 +48,30 @@ export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, suc
</p> </p>
</div> </div>
<div className="rounded-lg border p-4 space-y-4">
<h3 className="text-sm font-medium">Draft Pick Emails</h3>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="email-draft-switch" className="text-sm font-normal">
Email me when I'm on the clock
</Label>
<p className="text-xs text-muted-foreground">
Receive an email when it's your turn to pick in a draft. If you have back-to-back
picks, you'll get a single email letting you know.
</p>
</div>
<Switch
id="email-draft-switch"
checked={optimisticEmailEnabled}
onCheckedChange={handleEmailToggle}
disabled={emailFetcher.state !== "idle"}
/>
</div>
{emailFetcher.state === "idle" && emailFetcher.data && (emailFetcher.data as { intent?: string; success?: boolean }).intent === "update-email-draft-notifications" && (emailFetcher.data as { success?: boolean }).success && (
<p className="text-sm text-green-600">Email notification preference saved.</p>
)}
</div>
<div className="rounded-lg border p-4 space-y-4"> <div className="rounded-lg border p-4 space-y-4">
<h3 className="text-sm font-medium">Discord Pings</h3> <h3 className="text-sm font-medium">Discord Pings</h3>
@ -62,9 +101,9 @@ export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, suc
</div> </div>
<Switch <Switch
id="discord-ping-switch" id="discord-ping-switch"
checked={optimisticEnabled} checked={optimisticDiscordEnabled}
onCheckedChange={handleToggle} onCheckedChange={handleDiscordToggle}
disabled={fetcher.state !== "idle"} disabled={discordFetcher.state !== "idle"}
/> />
</div> </div>
{success && ( {success && (

View file

@ -8,6 +8,7 @@ import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSpo
import { getParticipantsForSeasonWithSports } from "./season-participant"; import { getParticipantsForSeasonWithSports } from "./season-participant";
import { getSeasonSportsSimple } from "./season-sport"; import { getSeasonSportsSimple } from "./season-sport";
import { calculateDraftEligibility } from "~/lib/draft-eligibility"; import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { sendOnTheClockEmail } from "~/services/draft-email.server";
import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket"; import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket";
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server"; import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
@ -833,6 +834,12 @@ export async function executeAutoPick(params: {
draftSlots, draftSlots,
db, db,
}); });
// After autodraft chain settles, notify whoever is actually on the clock
const postChainSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
const finalPickNumber = postChainSeason?.currentPickNumber ?? nextPickNumber;
sendOnTheClockEmail({ seasonId, currentPickNumber: finalPickNumber, totalTeams, totalPicks, draftSlots, db })
.catch((err) => logger.error("[AutoPick] On-the-clock email failed:", err));
} }
return { return {

View file

@ -11,6 +11,7 @@ import { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueIt
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket"; import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server"; import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
import { sendOnTheClockEmail } from "~/services/draft-email.server";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
@ -322,6 +323,13 @@ export async function action(args: ActionFunctionArgs) {
db, db,
}); });
} }
// After autodraft chain settles, notify whoever is actually on the clock
const postChainSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
const finalPickNumber = postChainSeason?.currentPickNumber ?? nextPickNumber;
const totalPicks = totalTeams * season.draftRounds;
sendOnTheClockEmail({ seasonId, currentPickNumber: finalPickNumber, totalTeams, totalPicks, draftSlots, db })
.catch((err) => logger.error("On-the-clock email failed:", err));
} }
return Response.json({ return Response.json({

View file

@ -1328,7 +1328,7 @@ export default function DraftRoom() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="hidden md:flex items-center gap-2 relative"> <div className="hidden md:flex items-center gap-2 relative">
<span className="text-xs text-muted-foreground">Pick Notifications</span> <span className="text-xs text-muted-foreground">Push Notifications</span>
<NotificationSettings <NotificationSettings
enabled={notificationsEnabled} enabled={notificationsEnabled}
onEnabledChange={setNotificationsEnabled} onEnabledChange={setNotificationsEnabled}

View file

@ -196,6 +196,7 @@ describe('Team Management - Admin User List', () => {
isAdmin: false, isAdmin: false,
timezone: null, timezone: null,
discordPingEnabled: false, discordPingEnabled: false,
draftEmailNotificationsEnabled: false,
lastDataRequestAt: null, lastDataRequestAt: null,
deletedAt: null, deletedAt: null,
createdAt: new Date(), createdAt: new Date(),
@ -215,6 +216,7 @@ describe('Team Management - Admin User List', () => {
isAdmin: false, isAdmin: false,
timezone: null, timezone: null,
discordPingEnabled: false, discordPingEnabled: false,
draftEmailNotificationsEnabled: false,
lastDataRequestAt: null, lastDataRequestAt: null,
deletedAt: null, deletedAt: null,
createdAt: new Date(), createdAt: new Date(),
@ -234,7 +236,7 @@ describe('Team Management - Admin User List', () => {
it('should only fetch users when user is admin', async () => { it('should only fetch users when user is admin', async () => {
const userId = 'admin-user-id'; const userId = 'admin-user-id';
vi.mocked(isUserAdmin).mockResolvedValue(true); vi.mocked(isUserAdmin).mockResolvedValue(true);
const isAdmin = await isUserAdmin(userId); const isAdmin = await isUserAdmin(userId);
@ -255,6 +257,7 @@ describe('Team Management - Admin User List', () => {
isAdmin: false, isAdmin: false,
timezone: null, timezone: null,
discordPingEnabled: false, discordPingEnabled: false,
draftEmailNotificationsEnabled: false,
lastDataRequestAt: null, lastDataRequestAt: null,
deletedAt: null, deletedAt: null,
createdAt: new Date(), createdAt: new Date(),

View file

@ -29,7 +29,7 @@ type SectionId = "profile" | "account" | "notifications" | "api" | "privacy";
const SECTIONS: readonly SettingsGridSection[] = [ const SECTIONS: readonly SettingsGridSection[] = [
{ id: "profile", label: "Profile", icon: User, subtitle: "Name, avatar, timezone" }, { id: "profile", label: "Profile", icon: User, subtitle: "Name, avatar, timezone" },
{ id: "account", label: "Account", icon: Shield, subtitle: "Email, sign-in methods" }, { id: "account", label: "Account", icon: Shield, subtitle: "Email, sign-in methods" },
{ id: "notifications", label: "Notifications", icon: Bell, subtitle: "Discord pings" }, { id: "notifications", label: "Notifications", icon: Bell, subtitle: "Email & Discord" },
{ id: "api", label: "API Access", icon: Key, subtitle: "Coming soon" }, { id: "api", label: "API Access", icon: Key, subtitle: "Coming soon" },
{ id: "privacy", label: "Data & Privacy", icon: Lock, subtitle: "Export, delete account", isDanger: true }, { id: "privacy", label: "Data & Privacy", icon: Lock, subtitle: "Export, delete account", isDanger: true },
] as const; ] as const;
@ -47,6 +47,8 @@ type ActionData =
| { intent: "update-profile"; error: string } | { intent: "update-profile"; error: string }
| { intent: "update-discord-ping"; success: true } | { intent: "update-discord-ping"; success: true }
| { intent: "update-discord-ping"; error: string } | { intent: "update-discord-ping"; error: string }
| { intent: "update-email-draft-notifications"; success: true }
| { intent: "update-email-draft-notifications"; error: string }
| { intent: "request-data"; dataRequestSuccess: true } | { intent: "request-data"; dataRequestSuccess: true }
| { intent: "request-data"; dataRequestError: string } | { intent: "request-data"; dataRequestError: string }
| { intent: string; error: string }; | { intent: string; error: string };
@ -157,6 +159,12 @@ export async function action(args: Route.ActionArgs): Promise<ActionData | Respo
return { intent: "update-discord-ping", success: true }; return { intent: "update-discord-ping", success: true };
} }
if (intent === "update-email-draft-notifications") {
const enabled = formData.get("draftEmailNotificationsEnabled") === "true";
await updateUser(session.user.id, { draftEmailNotificationsEnabled: enabled });
return { intent: "update-email-draft-notifications", success: true };
}
if (intent === "request-data") { if (intent === "request-data") {
if (user.lastDataRequestAt) { if (user.lastDataRequestAt) {
const cooldownExpires = new Date(user.lastDataRequestAt.getTime() + DATA_REQUEST_COOLDOWN_MS); const cooldownExpires = new Date(user.lastDataRequestAt.getTime() + DATA_REQUEST_COOLDOWN_MS);
@ -270,6 +278,7 @@ export default function SettingsPage({ loaderData, actionData }: Route.Component
<NotificationsSection <NotificationsSection
discordPingEnabled={user.discordPingEnabled} discordPingEnabled={user.discordPingEnabled}
hasDiscordLinked={linkedAccounts.some((a) => a.providerId === "discord")} hasDiscordLinked={linkedAccounts.some((a) => a.providerId === "discord")}
draftEmailNotificationsEnabled={user.draftEmailNotificationsEnabled}
success={ad?.intent === "update-discord-ping" && "success" in ad} success={ad?.intent === "update-discord-ping" && "success" in ad}
error={ad?.intent === "update-discord-ping" && "error" in ad ? ad.error : undefined} error={ad?.intent === "update-discord-ping" && "error" in ad ? ad.error : undefined}
onNavigateToAccount={() => handleSectionChange("account")} onNavigateToAccount={() => handleSectionChange("account")}

View file

@ -0,0 +1,107 @@
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import { sendEmail, wrapInEmailTemplate, emailParagraph, emailButton, escapeHtml } from "~/lib/email.server";
import { calculatePickInfo } from "~/models/draft-utils";
import { logger } from "~/lib/logger";
import type { database } from "~/database/context";
type DraftSlot = { teamId: string; draftOrder: number };
export async function sendOnTheClockEmail(params: {
seasonId: string;
currentPickNumber: number;
totalTeams: number;
totalPicks: number;
draftSlots: DraftSlot[];
db: ReturnType<typeof database>;
}): Promise<void> {
const { seasonId, currentPickNumber, totalTeams, totalPicks, draftSlots, db } = params;
if (currentPickNumber > totalPicks) return;
const { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
const currentSlot = draftSlots.find((s) => s.draftOrder === pickInRound);
if (!currentSlot) return;
// Fetch team + owner user
const team = await db.query.teams.findFirst({
where: eq(schema.teams.id, currentSlot.teamId),
});
if (!team?.ownerId) return;
const owner = await db.query.users.findFirst({
where: eq(schema.users.id, team.ownerId as string),
});
if (!owner?.email || !owner.draftEmailNotificationsEnabled) return;
// Safety net: skip if this team has autodraft enabled (their pick will be made automatically)
const autodraft = await db.query.autodraftSettings.findFirst({
where: and(
eq(schema.autodraftSettings.seasonId, seasonId),
eq(schema.autodraftSettings.teamId, currentSlot.teamId)
),
});
if (autodraft?.isEnabled) return;
// Detect consecutive picks (same team picks the very next slot too)
let isConsecutive = false;
if (currentPickNumber + 1 <= totalPicks) {
const { pickInRound: nextPickInRound } = calculatePickInfo(currentPickNumber + 1, totalTeams);
const nextSlot = draftSlots.find((s) => s.draftOrder === nextPickInRound);
if (nextSlot?.teamId === currentSlot.teamId) {
isConsecutive = true;
}
}
// Fetch season + league for context
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
if (!season) return;
const league = await db.query.leagues.findFirst({
where: eq(schema.leagues.id, season.leagueId),
});
if (!league) return;
const appUrl = process.env.APP_URL ?? "https://brackt.com";
const draftUrl = `${appUrl}/leagues/${season.leagueId}/draft/${seasonId}`;
const leagueName = escapeHtml(league.name);
const teamName = escapeHtml(team.name);
const { round } = calculatePickInfo(currentPickNumber, totalTeams);
let subject: string;
let preheader: string;
let bodyHtml: string;
if (isConsecutive) {
const { round: round2 } = calculatePickInfo(currentPickNumber + 1, totalTeams);
subject = `You have back-to-back picks in ${league.name}`;
preheader = `${team.name} picks twice in a row — head to the draft room!`;
bodyHtml = `
${emailParagraph(`<strong>You're up twice in a row!</strong>`)}
${emailParagraph(`<strong>${teamName}</strong> has back-to-back picks (Round ${round} and Round ${round2}) in <strong>${leagueName}</strong>. Head to the draft room to make both picks before time runs out.`)}
${emailButton(draftUrl, "Go to Draft Room")}
${emailParagraph(`<span style="font-size:13px;color:rgba(255,255,255,0.6);">You're receiving this because you enabled draft email notifications in your account settings.</span>`)}
`;
} else {
subject = `You're on the clock in ${league.name}`;
preheader = `${team.name} is on the clock — make your pick!`;
bodyHtml = `
${emailParagraph(`<strong>You're on the clock!</strong>`)}
${emailParagraph(`<strong>${teamName}</strong> is now on the clock in Round ${round} of <strong>${leagueName}</strong>. Head to the draft room to make your pick before time runs out.`)}
${emailButton(draftUrl, "Go to Draft Room")}
${emailParagraph(`<span style="font-size:13px;color:rgba(255,255,255,0.6);">You're receiving this because you enabled draft email notifications in your account settings.</span>`)}
`;
}
const { error } = await sendEmail({
to: owner.email,
subject,
html: wrapInEmailTemplate(bodyHtml, preheader),
});
if (error) {
logger.error(`[DraftEmail] Failed to send on-the-clock email to ${owner.email}:`, error);
}
}

View file

@ -15,6 +15,7 @@ export const users = pgTable("users", {
isAdmin: boolean("is_admin").notNull().default(false), isAdmin: boolean("is_admin").notNull().default(false),
timezone: varchar("timezone", { length: 50 }), // IANA timezone string, e.g. "America/Chicago" timezone: varchar("timezone", { length: 50 }), // IANA timezone string, e.g. "America/Chicago"
discordPingEnabled: boolean("discord_ping_enabled").notNull().default(false), discordPingEnabled: boolean("discord_ping_enabled").notNull().default(false),
draftEmailNotificationsEnabled: boolean("draft_email_notifications_enabled").notNull().default(false),
lastDataRequestAt: timestamp("last_data_request_at"), lastDataRequestAt: timestamp("last_data_request_at"),
deletedAt: timestamp("deleted_at"), deletedAt: timestamp("deleted_at"),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),

View file

@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "draft_email_notifications_enabled" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -778,6 +778,13 @@
"when": 1779256349467, "when": 1779256349467,
"tag": "0110_thankful_nova", "tag": "0110_thankful_nova",
"breakpoints": true "breakpoints": true
},
{
"idx": 111,
"version": "7",
"when": 1779312132517,
"tag": "0111_gorgeous_captain_universe",
"breakpoints": true
} }
] ]
} }