Skip to content

Timezone Management

This guide explains how the system handles timezone management to ensure accurate date/time operations across the entire application stack. The system accepts dates in the client’s local timezone, processes them correctly, but stores and transmits data in UTC format.

In enterprise systems, timezone misalignment causes critical issues:

  1. Server/Database Time: Configured as UTC (GMT+0) for international standardization
  2. Business Logic (Local Time): Day cutoff (e.g., “today’s records”) must start at 00:00 in the user’s timezone
  3. The Offset Gap Problem:
    • Query: “Find records for 2024-01-15”
    • Database interprets as: 2024-01-15 00:00:00 UTC
    • In a GMT+7 timezone, that’s: 2024-01-15 07:00:00 +07:00
    • Result: Data from 00:00–06:59 local time is missing from the query
graph LR
    subgraph "User Request (Local Time)"
        A["2024-01-15<br/>(Full day locally)"]
    end

    subgraph "Without Timezone Handling"
        B["2024-01-15 00:00 UTC<br/>(07:00 Local)"]
        C["Missing: 00:00-06:59 Local"]
    end

    subgraph "With Timezone Handling"
        D["2024-01-14 17:00 UTC<br/>(00:00 Local)"]
        E["2024-01-15 16:59 UTC<br/>(23:59 Local)"]
    end

    A --> B
    B --> C
    A --> D
    D --> E

    style C fill:#ffcccc
    style D fill:#ccffcc
    style E fill:#ccffcc

A “Double-Lock” approach ensures correct timezone handling at every level:

graph TB
    subgraph "Layer 1: Application Bootstrap"
        A1["process.env.TZ = configured timezone"]
        A2["dayjs.tz.setDefault(appTimezone)"]
    end

    subgraph "Layer 2: Database Connection"
        B1["TypeORM: timezone: 'Z'"]
        B2["All queries use UTC"]
    end

    subgraph "Layer 3: Query Builder"
        C1["Date-only expansion"]
        C2["Timezone conversion to UTC"]
    end

    A1 --> A2
    A2 --> B1
    B1 --> B2
    B2 --> C1
    C1 --> C2

In the application bootstrap (main.ts or bootstrap-application.ts), configure the global timezone settings:

libs/common/src/utils/bootstrap.util.ts
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
export async function bootstrapApplication(options: BootstrapOptions) {
// 1. Force Node.js process to use the configured application timezone
// This affects new Date() and other native date operations
process.env.TZ = process.env.APP_TIMEZONE ?? 'UTC';
// 2. Configure Dayjs with timezone support
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.tz.setDefault(process.env.APP_TIMEZONE ?? 'UTC');
// ... rest of bootstrap configuration
}
SettingPurposeAffects
process.env.TZSets Node.js process timezonenew Date(), Date.now(), native date operations
dayjs.tz.setDefault()Sets dayjs default timezoneAll dayjs operations without explicit timezone

In the DatabaseModule, configure TypeORM to communicate with PostgreSQL using UTC:

libs/database/src/database.module.ts
@Module({})
export class DatabaseModule {
static registerAsync(connectionName: string): DynamicModule {
return {
module: DatabaseModule,
imports: [
TypeOrmModule.forRootAsync({
name: connectionName,
useFactory: (configService: ConfigService) => ({
type: 'postgres',
replication: {
master: { /* master config */ },
slaves: [{ /* replica config */ }],
},
// CRITICAL: Force driver to communicate in UTC
timezone: 'Z',
// ... other options
}),
inject: [ConfigService],
}),
],
};
}
}

Always use timestamptz (timestamp with time zone) for date/time columns:

// Entity definition
@Entity({ name: 'records', database: AppDatabases.APP_CORE })
export class Record implements ITimestamp {
@PrimaryGeneratedColumn('uuid')
id: string;
// ✅ CORRECT: Use timestamptz for proper timezone handling
@Column({ type: 'timestamptz', comment: 'Record date and time' })
record_date: Date;
// ✅ CORRECT: Audit columns with timezone
@CreateDateColumn({ type: 'timestamptz' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updated_at: Date;
// ❌ AVOID: timestamp without timezone (loses timezone info)
// @Column({ type: 'timestamp' })
// some_date: Date;
}

The TypeOrmQueryBuilder class automatically processes date values based on the query context:

libs/common/src/utils/base-operations/typeorm-query-builder.util.ts
/**
* Date processing logic:
* - Date-only values (YYYY-MM-DD) are expanded to cover the full day
* - Greater than (>=): Start of day (00:00:00) in user timezone → UTC
* - Less than (<=): End of day (23:59:59.999) in user timezone → UTC
*/
private processDateValue(value: unknown, direction: ComparisonDirection): unknown {
if (typeof value !== 'string') return value;
const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(value);
if (isDateOnly) {
return this.expandDateOnlyValue(value, direction);
}
// Full datetime: convert directly to UTC
if (dayjs(value).isValid()) {
return dayjs.tz(value, this.timezone).toISOString();
}
return value;
}
private expandDateOnlyValue(dateString: string, direction: ComparisonDirection): string {
const localDate = dayjs.tz(dateString, this.timezone);
switch (direction) {
case 'greater':
return localDate.startOf('day').toISOString();
case 'less':
return localDate.endOf('day').toISOString();
case 'equal':
default:
return localDate.startOf('day').toISOString();
}
}
InputDirectionLocal Time (UTC+7 example)UTC Output
2024-01-15greater2024-01-15 00:00:00 +07:002024-01-14T17:00:00.000Z
2024-01-15less2024-01-15 23:59:59.999 +07:002024-01-15T16:59:59.999Z
2024-01-15 14:30:00any2024-01-15 14:30:00 +07:002024-01-15T07:30:00.000Z

Input Validation: Standard vs. Strict DateTime Validation

Section titled “Input Validation: Standard vs. Strict DateTime Validation”

When working with timestamptz columns and receiving date/time data from client payloads (HTTP POST, PUT, PATCH), proper input validation is critical. Without strict validation, timezone ambiguity can cause data storage issues and query mismatches.

The Problem: Timezone Ambiguity in Payloads

Section titled “The Problem: Timezone Ambiguity in Payloads”

Consider this scenario:

  1. Client A (UTC+7) sends: "2024-01-15T09:00:00" (no timezone)
  2. Client B (UTC-5) sends: "2024-01-15T09:00:00" (no timezone)
  3. Question: What time should be stored in the database?

Without timezone information, the backend cannot determine the user’s intended time. If the server interprets both as UTC:

  • Client A expected: 2024-01-15T02:00:00Z (09:00 local time)
  • Client B expected: 2024-01-15T14:00:00Z (09:00 local time)
  • Server stored: 2024-01-15T09:00:00Z (wrong for both!)

Result: Data inconsistency, failed queries, and “missing data” bugs.

graph LR
    subgraph "Client Sends (No Timezone)"
        A1["2024-01-15T09:00:00"]
    end

    subgraph "Backend Interpretation"
        B1["Assume UTC?<br/>Assume Server TZ?<br/>❌ Ambiguous"]
    end

    subgraph "Correct Approach"
        C1["2024-01-15T09:00:00+07:00<br/>✅ Unambiguous"]
    end

    A1 --> B1
    C1 --> D1["Store as UTC:<br/>2024-01-15T02:00:00Z"]

    style B1 fill:#ffcccc
    style D1 fill:#ccffcc

Comparison: IsDateString() vs. IsISO8601()

Section titled “Comparison: IsDateString() vs. IsISO8601()”
FeatureIsDateString() (Standard)@IsISO8601() (Strict)
Sourceclass-validator built-inCustom decorator
AcceptsVarious date formatsOnly complete ISO 8601
Timezone Required❌ No✅ Yes
Validation StrictnessLooseStrict
Recommended ForDisplay/filter inputsMutation payloads (POST/PUT/PATCH)

The built-in IsDateString() from class-validator accepts various date formats:

// ❌ All of these pass IsDateString() validation
'2024-01-15'; // Date only - NO timezone
'2024-01-15T09:00:00'; // Missing timezone offset
'2024-01-15T09:00:00.000'; // Missing timezone offset
'2024-01-15T09:00:00Z'; // ✅ Has timezone (UTC)
'2024-01-15T09:00:00+07:00';// ✅ Has timezone offset

Why this is problematic for mutations:

// DTO with standard validation
export class CreateRecordDTO {
@IsDateString() // ❌ Accepts ambiguous formats
record_date: string;
}
// Client sends (missing timezone)
POST /records
{ "record_date": "2024-01-15T09:00:00" }
// Backend receives: ambiguous — what timezone was intended?

Strict Validation: @IsISO8601() (Custom Decorator)

Section titled “Strict Validation: @IsISO8601() (Custom Decorator)”

The custom @IsISO8601() decorator enforces complete ISO 8601 format with mandatory timezone offset:

libs/common/src/decorators/custom-validate-dto/is-iso-8601.decorator.ts
/**
* Strict Regex for ISO 8601 with mandatory Timezone Offset.
* Pattern: YYYY-MM-DDTHH:mm:ss.sssZ or YYYY-MM-DDTHH:mm:ss.sss+HH:mm
*/
private readonly ISO_8601_STRICT_REGEX =
/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;

Validation Examples:

// ✅ Valid (complete format with timezone)
'2024-01-15T09:00:00Z'; // UTC
'2024-01-15T09:00:00+07:00'; // UTC+7
'2024-01-15T09:00:00.000+07:00'; // With milliseconds
'2024-01-15T09:00:00-05:00'; // UTC-5
// ❌ Invalid (rejected by @IsISO8601)
'2024-01-15'; // Date only
'2024-01-15T09:00:00'; // Missing timezone
'2024-01-15 09:00:00'; // Wrong separator (space)
'Jan 15, 2024'; // Wrong format

For any DTO that receives timestamptz data via POST, PUT, or PATCH, use the @IsISO8601() decorator:

apps/data-owner-bc/src/modules/record/dtos/create-record.dto.ts
import { IsISO8601 } from '@lib/common';
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateRecordDTO {
@IsUUID()
resource_id: string;
@IsISO8601() // ✅ Strict validation - requires timezone
record_date: string;
@IsISO8601()
@IsOptional()
scheduled_time?: string;
@IsString()
record_type: string;
}

Client Request (Correct Format):

{
"resource_id": "uuid-123",
"record_date": "2024-01-15T09:00:00+07:00",
"record_type": "standard"
}

Client Request (Invalid — Will Be Rejected):

{
"resource_id": "uuid-123",
"record_date": "2024-01-15T09:00:00",
"record_type": "standard"
}

Error Response:

{
"status": { "code": 400001, "message": "Validation Failed" },
"errors": [
{
"field": "record_date",
"message": "record_date must be a complete ISO 8601 string with a timezone offset (e.g., 2024-01-15T14:30:00+07:00 or 2024-01-15T14:30:00Z)"
}
]
}

graph TD
    A[Date/Time Field] --> B{Operation Type?}

    B -->|POST, PUT, PATCH| C[Mutation Request]
    B -->|GET Query Filter| D[Read Request]

    C --> E["Use @IsISO8601()<br/>✅ Strict validation<br/>✅ Requires timezone"]

    D --> F["TypeORM Query Builder<br/>✅ Auto-handles timezone<br/>✅ Accepts date-only format"]

    E --> G["Backend stores UTC<br/>No ambiguity"]
    F --> H["Converts to UTC<br/>Based on timezone param"]

    style E fill:#ccffcc
    style F fill:#ccffcc

HTTP Mutations (POST, PUT, PATCH): Use @IsISO8601()

Section titled “HTTP Mutations (POST, PUT, PATCH): Use @IsISO8601()”

When saving data to timestamptz columns, always require complete ISO 8601 with timezone:

SituationWhy Strict Validation?
Creating appointmentsExact time matters for scheduling
Updating record datesPrevents data drift from timezone confusion
Recording event timestampsCritical for auditability
Audit timestampsCompliance requires precision

HTTP Queries (GET with filters): Query Builder Handles It

Section titled “HTTP Queries (GET with filters): Query Builder Handles It”

When filtering data via GET requests, the TypeORM Query Builder automatically handles timezone conversion (see Base Operations Architecture):

Terminal window
# These all work correctly via the query builder
GET /records?filter=record_date||$eq||2024-01-15
GET /records?filter=record_date||$between||2024-01-01,2024-01-31
GET /records?s={"record_date":{"between":["2024-01-15","2024-01-20"]}}

The query builder:

  1. Interprets date-only values in the configured timezone
  2. Expands to full day range (00:00:00 to 23:59:59.999)
  3. Converts to UTC for database query

Ensure your frontend always sends complete ISO 8601 strings with timezone:

// ✅ With dayjs for explicit timezone handling
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
dayjs.extend(utc);
dayjs.extend(timezone);
// ✅ Correct: Frontend sends complete ISO 8601
const createRecord = async (resourceId: string, recordDate: Date) => {
const response = await fetch('/api/v1/records', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
resource_id: resourceId,
// toISOString() always returns UTC with 'Z' suffix
record_date: recordDate.toISOString(),
}),
});
return response.json();
};
// For user-input local dates, explicitly set timezone
const scheduleRecord = async (date: string, time: string, tz: string) => {
// User input: date="2024-01-15", time="09:00", tz="Asia/Tokyo"
const datetime = dayjs.tz(`${date} ${time}`, tz);
const response = await fetch('/api/v1/records', {
method: 'POST',
body: JSON.stringify({
scheduled_time: datetime.toISOString(), // UTC
}),
});
};

HTTP MethodUse CaseValidationFormat Accepted
POSTCreate record@IsISO8601()Complete ISO 8601 with TZ
PUTReplace record@IsISO8601()Complete ISO 8601 with TZ
PATCHUpdate fields@IsISO8601()Complete ISO 8601 with TZ
GETFilter/QueryQuery BuilderDate-only or ISO 8601

This dual approach ensures:

  • Data integrity: Mutations store precise, unambiguous timestamps
  • Developer experience: Queries remain flexible and user-friendly
  • International support: Works correctly across all timezones

The API supports an optional timezone query parameter to override the default:

Terminal window
# Default timezone (application-configured)
GET /data-owner-bc/v1/records?filter=record_date||$eq||2024-01-15
# Custom timezone
GET /data-owner-bc/v1/records?filter=record_date||$eq||2024-01-15&timezone=America/New_York
# UTC
GET /data-owner-bc/v1/records?filter=record_date||$eq||2024-01-15&timezone=UTC
Terminal window
# Exact date (expands to full day in configured timezone)
?filter=record_date||$eq||2024-01-15
# Greater than or equal (from start of day)
?filter=record_date||$gte||2024-01-15
# Less than or equal (to end of day)
?filter=record_date||$lte||2024-01-15
# Date range (between)
?filter=record_date||$between||2024-01-01,2024-01-31
# With custom timezone
?filter=record_date||$between||2024-01-01,2024-01-31&timezone=America/New_York
Terminal window
# Exact date
?s={"record_date":"2024-01-15"}
# Greater than
?s={"record_date":{">":"2024-01-15"}}
# Date range
?s={"record_date":{"between":["2024-01-01","2024-01-31"]}}
# Combined with timezone
?s={"record_date":{"between":["2024-01-01","2024-01-31"]}}&timezone=Asia/Tokyo

import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
dayjs.extend(utc);
dayjs.extend(timezone);
const APP_TIMEZONE = process.env.APP_TIMEZONE ?? 'UTC';
@Injectable()
export class RecordsService {
/**
* Get records for a specific date in the application timezone
*/
async getRecordsForDate(date: string, tz = APP_TIMEZONE): Promise<Record[]> {
const localDate = dayjs.tz(date, tz);
const startOfDay = localDate.startOf('day').toISOString();
const endOfDay = localDate.endOf('day').toISOString();
return this.recordRepository.find({
where: {
record_date: Between(startOfDay, endOfDay),
},
});
}
/**
* Create a record — @IsISO8601() ensures timezone is already included.
*
* The record_date is guaranteed to be a complete ISO 8601 string
* with timezone (e.g., "2024-01-15T09:00:00+07:00").
* No manual timezone handling needed — just parse directly.
*/
async createRecord(dto: CreateRecordDTO): Promise<Record> {
const record = this.recordRepository.create({
...dto,
// ✅ Simply parse — @IsISO8601() guarantees timezone is included
record_date: dayjs(dto.record_date).toDate(),
});
return this.recordRepository.save(record);
}
}
import dayjs from 'dayjs';
const TZ = 'Asia/Tokyo'; // Example timezone
// Current time in configured timezone
const now = dayjs().tz(TZ);
console.log(now.format('YYYY-MM-DD HH:mm:ss')); // "2024-01-15 14:30:00"
// Convert to UTC for database storage
const utcTime = now.utc();
console.log(utcTime.toISOString()); // "2024-01-15T05:30:00.000Z"
// Start/End of day in local timezone → UTC
const localDate = dayjs.tz('2024-01-15 09:00:00', TZ);
const startOfDay = localDate.startOf('day');
console.log(startOfDay.toISOString()); // UTC equivalent
const endOfDay = localDate.endOf('day');
console.log(endOfDay.toISOString()); // UTC equivalent
// Add/subtract time
const tomorrow = now.add(1, 'day');
const lastWeek = now.subtract(7, 'day');
// Compare dates
const isAfter = dayjs('2024-01-15').isAfter('2024-01-10'); // true
const isBefore = dayjs('2024-01-15').isBefore('2024-01-20'); // true
const isSame = dayjs('2024-01-15').isSame('2024-01-15', 'day'); // true

utils/dateUtils.ts
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
dayjs.extend(utc);
dayjs.extend(timezone);
const DEFAULT_TIMEZONE = 'UTC'; // Override per deployment
/**
* Format a UTC date from API for display in local timezone
*/
export function formatDateForDisplay(utcDate: string, format: string = 'DD/MM/YYYY HH:mm'): string {
return dayjs.utc(utcDate).tz(DEFAULT_TIMEZONE).format(format);
}
/**
* Convert a local date input to ISO string for API
*/
export function formatDateForAPI(localDate: string | Date): string {
return dayjs.tz(localDate, DEFAULT_TIMEZONE).toISOString();
}
/**
* Build a date filter for API query
*/
export function buildDateFilter(
field: string,
startDate: string,
endDate: string,
timezone?: string,
): string {
const params = new URLSearchParams();
params.set('filter', `${field}||$between||${startDate},${endDate}`);
if (timezone) {
params.set('timezone', timezone);
}
return params.toString();
}
components/DateRangeFilter.tsx
import { useState } from 'react';
interface DateRangeFilterProps {
onFilter: (startDate: string, endDate: string) => void;
}
export function DateRangeFilter({ onFilter }: DateRangeFilterProps) {
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const handleSubmit = () => {
// Dates sent as YYYY-MM-DD; the API handles timezone conversion
onFilter(startDate, endDate);
};
return (
<div>
<input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
<input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
<button onClick={handleSubmit}>Filter</button>
</div>
);
}
// Usage in a page component
function RecordsPage() {
const fetchRecords = async (startDate: string, endDate: string) => {
const response = await fetch(
`/data-owner-bc/v1/records?filter=record_date||$between||${startDate},${endDate}`,
);
const data = await response.json();
return data;
};
return <DateRangeFilter onFilter={fetchRecords} />;
}
components/DateRangeFilter.vue
<template>
<div class="date-filter">
<input type="date" v-model="startDate" />
<input type="date" v-model="endDate" />
<select v-model="timezone">
<option value="UTC">UTC</option>
<option value="Asia/Tokyo">Japan (GMT+9)</option>
<option value="Asia/Singapore">Singapore (GMT+8)</option>
<option value="America/New_York">New York (GMT-5)</option>
</select>
<button @click="applyFilter">Filter</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const startDate = ref('');
const endDate = ref('');
const timezone = ref('UTC');
const emit = defineEmits<{
filter: [query: string];
}>();
const applyFilter = () => {
const params = new URLSearchParams();
if (startDate.value && endDate.value) {
params.set('filter', `record_date||$between||${startDate.value},${endDate.value}`);
}
if (timezone.value !== 'UTC') {
params.set('timezone', timezone.value);
}
emit('filter', params.toString());
};
</script>

All date/time values in API responses are returned in UTC ISO 8601 format:

{
"status": { "code": 200000, "message": "Request Succeeded" },
"data": {
"type": "records",
"id": "uuid-123",
"attributes": {
"record_date": "2024-01-15T07:30:00.000Z",
"created_at": "2024-01-14T17:00:00.000Z",
"updated_at": "2024-01-15T08:45:00.000Z"
}
}
}

Frontend applications are responsible for converting UTC to local display time using the user’s timezone preference.


The system supports all standard IANA timezone identifiers:

RegionTimezoneUTC Offset
AsiaAsia/Tokyo+9:00
Asia/Singapore+8:00
Asia/Hong_Kong+8:00
Asia/Seoul+9:00
Asia/Shanghai+8:00
Asia/Bangkok+7:00
AmericasAmerica/New_York-5:00/-4:00 (DST)
America/Los_Angeles-8:00/-7:00 (DST)
America/Chicago-6:00/-5:00 (DST)
EuropeEurope/London+0:00/+1:00 (DST)
Europe/Paris+1:00/+2:00 (DST)
Europe/Berlin+1:00/+2:00 (DST)
OtherUTC+0:00

  • Set process.env.TZ to your deployment timezone in bootstrap
  • Configure dayjs.tz.setDefault() to match process.env.TZ
  • Extend dayjs with utc and timezone plugins
  • Set timezone: 'Z' in TypeORM connection config
  • Use timestamptz column type for all date/time columns
  • Store all dates in UTC format
  • Accept date-only format (YYYY-MM-DD) for user convenience in filters
  • Support timezone query parameter for international users
  • Return all dates in UTC ISO 8601 format
  • Document timezone behavior in API documentation
  • Convert UTC responses to local time for display
  • Send dates in YYYY-MM-DD format for GET filters (let API handle conversion)
  • Send complete ISO 8601 with timezone for POST/PUT/PATCH mutations
  • Provide timezone selector for international applications
  • Use dayjs consistently for all date operations

1. Dates are off by several hours

  • Check that timezone: 'Z' is set in TypeORM config
  • Verify process.env.TZ is set before any date operations

2. dayjs.tz is not a function

  • Ensure plugins are extended before use:
    dayjs.extend(utc);
    dayjs.extend(timezone);

3. Date range queries missing data

  • Verify the query builder is expanding date-only values correctly
  • Check that column type is timestamptz, not timestamp

4. Frontend shows wrong time

  • Ensure UTC dates from API are converted to local time for display
  • Check that dayjs timezone plugin is loaded in frontend

This timezone management system ensures:

  1. Precision: Queries return data accurately based on the user’s local day/time
  2. Standardization: All database storage is in UTC for international compatibility
  3. Transparency: Developers write business logic using local time concepts; the system handles conversion automatically

The 3-layer approach (Application → Database → Query Builder) provides a robust foundation for timezone-aware operations in multi-region enterprise systems.