Skip to content

Date and time handling

Overview

All date and time formatting across the BMS, client portal, and Edge Functions uses centralised utility functions that format in the configured timezone — Australia/Sydney by default.

Before this was set up, dates were formatted inconsistently. Some used the browser's local timezone, some used UTC, and several Edge Functions extracted dates using .toISOString().split('T')[0], which uses UTC. The most common bug was dates shifting by a day: a timestamp at 11pm UTC on April 16 is already April 17 in Sydney, so any UTC-based date extraction would show the wrong day for about 10 hours every night.

The timezone lockdown project (completed April 2026) standardised all date formatting through three utility files — one per runtime — and added governance to prevent anyone from bypassing them.

How to format dates

BMS (React)

import { useDateFormatter } from '@/hooks/useDateFormatter';

function MyComponent({ record }) {
  const { formatDate, formatDateTime, formatShortDate, formatTime } = useDateFormatter();

  return (
    <div>
      <span>{formatDate(record.created_at)}</span>       {/* "17/04/2026" */}
      <span>{formatShortDate(record.created_at)}</span>   {/* "17 Apr 2026" */}
      <span>{formatDateTime(record.created_at)}</span>    {/* "17 Apr 2026, 2:30 pm" */}
      <span>{formatTime(record.created_at)}</span>        {/* "2:30 pm" */}
    </div>
  );
}

The useDateFormatter hook reads the timezone from organisation_settings (cached by TanStack Query) and returns pre-bound format functions. You never need to pass the timezone yourself.

Other available functions: formatLongDate ("17 April 2026"), formatLongDateTime ("17 April 2026 at 2:30 PM" — uppercase AM/PM, for emails), toDateString ("2026-04-17" — timezone-aware YYYY-MM-DD).

Client Portal (React)

import { useDateFormatter } from '../hooks/useDateFormatter';

// Same API as BMS — same function names, same output formats.

The portal's useDateFormatter reads the timezone from the portal-org Edge Function response (already fetched by Layout.tsx on every page load), so there's no extra network call.

Edge Functions (Deno)

import { getTimezone, formatDate, formatDateTime, formatShortDate } from '../_shared/utils.ts';

const tz = await getTimezone();  // reads from organisation_settings, cached per instance
const formatted = formatDate(record.created_at, tz);

Edge Functions don't have hooks, so you call getTimezone() yourself and pass the result to each format function. getTimezone() accepts an optional Supabase client (getTimezone(supabase)) but also works without one — it falls back to a raw PostgREST fetch.

What NOT to do

These patterns are banned across the entire codebase. Do not use them outside the three utility files.

Banned pattern Why it's banned
toLocaleDateString() Uses the browser's local timezone, not Sydney. A user in Perth or London sees a different date.
toLocaleTimeString() Same timezone issue.
new Intl.DateTimeFormat() Bypasses the centralised utilities. If the format needs to change later, this call site would be missed.
.toISOString().split('T')[0] Extracts the UTC date, not the Sydney date. Near midnight, this returns yesterday's date in Sydney.
.toLocaleString() for dates Same timezone issue. (.toLocaleString() is fine for currency — the ban is about using it for date formatting.)

What happens if you break the rules

BMS: ESLint no-restricted-syntax rules will fail your build immediately. The error message tells you which pattern is banned and points to src/lib/dateFormat.ts as the correct alternative.

Client Portal and Edge Functions: No ESLint enforcement yet. Two other mechanisms catch violations: - The weekly AI review (runs Monday 4am UTC) scans all source files for banned patterns and flags them in a GitHub Issue. - Claude Code is instructed via CLAUDE.md to always use the utility files. If CC is writing your code, it will follow this rule automatically.

Adding a new date format

If you need a format that doesn't exist (e.g. a weekday name, a month-year without day):

  1. Add the function to all three utility files — they must stay in sync:
  2. apps/bms/src/lib/dateFormat.ts
  3. apps/client-portal/src/utils/dateFormat.ts
  4. supabase/functions/_shared/utils.ts
  5. Add the hook wrapper in both apps:
  6. apps/bms/src/hooks/useDateFormatter.ts
  7. apps/client-portal/src/hooks/useDateFormatter.ts
  8. Add tests in all three test files (see below)
  9. Update the reference doc: docs/live/date-time-handling.md

Do not add one-off formatting in a component or page file. The whole point of the system is that all formatting goes through the utility files.

Where the tests are

File What it tests
apps/bms/src/lib/dateFormat.test.ts BMS utility — all 7 functions
apps/client-portal/src/utils/dateFormat.test.ts Portal utility — same coverage
supabase/functions/_shared/dateFormat.test.ts Edge Function utility — same coverage
supabase/functions/portal-ticket-detail/dateFormatting.integration.test.ts Calls the real Edge Function on dev to verify the full pipeline

Run unit tests: npm run test (from repo root — runs all three suites)

Run Edge Function integration tests: npm run test:integration:edge (requires .env.test with dev Supabase credentials)

Further reading

  • docs/live/date-time-handling.md — full technical reference. Claude Code reads this before modifying any date/time code.
  • CLAUDE.md — banned patterns and utility sync rules that CC follows automatically.