home / skills / mjunaidca / mjs-agent-skills / datetime-timezone

This skill helps you implement reliable datetime and timezone handling across frontend, API, and database to prevent time drift.

npx playbooks add skill mjunaidca/mjs-agent-skills --skill datetime-timezone

Review the files below or copy the command above to add this skill to your agents.

Files (1)
skill.md
9.6 KB
---
name: datetime-timezone
description: Handle datetime and timezone conversions correctly across frontend, API, and database. Use this skill when working with datetime-local inputs, scheduling features, due dates, reminders, or any time-sensitive functionality. Covers the critical pitfall of browser datetime-local inputs lacking timezone info.
---

# DateTime and Timezone Handling

Correctly handle datetime values across the full stack: browser → API → database → back to browser.

## When to Use

- Implementing due dates, reminders, or scheduling features
- Working with `datetime-local` HTML inputs
- Storing timestamps in PostgreSQL/databases
- Displaying times in user's local timezone
- Debugging "wrong time" issues

## The Golden Rule

**Store UTC, Display Local**

```
Browser (local) → Convert to UTC → API → Store UTC → Database
Database → Return UTC → API → Browser → Display in local
```

## Critical Pitfall: datetime-local Input

The HTML `<input type="datetime-local">` returns a string **WITHOUT timezone info**:

```html
<input type="datetime-local" value="2025-12-15T23:13">
```

This gives you `"2025-12-15T23:13"` - but is this UTC? Local time? **The browser doesn't tell you.**

### The Bug

```typescript
// WRONG - Backend interprets as UTC, but user entered local time!
const dueDate = "2025-12-15T23:13"  // User in PKT (UTC+5)
await api.createTask({ due_date: dueDate })
// Backend stores as 23:13 UTC
// But user meant 23:13 PKT = 18:13 UTC
// Task is now 5 hours late!
```

### The Fix

```typescript
// CORRECT - Convert local datetime to UTC ISO string
function handleSubmit() {
  let dueDateUTC: string | undefined = undefined

  if (dueDate) {
    // datetime-local gives "2025-12-15T23:13" (local time, no TZ)
    // new Date() interprets it in browser's local timezone
    // toISOString() converts to UTC: "2025-12-15T18:13:00.000Z"
    const localDate = new Date(dueDate)
    dueDateUTC = localDate.toISOString()
  }

  await api.createTask({ due_date: dueDateUTC })
}
```

## Frontend Patterns (TypeScript/React)

### Converting datetime-local to UTC

```typescript
// Input: "2025-12-15T23:13" (from datetime-local)
// Output: "2025-12-15T18:13:00.000Z" (UTC ISO string)

function localToUTC(localDatetime: string): string {
  const date = new Date(localDatetime)
  return date.toISOString()
}
```

### Converting UTC to datetime-local (for editing)

```typescript
// Input: "2025-12-15T18:13:00Z" (UTC from API)
// Output: "2025-12-15T23:13" (local, for datetime-local input)

function utcToLocal(utcDatetime: string): string {
  const date = new Date(utcDatetime)
  // Format: YYYY-MM-DDTHH:MM (no seconds, no Z)
  const year = date.getFullYear()
  const month = String(date.getMonth() + 1).padStart(2, '0')
  const day = String(date.getDate()).padStart(2, '0')
  const hours = String(date.getHours()).padStart(2, '0')
  const minutes = String(date.getMinutes()).padStart(2, '0')
  return `${year}-${month}-${day}T${hours}:${minutes}`
}
```

### Full Form Example (React)

```tsx
"use client"

import { useState } from "react"

export function TaskForm() {
  const [dueDate, setDueDate] = useState("")  // Local datetime string

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    // Convert local to UTC for API
    let dueDateUTC: string | undefined
    if (dueDate) {
      dueDateUTC = new Date(dueDate).toISOString()
    }

    await api.createTask({
      title: "My Task",
      due_date: dueDateUTC,  // UTC ISO string
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="datetime-local"
        value={dueDate}
        onChange={(e) => setDueDate(e.target.value)}
      />
      <button type="submit">Create</button>
    </form>
  )
}
```

### Editing Existing Task

```tsx
"use client"

import { useState, useEffect } from "react"

export function EditTaskForm({ task }: { task: Task }) {
  const [dueDate, setDueDate] = useState("")

  useEffect(() => {
    // Convert UTC from API to local for input
    if (task.due_date) {
      setDueDate(utcToLocal(task.due_date))
    }
  }, [task])

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    // Convert back to UTC for API
    const dueDateUTC = dueDate ? new Date(dueDate).toISOString() : undefined

    await api.updateTask(task.id, { due_date: dueDateUTC })
  }

  return (
    <input
      type="datetime-local"
      value={dueDate}
      onChange={(e) => setDueDate(e.target.value)}
    />
  )
}
```

## Backend Patterns (Python/FastAPI)

### Pydantic Model with Timezone Normalization

```python
from datetime import UTC, datetime
from pydantic import field_validator
from sqlmodel import SQLModel

class TaskCreate(SQLModel):
    title: str
    due_date: datetime | None = None

    @field_validator("due_date", mode="after")
    @classmethod
    def normalize_datetime(cls, v: datetime | None) -> datetime | None:
        """Convert timezone-aware to naive UTC for storage."""
        if v is None:
            return None
        # If timezone-aware, convert to UTC and strip timezone
        if v.tzinfo is not None:
            v = v.astimezone(UTC).replace(tzinfo=None)
        return v
```

### API Endpoint

```python
@router.post("/tasks", response_model=TaskRead)
async def create_task(
    task_in: TaskCreate,
    session: AsyncSession = Depends(get_session),
):
    # due_date is already normalized to naive UTC by validator
    task = Task(**task_in.model_dump())
    session.add(task)
    await session.commit()
    await session.refresh(task)
    return task
```

### Response Serialization

```python
from datetime import datetime

class TaskRead(SQLModel):
    id: int
    title: str
    due_date: datetime | None = None  # Returned as ISO string with Z suffix
```

FastAPI automatically serializes `datetime` as ISO 8601 with `Z` suffix (UTC).

## Database Storage

**Always store as naive UTC** (no timezone info):

```sql
-- PostgreSQL
due_date TIMESTAMP  -- NOT TIMESTAMP WITH TIME ZONE
```

```python
# SQLModel
from datetime import datetime
from sqlmodel import Field

class Task(SQLModel, table=True):
    due_date: datetime | None = Field(default=None)
```

## Displaying Times to Users

### Relative Time (e.g., "in 2 hours")

```typescript
import { formatDistanceToNow } from 'date-fns'

function formatRelative(utcDatetime: string): string {
  const date = new Date(utcDatetime)
  return formatDistanceToNow(date, { addSuffix: true })
  // "in 2 hours", "3 days ago"
}
```

### Absolute Local Time

```typescript
function formatLocal(utcDatetime: string): string {
  const date = new Date(utcDatetime)
  return date.toLocaleString()
  // "12/15/2025, 11:13:00 PM" (in user's locale)
}
```

### With Specific Format

```typescript
import { format } from 'date-fns'

function formatDateTime(utcDatetime: string): string {
  const date = new Date(utcDatetime)
  return format(date, 'MMM d, yyyy h:mm a')
  // "Dec 15, 2025 11:13 PM"
}
```

## Debugging Timezone Issues

### 1. Log at Every Step

```typescript
// Frontend
console.log("datetime-local value:", dueDate)  // "2025-12-15T23:13"
console.log("Converted to UTC:", new Date(dueDate).toISOString())  // "2025-12-15T18:13:00.000Z"
```

```python
# Backend
logger.info("Received due_date: %s", task_in.due_date)  # 2025-12-15T18:13:00+00:00
logger.info("Stored due_date: %s", task.due_date)  # 2025-12-15 18:13:00
```

### 2. Check Browser Timezone

```javascript
console.log(Intl.DateTimeFormat().resolvedOptions().timeZone)
// "Asia/Karachi" (PKT = UTC+5)
```

### 3. Verify UTC Offset

```typescript
const date = new Date("2025-12-15T23:13")
console.log("UTC offset (minutes):", date.getTimezoneOffset())
// -300 for UTC+5 (PKT)
```

## Common Pitfalls

### 1. Sending datetime-local Directly

```typescript
// WRONG
const dueDate = e.target.value  // "2025-12-15T23:13"
await api.post({ due_date: dueDate })  // No timezone!

// CORRECT
const dueDate = new Date(e.target.value).toISOString()
await api.post({ due_date: dueDate })  // "2025-12-15T18:13:00.000Z"
```

### 2. Using Date.now() Without toISOString()

```typescript
// WRONG - sends milliseconds timestamp
await api.post({ timestamp: Date.now() })  // 1734285180000

// CORRECT
await api.post({ timestamp: new Date().toISOString() })  // "2025-12-15T18:13:00.000Z"
```

### 3. Comparing Dates Without Normalization

```python
# WRONG - comparing aware and naive
if task.due_date < datetime.now(UTC):  # Error or wrong result

# CORRECT - both naive UTC
if task.due_date < datetime.utcnow():
```

### 4. Displaying UTC Directly

```typescript
// WRONG - shows UTC time to user
<span>{task.due_date}</span>  // "2025-12-15T18:13:00Z" (confusing!)

// CORRECT - convert to local
<span>{new Date(task.due_date).toLocaleString()}</span>  // "Dec 15, 2025, 11:13 PM"
```

## Testing

### Test Timezone Conversion

```typescript
describe('datetime conversion', () => {
  it('converts local to UTC correctly', () => {
    // Mock timezone to PKT (UTC+5)
    const localDatetime = "2025-12-15T23:13"
    const utc = new Date(localDatetime).toISOString()

    // In PKT, 23:13 local = 18:13 UTC
    expect(utc).toBe("2025-12-15T18:13:00.000Z")
  })
})
```

### Test Backend Normalization

```python
def test_datetime_normalization():
    # With timezone
    task = TaskCreate(
        title="Test",
        due_date=datetime(2025, 12, 15, 18, 13, tzinfo=UTC)
    )
    assert task.due_date.tzinfo is None  # Normalized to naive
    assert task.due_date == datetime(2025, 12, 15, 18, 13)
```

## Summary

| Location | Format | Example |
|----------|--------|---------|
| `datetime-local` input | Local, no TZ | `2025-12-15T23:13` |
| API request/response | UTC ISO 8601 | `2025-12-15T18:13:00.000Z` |
| Database | Naive UTC | `2025-12-15 18:13:00` |
| Display to user | Local formatted | `Dec 15, 2025 11:13 PM` |

Overview

This skill teaches precise datetime and timezone handling across frontend, API, and database layers. It focuses on the critical pitfall that HTML datetime-local inputs lack timezone information and provides concrete conversion patterns so systems store UTC and present local time correctly.

How this skill works

It inspects the data flow: browser datetime-local input → convert to UTC ISO for API → normalize and store as naive UTC in the database → return UTC ISO to clients → convert to local for display or editing. The skill includes frontend helpers (localToUTC, utcToLocal), backend validators to normalize timezone-aware datetimes to naive UTC, and debugging/test patterns to verify offsets.

When to use it

  • Building due dates, reminders, scheduling, or calendar features
  • Handling HTML <input type="datetime-local"> values in forms
  • Storing and querying timestamps in PostgreSQL or other databases
  • Displaying timestamps in the user's local timezone
  • Investigating 'wrong time' bugs across frontend/backend

Best practices

  • Always convert browser local datetimes to UTC ISO (new Date(value).toISOString()) before sending to the API
  • Normalize incoming datetimes in the backend to naive UTC for storage (convert to UTC and strip tzinfo)
  • Store timestamps as naive UTC (TIMESTAMP) in the database and avoid TIMESTAMP WITH TIME ZONE for this pattern
  • Serialize API responses as UTC ISO strings (with Z) and convert to local on the client for display
  • Log datetime values at each step and include the browser timezone when debugging

Example use cases

  • A task manager: user picks a deadline with datetime-local, convert to UTC for API and store in DB
  • An events app: list events in user locale while storing canonical UTC times for all participants
  • Editing scheduled items: convert UTC from API to datetime-local format for the form and back to UTC on save
  • Automated reminders: compare normalized naive UTC datetimes server-side to trigger notifications
  • Tests that mock browser timezone to assert correct local->UTC conversions

FAQ

Why convert datetime-local to UTC before sending to the server?

datetime-local lacks timezone info and represents local time. Converting to UTC preserves the intended instant across timezones so the backend stores the exact moment the user selected.

Should I store timestamps with or without timezone in the database?

Store naive UTC (no timezone). Normalize incoming values to UTC and strip tzinfo so queries and comparisons are consistent across environments.

How do I show a stored UTC timestamp to users in their timezone?

Return UTC ISO from the API and convert on the client with new Date(utcString).toLocaleString() or a formatting library for localized display.