Civil dates come from timezone.localdate() (2026-08-25)¶
The servers keep UTC and the firm does not. With USE_TZ on, Python's
date.today() and timezone.now().date() give the UTC calendar day,
which rolls over at about eight in the evening in the firm's time zone.
Most of the year that was invisible. On the last evening of a month it
inverted every month-to-date range (the dash's work in progress, the
activity and realization windows, the unbilled cutoffs), re-triggered
the once-a-day dash check-in, and stamped completed tasks with
tomorrow's date.
Decision¶
A civil date (today, a default for a date field, the bounds of a
preset, a completion stamp) is django.utils.timezone.localdate(),
which converts the current instant to TIME_ZONE before taking the
date. date.today(), datetime.now().date() and
timezone.now().date() are wrong in application code, and one found
in review is a bug to fix, not a style point. Tests that build
today-relative fixtures use the same call.
Alternatives¶
- Running the servers in the firm's time zone. Not taken; the reason is
not recorded. The application already sets
TIME_ZONEandUSE_TZ, so the conversion belongs in the code, not in the host. - Fixing the sites that had been noticed, one at a time. That is how it
went until 2026-08-25:
date_completedwas fixed on 2026-08-03, the date presets on 2026-08-07, and the sweep of 2026-08-25 found the rest. Three more sites that the sweep missed (three invoicing forms and the bulk Create Invoices dialog, which usedtimezone.now().date()) were fixed on 2026-10-05, and the intake note stamp the same day.
Consequences¶
- Grep for
date.today()and.now().date()in a change before review; the correct spelling istimezone.localdate(). Both are wrong, and the second looks right. A few stragglers remain in the reports and the AI context at the time of writing; each is a bug. - A form's default date is set in
__init__fromlocaldate(), so it is computed per request, not at import. - The date presets store their name and re-derive their bounds from
localdate()on every read, so the stored session never holds a stale "today". - Anything that compares a stored date with "today" (overdue, past due, the month to date) must take today the same way, or the two disagree for four hours a day.
Evidence¶
- Commit "fix: derive civil dates from timezone.localdate(), not naive date.today()" (2026-08-25): "With USE_TZ and a UTC server clock, date.today() rolls to the next civil day at ~8pm ET. On the last evening of a month that inverted every month-to-date range ... and re-triggered the daily dash check-in. Sweep all app code to timezone.localdate()."
- Commit "fix(tasks): stamp date_completed with the local date, not UTC" (2026-08-03).
- Commit "fix(tasks): date presets become semantic; Today always means today" (2026-08-07).
- Commit "fix(invoicing): default form dates to today in the firm's time zone" (2026-10-05): "both the server's clock, so late in the evening the default was already tomorrow."
docs/dev/conventions/session-state.md, "Things that bite": "Dates come fromtimezone.localdate(), neverdate.today()."
Related¶
- Session state, "Semantic date presets"
- Time and billing
- Tasks and calendar