Compare commits

..

3 commits

Author SHA1 Message Date
Chris Parsons
0eb6715655 Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m51s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m26s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
- Broadcast timer-bank-updated after every pick so all connected clients
  immediately see the updated time bank (was only visible on next timer-pick-started)
- Capture pickMadeAt at route entry (before auth/DB overhead) and use Math.ceil
  so credited seconds always match the client countdown display
- Clear picksExpiresAt on every pick so _schedulePickForSeason starts fresh
- Hold schedulingInProgress lock for full timer callback to prevent the recovery
  interval from scheduling a duplicate timeout mid-pick
- Fix force-autopick route: call rescheduleTimer so the next team's clock
  starts immediately instead of waiting for the old timeout to fire naturally
- Fix draft.adjust-time-bank for on-clock teams: shift picksExpiresAt by the
  adjustment and reschedule, so the client countdown updates; block adjustments
  that would reduce the bank to zero
- Add timer-pick-started / timer-overnight-paused / timer-bank-updated socket
  events with full type definitions; replace dead timer-update event
- Fix draft-state-sync to include expiresAt for the active timer and
  isOvernightPause state so reconnecting clients see accurate countdown and
  pause banner immediately
- Fix room-closure countdown: capture client-side timestamp when draft completes
  so countdown runs even before the loader revalidates with draftCompletedAt
- Run countdown interval at 500ms with Math.ceil to prevent skipped seconds
- Add draft-started socket handler to transition pre-draft UI without a refresh
- Fix overnight pause: canPick only blocks on commissioner pause, not overnight
  pause (timer freezes but player can still pick early)
- Extract checkOvernightPause to server/overnight-pause-check.ts, breaking the
  timer↔socket circular import and ensuring the timezone cache is shared and
  evicted correctly across both callers
- Fix PostgreSQL varchar=uuid type mismatch in getTeamTimezone join

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 22:51:43 -07:00
Claude
c175d70084 Phase 1: Replace per-second timer tick with event-driven scheduler
Eliminates the setInterval(1s) + per-second DB write by storing picksExpiresAt
in draft_timers and using a targeted setTimeout per pick. Clients count down
locally from the expiresAt timestamp, removing server-pushed timer-update events.

Key changes:
- database/schema.ts: add picksExpiresAt and picksStartedAt to draft_timers
- server/timer.ts: full rewrite — schedulePickForSeason, rescheduleTimer,
  30s recovery interval instead of 1s tick, overnight-pause resume scheduling
- server/socket.ts: new timer-pick-started / timer-overnight-paused events,
  updated draft-state-sync to include expiresAt for reconnect recovery
- draft.make-pick / draft.force-manual-pick: compute actual remaining from
  picksExpiresAt at pick time; call rescheduleTimer after the autodraft chain
- useDraftSocketEvents: handle new timer events, restore countdown on reconnect
- $leagueId.draft.$seasonId: client-side countdown useEffect from expiresAt
- plans/zero-downtime-scaling.md: full 4-phase scaling plan for future reference

Resolves 2351 unit tests (all passing).

https://claude.ai/code/session_019k5J6Ty7uP5HxSx6CsbiBK
2026-06-05 22:51:43 -07:00
f657293430 claude/sports-templates-conversion-ILWAM (#71)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m32s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m18s
🚀 Deploy / 🐳 Build (push) Successful in 1m10s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #71
2026-06-04 21:31:49 +00:00
19 changed files with 7092 additions and 991 deletions

View file

@ -0,0 +1,43 @@
services:
migrate:
container_name: brackt-migrate
image: sjc.vultrcr.com/chrisparsons/brackt:latest
command: ["node", "/app/scripts/migrate.mjs"]
networks:
- internal
restart: "no"
brackt:
depends_on:
migrate:
condition: service_completed_successfully
deploy:
restart_policy:
condition: on-failure
max_attempts: 5
container_name: brackt
image: sjc.vultrcr.com/chrisparsons/brackt:latest
environment:
- NODE_ENV=production
- PGBOUNCER=true
- SQUIGGLE_CONTACT_EMAIL=chris@brackt.com
- BETTER_AUTH_URL=https://brackt.com
- APP_URL=https://brackt.com
labels:
- traefik.enable=true
- traefik.http.routers.brackt.rule=Host(`brackt.com`)
- traefik.http.routers.brackt.tls=true
- traefik.http.routers.brackt.tls.certresolver=lets-encrypt
- traefik.http.routers.brackt.entrypoints=websecure
- traefik.http.services.brackt.loadbalancer.server.port=3000
- traefik.http.routers.brackt-www.rule=Host(`www.brackt.com`)
- traefik.http.routers.brackt-www.tls=true
- traefik.http.routers.brackt-www.tls.certresolver=lets-encrypt
- traefik.http.routers.brackt-www.entrypoints=websecure
- traefik.http.routers.brackt-www.middlewares=redirect-to-nonwww
- traefik.http.middlewares.redirect-to-nonwww.redirectregex.regex=^https://www\.(.*)
- traefik.http.middlewares.redirect-to-nonwww.redirectregex.replacement=https://$${1}
- traefik.http.middlewares.redirect-to-nonwww.redirectregex.permanent=true
networks:
- internal
- web

View file

@ -1,6 +1,8 @@
import { eq, and } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { findDraftableSportsSeasonBySportId } from "~/models/sports-season";
import { logger } from "~/lib/logger";
export type SeasonSport = typeof schema.seasonSports.$inferSelect;
export type NewSeasonSport = typeof schema.seasonSports.$inferInsert;
@ -89,7 +91,6 @@ export async function applySportsFromTemplate(
): Promise<SeasonSport[]> {
const db = database();
// Get all sports from the template
const templateSports = await db.query.seasonTemplateSports.findMany({
where: eq(schema.seasonTemplateSports.templateId, templateId),
});
@ -98,12 +99,26 @@ export async function applySportsFromTemplate(
return [];
}
// Link them to the season
const resolved = await Promise.all(
templateSports.map(async (ts) => {
const season = await findDraftableSportsSeasonBySportId(ts.sportId);
if (!season) {
logger.warn(`No draftable season found for sport ${ts.sportId} in template ${templateId}`);
}
return season ?? null;
})
);
const sportsSeasonIds = resolved
.filter((s): s is NonNullable<typeof s> => s !== null)
.map((s) => s.id);
if (sportsSeasonIds.length === 0) {
return [];
}
return await linkMultipleSportsToSeason(
templateSports.map((ts) => ({
seasonId,
sportsSeasonId: ts.sportsSeasonId,
}))
sportsSeasonIds.map((sportsSeasonId) => ({ seasonId, sportsSeasonId }))
);
}

View file

@ -7,14 +7,14 @@ export type NewSeasonTemplateSport = typeof schema.seasonTemplateSports.$inferIn
export async function addSportToTemplate(
templateId: string,
sportsSeasonId: string
sportId: string
): Promise<SeasonTemplateSport> {
const db = database();
const [link] = await db
.insert(schema.seasonTemplateSports)
.values({
templateId,
sportsSeasonId,
sportId,
})
.returning();
return link;
@ -32,7 +32,7 @@ export async function addMultipleSportsToTemplate(
export async function removeSportFromTemplate(
templateId: string,
sportsSeasonId: string
sportId: string
): Promise<void> {
const db = database();
await db
@ -40,7 +40,7 @@ export async function removeSportFromTemplate(
.where(
and(
eq(schema.seasonTemplateSports.templateId, templateId),
eq(schema.seasonTemplateSports.sportsSeasonId, sportsSeasonId)
eq(schema.seasonTemplateSports.sportId, sportId)
)
);
}
@ -52,28 +52,23 @@ export async function findSeasonTemplateSportsByTemplateId(
return await db.query.seasonTemplateSports.findMany({
where: eq(schema.seasonTemplateSports.templateId, templateId),
with: {
sportsSeason: {
with: {
sport: true,
},
},
sport: true,
},
});
}
export async function findSeasonTemplateSportsBySportsSeasonId(
sportsSeasonId: string
export async function findSeasonTemplateSportsBySportId(
sportId: string
): Promise<SeasonTemplateSport[]> {
const db = database();
return await db.query.seasonTemplateSports.findMany({
where: eq(schema.seasonTemplateSports.sportsSeasonId, sportsSeasonId),
where: eq(schema.seasonTemplateSports.sportId, sportId),
with: {
template: true,
},
});
}
export async function deleteSeasonTemplateSport(id: string): Promise<void> {
const db = database();
await db.delete(schema.seasonTemplateSports).where(eq(schema.seasonTemplateSports.id, id));

View file

@ -29,18 +29,14 @@ export async function findSeasonTemplateById(id: string): Promise<SeasonTemplate
});
}
export async function findSeasonTemplateWithSportsSeasons(id: string) {
export async function findSeasonTemplateWithSports(id: string) {
const db = database();
return await db.query.seasonTemplates.findFirst({
where: eq(schema.seasonTemplates.id, id),
with: {
seasonTemplateSports: {
with: {
sportsSeason: {
with: {
sport: true,
},
},
sport: true,
},
},
},
@ -51,14 +47,6 @@ export async function findActiveSeasonTemplates(): Promise<SeasonTemplate[]> {
const db = database();
return await db.query.seasonTemplates.findMany({
where: eq(schema.seasonTemplates.isActive, true),
orderBy: (templates, { desc }) => [desc(templates.year)],
});
}
export async function findSeasonTemplatesByYear(year: number): Promise<SeasonTemplate[]> {
const db = database();
return await db.query.seasonTemplates.findMany({
where: eq(schema.seasonTemplates.year, year),
orderBy: (templates, { asc }) => [asc(templates.name)],
});
}
@ -66,7 +54,7 @@ export async function findSeasonTemplatesByYear(year: number): Promise<SeasonTem
export async function findAllSeasonTemplates(): Promise<SeasonTemplate[]> {
const db = database();
return await db.query.seasonTemplates.findMany({
orderBy: (templates, { desc }) => [desc(templates.year)],
orderBy: (templates, { asc }) => [asc(templates.name)],
});
}

View file

@ -151,6 +151,24 @@ export async function findDraftableSportsSeasons() {
}));
}
export async function findDraftableSportsSeasonBySportId(sportId: string) {
const db = database();
const today = sql`CURRENT_DATE`;
return await db.query.sportsSeasons.findFirst({
where: (ss, ops) =>
ops.and(
ops.eq(ss.sportId, sportId),
lte(ss.draftOn, today),
gte(ss.draftOff, today),
isNull(ss.fantasySeasonId)
),
orderBy: (ss, { desc }) => [desc(ss.year), desc(ss.draftOn)],
with: {
sport: true,
},
});
}
export async function updateSportsSeason(
id: string,
data: Partial<NewSportsSeason>

View file

@ -4,7 +4,7 @@ import type { Route } from "./+types/admin.templates.$id";
import { logger } from "~/lib/logger";
import {
findSeasonTemplateWithSportsSeasons,
findSeasonTemplateWithSports,
updateSeasonTemplate,
deleteSeasonTemplate,
setSeasonTemplateActive
@ -13,7 +13,7 @@ import {
addSportToTemplate,
removeSportFromTemplate
} from "~/models/season-template-sport";
import { findAllSportsSeasons } from "~/models/sports-season";
import { findAllSports } from "~/models/sport";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
@ -51,19 +51,15 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
}
export async function loader({ params }: Route.LoaderArgs) {
const template = await findSeasonTemplateWithSportsSeasons(params.id);
const template = await findSeasonTemplateWithSports(params.id);
if (!template) {
throw new Response("Template not found", { status: 404 });
}
const allSportsSeasons = await findAllSportsSeasons();
const allSports = await findAllSports();
// Type assertion since we know the sport relation is included
return {
template,
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>
};
return { template, allSports };
}
export async function action({ request, params }: Route.ActionArgs) {
@ -82,45 +78,34 @@ export async function action({ request, params }: Route.ActionArgs) {
}
if (intent === "add-sport") {
const sportsSeasonId = formData.get("sportsSeasonId");
const sportId = formData.get("sportId");
if (typeof sportsSeasonId === "string") {
await addSportToTemplate(params.id, sportsSeasonId);
if (typeof sportId === "string") {
await addSportToTemplate(params.id, sportId);
}
return { success: true };
}
if (intent === "remove-sport") {
const sportsSeasonId = formData.get("sportsSeasonId");
const sportId = formData.get("sportId");
if (typeof sportsSeasonId === "string") {
await removeSportFromTemplate(params.id, sportsSeasonId);
if (typeof sportId === "string") {
await removeSportFromTemplate(params.id, sportId);
}
return { success: true };
}
// Update template
const name = formData.get("name");
const year = formData.get("year");
const description = formData.get("description");
if (typeof name !== "string" || !name.trim()) {
return { error: "Template name is required" };
}
if (typeof year !== "string") {
return { error: "Year is required" };
}
const yearNum = parseInt(year, 10);
if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2100) {
return { error: "Year must be between 2000 and 2100" };
}
try {
await updateSeasonTemplate(params.id, {
name: name.trim(),
year: yearNum,
description: typeof description === "string" && description.trim() ? description.trim() : null,
});
@ -132,15 +117,15 @@ export async function action({ request, params }: Route.ActionArgs) {
}
export default function EditTemplate({ loaderData, actionData }: Route.ComponentProps) {
const { template, allSportsSeasons } = loaderData;
const { template, allSports } = loaderData;
const [selectedSportId, setSelectedSportId] = useState("");
const includedSeasonIds = new Set(
template.seasonTemplateSports.map((s) => s.sportsSeasonId)
const includedSportIds = new Set(
template.seasonTemplateSports.map((s) => s.sportId)
);
const availableSportsSeasons = allSportsSeasons.filter(
(s) => !includedSeasonIds.has(s.id)
const availableSports = allSports.filter(
(s) => !includedSportIds.has(s.id)
);
return (
@ -170,19 +155,6 @@ export default function EditTemplate({ loaderData, actionData }: Route.Component
/>
</div>
<div className="space-y-2">
<Label htmlFor="year">Year</Label>
<Input
id="year"
name="year"
type="number"
min="2000"
max="2100"
defaultValue={template.year}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description (Optional)</Label>
<Textarea
@ -236,9 +208,9 @@ export default function EditTemplate({ loaderData, actionData }: Route.Component
<Card>
<CardHeader>
<CardTitle>Sports Seasons</CardTitle>
<CardTitle>Sports</CardTitle>
<CardDescription>
Add sports seasons to this template
Add sports to this template. The active season for each sport will be used automatically when a league is created.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@ -249,17 +221,12 @@ export default function EditTemplate({ loaderData, actionData }: Route.Component
key={templateSport.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<p className="font-medium">
{templateSport.sportsSeason.sport.name} - {templateSport.sportsSeason.name}
</p>
<p className="text-sm text-muted-foreground">
{templateSport.sportsSeason.year} {templateSport.sportsSeason.scoringType.replace("_", " ")}
</p>
</div>
<p className="font-medium">
{templateSport.sport.name}
</p>
<Form method="post">
<input type="hidden" name="intent" value="remove-sport" />
<input type="hidden" name="sportsSeasonId" value={templateSport.sportsSeasonId} />
<input type="hidden" name="sportId" value={templateSport.sportId} />
<Button
type="submit"
variant="ghost"
@ -274,22 +241,22 @@ export default function EditTemplate({ loaderData, actionData }: Route.Component
</div>
)}
{availableSportsSeasons.length > 0 ? (
{availableSports.length > 0 ? (
<Form method="post" className="flex gap-2">
<input type="hidden" name="intent" value="add-sport" />
<Select
name="sportsSeasonId"
name="sportId"
value={selectedSportId}
onValueChange={setSelectedSportId}
required
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select a sports season" />
<SelectValue placeholder="Select a sport" />
</SelectTrigger>
<SelectContent>
{availableSportsSeasons.map((season) => (
<SelectItem key={season.id} value={season.id}>
{season.sport.name} - {season.name} ({season.year})
{availableSports.map((sport) => (
<SelectItem key={sport.id} value={sport.id}>
{sport.name}
</SelectItem>
))}
</SelectContent>
@ -300,7 +267,7 @@ export default function EditTemplate({ loaderData, actionData }: Route.Component
</Form>
) : (
<p className="text-sm text-muted-foreground text-center py-4">
All available sports seasons have been added
All available sports have been added
</p>
)}
</CardContent>

View file

@ -22,27 +22,15 @@ export function meta(): Route.MetaDescriptors {
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const name = formData.get("name");
const year = formData.get("year");
const description = formData.get("description");
// Validation
if (typeof name !== "string" || !name.trim()) {
return { error: "Template name is required" };
}
if (typeof year !== "string") {
return { error: "Year is required" };
}
const yearNum = parseInt(year, 10);
if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2100) {
return { error: "Year must be between 2000 and 2100" };
}
try {
const template = await createSeasonTemplate({
name: name.trim(),
year: yearNum,
description: typeof description === "string" && description.trim() ? description.trim() : null,
isActive: true,
});
@ -55,15 +43,13 @@ export async function action({ request }: Route.ActionArgs) {
}
export default function NewTemplate({ actionData }: Route.ComponentProps) {
const currentYear = new Date().getFullYear();
return (
<div className="p-8">
<div className="max-w-2xl">
<div className="mb-6">
<h1 className="text-3xl font-bold">Create Season Template</h1>
<p className="text-muted-foreground mt-1">
Bundle sports seasons together for league commissioners
Bundle sports together for league commissioners
</p>
</div>
@ -82,7 +68,7 @@ export default function NewTemplate({ actionData }: Route.ComponentProps) {
id="name"
name="name"
type="text"
placeholder="e.g., 2025 Full Season, 2025 Playoffs Only"
placeholder="e.g., Full Season, Playoffs Only"
required
/>
<p className="text-sm text-muted-foreground">
@ -90,25 +76,12 @@ export default function NewTemplate({ actionData }: Route.ComponentProps) {
</p>
</div>
<div className="space-y-2">
<Label htmlFor="year">Year</Label>
<Input
id="year"
name="year"
type="number"
min="2000"
max="2100"
defaultValue={currentYear}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description (Optional)</Label>
<Textarea
id="description"
name="description"
placeholder="Describe what sports seasons are included in this template"
placeholder="Describe what sports are included in this template"
rows={3}
/>
</div>
@ -138,7 +111,7 @@ export default function NewTemplate({ actionData }: Route.ComponentProps) {
<CardContent className="text-sm text-foreground/80">
<p>After creating the template, you'll be able to:</p>
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Add sports seasons to the template</li>
<li>Add sports to the template</li>
<li>Mark which sports are required vs optional</li>
<li>Set the number of flex spots</li>
</ul>

View file

@ -77,7 +77,6 @@ export default function AdminTemplates({ loaderData }: Route.ComponentProps) {
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Year</TableHead>
<TableHead>Status</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Actions</TableHead>
@ -87,7 +86,6 @@ export default function AdminTemplates({ loaderData }: Route.ComponentProps) {
{templates.map((template) => (
<TableRow key={template.id}>
<TableCell className="font-medium">{template.name}</TableCell>
<TableCell>{template.year}</TableCell>
<TableCell>
<Badge variant={template.isActive ? "default" : "secondary"}>
{template.isActive ? "Active" : "Inactive"}

View file

@ -10,7 +10,7 @@ import { createCommissioner } from "~/models/commissioner";
import { createSeason } from "~/models/season";
import { linkMultipleSportsToSeason } from "~/models/season-sport";
import { createManyTeams } from "~/models/team";
import { findActiveSeasonTemplates, findSeasonTemplateWithSportsSeasons } from "~/models/season-template";
import { findActiveSeasonTemplates, findSeasonTemplateWithSports } from "~/models/season-template";
import { findDraftableSportsSeasons } from "~/models/sports-season";
import { findUserById, updateUser } from "~/models/user";
import { generateUniqueTeamNames, prependOwnerToTeamName } from "~/utils/team-names";
@ -57,7 +57,6 @@ type SportSeason = {
type Template = {
id: string;
name: string;
year: number;
description: string | null;
sportsSeasonIds: string[];
};
@ -104,14 +103,18 @@ export async function loader(args: Route.LoaderArgs) {
const templates = await findActiveSeasonTemplates();
const allSportsSeasons = await findDraftableSportsSeasons();
const draftableSeasonBySportId = new Map(
allSportsSeasons.map((s) => [s.sportId, s])
);
const templatesWithSports = await Promise.all(
templates.map(async (template) => {
const t = await findSeasonTemplateWithSportsSeasons(template.id);
const t = await findSeasonTemplateWithSports(template.id);
return {
...template,
sportsSeasonIds: t?.seasonTemplateSports?.map(
(ts: { sportsSeason: { id: string } }) => ts.sportsSeason.id
) ?? [],
sportsSeasonIds: (t?.seasonTemplateSports ?? [])
.map((ts: { sportId: string }) => draftableSeasonBySportId.get(ts.sportId)?.id)
.filter((id): id is string => id !== null && id !== undefined),
};
})
);

View file

@ -80,14 +80,11 @@ const ImportDataSchema = z.object({
seasonTemplates: z.array(
z.object({
name: z.string(),
year: z.number(),
description: z.string().nullable(),
isActive: z.boolean(),
sports: z.array(
z.object({
sportSlug: z.string(),
seasonName: z.string(),
seasonYear: z.number(),
})
),
})
@ -133,10 +130,10 @@ export async function exportSportsDataToJSON(): Promise<ExportData> {
db.query.seasonTemplates.findMany({
with: {
seasonTemplateSports: {
with: { sportsSeason: { with: { sport: true } } },
with: { sport: true },
},
},
orderBy: (t, { desc }) => [desc(t.year)],
orderBy: (t, { asc }) => [asc(t.name)],
}),
]);
@ -197,13 +194,10 @@ export async function exportSportsDataToJSON(): Promise<ExportData> {
})),
seasonTemplates: seasonTemplates.map((t) => ({
name: t.name,
year: t.year,
description: t.description,
isActive: t.isActive,
sports: t.seasonTemplateSports.map((ts) => ({
sportSlug: ts.sportsSeason.sport.slug,
seasonName: ts.sportsSeason.name,
seasonYear: ts.sportsSeason.year,
sportSlug: ts.sport.slug,
})),
})),
};
@ -529,10 +523,7 @@ export async function importSportsDataFromJSON(
for (const template of importData.seasonTemplates) {
const existing = await tx.query.seasonTemplates.findFirst({
where: and(
eq(schema.seasonTemplates.name, template.name),
eq(schema.seasonTemplates.year, template.year)
),
where: eq(schema.seasonTemplates.name, template.name),
});
let templateId: string;
@ -561,7 +552,6 @@ export async function importSportsDataFromJSON(
.insert(schema.seasonTemplates)
.values({
name: template.name,
year: template.year,
description: template.description,
isActive: template.isActive,
})
@ -571,12 +561,11 @@ export async function importSportsDataFromJSON(
}
for (const templateSport of template.sports) {
const seasonKey = `${templateSport.sportSlug}:${templateSport.seasonName}:${templateSport.seasonYear}`;
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
if (sportsSeasonId) {
const sportId = sportIdMap.get(templateSport.sportSlug);
if (sportId) {
await tx.insert(schema.seasonTemplateSports).values({
templateId,
sportsSeasonId,
sportId,
});
}
}

View file

@ -190,11 +190,12 @@ export const seasonTemplates = pgTable("season_templates", {
id: uuid("id").primaryKey().defaultRandom(),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
year: integer("year").notNull(),
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
}, (t) => ({
uniqueName: uniqueIndex("season_templates_name_unique").on(t.name),
}));
export const seasons = pgTable("seasons", {
id: uuid("id").primaryKey().defaultRandom(),
@ -569,11 +570,13 @@ export const seasonTemplateSports = pgTable("season_template_sports", {
templateId: uuid("template_id")
.notNull()
.references(() => seasonTemplates.id, { onDelete: "cascade" }),
sportsSeasonId: uuid("sports_season_id")
sportId: uuid("sport_id")
.notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
.references(() => sports.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
}, (t) => ({
uniqueTemplateSport: uniqueIndex("season_template_sports_template_sport_unique").on(t.templateId, t.sportId),
}));
export const seasonSports = pgTable("season_sports", {
id: uuid("id").primaryKey().defaultRandom(),
@ -976,6 +979,7 @@ export const sportsRelations = relations(sports, ({ many }) => ({
sportsSeasons: many(sportsSeasons),
tournaments: many(tournaments),
participants: many(participants),
seasonTemplateSports: many(seasonTemplateSports),
}));
export const sportsSeasonsRelations = relations(sportsSeasons, ({ one, many }) => ({
@ -990,7 +994,6 @@ export const sportsSeasonsRelations = relations(sportsSeasons, ({ one, many }) =
participants: many(seasonParticipants),
simulatorConfig: one(sportsSeasonSimulatorConfigs),
simulatorInputs: many(seasonParticipantSimulatorInputs),
seasonTemplateSports: many(seasonTemplateSports),
seasonSports: many(seasonSports),
participantResults: many(seasonParticipantResults),
regularSeasonStandings: many(regularSeasonStandings),
@ -1094,9 +1097,9 @@ export const seasonTemplateSportsRelations = relations(seasonTemplateSports, ({
fields: [seasonTemplateSports.templateId],
references: [seasonTemplates.id],
}),
sportsSeason: one(sportsSeasons, {
fields: [seasonTemplateSports.sportsSeasonId],
references: [sportsSeasons.id],
sport: one(sports, {
fields: [seasonTemplateSports.sportId],
references: [sports.id],
}),
}));

View file

@ -0,0 +1,282 @@
# Deadline-based draft timer — implementation guide
Companion to [`docs/infrastructure-roadmap.md`](../infrastructure-roadmap.md) Phase 1. This is the "how" doc; the roadmap is the "why."
## The shape of the change
Today, `server/timer.ts` runs a 1-second `setInterval` that decrements `draftTimers.timeRemaining` and broadcasts `timer-update` events. Everything else (manual picks, admin adjustments, pause/resume) just writes `timeRemaining`; the tick is the only thing that knows about "time."
After this change, **time becomes a deadline (a `timestamptz`) stored on the `seasons` row**. The server schedules a single `setTimeout` per active draft, sleeps until the deadline, and fires the auto-pick. Per-second writes and per-second broadcasts go away.
## Schema
One new nullable column on `seasons`:
```sql
ALTER TABLE seasons ADD COLUMN pick_deadline_at timestamptz;
```
Drizzle:
```ts
// app/database/schema.ts — seasons table
pickDeadlineAt: timestamp("pick_deadline_at", { withTimezone: true }),
```
Semantics:
- **NULL** when the draft is paused, pre-draft, completed, or before the first deadline has been written.
- **Set** to an absolute UTC timestamp when the current pick is actively counting down.
`draftTimers.timeRemaining` keeps its existing meaning (per-team remaining seconds, used for chess-clock bank and as "saved seconds while paused"). No other schema changes.
## The single helper every callsite uses
The risk in this refactor is forgetting one of the four+ places that write `draftTimers` today. Mitigate by funneling all deadline writes through one helper.
```ts
// app/models/draft-timer.ts
export interface SetNextPickDeadlineParams {
seasonId: string;
teamId: string; // the team whose pick this is (for chess-clock bank update)
budgetSeconds: number; // seconds until the deadline
tx: DbOrTx; // pass the enclosing transaction
}
export async function setNextPickDeadline({
seasonId, teamId, budgetSeconds, tx,
}: SetNextPickDeadlineParams): Promise<{ deadline: Date }> {
const now = new Date();
const deadline = new Date(now.getTime() + budgetSeconds * 1000);
// 1. Write the deadline on the season.
await tx.update(schema.seasons)
.set({ pickDeadlineAt: deadline })
.where(eq(schema.seasons.id, seasonId));
// 2. Reset the team's timeRemaining so clients/admin tools that read it stay
// consistent. Chess-clock callers should pre-compute the new bank and pass it
// as budgetSeconds; this function does not know the mode.
await tx.update(schema.draftTimers)
.set({ timeRemaining: budgetSeconds, updatedAt: now })
.where(and(
eq(schema.draftTimers.seasonId, seasonId),
eq(schema.draftTimers.teamId, teamId),
));
return { deadline };
}
```
The scheduler module (below) consumes the returned `{ deadline }` to (re)register the in-memory `setTimeout` and emit the socket event. Keep DB writes inside `tx`; do the in-memory + socket work *after* the transaction commits to avoid scheduling against an aborted write.
## The scheduler module
```ts
// server/scheduler.ts (rename target for the rewritten server/timer.ts)
const timers = new Map<string, { deadline: Date; handle: NodeJS.Timeout }>();
export function scheduleDeadline(seasonId: string, deadline: Date): void {
const existing = timers.get(seasonId);
if (existing) clearTimeout(existing.handle);
const delay = Math.max(0, deadline.getTime() - Date.now());
const handle = setTimeout(() => fireDeadline(seasonId, deadline), delay);
timers.set(seasonId, { deadline, handle });
}
export function cancelDeadline(seasonId: string): void {
const existing = timers.get(seasonId);
if (!existing) return;
clearTimeout(existing.handle);
timers.delete(seasonId);
}
async function fireDeadline(seasonId: string, expected: Date): Promise<void> {
// Re-read state — the closure may be stale.
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
if (!season) return; // gone
if (season.status !== "draft") return; // completed/cancelled
if (season.draftPaused) return; // paused mid-flight
if (!season.pickDeadlineAt) return; // paused or cleared
if (season.pickDeadlineAt.getTime() !== expected.getTime()) {
// Someone changed the deadline (admin adjust, etc). Reschedule, don't fire.
return scheduleDeadline(seasonId, season.pickDeadlineAt);
}
// Deadline still valid — run the auto-pick.
await runAutoPickForCurrentTeam(seasonId);
}
```
On boot, hydrate from the DB:
```ts
export async function bootstrapScheduler(): Promise<void> {
const active = await db.query.seasons.findMany({
where: and(
eq(schema.seasons.status, "draft"),
isNotNull(schema.seasons.pickDeadlineAt),
),
});
for (const s of active) {
scheduleDeadline(s.id, s.pickDeadlineAt!);
}
}
```
Already-expired deadlines fire immediately (`Math.max(0, …)``setTimeout(0)`), and `executeAutoPick`'s existing idempotency (`(seasonId, pickNumber)` uniqueness with `ON CONFLICT DO NOTHING` in `app/models/draft-utils.ts:629`) protects against duplicate firing if the crash happened mid-pick.
## Callsite migration
Five sites need to call `setNextPickDeadline` after this change:
### 1. Draft auto-start — `app/services/draft-autostart.ts`
Today: deletes timer rows, re-seeds them with `timeRemaining: initialTime`. After the draft transitions to `status='draft'`, call the helper for pick #1's team:
```ts
const firstTeamId = /* pick 1's team from draftSlots */;
const { deadline } = await setNextPickDeadline({
seasonId, teamId: firstTeamId, budgetSeconds: initialTime, tx,
});
// After commit:
scheduleDeadline(seasonId, deadline);
emitDeadlineEvent(seasonId, deadline);
```
### 2. Manual pick — `app/routes/api/draft.make-pick.ts`
Today (`:216-262`): updates next picker's `timeRemaining` to `incrementTime` (standard) or `bank + incrementTime` (chess clock). Replace those raw `UPDATE` statements with a call to `setNextPickDeadline` for the *next* team, passing the computed budget. Inside the same transaction as the pick insert.
### 3. Auto-pick — `server/timer.ts` rewrite
The old `triggerAutoPick` path becomes `runAutoPickForCurrentTeam` (called from `fireDeadline`). After `executeAutoPick` succeeds, compute the next picker's budget the same way `draft.make-pick.ts` does and call `setNextPickDeadline`. If the just-picked draft completes (no next pick), call `cancelDeadline` and clear `pickDeadlineAt`.
**Move `while_on` cascade here:** after setting the next deadline, if the next team is `while_on` autodraft, fire `runAutoPickForCurrentTeam` immediately (recursive call), capped at `totalTeams` iterations. Same semantics as today's tick loop.
### 4. Admin time-bank adjust — `app/routes/api/draft.adjust-time-bank.ts`
Today (`:76-78`): writes new `timeRemaining`. After the rewrite, if the team being adjusted is the *current* picker, also update `pickDeadlineAt` to reflect the new budget:
```ts
if (teamId === currentPickerTeamId && !season.draftPaused) {
await setNextPickDeadline({ seasonId, teamId, budgetSeconds: newTime, tx });
scheduleDeadline(seasonId, /* new deadline */);
}
```
For non-current teams, only `timeRemaining` changes (no deadline impact).
### 5. Pause / Resume
- **Pause:** compute remaining = `pickDeadlineAt now()`, write into `draftTimers.timeRemaining`, null out `seasons.pickDeadlineAt`, call `cancelDeadline(seasonId)`.
- **Resume:** call `setNextPickDeadline` with `budgetSeconds = current draftTimers.timeRemaining`. Same code path as a fresh pick start.
## Overnight pause
Existing logic in `server/timer.ts` (`checkOvernightPause`, `isInOvernightWindow`, `getOvernightResumeUTC`) ports cleanly. When `setNextPickDeadline` is about to write a deadline that falls inside an overnight window, instead write `deadline = resumeAtUTC`, and include a `frozen: true` flag in the socket event so the UI shows the pause indicator. The scheduler doesn't need to know — it just wakes up at `resumeAtUTC`.
Edge: if a pause window crosses an admin time-bank adjust, the recompute path naturally re-evaluates the window. Always go through `setNextPickDeadline`.
## Client side
### Socket event
Replace the per-second `timer-update` with a one-shot `deadline-update`:
```ts
// shared types
interface DeadlineUpdate {
seasonId: string;
teamId: string;
pickNumber: number;
deadline: string; // ISO timestamp
serverNow: string; // ISO timestamp at emit time
frozen?: boolean; // overnight pause
resumesAtUTC?: number; // when frozen
}
```
(You can keep the `timer-update` name if you'd rather minimize churn — there's exactly one consumer, `useDraftSocketEvents.ts:261`. Renaming makes the migration grep-able; not renaming makes the diff smaller. Suggest renaming.)
### Handler
`useDraftSocketEvents.ts:handleTimerUpdate` becomes:
```ts
const handleDeadlineUpdate = (data: DeadlineUpdate) => {
const offset = new Date(data.serverNow).getTime() - Date.now();
setTimerState({
deadlineMs: new Date(data.deadline).getTime(),
serverOffsetMs: offset,
pickNumber: data.pickNumber,
frozen: data.frozen ?? false,
});
};
```
### Countdown rendering
Wherever the draft room renders the countdown, compute locally:
```ts
const remainingMs = timerState.deadlineMs - (Date.now() + timerState.serverOffsetMs);
const remainingSeconds = Math.max(0, Math.ceil(remainingMs / 1000));
```
Drive re-renders via `requestAnimationFrame` (smoother) or a 1Hz `setInterval` (cheaper, matches today's perceived cadence). Force a recompute on `visibilitychange → visible` so tab-backgrounded users get an accurate countdown on return.
### Loader
The draft room loader (`app/routes/leagues/$leagueId.draft.$seasonId.tsx`) must return `pickDeadlineAt` (and a server timestamp) alongside the existing payload. The reconnect path (`useDraftSocket`'s `revalidate` on reconnect) then re-initializes the timer without waiting for a socket event.
## Migration strategy
Two-stage deploy to keep correctness rollback-able:
**Stage 1 — shadow writes (1 day in production).**
1. Add the column.
2. Update all five callsites to call `setNextPickDeadline` (which writes `pickDeadlineAt`).
3. Leave the 1-second `setInterval` running as the authoritative auto-pick path.
4. Add a metric: at each tick, log `actualPickDeadline = now() + timeRemaining` vs the stored `pickDeadlineAt`. They should agree to within ~1 second.
5. Observe a draft or two. Spot-check the metric.
**Stage 2 — flip authority.**
1. Replace `startDraftTimerSystem()` with `bootstrapScheduler()` in `server.ts`.
2. The scheduler is now authoritative; the 1-second interval is gone.
3. Roll out. Confirm `deadline-fire accuracy` metric (actual vs scheduled fire time) stays under 100ms.
**Rollback:** revert the deploy. The `pickDeadlineAt` column remains but is unused; `timeRemaining` semantics are unchanged, so the old tick resumes correctly.
## Tests
New tests (add alongside existing `server/__tests__/timer-*.test.ts`):
1. **Idempotency on restart.** Insert a draft with `pickDeadlineAt` in the past and an existing pick at `pickNumber`. Call `bootstrapScheduler()`. Assert no duplicate pick is created (relies on `executeAutoPick`'s existing `ON CONFLICT DO NOTHING`).
2. **Stale closure reschedule.** Schedule a deadline, mutate `pickDeadlineAt` in the DB to a later time, advance fake timers to the original deadline. Assert `fireDeadline` reschedules instead of picking.
3. **Pause during in-flight.** Schedule a deadline, set `draftPaused=true` in the DB, advance fake timers. Assert no pick is made.
4. **Helper invariant.** Every test that simulates a pick (manual or auto) asserts that *both* `seasons.pickDeadlineAt` and `draftTimers.timeRemaining` were updated. This catches future callsites that bypass `setNextPickDeadline`.
5. **`while_on` cascade.** Three teams all in `while_on` mode; one manual pick by team 1 should cascade to picks by teams 2 and 3 immediately, then schedule the next deadline.
6. **Overnight pause covers deadline.** Set an overnight window 5 minutes before the computed deadline. Assert the stored deadline equals `resumeAtUTC` (not `now + budget`) and the event payload has `frozen: true`.
Existing tests under `server/__tests__/` and `app/models/__tests__/executeAutoPick.timer.test.ts` should still pass — verify before flipping authority.
## Observability
Replace the Phase 0 "tick drift" metric:
```ts
// At fireDeadline entry:
const drift = Date.now() - expectedDeadline.getTime();
logger.log("[Scheduler] deadline fired", { seasonId, driftMs: drift });
```
`driftMs > 500` is the new "tick drift > 1100ms." Anything above a few hundred ms means the event loop was blocked at the moment of firing — worth investigating but not user-visible (the re-read guard handles arbitrary drift correctly).
## What this does *not* solve
- **Multi-instance scheduling.** Two web instances both calling `bootstrapScheduler()` would both schedule `setTimeout`s for the same draft, leading to duplicate auto-pick attempts. Idempotency saves correctness but you'd get duplicate broadcasts. Phase 5 problem, not Phase 1; document the "single scheduler instance" assumption in `server/scheduler.ts`.
- **Scheduled-draft auto-start.** `checkAndAutoStartDrafts` (currently called every tick) needs its own home. Easiest: a separate low-frequency (3060s) `setInterval` in `server.ts`. Rare event, doesn't need precision.
- **Pick-completion broadcast volume.** The existing `pick-made` event still fires per pick; this refactor doesn't change that. Only the per-second `timer-update` chatter goes away.

View file

@ -0,0 +1,281 @@
# Infrastructure Roadmap for brackt.com
## Context
brackt.com today runs as a **single Node.js process** that bundles three concerns:
1. The Express + React Router 7 SSR web server (`server.ts`)
2. A 1-second `setInterval` tick loop driving draft timers and auto-picks (`server/timer.ts`)
3. A Socket.IO server with **in-memory** room/connection state (`server/socket.ts`)
Plus a 24h snapshot interval (`server/snapshots.ts`). There is no job queue, no scheduler beyond `setInterval`, and no pub/sub layer. Deploys drop all socket connections; a crash takes down drafts, web, and the tick simultaneously.
Based on the discovery questions:
- **No HA fire.** Drafts are infrequent enough that a 30-min outage isn't catastrophic *yet*.
- **Zero-downtime deploys are the immediate pain.** Connections drop on every release.
- **Low concurrency today** (<5 live drafts, <20 in 6mo) capacity is not the constraint.
- **Avoid Redis** unless clearly justified; prefer Postgres-backed.
- **Go rewrite is speculative.** No measurements show Node is the bottleneck.
The roadmap below sequences work by **risk-adjusted value**: cheap, reversible wins first; expensive rewrites only when data demands them.
---
## Current production topology
What's actually deployed today (per `.production-info/docker-compose-production.yml`):
- **Single `brackt` container** running on Vultr, image pulled from `sjc.vultrcr.com/chrisparsons/brackt:latest`.
- **One-shot `migrate` container** runs `node /app/scripts/migrate.mjs` before brackt starts (`depends_on: condition: service_completed_successfully`).
- **Traefik** is already fronting the app — TLS via Let's Encrypt, `brackt.com` apex + `www` → apex redirect, port 3000.
- **External Postgres** (not in this compose). `PGBOUNCER=true` is set in env — the app connects through Vultr's built-in pgbouncer. Vultr supports per-pool mode selection (session/transaction/statement), so worst case is "add a second pool" rather than re-architecting.
- **No observability stack.** No Prometheus, Loki, Grafana, no log shipper. Logs live in `docker logs brackt`.
- **No Redis, no separate worker, no tick container.** Everything is one process.
- `restart_policy: condition: on-failure, max_attempts: 5` — after 5 crashes the container stays down with no alerting.
- **Hidden blockers for any multi-replica future:** `container_name: brackt` is hardcoded (Docker won't allow two containers with the same name), no healthcheck on the brackt service, and the image tag is `:latest` (rollbacks are painful and you can't tell which replica is on which version mid-deploy).
These facts shape the phases below — particularly the deadline-based timer recommendation that sidesteps several of these constraints entirely.
---
## Roadmap
### Phase 0 — Instrumentation (12 days) ⭐ do this first
You cannot make further sequencing decisions without data. Almost everything below ("is the tick slow?", "do we need multi-instance?", "is the timer refactor working?") becomes guesswork without basic metrics.
**Add:**
- Tick drift measurement in `server/timer.ts` — log when actual interval > 1100ms.
- Auto-pick latency: time from `timeRemaining <= 0``executeAutoPick()` resolved.
- Concurrent socket connections + active draft rooms gauge.
- Basic HTTP request duration histogram (any lightweight middleware).
**Where to look at the data — pick one, don't leave this abstract:**
| Option | Effort | What you get |
|---|---|---|
| Structured JSON logs to stdout, view via `docker logs brackt \| jq` | ~2 hours | Good enough for low-volume signals (tick drift, auto-pick latency). Start here. |
| Grafana + Loki + Promtail as sidecars in the compose | ~1 day | Real dashboards, log retention, query history |
| Hosted (Axiom / Better Stack / Grafana Cloud free tier) | ~half day | No infra to run; ship logs over the wire |
**Recommendation:** start with structured JSON + `jq` for a week, escalate only if you find yourself wanting historical queries you can't get from logs.
**Why first:** every decision below is currently a guess. A week of metrics turns guesses into decisions, *and* gives the Phase 1 timer refactor a baseline to compare against.
---
### Phase 1 — Deadline-based draft timer (35 days) ⭐ biggest win
**The problem:** the 1-second `setInterval` in `server/timer.ts` is the *cause* of nearly every downstream problem in this roadmap. Per active draft, every second it issues an UPDATE on `draftTimers`, several SELECTs, and a `timer-update` socket broadcast. It also makes the process un-killable without dropping drafts, makes multi-instance hard, and is the only thing in this stack that looks like it might need Go.
**The insight:** the timer state is *deterministic from a deadline*. If you store `pickDeadlineAt` (absolute UTC) instead of `timeRemaining` (seconds), the remaining time at any moment is just `pickDeadlineAt now()`. The server doesn't need to tick — it only needs to wake up *at the deadline* to run the auto-pick.
**The change:**
Schema (one new column total):
```
seasons
+ pickDeadlineAt timestamptz NULL -- the active deadline for the current pick;
-- NULL while paused, pre-draft, or completed
```
Why on `seasons` and not `draftTimers`: only one team's deadline matters at a time (the current picker's). One row per draft matches the semantics and gives a trivial bootstrap query (`SELECT id FROM seasons WHERE status='draft' AND pickDeadlineAt IS NOT NULL`). The existing `draftTimers.timeRemaining` stays where it is — still needed for chess-clock bank tracking and as the "saved remaining seconds while paused" storage.
Server behavior — funnel **every** deadline write through a single helper:
```ts
async function setNextPickDeadline(tx, { seasonId, teamId, budgetSeconds }) {
// 1. UPDATE seasons SET pickDeadlineAt = now() + budgetSeconds
// 2. UPDATE draftTimers SET timeRemaining = budgetSeconds (chess-clock: add increment)
// 3. (re)schedule in-memory setTimeout for this seasonId
// 4. emit { deadline, serverNow } on draft-${seasonId}
}
```
Callsites (audit surface):
- **Draft auto-start** (`app/services/draft-autostart.ts`) — first pick's deadline.
- **Manual pick** (`app/routes/api/draft.make-pick.ts`) — next pick's deadline (currently writes `timeRemaining` only).
- **Auto-pick** (`server/timer.ts` rewrite) — next pick's deadline after auto-picking.
- **Admin time-bank adjust** (`app/routes/api/draft.adjust-time-bank.ts`) — recompute `pickDeadlineAt` to match new `timeRemaining`.
- **Resume from pause** — same as auto-start but for the current picker.
Other behaviors:
- **Process restart:** on boot, query active drafts where `pickDeadlineAt IS NOT NULL` and re-schedule a `setTimeout` for each. Already-expired deadlines auto-pick immediately.
- **Pause:** save remaining into existing `draftTimers.timeRemaining` (`= pickDeadlineAt now()`), null out `seasons.pickDeadlineAt`, clear the in-memory timeout. **No new "paused remaining" column needed**`timeRemaining` already serves this purpose.
- **Overnight pause:** when scheduling, if the deadline falls inside the pause window, schedule for `resumeAtUTC` instead and emit a `frozen` flag.
- **`while_on` autodraft:** unchanged semantics — fire immediately on pick transition, capped at `totalTeams` iterations. The logic moves from the tick to the pick-completion handler (inside `setNextPickDeadline` or just before it).
Client behavior:
- Receive `{ deadline, serverNow }`; compute clock offset once.
- Render countdown locally from `deadline clientNow() + offset`. Already has to do this between 1Hz emits today, so the interpolation code likely exists.
**Wins vs today:**
| | 1s setInterval (today) | Deadline-based |
|---|---|---|
| DB writes per active draft per pick | ~30120 | 1 |
| Socket emits per active draft per pick | ~30120 | 1 (deadline) + 1 (pick made) |
| Server wake-ups per second | 1 always | 0 (sleeps until deadline) |
| Survives process restart | dies | one-line bootstrap query |
| Cross-process / multi-instance | hard (singleton tick required) | trivial (whichever instance handles the pick schedules the next deadline) |
**Critical constraints:**
- **Single scheduler assumption.** Phase 1 runs in one process. Multi-instance leader-election (per-`seasonId` advisory lock + claim metadata) is deferred to Phase 5. Document this assumption explicitly in `server/timer.ts`.
- **Clock skew.** Send `serverNow` with each deadline event so clients can correct.
- **Reconnect path.** The loader must return `pickDeadlineAt` (and `serverNow`) so clients re-initializing after a reconnect render the correct countdown without waiting for a socket event.
- **Migration safety.** Run both systems in parallel for one draft (deadline-based writes the new column; old setInterval still drives picks). Compare. Flip when confident.
**Why this matters more than splitting the tick into its own process:** it dissolves the problem rather than relocating it. Once the tick is deadline-based and idle 99% of the time, splitting it into its own container becomes a deploy-isolation play, not a correctness one. And the Phase 5 Go rewrite goes off the table permanently — there is no hot loop left to optimize.
**Files:** `server/timer.ts` (rewrite as a scheduler module), `app/database/schema.ts` + new migration (single column on `seasons`), `app/models/draft-timer.ts` (new `setNextPickDeadline` helper — see implementation guide), `app/services/draft-autostart.ts` (call helper on draft start), `app/routes/api/draft.make-pick.ts` (call helper on manual pick), `app/routes/api/draft.adjust-time-bank.ts` (recompute deadline on adjust), `app/hooks/useDraftSocketEvents.ts` (sole `timer-update` consumer — switch to deadline event), draft room loader (`app/routes/leagues/$leagueId.draft.$seasonId.tsx`), socket event types.
**See [`docs/agents/deadline-based-timer.md`](agents/deadline-based-timer.md) for the implementation guide** — schema, helper signature, callsite-by-callsite migration, test plan.
**Gotchas to handle (load-bearing for correctness):**
1. **Stale in-memory `setTimeout` after out-of-band DB writes.** Anything that mutates `pickDeadlineAt` outside the scheduler (admin extend, retro edit) leaves the captured closure stale. *Mitigation:* the auto-pick callback must re-read the row and verify `pickDeadlineAt` still equals the expected value before firing; if changed, reschedule and return. This single guard also handles wall-clock vs. monotonic drift (NTP jumps, VM resume): the re-check catches "deadline already passed" and "deadline moved" with the same code path.
2. **Pause-during-flight race.** `setTimeout` fires → enters auto-pick logic → admin pauses concurrently. The re-read in (1) must also check `draftPaused` before proceeding. Contract: in-flight picks complete; future scheduled picks don't fire after pause.
3. **Idempotency on restart.** Bootstrap query (`pickDeadlineAt IS NOT NULL`) fires all expired deadlines on boot. Idempotency is **already guaranteed** by `executeAutoPick` (`app/models/draft-utils.ts:499512` pre-check + `:629650` `ON CONFLICT DO NOTHING` on `(seasonId, pickNumber)`). Add an explicit test: "auto-pick is idempotent across process restart."
4. **Transaction boundary.** Pick row + next `pickDeadlineAt` write must share one transaction. Otherwise a crash between them leaves the draft "completed pick / no deadline" — the bootstrap query won't catch it.
**Lower-severity edge cases (worth knowing, not blocking):**
- **`setTimeout` 24.8-day overflow:** irrelevant for per-pick deadlines; *don't* reuse this primitive for `season.draftDateTime` auto-start — keep that on a low-frequency (3060s) `setInterval` poll.
- **`while_on` cascade** moves from the tick to the pick-completion handler: check next team's autodraft mode before scheduling next deadline; fire immediately if `while_on`, capped at `totalTeams` iterations.
- **Client tab backgrounding** is *better* than today: browsers throttle timers, but `visibilitychange → visible` triggers a recompute from the current deadline, so countdowns snap to truth instead of waiting for the next server emit.
- **Clock skew:** server includes `serverNow` with every deadline event; client computes a one-time offset.
- **Migration of in-flight drafts:** two-stage deploy — first ship shadow writes alongside the existing tick (observe a day), then flip the authoritative path and drop the interval.
- **Multi-instance scheduling** (Phase 5 inheritance): defer the leader-election design; document the "single scheduler" assumption explicitly. The bootstrap query recovers a crashed scheduler on restart, which is sufficient for one instance.
- **Observability swap:** the Phase 0 "tick drift" metric becomes meaningless after Phase 1. Replace with **deadline-fire accuracy** = `actualFireTime scheduledFireTime`. Same intent.
---
### Phase 2 — Scheduled work: snapshots off `setInterval` (1 day)
**The problem:** `server/snapshots.ts` uses a 24h `setInterval` that dies with the process, has no retry, and no visibility. As you add standings sync, scoring imports, EV snapshots, etc., this gets worse.
**The realistic options** (the original roadmap assumed pg-boss; given the production constraints, simpler choices win here):
| Option | Pros | Cons | Verdict |
|---|---|---|---|
| **External HTTP cron** (healthchecks.io free / GitHub Actions `schedule:` / cron-job.org) hitting `POST /admin/jobs/run-daily-snapshots` | Zero new infra; dead-man-switch alerting built-in; sidesteps pgbouncer entirely | External dependency; needs auth on the endpoint | ⭐ start here |
| `node-cron` in a small worker container with Postgres advisory lock for singleton | Self-contained; trivial; no 3rd-party | Build basic retry/durability yourself; another container | Good middle ground |
| `pg-boss` | Real job system, retries, audit table | Uses `LISTEN/NOTIFY` and prepared statements — incompatible with pgbouncer transaction pooling. **On Vultr, this is solvable:** create a second session-mode pool just for pg-boss and pass its DSN to that workload only. No built-in UI. | Defer until you have 5+ scheduled jobs |
| `pg_cron` extension | Runs inside Postgres; rock solid; no scheduler process. **Supported on Vultr** (`CREATE EXTENSION IF NOT EXISTS pg_cron;`). | Logic lives in SQL; for app-level work you'd call out via `pg_net → POST /admin/jobs/snapshots`, which is just external HTTP cron with extra steps | Skip for one job; reconsider if you accumulate many |
**Recommendation:** external HTTP cron. The "job" becomes an authenticated `POST /admin/jobs/run-daily-snapshots` route that calls the existing `createSnapshotsForAllSeasons()`. Healthchecks.io's free tier gives you "you didn't run today" alerts for free.
**Why now (not later):** it's a 1-hour change once you have an admin endpoint, and it removes the last `setInterval` from `server.ts` — which makes Phase 3 (process restart safety) trivially correct rather than "hopefully correct."
**Files:** new admin route `app/routes/admin/jobs.run-daily-snapshots.tsx` (POST handler with shared-secret auth), remove `startSnapshotSystem()` call from `server.ts`, delete or repurpose `server/snapshots.ts`.
---
### Phase 3 — Production hardening prerequisites (12 days)
Before any multi-replica / zero-downtime work, the production compose needs three small fixes. These are cheap and worth doing regardless of what comes next.
**The changes:**
1. **Remove hardcoded `container_name: brackt`.** Docker rejects duplicate names — this alone blocks scaling to 2 replicas. Use the service name + replica index (Compose handles this automatically when `container_name` is omitted).
2. **Add a healthcheck** to the brackt service (e.g., `GET /healthz` returns 200 once the server is listening and DB is reachable). Without this, Traefik will route to a half-started container during rolling deploys.
3. **Pin the image tag.** Move off `:latest` to a Git SHA or version. Rollbacks become a one-line edit; you can tell which replica is running which version during a rolling deploy.
**Files:** `.production-info/docker-compose-production.yml`, new `app/routes/healthz.tsx`, deploy script (whatever sets the tag).
---
### Phase 4 — Optional: split tick into its own process (1 week, no longer urgent)
**Status after Phase 1:** the "tick" is now an idle process that wakes up at deadlines. It can be co-located with web indefinitely without correctness or performance issues.
**When to revisit:** if you want pure deploy isolation — restart the web tier without touching the timer scheduler at all. This is a nice-to-have, not a blocker.
**The change (if/when):**
- Extract timer scheduling into `server/tick.ts` standalone entrypoint.
- Run as a second container in compose.
- Tick still emits via Socket.IO — either via Postgres `LISTEN/NOTIFY` to the web tier (⚠️ verify pgbouncer mode supports session-scoped listeners) or via a pending-broadcasts table the web tier polls. With Phase 1 done, the broadcast volume is tiny — polling is fine.
- Singleton enforced via Postgres advisory lock (same primitive Phase 1 already uses for per-draft scheduling).
**Why this is downgraded vs. the old roadmap:** Phase 1 already removed the per-second hot loop. The remaining motivation is purely "I want to deploy web independently of timer code." That's nice but not load-bearing.
---
### Phase 5 — Zero-downtime web deploys via Traefik + sticky sessions (23 days)
**The problem:** even after Phase 1, restarting the web container still drops sockets.
**The change:**
- Run **2 web containers** behind Traefik (prerequisites from Phase 3 must be done first).
- Enable **sticky sessions** in Traefik via labels: `traefik.http.services.brackt.loadbalancer.sticky.cookie=true`. Socket.IO needs this — clients must return to the same instance until you add a pub/sub adapter.
- Rolling deploy: take instance A out of Traefik's pool → wait for connections to drain (or hard-cut, since reconnect works) → deploy A → put back in pool → repeat for B.
- This works **without Redis** because each draft room's clients are sticky to one instance.
- For broadcasts that need to reach *both* instances (e.g., timer scheduled on instance A, but admin action on instance B): use Postgres `LISTEN/NOTIFY` (⚠️ verify pgbouncer mode first) or a pending-broadcasts table.
**Why this works at your scale:** with <20 concurrent drafts, a single web instance has zero capacity issues. The second instance is purely for deploy rotation.
**Files:** `.production-info/docker-compose-production.yml` (replicas + sticky labels), deploy script.
---
### Phase 6 — Defer: Socket.IO Redis adapter (only when needed)
**When to revisit:** if you ever want a single draft's clients to span multiple web instances (e.g., 50+ concurrent connections per draft, or geo-distributed users). You're nowhere near that.
**If/when you do:** add Redis, install `@socket.io/redis-adapter`, drop sticky sessions. ~1-day change if Phases 15 are clean.
---
### ~~Phase 7 — Go tick rewrite~~ (removed)
Phase 1 (deadline-based timer) eliminates the hot loop the Go rewrite was meant to optimize. The remaining scheduler is `setTimeout` and a Postgres query — Node handles this for free.
If you want to learn Go, pick a greenfield side service (a webhook receiver for scoring feeds, for instance). Don't put it on the critical path of live drafts.
---
## Sequencing Summary
| # | Phase | Effort | Unblocks |
|---|---|---|---|
| 0 | Instrumentation | 12d | Every decision below |
| 1 | **Deadline-based draft timer** | 35d | Eliminates the 1s tick; makes everything else easier |
| 2 | Snapshots → external HTTP cron | 1d | Removes the last `setInterval`; clean restart semantics |
| 3 | Prod hardening (no `container_name`, healthcheck, pinned tag) | 12d | Required for any multi-replica work |
| 4 | Split tick into own process | ~1wk | Optional after Phase 1; deploy isolation only |
| 5 | 2 web instances + Traefik sticky sessions | 23d | Zero-downtime deploys (the actual goal) |
| 6 | Redis + socket.io adapter | *deferred* | Cross-instance fan-out (not needed at current scale) |
**Total to "I can deploy without dropping connections": ~2 weeks of focused work** (Phases 0, 1, 2, 3, 5; Phase 4 optional).
---
## Open questions
**Resolved (Vultr managed Postgres):**
- **Pgbouncer modes:** Vultr ships pgbouncer built-in and supports all three modes (session, transaction, statement). You can create **multiple pools with different modes per cluster** via the Vultr panel — so the "pgbouncer incompatibility" concern for pg-boss / `LISTEN/NOTIFY` is *not* a hard blocker; it's "use a session-mode pool for those workloads, transaction-mode for the main app." *Action when relevant:* confirm what pool mode `PGBOUNCER=true` currently routes through (transaction is the typical default) and add a second session-mode pool the day you adopt pg-boss or `LISTEN/NOTIFY`.
- **`pg_cron`:** supported on Vultr (`CREATE EXTENSION IF NOT EXISTS pg_cron;`). Doesn't change the Phase 2 recommendation (external HTTP cron remains simpler for one job) but stays in the toolbox.
**Still open:**
- **Where do logs/metrics go?** Pick one in Phase 0 and commit.
---
## Critical files
- `server.ts` — main entrypoint; stops calling `startSnapshotSystem` (Phase 2); timer system becomes deadline-scheduler (Phase 1).
- `server/timer.ts` — rewrite to deadline-based scheduler (Phase 1); add tick drift metric *before* the rewrite to baseline (Phase 0).
- `server/snapshots.ts` — delete or repurpose; logic moves behind an admin endpoint (Phase 2).
- `server/socket.ts` — emit `{ deadline, serverNow }` events instead of per-second `timer-update` (Phase 1).
- `app/hooks/useDraftSocketEvents.ts` and draft room route — client-side countdown interpolation from deadline (Phase 1).
- `app/database/schema.ts` — add `pickDeadlineAt` to `seasons` (Phase 1).
- `.production-info/docker-compose-production.yml` — remove `container_name`, add healthcheck, pin tag, then add replicas + sticky labels (Phases 3, 5).
- New `app/routes/admin/jobs.run-daily-snapshots.tsx` — HTTP-triggered snapshot job (Phase 2).
- New `app/routes/healthz.tsx` — Traefik healthcheck target (Phase 3).
## Verification
- **Phase 0:** confirm metrics emit (grep logs for tick drift, auto-pick latency); leave running for a week before Phase 1 ships.
- **Phase 1:** run side-by-side mode in a staging draft — deadline-based writes shadow columns, setInterval still drives picks; confirm zero drift, then flip. Then: restart the process mid-draft, confirm timer keeps the right deadline and auto-pick fires at the original time.
- **Phase 2:** kill the web process mid-day; confirm the external cron still triggers tomorrow and the endpoint runs successfully.
- **Phase 3:** `curl /healthz` returns 200 only after DB is reachable; deploy with a pinned tag and confirm you can roll back by changing one line.
- **Phase 5:** rolling-deploy both web instances during a staging draft; users see at most a single brief reconnect (or none, with proper drain).

View file

@ -0,0 +1,13 @@
TRUNCATE TABLE "season_template_sports";
--> statement-breakpoint
ALTER TABLE "season_template_sports" DROP CONSTRAINT "season_template_sports_sports_season_id_sports_seasons_id_fk";
--> statement-breakpoint
ALTER TABLE "season_template_sports" ADD COLUMN "sport_id" uuid NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "season_template_sports" ADD CONSTRAINT "season_template_sports_sport_id_sports_id_fk" FOREIGN KEY ("sport_id") REFERENCES "public"."sports"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
ALTER TABLE "season_template_sports" DROP COLUMN IF EXISTS "sports_season_id";--> statement-breakpoint
ALTER TABLE "season_templates" DROP COLUMN IF EXISTS "year";

View file

@ -0,0 +1,2 @@
CREATE UNIQUE INDEX IF NOT EXISTS "season_template_sports_template_sport_unique" ON "season_template_sports" USING btree ("template_id","sport_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "season_templates_name_unique" ON "season_templates" USING btree ("name");

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
{
"id": "fd1d64d5-1381-4e52-866f-42e83690e1a3",
"id": "0116_panoramic_human_cannonball",
"prevId": "a26388b5-0b00-4107-9e26-4b4f4ed8dc31",
"version": "7",
"dialect": "postgresql",
@ -854,6 +854,13 @@
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"picks_expires_at": {
"name": "picks_expires_at",
"type": "timestamp",
@ -865,13 +872,6 @@
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
@ -3584,8 +3584,8 @@
"primaryKey": false,
"notNull": true
},
"sports_season_id": {
"name": "sports_season_id",
"sport_id": {
"name": "sport_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
@ -3613,12 +3613,12 @@
"onDelete": "cascade",
"onUpdate": "no action"
},
"season_template_sports_sports_season_id_sports_seasons_id_fk": {
"name": "season_template_sports_sports_season_id_sports_seasons_id_fk",
"season_template_sports_sport_id_sports_id_fk": {
"name": "season_template_sports_sport_id_sports_id_fk",
"tableFrom": "season_template_sports",
"tableTo": "sports_seasons",
"tableTo": "sports",
"columnsFrom": [
"sports_season_id"
"sport_id"
],
"columnsTo": [
"id"
@ -3656,12 +3656,6 @@
"primaryKey": false,
"notNull": false
},
"year": {
"name": "year",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"is_active": {
"name": "is_active",
"type": "boolean",

View file

@ -2,810 +2,122 @@
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1732076135211,
"tag": "0000_short_donald_blake",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1760165302772,
"tag": "0001_serious_la_nuit",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1760166775427,
"tag": "0002_tired_iron_man",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1760167848365,
"tag": "0003_public_shadowcat",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1760168383812,
"tag": "0004_spicy_zarda",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1760169537260,
"tag": "0005_abnormal_thunderbird",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1760241924414,
"tag": "0006_needy_junta",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1760327817376,
"tag": "0007_pink_nebula",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1760375514773,
"tag": "0008_salty_trauma",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1760392647508,
"tag": "0009_fuzzy_korvac",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1760469105799,
"tag": "0010_complex_the_stranger",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1760501621605,
"tag": "0011_gifted_mystique",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1760504208398,
"tag": "0012_yielding_phil_sheldon",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1760543654915,
"tag": "0013_pink_the_order",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1760588333462,
"tag": "0014_lowly_gateway",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1760589195551,
"tag": "0015_exotic_loners",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1760590495868,
"tag": "0016_majestic_roulette",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1760599350154,
"tag": "0017_calm_zarda",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1760996053371,
"tag": "0018_numerous_stardust",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1761112236540,
"tag": "0019_acoustic_ben_grimm",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1761719248881,
"tag": "0020_slim_spacker_dave",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1762190473418,
"tag": "0021_fair_ser_duncan",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1762191156317,
"tag": "0022_shiny_mother_askani",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1762200550705,
"tag": "0023_cynical_jack_power",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1763276901482,
"tag": "0024_faithful_mesmero",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1763277000000,
"tag": "0025_rename_participant_ev_season_to_sports_season",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1763278000000,
"tag": "0026_add_source_odds_to_participant_ev",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1763279000000,
"tag": "0027_add_projected_points_to_team_standings",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1763280000000,
"tag": "0028_add_tournament_groups",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1763281000000,
"tag": "0029_change_participant_ev_to_decimal",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1763282000000,
"tag": "0030_add_draft_picks_unique_slot",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1763283000000,
"tag": "0031_add_autodraft_queue_only",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1763284000000,
"tag": "0032_consolidate_playoff_pattern",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1763285000000,
"tag": "0033_add_schedule_event_type",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1763286000000,
"tag": "0034_add_ev_snapshots",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1763287000000,
"tag": "0035_increase_prob_precision",
"breakpoints": true
},
{
"idx": 36,
"version": "7",
"when": 1763288000000,
"tag": "0036_increase_ev_precision",
"breakpoints": true
},
{
"idx": 37,
"version": "7",
"when": 1763289000000,
"tag": "0037_add_simulator_type_to_sports",
"breakpoints": true
},
{
"idx": 38,
"version": "7",
"when": 1773122866361,
"tag": "0038_sad_wilson_fisk",
"breakpoints": true
},
{
"idx": 39,
"version": "7",
"when": 1773200000000,
"tag": "0039_add_ucl_bracket_simulator_type",
"breakpoints": true
},
{
"idx": 40,
"version": "7",
"when": 1773262080928,
"tag": "0040_fat_puma",
"breakpoints": true
},
{
"idx": 41,
"version": "7",
"when": 1773557500336,
"tag": "0041_great_magma",
"breakpoints": true
},
{
"idx": 42,
"version": "7",
"when": 1773600000000,
"tag": "0042_migrate_event_date_to_starts_at",
"breakpoints": true
},
{
"idx": 43,
"version": "7",
"when": 1773634394633,
"tag": "0043_demonic_vanisher",
"breakpoints": true
},
{
"idx": 44,
"version": "7",
"when": 1773700000000,
"tag": "0044_add_ncaam_bracket_simulator_type",
"breakpoints": true
},
{
"idx": 45,
"version": "7",
"when": 1773710000000,
"tag": "0045_add_ncaaw_bracket_simulator_type",
"breakpoints": true
},
{
"idx": 46,
"version": "7",
"when": 1773720000000,
"tag": "0046_add_nba_bracket_simulator_type",
"breakpoints": true
},
{
"idx": 47,
"version": "7",
"when": 1773730000000,
"tag": "0047_fix_nba_bracket_simulator_type",
"breakpoints": true
},
{
"idx": 48,
"version": "7",
"when": 1773765768905,
"tag": "0048_eager_songbird",
"breakpoints": true
},
{
"idx": 49,
"version": "7",
"when": 1773800000000,
"tag": "0049_add_admin_picked_by_type",
"breakpoints": true
},
{
"idx": 50,
"version": "7",
"when": 1773821310661,
"tag": "0050_chief_peter_quill",
"breakpoints": true
},
{
"idx": 51,
"version": "7",
"when": 1773900000000,
"tag": "0051_add_standings_snapshot_unique_index",
"breakpoints": true
},
{
"idx": 52,
"version": "7",
"when": 1773897678653,
"tag": "0052_new_warbird",
"breakpoints": true
},
{
"idx": 53,
"version": "7",
"when": 1773903201189,
"tag": "0053_smooth_kingpin",
"breakpoints": true
},
{
"idx": 54,
"version": "7",
"when": 1774070769112,
"tag": "0054_massive_vulcan",
"breakpoints": true
},
{
"idx": 55,
"version": "7",
"when": 1774072156237,
"tag": "0055_special_vampiro",
"breakpoints": true
},
{
"idx": 56,
"version": "7",
"when": 1774167142673,
"tag": "0056_jittery_the_fallen",
"breakpoints": true
},
{
"idx": 57,
"version": "7",
"when": 1774239259550,
"tag": "0057_cultured_doctor_doom",
"breakpoints": true
},
{
"idx": 58,
"version": "7",
"when": 1774310000000,
"tag": "0058_add_unique_participant_ev",
"breakpoints": true
},
{
"idx": 59,
"version": "7",
"when": 1774329951326,
"tag": "0059_mysterious_jack_murdock",
"breakpoints": true
},
{
"idx": 60,
"version": "7",
"when": 1774330910098,
"tag": "0060_omniscient_outlaw_kid",
"breakpoints": true
},
{
"idx": 61,
"version": "7",
"when": 1774410244601,
"tag": "0061_violet_mephistopheles",
"breakpoints": true
},
{
"idx": 62,
"version": "7",
"when": 1774500000000,
"tag": "0062_add_mlb_bracket_simulator_type",
"breakpoints": true
},
{
"idx": 63,
"version": "7",
"when": 1774504559479,
"tag": "0063_bored_ultimo",
"breakpoints": true
},
{
"idx": 64,
"version": "7",
"when": 1774598151282,
"tag": "0064_fuzzy_wolfsbane",
"breakpoints": true
},
{
"idx": 65,
"version": "7",
"when": 1774719902130,
"tag": "0065_kind_mantis",
"breakpoints": true
},
{
"idx": 66,
"version": "7",
"when": 1774802533652,
"tag": "0066_stiff_siren",
"breakpoints": true
},
{
"idx": 67,
"version": "7",
"when": 1774900000000,
"tag": "0067_darts_bracket",
"breakpoints": true
},
{
"idx": 68,
"version": "7",
"when": 1775485575205,
"tag": "0068_cs2_major_simulator",
"breakpoints": true
},
{
"idx": 69,
"version": "7",
"when": 1775485599229,
"tag": "0069_draft_window",
"breakpoints": true
},
{
"idx": 70,
"version": "7",
"when": 1775485630297,
"tag": "0070_verify_migrations",
"breakpoints": true
},
{
"idx": 71,
"version": "7",
"when": 1775528386356,
"tag": "0071_many_nico_minoru",
"breakpoints": true
},
{
"idx": 72,
"version": "7",
"when": 1775652883157,
"tag": "0072_jittery_steve_rogers",
"breakpoints": true
},
{
"idx": 73,
"version": "7",
"when": 1775679862606,
"tag": "0073_blushing_lady_vermin",
"breakpoints": true
},
{
"idx": 74,
"version": "7",
"when": 1775699707816,
"tag": "0074_clammy_tiger_shark",
"breakpoints": true
},
{
"idx": 75,
"version": "7",
"when": 1776101579459,
"tag": "0075_chubby_stephen_strange",
"breakpoints": true
},
{
"idx": 76,
"version": "7",
"when": 1776226761564,
"tag": "0076_heavy_zemo",
"breakpoints": true
},
{
"idx": 77,
"version": "7",
"when": 1776312815334,
"tag": "0077_lively_wolfpack",
"breakpoints": true
},
{
"idx": 78,
"version": "7",
"when": 1776314387095,
"tag": "0078_striped_rick_jones",
"breakpoints": true
},
{
"idx": 79,
"version": "7",
"when": 1776316296907,
"tag": "0079_wide_trauma",
"breakpoints": true
},
{
"idx": 80,
"version": "7",
"when": 1776318182740,
"tag": "0080_chemical_gorilla_man",
"breakpoints": true
},
{
"idx": 81,
"version": "7",
"when": 1777067050669,
"tag": "0081_careless_magdalene",
"breakpoints": true
},
{
"idx": 82,
"version": "7",
"when": 1777090754037,
"tag": "0082_stale_pride",
"breakpoints": true
},
{
"idx": 83,
"version": "7",
"when": 1777180952144,
"tag": "0083_fearless_leader",
"breakpoints": true
},
{
"idx": 84,
"version": "7",
"when": 1777477826949,
"tag": "0084_wooden_retro_girl",
"breakpoints": true
},
{
"idx": 85,
"version": "7",
"when": 1777483407743,
"tag": "0085_young_cobalt_man",
"breakpoints": true
},
{
"idx": 86,
"version": "7",
"when": 1777505810235,
"tag": "0086_marvelous_infant_terrible",
"breakpoints": true
},
{
"idx": 87,
"version": "7",
"when": 1777661299612,
"tag": "0087_small_susan_delgado",
"breakpoints": true
},
{
"idx": 88,
"version": "7",
"when": 1777666830394,
"tag": "0088_cheerful_norrin_radd",
"breakpoints": true
},
{
"idx": 89,
"version": "7",
"when": 1777675084016,
"tag": "0089_lonely_tigra",
"breakpoints": true
},
{
"idx": 90,
"version": "7",
"when": 1777677410094,
"tag": "0090_powerful_redwing",
"breakpoints": true
},
{
"idx": 91,
"version": "7",
"when": 1777700829535,
"tag": "0091_fast_inhumans",
"breakpoints": true
},
{
"idx": 92,
"version": "7",
"when": 1777745229462,
"tag": "0092_robust_klaw",
"breakpoints": true
},
{
"idx": 93,
"version": "7",
"when": 1777783789103,
"tag": "0093_ambiguous_hellcat",
"breakpoints": true
},
{
"idx": 94,
"version": "7",
"when": 1777920441672,
"tag": "0094_curved_nick_fury",
"breakpoints": true
},
{
"idx": 95,
"version": "7",
"when": 1778043585280,
"tag": "0095_lucky_madrox",
"breakpoints": true
},
{
"idx": 96,
"version": "7",
"when": 1778177368941,
"tag": "0096_demonic_vulture",
"breakpoints": true
},
{
"idx": 97,
"version": "7",
"when": 1778189247519,
"tag": "0097_puzzling_spectrum",
"breakpoints": true
},
{
"idx": 98,
"version": "7",
"when": 1778261539567,
"tag": "0098_lucky_sabra",
"breakpoints": true
},
{
"idx": 99,
"version": "7",
"when": 1778341807236,
"tag": "0099_peaceful_hannibal_king",
"breakpoints": true
},
{
"idx": 100,
"version": "7",
"when": 1778440988519,
"tag": "0100_mighty_weapon_omega",
"breakpoints": true
},
{
"idx": 101,
"version": "7",
"when": 1778445560663,
"tag": "0101_simple_black_bolt",
"breakpoints": true
},
{
"idx": 102,
"version": "7",
"when": 1778539362096,
"tag": "0102_wild_wiccan",
"breakpoints": true
},
{
"idx": 103,
"version": "7",
"when": 1778611567910,
"tag": "0103_salty_runaways",
"breakpoints": true
},
{
"idx": 104,
"version": "7",
"when": 1778784280606,
"tag": "0104_chief_boom_boom",
"breakpoints": true
},
{
"idx": 105,
"version": "7",
"when": 1778862483243,
"tag": "0105_mighty_talisman",
"breakpoints": true
},
{
"idx": 106,
"version": "7",
"when": 1778867381171,
"tag": "0106_chilly_chimera",
"breakpoints": true
},
{
"idx": 107,
"version": "7",
"when": 1778868190284,
"tag": "0107_outgoing_mikhail_rasputin",
"breakpoints": true
},
{
"idx": 108,
"version": "7",
"when": 1778997704083,
"tag": "0108_faulty_purple_man",
"breakpoints": true
},
{
"idx": 109,
"version": "7",
"when": 1779211722613,
"tag": "0109_fair_peter_quill",
"breakpoints": true
},
{
"idx": 110,
"version": "7",
"when": 1779256349467,
"tag": "0110_thankful_nova",
"breakpoints": true
},
{
"idx": 111,
"version": "7",
"when": 1779312132517,
"tag": "0111_gorgeous_captain_universe",
"breakpoints": true
},
{
"idx": 112,
"version": "7",
"when": 1779318579169,
"tag": "0112_married_captain_midlands",
"breakpoints": true
},
{
"idx": 113,
"version": "7",
"when": 1779579877169,
"tag": "0113_bent_banshee",
"breakpoints": true
},
{
"idx": 114,
"version": "7",
"when": 1780454064143,
"tag": "0114_panoramic_human_cannonball",
"breakpoints": true
}
{ "idx": 0, "version": "7", "when": 1732076135211, "tag": "0000_short_donald_blake", "breakpoints": true },
{ "idx": 1, "version": "7", "when": 1760165302772, "tag": "0001_serious_la_nuit", "breakpoints": true },
{ "idx": 2, "version": "7", "when": 1760166775427, "tag": "0002_tired_iron_man", "breakpoints": true },
{ "idx": 3, "version": "7", "when": 1760167848365, "tag": "0003_public_shadowcat", "breakpoints": true },
{ "idx": 4, "version": "7", "when": 1760168383812, "tag": "0004_spicy_zarda", "breakpoints": true },
{ "idx": 5, "version": "7", "when": 1760169537260, "tag": "0005_abnormal_thunderbird", "breakpoints": true },
{ "idx": 6, "version": "7", "when": 1760241924414, "tag": "0006_needy_junta", "breakpoints": true },
{ "idx": 7, "version": "7", "when": 1760327817376, "tag": "0007_pink_nebula", "breakpoints": true },
{ "idx": 8, "version": "7", "when": 1760375514773, "tag": "0008_salty_trauma", "breakpoints": true },
{ "idx": 9, "version": "7", "when": 1760392647508, "tag": "0009_fuzzy_korvac", "breakpoints": true },
{ "idx": 10, "version": "7", "when": 1760469105799, "tag": "0010_complex_the_stranger", "breakpoints": true },
{ "idx": 11, "version": "7", "when": 1760501621605, "tag": "0011_gifted_mystique", "breakpoints": true },
{ "idx": 12, "version": "7", "when": 1760504208398, "tag": "0012_yielding_phil_sheldon", "breakpoints": true },
{ "idx": 13, "version": "7", "when": 1760543654915, "tag": "0013_pink_the_order", "breakpoints": true },
{ "idx": 14, "version": "7", "when": 1760588333462, "tag": "0014_lowly_gateway", "breakpoints": true },
{ "idx": 15, "version": "7", "when": 1760589195551, "tag": "0015_exotic_loners", "breakpoints": true },
{ "idx": 16, "version": "7", "when": 1760590495868, "tag": "0016_majestic_roulette", "breakpoints": true },
{ "idx": 17, "version": "7", "when": 1760599350154, "tag": "0017_calm_zarda", "breakpoints": true },
{ "idx": 18, "version": "7", "when": 1760996053371, "tag": "0018_numerous_stardust", "breakpoints": true },
{ "idx": 19, "version": "7", "when": 1761112236540, "tag": "0019_acoustic_ben_grimm", "breakpoints": true },
{ "idx": 20, "version": "7", "when": 1761719248881, "tag": "0020_slim_spacker_dave", "breakpoints": true },
{ "idx": 21, "version": "7", "when": 1762190473418, "tag": "0021_fair_ser_duncan", "breakpoints": true },
{ "idx": 22, "version": "7", "when": 1762191156317, "tag": "0022_shiny_mother_askani", "breakpoints": true },
{ "idx": 23, "version": "7", "when": 1762200550705, "tag": "0023_cynical_jack_power", "breakpoints": true },
{ "idx": 24, "version": "7", "when": 1763276901482, "tag": "0024_faithful_mesmero", "breakpoints": true },
{ "idx": 25, "version": "7", "when": 1763277000000, "tag": "0025_rename_participant_ev_season_to_sports_season", "breakpoints": true },
{ "idx": 26, "version": "7", "when": 1763278000000, "tag": "0026_add_source_odds_to_participant_ev", "breakpoints": true },
{ "idx": 27, "version": "7", "when": 1763279000000, "tag": "0027_add_projected_points_to_team_standings", "breakpoints": true },
{ "idx": 28, "version": "7", "when": 1763280000000, "tag": "0028_add_tournament_groups", "breakpoints": true },
{ "idx": 29, "version": "7", "when": 1763281000000, "tag": "0029_change_participant_ev_to_decimal", "breakpoints": true },
{ "idx": 30, "version": "7", "when": 1763282000000, "tag": "0030_add_draft_picks_unique_slot", "breakpoints": true },
{ "idx": 31, "version": "7", "when": 1763283000000, "tag": "0031_add_autodraft_queue_only", "breakpoints": true },
{ "idx": 32, "version": "7", "when": 1763284000000, "tag": "0032_consolidate_playoff_pattern", "breakpoints": true },
{ "idx": 33, "version": "7", "when": 1763285000000, "tag": "0033_add_schedule_event_type", "breakpoints": true },
{ "idx": 34, "version": "7", "when": 1763286000000, "tag": "0034_add_ev_snapshots", "breakpoints": true },
{ "idx": 35, "version": "7", "when": 1763287000000, "tag": "0035_increase_prob_precision", "breakpoints": true },
{ "idx": 36, "version": "7", "when": 1763288000000, "tag": "0036_increase_ev_precision", "breakpoints": true },
{ "idx": 37, "version": "7", "when": 1763289000000, "tag": "0037_add_simulator_type_to_sports", "breakpoints": true },
{ "idx": 38, "version": "7", "when": 1773122866361, "tag": "0038_sad_wilson_fisk", "breakpoints": true },
{ "idx": 39, "version": "7", "when": 1773200000000, "tag": "0039_add_ucl_bracket_simulator_type", "breakpoints": true },
{ "idx": 40, "version": "7", "when": 1773262080928, "tag": "0040_fat_puma", "breakpoints": true },
{ "idx": 41, "version": "7", "when": 1773557500336, "tag": "0041_great_magma", "breakpoints": true },
{ "idx": 42, "version": "7", "when": 1773600000000, "tag": "0042_migrate_event_date_to_starts_at", "breakpoints": true },
{ "idx": 43, "version": "7", "when": 1773634394633, "tag": "0043_demonic_vanisher", "breakpoints": true },
{ "idx": 44, "version": "7", "when": 1773700000000, "tag": "0044_add_ncaam_bracket_simulator_type", "breakpoints": true },
{ "idx": 45, "version": "7", "when": 1773710000000, "tag": "0045_add_ncaaw_bracket_simulator_type", "breakpoints": true },
{ "idx": 46, "version": "7", "when": 1773720000000, "tag": "0046_add_nba_bracket_simulator_type", "breakpoints": true },
{ "idx": 47, "version": "7", "when": 1773730000000, "tag": "0047_fix_nba_bracket_simulator_type", "breakpoints": true },
{ "idx": 48, "version": "7", "when": 1773765768905, "tag": "0048_eager_songbird", "breakpoints": true },
{ "idx": 49, "version": "7", "when": 1773800000000, "tag": "0049_add_admin_picked_by_type", "breakpoints": true },
{ "idx": 50, "version": "7", "when": 1773821310661, "tag": "0050_chief_peter_quill", "breakpoints": true },
{ "idx": 51, "version": "7", "when": 1773900000000, "tag": "0051_add_standings_snapshot_unique_index", "breakpoints": true },
{ "idx": 52, "version": "7", "when": 1773897678653, "tag": "0052_new_warbird", "breakpoints": true },
{ "idx": 53, "version": "7", "when": 1773903201189, "tag": "0053_smooth_kingpin", "breakpoints": true },
{ "idx": 54, "version": "7", "when": 1774070769112, "tag": "0054_massive_vulcan", "breakpoints": true },
{ "idx": 55, "version": "7", "when": 1774072156237, "tag": "0055_special_vampiro", "breakpoints": true },
{ "idx": 56, "version": "7", "when": 1774167142673, "tag": "0056_jittery_the_fallen", "breakpoints": true },
{ "idx": 57, "version": "7", "when": 1774239259550, "tag": "0057_cultured_doctor_doom", "breakpoints": true },
{ "idx": 58, "version": "7", "when": 1774310000000, "tag": "0058_add_unique_participant_ev", "breakpoints": true },
{ "idx": 59, "version": "7", "when": 1774329951326, "tag": "0059_mysterious_jack_murdock", "breakpoints": true },
{ "idx": 60, "version": "7", "when": 1774330910098, "tag": "0060_omniscient_outlaw_kid", "breakpoints": true },
{ "idx": 61, "version": "7", "when": 1774410244601, "tag": "0061_violet_mephistopheles", "breakpoints": true },
{ "idx": 62, "version": "7", "when": 1774500000000, "tag": "0062_add_mlb_bracket_simulator_type", "breakpoints": true },
{ "idx": 63, "version": "7", "when": 1774504559479, "tag": "0063_bored_ultimo", "breakpoints": true },
{ "idx": 64, "version": "7", "when": 1774598151282, "tag": "0064_fuzzy_wolfsbane", "breakpoints": true },
{ "idx": 65, "version": "7", "when": 1774719902130, "tag": "0065_kind_mantis", "breakpoints": true },
{ "idx": 66, "version": "7", "when": 1774802533652, "tag": "0066_stiff_siren", "breakpoints": true },
{ "idx": 67, "version": "7", "when": 1774900000000, "tag": "0067_darts_bracket", "breakpoints": true },
{ "idx": 68, "version": "7", "when": 1775485575205, "tag": "0068_cs2_major_simulator", "breakpoints": true },
{ "idx": 69, "version": "7", "when": 1775485599229, "tag": "0069_draft_window", "breakpoints": true },
{ "idx": 70, "version": "7", "when": 1775485630297, "tag": "0070_verify_migrations", "breakpoints": true },
{ "idx": 71, "version": "7", "when": 1775528386356, "tag": "0071_many_nico_minoru", "breakpoints": true },
{ "idx": 72, "version": "7", "when": 1775652883157, "tag": "0072_jittery_steve_rogers", "breakpoints": true },
{ "idx": 73, "version": "7", "when": 1775679862606, "tag": "0073_blushing_lady_vermin", "breakpoints": true },
{ "idx": 74, "version": "7", "when": 1775699707816, "tag": "0074_clammy_tiger_shark", "breakpoints": true },
{ "idx": 75, "version": "7", "when": 1776101579459, "tag": "0075_chubby_stephen_strange", "breakpoints": true },
{ "idx": 76, "version": "7", "when": 1776226761564, "tag": "0076_heavy_zemo", "breakpoints": true },
{ "idx": 77, "version": "7", "when": 1776312815334, "tag": "0077_lively_wolfpack", "breakpoints": true },
{ "idx": 78, "version": "7", "when": 1776314387095, "tag": "0078_striped_rick_jones", "breakpoints": true },
{ "idx": 79, "version": "7", "when": 1776316296907, "tag": "0079_wide_trauma", "breakpoints": true },
{ "idx": 80, "version": "7", "when": 1776318182740, "tag": "0080_chemical_gorilla_man", "breakpoints": true },
{ "idx": 81, "version": "7", "when": 1777067050669, "tag": "0081_careless_magdalene", "breakpoints": true },
{ "idx": 82, "version": "7", "when": 1777090754037, "tag": "0082_stale_pride", "breakpoints": true },
{ "idx": 83, "version": "7", "when": 1777180952144, "tag": "0083_fearless_leader", "breakpoints": true },
{ "idx": 84, "version": "7", "when": 1777477826949, "tag": "0084_wooden_retro_girl", "breakpoints": true },
{ "idx": 85, "version": "7", "when": 1777483407743, "tag": "0085_young_cobalt_man", "breakpoints": true },
{ "idx": 86, "version": "7", "when": 1777505810235, "tag": "0086_marvelous_infant_terrible", "breakpoints": true },
{ "idx": 87, "version": "7", "when": 1777661299612, "tag": "0087_small_susan_delgado", "breakpoints": true },
{ "idx": 88, "version": "7", "when": 1777666830394, "tag": "0088_cheerful_norrin_radd", "breakpoints": true },
{ "idx": 89, "version": "7", "when": 1777675084016, "tag": "0089_lonely_tigra", "breakpoints": true },
{ "idx": 90, "version": "7", "when": 1777677410094, "tag": "0090_powerful_redwing", "breakpoints": true },
{ "idx": 91, "version": "7", "when": 1777700829535, "tag": "0091_fast_inhumans", "breakpoints": true },
{ "idx": 92, "version": "7", "when": 1777745229462, "tag": "0092_robust_klaw", "breakpoints": true },
{ "idx": 93, "version": "7", "when": 1777783789103, "tag": "0093_ambiguous_hellcat", "breakpoints": true },
{ "idx": 94, "version": "7", "when": 1777920441672, "tag": "0094_curved_nick_fury", "breakpoints": true },
{ "idx": 95, "version": "7", "when": 1778043585280, "tag": "0095_lucky_madrox", "breakpoints": true },
{ "idx": 96, "version": "7", "when": 1778177368941, "tag": "0096_demonic_vulture", "breakpoints": true },
{ "idx": 97, "version": "7", "when": 1778189247519, "tag": "0097_puzzling_spectrum", "breakpoints": true },
{ "idx": 98, "version": "7", "when": 1778261539567, "tag": "0098_lucky_sabra", "breakpoints": true },
{ "idx": 99, "version": "7", "when": 1778341807236, "tag": "0099_peaceful_hannibal_king", "breakpoints": true },
{ "idx": 100, "version": "7", "when": 1778440988519, "tag": "0100_mighty_weapon_omega", "breakpoints": true },
{ "idx": 101, "version": "7", "when": 1778445560663, "tag": "0101_simple_black_bolt", "breakpoints": true },
{ "idx": 102, "version": "7", "when": 1778539362096, "tag": "0102_wild_wiccan", "breakpoints": true },
{ "idx": 103, "version": "7", "when": 1778611567910, "tag": "0103_salty_runaways", "breakpoints": true },
{ "idx": 104, "version": "7", "when": 1778784280606, "tag": "0104_chief_boom_boom", "breakpoints": true },
{ "idx": 105, "version": "7", "when": 1778862483243, "tag": "0105_mighty_talisman", "breakpoints": true },
{ "idx": 106, "version": "7", "when": 1778867381171, "tag": "0106_chilly_chimera", "breakpoints": true },
{ "idx": 107, "version": "7", "when": 1778868190284, "tag": "0107_outgoing_mikhail_rasputin", "breakpoints": true },
{ "idx": 108, "version": "7", "when": 1778997704083, "tag": "0108_faulty_purple_man", "breakpoints": true },
{ "idx": 109, "version": "7", "when": 1779211722613, "tag": "0109_fair_peter_quill", "breakpoints": true },
{ "idx": 110, "version": "7", "when": 1779256349467, "tag": "0110_thankful_nova", "breakpoints": true },
{ "idx": 111, "version": "7", "when": 1779312132517, "tag": "0111_gorgeous_captain_universe", "breakpoints": true },
{ "idx": 112, "version": "7", "when": 1779318579169, "tag": "0112_married_captain_midlands", "breakpoints": true },
{ "idx": 113, "version": "7", "when": 1779579877169, "tag": "0113_bent_banshee", "breakpoints": true },
{ "idx": 114, "version": "7", "when": 1780595525976, "tag": "0114_warm_swarm", "breakpoints": true },
{ "idx": 115, "version": "7", "when": 1780596702982, "tag": "0115_spooky_spot", "breakpoints": true },
{ "idx": 116, "version": "7", "when": 1780454064143, "tag": "0116_panoramic_human_cannonball", "breakpoints": true }
]
}