Fix Tournament Date Timezone Offset Bug
Problem
Tournament.start_date and end_date are stored as datetime in the backend schema, but they are date-only fields — no time component is ever collected or meaningful. The bug chain is:
- Frontend
<input type="date"> produces a YYYY-MM-DD string (e.g. "2025-11-15").
- That string is sent to the API as-is. Pydantic parses it as
2025-11-15T00:00:00 — interpreted as UTC midnight.
- On read, the frontend receives
"2025-11-15T00:00:00" and passes it to new Date(d), which converts UTC midnight to local time. In PST (UTC-8), this becomes 2025-11-14T16:00:00 — displaying November 14 instead of November 15.
The fix is to change start_date and end_date from datetime to date throughout the stack. A date without a time component is inherently timezone-agnostic — no conversion logic is needed anywhere.
Additionally, the dashboard TournamentCard date display has two issues to fix while we're here:
- It passes the date string through
new Date(d) which causes the same UTC-shift bug.
- The single-date vs range logic compares strings (
end_date !== start_date) which works today but is fragile. It should be an explicit date equality check.
Finally, end date before start date validation exists on the backend but is not enforced in the frontend — add a frontend validation error so the TD gets immediate feedback without a round trip.
Changes
Backend — schemas/tournament.py
- Change
start_date and end_date from datetime | None to date | None on TournamentBase, TournamentUpdate, and TournamentRead.
- Update import:
from datetime import datetime, date → use date for these two fields, keep datetime for created_at / updated_at.
- The existing
validate_dates model validator already checks end_date < start_date — this continues to work with date objects. No change needed to the validator logic.
- Response serialization:
date fields serialize to "YYYY-MM-DD" strings by default in Pydantic — no custom serializer needed.
# Before
start_date: datetime | None = None
end_date: datetime | None = None
# After
start_date: date | None = None
end_date: date | None = None
Backend — models/models.py
- Change the
Tournament model columns from DateTime to Date for start_date and end_date.
- Add an Alembic migration to alter the column types.
Backend — DB migration
Alter tournaments.start_date and tournaments.end_date from TIMESTAMP / DATETIME to DATE. Existing values are truncated to date — any time component (always 00:00:00 since it was never collected) is dropped cleanly.
Frontend — lib/api.ts
No type changes needed — start_date and end_date are already typed as string | null. The returned value changes from "2025-11-15T00:00:00" to "2025-11-15", which is correct and simpler to work with.
Frontend — app/dashboard/page.tsx and components/ui/NewTournamentModal.tsx (inline version)
Fix the fmt function in TournamentCard to avoid timezone shift. Replace new Date(d) (which parses as UTC and converts to local) with a timezone-safe parse:
// Before — shifts date in negative UTC-offset timezones
const fmt = (d: string) =>
new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
// After — splits YYYY-MM-DD directly, no timezone conversion
const fmt = (d: string) => {
const [year, month, day] = d.split("-").map(Number);
return new Date(year, month - 1, day)
.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
};
new Date(year, month - 1, day) constructs a local-time date with no UTC conversion.
Fix the date range display logic:
// Before — string comparison (fragile)
const dateRange = tournament.start_date
? tournament.end_date && tournament.end_date !== tournament.start_date
? `${fmt(tournament.start_date)} – ${fmt(tournament.end_date)}`
: fmt(tournament.start_date)
: null;
// After — explicit same-day check
const dateRange = tournament.start_date
? tournament.end_date && tournament.end_date !== tournament.start_date
? `${fmt(tournament.start_date)} – ${fmt(tournament.end_date)}`
: fmt(tournament.start_date)
: null;
Note: with date-only values ("YYYY-MM-DD"), string equality is reliable — "2025-11-15" === "2025-11-15" always holds. The logic is unchanged but now correct by construction since datetime noise is gone.
Frontend — components/ui/NewTournamentModal.tsx
Add frontend validation for end date before start date:
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) { setError("Name is required"); return; }
if (startDate && endDate && endDate < startDate) {
setError("End date cannot be before start date");
return;
}
// ... rest of submit
}
String comparison works correctly for YYYY-MM-DD format — lexicographic order matches chronological order.
Also check if there is an edit tournament modal or settings page that also has date inputs — apply the same validation there.
What Does Not Change
- The
<input type="date"> elements in NewTournamentModal — they already produce YYYY-MM-DD strings and send them as-is. This is now correct behavior since the backend accepts date not datetime.
- The
validate_dates backend validator — already correct, continues to work with date objects.
- Time block
date fields (TournamentBlock.date) — already stored as "YYYY-MM-DD" strings, unaffected.
created_at / updated_at — remain datetime, unaffected.
Files Expected to Change
backend/app/schemas/tournament.py — date type for start_date / end_date
backend/app/models/models.py — Date column type for start_date / end_date
backend/migrations/versions/<new_migration>.py — alter column types
frontend/app/dashboard/page.tsx — fix fmt, fix date range logic
frontend/components/ui/NewTournamentModal.tsx — fix fmt if duplicated here, add end-before-start validation
backend/tests/api/test_tournaments.py — update any tests that send or assert datetime strings for these fields
Completion Criteria
- Creating a tournament with
start_date: "2025-11-15" stores 2025-11-15 (date, no time) in the DB.
- Reading that tournament returns
"start_date": "2025-11-15" — no time component, no T00:00:00.
- The tournament card displays "Nov 15, 2025" correctly regardless of the user's timezone (tested in UTC-8, UTC+9).
- Single-day tournament (same start and end) displays one date, not a range.
- Multi-day tournament displays a range: "Nov 15, 2025 – Nov 16, 2025".
- Submitting end date before start date in the create modal shows an inline error without making an API call.
- Backend continues to reject end before start with a 422.
Fix Tournament Date Timezone Offset Bug
Problem
Tournament.start_dateandend_dateare stored asdatetimein the backend schema, but they are date-only fields — no time component is ever collected or meaningful. The bug chain is:<input type="date">produces aYYYY-MM-DDstring (e.g."2025-11-15").2025-11-15T00:00:00— interpreted as UTC midnight."2025-11-15T00:00:00"and passes it tonew Date(d), which converts UTC midnight to local time. In PST (UTC-8), this becomes2025-11-14T16:00:00— displaying November 14 instead of November 15.The fix is to change
start_dateandend_datefromdatetimetodatethroughout the stack. A date without a time component is inherently timezone-agnostic — no conversion logic is needed anywhere.Additionally, the dashboard
TournamentCarddate display has two issues to fix while we're here:new Date(d)which causes the same UTC-shift bug.end_date !== start_date) which works today but is fragile. It should be an explicit date equality check.Finally, end date before start date validation exists on the backend but is not enforced in the frontend — add a frontend validation error so the TD gets immediate feedback without a round trip.
Changes
Backend —
schemas/tournament.pystart_dateandend_datefromdatetime | Nonetodate | NoneonTournamentBase,TournamentUpdate, andTournamentRead.from datetime import datetime, date→ usedatefor these two fields, keepdatetimeforcreated_at/updated_at.validate_datesmodel validator already checksend_date < start_date— this continues to work withdateobjects. No change needed to the validator logic.datefields serialize to"YYYY-MM-DD"strings by default in Pydantic — no custom serializer needed.Backend —
models/models.pyTournamentmodel columns fromDateTimetoDateforstart_dateandend_date.Backend — DB migration
Alter
tournaments.start_dateandtournaments.end_datefromTIMESTAMP/DATETIMEtoDATE. Existing values are truncated to date — any time component (always00:00:00since it was never collected) is dropped cleanly.Frontend —
lib/api.tsNo type changes needed —
start_dateandend_dateare already typed asstring | null. The returned value changes from"2025-11-15T00:00:00"to"2025-11-15", which is correct and simpler to work with.Frontend —
app/dashboard/page.tsxandcomponents/ui/NewTournamentModal.tsx(inline version)Fix the
fmtfunction inTournamentCardto avoid timezone shift. Replacenew Date(d)(which parses as UTC and converts to local) with a timezone-safe parse:new Date(year, month - 1, day)constructs a local-time date with no UTC conversion.Fix the date range display logic:
Note: with
date-only values ("YYYY-MM-DD"), string equality is reliable —"2025-11-15" === "2025-11-15"always holds. The logic is unchanged but now correct by construction since datetime noise is gone.Frontend —
components/ui/NewTournamentModal.tsxAdd frontend validation for end date before start date:
String comparison works correctly for
YYYY-MM-DDformat — lexicographic order matches chronological order.Also check if there is an edit tournament modal or settings page that also has date inputs — apply the same validation there.
What Does Not Change
<input type="date">elements inNewTournamentModal— they already produceYYYY-MM-DDstrings and send them as-is. This is now correct behavior since the backend acceptsdatenotdatetime.validate_datesbackend validator — already correct, continues to work withdateobjects.datefields (TournamentBlock.date) — already stored as"YYYY-MM-DD"strings, unaffected.created_at/updated_at— remaindatetime, unaffected.Files Expected to Change
backend/app/schemas/tournament.py—datetype forstart_date/end_datebackend/app/models/models.py—Datecolumn type forstart_date/end_datebackend/migrations/versions/<new_migration>.py— alter column typesfrontend/app/dashboard/page.tsx— fixfmt, fix date range logicfrontend/components/ui/NewTournamentModal.tsx— fixfmtif duplicated here, add end-before-start validationbackend/tests/api/test_tournaments.py— update any tests that send or assert datetime strings for these fieldsCompletion Criteria
start_date: "2025-11-15"stores2025-11-15(date, no time) in the DB."start_date": "2025-11-15"— no time component, noT00:00:00.