BLOG · yazılım mimarisi· saat dilimi· backend

Kampüs yazılımında saat dilimi hataları nasıl önlenir

Efe Müderrisoğlu · 1 Ağustos 2026 · 14 dakika

Bir kullanıcı akşam 19:00’daki etkinliği uygulamada 16:00 olarak gördü. Veri doğruydu, gösterim yanlıştı. Neden basitti: backend zaman damgasını UTC saklıyordu ama offset bilgisi olmadan gönderiyordu, tarayıcı da bu metni yerel saat sanıyordu. Türkiye GMT+3 olduğu için fark tam olarak 3 saatti.

Bu hata sınıfı, kampüs yazılımlarında en pahalı olanlardan biri, çünkü sessiz. Sistem çökmez, log basmaz, test yeşil kalır. Sadece öğrenci yanlış saatte etkinliğe gelir. Aşağıda hatanın anatomisi ve kalıcı çözüm var.

Zaman damgası neden 3 saat kayıyor?

Kayma, offset bilgisinin kaybolduğu tek bir noktada oluşur: serileştirme. Veritabanında UTC olarak duran değer, Python tarafında naive bir datetime nesnesine dönüşür, isoformat() bu nesneyi 2026-08-01T09:00:00 diye üretir, JSON’a öyle girer. Tarayıcıda new Date("2026-08-01T09:00:00") çağrısı bu metni yerel saat kabul eder ve GMT+3’te 06:00 UTC anına karşılık gelen bir Date üretir. Değer üç saat geriye kaymıştır.

Zincirdeki dört adımı ayrı ayrı görmek gerekiyor:

AdımNe olurRisk
YazmaUygulama datetime üretir, DB’ye yazarYerel saat yazılırsa kaynak baştan bozulur
OkumaDB’den naive datetime dönertzinfo yoktur, anlam koda gömülüdür
SerileştirmeJSON’a string olarak yazılırOffset düşerse bilgi burada kaybolur
Gösterimİstemci string’i Date’e çevirirYorum istemcinin yerel saatine bırakılır

Kaybın nerede olduğu önemli, çünkü düzeltme de orada yapılmalı. İstemcide +3 ekleyerek kaymayı kapatmak yaygın bir refleks ve yanlış: yurt dışındaki kullanıcıda hata yeniden ortaya çıkar, DST geçişinde ikinci kez kayar, ve bir gün offset’li veri gelmeye başladığında düzeltme bu kez ters yönde bozar.

Doğru kural üçlemesi nedir?

Üç cümle, sistemin tamamını kapsıyor: UTC olarak depola, offset’li ISO 8601 ile gönder, kullanıcının ya da kurumun saat diliminde göster. Bu üç kural birbirinden bağımsız katmanlarda uygulanır ve hiçbir katman diğerinin işini yapmaz.

Depola: UTC. Veritabanına yazılan her zaman damgası UTC’dir. İstisna yok. Kullanıcının girdiği yerel saat, yazma anında UTC’ye çevrilir. Bu kuralın tek gerekçesi tutarlılık değil, karşılaştırılabilirlik: farklı saat dilimlerinde üretilmiş kayıtlar ancak ortak bir zemin varsa sıralanabilir.

Gönder: offset’li ISO 8601. API sözleşmesinde her tarih alanı 2026-08-01T09:00:00+00:00 biçimindedir. Offset açıkça yazılır. Bu, istemcinin yorum yapmasını gereksiz kılar ve zincirdeki tek gerçek bilgi kaybını önler.

Göster: kurumun saat dilimi. Formatlama katmanı, mutlak anı alır ve kurum saat diliminde biçimlendirir. Tarayıcının saat dilimi burada kullanılmaz, çünkü kampüs saati kurumun saatidir.

CampusCore’da bu üçleme iki dosyada toplanmış durumda: backend’de app/utils/timeutil.py, frontend’de src/lib/utils/datetime.ts. Zaman damgası üreten hiçbir servis kendi başına isoformat() çağırmıyor, gösterim yapan hiçbir bileşen kendi başına toLocaleString() çağırmıyor.

Naive ve aware datetime arasındaki fark neden bu kadar önemli?

Naive bir datetime hangi anı gösterdiğini söylemez. 2026-08-01 09:00 yazan bir nesne, bu bilgi olmadan üç farklı anı gösterebilir. Yorum, kodun içindeki yazılı olmayan bir varsayımda saklıdır ve o varsayım ekipteki her geliştiricide aynı değildir.

Kural olarak, sistemin dış sınırlarından yalnızca aware değerler geçmeli. İç katmanlarda naive kalması kabul edilebilir ama o zaman naive değerin UTC olduğu tek bir yerde yazılı olmalı ve serileştirme her zaman o varsayımı uygulamalı.

CampusCore’un yaklaşımı tam olarak bu. Veritabanı kolonları naive UTC değer tutuyor, serileştirmede tek bir fonksiyon aware’a çeviriyor:

from datetime import datetime, timezone


def to_utc_iso(dt: datetime | None) -> str | None:
    """Naive datetime'ı UTC kabul edip açık offset'li ISO-8601 döndürür.
    Zaten aware ise UTC'ye çevirir. None -> None."""
    if dt is None:
        return None
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt.astimezone(timezone.utc).isoformat()

Fonksiyonun kritik satırı replace(tzinfo=timezone.utc). Bu, değeri değiştirmez, yalnızca varsayımı açık hale getirir. astimezone ise aware bir değer geldiğinde onu UTC’ye normalize eder, böylece fonksiyon her iki girdiyi de kabul eder ve çıktısı her zaman tek biçimdir.

Yazma tarafında ters yönü de tek bir yere koyun:

from datetime import datetime, timezone
from zoneinfo import ZoneInfo


def local_input_to_utc(naive_local: datetime, tz_name: str) -> datetime:
    """Kullanıcının girdiği yerel saati UTC'ye çevirir.
    tz_name IANA adıdır: 'Europe/Istanbul'."""
    aware_local = naive_local.replace(tzinfo=ZoneInfo(tz_name))
    return aware_local.astimezone(timezone.utc)

Burada sabit +3 yerine IANA adı kullanılması zorunlu. Sabit offset, DST uygulayan bölgelerde yılın yarısında yanlıştır ve Türkiye gibi kalıcı offset’e geçmiş ülkelerde bile geçmiş tarihli kayıtlar için hatalıdır.

Kaçınılacak iki yaygın çağrı:

datetime.now()           # naive, makine saat dilimine bağlı, yasak
datetime.utcnow()        # naive UTC, offset yok, tuzak
datetime.now(timezone.utc)  # doğru: aware UTC

utcnow() özellikle sinsi, çünkü değeri doğru ama tipi eksik. Kod tabanınızda geçen her utcnow çağrısını ayıklayın.

Veritabanı kolon tipi ne olmalı?

PostgreSQL kullanıyorsanız timestamptz seçin. İsminin aksine offset saklamaz, girdiyi UTC’ye normalize eder ve okurken oturum saat dilimine çevirir. Bu davranış, uygulama katmanı disiplinli olduğu sürece doğru sonucu verir.

MySQL’de TIMESTAMP tipi UTC’ye çevirip geri döndürür ama sunucu ve bağlantı saat dilimi ayarına bağımlıdır ve 2038 sınırı vardır. Pratikte DATETIME kolonu artı “yalnızca UTC yaz” kuralı daha öngörülebilir sonuç veriyor. CampusCore MySQL üzerinde DateTime kolonlarıyla çalışıyor ve normalizasyonu uygulama katmanında yapıyor.

Hangi tipi seçerseniz seçin, üç şeyi sabitleyin:

  1. Sunucu saat dilimi UTC. Uygulama konteynerinde ve veritabanı sunucusunda TZ=UTC. Bu, func.now() gibi veritabanı tarafı varsayılanların doğru değer üretmesi için gerekli.
  2. Bağlantı saat dilimi sabit. Bağlantı havuzu farklı saat dilimleriyle açılırsa aynı sorgu farklı sonuç verir.
  3. Tarih ve saat ayrımı. Doğum tarihi gibi gerçek anlamda saatsiz veriler DATE kolonunda tutulur, saat dilimi dönüşümüne hiç girmez. Bunları datetime yapmak, gece yarısı sınırında bir gün kaydıran klasik hataya yol açar.

API sözleşmesinde tarih formatı nasıl yazılır?

Sözleşmeyi belgeleyin ve tek bir örnek verin. Belirsizlik bırakılan her alan, bir istemcide farklı yorumlanır.

KuralDoğruYanlış
Format2026-08-01T09:00:00+00:002026-08-01 09:00:00
OffsetHer zaman varBazı uçlarda var, bazılarında yok
Alan adıstarts_at, created_atdate, time
Saatsiz veri2003-04-17 (ayrı tip)2003-04-17T00:00:00+00:00
SüreSaniye cinsinden tam sayı“2 saat” gibi metin

Alan adında son ekin _at olması, o alanın bir an olduğunu belirtir ve gözden geçirmede kolay tarama sağlar. date gibi genel isimler, alanın gün mü an mı olduğunu belirsiz bırakır.

İstemci tarafında ayrıştırma tek satır kalır ve tarayıcının saat dilimine bağımlı olmaz:

// Offset'li geldiği için bu çağrı tarayıcının saat dilimine bakmaksızın
// doğru mutlak anı çözer.
const d = new Date("2026-08-01T09:00:00+00:00");

Offset olmasaydı aynı satır, kullanıcının bulunduğu yere göre farklı sonuç verirdi. Sözleşmenin tek işlevi bu belirsizliği ortadan kaldırmak.

Çok kiracılı sistemde saat dilimi neden kurum ayarı?

Kampüs saati kurumun saatidir. İstanbul’daki bir üniversitenin 19:00 etkinliği, Berlin’deki değişim öğrencisi için de 19:00 kampüs saatidir. Gösterimi tarayıcının saat dilimine bağlarsanız o öğrenci 17:00 görür ve etkinliğe iki saat erken gelir.

Bu yüzden saat dilimi, kiracı kaydının bir alanı olmalı. CampusCore’da tenants tablosunda timezone kolonu IANA adını tutuyor ve frontend gösterimi bu değere göre yapıyor:

const DEFAULT_TZ = 'Europe/Istanbul';

export function tenantTz(): string {
  return auth.currentUser?.tenant?.timezone || DEFAULT_TZ;
}

export function formatDateTime(iso: string | null | undefined): string {
  if (!iso) return '';
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return '';
  return d.toLocaleString(bcp47Locale(), {
    day: 'numeric', month: 'short',
    hour: '2-digit', minute: '2-digit',
    timeZone: tenantTz(),
  });
}

timeZone seçeneğini vermek zorunlu. Verilmediğinde toLocaleString tarayıcının saat dilimini kullanır ve hata, offset’li veri gönderiyor olmanıza rağmen gösterim katmanında yeniden doğar.

Dikkat edilecek bir ayrım daha var: dil ve saat dilimi farklı kavramlar. tr-TR bir locale’dir ve ay adlarını belirler, Europe/Istanbul bir saat dilimidir ve anı belirler. İkisini tek ayara bağlayan kod, İngilizce arayüz seçen bir kullanıcının saatini de değiştirir.

Göreli zaman ifadeleri (“5 dk önce”) bu kuralın dışında kalır, çünkü iki mutlak an arasındaki farktır ve saat diliminden bağımsızdır. Bu fonksiyonlarda saat dilimi dönüşümü yapmak gereksiz karmaşıklık ekler.

Tekrarlayan etkinlik ve yaz saati tuzağı

Tekrarlayan etkinlikleri UTC olarak açıp saklamak, DST uygulayan bölgelerde kesin hatadır. “Her salı 19:00” kuralını mart ayında UTC’ye çevirip 16:00 diye kaydederseniz, saatler ileri alındıktan sonra aynı kayıt yerel 20:00 olarak görünür.

Doğru yapı, kuralı yerel saatte ve IANA saat dilimi adıyla saklamaktır:

{
  "rule": "FREQ=WEEKLY;BYDAY=TU",
  "local_time": "19:00",
  "timezone": "Europe/Istanbul",
  "starts_on": "2026-09-15",
  "ends_on": "2027-01-10"
}

Her tekrar örneği, gösterim ya da hatırlatma anında bu kuraldan hesaplanır ve o günün offset’i uygulanır. Somutlaştırılmış (materialize edilmiş) örnekler tutuyorsanız, saat dilimi kuralları değiştiğinde bunları yeniden üretmeniz gerekir. IANA veritabanı yılda birkaç kez güncellenir ve ülkeler DST kararlarını bazen birkaç hafta önceden duyurur.

İki uç durum daha var. Saatler ileri alındığında yerel saatte hiç var olmayan bir saat oluşur, o aralığa denk gelen bir etkinlik tanımsızdır. Saatler geri alındığında ise aynı yerel saat iki kez yaşanır, hangisinin kastedildiği belirsizdir. Bu iki durumu kodda açıkça ele alın, kütüphanenin varsayılan davranışına bırakmayın.

“Bugün” ve “bu hafta” filtreleri hangi saat diliminde hesaplanır?

Kurumun saat diliminde. Gün sınırını UTC’de hesaplayan bir filtre, GMT+3 bir kampüste gece 03:00’te günü değiştirir. Akşam 21:00’de biten bir etkinlik, UTC gününe göre hâlâ aynı gündedir ama saat 02:00’de yapılan bir sorgu onu “dün” grubuna atar.

Doğru sıra şu: sınırları kurum saat diliminde bul, sonra UTC’ye çevir, sonra sorguya ver.

from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo


def day_bounds_utc(tz_name: str, day_offset: int = 0) -> tuple[datetime, datetime]:
    """Kurum saat diliminde bir günün başlangıç ve bitişini UTC olarak döndürür."""
    tz = ZoneInfo(tz_name)
    now_local = datetime.now(tz) + timedelta(days=day_offset)
    start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
    end_local = start_local + timedelta(days=1)
    return (
        start_local.astimezone(timezone.utc),
        end_local.astimezone(timezone.utc),
    )

Sorgu daima yarı açık aralık kullanmalı: start <= t < end. Kapalı aralık, gün sonundaki tek bir kaydı iki güne birden sokar ve toplamlar tutmaz.

Aynı kural haftalık ve dönemlik raporlar için de geçerli. Akreditasyon raporunda dönem başlangıcı UTC’de hesaplanırsa, dönemin ilk gecesindeki etkinlikler bir önceki döneme düşer. Sayı hatası küçüktür ama karşılaştırmayı bozar ve kaynağını bulmak günler alır.

Saat dilimi hataları nasıl test edilir?

Makinesi UTC’de çalışan bir geliştirici bu hataların hiçbirini göremez, çünkü UTC’de her yanlış varsayım doğru sonuç verir. Bu yüzden testin ilk kuralı, ortamı UTC dışına almaktır.

Uygulanacak yöntemler:

  1. Test ortamını sabit bir dilime kurun. CI’da TZ=Pacific/Chatham gibi yarım saatlik olmayan bir offset kullanın. Tam saatlik offset’ler bazı hataları maskeler.
  2. Sınır saatlerini test edin. Yerel gece yarısına yakın anlar, gün filtrelerinin kırıldığı yerdir. 23:30 ve 00:30 için ayrı test yazın.
  3. DST geçiş gününü kapsayın. Saatlerin ileri ve geri alındığı iki günü sabit tarih olarak teste koyun ve tekrarlayan etkinliğin yerel saatinin değişmediğini doğrulayın.
  4. Serileştirmeyi doğrudan test edin. Her API yanıtındaki tarih alanının offset içerdiğini kontrol eden tek bir test, gelecekteki bütün regresyonları yakalar:
const ISO_WITH_OFFSET = /(Z|[+-]d{2}:d{2})$/;

it('tarih alanları offset içerir', async () => {
  const res = await api.get('/events/1');
  expect(res.starts_at).toMatch(ISO_WITH_OFFSET);
  expect(res.created_at).toMatch(ISO_WITH_OFFSET);
});
  1. Farklı istemci saat dilimlerinde gösterim testi. Frontend testinde saat dilimini değiştirip aynı ISO değerinin aynı kurum saatini ürettiğini doğrulayın. Bu test, timeZone seçeneğinin unutulduğu her yeni bileşende kırılır ve tam da bunun için vardır.
  2. Kod tabanında yasaklı çağrı taraması. datetime.utcnow, datetime.now() (argümansız) ve toLocaleString çağrılarını lint kuralıyla veya basit bir grep kontrolüyle engelleyin. Disiplin, gözden geçirmeye değil otomasyona bağlanmalı.

Bu altı adım, saat dilimi hatalarının pratikte tamamını kapsıyor. Kalan az sayıdaki vaka, üçüncü taraf takvim entegrasyonlarında ve kullanıcıdan gelen dosya içe aktarımlarında çıkıyor, ikisinde de kural aynı: girdiyi sınırda aware hale getir, gerisini sisteme bırak.

Hızlı kontrol listesi

  • Veritabanına yazılan her değer UTC mi?
  • datetime.utcnow() ve argümansız datetime.now() kod tabanından kaldırıldı mı?
  • Bütün API tarih alanları offset içeriyor mu?
  • Serileştirme tek bir yardımcı fonksiyondan mı geçiyor?
  • Gösterim timeZone seçeneğini açıkça veriyor mu?
  • Saat dilimi kurum ayarı olarak mı tutuluyor?
  • Gün ve hafta sınırları kurum saat diliminde mi hesaplanıyor?
  • Tekrarlayan etkinlikler yerel saat ve IANA adıyla mı saklanıyor?
  • CI saat dilimi UTC dışında mı?
  • Sunucu ve veritabanı konteynerlerinde TZ=UTC ayarlı mı?

CardeaCore bu işi nasıl yapıyor

CardeaCore’da zaman damgası serileştirmesi tek bir yardımcı fonksiyonda (to_utc_iso) toplanmış, gösterim ise frontend’de tek bir modülde (datetime.ts) yapılıyor ve her formatlama kurumun tenants.timezone alanındaki IANA saat dilimini kullanıyor. Kullanıcı nerede olursa olsun kampüs saatini görüyor. Platformun geneli için Cardea Campus sayfasına bakabilirsiniz.

SIK SORULAN SORULAR

Sık sorulan sorular

Etkinlik saatinin 3 saat kayması neden olur?

Sunucu, UTC olarak sakladığı zaman damgasını offset bilgisi olmadan gönderdiğinde olur. Tarayıcı '2026-08-01T09:00:00' gibi offset'siz bir metni yerel saat kabul eder, oysa değer UTC'dir. GMT+3 bir kullanıcıda aradaki fark tam olarak 3 saat görünür.

Zaman damgası veritabanında hangi tipte saklanmalı?

UTC'ye normalize edilmiş bir zaman damgası kolonunda. PostgreSQL'de timestamptz, MySQL'de DATETIME kolonuna yalnızca UTC değer yazma disipliniyle. Kritik olan kolon tipinden çok kuraldır: veritabanına yerel saat asla yazılmaz.

API'de tarih hangi formatta gönderilmeli?

Açık offset içeren ISO 8601 ile, örneğin 2026-08-01T09:00:00+00:00. Offset'siz string göndermek istemciye yorumlama serbestisi bırakır ve kayma bu boşlukta oluşur. Epoch saniye de doğrudur ama okunabilirliği düşüktür.

Naive ve aware datetime arasındaki fark nedir?

Aware datetime tzinfo bilgisi taşır, naive taşımaz. Naive bir değer tek başına hangi anı gösterdiğini söylemez, yorumu koda gömülü varsayıma bırakır. Sistem sınırlarını yalnızca aware değerlerin geçmesi kural haline getirilmelidir.

Çok kiracılı sistemde saat dilimi kullanıcı ayarı mı olmalı?

Kurum ayarı olmalı. Kampüs saati kurumun saatidir, yurt dışındaki bir öğrenci etkinliği kendi yerel saatinde görürse yanlış saatte gelir. Kullanıcı tercihi ancak açık bir tercih olarak, kurum saati varsayılan kalacak şekilde eklenebilir.

Yaz saati uygulaması tekrarlayan etkinlikleri nasıl bozar?

Tekrarlayan etkinlik UTC olarak saklanırsa DST geçişinden sonra yerel saat kayar, çünkü offset değişir. Doğrusu, tekrarlayan kuralı yerel saat ve IANA saat dilimi adıyla saklamak, her tekrarı gösterim anında o günün offset'i ile hesaplamaktır.

Bugün ve bu hafta filtreleri hangi saat diliminde hesaplanmalı?

Kurumun saat diliminde. Gün sınırı UTC'de hesaplanırsa GMT+3 bir kampüste gece 03:00'te gün değişir ve akşam etkinlikleri yanlış güne düşer. Filtre sınırlarını kurum saat diliminde bulup UTC'ye çevirdikten sonra sorguya verin.

Saat dilimi hataları nasıl test edilir?

Test ortamının saat dilimini UTC dışında bir değere sabitleyerek, tercihen yarım saatlik offset'i olan bir bölge ile. Her şey UTC'de çalışan bir makinede hata görünmez. En az bir testin DST geçiş gününü kapsaması gerekir.