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:
parent
1bf65e33f7
commit
dbc23f14ce
29 changed files with 6362 additions and 103 deletions
|
|
@ -94,7 +94,6 @@ interface CompactAutodraftBadgeProps {
|
|||
isEnabled: boolean;
|
||||
mode: AutodraftMode;
|
||||
queueOnly: boolean;
|
||||
isMyTurn: boolean;
|
||||
showChevron?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -102,7 +101,6 @@ export function CompactAutodraftBadge({
|
|||
isEnabled,
|
||||
mode,
|
||||
queueOnly,
|
||||
isMyTurn,
|
||||
showChevron = false,
|
||||
}: CompactAutodraftBadgeProps) {
|
||||
const state = toAutodraftState(isEnabled, mode, queueOnly);
|
||||
|
|
@ -122,9 +120,6 @@ export function CompactAutodraftBadge({
|
|||
{label}
|
||||
{showChevron && <ChevronDown className="h-3 w-3" />}
|
||||
</span>
|
||||
{isMyTurn && (
|
||||
<span className="text-xs text-amber-accent font-medium">Your turn!</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -137,6 +132,7 @@ interface AutodraftBadgeWithPopoverProps {
|
|||
queueOnly: boolean;
|
||||
isMyTurn: boolean;
|
||||
onUpdate: (isEnabled: boolean, mode: AutodraftMode, queueOnly: boolean) => void;
|
||||
showOvernightNote?: boolean;
|
||||
}
|
||||
|
||||
function AutodraftOptions({
|
||||
|
|
@ -243,6 +239,7 @@ export function AutodraftBadgeWithPopover({
|
|||
queueOnly,
|
||||
isMyTurn,
|
||||
onUpdate,
|
||||
showOvernightNote = false,
|
||||
}: AutodraftBadgeWithPopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
|
|
@ -258,7 +255,6 @@ export function AutodraftBadgeWithPopover({
|
|||
isEnabled={isEnabled}
|
||||
mode={mode}
|
||||
queueOnly={queueOnly}
|
||||
isMyTurn={isMyTurn}
|
||||
showChevron
|
||||
/>
|
||||
</button>
|
||||
|
|
@ -299,6 +295,11 @@ export function AutodraftBadgeWithPopover({
|
|||
);
|
||||
})}
|
||||
</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>
|
||||
</Popover>
|
||||
</div>
|
||||
|
|
@ -313,6 +314,7 @@ interface AutodraftSettingsProps {
|
|||
queueOnly: boolean;
|
||||
isMyTurn: boolean;
|
||||
onUpdate: (isEnabled: boolean, mode: AutodraftMode, queueOnly: boolean) => void;
|
||||
showOvernightNote?: boolean;
|
||||
}
|
||||
|
||||
export function AutodraftSettings({
|
||||
|
|
@ -323,6 +325,7 @@ export function AutodraftSettings({
|
|||
queueOnly,
|
||||
isMyTurn,
|
||||
onUpdate,
|
||||
showOvernightNote,
|
||||
}: AutodraftSettingsProps) {
|
||||
const [localState, setLocalState] = useState<AutodraftState>(
|
||||
toAutodraftState(isEnabled, mode, queueOnly)
|
||||
|
|
@ -440,6 +443,11 @@ export function AutodraftSettings({
|
|||
{isMyTurn && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ interface DraftGridSectionProps {
|
|||
isCommissioner: boolean;
|
||||
seasonStatus?: SeasonStatus;
|
||||
draftPaused?: boolean;
|
||||
overnightPauseInfo?: { isActive: boolean; resumesAt: Date | null; pausedTeamIds?: Set<string> };
|
||||
onAdjustTimeBankOpen?: (teamId: string) => void;
|
||||
onSetAutodraftOpen?: (teamId: string) => void;
|
||||
onForceAutopick?: (pickNumber: number, teamId: string) => void;
|
||||
|
|
@ -75,6 +76,7 @@ export const DraftGridSection = memo(function DraftGridSection({
|
|||
isCommissioner,
|
||||
seasonStatus,
|
||||
draftPaused,
|
||||
overnightPauseInfo,
|
||||
onAdjustTimeBankOpen,
|
||||
onSetAutodraftOpen,
|
||||
onForceAutopick,
|
||||
|
|
@ -90,6 +92,8 @@ export const DraftGridSection = memo(function DraftGridSection({
|
|||
[draftGrid, currentPick]
|
||||
);
|
||||
|
||||
const isOvernightActive = overnightPauseInfo?.isActive ?? false;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-4">
|
||||
<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 isAutodraft = autodraftStatus[slot.team.id]?.isEnabled || false;
|
||||
const isConnected = connectedTeams.has(slot.team.id);
|
||||
const isCurrentTeamOvernight = slot.team.id === currentTeamId
|
||||
? isOvernightActive
|
||||
: (overnightPauseInfo?.pausedTeamIds?.has(slot.team.id) ?? false);
|
||||
|
||||
const headerInner = (
|
||||
<div className="relative">
|
||||
|
|
@ -127,9 +134,9 @@ export const DraftGridSection = memo(function DraftGridSection({
|
|||
<span className="truncate">{ownerMap[slot.team.id] || slot.team.name}</span>
|
||||
</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>
|
||||
{isCommissioner && (onAdjustTimeBankOpen || onSetAutodraftOpen) && (
|
||||
<button
|
||||
|
|
@ -228,6 +235,8 @@ export const DraftGridSection = memo(function DraftGridSection({
|
|||
coronaState={pendingCorona}
|
||||
seasonStatus={seasonStatus}
|
||||
draftPaused={draftPaused}
|
||||
isOvernightPause={isCurrent && isOvernightActive}
|
||||
resumesAt={isCurrent && isOvernightActive && overnightPauseInfo?.resumesAt ? overnightPauseInfo.resumesAt : undefined}
|
||||
className="cursor-context-menu"
|
||||
>
|
||||
{mobileButtons}
|
||||
|
|
@ -301,6 +310,8 @@ export const DraftGridSection = memo(function DraftGridSection({
|
|||
coronaState={pendingCorona}
|
||||
seasonStatus={seasonStatus}
|
||||
draftPaused={draftPaused}
|
||||
isOvernightPause={isCurrent && isOvernightActive}
|
||||
resumesAt={isCurrent && isOvernightActive && overnightPauseInfo?.resumesAt ? overnightPauseInfo.resumesAt : undefined}
|
||||
>
|
||||
{mobileButtons}
|
||||
</DraftPickCell>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ type DraftPickCellProps = ComponentPropsWithoutRef<"div"> & {
|
|||
coronaState?: CoronaState;
|
||||
seasonStatus?: SeasonStatus;
|
||||
draftPaused?: boolean;
|
||||
isOvernightPause?: boolean;
|
||||
resumesAt?: Date;
|
||||
};
|
||||
|
||||
const coronaClasses: Record<CoronaVariant, string> = {
|
||||
|
|
@ -60,6 +62,8 @@ function DraftPickCell({
|
|||
coronaState,
|
||||
seasonStatus,
|
||||
draftPaused,
|
||||
isOvernightPause,
|
||||
resumesAt,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
|
|
@ -70,6 +74,8 @@ function DraftPickCell({
|
|||
let currentLabel = "On The Clock";
|
||||
if (seasonStatus === "pre_draft") {
|
||||
currentLabel = "Up Next";
|
||||
} else if (seasonStatus === "draft" && isOvernightPause) {
|
||||
currentLabel = "🌙 Overnight Pause";
|
||||
} else if (seasonStatus === "draft" && draftPaused) {
|
||||
currentLabel = "Paused Draft";
|
||||
}
|
||||
|
|
@ -136,7 +142,14 @@ function DraftPickCell({
|
|||
</div>
|
||||
</>
|
||||
) : isCurrent ? (
|
||||
<>
|
||||
<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}
|
||||
</div>
|
||||
{children && <div className="absolute inset-0 pointer-events-none [&_*]:pointer-events-auto">{children}</div>}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export interface MiniDraftGridProps {
|
|||
connectedTeams?: Set<string>;
|
||||
seasonStatus?: SeasonStatus;
|
||||
draftPaused?: boolean;
|
||||
overnightPauseInfo?: { isActive: boolean; resumesAt: Date | null; pausedTeamIds?: Set<string> };
|
||||
onAdjustTimeBankOpen?: (teamId: string) => void;
|
||||
onSetAutodraftOpen?: (teamId: string) => void;
|
||||
onForceAutopick?: (pickNumber: number, teamId: string) => void;
|
||||
|
|
@ -70,6 +71,7 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
|
|||
connectedTeams,
|
||||
seasonStatus,
|
||||
draftPaused,
|
||||
overnightPauseInfo,
|
||||
onAdjustTimeBankOpen,
|
||||
onSetAutodraftOpen,
|
||||
onForceAutopick,
|
||||
|
|
@ -151,6 +153,9 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
|
|||
? [displayedIndices[0], displayedIndices[1], extraIndex]
|
||||
: [displayedIndices[0], displayedIndices[1]];
|
||||
|
||||
const currentTeamId = draftGrid.flat().find((c) => c.pickNumber === currentPick)?.teamId;
|
||||
const isOvernightActive = overnightPauseInfo?.isActive ?? false;
|
||||
|
||||
return (
|
||||
<div ref={scrollContainerRef} className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full">
|
||||
|
|
@ -160,6 +165,9 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
|
|||
const teamTime = teamTimers[slot.team.id];
|
||||
const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled ?? false;
|
||||
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 = (
|
||||
<>
|
||||
|
|
@ -171,8 +179,8 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
|
|||
{ownerMap[slot.team.id] || slot.team.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`text-xs font-mono px-1 ${getTimerColorClass(teamTime)}`}>
|
||||
{formatClockTime(teamTime)}
|
||||
<div className={`text-xs font-mono px-1 ${isCurrentTeamOvernight ? "text-muted-foreground" : getTimerColorClass(teamTime)}`}>
|
||||
{isCurrentTeamOvernight ? `🌙 ${formatClockTime(teamTime)}` : formatClockTime(teamTime)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
@ -253,6 +261,8 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
|
|||
pick={pickData}
|
||||
seasonStatus={seasonStatus}
|
||||
draftPaused={draftPaused}
|
||||
isOvernightPause={isCurrent && isOvernightActive}
|
||||
resumesAt={isCurrent && isOvernightActive && overnightPauseInfo?.resumesAt ? overnightPauseInfo.resumesAt : undefined}
|
||||
className="cursor-context-menu"
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
|
|
@ -317,6 +327,8 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
|
|||
pick={pickData}
|
||||
seasonStatus={seasonStatus}
|
||||
draftPaused={draftPaused}
|
||||
isOvernightPause={isCurrent && isOvernightActive}
|
||||
resumesAt={isCurrent && isOvernightActive && overnightPauseInfo?.resumesAt ? overnightPauseInfo.resumesAt : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { formatClockTime } from "~/lib/draft-timer";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
|
@ -24,6 +24,10 @@ export interface DraftInfoCardProps {
|
|||
draftRoomHref: string;
|
||||
/** 1-based draft position for the current user; omit or undefined to hide the panel */
|
||||
userDraftPosition?: number;
|
||||
overnightPauseMode?: "none" | "league" | "per_user";
|
||||
overnightPauseStart?: string | null;
|
||||
overnightPauseEnd?: string | null;
|
||||
overnightPauseTimezone?: string | null;
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
|
@ -52,6 +56,10 @@ function DraftInfoCardVariant({
|
|||
draftDateTime,
|
||||
draftRoomHref,
|
||||
userDraftPosition,
|
||||
overnightPauseMode,
|
||||
overnightPauseStart,
|
||||
overnightPauseEnd,
|
||||
overnightPauseTimezone,
|
||||
}: DraftInfoCardProps) {
|
||||
const flexSpots = Math.max(0, draftRounds - sportsCount);
|
||||
|
||||
|
|
@ -66,6 +74,31 @@ function DraftInfoCardVariant({
|
|||
flexSpots > 0 ? `${flexSpots} flex` : null,
|
||||
].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 2–3 items.
|
||||
// When no overnight, Draft Date stays in this row giving 3–4 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 (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
|
|
@ -82,37 +115,47 @@ function DraftInfoCardVariant({
|
|||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={`grid gap-2 ${userDraftPosition !== null && userDraftPosition !== undefined ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-2 sm:grid-cols-3"}`}>
|
||||
{/* Draft Date */}
|
||||
<CardContent className="space-y-2">
|
||||
{/* Scheduling row: Draft Date + Overnight Pause side-by-side when overnight is configured */}
|
||||
{hasOvernight && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<InfoPanel
|
||||
label="Draft Date"
|
||||
value={draftDateTime ? format(new Date(draftDateTime), "MMM d, yyyy") : "TBD"}
|
||||
sub={draftDateTime ? format(new Date(draftDateTime), "p") : undefined}
|
||||
/>
|
||||
|
||||
{/* Rounds */}
|
||||
<InfoPanel label="Rounds" value={draftRounds} sub={roundsSub} />
|
||||
|
||||
{/* User's Draft Position (only when set) */}
|
||||
{userDraftPosition !== null && userDraftPosition !== undefined && (
|
||||
<InfoPanel label="Your Pick" value={`#${userDraftPosition}`} sub="round 1 position" />
|
||||
)}
|
||||
|
||||
{/* Timer */}
|
||||
<InfoPanel
|
||||
label={timerLabel}
|
||||
label="Overnight Pause"
|
||||
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}
|
||||
<Moon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
{formatHHMM(overnightPauseStart ?? "")} – {formatHHMM(overnightPauseEnd ?? "")}
|
||||
</span>
|
||||
}
|
||||
sub={timerSub}
|
||||
sub={overnightPauseMode === "league" && overnightPauseTimezone
|
||||
? overnightPauseTimezone
|
||||
: "per-user timezone"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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} />
|
||||
{hasPosition && (
|
||||
<InfoPanel label="Your Pick" value={`#${userDraftPosition}`} sub="round 1 position" />
|
||||
)}
|
||||
{timerPanel}
|
||||
{/* placeholder to keep grid balanced on mobile when only 2 items */}
|
||||
{hasOvernight && !hasPosition && <div />}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
@ -120,6 +163,16 @@ function DraftInfoCardVariant({
|
|||
|
||||
// ─── 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". */
|
||||
function formatHumanTime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
|
|
|
|||
72
app/components/league/TimezonePromptBanner.tsx
Normal file
72
app/components/league/TimezonePromptBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
app/components/league/TimezoneSelect.tsx
Normal file
141
app/components/league/TimezoneSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -125,6 +125,9 @@ export function useDraftRoomState({
|
|||
const [timeBankDirection, setTimeBankDirection] = useState<"add" | "remove">("add");
|
||||
const [isAdjustingTimeBank, setIsAdjustingTimeBank] = useState(false);
|
||||
|
||||
const [isOvernightPause, setIsOvernightPause] = useState(false);
|
||||
const [overnightResumesAt, setOvernightResumesAt] = useState<Date | null>(null);
|
||||
|
||||
return {
|
||||
picks, setPicks,
|
||||
currentPick, setCurrentPick,
|
||||
|
|
@ -160,5 +163,7 @@ export function useDraftRoomState({
|
|||
timeBankUnit, setTimeBankUnit,
|
||||
timeBankDirection, setTimeBankDirection,
|
||||
isAdjustingTimeBank, setIsAdjustingTimeBank,
|
||||
isOvernightPause, setIsOvernightPause,
|
||||
overnightResumesAt, setOvernightResumesAt,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ interface UseDraftSocketEventsParams {
|
|||
setConnectedTeams: (fn: (prev: Set<string>) => Set<string>) => void;
|
||||
setQueue: (fn: (prev: QueueItem[]) => QueueItem[]) => void;
|
||||
setTeamTimers: (fn: (prev: Record<string, number>) => Record<string, number>) => void;
|
||||
setIsOvernightPause: (value: boolean) => void;
|
||||
setOvernightResumesAt: (value: Date | null) => void;
|
||||
}
|
||||
|
||||
export function useDraftSocketEvents({
|
||||
|
|
@ -60,6 +62,8 @@ export function useDraftSocketEvents({
|
|||
setConnectedTeams,
|
||||
setQueue,
|
||||
setTeamTimers,
|
||||
setIsOvernightPause,
|
||||
setOvernightResumesAt,
|
||||
}: UseDraftSocketEventsParams) {
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
if (prev[data.teamId] === data.timeRemaining) return prev;
|
||||
return { ...prev, [data.teamId]: data.timeRemaining };
|
||||
|
|
@ -101,6 +111,9 @@ export function useDraftSocketEvents({
|
|||
if (data.currentPickNumber !== null) {
|
||||
setCurrentPick(data.currentPickNumber);
|
||||
}
|
||||
const pauseActive = data.overnightPauseActive ?? false;
|
||||
setIsOvernightPause(pauseActive);
|
||||
setOvernightResumesAt(pauseActive && data.resumesAtUTC ? new Date(data.resumesAtUTC) : null);
|
||||
};
|
||||
|
||||
const handleDraftPaused = () => setIsPaused(true);
|
||||
|
|
|
|||
147
app/lib/__tests__/overnight-pause.test.ts
Normal file
147
app/lib/__tests__/overnight-pause.test.ts
Normal 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:00–07: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:00–18: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:00–07: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());
|
||||
});
|
||||
});
|
||||
96
app/lib/overnight-pause.ts
Normal file
96
app/lib/overnight-pause.ts
Normal 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
|
||||
* startHHMM–endHHMM 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;
|
||||
}
|
||||
|
|
@ -705,6 +705,7 @@ export async function executeAutoPick(params: {
|
|||
teamId,
|
||||
timeRemaining: emitTimeRemaining,
|
||||
currentPickNumber: nextPickNumber,
|
||||
overnightPauseActive: false,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("[AutoPick] Socket.IO timer-update error:", error);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { eq, inArray } from "drizzle-orm";
|
||||
import { eq, inArray, and } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
|
|
@ -74,3 +74,23 @@ export async function findAllUsers(): Promise<User[]> {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export default [
|
|||
route("api/draft/rollback", "routes/api/draft.rollback.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/user/timezone", "routes/api/user.timezone.ts"),
|
||||
route("api/seasons/:seasonId/draft", "routes/api/seasons.$seasonId.draft.ts"),
|
||||
route("login", "routes/login.tsx"),
|
||||
route("register", "routes/register.tsx"),
|
||||
|
|
|
|||
32
app/routes/api/user.timezone.ts
Normal file
32
app/routes/api/user.timezone.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useLoaderData, Link, useRevalidator } from "react-router";
|
||||
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
||||
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 { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
|
|
@ -20,8 +20,9 @@ import { getDraftParticipants } from "~/models/participant";
|
|||
import { getSeasonTimers } from "~/models/draft-timer";
|
||||
import { getSeasonAutodraftSettings } from "~/models/autodraft-settings";
|
||||
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 { buildOwnerMap } from "~/lib/owner-map";
|
||||
import { DraftSidebar } from "~/components/DraftSidebar";
|
||||
import { TeamRosterView } from "~/components/draft/TeamRosterView";
|
||||
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)
|
||||
: 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 {
|
||||
season,
|
||||
|
|
@ -128,6 +154,7 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
isCommissioner: !!isCommissioner,
|
||||
currentUserId: userId,
|
||||
ownerMap,
|
||||
teamTimezoneMap,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -145,6 +172,7 @@ export default function DraftRoom() {
|
|||
isCommissioner,
|
||||
currentUserId,
|
||||
ownerMap,
|
||||
teamTimezoneMap,
|
||||
} = useLoaderData<typeof loader>();
|
||||
const { revalidate, state: revalidatorState } = useRevalidator();
|
||||
const { isConnected, connectionError, isReconnecting, reconnectCount, socketVersion, on, off } = useDraftSocket(season.id, userTeam?.id);
|
||||
|
|
@ -203,6 +231,8 @@ export default function DraftRoom() {
|
|||
timeBankUnit, setTimeBankUnit,
|
||||
timeBankDirection, setTimeBankDirection,
|
||||
isAdjustingTimeBank, setIsAdjustingTimeBank,
|
||||
isOvernightPause, setIsOvernightPause,
|
||||
overnightResumesAt, setOvernightResumesAt,
|
||||
} = useDraftRoomState({
|
||||
draftPicks,
|
||||
season,
|
||||
|
|
@ -214,6 +244,27 @@ export default function DraftRoom() {
|
|||
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 {
|
||||
authDegraded,
|
||||
authFetch,
|
||||
|
|
@ -365,6 +416,8 @@ export default function DraftRoom() {
|
|||
setConnectedTeams,
|
||||
setQueue,
|
||||
setTeamTimers,
|
||||
setIsOvernightPause,
|
||||
setOvernightResumesAt,
|
||||
});
|
||||
|
||||
// Persist sidebar collapsed state
|
||||
|
|
@ -956,13 +1009,14 @@ export default function DraftRoom() {
|
|||
connectedTeams,
|
||||
seasonStatus: season.status,
|
||||
draftPaused: isPaused,
|
||||
overnightPauseInfo: { isActive: isOvernightPause, resumesAt: overnightResumesAt, pausedTeamIds },
|
||||
onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined,
|
||||
onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined,
|
||||
onForceAutopick: isCommissioner ? handleForceAutopick : undefined,
|
||||
onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined,
|
||||
onReplacePick: isCommissioner ? handleReplacePickOpen : 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(() => ({
|
||||
participants: filteredParticipants,
|
||||
|
|
@ -1002,6 +1056,7 @@ export default function DraftRoom() {
|
|||
ownerMap,
|
||||
seasonStatus: season.status,
|
||||
draftPaused: isPaused,
|
||||
overnightPauseInfo: { isActive: isOvernightPause, resumesAt: overnightResumesAt, pausedTeamIds },
|
||||
onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined,
|
||||
onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined,
|
||||
onForceAutopick: isCommissioner ? handleForceAutopick : undefined,
|
||||
|
|
@ -1145,12 +1200,20 @@ export default function DraftRoom() {
|
|||
{season.status === "draft" && !isDraftComplete && currentDraftSlot && (
|
||||
<div className={`md:hidden flex-shrink-0 flex items-center justify-between px-4 py-2 border-b ${
|
||||
isMyTurn
|
||||
? "bg-electric/25 border-electric"
|
||||
? isOvernightPause
|
||||
? "bg-blue-950/40 border-blue-800/40"
|
||||
: "bg-electric/25 border-electric"
|
||||
: "bg-muted/30"
|
||||
}`}>
|
||||
<div>
|
||||
<div className={`text-xs font-bold uppercase tracking-wider ${isMyTurn ? "text-electric" : "text-muted-foreground"}`}>
|
||||
{isMyTurn ? "Your Turn!" : "On the Clock"}
|
||||
<div className={`text-xs font-bold uppercase tracking-wider ${
|
||||
isMyTurn
|
||||
? isOvernightPause ? "text-blue-400" : "text-electric"
|
||||
: "text-muted-foreground"
|
||||
}`}>
|
||||
{isMyTurn
|
||||
? isOvernightPause ? "Overnight Pause" : "Your Turn!"
|
||||
: "On the Clock"}
|
||||
</div>
|
||||
<div className="text-sm font-bold">
|
||||
{currentDraftSlotOwnerName ?? currentDraftSlot.team.name}
|
||||
|
|
@ -1161,6 +1224,10 @@ export default function DraftRoom() {
|
|||
</div>
|
||||
{isPaused ? (
|
||||
<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}`}>
|
||||
{formatClockTime(currentClockTime)}
|
||||
|
|
@ -1170,9 +1237,11 @@ export default function DraftRoom() {
|
|||
)}
|
||||
|
||||
{/* 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
|
||||
? "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"
|
||||
}`}>
|
||||
<Tabs
|
||||
|
|
@ -1188,6 +1257,23 @@ export default function DraftRoom() {
|
|||
<TabsTrigger value="summary">Summary</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="flex justify-center">
|
||||
{isMyTurn && season.status === "draft" && !isDraftComplete && (
|
||||
isOvernightPause ? (
|
||||
<span className="text-sm text-blue-200 text-center leading-snug">
|
||||
🌙 Overnight pause active — timer frozen
|
||||
{overnightResumesAt && (
|
||||
<> until {overnightResumesAt.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}</>
|
||||
)}. You can still pick.
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-base font-bold tracking-wide text-electric">
|
||||
It's your turn!
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
{userTeam && (
|
||||
<AutodraftBadgeWithPopover
|
||||
seasonId={season.id}
|
||||
|
|
@ -1197,9 +1283,11 @@ export default function DraftRoom() {
|
|||
queueOnly={userAutodraft.queueOnly}
|
||||
isMyTurn={isMyTurn}
|
||||
onUpdate={handleAutodraftUpdate}
|
||||
showOvernightNote={season.overnightPauseMode !== "none"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content — single layout tree based on isMobile to avoid duplicate component instances */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event"
|
|||
import { getUpcomingGroupStageMatchesForParticipants } from "~/models/group-stage-match";
|
||||
import { getDraftedParticipantsBySportsSeason, getDraftedParticipantsWithPoints, type DraftedParticipantWithPoints } from "~/models/draft-pick";
|
||||
import { getAuditLogForSeason } from "~/models/audit-log";
|
||||
import { findUserById } from "~/models/user";
|
||||
import type { Route } from "./+types/$leagueId";
|
||||
|
||||
export async function loader(args: Route.LoaderArgs) {
|
||||
|
|
@ -205,9 +206,18 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
? await getAuditLogForSeason(season.id, { limit: 5 })
|
||||
: { 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 {
|
||||
league,
|
||||
season,
|
||||
showTimezoneBanner: !!showTimezoneBanner,
|
||||
teams,
|
||||
commissioners,
|
||||
currentUserId: userId,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,10 @@ import {
|
|||
import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function isValidHHMM(s: string): boolean {
|
||||
return /^\d{2}:\d{2}$/.test(s);
|
||||
}
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Settings — ${data?.league?.name ?? "League"} - Brackt` }];
|
||||
}
|
||||
|
|
@ -156,6 +160,8 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
name: o.name,
|
||||
}));
|
||||
|
||||
const commishTimezone = (await findUserById(userId))?.timezone ?? null;
|
||||
|
||||
return {
|
||||
league,
|
||||
season,
|
||||
|
|
@ -170,6 +176,7 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
ownerMap: Object.fromEntries(ownerMap),
|
||||
commissioners: commissionerUserData,
|
||||
currentUserId: userId,
|
||||
commishTimezone,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -587,6 +594,28 @@ export async function action(args: Route.ActionArgs) {
|
|||
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)
|
||||
if (season.status === "pre_draft") {
|
||||
const scoringFields = [
|
||||
|
|
@ -823,10 +852,11 @@ export async function action(args: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
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 [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
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>>(
|
||||
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")
|
||||
: ""
|
||||
);
|
||||
|
||||
// Update draft order when loader data changes (after randomization)
|
||||
useEffect(() => {
|
||||
const newOrder = draftSlots.length > 0
|
||||
|
|
@ -1239,6 +1268,137 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Overnight Pause */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Overnight Pause</CardTitle>
|
||||
<CardDescription>
|
||||
Protect players from having their pick timer expire while they'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'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'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'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 */}
|
||||
<ScoringRulesEditor
|
||||
scoringRules={season ? {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { SportsSeasonsSummary } from "~/components/league/SportsSeasonsSummary";
|
|||
import { UpcomingEventsCard } from "~/components/sport-season/UpcomingEventsCard";
|
||||
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
|
||||
import { StandingsPreview, type StandingsPreviewEntry } from "~/components/league/StandingsPreview";
|
||||
import { TimezonePromptBanner } from "~/components/league/TimezonePromptBanner";
|
||||
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
|
|
@ -40,6 +41,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
|||
origin,
|
||||
upcomingCalendarEvents,
|
||||
recentActivity,
|
||||
showTimezoneBanner,
|
||||
} = loaderData;
|
||||
|
||||
const myTeam = teams.find((t) => t.ownerId === currentUserId);
|
||||
|
|
@ -131,6 +133,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
|||
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4">
|
||||
{showTimezoneBanner && <TimezonePromptBanner />}
|
||||
<div className="mb-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div>
|
||||
|
|
@ -200,6 +203,10 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
|||
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
|
||||
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
|
||||
userDraftPosition={userDraftPosition}
|
||||
overnightPauseMode={season.overnightPauseMode}
|
||||
overnightPauseStart={season.overnightPauseStart}
|
||||
overnightPauseEnd={season.overnightPauseEnd}
|
||||
overnightPauseTimezone={season.overnightPauseTimezone}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ const createMockSeason = (overrides: {
|
|||
currentPickNumber: null,
|
||||
draftStartedAt: null,
|
||||
draftPaused: false,
|
||||
overnightPauseMode: 'none' as const,
|
||||
overnightPauseStart: null,
|
||||
overnightPauseEnd: null,
|
||||
overnightPauseTimezone: null,
|
||||
inviteCode: 'ABC123',
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ const mockSeason = {
|
|||
currentPickNumber: null,
|
||||
draftStartedAt: null,
|
||||
draftPaused: false,
|
||||
overnightPauseMode: 'none' as const,
|
||||
overnightPauseStart: null,
|
||||
overnightPauseEnd: null,
|
||||
overnightPauseTimezone: null,
|
||||
inviteCode: 'TEST123',
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ describe('Team Management - Admin User List', () => {
|
|||
lastName: 'One',
|
||||
imageUrl: null,
|
||||
isAdmin: false,
|
||||
timezone: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
|
|
@ -205,6 +206,7 @@ describe('Team Management - Admin User List', () => {
|
|||
lastName: 'Two',
|
||||
imageUrl: null,
|
||||
isAdmin: false,
|
||||
timezone: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
|
|
@ -240,6 +242,7 @@ describe('Team Management - Admin User List', () => {
|
|||
lastName: 'One',
|
||||
imageUrl: null,
|
||||
isAdmin: false,
|
||||
timezone: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Form, redirect, Link } from "react-router";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import type { Route } from "./+types/new";
|
||||
|
|
@ -11,6 +11,7 @@ import { linkMultipleSportsToSeason } from "~/models/season-sport";
|
|||
import { createManyTeams } from "~/models/team";
|
||||
import { findActiveSeasonTemplates, findSeasonTemplateWithSportsSeasons } from "~/models/season-template";
|
||||
import { findDraftableSportsSeasons } from "~/models/sports-season";
|
||||
import { findUserById } from "~/models/user";
|
||||
import { generateUniqueTeamNames } from "~/utils/team-names";
|
||||
import { format } from "date-fns";
|
||||
import { CalendarIcon, Check } from "lucide-react";
|
||||
|
|
@ -42,16 +43,19 @@ import {
|
|||
import { cn } from "~/lib/utils";
|
||||
import { parseDraftSpeed } from "~/lib/draft-timer";
|
||||
import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor";
|
||||
import { TimezoneSelect } from "~/components/league/TimezoneSelect";
|
||||
|
||||
export function meta(): Route.MetaDescriptors {
|
||||
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 allSportsSeasons = await findDraftableSportsSeasons();
|
||||
|
||||
// Fetch templates with their sports seasons for mapping
|
||||
const templatesWithSports = await Promise.all(
|
||||
templates.map(async (template) => {
|
||||
const templateWithSports = await findSeasonTemplateWithSportsSeasons(template.id);
|
||||
|
|
@ -64,9 +68,14 @@ export async function loader() {
|
|||
})
|
||||
);
|
||||
|
||||
const commishTimezone = userId
|
||||
? ((await findUserById(userId))?.timezone ?? null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
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 draftTimerMode = (formData.get("draftTimerMode") as "chess_clock" | "standard") || "chess_clock";
|
||||
|
||||
// Map draft speed to time values
|
||||
const { draftInitialTime: draftInitialTimeNum, draftIncrementTime: draftIncrementTimeNum } =
|
||||
parseDraftSpeed(draftSpeed as string | null, draftTimerMode);
|
||||
|
||||
// Validation
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
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" };
|
||||
}
|
||||
|
||||
// Extract and validate scoring rules
|
||||
const scoringFields = [
|
||||
"pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
|
||||
"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 {
|
||||
// Create league
|
||||
const league = await createLeague({
|
||||
name: name.trim(),
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
// Make creator a commissioner
|
||||
await createCommissioner({
|
||||
leagueId: league.id,
|
||||
userId: userId,
|
||||
});
|
||||
|
||||
// Create first season
|
||||
const currentYear = new Date().getFullYear();
|
||||
const season = await createSeason({
|
||||
leagueId: league.id,
|
||||
|
|
@ -156,16 +172,17 @@ export async function action(args: Route.ActionArgs) {
|
|||
draftInitialTime: draftInitialTimeNum,
|
||||
draftIncrementTime: draftIncrementTimeNum,
|
||||
draftTimerMode,
|
||||
overnightPauseMode,
|
||||
overnightPauseStart,
|
||||
overnightPauseEnd,
|
||||
overnightPauseTimezone,
|
||||
...scoringRules,
|
||||
});
|
||||
|
||||
// Set this as the current season for the league
|
||||
await setCurrentSeason(league.id, season.id);
|
||||
|
||||
// Add selected sports to the season
|
||||
const selectedSports = formData.getAll("sportsSeasons");
|
||||
|
||||
// Validate that draft rounds is at least the number of sports
|
||||
if (selectedSports.length > draftRoundsNum) {
|
||||
return {
|
||||
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 teamNames = generateUniqueTeamNames(teamCountNum);
|
||||
const teams = teamNames.map((teamName, i) => ({
|
||||
|
|
@ -192,7 +208,6 @@ export async function action(args: Route.ActionArgs) {
|
|||
|
||||
await createManyTeams(teams);
|
||||
|
||||
// Redirect to league homepage
|
||||
return redirect(`/leagues/${league.id}`);
|
||||
} catch (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) {
|
||||
const { templates, allSportsSeasons } = loaderData;
|
||||
const { templates, allSportsSeasons, commishTimezone } = loaderData;
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string>("");
|
||||
const [selectedSports, setSelectedSports] = useState<Set<string>>(new Set());
|
||||
const [draftRounds, setDraftRounds] = useState<number>(20);
|
||||
const [draftDate, setDraftDate] = useState<Date>();
|
||||
const [draftTime, setDraftTime] = useState<string>("");
|
||||
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 newSelected = new Set(selectedSports);
|
||||
|
|
@ -224,7 +248,6 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
|
|||
setSelectedSports(new Set(sportsSeasonIds));
|
||||
};
|
||||
|
||||
// Calculate flex spots and minimum rounds
|
||||
const sportsCount = selectedSports.size;
|
||||
const minRounds = sportsCount;
|
||||
const recommendedRounds = Math.ceil(sportsCount * 1.25);
|
||||
|
|
@ -425,6 +448,89 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
|
|||
</p>
|
||||
</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's own timezone</option>
|
||||
</select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Protect players from having their pick timer expire while they'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'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'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't set one.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScoringRulesEditor disabled={false} />
|
||||
|
||||
<div className="space-y-4">
|
||||
|
|
@ -435,7 +541,6 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
|
|||
|
||||
<input type="hidden" name="templateId" value={selectedTemplate} />
|
||||
|
||||
{/* Template Cards */}
|
||||
{templates.length > 0 && (
|
||||
<div className="grid gap-3">
|
||||
{templates.map((template) => (
|
||||
|
|
@ -471,7 +576,6 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Sports Checkboxes */}
|
||||
<div className="space-y-3">
|
||||
<div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import { redirect, Form } from "react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
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 {
|
||||
return [{ title: "Profile - Brackt" }];
|
||||
|
|
@ -20,7 +26,8 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
if (!user) {
|
||||
return redirect("/");
|
||||
}
|
||||
return { user };
|
||||
const isInActiveDraft = await isUserInActiveDraft(session.user.id);
|
||||
return { user, isInActiveDraft };
|
||||
}
|
||||
|
||||
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 firstName = formData.get("firstName") 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, {
|
||||
displayName: displayName || undefined,
|
||||
username: username || undefined,
|
||||
firstName: firstName || undefined,
|
||||
lastName: lastName || undefined,
|
||||
timezone: timezoneValue,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
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 (
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||
</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's detected timezone.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full">Save Changes</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export const users = pgTable("users", {
|
|||
lastName: varchar("last_name", { length: 255 }),
|
||||
imageUrl: varchar("image_url", { length: 512 }), // mapped as BetterAuth's "image" field
|
||||
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(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
|
@ -107,6 +108,12 @@ export const draftTimerModeEnum = pgEnum("draft_timer_mode", [
|
|||
"standard",
|
||||
]);
|
||||
|
||||
export const overnightPauseModeEnum = pgEnum("overnight_pause_mode", [
|
||||
"none",
|
||||
"league",
|
||||
"per_user",
|
||||
]);
|
||||
|
||||
export const probabilitySourceEnum = pgEnum("probability_source", [
|
||||
"manual",
|
||||
"futures_odds",
|
||||
|
|
@ -188,6 +195,10 @@ export const seasons = pgTable("seasons", {
|
|||
currentPickNumber: integer("current_pick_number").default(1),
|
||||
draftStartedAt: timestamp("draft_started_at"),
|
||||
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(),
|
||||
// Scoring configuration (8 values for 1st through 8th)
|
||||
pointsFor1st: integer("points_for_1st").notNull().default(100),
|
||||
|
|
|
|||
6
drizzle/0083_fearless_leader.sql
Normal file
6
drizzle/0083_fearless_leader.sql
Normal 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);
|
||||
5102
drizzle/meta/0083_snapshot.json
Normal file
5102
drizzle/meta/0083_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -582,6 +582,13 @@
|
|||
"when": 1777090754037,
|
||||
"tag": "0082_stale_pride",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 83,
|
||||
"version": "7",
|
||||
"when": 1777180952144,
|
||||
"tag": "0083_fearless_leader",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { eq, and, asc, sql } from "drizzle-orm";
|
|||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { getSocketIO } from "./socket";
|
||||
import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils";
|
||||
import { isInOvernightWindow, getOvernightResumeUTC } from "~/lib/overnight-pause";
|
||||
import { logger } from "./logger";
|
||||
import { db } from "./db";
|
||||
|
||||
|
|
@ -13,6 +14,63 @@ let timerTickRunning = false;
|
|||
// a redundant query on every tick.
|
||||
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
|
||||
* Runs every second to update all active draft timers
|
||||
|
|
@ -73,6 +131,9 @@ async function updateDraftTimers(): Promise<void> {
|
|||
for (const cachedId of draftSlotsCache.keys()) {
|
||||
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) {
|
||||
// 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.
|
||||
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.
|
||||
// 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
|
||||
|
|
@ -174,6 +239,7 @@ async function updateDraftTimers(): Promise<void> {
|
|||
teamId: currentTeamId,
|
||||
timeRemaining: 0,
|
||||
currentPickNumber,
|
||||
overnightPauseActive: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -188,6 +254,19 @@ async function updateDraftTimers(): Promise<void> {
|
|||
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
|
||||
// so concurrent increments from pick handlers are never overwritten)
|
||||
const [updatedTimer] = await db
|
||||
|
|
@ -207,6 +286,7 @@ async function updateDraftTimers(): Promise<void> {
|
|||
teamId: currentTeamId,
|
||||
timeRemaining: newTimeRemaining,
|
||||
currentPickNumber,
|
||||
overnightPauseActive: false,
|
||||
});
|
||||
|
||||
// If timer just hit 0, trigger auto-pick (next_pick mode or no autodraft)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue