Skip to content

Timezone off-by-one in "Captured:" display for users far from UTC #335

@homeacc

Description

@homeacc

TL;DR

The Captured: line returned by search_thoughts, list_thoughts, and thought_stats (plus the date prefix in stats and the email-import display) is rendered server-side via Date.toLocaleDateString(). Because the Supabase Edge Functions run in UTC, users in negative-offset zones see the next calendar day for any capture made after their local ~17:00–20:00. A 21:25 capture in Chile (UTC-04) becomes 01:25 UTC next day → displays as "tomorrow".

Reproduction

Any timezone with non-zero offset from UTC, capturing close to local midnight or earlier-evening for negative offsets. Specific case:

  • Wall-clock at capture: 2026-05-29 21:25 Chile (UTC-04:00)
  • Stored created_at (Postgres timestamptz): 2026-05-30 01:25:00+00
  • toLocaleDateString() on the Edge Function runtime (UTC) → "5/30/2026"
  • User sees Captured: 5/30/2026 (the day after they captured)

Root cause

Storage is fine — timestamptz preserves the instant correctly. The truncation to date-only happens in the output formatter:

  • server/index.ts:38datePrefix for stats header
  • server/index.ts:256Captured: line in search_thoughts response
  • server/index.ts:342[date] prefix in list_thoughts response
  • server/index.ts:407-409 — stats date range
  • integrations/kubernetes-deployment/index.ts:75, 301, 401, 475-477 — same pattern duplicated in the K8s deployment variant
  • recipes/email-history-import/pull-gmail.ts:883 — date column in email import display

All call new Date(t.created_at).toLocaleDateString() server-side in a UTC runtime → loses both time-of-day and the user's local offset.

Why this likely hasn't been flagged before

Hypotheses (happy to be wrong):

  1. Most reported users seem to be on US East/Central, where the off-by-one only triggers for captures between ~20:00–midnight local. Easy to not notice if your typical capture pattern is daytime.
  2. The dashboard parses Captured: ... back into createdAt via dashboards/open-brain-dashboard/src/lib/api.ts:179-180 as a date string. So even if the format changes, the dashboard would silently break unless updated in sync.
  3. Date-only is "good enough" for most semantic recall — the bug only really bites when grouping by year (autobiography/wiki synthesis) or when users in far-from-UTC zones notice the day mismatch.

Fix paths (ordered by invasiveness)

# Change Backwards-compat Notes
1 Add optional captured_at (ISO 8601 with offset) parameter to capture_thought / ingest-thought Yes (additive) Lets clients with timezone knowledge (Outlook plugin, mobile capture, backfill scripts) preserve local time. Defaults to now() server-side. Zero behavioral change for existing callers.
2 Change output formatter to emit full ISO 8601 instead of toLocaleDateString() Breaks dashboard (parses date string) and any external consumer of the Captured: line. Needs coordinated PR across server + dashboard + recipes. Resolves the display bug for all timezones.
3 Store with explicit client-provided offset (captured_at from #1) so future formatters can honor original local time Yes (depends on #1) The cleanest long-term fix — now() in the Edge Function always loses the client's timezone.

#1 alone fixes the underlying data-loss problem without changing any display contract. #2 fixes the visible symptom but is the biggest blast radius.

Bonus: fork-relevant gotcha

Not relevant on Postgres, but worth flagging for anyone considering a SQL Server port: pyodbc + ODBC Driver 18 has no default output converter for DATETIMEOFFSET (SQL type -155). Inputs work, but the first SELECT returns 'ODBC SQL type -155 is not yet supported'. Fix is to register conn.add_output_converter(-155, ...) with a 20-byte struct unpack — see pyodbc issue #134.

Happy to open a PR for fix path #1 if it would be welcome — it's the smallest, most reversible change of the three.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions