Skip to the content.

0004. endDate は exclusive 半開区間で保存、フォーム境界で Zod transform で 1 箇所変換

Context

ADR 0003 (現在 Superseded) では「全層 inclusive + ResourceTimeline 境界で ±1 day 変換」を採用したが、業界標準調査の結果、内部 exclusive 半開区間 [start, end) が圧倒的に多数派と判明:

規格 / ライブラリ / API 規約
RFC 5545 (iCalendar) DTEND は exclusive
Google Calendar API end.date は exclusive (RFC 5545 準拠)
Outlook Calendar API exclusive
PostgreSQL daterange デフォルト [) (start inclusive, end exclusive)
Java LocalDate.datesUntil end exclusive
Rust Range<T> (..) exclusive
Python range() exclusive
Bryntum Gantt finish exclusive
DHTMLX Gantt end exclusive
@tommykey-apps/ui-components ResourceTimeline endDate exclusive

Inclusive を採用した場合 (= 旧 ADR 0003)、ResourceTimeline / 将来の iCal/Google Calendar 連携 / PostgreSQL daterange と毎回 ±1 day 変換が発生し、「変換ロジックがレイヤを跨いで散らばる」問題があった。

業界の優れた実装は 「内部 exclusive で統一 + フォーム widget 境界で UX inclusive を 1 箇所で翻訳」 という形 (Google Calendar、Outlook、Bryntum など)。これを採用すれば:

Decision

内部 exclusive 半開区間 [startDate, endDateExclusive) で統一。 変換はフォーム境界の Zod .transform() のみ。

各レイヤの規約

レイヤ 規約 値の例 (5/1〜5/31 のアサイン)
DB / API / Repository / Type exclusive [start, end) startDate=2026-05-01, endDateExclusive=2026-06-01
ResourceTimeline 渡し (Date 型) exclusive (規約一致) adapter は型変換のみ、±1 day 不要
フォーム widget (AssignmentCreator) UX inclusive (label「終了日 (含む)」5/31) ユーザーは引き続き 5/31 と入力
変換場所 Zod .transform() の 1 箇所 addDays(input.endDate, 1)

Type-level distinction

これにより「どのレイヤで何の意味を持つか」が型システムで明示される。endDateendDateExclusive に渡そうとするとコンパイルエラー。

フィールド名

endDate ではなく endDateExclusive を採用。理由:

Consequences

Positive

Negative

Neutral

Alternatives considered

採用案は 「内部 exclusive + UX inclusive、変換は schema.transform 1 箇所」 で、(A)〜(D) の良いとこ取り。

Implementation notes

実装 PR の主な変更:

フォーム (AssignmentCreator.svelte) は 無変更<input name="endDate"> のまま。submit 時に Zod が .transform()endDateExclusive に変換する。

References