Skip to content

タイムゾーンの考え方 ​

第5版作成 最終更新 (日本時間)
確認バージョン1.5.1.01.5.8.1

プリザンターの DB には、日時がタイムゾーン情報なしの DateTime 型で、サーバーローカル タイムゾーン(UTC ではありません)の値として格納されています。入出力のたびに「サーバーローカル」「ユーザー」「UTC」の 3 つのタイムゾーンを使い分けて変換しています。 このページでは、その変換の仕組みと、入力経路(フォーム・API・CSV・サーバースクリプト)ごとの違い、タイムゾーンが混在する環境で起きる日付ズレと対策を整理します。

結論

  • 全ユーザーがサーバーと同じタイムゾーンなら、変換は恒等変換になり問題は起きません
  • 通常のサーバースクリプトだけは ConvertTimeFromUtc による独自の変換を使うため、サーバーのタイムゾーンが UTC 以外だと日付がズレます
  • API はリクエストでタイムゾーンを指定できず、API キーの所有ユーザーのタイムゾーンが基準になります

3 つのタイムゾーン ​

名称実体決定方法用途
サーバーローカル タイムゾーンTimeZoneInfo.LocalOS から自動取得DB 格納、内部処理の基準
ユーザー タイムゾーンcontext.TimeZoneInfoユーザー設定 / パラメータ画面表示、フォーム入力、API 入出力
UTCDateTimeKind.Utc固定JavaScript エンジン(V8)の内部形式

図を読み込み中…

タイムゾーン設定の階層 ​

システムデフォルト(Service.json) ​

App_Data/Parameters/Service.json の TimeZoneDefault で、システム全体のデフォルトタイムゾーンを設定します。配布時の既定値は "UTC" で、次の例は日本時間に変えた場合です。

json
{
    "TimeZoneDefault": "Tokyo Standard Time"
}

この値は起動時に Environments.TimeZoneInfoDefault に解決されます。OS に存在しない ID を書いた場合は UTC になり、Users_TimeZone 列の既定値もこの値に置き換わります(1.5.8.1 のソースでは Initializer.cs#L1333-L1340、Service.json#L4)。次の場面で使われます。

利用場面説明
ユーザー新規作成TimeZone カラムのデフォルト値
ユーザー一括登録タイムゾーン未指定時のフォールバック
Context 初期化未認証リクエスト時のタイムゾーン
バックグラウンドサーバースクリプトスケジュールのタイムゾーン未設定時のフォールバック

WARNING

TimeZoneDefault はサーバーローカル タイムゾーン(TimeZoneInfo.Local)とは別物です。サーバーローカル タイムゾーンは OS のタイムゾーン設定によって自動決定されます。

ユーザーごとのタイムゾーン ​

各ユーザーの管理画面でタイムゾーンを個別に設定でき、Users テーブルの TimeZone カラムに文字列で保存されます。

csharp
public string TimeZone = "UTC";  // DB カラム

public TimeZoneInfo TimeZoneInfo
{
    get
    {
        return TimeZoneInfo.GetSystemTimeZones()
            .FirstOrDefault(o => o.Id == TimeZone);
    }
}

無効な値が設定されている場合のフォールバック順序は、設定値 → 東京標準時 → サーバーローカル タイムゾーンです(1.5.8.1 のソースでは UserModel.InitializeTimeZone()、UserModel.cs#L6085-L6089)。

SAML 認証では、Authentication.json の SamlParameters.Attributes.TimeZone(既定の属性名は TimeZone)に対応する属性が IdP から送られてくると、その値がユーザーの TimeZone に同期されます。属性が無ければ同期しません(Authentication.json#L56、Saml.cs#L214-L216)。

実行時のタイムゾーン(Context.TimeZoneInfo) ​

図を読み込み中…

ログイン時や API 認証時にユーザーのタイムゾーンで上書きされ、未認証の場合は TimeZoneDefault がそのまま使われます。

DB 格納形式 ​

プリザンターはすべての日時をサーバーローカル タイムゾーンで DB に格納します。

RDBMS現在日時の取得格納されるタイムゾーン
SQL Servergetdate()サーバーローカル
PostgreSQLCURRENT_TIMESTAMPサーバーローカル
MySQLCURRENT_TIMESTAMPサーバーローカル

DANGER

サーバーの OS タイムゾーンを変更すると、既存データとの整合性が崩れます。運用中の変更は避けてください。

やむを得ずサーバーのタイムゾーンを変えるときは、CodeDefiner の ConvertTime /h 時差 で DB の日時列をまとめてずらせます(CodeDefiner のコマンド一覧を参照)。

変換ヘルパー ToLocal / ToUniversal ​

日時変換の中核は Times.cs に定義された 2 つの拡張メソッドです。

メソッド変換方向用途
ToLocal(context)サーバーローカル → ユーザー タイムゾーンDB 値を画面表示・API レスポンスに
ToUniversal(context)ユーザー タイムゾーン → サーバーローカルフォーム入力・API リクエストを DB に
csharp
// サーバーローカル タイムゾーン → ユーザー タイムゾーン
public static DateTime ToLocal(this DateTime value, Context context)
{
    var timeZoneInfo = context.TimeZoneInfo;
    if (timeZoneInfo == null || timeZoneInfo.Id == TimeZoneInfo.Local.Id)
        return value;
    return TimeZoneInfo.ConvertTime(value, timeZoneInfo);
}

// ユーザー タイムゾーン → サーバーローカル タイムゾーン
public static DateTime ToUniversal(this DateTime value, Context context)
{
    var timeZoneInfo = context.TimeZoneInfo;
    if (timeZoneInfo == null || timeZoneInfo.Id == TimeZoneInfo.Local.Id)
        return value;
    return TimeZoneInfo.ConvertTime(value, timeZoneInfo, TimeZoneInfo.Local);
}

ToUniversal は UTC への変換ではない

ToUniversal という名前ですが、UTC への変換ではありません。ユーザー タイムゾーンからサーバーローカル タイムゾーンへの変換です。

ユーザー タイムゾーンとサーバーローカル タイムゾーンが同一の場合は変換がスキップされるため、単一タイムゾーン環境では実質的に何も起きません。

Time クラスの二重保持 ​

Time クラスは、1 つの日時に対してサーバーローカルの値と表示用の値の 2 つを保持します。

csharp
public class Time : IConvertable
{
    public DateTime Value = 0.ToDateTime();        // サーバーローカル タイムゾーン(DB 保存値)
    public DateTime DisplayValue = 0.ToDateTime();  // ユーザー タイムゾーン(表示用)
}

DB から読み取ったときは Value を ToLocal して DisplayValue にセットし、フォーム入力時は DisplayValue を ToUniversal して Value にセットします。

フォーム入力から画面表示までの流れ ​

サーバーのタイムゾーンが UTC、ユーザーのタイムゾーンが JST(+9)の例です。入力と表示で対称的な変換が行われるため、同じユーザーが操作する限り日時のズレは発生しません。

図を読み込み中…

入出力経路ごとの変換方式 ​

入力経路変換メソッドタイムゾーン基準の決定要因
フォーム入力ToUniversal(context)ログインユーザーのタイムゾーン
API リクエストToUniversal(context)API キー所有ユーザーのタイムゾーン(リクエスト側で指定不可)
CSV インポートToUniversal(context)インポート実行ユーザーのタイムゾーン
計算式サーバースクリプトToUniversal(context)操作ユーザーのタイムゾーン
通常サーバースクリプトConvertTimeFromUtc(v, Local)UTC 固定(ユーザー タイムゾーンを一切使わない)
出力経路変換メソッドタイムゾーン基準
画面表示ToLocal(context)ユーザー タイムゾーン
API レスポンスToLocal(context)ユーザー タイムゾーン
計算式サーバースクリプトToLocal(context) → 文字列化ユーザー タイムゾーン
通常サーバースクリプト変換なし(生の DateTime)なし

図を読み込み中…

サーバースクリプトでの日時 ​

サーバースクリプトは JavaScript エンジンとして ClearScript(V8)を使用しています。.NET の DateTime を JavaScript に渡すと、V8 は DateTimeKind に関係なく UTC の Date オブジェクトとして扱います。この制約により、通常のサーバースクリプトの日時変換はフォーム入力や API とは根本的に異なる方式になっています。

通常サーバースクリプトの入出力 ​

入力(DB → JavaScript): Values() メソッドを通じて、サーバーローカル タイムゾーンの生の DateTime がそのまま渡されます。V8 はこれを UTC の Date として扱います。

csharp
// 通常サーバスクリプト
ReadNameValue(
    columnName: nameof(model.CreatedTime),
    value: model.CreatedTime?.Value,  // 生の DateTime(サーバーローカル タイムゾーン)
    mine: mine),

出力(JavaScript → DB): Date() メソッドで書き戻されます。ConvertTimeFromUtc は入力値を常に UTC として扱うため、サーバーローカルの値であっても UTC として解釈して変換します。

csharp
private static DateTime Date(ExpandoObject data, string name)
{
    var value = Value(data, name);
    return value is DateTime dateTime
        ? TimeZoneInfo.ConvertTimeFromUtc(dateTime, TimeZoneInfo.Local)
        : Types.ToDateTime(0);
}

サーバーが UTC 以外だと日時がズレる ​

サーバーのタイムゾーンが JST(+9)の環境で、日付をそのまま書き戻す場合です。

図を読み込み中…

サーバーのタイムゾーンが UTC の場合は ConvertTimeFromUtc(UTC, UTC) が恒等変換になるためズレは発生しません。サーバーのタイムゾーンが UTC 以外の場合に問題が顕在化します。

日付を別の項目にコピーする場合も同様です。model.DateA = model.StartTime; とすると、StartTime が 2024/01/02 00:00 なのに、書き戻された DateA は 2024/01/02 09:00 になります(二重シフト)。

文字列で代入すると値が消える ​

組み込み日付項目に文字列を代入すると、値が消失します。Date() メソッドが value is DateTime をチェックし、文字列の場合は Types.ToDateTime(0) を返すためです。

javascript
// NG: 値が消失する
model.StartTime = "2024/01/01";

計算式サーバースクリプトは安全 ​

計算式サーバースクリプトでは、通常のフォーム入力と同じ変換方式が使われます。入力時に ToClientTimeZone(context)(= ToLocal(context))でユーザー タイムゾーンの文字列として渡し、書き戻しは SetByFormData で ToUniversal(context) するため、日時のズレは発生しません。

項目通常サーバースクリプト計算式サーバースクリプト
入力の型DateTime(サーバーローカル タイムゾーン)string(ユーザー タイムゾーン)
出力の変換ConvertTimeFromUtc(v, Local)ToUniversal(context)
対称性非対称(入力のタイムゾーンと出力の変換基準が異なる)対称(入出力が同じタイムゾーン基準)

$NOW() / $TODAY() と utilities.Today() ​

関数使える場所動作
$NOW()計算式UTC + ユーザー タイムゾーンのオフセットで、ユーザー タイムゾーンの日時文字列を返す
$TODAY()計算式同上で時刻部分を切り捨てた日付文字列を返す
utilities.Today()通常サーバースクリプト下記のとおり

WARNING

$NOW() / $TODAY() は BaseUtcOffset を使用しています。夏時間(DST)を持つタイムゾーンでは不正確になる可能性があります。

csharp
public DateTime Today()
{
    return DateTime.Now.ToLocal(context: Context).Date.ToUniversal(context: Context);
}
  1. DateTime.Now でサーバーローカルの現在時刻を取得
  2. .ToLocal(context) でユーザー タイムゾーンに変換
  3. .Date で日付部分のみ取得(00:00:00)
  4. .ToUniversal(context) でサーバーローカルに戻す

この値をサーバースクリプト内で項目に代入すると、書き戻し時に Date() メソッドの ConvertTimeFromUtc が適用される点に注意が必要です。

model.XXX と model.Body.XXX の違い ​

サーバースクリプト内で日付を設定する方法は 2 つあり、拡張日付項目では変換処理が異なります。

項目パス変換フロー
組み込み日付項目(StartTime 等)model.StartTime(ExpandoObject)Date(data, name) → ConvertTimeFromUtc → サーバーローカル
組み込み日付項目model.Body.StartTime(API モデル)TrySetMember → Date(value) → ConvertTimeFromUtc → サーバーローカル
拡張日付項目(DateA 等)model.DateA(ExpandoObject)Date() → ConvertTimeFromUtc → 文字列 → SetValue(toUniversal: false)
拡張日付項目model.Body.DateA(API モデル)value.ToStr() → SetValue(toUniversal: false)(タイムゾーン変換なし)

組み込み日付項目はどちらのパスでも同じ変換ですが、拡張日付項目では同じ値を代入してもパスによって異なるタイムゾーン処理が適用されます。

CompletionTime の特殊処理 ​

CompletionTime(期限日)は、表示フォーマットが Ymd の場合に内部で +1 日のオフセットを持ちます(後述の「日付のみの項目(Ymd 形式)」を参照)。計算式サーバースクリプトでは入力時に -1 日、書き戻し時に +1 日で正しく調整されますが、通常サーバースクリプトではこの調整が行われないため、CompletionTime を直接操作する際は注意が必要です。

安全パターンと危険パターン ​

区分パターン理由
危険model.StartTime = new Date(2024, 0, 1)Date() で UTC → サーバーローカル変換。サーバーのタイムゾーンが UTC 以外ならズレる
危険model.DateA = model.StartTime日付コピーで二重シフトが発生
危険model.StartTime = "2024/01/01"is DateTime チェック失敗で値が消失する
安全計算式サーバースクリプトを使うToLocal / ToUniversal で対称変換
安全サーバーのタイムゾーンを UTC で運用するConvertTimeFromUtc(UTC, UTC) が恒等変換になる

API と CSV での日時 ​

API は API キーのユーザーのタイムゾーンが基準 ​

API の日時処理では、API キーに紐づくユーザーのタイムゾーンが基準になります。リクエスト側でタイムゾーンを指定する手段はなく、日時にタイムゾーン情報(Z や +09:00 など)を付与しても無視されます。

図を読み込み中…

作成・更新時は SetUser() で API キーからユーザーのタイムゾーンを解決し、SetByApi() で ToUniversal(context) します。レスポンスは ToLocal(context) で API キーの所有ユーザーのタイムゾーンで返ります。たとえば所有ユーザーが JST、サーバーが UTC の場合、"2024/01/15 09:00" は 2024/01/15 00:00 として保存され、取得時には "2024/01/15 09:00" で返ります。

入力と出力が対称なので、同じ API キーで操作する限り日時のズレは発生しません。異なるタイムゾーンのユーザーの API キーで取得すると別の時刻で返ります(JST で登録した 1/15 09:00 は、PST のユーザーには 1/14 16:00 で返る)。これは同じ瞬間を指すので日時としては正しい変換ですが、日付のみの項目では日付が変わってしまうことがあります。

CSV インポートは実行ユーザーのタイムゾーンが基準 ​

CSV インポートも、インポートを実行したユーザーのタイムゾーンで日時が解釈されます。

csharp
else if (TypeName == "datetime")
{
    return value?.ToDateTime(format: RecordingFormat)
        .ToUniversal(context: context).ToString()
        ?? string.Empty;
}
CSV の値ユーザーのタイムゾーンDB 値(サーバーのタイムゾーン = UTC)
2024/01/15 09:00JST (+9)2024/01/15 00:00
2024/01/15 09:00PST (-8)2024/01/15 17:00

WARNING

CSV インポートを行う際は、CSV 内の日時がどのタイムゾーンを想定しているかを意識し、インポート実行ユーザーのタイムゾーン設定を確認してください。

バックグラウンドサーバースクリプトのスケジュール ​

スケジュール評価では次の優先順位でタイムゾーンが決まります。

優先順位設定値説明
1schedule.ScheduleTimeZoneIdスケジュール個別設定
2TimeZoneDefaultService.json のシステムデフォルト
3UTCいずれも未設定時のフォールバック
csharp
var timeZone = TimeZoneInfo.FindSystemTimeZoneById(
    !schedule.ScheduleTimeZoneId.IsNullOrEmpty()
        ? schedule.ScheduleTimeZoneId
        : !Parameters.Service.TimeZoneDefault.IsNullOrEmpty()
            ? Parameters.Service.TimeZoneDefault
            : TimeZoneInfo.Utc.Id);

フロントエンドへの伝達 ​

画面表示の際は、ユーザー タイムゾーンのオフセットが hidden 要素 TimeZoneOffset(例: "+09:00"、context.TimeZoneInfoOffset())として HTML に埋め込まれ、フロントエンドの JavaScript で日付ピッカーなどの表示補正に使われます(HtmlTemplates.cs)。

タイムゾーン混在環境で起きる問題 ​

同じタイムゾーンのユーザーだけなら問題ありませんが、異なるタイムゾーンのユーザーが混在すると、変換の非対称性が日付ズレとして現れます。

日付のみの項目(Ymd 形式) ​

EditorFormat が Ymd(日付のみ)の項目は、DB に保存する際に「表示日 + 1 日」の値を格納します(AddDifferenceOfDates)。たとえば期限日 2026-02-24 は DB 上では 2026-02-25 00:00:00(サーバーローカル時刻)です。

EditorFormatピッカー形式DB 保存時表示時
Ymd日付のみ+1 日-1 日
Ymdhm日時(分)そのままそのまま
Ymdhms日時(秒)そのままそのまま
csharp
public static int DifferenceOfDates(string format, bool minus = false)
{
    switch (format)
    {
        case "Ymd": return minus ? -1 : 1;
        default: return 0;
    }
}

タイムゾーン変換とこの ±1 日が組み合わさると、日付がズレます(サーバーが UTC の例)。

図を読み込み中…

WARNING

この例はサーバーのタイムゾーンが UTC の場合です。サーバーが JST であれば JST ユーザーの ToUniversal は恒等変換になりますが、サーバーローカルと異なるタイムゾーンのユーザーは常に影響を受けます。

根本原因は、「日付のみ」でタイムゾーンに依存しないはずの値が DateTime 型で管理され、特定の時刻を持ってしまう点です。AddDifferenceOfDates は単純な ±1 日であり、タイムゾーンのオフセット差を吸収しません。

期限切れ判定(Overdue) ​

Overdue() はサーバーローカル時刻同士の比較だけで判定し、ユーザー タイムゾーンを考慮しません。

csharp
public bool Overdue()
{
    return Status.Incomplete() && Value < DateTime.Now;
}

サーバーが JST、ユーザーが PST (-8) で期限日 2/24 を入力した場合です。

処理値
入力: 2/24PST 2/24 00:00
ToUniversal (PST → JST)JST 2/25 01:00
AddDifferenceOfDates (+1)JST 2/26 01:00(DB 値)
Overdue 判定2/26 01:00 < DateTime.Now(JST) で比較

JST のユーザーから見れば 2/24 の期限日なのに、Overdue になるのは JST の 2/26 01:00 以降で、約 2 日の遅延が生じます。SiteMenu の期限切れ件数も DB サーバーの getdate() / CURRENT_TIMESTAMP との比較であり、同様にタイムゾーンを考慮しません。

そのほかの影響箇所 ​

機能内容
カレンダーの終日表示時刻部分が 00:00:00 かどうか(FullCalendar の nextDayThreshold: "00:00:00")で終日判定するため、変換で時刻部分が残ると時間指定イベントとして表示される。例: Ymd の CompletionTime の DB 値が 2026-02-25 15:00(UTC)なら、JST ユーザーは 02-25 00:00 で正しいが、PST ユーザーは 02-24 07:00 になる(HtmlCalendar.cs の ConvertIfCompletionTime)
ガントチャートバーの描画用は AddDifferenceOfDates 前、表示ラベル用は後の値を使うため、日付がまたぐとバーの長さとラベルが 1 日ズレることがある(GanttElement.cs)
期限が近いフィルター(NearCompletionTime)DateTime.Now.ToLocal(context).Date でユーザーの「今日」を算出するが、サーバーローカルで格納された DB 値と直接比較される(View.cs)
日付フィルター(「今日」「今月」等)基準日は DateTime.Now.ToLocal(context) でユーザー タイムゾーン、DB 値はサーバーローカル(DateColumnExtensions.cs)
CSV エクスポートToLocal 済みの DisplayValue を出力するため、実行者のタイムゾーンが反映される。サーバー JST で JST ユーザーが登録した 2026-02-24(DB 値 2026-02-25 00:00)を PST ユーザーが出力すると 2026/02/23 になる
リマインダー開始日時の初期値は DateTime.Now.ToLocal(context).Date.AddDays(1)(設定したユーザーの翌日 0:00)。送信の処理の context はログインを経ないため TimeZoneDefault のまま。送るかどうかの判定(ScheduledTime <= DateTime.Now.ToLocal(context))も次回の計算も TimeZoneDefault の時計で行うので、開始日時は「TimeZoneDefault で何時か」として扱われ、TimeZoneDefault が UTC のままだと日本時間の利用者には 9 時間遅れて届く。対象レコードの「今日 + Range 日」も TimeZoneDefault の今日をサーバーローカルの DB 値と直接比べる。Ymd の項目で DB 値が 0:00 ちょうどでない場合も判定がずれることがある(通知のカスタムフォーマットとリマインダーの内部動作)
遅延フィルター(Delay)進捗率の期待値を SQL(ProgressRateDelay)で CompletionTime の DB 値から計算するため、CompletionTime のズレをそのまま引き継ぐ(View.cs)
日付項目の既定値(DefaultInput)Ymd の項目は DateTime.Now.ToLocal(context).Date に日数を足して ToUniversal した値になり、入力したユーザーのタイムゾーンで DB 値が変わる。Ymd 以外は DateTime.Now(サーバーローカル)に日数を足すだけ(Column.cs#L1082-L1089)
期限が近い判定(CompletionTime.Near())ユーザーの「今日」と閲覧者のタイムゾーンで変換済みの DisplayValue を比べるため、閲覧者のタイムゾーン内で閉じており影響は小さい(CompletionTime.cs#L109-L118)
バーンダウンチャート(影響小)「今日の線」がユーザーとサーバーのタイムゾーン差で数時間ズレる
DataChange 日付自動設定(影響小)ユーザー タイムゾーンによる「今日」が DB 保存値に影響
サーバースクリプト Today()(影響小)ユーザー タイムゾーンにより異なる値が返る
クロス集計(影響小)タイムゾーン差 + AddDifferenceOfDates の二重補正による集計軸のズレ

図を読み込み中…

根本原因 ​

  1. 日付のみの値に時刻情報が付随する: Ymd 形式でも DateTime 型で管理されるため、タイムゾーン変換を経由すると時刻成分が混入する。AddDifferenceOfDates はこれを考慮しない単純な ±1 日
  2. ToUniversal / ToLocal がサーバーローカル基準: UTC を経由しないため、変換結果がサーバーのタイムゾーン設定に依存する
  3. 判定ロジックがタイムゾーンを考慮しない: Overdue() や SiteMenu の件数は DateTime.Now や DB サーバーの現在時刻を直接使う
  4. 日付フィルターの不整合: 条件はユーザー タイムゾーンで生成されるが、比較対象の DB 値は別のユーザーのタイムゾーンで保存された可能性がある

対策 ​

全ユーザーがサーバーと同じタイムゾーンを使う環境(単一タイムゾーン環境)では、ToLocal / ToUniversal が恒等変換になるため、上記の混在環境の問題は発生しません。

対策内容
サーバーのタイムゾーンを UTC にするタイムゾーン変換の影響を最小限にでき、通常サーバースクリプトの ConvertTimeFromUtc も恒等変換になる
日時の項目(Ymdhm / Ymdhms)を使うAddDifferenceOfDates の ±1 日が適用されないため、日付ズレが軽減される
日時を扱うサーバースクリプトは計算式を使う対称変換で日時がズレない
API キーのタイムゾーンを統一する外部連携で複数の API キーを使う場合の日時の混乱を防ぐ
API キーのユーザーのタイムゾーンをサーバーに合わせるToUniversal / ToLocal がスキップされ、送信した日時がそのまま DB に格納される
CSV のインポート/エクスポートは同じタイムゾーンのユーザーで行うタイムゾーン差による日付変動を回避
Overdue 判定のズレを認識するタイムゾーン差がある環境では期限切れの表示が正確でない可能性を利用者に周知する

関連ページ ​

変更履歴

第5版通知とリマインダーの書式・置き換わらない書き方・タイムゾーンの影響、システムログの外部通知の解説と、関連する改修・設計メモを追加
第4版CodeDefiner のデータベース作成・更新とパラメータの引き継ぎ、画面でのパラメータ管理、MCP エンドポイントのブラウザアクセスの解説と、関連する改修・設計メモを追加
第3版「内部実装を読む」を 1.5.8.1 のソースで検証して修正
第2版記事のファイル名に並び順の番号を付け、元記事リンクを frontmatter の sources に移行
第1版「内部実装を読む」に検索・内部 SQL・CRUD・タイムゾーン・開発環境を追加