Skip to content

fix: tournament date offset #28

Description

@ethnjs

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:

  1. Frontend <input type="date"> produces a YYYY-MM-DD string (e.g. "2025-11-15").
  2. That string is sent to the API as-is. Pydantic parses it as 2025-11-15T00:00:00 — interpreted as UTC midnight.
  3. 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

  1. Creating a tournament with start_date: "2025-11-15" stores 2025-11-15 (date, no time) in the DB.
  2. Reading that tournament returns "start_date": "2025-11-15" — no time component, no T00:00:00.
  3. The tournament card displays "Nov 15, 2025" correctly regardless of the user's timezone (tested in UTC-8, UTC+9).
  4. Single-day tournament (same start and end) displays one date, not a range.
  5. Multi-day tournament displays a range: "Nov 15, 2025 – Nov 16, 2025".
  6. Submitting end date before start date in the create modal shows an inline error without making an API call.
  7. Backend continues to reject end before start with a 422.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions