Kamil Owczarek
Opublikowano

Obsługa daty i czasu odporna na strefy czasowe w aplikacjach globalnych

Autorzy

Bug, który nas ugryzł

Mieliśmy funkcję harmonogramowania treści. Autorzy mogli ustawiać artykułom datę „opublikuj o”. Dość proste, prawda?

Potem zauważyliśmy, że artykuły publikują się o złych godzinach. Artykuł zaplanowany na 12:00 w Polsce publikował się o 10:00. Użytkownicy w Niemczech widzieli inne godziny publikacji niż użytkownicy w Hiszpanii.

Gdzie był problem? Nasza obsługa daty i czasu była jednym wielkim bałaganem. Część kodu używała czasu lokalnego, część UTC, i nic nie było spójne.

Zasada, która naprawiła wszystko

Po zdecydowanie zbyt długim debugowaniu ustaliliśmy jedną prostą zasadę:

Frontend odpowiada za całą konwersję stref czasowych. Backend wyłącznie przechowuje i porównuje UTC.

To oznacza:

  1. Użytkownik wybiera czas lokalny w UI
  2. Frontend konwertuje go na UTC przed wysłaniem
  3. Backend zapisuje UTC bez zmian
  4. Backend porównuje w UTC
  5. Frontend konwertuje UTC z powrotem na czas lokalny do wyświetlenia

Bez wyjątków. Bez „sprytnych” konwersji po stronie serwera.

Narzędzia

Oto zestaw narzędzi, który zbudowaliśmy:

// utils/timezone.utils.ts

/**
 * Gets current UTC time for all comparisons
 * ALWAYS use this instead of new Date() for datetime comparisons!
 */
export function getCurrentUTC(): Date {
  return new Date()
}

/**
 * Converts local datetime input to UTC string for backend storage
 * @param localDatetimeString - Value from datetime-local input (e.g., "2024-01-01T12:00")
 * @returns ISO UTC string for backend (e.g., "2024-01-01T10:00:00.000Z")
 */
export function toUTCForBackend(localDatetimeString: string): string {
  if (!localDatetimeString) return ''

  try {
    return new Date(localDatetimeString).toISOString()
  } catch {
    return ''
  }
}

/**
 * Converts UTC datetime from backend to local datetime for display
 * @param utcDatetimeString - UTC datetime from backend
 * @returns Local datetime string for input (e.g., "2024-01-01T12:00")
 */
export function fromUTCForDisplay(utcDatetimeString: string): string {
  if (!utcDatetimeString) return ''

  try {
    const utcDate = new Date(utcDatetimeString)
    const localDate = new Date(utcDate.getTime() - utcDate.getTimezoneOffset() * 60000)
    return localDate.toISOString().slice(0, 16)
  } catch {
    return ''
  }
}

/**
 * Safe UTC date parser for backend storage
 * @param utcDatetimeString - UTC datetime string from frontend
 * @returns Date object or null if invalid
 */
export function parseUTCDatetimeFromFrontend(utcDatetimeString: string): Date | null {
  if (!utcDatetimeString) return null

  try {
    const date = new Date(utcDatetimeString)
    return isNaN(date.getTime()) ? null : date
  } catch {
    return null
  }
}

Użycie na frontendzie

Wysyłanie danych na backend

Gdy użytkownik wypełnia input datetime-local:

<script setup lang="ts">
import { toUTCForBackend } from '~/utils/timezone.utils'

const publishAt = ref('')

async function saveArticle() {
  await $fetch('/api/articles', {
    method: 'POST',
    body: {
      title: title.value,
      // Convert local input to UTC before sending
      publishedAt: toUTCForBackend(publishAt.value),
    },
  })
}
</script>

<template>
  <input type="datetime-local" v-model="publishAt" />
  <button @click="saveArticle">Save</button>
</template>

Wyświetlanie danych z backendu

Przy wypełnianiu formularza edycji:

<script setup lang="ts">
import { fromUTCForDisplay } from '~/utils/timezone.utils'

const { data: article } = await useFetch('/api/articles/1')

// Convert UTC from backend to local for input display
const publishAt = ref(fromUTCForDisplay(article.value?.publishedAt ?? ''))
</script>

<template>
  <input type="datetime-local" v-model="publishAt" />
</template>

Wyświetlanie dat tylko do odczytu

W scenariuszach czysto prezentacyjnych użyj Intl.DateTimeFormat:

function formatDate(utcString: string): string {
  return new Intl.DateTimeFormat('pl-PL', {
    dateStyle: 'long',
    timeStyle: 'short',
  }).format(new Date(utcString))
}

To automatycznie przelicza wartość na lokalną strefę czasową użytkownika.

Użycie na backendzie

Filtrowanie opublikowanych treści

// server/api/articles/published.get.ts
import { getCurrentUTC } from '~/utils/timezone.utils'

export default defineEventHandler(async () => {
  // ALWAYS use getCurrentUTC() for comparisons
  const now = getCurrentUTC()

  return prisma.article.findMany({
    where: {
      publishedAt: {
        lte: now, // Published at or before now
      },
    },
  })
})

Zapisywanie dat z frontendu

// server/api/articles/index.post.ts
import { parseUTCDatetimeFromFrontend } from '~/utils/timezone.utils'

export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  return prisma.article.create({
    data: {
      title: body.title,
      // Parse the UTC string from frontend
      publishedAt: parseUTCDatetimeFromFrontend(body.publishedAt),
    },
  })
})

Dlaczego taka architektura?

1. Niezależność od lokalizacji serwera

Twój serwer może stać w:

  • US East (UTC-5)
  • Frankfurcie (UTC+1)
  • Singapurze (UTC+8)

Przy tym wzorcu to bez znaczenia. Wszystkie porównania idą w UTC, więc przeniesienie serwerów nie zmienia zachowania.

2. Koniec z bugami podwójnej konwersji

Typowy bug:

// BAD: Server tries to be "smart"
const userTime = new Date(body.publishedAt)
const utcTime = convertToUTC(userTime) // Wait, it's already UTC!

Gdy i frontend, i backend próbują konwertować, dostajesz podwójnie przeliczone daty rozjechane o godziny.

Nasz wzorzec: frontend konwertuje raz. Backend ufa temu, co dostał.

3. Spójne wyświetlanie u wszystkich użytkowników

Użytkownik A w Polsce widzi „12:00” Użytkownik B w Niemczech widzi „11:00” (ten sam moment w czasie)

Oba odczyty są poprawne dla swojej strefy. Wartość UTC pod spodem jest identyczna.

Antywzorce

Nie rób tego: new Date() do porównań

// BAD: Server timezone affects comparison
const articles = await prisma.article.findMany({
  where: {
    publishedAt: { lte: new Date() }, // Which timezone is this?
  },
})

// GOOD: Explicit UTC reference
const articles = await prisma.article.findMany({
  where: {
    publishedAt: { lte: getCurrentUTC() },
  },
})

Nie rób tego: zapisywanie czasu lokalnego

// BAD: Storing local time
await prisma.article.create({
  data: {
    publishedAt: new Date(body.publishedAt), // Might be local!
  },
})

// GOOD: Parse as UTC
await prisma.article.create({
  data: {
    publishedAt: parseUTCDatetimeFromFrontend(body.publishedAt),
  },
})

Nie rób tego: konwersja na backendzie

// BAD: Backend trying to convert
const utcDate = moment.tz(body.publishedAt, 'Europe/Warsaw').utc()

// GOOD: Trust the frontend-converted UTC
const utcDate = parseUTCDatetimeFromFrontend(body.publishedAt)

Harmonogramowanie wielojęzyczne

W serwisach z wieloma językami każda wersja może mieć własną datę publikacji:

// Database structure
interface ArticlePublishDates {
  pl: Date | null // Polish version publish date
  en: Date | null // English version publish date
  de: Date | null // German version publish date
}

// Frontend: convert each language's date
const publishDates = {
  pl: toUTCForBackend(form.publishAt.pl),
  en: toUTCForBackend(form.publishAt.en),
  de: toUTCForBackend(form.publishAt.de),
}

// Backend: filter by current language
const langField = getLanguageField(event) // 'pl', 'en', 'de'
const articles = await prisma.article.findMany({
  where: {
    [`publishedAt.${langField}`]: {
      lte: getCurrentUTC(),
    },
  },
})

Testowanie logiki stref czasowych

Testy jednostkowe

import { toUTCForBackend, fromUTCForDisplay } from '~/utils/timezone.utils'

describe('timezone utils', () => {
  it('converts local to UTC correctly', () => {
    // Mock timezone offset for consistent testing
    const input = '2024-01-15T12:00'
    const result = toUTCForBackend(input)

    expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/)
  })

  it('handles empty input', () => {
    expect(toUTCForBackend('')).toBe('')
    expect(fromUTCForDisplay('')).toBe('')
  })

  it('round-trips correctly', () => {
    const original = '2024-01-15T12:00'
    const utc = toUTCForBackend(original)
    const back = fromUTCForDisplay(utc)

    expect(back).toBe(original)
  })
})

Testy integracyjne

Testuj na faktycznych różnicach stref czasowych:

describe('scheduling across timezones', () => {
  it('publishes at correct UTC time regardless of server timezone', async () => {
    // Schedule for 12:00 Warsaw time (UTC+1)
    const localTime = '2024-01-15T12:00'
    const utcTime = toUTCForBackend(localTime) // Should be 11:00Z

    await createArticle({ publishedAt: utcTime })

    // At 10:59 UTC, article should NOT be visible
    mockServerTime('2024-01-15T10:59:00Z')
    expect(await getPublishedArticles()).toHaveLength(0)

    // At 11:01 UTC, article SHOULD be visible
    mockServerTime('2024-01-15T11:01:00Z')
    expect(await getPublishedArticles()).toHaveLength(1)
  })
})

Podsumowanie

Zasada jest prosta:

  1. Frontend: przelicz lokalny → UTC przed wysłaniem
  2. Backend: przechowuj i porównuj wyłącznie UTC
  3. Frontend: przelicz UTC → lokalny do wyświetlenia

Trzy funkcje załatwiają całą resztę:

  • toUTCForBackend() — lokalny input → string UTC
  • fromUTCForDisplay() — string UTC → lokalny input
  • getCurrentUTC() — bieżący czas do porównań

Żadnych bibliotek. Żadnych baz stref czasowych. Po prostu spójna obsługa UTC wszędzie.


Realny wzorzec z platformy e-commerce obsługującej 11 rynków europejskich. Od wdrożenia zero bugów harmonogramowania związanych ze strefami czasowymi.