- 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:
- Użytkownik wybiera czas lokalny w UI
- Frontend konwertuje go na UTC przed wysłaniem
- Backend zapisuje UTC bez zmian
- Backend porównuje w UTC
- 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:
- Frontend: przelicz lokalny → UTC przed wysłaniem
- Backend: przechowuj i porównuj wyłącznie UTC
- Frontend: przelicz UTC → lokalny do wyświetlenia
Trzy funkcje załatwiają całą resztę:
toUTCForBackend()— lokalny input → string UTCfromUTCForDisplay()— string UTC → lokalny inputgetCurrentUTC()— 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.