Add overnight pause for draft timers (#335)

* Add overnight pause feature for draft timers

Protects players from having their pick timer expire while asleep.
Admins configure a nightly window (league-wide or per-user timezone);
the timer freezes during that window while autodraft can still fire.

Fixes #66

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix TS error: guard getUserDisplayName against undefined user

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-26 22:31:52 -07:00 committed by GitHub
parent 1bf65e33f7
commit dbc23f14ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 6362 additions and 103 deletions

View file

@ -94,7 +94,6 @@ interface CompactAutodraftBadgeProps {
isEnabled: boolean; isEnabled: boolean;
mode: AutodraftMode; mode: AutodraftMode;
queueOnly: boolean; queueOnly: boolean;
isMyTurn: boolean;
showChevron?: boolean; showChevron?: boolean;
} }
@ -102,7 +101,6 @@ export function CompactAutodraftBadge({
isEnabled, isEnabled,
mode, mode,
queueOnly, queueOnly,
isMyTurn,
showChevron = false, showChevron = false,
}: CompactAutodraftBadgeProps) { }: CompactAutodraftBadgeProps) {
const state = toAutodraftState(isEnabled, mode, queueOnly); const state = toAutodraftState(isEnabled, mode, queueOnly);
@ -122,9 +120,6 @@ export function CompactAutodraftBadge({
{label} {label}
{showChevron && <ChevronDown className="h-3 w-3" />} {showChevron && <ChevronDown className="h-3 w-3" />}
</span> </span>
{isMyTurn && (
<span className="text-xs text-amber-accent font-medium">Your turn!</span>
)}
</div> </div>
); );
} }
@ -137,6 +132,7 @@ interface AutodraftBadgeWithPopoverProps {
queueOnly: boolean; queueOnly: boolean;
isMyTurn: boolean; isMyTurn: boolean;
onUpdate: (isEnabled: boolean, mode: AutodraftMode, queueOnly: boolean) => void; onUpdate: (isEnabled: boolean, mode: AutodraftMode, queueOnly: boolean) => void;
showOvernightNote?: boolean;
} }
function AutodraftOptions({ function AutodraftOptions({
@ -243,6 +239,7 @@ export function AutodraftBadgeWithPopover({
queueOnly, queueOnly,
isMyTurn, isMyTurn,
onUpdate, onUpdate,
showOvernightNote = false,
}: AutodraftBadgeWithPopoverProps) { }: AutodraftBadgeWithPopoverProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -258,7 +255,6 @@ export function AutodraftBadgeWithPopover({
isEnabled={isEnabled} isEnabled={isEnabled}
mode={mode} mode={mode}
queueOnly={queueOnly} queueOnly={queueOnly}
isMyTurn={isMyTurn}
showChevron showChevron
/> />
</button> </button>
@ -299,6 +295,11 @@ export function AutodraftBadgeWithPopover({
); );
})} })}
</div> </div>
{showOvernightNote && (
<p className="text-xs text-muted-foreground mt-3 pt-3 border-t leading-relaxed">
Autodraft picks still fire during the overnight pause the pause only freezes the timer if autodraft is off.
</p>
)}
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </div>
@ -313,6 +314,7 @@ interface AutodraftSettingsProps {
queueOnly: boolean; queueOnly: boolean;
isMyTurn: boolean; isMyTurn: boolean;
onUpdate: (isEnabled: boolean, mode: AutodraftMode, queueOnly: boolean) => void; onUpdate: (isEnabled: boolean, mode: AutodraftMode, queueOnly: boolean) => void;
showOvernightNote?: boolean;
} }
export function AutodraftSettings({ export function AutodraftSettings({
@ -323,6 +325,7 @@ export function AutodraftSettings({
queueOnly, queueOnly,
isMyTurn, isMyTurn,
onUpdate, onUpdate,
showOvernightNote,
}: AutodraftSettingsProps) { }: AutodraftSettingsProps) {
const [localState, setLocalState] = useState<AutodraftState>( const [localState, setLocalState] = useState<AutodraftState>(
toAutodraftState(isEnabled, mode, queueOnly) toAutodraftState(isEnabled, mode, queueOnly)
@ -440,6 +443,11 @@ export function AutodraftSettings({
{isMyTurn && ( {isMyTurn && (
<p className="text-xs text-muted-foreground mt-2">You're on the clock!</p> <p className="text-xs text-muted-foreground mt-2">You're on the clock!</p>
)} )}
{showOvernightNote && (
<p className="text-xs text-muted-foreground mt-3 leading-relaxed">
If overnight pause is enabled, autodraft picks still fire during your overnight window the pause only protects your timer if autodraft is off.
</p>
)}
</div> </div>
); );
} }

View file

@ -56,6 +56,7 @@ interface DraftGridSectionProps {
isCommissioner: boolean; isCommissioner: boolean;
seasonStatus?: SeasonStatus; seasonStatus?: SeasonStatus;
draftPaused?: boolean; draftPaused?: boolean;
overnightPauseInfo?: { isActive: boolean; resumesAt: Date | null; pausedTeamIds?: Set<string> };
onAdjustTimeBankOpen?: (teamId: string) => void; onAdjustTimeBankOpen?: (teamId: string) => void;
onSetAutodraftOpen?: (teamId: string) => void; onSetAutodraftOpen?: (teamId: string) => void;
onForceAutopick?: (pickNumber: number, teamId: string) => void; onForceAutopick?: (pickNumber: number, teamId: string) => void;
@ -75,6 +76,7 @@ export const DraftGridSection = memo(function DraftGridSection({
isCommissioner, isCommissioner,
seasonStatus, seasonStatus,
draftPaused, draftPaused,
overnightPauseInfo,
onAdjustTimeBankOpen, onAdjustTimeBankOpen,
onSetAutodraftOpen, onSetAutodraftOpen,
onForceAutopick, onForceAutopick,
@ -90,6 +92,8 @@ export const DraftGridSection = memo(function DraftGridSection({
[draftGrid, currentPick] [draftGrid, currentPick]
); );
const isOvernightActive = overnightPauseInfo?.isActive ?? false;
return ( return (
<div className="h-full flex flex-col p-4"> <div className="h-full flex flex-col p-4">
<h2 className="text-xl font-semibold mb-4 flex-shrink-0">Draft Grid</h2> <h2 className="text-xl font-semibold mb-4 flex-shrink-0">Draft Grid</h2>
@ -101,6 +105,9 @@ export const DraftGridSection = memo(function DraftGridSection({
const teamTime = teamTimers[slot.team.id]; const teamTime = teamTimers[slot.team.id];
const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled || false; const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled || false;
const isConnected = connectedTeams.has(slot.team.id); const isConnected = connectedTeams.has(slot.team.id);
const isCurrentTeamOvernight = slot.team.id === currentTeamId
? isOvernightActive
: (overnightPauseInfo?.pausedTeamIds?.has(slot.team.id) ?? false);
const headerInner = ( const headerInner = (
<div className="relative"> <div className="relative">
@ -127,9 +134,9 @@ export const DraftGridSection = memo(function DraftGridSection({
<span className="truncate">{ownerMap[slot.team.id] || slot.team.name}</span> <span className="truncate">{ownerMap[slot.team.id] || slot.team.name}</span>
</div> </div>
<div <div
className={`text-xs font-mono px-1 ${getTimerColorClass(teamTime)}`} className={`text-xs font-mono px-1 ${isCurrentTeamOvernight ? "text-muted-foreground" : getTimerColorClass(teamTime)}`}
> >
{formatClockTime(teamTime)} {isCurrentTeamOvernight ? `🌙 ${formatClockTime(teamTime)}` : formatClockTime(teamTime)}
</div> </div>
{isCommissioner && (onAdjustTimeBankOpen || onSetAutodraftOpen) && ( {isCommissioner && (onAdjustTimeBankOpen || onSetAutodraftOpen) && (
<button <button
@ -228,6 +235,8 @@ export const DraftGridSection = memo(function DraftGridSection({
coronaState={pendingCorona} coronaState={pendingCorona}
seasonStatus={seasonStatus} seasonStatus={seasonStatus}
draftPaused={draftPaused} draftPaused={draftPaused}
isOvernightPause={isCurrent && isOvernightActive}
resumesAt={isCurrent && isOvernightActive && overnightPauseInfo?.resumesAt ? overnightPauseInfo.resumesAt : undefined}
className="cursor-context-menu" className="cursor-context-menu"
> >
{mobileButtons} {mobileButtons}
@ -301,6 +310,8 @@ export const DraftGridSection = memo(function DraftGridSection({
coronaState={pendingCorona} coronaState={pendingCorona}
seasonStatus={seasonStatus} seasonStatus={seasonStatus}
draftPaused={draftPaused} draftPaused={draftPaused}
isOvernightPause={isCurrent && isOvernightActive}
resumesAt={isCurrent && isOvernightActive && overnightPauseInfo?.resumesAt ? overnightPauseInfo.resumesAt : undefined}
> >
{mobileButtons} {mobileButtons}
</DraftPickCell> </DraftPickCell>

View file

@ -25,6 +25,8 @@ type DraftPickCellProps = ComponentPropsWithoutRef<"div"> & {
coronaState?: CoronaState; coronaState?: CoronaState;
seasonStatus?: SeasonStatus; seasonStatus?: SeasonStatus;
draftPaused?: boolean; draftPaused?: boolean;
isOvernightPause?: boolean;
resumesAt?: Date;
}; };
const coronaClasses: Record<CoronaVariant, string> = { const coronaClasses: Record<CoronaVariant, string> = {
@ -60,6 +62,8 @@ function DraftPickCell({
coronaState, coronaState,
seasonStatus, seasonStatus,
draftPaused, draftPaused,
isOvernightPause,
resumesAt,
className, className,
children, children,
...rest ...rest
@ -70,6 +74,8 @@ function DraftPickCell({
let currentLabel = "On The Clock"; let currentLabel = "On The Clock";
if (seasonStatus === "pre_draft") { if (seasonStatus === "pre_draft") {
currentLabel = "Up Next"; currentLabel = "Up Next";
} else if (seasonStatus === "draft" && isOvernightPause) {
currentLabel = "🌙 Overnight Pause";
} else if (seasonStatus === "draft" && draftPaused) { } else if (seasonStatus === "draft" && draftPaused) {
currentLabel = "Paused Draft"; currentLabel = "Paused Draft";
} }
@ -136,7 +142,14 @@ function DraftPickCell({
</div> </div>
</> </>
) : isCurrent ? ( ) : isCurrent ? (
<div className="text-sm font-bold text-white">{currentLabel}</div> <>
<div className="text-sm font-bold text-white">{currentLabel}</div>
{resumesAt && (
<div className="text-[10px] text-muted-foreground mt-0.5">
Resumes {resumesAt.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}
</div>
)}
</>
) : null} ) : null}
</div> </div>
{children && <div className="absolute inset-0 pointer-events-none [&_*]:pointer-events-auto">{children}</div>} {children && <div className="absolute inset-0 pointer-events-none [&_*]:pointer-events-auto">{children}</div>}

View file

@ -51,6 +51,7 @@ export interface MiniDraftGridProps {
connectedTeams?: Set<string>; connectedTeams?: Set<string>;
seasonStatus?: SeasonStatus; seasonStatus?: SeasonStatus;
draftPaused?: boolean; draftPaused?: boolean;
overnightPauseInfo?: { isActive: boolean; resumesAt: Date | null; pausedTeamIds?: Set<string> };
onAdjustTimeBankOpen?: (teamId: string) => void; onAdjustTimeBankOpen?: (teamId: string) => void;
onSetAutodraftOpen?: (teamId: string) => void; onSetAutodraftOpen?: (teamId: string) => void;
onForceAutopick?: (pickNumber: number, teamId: string) => void; onForceAutopick?: (pickNumber: number, teamId: string) => void;
@ -70,6 +71,7 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
connectedTeams, connectedTeams,
seasonStatus, seasonStatus,
draftPaused, draftPaused,
overnightPauseInfo,
onAdjustTimeBankOpen, onAdjustTimeBankOpen,
onSetAutodraftOpen, onSetAutodraftOpen,
onForceAutopick, onForceAutopick,
@ -151,6 +153,9 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
? [displayedIndices[0], displayedIndices[1], extraIndex] ? [displayedIndices[0], displayedIndices[1], extraIndex]
: [displayedIndices[0], displayedIndices[1]]; : [displayedIndices[0], displayedIndices[1]];
const currentTeamId = draftGrid.flat().find((c) => c.pickNumber === currentPick)?.teamId;
const isOvernightActive = overnightPauseInfo?.isActive ?? false;
return ( return (
<div ref={scrollContainerRef} className="overflow-x-auto"> <div ref={scrollContainerRef} className="overflow-x-auto">
<div className="inline-block min-w-full"> <div className="inline-block min-w-full">
@ -160,6 +165,9 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
const teamTime = teamTimers[slot.team.id]; const teamTime = teamTimers[slot.team.id];
const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled ?? false; const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled ?? false;
const isConnected = connectedTeams ? connectedTeams.has(slot.team.id) : true; const isConnected = connectedTeams ? connectedTeams.has(slot.team.id) : true;
const isCurrentTeamOvernight = slot.team.id === currentTeamId
? isOvernightActive
: (overnightPauseInfo?.pausedTeamIds?.has(slot.team.id) ?? false);
const headerContent = ( const headerContent = (
<> <>
@ -171,8 +179,8 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
{ownerMap[slot.team.id] || slot.team.name} {ownerMap[slot.team.id] || slot.team.name}
</span> </span>
</div> </div>
<div className={`text-xs font-mono px-1 ${getTimerColorClass(teamTime)}`}> <div className={`text-xs font-mono px-1 ${isCurrentTeamOvernight ? "text-muted-foreground" : getTimerColorClass(teamTime)}`}>
{formatClockTime(teamTime)} {isCurrentTeamOvernight ? `🌙 ${formatClockTime(teamTime)}` : formatClockTime(teamTime)}
</div> </div>
</> </>
); );
@ -253,6 +261,8 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
pick={pickData} pick={pickData}
seasonStatus={seasonStatus} seasonStatus={seasonStatus}
draftPaused={draftPaused} draftPaused={draftPaused}
isOvernightPause={isCurrent && isOvernightActive}
resumesAt={isCurrent && isOvernightActive && overnightPauseInfo?.resumesAt ? overnightPauseInfo.resumesAt : undefined}
className="cursor-context-menu" className="cursor-context-menu"
/> />
</ContextMenuTrigger> </ContextMenuTrigger>
@ -317,6 +327,8 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
pick={pickData} pick={pickData}
seasonStatus={seasonStatus} seasonStatus={seasonStatus}
draftPaused={draftPaused} draftPaused={draftPaused}
isOvernightPause={isCurrent && isOvernightActive}
resumesAt={isCurrent && isOvernightActive && overnightPauseInfo?.resumesAt ? overnightPauseInfo.resumesAt : undefined}
/> />
); );
})} })}

View file

@ -1,5 +1,5 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { ChevronRight, ChessPawn, Hourglass, LayoutGrid } from "lucide-react"; import { ChevronRight, ChessPawn, Hourglass, LayoutGrid, Moon } from "lucide-react";
import { Link } from "react-router"; import { Link } from "react-router";
import { formatClockTime } from "~/lib/draft-timer"; import { formatClockTime } from "~/lib/draft-timer";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@ -24,6 +24,10 @@ export interface DraftInfoCardProps {
draftRoomHref: string; draftRoomHref: string;
/** 1-based draft position for the current user; omit or undefined to hide the panel */ /** 1-based draft position for the current user; omit or undefined to hide the panel */
userDraftPosition?: number; userDraftPosition?: number;
overnightPauseMode?: "none" | "league" | "per_user";
overnightPauseStart?: string | null;
overnightPauseEnd?: string | null;
overnightPauseTimezone?: string | null;
} }
// ─── Sub-components ─────────────────────────────────────────────────────────── // ─── Sub-components ───────────────────────────────────────────────────────────
@ -52,6 +56,10 @@ function DraftInfoCardVariant({
draftDateTime, draftDateTime,
draftRoomHref, draftRoomHref,
userDraftPosition, userDraftPosition,
overnightPauseMode,
overnightPauseStart,
overnightPauseEnd,
overnightPauseTimezone,
}: DraftInfoCardProps) { }: DraftInfoCardProps) {
const flexSpots = Math.max(0, draftRounds - sportsCount); const flexSpots = Math.max(0, draftRounds - sportsCount);
@ -66,6 +74,31 @@ function DraftInfoCardVariant({
flexSpots > 0 ? `${flexSpots} flex` : null, flexSpots > 0 ? `${flexSpots} flex` : null,
].filter(Boolean).join(" + "); ].filter(Boolean).join(" + ");
const hasPosition = userDraftPosition !== null && userDraftPosition !== undefined;
const hasOvernight = !!(overnightPauseMode && overnightPauseMode !== "none" && overnightPauseStart && overnightPauseEnd);
// Mechanics row: Rounds + (Your Pick?) + Timer
// When overnight is shown, Draft Date moves to the top row so mechanics has 23 items.
// When no overnight, Draft Date stays in this row giving 34 items.
const mechanicsColsClass = hasOvernight
? (hasPosition ? "grid-cols-2 sm:grid-cols-3" : "grid-cols-2")
: (hasPosition ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-2 sm:grid-cols-3");
const timerPanel = (
<InfoPanel
label={timerLabel}
value={
<span className="flex items-center gap-1.5">
{draftTimerMode === "chess_clock"
? <ChessPawn className="h-4 w-4 shrink-0 text-muted-foreground" />
: <Hourglass className="h-4 w-4 shrink-0 text-muted-foreground" />}
{timerValue}
</span>
}
sub={timerSub}
/>
);
return ( return (
<Card> <Card>
<CardHeader className="pb-4"> <CardHeader className="pb-4">
@ -82,36 +115,46 @@ function DraftInfoCardVariant({
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-2">
<div className={`grid gap-2 ${userDraftPosition !== null && userDraftPosition !== undefined ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-2 sm:grid-cols-3"}`}> {/* Scheduling row: Draft Date + Overnight Pause side-by-side when overnight is configured */}
{/* Draft Date */} {hasOvernight && (
<InfoPanel <div className="grid grid-cols-2 gap-2">
label="Draft Date" <InfoPanel
value={draftDateTime ? format(new Date(draftDateTime), "MMM d, yyyy") : "TBD"} label="Draft Date"
sub={draftDateTime ? format(new Date(draftDateTime), "p") : undefined} value={draftDateTime ? format(new Date(draftDateTime), "MMM d, yyyy") : "TBD"}
/> sub={draftDateTime ? format(new Date(draftDateTime), "p") : undefined}
/>
<InfoPanel
label="Overnight Pause"
value={
<span className="flex items-center gap-1.5">
<Moon className="h-4 w-4 shrink-0 text-muted-foreground" />
{formatHHMM(overnightPauseStart ?? "")} {formatHHMM(overnightPauseEnd ?? "")}
</span>
}
sub={overnightPauseMode === "league" && overnightPauseTimezone
? overnightPauseTimezone
: "per-user timezone"}
/>
</div>
)}
{/* Rounds */} {/* Mechanics row: Rounds + Your Pick? + Timer (+ Draft Date when no overnight) */}
<div className={`grid gap-2 ${mechanicsColsClass}`}>
{!hasOvernight && (
<InfoPanel
label="Draft Date"
value={draftDateTime ? format(new Date(draftDateTime), "MMM d, yyyy") : "TBD"}
sub={draftDateTime ? format(new Date(draftDateTime), "p") : undefined}
/>
)}
<InfoPanel label="Rounds" value={draftRounds} sub={roundsSub} /> <InfoPanel label="Rounds" value={draftRounds} sub={roundsSub} />
{hasPosition && (
{/* User's Draft Position (only when set) */}
{userDraftPosition !== null && userDraftPosition !== undefined && (
<InfoPanel label="Your Pick" value={`#${userDraftPosition}`} sub="round 1 position" /> <InfoPanel label="Your Pick" value={`#${userDraftPosition}`} sub="round 1 position" />
)} )}
{timerPanel}
{/* Timer */} {/* placeholder to keep grid balanced on mobile when only 2 items */}
<InfoPanel {hasOvernight && !hasPosition && <div />}
label={timerLabel}
value={
<span className="flex items-center gap-1.5">
{draftTimerMode === "chess_clock"
? <ChessPawn className="h-4 w-4 shrink-0 text-muted-foreground" />
: <Hourglass className="h-4 w-4 shrink-0 text-muted-foreground" />}
{timerValue}
</span>
}
sub={timerSub}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -120,6 +163,16 @@ function DraftInfoCardVariant({
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
/** Formats "23:00" → "11 PM", "07:00" → "7 AM". */
function formatHHMM(hhmm: string): string {
const [hStr, mStr] = hhmm.split(":");
const h = parseInt(hStr, 10);
const m = parseInt(mStr, 10);
const period = h < 12 ? "AM" : "PM";
const h12 = h % 12 === 0 ? 12 : h % 12;
return m === 0 ? `${h12} ${period}` : `${h12}:${String(m).padStart(2, "0")} ${period}`;
}
/** Converts a seconds value to a short human-readable string, e.g. "1 minute", "10 seconds", "2 hours". */ /** Converts a seconds value to a short human-readable string, e.g. "1 minute", "10 seconds", "2 hours". */
function formatHumanTime(seconds: number): string { function formatHumanTime(seconds: number): string {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);

View file

@ -0,0 +1,72 @@
import { useState } from "react";
import { TIMEZONE_OPTIONS } from "~/components/league/TimezoneSelect";
const VALID_TIMEZONES = new Set(
TIMEZONE_OPTIONS.flatMap((g) => g.zones.map((z) => z.value))
);
interface TimezonePromptBannerProps {
/** Called with the saved timezone value after a successful POST. */
onSaved?: (timezone: string) => void;
}
export function TimezonePromptBanner({ onSaved }: TimezonePromptBannerProps) {
const [dismissed, setDismissed] = useState(false);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
if (dismissed || saved) return null;
const detectedTz =
typeof window !== "undefined"
? (() => {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
return VALID_TIMEZONES.has(tz) ? tz : null;
} catch {
return null;
}
})()
: null;
if (!detectedTz) return null;
const handleSave = async () => {
setSaving(true);
try {
const body = new FormData();
body.set("timezone", detectedTz);
const res = await fetch("/api/user/timezone", { method: "POST", body });
if (res.ok) {
setSaved(true);
onSaved?.(detectedTz);
}
} finally {
setSaving(false);
}
};
return (
<div className="flex items-center justify-between gap-3 px-4 py-2 bg-blue-950/40 border-b border-blue-800/40 text-sm text-blue-200">
<span>
Set your timezone to <strong>{detectedTz}</strong> for overnight pick
protection?
</span>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={handleSave}
disabled={saving}
className="px-3 py-1 rounded bg-blue-700 hover:bg-blue-600 text-white text-xs font-medium disabled:opacity-50"
>
{saving ? "Saving…" : "Save"}
</button>
<button
onClick={() => setDismissed(true)}
className="text-blue-400 hover:text-blue-200 text-xs"
>
Dismiss
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,141 @@
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
export const TIMEZONE_OPTIONS: { region: string; zones: { value: string; label: string }[] }[] = [
{
region: "North America",
zones: [
{ value: "America/New_York", label: "Eastern Time (ET)" },
{ value: "America/Chicago", label: "Central Time (CT)" },
{ value: "America/Denver", label: "Mountain Time (MT)" },
{ value: "America/Phoenix", label: "Mountain Time Arizona (no DST)" },
{ value: "America/Los_Angeles", label: "Pacific Time (PT)" },
{ value: "America/Anchorage", label: "Alaska Time (AKT)" },
{ value: "Pacific/Honolulu", label: "Hawaii Time (HT)" },
{ value: "America/Toronto", label: "Eastern Time Toronto" },
{ value: "America/Vancouver", label: "Pacific Time Vancouver" },
{ value: "America/Edmonton", label: "Mountain Time Edmonton" },
{ value: "America/Winnipeg", label: "Central Time Winnipeg" },
{ value: "America/Halifax", label: "Atlantic Time Halifax" },
{ value: "America/St_Johns", label: "Newfoundland Time" },
{ value: "America/Mexico_City", label: "Central Time Mexico City" },
],
},
{
region: "South America",
zones: [
{ value: "America/Sao_Paulo", label: "Brasília Time (BRT)" },
{ value: "America/Argentina/Buenos_Aires", label: "Argentina Time (ART)" },
{ value: "America/Bogota", label: "Colombia Time (COT)" },
{ value: "America/Lima", label: "Peru Time (PET)" },
{ value: "America/Santiago", label: "Chile Time (CLT)" },
],
},
{
region: "Europe",
zones: [
{ value: "Europe/London", label: "GMT / British Time (GMT/BST)" },
{ value: "Europe/Dublin", label: "Irish Time (GMT/IST)" },
{ value: "Europe/Paris", label: "Central European Time (CET/CEST)" },
{ value: "Europe/Berlin", label: "Central European Time Berlin" },
{ value: "Europe/Amsterdam", label: "Central European Time Amsterdam" },
{ value: "Europe/Madrid", label: "Central European Time Madrid" },
{ value: "Europe/Rome", label: "Central European Time Rome" },
{ value: "Europe/Warsaw", label: "Central European Time Warsaw" },
{ value: "Europe/Helsinki", label: "Eastern European Time (EET/EEST)" },
{ value: "Europe/Athens", label: "Eastern European Time Athens" },
{ value: "Europe/Bucharest", label: "Eastern European Time Bucharest" },
{ value: "Europe/Kyiv", label: "Eastern European Time Kyiv" },
{ value: "Europe/Moscow", label: "Moscow Time (MSK)" },
],
},
{
region: "Africa",
zones: [
{ value: "Africa/Johannesburg", label: "South Africa Time (SAST)" },
{ value: "Africa/Cairo", label: "Eastern European Time Cairo" },
{ value: "Africa/Lagos", label: "West Africa Time (WAT)" },
{ value: "Africa/Nairobi", label: "East Africa Time (EAT)" },
],
},
{
region: "Asia / Middle East",
zones: [
{ value: "Asia/Dubai", label: "Gulf Standard Time (GST)" },
{ value: "Asia/Riyadh", label: "Arabia Standard Time (AST)" },
{ value: "Asia/Kolkata", label: "India Standard Time (IST)" },
{ value: "Asia/Dhaka", label: "Bangladesh Standard Time (BST)" },
{ value: "Asia/Bangkok", label: "Indochina Time (ICT)" },
{ value: "Asia/Singapore", label: "Singapore Time (SGT)" },
{ value: "Asia/Shanghai", label: "China Standard Time (CST)" },
{ value: "Asia/Hong_Kong", label: "Hong Kong Time (HKT)" },
{ value: "Asia/Tokyo", label: "Japan Standard Time (JST)" },
{ value: "Asia/Seoul", label: "Korea Standard Time (KST)" },
{ value: "Asia/Karachi", label: "Pakistan Standard Time (PKT)" },
{ value: "Asia/Colombo", label: "Sri Lanka Time (SLST)" },
{ value: "Asia/Tehran", label: "Iran Standard Time (IRST)" },
{ value: "Asia/Jerusalem", label: "Israel Standard Time (IST/IDT)" },
],
},
{
region: "Oceania",
zones: [
{ value: "Australia/Sydney", label: "Australian Eastern Time (AEST/AEDT)" },
{ value: "Australia/Melbourne", label: "Australian Eastern Time Melbourne" },
{ value: "Australia/Brisbane", label: "Australian Eastern Time Queensland" },
{ value: "Australia/Adelaide", label: "Australian Central Time (ACST/ACDT)" },
{ value: "Australia/Perth", label: "Australian Western Time (AWST)" },
{ value: "Pacific/Auckland", label: "New Zealand Time (NZST/NZDT)" },
{ value: "Pacific/Fiji", label: "Fiji Time (FJT)" },
],
},
{
region: "UTC / Other",
zones: [
{ value: "UTC", label: "UTC (Coordinated Universal Time)" },
],
},
];
interface TimezoneSelectProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
placeholder?: string;
name?: string;
}
export function TimezoneSelect({
value,
onChange,
disabled,
placeholder = "Select timezone…",
name,
}: TimezoneSelectProps) {
return (
<Select value={value} onValueChange={onChange} disabled={disabled} name={name}>
<SelectTrigger className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent className="max-h-80">
{TIMEZONE_OPTIONS.map((group) => (
<SelectGroup key={group.region}>
<SelectLabel>{group.region}</SelectLabel>
{group.zones.map((tz) => (
<SelectItem key={tz.value} value={tz.value}>
{tz.label}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
);
}

View file

@ -125,6 +125,9 @@ export function useDraftRoomState({
const [timeBankDirection, setTimeBankDirection] = useState<"add" | "remove">("add"); const [timeBankDirection, setTimeBankDirection] = useState<"add" | "remove">("add");
const [isAdjustingTimeBank, setIsAdjustingTimeBank] = useState(false); const [isAdjustingTimeBank, setIsAdjustingTimeBank] = useState(false);
const [isOvernightPause, setIsOvernightPause] = useState(false);
const [overnightResumesAt, setOvernightResumesAt] = useState<Date | null>(null);
return { return {
picks, setPicks, picks, setPicks,
currentPick, setCurrentPick, currentPick, setCurrentPick,
@ -160,5 +163,7 @@ export function useDraftRoomState({
timeBankUnit, setTimeBankUnit, timeBankUnit, setTimeBankUnit,
timeBankDirection, setTimeBankDirection, timeBankDirection, setTimeBankDirection,
isAdjustingTimeBank, setIsAdjustingTimeBank, isAdjustingTimeBank, setIsAdjustingTimeBank,
isOvernightPause, setIsOvernightPause,
overnightResumesAt, setOvernightResumesAt,
}; };
} }

View file

@ -36,6 +36,8 @@ interface UseDraftSocketEventsParams {
setConnectedTeams: (fn: (prev: Set<string>) => Set<string>) => void; setConnectedTeams: (fn: (prev: Set<string>) => Set<string>) => void;
setQueue: (fn: (prev: QueueItem[]) => QueueItem[]) => void; setQueue: (fn: (prev: QueueItem[]) => QueueItem[]) => void;
setTeamTimers: (fn: (prev: Record<string, number>) => Record<string, number>) => void; setTeamTimers: (fn: (prev: Record<string, number>) => Record<string, number>) => void;
setIsOvernightPause: (value: boolean) => void;
setOvernightResumesAt: (value: Date | null) => void;
} }
export function useDraftSocketEvents({ export function useDraftSocketEvents({
@ -60,6 +62,8 @@ export function useDraftSocketEvents({
setConnectedTeams, setConnectedTeams,
setQueue, setQueue,
setTeamTimers, setTeamTimers,
setIsOvernightPause,
setOvernightResumesAt,
}: UseDraftSocketEventsParams) { }: UseDraftSocketEventsParams) {
useEffect(() => { useEffect(() => {
type PickMadePayload = { type PickMadePayload = {
@ -93,7 +97,13 @@ export function useDraftSocketEvents({
} }
}; };
const handleTimerUpdate = (data: { teamId: string; timeRemaining: number; currentPickNumber: number | null }) => { const handleTimerUpdate = (data: {
teamId: string;
timeRemaining: number;
currentPickNumber: number | null;
overnightPauseActive?: boolean;
resumesAtUTC?: number;
}) => {
setTeamTimers((prev) => { setTeamTimers((prev) => {
if (prev[data.teamId] === data.timeRemaining) return prev; if (prev[data.teamId] === data.timeRemaining) return prev;
return { ...prev, [data.teamId]: data.timeRemaining }; return { ...prev, [data.teamId]: data.timeRemaining };
@ -101,6 +111,9 @@ export function useDraftSocketEvents({
if (data.currentPickNumber !== null) { if (data.currentPickNumber !== null) {
setCurrentPick(data.currentPickNumber); setCurrentPick(data.currentPickNumber);
} }
const pauseActive = data.overnightPauseActive ?? false;
setIsOvernightPause(pauseActive);
setOvernightResumesAt(pauseActive && data.resumesAtUTC ? new Date(data.resumesAtUTC) : null);
}; };
const handleDraftPaused = () => setIsPaused(true); const handleDraftPaused = () => setIsPaused(true);

View file

@ -0,0 +1,147 @@
import { describe, it, expect } from "vitest";
import { isInOvernightWindow, getOvernightResumeUTC } from "../overnight-pause";
// Helper to build a Date at a specific local time in a timezone.
// We use a known UTC offset approach: format a UTC date, check what local time
// it produces, then shift until we hit the desired time.
function dateAtLocalTime(
timezone: string,
year: number,
month: number, // 1-indexed
day: number,
hour: number,
minute: number
): Date {
// Use a binary-search-free approach: construct approximate UTC, then correct
const approxUtc = Date.UTC(year, month - 1, day, hour, minute);
const probe = new Date(approxUtc);
const fmt = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const parts = fmt.formatToParts(probe);
const localHour = parseInt(parts.find((p) => p.type === "hour")?.value ?? "0", 10);
const localMin = parseInt(parts.find((p) => p.type === "minute")?.value ?? "0", 10);
const h = localHour === 24 ? 0 : localHour;
const diffMs = ((hour * 60 + minute) - (h * 60 + localMin)) * 60 * 1000;
return new Date(approxUtc + diffMs);
}
// ─── isInOvernightWindow ──────────────────────────────────────────────────────
describe("isInOvernightWindow — midnight-spanning window (23:0007:00)", () => {
const tz = "America/New_York";
const start = "23:00";
const end = "07:00";
it("returns true just after window starts (23:30)", () => {
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 15, 23, 30))).toBe(true);
});
it("returns true at exact window start (23:00)", () => {
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 15, 23, 0))).toBe(true);
});
it("returns true past midnight (01:00)", () => {
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 16, 1, 0))).toBe(true);
});
it("returns true just before window ends (06:59)", () => {
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 16, 6, 59))).toBe(true);
});
it("returns false exactly at window end (07:00)", () => {
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 16, 7, 0))).toBe(false);
});
it("returns false in the middle of the day (14:00)", () => {
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 15, 14, 0))).toBe(false);
});
it("returns false just before window starts (22:59)", () => {
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 15, 22, 59))).toBe(false);
});
});
describe("isInOvernightWindow — same-day window (10:0018:00)", () => {
const tz = "America/Chicago";
const start = "10:00";
const end = "18:00";
it("returns true at 14:00", () => {
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 15, 14, 0))).toBe(true);
});
it("returns true at exact start (10:00)", () => {
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 15, 10, 0))).toBe(true);
});
it("returns false at exact end (18:00)", () => {
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 15, 18, 0))).toBe(false);
});
it("returns false at 09:59", () => {
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 15, 9, 59))).toBe(false);
});
it("returns false at 20:00", () => {
expect(isInOvernightWindow(tz, start, end, dateAtLocalTime(tz, 2025, 6, 15, 20, 0))).toBe(false);
});
});
describe("isInOvernightWindow — different IANA timezones", () => {
it("works for Europe/London at 00:30 in a midnight-spanning window", () => {
const d = dateAtLocalTime("Europe/London", 2025, 6, 15, 0, 30);
expect(isInOvernightWindow("Europe/London", "23:00", "07:00", d)).toBe(true);
});
it("works for Asia/Tokyo — 23:30 is in window", () => {
const d = dateAtLocalTime("Asia/Tokyo", 2025, 6, 15, 23, 30);
expect(isInOvernightWindow("Asia/Tokyo", "23:00", "07:00", d)).toBe(true);
});
it("works for Asia/Tokyo — 14:00 is not in window", () => {
const d = dateAtLocalTime("Asia/Tokyo", 2025, 6, 15, 14, 0);
expect(isInOvernightWindow("Asia/Tokyo", "23:00", "07:00", d)).toBe(false);
});
});
// ─── getOvernightResumeUTC ────────────────────────────────────────────────────
describe("getOvernightResumeUTC", () => {
it("returns a future date when called during the overnight window", () => {
const tz = "America/New_York";
// 2:00 AM ET — inside the 23:0007:00 window
const now = dateAtLocalTime(tz, 2025, 6, 16, 2, 0);
const resume = getOvernightResumeUTC(tz, "07:00", now);
expect(resume.getTime()).toBeGreaterThan(now.getTime());
});
it("returns a date that represents 07:00 in target timezone", () => {
const tz = "America/Chicago";
const now = dateAtLocalTime(tz, 2025, 6, 16, 1, 0);
const resume = getOvernightResumeUTC(tz, "07:00", now);
// Verify it shows as 07:00 in Chicago
const fmt = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const parts = fmt.formatToParts(resume);
const h = parseInt(parts.find((p) => p.type === "hour")?.value ?? "0", 10);
const m = parseInt(parts.find((p) => p.type === "minute")?.value ?? "0", 10);
expect(h).toBe(7);
expect(m).toBe(0);
});
it("returns tomorrow's end time when current time is after window end (daytime)", () => {
const tz = "America/New_York";
// 14:00 ET — outside the window, should return tomorrow 07:00
const now = dateAtLocalTime(tz, 2025, 6, 16, 14, 0);
const resume = getOvernightResumeUTC(tz, "07:00", now);
expect(resume.getTime()).toBeGreaterThan(now.getTime());
});
});

View file

@ -0,0 +1,96 @@
/**
* Utilities for overnight pause windows.
* Works in both Node.js and browser uses only Intl, no extra dependencies.
*/
function getLocalMinutes(timezone: string, now: Date): number {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const parts = formatter.formatToParts(now);
const hour = parseInt(parts.find((p) => p.type === "hour")?.value ?? "0", 10);
const minute = parseInt(parts.find((p) => p.type === "minute")?.value ?? "0", 10);
// Intl hour12:false can return "24" for midnight — normalize to 0
return (hour === 24 ? 0 : hour) * 60 + minute;
}
/**
* Build a UTC Date representing "the given date at HH:MM in the given timezone."
* Corrects for the UTC offset by comparing what local time the naive UTC produces.
*/
function utcForLocalTime(
timezone: string,
year: number,
month: number, // 1-indexed
day: number,
hour: number,
minute: number
): Date {
// Start with a naive UTC (treating target H:M as UTC)
const approxUTC = Date.UTC(year, month - 1, day, hour, minute, 0);
const probe = new Date(approxUTC);
const localMins = getLocalMinutes(timezone, probe);
const targetMins = hour * 60 + minute;
// Shift: if local is behind target, add the gap; if ahead, subtract it
return new Date(approxUTC + (targetMins - localMins) * 60 * 1000);
}
/**
* Returns true if `now` falls inside the overnight pause window defined by
* startHHMMendHHMM in the given IANA timezone.
*
* The window almost always spans midnight (e.g. "23:00""07:00"), but a
* same-day window (e.g. "10:00""18:00") is also handled correctly.
*/
export function isInOvernightWindow(
timezone: string,
startHHMM: string,
endHHMM: string,
now = new Date()
): boolean {
const currentMinutes = getLocalMinutes(timezone, now);
const [sh, sm] = startHHMM.split(":").map(Number);
const [eh, em] = endHHMM.split(":").map(Number);
const startMinutes = sh * 60 + sm;
const endMinutes = eh * 60 + em;
if (startMinutes > endMinutes) {
// Spans midnight: in window if current >= start OR current < end
return currentMinutes >= startMinutes || currentMinutes < endMinutes;
}
// Same-day window: in window if start <= current < end
return currentMinutes >= startMinutes && currentMinutes < endMinutes;
}
/**
* Returns the next UTC Date when endHHMM will occur in the given timezone.
* Used to tell clients when the overnight window will end.
*/
export function getOvernightResumeUTC(
timezone: string,
endHHMM: string,
now = new Date()
): Date {
const [eh, em] = endHHMM.split(":").map(Number);
// Get current calendar date in the target timezone
const dateFmt = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const [year, month, day] = dateFmt.format(now).split("-").map(Number);
// Candidate: today at endHHMM in the target timezone
const candidate = utcForLocalTime(timezone, year, month, day, eh, em);
// If this moment has already passed, advance one day
if (candidate.getTime() <= now.getTime()) {
return new Date(candidate.getTime() + 24 * 60 * 60 * 1000);
}
return candidate;
}

View file

@ -705,6 +705,7 @@ export async function executeAutoPick(params: {
teamId, teamId,
timeRemaining: emitTimeRemaining, timeRemaining: emitTimeRemaining,
currentPickNumber: nextPickNumber, currentPickNumber: nextPickNumber,
overnightPauseActive: false,
}); });
} catch (error) { } catch (error) {
logger.error("[AutoPick] Socket.IO timer-update error:", error); logger.error("[AutoPick] Socket.IO timer-update error:", error);

View file

@ -1,4 +1,4 @@
import { eq, inArray } from "drizzle-orm"; import { eq, inArray, and } from "drizzle-orm";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
@ -74,3 +74,23 @@ export async function findAllUsers(): Promise<User[]> {
orderBy: (users, { asc }) => [asc(users.displayName)], orderBy: (users, { asc }) => [asc(users.displayName)],
}); });
} }
/**
* Returns true if the user currently owns a team in a draft-status season.
* Used to prevent timezone changes mid-draft.
*/
export async function isUserInActiveDraft(userId: string): Promise<boolean> {
const db = database();
const result = await db
.select({ id: schema.teams.id })
.from(schema.teams)
.innerJoin(schema.seasons, eq(schema.teams.seasonId, schema.seasons.id))
.where(
and(
eq(schema.teams.ownerId, userId),
eq(schema.seasons.status, "draft")
)
)
.limit(1);
return result.length > 0;
}

View file

@ -47,6 +47,7 @@ export default [
route("api/draft/rollback", "routes/api/draft.rollback.ts"), route("api/draft/rollback", "routes/api/draft.rollback.ts"),
route("api/draft/adjust-time-bank", "routes/api/draft.adjust-time-bank.ts"), route("api/draft/adjust-time-bank", "routes/api/draft.adjust-time-bank.ts"),
route("api/autodraft/update", "routes/api/autodraft.update.ts"), route("api/autodraft/update", "routes/api/autodraft.update.ts"),
route("api/user/timezone", "routes/api/user.timezone.ts"),
route("api/seasons/:seasonId/draft", "routes/api/seasons.$seasonId.draft.ts"), route("api/seasons/:seasonId/draft", "routes/api/seasons.$seasonId.draft.ts"),
route("login", "routes/login.tsx"), route("login", "routes/login.tsx"),
route("register", "routes/register.tsx"), route("register", "routes/register.tsx"),

View file

@ -0,0 +1,32 @@
import type { ActionFunctionArgs } from "react-router";
import { auth } from "~/lib/auth.server";
import { updateUser, isUserInActiveDraft } from "~/models/user";
import { TIMEZONE_OPTIONS } from "~/components/league/TimezoneSelect";
const VALID_TIMEZONES = new Set(
TIMEZONE_OPTIONS.flatMap((g) => g.zones.map((z) => z.value))
);
export async function action(args: ActionFunctionArgs) {
const session = await auth.api.getSession({ headers: args.request.headers });
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await args.request.formData();
const timezone = formData.get("timezone") as string | null;
if (!timezone || !VALID_TIMEZONES.has(timezone)) {
return Response.json({ error: "Invalid timezone" }, { status: 400 });
}
if (await isUserInActiveDraft(session.user.id)) {
return Response.json(
{ error: "Cannot change timezone during an active draft" },
{ status: 403 }
);
}
await updateUser(session.user.id, { timezone });
return Response.json({ success: true });
}

View file

@ -1,7 +1,7 @@
import { useLoaderData, Link, useRevalidator } from "react-router"; import { useLoaderData, Link, useRevalidator } from "react-router";
import { useDraftSocket } from "~/hooks/useDraftSocket"; import { useDraftSocket } from "~/hooks/useDraftSocket";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { auth } from "~/lib/auth.server"; import { auth } from "~/lib/auth.server";
@ -20,8 +20,9 @@ import { getDraftParticipants } from "~/models/participant";
import { getSeasonTimers } from "~/models/draft-timer"; import { getSeasonTimers } from "~/models/draft-timer";
import { getSeasonAutodraftSettings } from "~/models/autodraft-settings"; import { getSeasonAutodraftSettings } from "~/models/autodraft-settings";
import { hasCommissionerRecord } from "~/models/commissioner"; import { hasCommissionerRecord } from "~/models/commissioner";
import { findUsersByIds, getUserDisplayName } from "~/models/user";
import { isInOvernightWindow } from "~/lib/overnight-pause";
import logomarkUrl from "../../../public/logomark.svg?url"; import logomarkUrl from "../../../public/logomark.svg?url";
import { buildOwnerMap } from "~/lib/owner-map";
import { DraftSidebar } from "~/components/DraftSidebar"; import { DraftSidebar } from "~/components/DraftSidebar";
import { TeamRosterView } from "~/components/draft/TeamRosterView"; import { TeamRosterView } from "~/components/draft/TeamRosterView";
import { DraftSummaryView } from "~/components/draft/DraftSummaryView"; import { DraftSummaryView } from "~/components/draft/DraftSummaryView";
@ -113,7 +114,32 @@ export async function loader(args: Route.LoaderArgs) {
? (autodraftSettings.find((s) => s.teamId === userTeam.id) ?? null) ? (autodraftSettings.find((s) => s.teamId === userTeam.id) ?? null)
: null; : null;
const ownerMap = await buildOwnerMap(draftSlots); // Single users query builds both the owner display-name map and the timezone
// map for overnight pause moon icons — avoids two separate findUsersByIds calls.
const uniqueOwnerIds = [...new Set(
draftSlots.map((s) => s.team.ownerId).filter((id): id is string => id !== null)
)];
const userRows = uniqueOwnerIds.length ? await findUsersByIds(uniqueOwnerIds) : [];
const userById = new Map(userRows.map((u) => [u.id, u]));
const ownerMap: Record<string, string> = {};
for (const slot of draftSlots) {
if (slot.team.ownerId) {
const user = userById.get(slot.team.ownerId);
const name = user ? getUserDisplayName(user) : null;
if (name) ownerMap[slot.team.id] = name;
}
}
const teamTimezoneMap: Record<string, string | null> =
season.overnightPauseMode === "per_user"
? Object.fromEntries(
draftSlots.map((slot) => [
slot.team.id,
slot.team.ownerId ? (userById.get(slot.team.ownerId)?.timezone || null) : null,
])
)
: {};
return { return {
season, season,
@ -128,6 +154,7 @@ export async function loader(args: Route.LoaderArgs) {
isCommissioner: !!isCommissioner, isCommissioner: !!isCommissioner,
currentUserId: userId, currentUserId: userId,
ownerMap, ownerMap,
teamTimezoneMap,
}; };
} }
@ -145,6 +172,7 @@ export default function DraftRoom() {
isCommissioner, isCommissioner,
currentUserId, currentUserId,
ownerMap, ownerMap,
teamTimezoneMap,
} = useLoaderData<typeof loader>(); } = useLoaderData<typeof loader>();
const { revalidate, state: revalidatorState } = useRevalidator(); const { revalidate, state: revalidatorState } = useRevalidator();
const { isConnected, connectionError, isReconnecting, reconnectCount, socketVersion, on, off } = useDraftSocket(season.id, userTeam?.id); const { isConnected, connectionError, isReconnecting, reconnectCount, socketVersion, on, off } = useDraftSocket(season.id, userTeam?.id);
@ -203,6 +231,8 @@ export default function DraftRoom() {
timeBankUnit, setTimeBankUnit, timeBankUnit, setTimeBankUnit,
timeBankDirection, setTimeBankDirection, timeBankDirection, setTimeBankDirection,
isAdjustingTimeBank, setIsAdjustingTimeBank, isAdjustingTimeBank, setIsAdjustingTimeBank,
isOvernightPause, setIsOvernightPause,
overnightResumesAt, setOvernightResumesAt,
} = useDraftRoomState({ } = useDraftRoomState({
draftPicks, draftPicks,
season, season,
@ -214,6 +244,27 @@ export default function DraftRoom() {
userQueue, userQueue,
}); });
// Advances every second so isInOvernightWindow sees the real current time.
const [nowForPauseCheck, setNowForPauseCheck] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNowForPauseCheck(new Date()), 1_000);
return () => clearInterval(id);
}, []);
// Compute which teams are currently in their overnight pause window.
const pausedTeamIds = useMemo(() => {
const { overnightPauseMode: mode, overnightPauseStart: start, overnightPauseEnd: end, overnightPauseTimezone: fallbackTz } = season;
if (mode === "none" || !start || !end) return new Set<string>();
const result = new Set<string>();
for (const slot of draftSlots) {
const tz = mode === "league"
? (fallbackTz || null)
: (teamTimezoneMap[slot.team.id] || fallbackTz || null);
if (tz && isInOvernightWindow(tz, start, end, nowForPauseCheck)) result.add(slot.team.id);
}
return result;
}, [season, draftSlots, teamTimezoneMap, nowForPauseCheck]);
const { const {
authDegraded, authDegraded,
authFetch, authFetch,
@ -365,6 +416,8 @@ export default function DraftRoom() {
setConnectedTeams, setConnectedTeams,
setQueue, setQueue,
setTeamTimers, setTeamTimers,
setIsOvernightPause,
setOvernightResumesAt,
}); });
// Persist sidebar collapsed state // Persist sidebar collapsed state
@ -956,13 +1009,14 @@ export default function DraftRoom() {
connectedTeams, connectedTeams,
seasonStatus: season.status, seasonStatus: season.status,
draftPaused: isPaused, draftPaused: isPaused,
overnightPauseInfo: { isActive: isOvernightPause, resumesAt: overnightResumesAt, pausedTeamIds },
onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined, onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined,
onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined, onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined,
onForceAutopick: isCommissioner ? handleForceAutopick : undefined, onForceAutopick: isCommissioner ? handleForceAutopick : undefined,
onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined, onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined,
onReplacePick: isCommissioner ? handleReplacePickOpen : undefined, onReplacePick: isCommissioner ? handleReplacePickOpen : undefined,
onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined, onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined,
}), [draftSlots, draftGrid, currentPick, currentRound, ownerMap, teamTimers, autodraftStatus, connectedTeams, season.status, isPaused, isCommissioner, handleAdjustTimeBankOpen, handleSetAutodraftOpen, handleForceAutopick, handleForceManualPickOpen, handleReplacePickOpen, handleRollbackToPick, isDraftComplete]); }), [draftSlots, draftGrid, currentPick, currentRound, ownerMap, teamTimers, autodraftStatus, connectedTeams, season.status, isPaused, isOvernightPause, overnightResumesAt, pausedTeamIds, isCommissioner, handleAdjustTimeBankOpen, handleSetAutodraftOpen, handleForceAutopick, handleForceManualPickOpen, handleReplacePickOpen, handleRollbackToPick, isDraftComplete]);
const availableParticipantsSectionProps = useMemo(() => ({ const availableParticipantsSectionProps = useMemo(() => ({
participants: filteredParticipants, participants: filteredParticipants,
@ -1002,6 +1056,7 @@ export default function DraftRoom() {
ownerMap, ownerMap,
seasonStatus: season.status, seasonStatus: season.status,
draftPaused: isPaused, draftPaused: isPaused,
overnightPauseInfo: { isActive: isOvernightPause, resumesAt: overnightResumesAt, pausedTeamIds },
onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined, onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined,
onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined, onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined,
onForceAutopick: isCommissioner ? handleForceAutopick : undefined, onForceAutopick: isCommissioner ? handleForceAutopick : undefined,
@ -1145,12 +1200,20 @@ export default function DraftRoom() {
{season.status === "draft" && !isDraftComplete && currentDraftSlot && ( {season.status === "draft" && !isDraftComplete && currentDraftSlot && (
<div className={`md:hidden flex-shrink-0 flex items-center justify-between px-4 py-2 border-b ${ <div className={`md:hidden flex-shrink-0 flex items-center justify-between px-4 py-2 border-b ${
isMyTurn isMyTurn
? "bg-electric/25 border-electric" ? isOvernightPause
? "bg-blue-950/40 border-blue-800/40"
: "bg-electric/25 border-electric"
: "bg-muted/30" : "bg-muted/30"
}`}> }`}>
<div> <div>
<div className={`text-xs font-bold uppercase tracking-wider ${isMyTurn ? "text-electric" : "text-muted-foreground"}`}> <div className={`text-xs font-bold uppercase tracking-wider ${
{isMyTurn ? "Your Turn!" : "On the Clock"} isMyTurn
? isOvernightPause ? "text-blue-400" : "text-electric"
: "text-muted-foreground"
}`}>
{isMyTurn
? isOvernightPause ? "Overnight Pause" : "Your Turn!"
: "On the Clock"}
</div> </div>
<div className="text-sm font-bold"> <div className="text-sm font-bold">
{currentDraftSlotOwnerName ?? currentDraftSlot.team.name} {currentDraftSlotOwnerName ?? currentDraftSlot.team.name}
@ -1161,6 +1224,10 @@ export default function DraftRoom() {
</div> </div>
{isPaused ? ( {isPaused ? (
<span className="text-sm font-bold text-amber-accent">Paused</span> <span className="text-sm font-bold text-amber-accent">Paused</span>
) : isOvernightPause ? (
<span className={`text-xl font-mono font-bold tabular-nums ${currentClockColor}`}>
🌙 {formatClockTime(currentClockTime)}
</span>
) : ( ) : (
<span className={`text-xl font-mono font-bold tabular-nums ${currentClockColor}`}> <span className={`text-xl font-mono font-bold tabular-nums ${currentClockColor}`}>
{formatClockTime(currentClockTime)} {formatClockTime(currentClockTime)}
@ -1170,9 +1237,11 @@ export default function DraftRoom() {
)} )}
{/* Desktop Tab Navigation Row */} {/* Desktop Tab Navigation Row */}
<div className={`hidden md:flex flex-shrink-0 items-center justify-between px-4 py-2 border-b transition-all duration-300 ${ <div className={`hidden md:grid md:grid-cols-3 flex-shrink-0 items-center px-4 py-2 border-b transition-all duration-300 ${
isMyTurn && season.status === "draft" && !isDraftComplete isMyTurn && season.status === "draft" && !isDraftComplete
? "bg-electric/25 border-electric shadow-[0_0_24px_0_rgb(0_200_255_/_0.18)]" ? isOvernightPause
? "bg-blue-950/40 border-blue-800/40"
: "bg-electric/25 border-electric shadow-[0_0_24px_0_rgb(0_200_255_/_0.18)]"
: "bg-card" : "bg-card"
}`}> }`}>
<Tabs <Tabs
@ -1188,17 +1257,36 @@ export default function DraftRoom() {
<TabsTrigger value="summary">Summary</TabsTrigger> <TabsTrigger value="summary">Summary</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
{userTeam && ( <div className="flex justify-center">
<AutodraftBadgeWithPopover {isMyTurn && season.status === "draft" && !isDraftComplete && (
seasonId={season.id} isOvernightPause ? (
teamId={userTeam.id} <span className="text-sm text-blue-200 text-center leading-snug">
isEnabled={userAutodraft.isEnabled} 🌙 Overnight pause active timer frozen
mode={userAutodraft.mode} {overnightResumesAt && (
queueOnly={userAutodraft.queueOnly} <> until {overnightResumesAt.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}</>
isMyTurn={isMyTurn} )}. You can still pick.
onUpdate={handleAutodraftUpdate} </span>
/> ) : (
)} <span className="text-base font-bold tracking-wide text-electric">
It&apos;s your turn!
</span>
)
)}
</div>
<div className="flex justify-end">
{userTeam && (
<AutodraftBadgeWithPopover
seasonId={season.id}
teamId={userTeam.id}
isEnabled={userAutodraft.isEnabled}
mode={userAutodraft.mode}
queueOnly={userAutodraft.queueOnly}
isMyTurn={isMyTurn}
onUpdate={handleAutodraftUpdate}
showOvernightNote={season.overnightPauseMode !== "none"}
/>
)}
</div>
</div> </div>
{/* Main Content — single layout tree based on isMobile to avoid duplicate component instances */} {/* Main Content — single layout tree based on isMobile to avoid duplicate component instances */}

View file

@ -17,6 +17,7 @@ import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event"
import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match"; import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match";
import { getDraftedParticipantsBySportsSeason, getDraftedParticipantsWithPoints, type DraftedParticipantWithPoints } from "~/models/draft-pick"; import { getDraftedParticipantsBySportsSeason, getDraftedParticipantsWithPoints, type DraftedParticipantWithPoints } from "~/models/draft-pick";
import { getAuditLogForSeason } from "~/models/audit-log"; import { getAuditLogForSeason } from "~/models/audit-log";
import { findUserById } from "~/models/user";
import type { Route } from "./+types/$leagueId"; import type { Route } from "./+types/$leagueId";
export async function loader(args: Route.LoaderArgs) { export async function loader(args: Route.LoaderArgs) {
@ -205,9 +206,18 @@ export async function loader(args: Route.LoaderArgs) {
? await getAuditLogForSeason(season.id, { limit: 5 }) ? await getAuditLogForSeason(season.id, { limit: 5 })
: { entries: [], total: 0, hasMore: false }; : { entries: [], total: 0, hasMore: false };
// Show timezone banner in per_user overnight pause mode when user hasn't set their timezone
const showTimezoneBanner =
userId &&
season?.status === "pre_draft" &&
season?.overnightPauseMode === "per_user"
? !(await findUserById(userId))?.timezone
: false;
return { return {
league, league,
season, season,
showTimezoneBanner: !!showTimezoneBanner,
teams, teams,
commissioners, commissioners,
currentUserId: userId, currentUserId: userId,

View file

@ -68,6 +68,10 @@ import {
import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor"; import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor";
import { toast } from "sonner"; import { toast } from "sonner";
function isValidHHMM(s: string): boolean {
return /^\d{2}:\d{2}$/.test(s);
}
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Settings — ${data?.league?.name ?? "League"} - Brackt` }]; return [{ title: `Settings — ${data?.league?.name ?? "League"} - Brackt` }];
} }
@ -156,6 +160,8 @@ export async function loader(args: Route.LoaderArgs) {
name: o.name, name: o.name,
})); }));
const commishTimezone = (await findUserById(userId))?.timezone ?? null;
return { return {
league, league,
season, season,
@ -170,6 +176,7 @@ export async function loader(args: Route.LoaderArgs) {
ownerMap: Object.fromEntries(ownerMap), ownerMap: Object.fromEntries(ownerMap),
commissioners: commissionerUserData, commissioners: commissionerUserData,
currentUserId: userId, currentUserId: userId,
commishTimezone,
}; };
} }
@ -587,6 +594,28 @@ export async function action(args: Route.ActionArgs) {
seasonUpdates.draftTimerMode = draftTimerMode; seasonUpdates.draftTimerMode = draftTimerMode;
} }
// Overnight pause settings
const overnightPauseMode = formData.get("overnightPauseMode") as "none" | "league" | "per_user" | null;
if (overnightPauseMode && ["none", "league", "per_user"].includes(overnightPauseMode)) {
if (overnightPauseMode !== season.overnightPauseMode) {
seasonUpdates.overnightPauseMode = overnightPauseMode;
}
if (overnightPauseMode !== "none") {
const start = formData.get("overnightPauseStart") as string | null;
const end = formData.get("overnightPauseEnd") as string | null;
const tz = formData.get("overnightPauseTimezone") as string | null;
if (start && isValidHHMM(start) && start !== season.overnightPauseStart) {
seasonUpdates.overnightPauseStart = start;
}
if (end && isValidHHMM(end) && end !== season.overnightPauseEnd) {
seasonUpdates.overnightPauseEnd = end;
}
if (tz && tz.length > 0 && tz !== season.overnightPauseTimezone) {
seasonUpdates.overnightPauseTimezone = tz;
}
}
}
// Handle scoring rules (only if in pre_draft status and values changed) // Handle scoring rules (only if in pre_draft status and values changed)
if (season.status === "pre_draft") { if (season.status === "pre_draft") {
const scoringFields = [ const scoringFields = [
@ -823,10 +852,11 @@ export async function action(args: Route.ActionArgs) {
} }
export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) { export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) {
const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, allUsers, leagueMembers, ownerMap, commissioners, currentUserId } = loaderData; const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, allUsers, leagueMembers, ownerMap, commissioners, currentUserId, commishTimezone } = loaderData;
const navigation = useNavigation(); const navigation = useNavigation();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock"); const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock");
const [overnightMode, setOvernightMode] = useState<"none" | "league" | "per_user">(season?.overnightPauseMode ?? "none");
const [selectedSports, setSelectedSports] = useState<Set<string>>( const [selectedSports, setSelectedSports] = useState<Set<string>>(
new Set(season?.seasonSports?.map((s) => s.sportsSeason.id) || []) new Set(season?.seasonSports?.map((s) => s.sportsSeason.id) || [])
); );
@ -844,7 +874,6 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
? format(new Date(season.draftDateTime), "HH:mm") ? format(new Date(season.draftDateTime), "HH:mm")
: "" : ""
); );
// Update draft order when loader data changes (after randomization) // Update draft order when loader data changes (after randomization)
useEffect(() => { useEffect(() => {
const newOrder = draftSlots.length > 0 const newOrder = draftSlots.length > 0
@ -1239,6 +1268,137 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
</CardContent> </CardContent>
</Card> </Card>
{/* Overnight Pause */}
<Card>
<CardHeader>
<CardTitle>Overnight Pause</CardTitle>
<CardDescription>
Protect players from having their pick timer expire while they&apos;re asleep.
Autodraft will still fire for teams that have it enabled.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="overnightPauseMode">Pause Mode</Label>
<select
id="overnightPauseMode"
name="overnightPauseMode"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={overnightMode}
onChange={(e) => setOvernightMode(e.target.value as "none" | "league" | "per_user")}
disabled={!canEditDraftRounds}
>
<option value="none">No pause timer always runs</option>
<option value="league">League-wide one timezone for everyone</option>
<option value="per_user">Per-user each player&apos;s own timezone</option>
</select>
<p className="text-sm text-muted-foreground">
Per-user mode: each player sets their timezone in their profile. League-wide mode: one timezone applies to all teams.
</p>
{overnightMode === "per_user" && (
<div className={`rounded-md border px-3 py-2.5 text-sm space-y-1 ${commishTimezone ? "border-border bg-muted/40 text-muted-foreground" : "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400"}`}>
<p>Each player&apos;s profile timezone is used to determine their overnight window. Players without one set fall back to the timezone below.</p>
{!commishTimezone && (
<p>
You haven&apos;t set your timezone yet {" "}
<a href="/profile" className="font-medium underline underline-offset-2">
set it in your profile
</a>
.
</p>
)}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="overnightPauseStart">Pause starts at</Label>
<input
id="overnightPauseStart"
name="overnightPauseStart"
type="time"
defaultValue={season?.overnightPauseStart ?? "23:00"}
disabled={!canEditDraftRounds}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="overnightPauseEnd">Pause ends at</Label>
<input
id="overnightPauseEnd"
name="overnightPauseEnd"
type="time"
defaultValue={season?.overnightPauseEnd ?? "07:00"}
disabled={!canEditDraftRounds}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="overnightPauseTimezone">
Timezone
<span className="text-muted-foreground font-normal ml-1">
(league mode) or fallback for users without one set (per-user mode)
</span>
</Label>
<select
id="overnightPauseTimezone"
name="overnightPauseTimezone"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
defaultValue={season?.overnightPauseTimezone ?? commishTimezone ?? ""}
disabled={!canEditDraftRounds}
>
<option value="">Select a timezone</option>
<optgroup label="North America">
<option value="America/New_York">Eastern Time (ET)</option>
<option value="America/Chicago">Central Time (CT)</option>
<option value="America/Denver">Mountain Time (MT)</option>
<option value="America/Phoenix">Mountain Time Arizona (no DST)</option>
<option value="America/Los_Angeles">Pacific Time (PT)</option>
<option value="America/Anchorage">Alaska Time (AKT)</option>
<option value="Pacific/Honolulu">Hawaii Time (HT)</option>
<option value="America/Toronto">Eastern Time Toronto</option>
<option value="America/Vancouver">Pacific Time Vancouver</option>
<option value="America/Winnipeg">Central Time Winnipeg</option>
<option value="America/Halifax">Atlantic Time Halifax</option>
<option value="America/Mexico_City">Central Time Mexico City</option>
</optgroup>
<optgroup label="South America">
<option value="America/Sao_Paulo">Brasília Time (BRT)</option>
<option value="America/Argentina/Buenos_Aires">Argentina Time (ART)</option>
<option value="America/Bogota">Colombia Time (COT)</option>
<option value="America/Santiago">Chile Time (CLT)</option>
</optgroup>
<optgroup label="Europe">
<option value="Europe/London">GMT / British Time (GMT/BST)</option>
<option value="Europe/Dublin">Irish Time (GMT/IST)</option>
<option value="Europe/Paris">Central European Time (CET/CEST)</option>
<option value="Europe/Berlin">Central European Time Berlin</option>
<option value="Europe/Moscow">Moscow Time (MSK)</option>
</optgroup>
<optgroup label="Asia / Middle East">
<option value="Asia/Dubai">Gulf Standard Time (GST)</option>
<option value="Asia/Kolkata">India Standard Time (IST)</option>
<option value="Asia/Singapore">Singapore Time (SGT)</option>
<option value="Asia/Shanghai">China Standard Time (CST)</option>
<option value="Asia/Tokyo">Japan Standard Time (JST)</option>
<option value="Asia/Seoul">Korea Standard Time (KST)</option>
</optgroup>
<optgroup label="Oceania">
<option value="Australia/Sydney">Australian Eastern Time (AEST/AEDT)</option>
<option value="Australia/Perth">Australian Western Time (AWST)</option>
<option value="Pacific/Auckland">New Zealand Time (NZST/NZDT)</option>
</optgroup>
<optgroup label="UTC">
<option value="UTC">UTC (Coordinated Universal Time)</option>
</optgroup>
</select>
</div>
</CardContent>
</Card>
{/* Scoring Rules */} {/* Scoring Rules */}
<ScoringRulesEditor <ScoringRulesEditor
scoringRules={season ? { scoringRules={season ? {

View file

@ -13,6 +13,7 @@ import { SportsSeasonsSummary } from "~/components/league/SportsSeasonsSummary";
import { UpcomingEventsCard } from "~/components/sport-season/UpcomingEventsCard"; import { UpcomingEventsCard } from "~/components/sport-season/UpcomingEventsCard";
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display"; import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
import { StandingsPreview, type StandingsPreviewEntry } from "~/components/league/StandingsPreview"; import { StandingsPreview, type StandingsPreviewEntry } from "~/components/league/StandingsPreview";
import { TimezonePromptBanner } from "~/components/league/TimezonePromptBanner";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
@ -40,6 +41,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
origin, origin,
upcomingCalendarEvents, upcomingCalendarEvents,
recentActivity, recentActivity,
showTimezoneBanner,
} = loaderData; } = loaderData;
const myTeam = teams.find((t) => t.ownerId === currentUserId); const myTeam = teams.find((t) => t.ownerId === currentUserId);
@ -131,6 +133,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
return ( return (
<div className="container mx-auto py-8 px-4"> <div className="container mx-auto py-8 px-4">
{showTimezoneBanner && <TimezonePromptBanner />}
<div className="mb-8"> <div className="mb-8">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div> <div>
@ -200,6 +203,10 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`} draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`} draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
userDraftPosition={userDraftPosition} userDraftPosition={userDraftPosition}
overnightPauseMode={season.overnightPauseMode}
overnightPauseStart={season.overnightPauseStart}
overnightPauseEnd={season.overnightPauseEnd}
overnightPauseTimezone={season.overnightPauseTimezone}
/> />
)} )}

View file

@ -26,6 +26,10 @@ const createMockSeason = (overrides: {
currentPickNumber: null, currentPickNumber: null,
draftStartedAt: null, draftStartedAt: null,
draftPaused: false, draftPaused: false,
overnightPauseMode: 'none' as const,
overnightPauseStart: null,
overnightPauseEnd: null,
overnightPauseTimezone: null,
inviteCode: 'ABC123', inviteCode: 'ABC123',
pointsFor1st: 100, pointsFor1st: 100,
pointsFor2nd: 70, pointsFor2nd: 70,

View file

@ -56,6 +56,10 @@ const mockSeason = {
currentPickNumber: null, currentPickNumber: null,
draftStartedAt: null, draftStartedAt: null,
draftPaused: false, draftPaused: false,
overnightPauseMode: 'none' as const,
overnightPauseStart: null,
overnightPauseEnd: null,
overnightPauseTimezone: null,
inviteCode: 'TEST123', inviteCode: 'TEST123',
pointsFor1st: 100, pointsFor1st: 100,
pointsFor2nd: 70, pointsFor2nd: 70,

View file

@ -191,6 +191,7 @@ describe('Team Management - Admin User List', () => {
lastName: 'One', lastName: 'One',
imageUrl: null, imageUrl: null,
isAdmin: false, isAdmin: false,
timezone: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}, },
@ -205,6 +206,7 @@ describe('Team Management - Admin User List', () => {
lastName: 'Two', lastName: 'Two',
imageUrl: null, imageUrl: null,
isAdmin: false, isAdmin: false,
timezone: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}, },
@ -240,6 +242,7 @@ describe('Team Management - Admin User List', () => {
lastName: 'One', lastName: 'One',
imageUrl: null, imageUrl: null,
isAdmin: false, isAdmin: false,
timezone: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}, },

View file

@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { Form, redirect, Link } from "react-router"; import { Form, redirect, Link } from "react-router";
import { auth } from "~/lib/auth.server"; import { auth } from "~/lib/auth.server";
import type { Route } from "./+types/new"; import type { Route } from "./+types/new";
@ -11,6 +11,7 @@ import { linkMultipleSportsToSeason } from "~/models/season-sport";
import { createManyTeams } from "~/models/team"; import { createManyTeams } from "~/models/team";
import { findActiveSeasonTemplates, findSeasonTemplateWithSportsSeasons } from "~/models/season-template"; import { findActiveSeasonTemplates, findSeasonTemplateWithSportsSeasons } from "~/models/season-template";
import { findDraftableSportsSeasons } from "~/models/sports-season"; import { findDraftableSportsSeasons } from "~/models/sports-season";
import { findUserById } from "~/models/user";
import { generateUniqueTeamNames } from "~/utils/team-names"; import { generateUniqueTeamNames } from "~/utils/team-names";
import { format } from "date-fns"; import { format } from "date-fns";
import { CalendarIcon, Check } from "lucide-react"; import { CalendarIcon, Check } from "lucide-react";
@ -42,16 +43,19 @@ import {
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { parseDraftSpeed } from "~/lib/draft-timer"; import { parseDraftSpeed } from "~/lib/draft-timer";
import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor"; import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor";
import { TimezoneSelect } from "~/components/league/TimezoneSelect";
export function meta(): Route.MetaDescriptors { export function meta(): Route.MetaDescriptors {
return [{ title: "New League - Brackt" }]; return [{ title: "New League - Brackt" }];
} }
export async function loader() { export async function loader(args: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
const templates = await findActiveSeasonTemplates(); const templates = await findActiveSeasonTemplates();
const allSportsSeasons = await findDraftableSportsSeasons(); const allSportsSeasons = await findDraftableSportsSeasons();
// Fetch templates with their sports seasons for mapping
const templatesWithSports = await Promise.all( const templatesWithSports = await Promise.all(
templates.map(async (template) => { templates.map(async (template) => {
const templateWithSports = await findSeasonTemplateWithSportsSeasons(template.id); const templateWithSports = await findSeasonTemplateWithSportsSeasons(template.id);
@ -64,9 +68,14 @@ export async function loader() {
}) })
); );
const commishTimezone = userId
? ((await findUserById(userId))?.timezone ?? null)
: null;
return { return {
templates: templatesWithSports, templates: templatesWithSports,
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }> allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>,
commishTimezone,
}; };
} }
@ -88,11 +97,9 @@ export async function action(args: Route.ActionArgs) {
const draftSpeed = formData.get("draftSpeed"); const draftSpeed = formData.get("draftSpeed");
const draftTimerMode = (formData.get("draftTimerMode") as "chess_clock" | "standard") || "chess_clock"; const draftTimerMode = (formData.get("draftTimerMode") as "chess_clock" | "standard") || "chess_clock";
// Map draft speed to time values
const { draftInitialTime: draftInitialTimeNum, draftIncrementTime: draftIncrementTimeNum } = const { draftInitialTime: draftInitialTimeNum, draftIncrementTime: draftIncrementTimeNum } =
parseDraftSpeed(draftSpeed as string | null, draftTimerMode); parseDraftSpeed(draftSpeed as string | null, draftTimerMode);
// Validation
if (typeof name !== "string" || !name.trim()) { if (typeof name !== "string" || !name.trim()) {
return { error: "League name is required" }; return { error: "League name is required" };
} }
@ -111,7 +118,6 @@ export async function action(args: Route.ActionArgs) {
return { error: "Draft rounds must be between 1 and 50" }; return { error: "Draft rounds must be between 1 and 50" };
} }
// Extract and validate scoring rules
const scoringFields = [ const scoringFields = [
"pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th", "pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
"pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th" "pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th"
@ -131,20 +137,30 @@ export async function action(args: Route.ActionArgs) {
} }
} }
const rawPauseMode = formData.get("overnightPauseMode") as string | null;
const overnightPauseMode: "none" | "league" | "per_user" =
rawPauseMode === "league" || rawPauseMode === "per_user" ? rawPauseMode : "none";
const overnightPauseStart = overnightPauseMode !== "none"
? (formData.get("overnightPauseStart") as string | null) ?? "23:00"
: null;
const overnightPauseEnd = overnightPauseMode !== "none"
? (formData.get("overnightPauseEnd") as string | null) ?? "07:00"
: null;
const overnightPauseTimezone = overnightPauseMode !== "none"
? (formData.get("overnightPauseTimezone") as string | null) ?? null
: null;
try { try {
// Create league
const league = await createLeague({ const league = await createLeague({
name: name.trim(), name: name.trim(),
createdBy: userId, createdBy: userId,
}); });
// Make creator a commissioner
await createCommissioner({ await createCommissioner({
leagueId: league.id, leagueId: league.id,
userId: userId, userId: userId,
}); });
// Create first season
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const season = await createSeason({ const season = await createSeason({
leagueId: league.id, leagueId: league.id,
@ -156,16 +172,17 @@ export async function action(args: Route.ActionArgs) {
draftInitialTime: draftInitialTimeNum, draftInitialTime: draftInitialTimeNum,
draftIncrementTime: draftIncrementTimeNum, draftIncrementTime: draftIncrementTimeNum,
draftTimerMode, draftTimerMode,
overnightPauseMode,
overnightPauseStart,
overnightPauseEnd,
overnightPauseTimezone,
...scoringRules, ...scoringRules,
}); });
// Set this as the current season for the league
await setCurrentSeason(league.id, season.id); await setCurrentSeason(league.id, season.id);
// Add selected sports to the season
const selectedSports = formData.getAll("sportsSeasons"); const selectedSports = formData.getAll("sportsSeasons");
// Validate that draft rounds is at least the number of sports
if (selectedSports.length > draftRoundsNum) { if (selectedSports.length > draftRoundsNum) {
return { return {
error: `You need at least ${selectedSports.length} draft rounds for the ${selectedSports.length} sports selected. Currently set to ${draftRoundsNum} rounds.` error: `You need at least ${selectedSports.length} draft rounds for the ${selectedSports.length} sports selected. Currently set to ${draftRoundsNum} rounds.`
@ -181,7 +198,6 @@ export async function action(args: Route.ActionArgs) {
); );
} }
// Create teams with fun random names
const joinAsPlayer = formData.get("joinAsPlayer") === "on"; const joinAsPlayer = formData.get("joinAsPlayer") === "on";
const teamNames = generateUniqueTeamNames(teamCountNum); const teamNames = generateUniqueTeamNames(teamCountNum);
const teams = teamNames.map((teamName, i) => ({ const teams = teamNames.map((teamName, i) => ({
@ -192,7 +208,6 @@ export async function action(args: Route.ActionArgs) {
await createManyTeams(teams); await createManyTeams(teams);
// Redirect to league homepage
return redirect(`/leagues/${league.id}`); return redirect(`/leagues/${league.id}`);
} catch (error) { } catch (error) {
logger.error("Error creating league:", error); logger.error("Error creating league:", error);
@ -201,13 +216,22 @@ export async function action(args: Route.ActionArgs) {
} }
export default function NewLeague({ loaderData, actionData }: Route.ComponentProps) { export default function NewLeague({ loaderData, actionData }: Route.ComponentProps) {
const { templates, allSportsSeasons } = loaderData; const { templates, allSportsSeasons, commishTimezone } = loaderData;
const [selectedTemplate, setSelectedTemplate] = useState<string>(""); const [selectedTemplate, setSelectedTemplate] = useState<string>("");
const [selectedSports, setSelectedSports] = useState<Set<string>>(new Set()); const [selectedSports, setSelectedSports] = useState<Set<string>>(new Set());
const [draftRounds, setDraftRounds] = useState<number>(20); const [draftRounds, setDraftRounds] = useState<number>(20);
const [draftDate, setDraftDate] = useState<Date>(); const [draftDate, setDraftDate] = useState<Date>();
const [draftTime, setDraftTime] = useState<string>(""); const [draftTime, setDraftTime] = useState<string>("");
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">("chess_clock"); const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">("chess_clock");
const [overnightMode, setOvernightMode] = useState<"none" | "league" | "per_user">("none");
const [overnightTimezone, setOvernightTimezone] = useState<string>(commishTimezone ?? "");
useEffect(() => {
if (!overnightTimezone) {
const detected = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (detected) setOvernightTimezone(detected);
}
}, [overnightTimezone]);
const handleSportToggle = (sportId: string) => { const handleSportToggle = (sportId: string) => {
const newSelected = new Set(selectedSports); const newSelected = new Set(selectedSports);
@ -224,7 +248,6 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
setSelectedSports(new Set(sportsSeasonIds)); setSelectedSports(new Set(sportsSeasonIds));
}; };
// Calculate flex spots and minimum rounds
const sportsCount = selectedSports.size; const sportsCount = selectedSports.size;
const minRounds = sportsCount; const minRounds = sportsCount;
const recommendedRounds = Math.ceil(sportsCount * 1.25); const recommendedRounds = Math.ceil(sportsCount * 1.25);
@ -425,6 +448,89 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
</p> </p>
</div> </div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="overnightPauseMode">Overnight Pause</Label>
<select
id="overnightPauseMode"
name="overnightPauseMode"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={overnightMode}
onChange={(e) => setOvernightMode(e.target.value as "none" | "league" | "per_user")}
>
<option value="none">No pause timer always runs</option>
<option value="league">League-wide one timezone for everyone</option>
<option value="per_user">Per-user each player&apos;s own timezone</option>
</select>
<p className="text-sm text-muted-foreground">
Protect players from having their pick timer expire while they&apos;re asleep. Autodraft still fires normally.
</p>
</div>
{overnightMode === "per_user" && (
<div className={`rounded-md border px-3 py-2.5 text-sm space-y-1 ${commishTimezone ? "border-border bg-muted/40 text-muted-foreground" : "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400"}`}>
<p>Each player&apos;s profile timezone is used to determine their overnight window. Players without one set fall back to the timezone below.</p>
{!commishTimezone && (
<p>
You haven&apos;t set your timezone yet {" "}
<a href="/profile" className="font-medium underline underline-offset-2">
set it in your profile
</a>
.
</p>
)}
</div>
)}
{overnightMode !== "none" && (
<>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="overnightPauseStart">Pause starts at</Label>
<input
id="overnightPauseStart"
name="overnightPauseStart"
type="time"
defaultValue="23:00"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="space-y-2">
<Label htmlFor="overnightPauseEnd">Pause ends at</Label>
<input
id="overnightPauseEnd"
name="overnightPauseEnd"
type="time"
defaultValue="07:00"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="overnightPauseTimezone">
Timezone
{overnightMode === "per_user" && (
<span className="text-muted-foreground font-normal ml-1">
(fallback for users without one set)
</span>
)}
</Label>
<TimezoneSelect
value={overnightTimezone}
onChange={setOvernightTimezone}
name="overnightPauseTimezone"
/>
{overnightMode === "per_user" && (
<p className="text-sm text-muted-foreground">
Each player sets their timezone in their profile. This is used as a fallback if they haven&apos;t set one.
</p>
)}
</div>
</>
)}
</div>
<ScoringRulesEditor disabled={false} /> <ScoringRulesEditor disabled={false} />
<div className="space-y-4"> <div className="space-y-4">
@ -435,7 +541,6 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
<input type="hidden" name="templateId" value={selectedTemplate} /> <input type="hidden" name="templateId" value={selectedTemplate} />
{/* Template Cards */}
{templates.length > 0 && ( {templates.length > 0 && (
<div className="grid gap-3"> <div className="grid gap-3">
{templates.map((template) => ( {templates.map((template) => (
@ -471,7 +576,6 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
</div> </div>
)} )}
{/* Sports Checkboxes */}
<div className="space-y-3"> <div className="space-y-3">
<div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3"> <div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View file

@ -1,11 +1,17 @@
import { redirect, Form } from "react-router"; import { redirect, Form } from "react-router";
import { useEffect, useState } from "react";
import { auth } from "~/lib/auth.server"; import { auth } from "~/lib/auth.server";
import { findUserById, updateUser } from "~/models/user"; import { findUserById, updateUser, isUserInActiveDraft } from "~/models/user";
import type { Route } from "./+types/user-profile"; import type { Route } from "./+types/user-profile";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { TimezoneSelect, TIMEZONE_OPTIONS } from "~/components/league/TimezoneSelect";
const VALID_TIMEZONES = new Set(
TIMEZONE_OPTIONS.flatMap((g) => g.zones.map((z) => z.value))
);
export function meta(): Route.MetaDescriptors { export function meta(): Route.MetaDescriptors {
return [{ title: "Profile - Brackt" }]; return [{ title: "Profile - Brackt" }];
@ -20,7 +26,8 @@ export async function loader(args: Route.LoaderArgs) {
if (!user) { if (!user) {
return redirect("/"); return redirect("/");
} }
return { user }; const isInActiveDraft = await isUserInActiveDraft(session.user.id);
return { user, isInActiveDraft };
} }
export async function action(args: Route.ActionArgs) { export async function action(args: Route.ActionArgs) {
@ -34,19 +41,39 @@ export async function action(args: Route.ActionArgs) {
const username = formData.get("username") as string | null; const username = formData.get("username") as string | null;
const firstName = formData.get("firstName") as string | null; const firstName = formData.get("firstName") as string | null;
const lastName = formData.get("lastName") as string | null; const lastName = formData.get("lastName") as string | null;
const timezone = formData.get("timezone") as string | null;
const timezoneValue =
timezone && VALID_TIMEZONES.has(timezone) ? timezone : undefined;
await updateUser(session.user.id, { await updateUser(session.user.id, {
displayName: displayName || undefined, displayName: displayName || undefined,
username: username || undefined, username: username || undefined,
firstName: firstName || undefined, firstName: firstName || undefined,
lastName: lastName || undefined, lastName: lastName || undefined,
timezone: timezoneValue,
}); });
return { success: true }; return { success: true };
} }
export default function UserProfilePage({ loaderData, actionData }: Route.ComponentProps) { export default function UserProfilePage({ loaderData, actionData }: Route.ComponentProps) {
const { user } = loaderData; const { user, isInActiveDraft } = loaderData;
const [timezone, setTimezone] = useState(user.timezone ?? "");
// Auto-detect browser timezone if not already set
useEffect(() => {
if (!user.timezone) {
try {
const detected = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (detected && VALID_TIMEZONES.has(detected)) {
setTimezone(detected);
}
} catch {
// Intl not available — leave blank
}
}
}, [user.timezone]);
return ( return (
<div className="container mx-auto py-8 px-4 max-w-lg"> <div className="container mx-auto py-8 px-4 max-w-lg">
@ -97,6 +124,27 @@ export default function UserProfilePage({ loaderData, actionData }: Route.Compon
<Label>Email</Label> <Label>Email</Label>
<p className="text-sm text-muted-foreground">{user.email}</p> <p className="text-sm text-muted-foreground">{user.email}</p>
</div> </div>
<div className="space-y-2">
<Label htmlFor="timezone">Timezone</Label>
{/* Hidden input carries the controlled value into the form submission */}
<input type="hidden" name="timezone" value={timezone} />
<TimezoneSelect
value={timezone}
onChange={setTimezone}
disabled={isInActiveDraft}
/>
{isInActiveDraft ? (
<p className="text-xs text-muted-foreground">
Timezone cannot be changed while you are in an active draft.
</p>
) : (
<p className="text-xs text-muted-foreground">
Used for overnight pick protection. We pre-filled your browser&apos;s detected timezone.
</p>
)}
</div>
<Button type="submit" className="w-full">Save Changes</Button> <Button type="submit" className="w-full">Save Changes</Button>
</Form> </Form>
</CardContent> </CardContent>

View file

@ -12,6 +12,7 @@ export const users = pgTable("users", {
lastName: varchar("last_name", { length: 255 }), lastName: varchar("last_name", { length: 255 }),
imageUrl: varchar("image_url", { length: 512 }), // mapped as BetterAuth's "image" field imageUrl: varchar("image_url", { length: 512 }), // mapped as BetterAuth's "image" field
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"
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}); });
@ -107,6 +108,12 @@ export const draftTimerModeEnum = pgEnum("draft_timer_mode", [
"standard", "standard",
]); ]);
export const overnightPauseModeEnum = pgEnum("overnight_pause_mode", [
"none",
"league",
"per_user",
]);
export const probabilitySourceEnum = pgEnum("probability_source", [ export const probabilitySourceEnum = pgEnum("probability_source", [
"manual", "manual",
"futures_odds", "futures_odds",
@ -188,6 +195,10 @@ export const seasons = pgTable("seasons", {
currentPickNumber: integer("current_pick_number").default(1), currentPickNumber: integer("current_pick_number").default(1),
draftStartedAt: timestamp("draft_started_at"), draftStartedAt: timestamp("draft_started_at"),
draftPaused: boolean("draft_paused").notNull().default(false), draftPaused: boolean("draft_paused").notNull().default(false),
overnightPauseMode: overnightPauseModeEnum("overnight_pause_mode").notNull().default("none"),
overnightPauseStart: varchar("overnight_pause_start", { length: 5 }), // "23:00"
overnightPauseEnd: varchar("overnight_pause_end", { length: 5 }), // "07:00"
overnightPauseTimezone: varchar("overnight_pause_timezone", { length: 50 }), // IANA — league TZ (league mode) or per-user fallback
inviteCode: varchar("invite_code", { length: 20 }).notNull().unique(), inviteCode: varchar("invite_code", { length: 20 }).notNull().unique(),
// Scoring configuration (8 values for 1st through 8th) // Scoring configuration (8 values for 1st through 8th)
pointsFor1st: integer("points_for_1st").notNull().default(100), pointsFor1st: integer("points_for_1st").notNull().default(100),

View file

@ -0,0 +1,6 @@
CREATE TYPE "public"."overnight_pause_mode" AS ENUM('none', 'league', 'per_user');--> statement-breakpoint
ALTER TABLE "seasons" ADD COLUMN "overnight_pause_mode" "overnight_pause_mode" DEFAULT 'none' NOT NULL;--> statement-breakpoint
ALTER TABLE "seasons" ADD COLUMN "overnight_pause_start" varchar(5);--> statement-breakpoint
ALTER TABLE "seasons" ADD COLUMN "overnight_pause_end" varchar(5);--> statement-breakpoint
ALTER TABLE "seasons" ADD COLUMN "overnight_pause_timezone" varchar(50);--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "timezone" varchar(50);

File diff suppressed because it is too large Load diff

View file

@ -582,6 +582,13 @@
"when": 1777090754037, "when": 1777090754037,
"tag": "0082_stale_pride", "tag": "0082_stale_pride",
"breakpoints": true "breakpoints": true
},
{
"idx": 83,
"version": "7",
"when": 1777180952144,
"tag": "0083_fearless_leader",
"breakpoints": true
} }
] ]
} }

View file

@ -3,6 +3,7 @@ import { eq, and, asc, sql } from "drizzle-orm";
import type { InferSelectModel } from "drizzle-orm"; import type { InferSelectModel } from "drizzle-orm";
import { getSocketIO } from "./socket"; import { getSocketIO } from "./socket";
import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils"; import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils";
import { isInOvernightWindow, getOvernightResumeUTC } from "~/lib/overnight-pause";
import { logger } from "./logger"; import { logger } from "./logger";
import { db } from "./db"; import { db } from "./db";
@ -13,6 +14,63 @@ let timerTickRunning = false;
// a redundant query on every tick. // a redundant query on every tick.
const draftSlotsCache = new Map<string, { teamId: string; draftOrder: number }[]>(); const draftSlotsCache = new Map<string, { teamId: string; draftOrder: number }[]>();
// Team timezone cache for per_user overnight pause mode (seasonId → teamId → timezone).
// Populated lazily; evicted when the season leaves active drafting.
const teamTimezoneCache = new Map<string, Map<string, string | null>>();
async function getTeamTimezone(seasonId: string, teamId: string): Promise<string | null> {
let seasonMap = teamTimezoneCache.get(seasonId);
if (!seasonMap) {
try {
const rows = await db
.select({ teamId: schema.teams.id, timezone: schema.users.timezone })
.from(schema.teams)
.leftJoin(schema.users, sql`${schema.teams.ownerId}::uuid = ${schema.users.id}`)
.where(eq(schema.teams.seasonId, seasonId));
seasonMap = new Map(rows.map((r) => [r.teamId, r.timezone || null]));
} catch (err) {
// ownerId column is varchar; if any row still holds a non-UUID value
// (pre-migration remnant) the ::uuid cast throws. Degrade gracefully.
logger.error("[Timer] getTeamTimezone cast failed, skipping overnight check:", err);
seasonMap = new Map();
}
teamTimezoneCache.set(seasonId, seasonMap);
}
return seasonMap.get(teamId) ?? null;
}
/**
* Determine whether the current team's pick is in an overnight pause window.
* Returns { active: false } or { active: true, resumesAtUTC: number }.
*/
async function checkOvernightPause(
season: InferSelectModel<typeof schema.seasons>,
currentTeamId: string
): Promise<{ active: boolean; resumesAtUTC?: number }> {
const mode = season.overnightPauseMode;
const start = season.overnightPauseStart;
const end = season.overnightPauseEnd;
if (mode === "none" || !start || !end) return { active: false };
let tz: string | null = null;
if (mode === "league") {
tz = season.overnightPauseTimezone || null;
} else {
// per_user: use the team owner's timezone, fall back to league timezone
tz = await getTeamTimezone(season.id, currentTeamId);
if (!tz) tz = season.overnightPauseTimezone || null;
}
if (!tz) return { active: false };
if (isInOvernightWindow(tz, start, end)) {
const resumesAt = getOvernightResumeUTC(tz, end);
return { active: true, resumesAtUTC: resumesAt.getTime() };
}
return { active: false };
}
/** /**
* Start the draft timer system * Start the draft timer system
* Runs every second to update all active draft timers * Runs every second to update all active draft timers
@ -73,6 +131,9 @@ async function updateDraftTimers(): Promise<void> {
for (const cachedId of draftSlotsCache.keys()) { for (const cachedId of draftSlotsCache.keys()) {
if (!activeIds.has(cachedId)) draftSlotsCache.delete(cachedId); if (!activeIds.has(cachedId)) draftSlotsCache.delete(cachedId);
} }
for (const cachedId of teamTimezoneCache.keys()) {
if (!activeIds.has(cachedId)) teamTimezoneCache.delete(cachedId);
}
for (const season of activeDrafts) { for (const season of activeDrafts) {
// Skip if draft is paused // Skip if draft is paused
@ -153,6 +214,10 @@ async function updateDraftTimers(): Promise<void> {
// while_on means "pick immediately when it's my turn" — bypass the countdown. // while_on means "pick immediately when it's my turn" — bypass the countdown.
const isWhileOn = shouldAutodraft && autodraftSettings?.mode === "while_on"; const isWhileOn = shouldAutodraft && autodraftSettings?.mode === "while_on";
// Overnight pause: freeze timer for non-autodraft teams in their overnight window.
const overnightPause = await checkOvernightPause(season, currentTeamId);
const isOvernightFreeze = overnightPause.active && !shouldAutodraft;
// Trigger pick when: timer expired OR team is in while_on autodraft mode. // Trigger pick when: timer expired OR team is in while_on autodraft mode.
// The chain picks consecutive while_on teams after each pick, but it is capped at // The chain picks consecutive while_on teams after each pick, but it is capped at
// totalTeams iterations. The while_on bypass here fills the gap: the timer picks // totalTeams iterations. The while_on bypass here fills the gap: the timer picks
@ -174,6 +239,7 @@ async function updateDraftTimers(): Promise<void> {
teamId: currentTeamId, teamId: currentTeamId,
timeRemaining: 0, timeRemaining: 0,
currentPickNumber, currentPickNumber,
overnightPauseActive: false,
}); });
} }
@ -188,6 +254,19 @@ async function updateDraftTimers(): Promise<void> {
continue; continue;
} }
// Overnight freeze: skip decrement, notify clients to display the pause indicator.
if (isOvernightFreeze) {
io.to(`draft-${season.id}`).emit("timer-update", {
seasonId: season.id,
teamId: currentTeamId,
timeRemaining: timer.timeRemaining,
currentPickNumber,
overnightPauseActive: true,
resumesAtUTC: overnightPause.resumesAtUTC,
});
continue;
}
// Atomically decrement timer (race-condition safe: uses DB-level update // Atomically decrement timer (race-condition safe: uses DB-level update
// so concurrent increments from pick handlers are never overwritten) // so concurrent increments from pick handlers are never overwritten)
const [updatedTimer] = await db const [updatedTimer] = await db
@ -207,6 +286,7 @@ async function updateDraftTimers(): Promise<void> {
teamId: currentTeamId, teamId: currentTeamId,
timeRemaining: newTimeRemaining, timeRemaining: newTimeRemaining,
currentPickNumber, currentPickNumber,
overnightPauseActive: false,
}); });
// If timer just hit 0, trigger auto-pick (next_pick mode or no autodraft) // If timer just hit 0, trigger auto-pick (next_pick mode or no autodraft)