ADR-012: 營業行事曆與日期可選性契約(BusinessCalendar)
ADR 編號: 012 狀態: 已接受 (Accepted) 決策日期: 2026-07-04 決策者: Framework Maintainer + AI Assistant 取代: 無 被取代: 無
摘要
appfuse-server 以「組合」為中心重塑行事曆能力:新增 BusinessCalendar 抽象(日期 → DayStatus)與組合子(政府行事曆 + 每週店休 + 非過去 + 前置天數 + 特例覆寫),almanac 的 CalendarService 降級為其中一個資料來源 adapter。同一份具名行事曆同時服務兩個消費點——resolve()(解算範圍為 DayStatus[] wire format,供前端渲染)與 validate()(提交時驗證,後端權威)——組合邏輯只存在後端,前後端規則在結構上不可能漂移。appfuse-web 新增薄 DatePicker(遵循 data-input 表單欄位契約、吃已解算的 DayStatus[]、零行事曆邏輯)與可重用的月曆網格(day-cell slot 留給未來事件行事曆檢視)。事件行事曆(會議室/個人排程)為不同 domain,明確 out of scope,以 DayStatus 投影為橋接。
背景 (Context)
問題陳述
下游應用的日期輸入普遍受「與應用相關的行事曆」限制。具體案例(花店參考實作 US-201 創建訂單的配送日期,已於 Claude Design 原型驗證 UX):
- 過去日期不可選(今天之前灰字停用)
- 店休日不可選(每週一灰字+刪除線,附「灰字=不可選(已過或週一店休)」圖例)
- 其他常見變體:政府例假日不可選(B2B 出貨)、前置天數(今天 +N 個工作日起可選)、臨時休/補開特例
現況兩端都有缺口:
| 端 | 現況 | 缺口 |
|---|---|---|
| appfuse-server | almanac/calendar/ 的 CalendarService + CalendarDay(date, holiday, description)——形狀鏡射 almanac wire format(GET /api/v1/calendar/{year}) | 只有「政府行事曆查詢」,無業務規則組合(店休、非過去、前置);無供前端消費的可選性契約;無提交驗證的標準入口 |
| appfuse-web | data-input 家族無日期元件,靠原生 <input type="date"> | 原生控件只能表達 min/max 連續範圍,無法表達「店休日」這種非連續禁用,也給不出不可選的原因 |
需要決定:行事曆組合邏輯放哪一端?前後端以什麼契約銜接?
限制條件
- 後端是驗證權威:前端限制只是 UX;配送日期落在店休日的提交,server 端必須擋下。任何設計都不能免除後端驗證。
- 框架元件是純 UI primitive:appfuse-web 元件不含業務邏輯、不打 API(不知道應用 endpoint、認證、context-path);資料一律由應用層取得後以 props 注入(對齊既有慣例:
Select接 options、不自抓 reference-data;亦是 mockup MSW 接縫的前提)。 - appfuse-web 不得依賴 appfuse-server:跨端契約以文檔對齊(TS 型別鏡射 Java record),不是程式碼依賴。
- appfuse-server 設計原則:提供工具集而非預設實作——框架不出 controller,endpoint 由應用層曝露。
- 破壞性變更成本此刻最低:
CalendarService/CalendarDay皆@since 4.0、仍在[Unreleased](4.0 未正式發版),重塑屬 pre-release 期間調整;下游僅一個專案使用。不必遷就 almanac 的 wire format,可轉換成最適合框架的形狀。
假設前提
- 行事曆解算的資料量小(一次一兩個月、幾十筆
DayStatus),HTTP 傳輸與快取皆無壓力。 - 政府行事曆資料源穩定走 almanac(既有檔案快取),組合層是純運算、無新外部依賴。
- 「哪份行事曆適用於哪個輸入欄位」是應用層的業務決策(配送行事曆 ≠ 預約行事曆),框架只提供組合與契約。
考量的方案 (Options Considered)
方案 A: 各應用自行手刻(現況延伸)
說明: 框架不動。前端在元件裡寫死星期規則,後端在 service 層 ad-hoc 驗證。
優點:
- ✅ 零框架改動
缺點:
- ❌ 每個下游 app 重寫一遍「行事曆 → 禁用日期」的推導,且前後端各寫一份
- ❌ 前端寫死的規則(如「週一店休」)與後端驗證必然漂移
- ❌ 政府行事曆(almanac)與業務規則的組合無單一事實來源
評分: 1/5
方案 B: 前後端各自提供組合工具
說明: appfuse-web 提供 TS 端行事曆組合工具(base 行事曆 + overlay 規則)與 DatePicker;appfuse-server 提供對應的驗證工具。前端拿政府行事曆原始資料自行組合出可選性。
優點:
- ✅ 前端離線可算(少一次解算請求)
- ✅ 兩端都有框架支援
缺點:
- ❌ 組合邏輯存在兩份(TS 一份、Java 一份):規則語意、時區、邊界條件(跨年、補班日)要靠紀律保持一致——前端算出可選、後端打回(或反過來誤鎖合法日期)只是時間問題
- ❌ 前端需要拿到「原始行事曆 + 規則定義」才能組合,wire format 反而更複雜
- ❌ 業務規則(店休日、前置天數)洩漏到前端碼,改規則要動兩端
評分: 2/5
方案 C: 組合只在後端,前端吃已解算結果(選定)
說明:
appfuse-server 提供 BusinessCalendar 抽象與組合子;應用層宣告具名行事曆(如「配送行事曆」= 政府行事曆 + 週一店休 + 非過去)。同一份具名行事曆餵兩個消費點:resolve(from, to) 解算為 DayStatus[] 經應用 endpoint 下行給前端渲染;validate(date) 在提交時驗證。appfuse-web 的 DatePicker 只吃 DayStatus[],零行事曆邏輯。
優點:
- ✅ 組合邏輯單一存在:前端渲染與後端驗證消費同一個物件,漂移在結構上不可能發生
- ✅ 前端契約極薄(
{date, selectable, reasonCode, description?}清單),mockup 階段 MSW handler 直接 seed 寫死的DayStatus[]——純值、零邏輯,完全符合 mock 邊界紀律 - ✅ 業務規則(店休、前置)留在後端,改規則不動前端
- ✅ 政府行事曆(almanac)降級為資料來源之一,框架模型不受外部 wire format 綁架
缺點:
- ❌ 前端月份導航跨出已載入範圍時需補載(
onRangeChange回呼 + 應用層再查一次) - ❌ 「今天」的判定在 server(server 時鐘 + 時區)——跨時區使用者看到的「非過去」邊界以 server 為準(本框架場景固定 Asia/Taipei,可接受)
評分: 5/5
決策 (Decision)
選擇方案: C(組合在後端,前端吃已解算結果)
核心理由:
- 後端反正必須驗證(權威);讓渲染與驗證消費同一份具名行事曆,是「前後端語意一致」的機制保證,不是靠紀律。
- 前端契約退化成純資料,DatePicker 保持 UI primitive 純度(不打 API、零業務邏輯),且 mockup 的 MSW 接縫天然成立。
CalendarService尚未正式發版、下游僅一家——以「組合」為中心重塑的成本此刻最低,且框架模型從此不遷就 almanac 格式。
附帶決策
| # | 決策 | 理由 |
|---|---|---|
| D-1 | 命名用 BusinessCalendar,不佔 Calendar/EventCalendar | 「可選性行事曆」(日期 → 狀態的函數)與「事件行事曆」(會議室/個人排程:時段粒度、RRULE、衝突偵測、CalDAV)是不同 domain。名字空間現在讓開,未來排程 domain 無需改名遷就 |
| D-2 | 事件行事曆 out of scope,以 DayStatus 投影為橋接 | 排程 domain 複雜度不同量級,等真實 US 出現再立專屬 ADR(更可能長成 almanac 式平台服務)。橋接點:事件行事曆可投影成可選性(「當天已無空檔」→ {selectable: false, reasonCode: FULLY_BOOKED}),供「幫會議選日期」的 DatePicker 消費 |
| D-3 | DatePicker 遵循 data-input 表單欄位契約 | 與 Input/Select 同族:name/control(react-hook-form)、label/placeholder/aria-label 自動翻譯、required 標記、schema + validatorResolver 驗證、inline 錯誤——實作上以 Input 的欄位外殼(label/error/required 呈現)擴充 popover 月曆,不另創欄位形狀 |
| D-4 | 月曆網格 renderer 與 DatePicker 分離,day-cell 留 slot | 網格渲染器(月檢視、day-cell 內容以 render prop 擴充)是 DatePicker 與未來事件行事曆檢視的共用底層;現在只渲染日期數字+不可選樣式+原因,未來 cell 內放事件 chips。純渲染層擴充點,不承諾事件 domain 語意 |
權衡分析 (Trade-offs)
我們獲得什麼 (Gains)
- ✅ 前後端日期限制語意單一來源(具名行事曆),結構性消除漂移
- ✅ 下游 app 宣告式組合行事曆(builder 幾行),不再手刻推導與驗證
- ✅ 不可選「原因」進入契約(
reasonCode),UX 可解釋(圖例、tooltip)且可 i18n - ✅ 框架行事曆模型自主(almanac 只是 adapter),未來可加其他來源(自建假日表、人事系統)
我們放棄什麼 (Losses)
- ❌ 前端離線自算可選性的能力(一律仰賴後端解算;mockup 階段以 seed 資料替代)
- ❌
CalendarService直接呼叫面的簡單性(消費端從「呼叫服務方法」變「組合行事曆物件」)
風險與緩解措施 (Risks & Mitigations)
| 風險 | 嚴重性 | 機率 | 緩解措施 |
|---|---|---|---|
| 前端月份導航頻繁補載造成請求噪音 | 低 | 中 | 應用層一次解算較寬範圍(如前後各 3 個月)+ HTTP 快取;資料量小(每月 ~31 筆) |
reasonCode 各 app 自創、i18n 對不上 | 低 | 中 | 框架定義常用代碼常數(PAST/HOLIDAY/CLOSED_WEEKLY/LEAD_TIME…),app 可擴充字串;guide 明訂前端 nls key 慣例 |
| 「今天」邊界因 server/client 時鐘不一致造成困惑 | 低 | 低 | 固定 zone(預設 Asia/Taipei)、Clock 可注入(測試);guide 說明邊界語意 |
未來事件行事曆需求被硬塞進 BusinessCalendar | 中 | 低 | D-1/D-2 已預先劃界:日粒度可選性 vs 時段粒度排程,越界時立新 ADR |
影響 (Consequences)
正面影響
- ➕ US-201 型的日期限制(過去日期、店休日、含原因圖例)從「原型手刻」變「框架一等能力」
- ➕ 提交驗證標準化:不可選日期 → 框架標準例外 → 400(對齊既有錯誤格式)
- ➕
plusWorkingDays型工作日運算泛化到任何組合後行事曆(「加 N 個可選日」)
負面影響
- ➖ appfuse-server 公開面新增一個套件(抽象 + 組合子 + 契約),文檔與維護面變大
- ➖ 既有
CalendarService消費端(一個下游)需隨 4.0 調整呼叫面
中性影響
- 🔸 almanac 客戶端(
AlmanacClient、快取、認證)不動,只是消費位置下移為 source adapter - 🔸 應用層 endpoint 形狀(
GET /api/v1/calendars/{name}?from=&to=)屬參考慣例,框架不出 controller
實作指南 (Implementation Guidelines)
appfuse-server:io.leandev.appfuse.calendar 套件(新)
// 核心抽象:日期 → 狀態
public interface BusinessCalendar {
DayStatus statusOf(LocalDate date);
List<DayStatus> resolve(LocalDate from, LocalDate to); // 解算範圍(前端渲染用)
void validate(LocalDate date); // 不可選 → 框架標準例外 → 400(提交驗證用)
LocalDate plusSelectableDays(LocalDate from, long days); // 泛化的工作日運算
}
// 契約 record(wire format 即其序列化)
public record DayStatus(LocalDate date, boolean selectable, String reasonCode, String description) {}
// 組合子(builder)——應用層宣告具名行事曆
BusinessCalendar delivery = Calendars.government(almanac.calendar()) // 政府工作日/假日(almanac source)
.closedOn(DayOfWeek.MONDAY) // 每週店休 → CLOSED_WEEKLY
.noPast() // 非過去(Clock + zone)→ PAST
.leadDays(2) // 前置天數(選用)→ LEAD_TIME
.closed(LocalDate.of(2026, 8, 10), "STORE_EVENT") // 特例休
.open(LocalDate.of(2026, 7, 20)) // 特例補開(覆寫上游規則)
.build();
reasonCode結構化:框架常數PAST/HOLIDAY/CLOSED_WEEKLY/LEAD_TIME/OUT_OF_RANGE,app 可擴充自訂字串;description透傳來源說明(如節日名稱)。前端以 nls 翻譯 reasonCode,不傳本地化文字。- almanac 降級為 source:
CalendarService重塑為政府行事曆 source(保留isWorkingDay/plusWorkingDays等便利方法或以government(...)行事曆等價提供);CalendarDay退居 almanac adapter 的內部形狀。時區固定 Asia/Taipei、Clock可注入。 - 驗證:
validate()丟框架標準例外(掛進既有 ProblemDetail 錯誤處理 → 400,帶reasonCode);應用 service 層在提交路徑呼叫。 - endpoint 由應用層曝露(框架不出 controller):guide 示範
GET /api/v1/calendars/{name}?from=&to=回DayStatus[]。
appfuse-web:DatePicker + 月曆網格
- 落點:
lib/components/data-input/date-picker/;月曆網格lib/components/data-display/calendar-grid/(或 date-picker 內部先行、成熟後抽出)。 - 表單欄位契約(D-3):與
Input/Select同族——name/control(react-hook-form)、label/placeholder/aria-label自動翻譯、required必填標記、schema + validatorResolver、inline 錯誤、<form noValidate>紀律適用。以 Input 的欄位外殼擴充 popover 月曆。 - 資料 props:
days: DayStatus[](TS 型別鏡射後端 record,文檔對齊、非程式碼依賴)+onRangeChange?(from, to)(月份導航跨出已載入範圍時補載)。date的 wire format 為YYYY-MM-DD;前端以字串保存LocalDate,HTTP client 不轉成Date,應用層零轉換。base 與 form 版的onChange/表單值皆輸出 ISOYYYY-MM-DD字串或null。 - 逃生口:
disabledDate?: (date) => booleanpredicate 僅供純前端表單內約束(如迄日不可早於起日);業務行事曆一律走資料路徑。 - 呈現:不可選日期灰字(PAST 類)/灰字+刪除線(店休類)、圖例區顯示 reasonCode 的 nls 翻譯、選定值帶星期標註(如
2026/07/10(五))。 - day-cell slot(D-4):網格 cell 內容以 render prop 擴充,預設渲染日期數字+狀態樣式。
Mockup(MSW)階段
handler 直接回傳 seed 寫死的 DayStatus[](純值、零行事曆運算),符合 mock 邊界的「用 seed 寫死預期值、不在 handler 重算」紀律;E2E 斷言錨定 seed。
框架發版義務
appfuse-server/CHANGELOG.md:CalendarService仍在[Unreleased](4.0 未發版)→ 就地改寫 almanac calendar 條目為新形狀(BusinessCalendar/DayStatus/組合子),不需 Breaking Changes 段;下游以/upgrade-appfuse-server對齊。appfuse-web/CHANGELOG.md:### AddedDatePicker/calendar-grid。appfuse-server/.claude/rules/12-modules.md:模組索引補calendar/列、almanac 列註記 source 角色。- 新增設計指南
docs-server/guides/design/business-calendar.md(宣告具名行事曆、endpoint 慣例、驗證、前端接線)。 m-server-common.md「按需求查找」表補列(關鍵字:行事曆/工作日/假日/日期限制/店休),觸發關鍵字表補/epic、/us、/domain-model對應列。static/llms.txt補新頁面(並補漏列的 ADR-011)。
檢查清單
- Phase 0:本 ADR 簽核
- Phase 1:appfuse-server
calendar套件(BusinessCalendar/DayStatus/組合子/almanac source 重塑/validate例外映射)+ 單元測試 + CHANGELOG +12-modules - Phase 2:設計指南
business-calendar.md+m-server-common.md檢索列 +llms.txt - Phase 3:appfuse-web DatePicker + 月曆網格(day-cell slot)+ nls + CHANGELOG + docs-web
- Phase 4:參考實作接線——app-server 配送行事曆(具名宣告 + endpoint + 提交驗證);花店前端(app-office-mockup)配送日期欄位改用 DatePicker + calendar service/queries + MSW handler(US-201 原型行為落地並經瀏覽器驗證)。後續:app-office 經
/promote-webre-sync 帶入;正規 US 軌道文檔另補
相關文檔 (References)
內部文檔
- almanac 服務指南:
../guides/services/almanac.md - (待建)營業行事曆設計指南:
../guides/design/business-calendar.md - 錯誤處理指南:
../guides/services/error.md(validate()例外映射) - NLS / i18n 指南:
../guides/services/nls.md(reasonCode 翻譯)
外部資源
- almanac 平台服務:
https://almanac.leandev.io(GET /api/v1/calendar/{year}) - iCalendar / CalDAV(事件行事曆 domain 的生態,本 ADR out of scope 的參考邊界)
相關 ADR
- ADR-010: 事件驅動通知子系統:
./010-event-driven-notification.md(「框架中性機制 + 業務語意留應用層」同源紀律)
變更歷史 (Change Log)
| 日期 | 變更內容 | 變更者 |
|---|---|---|
| 2026-07-04 | 初版(BusinessCalendar 組合模型 + DayStatus 契約 + 薄 DatePicker;事件行事曆 out of scope) | Framework Maintainer + AI Assistant |
| 2026-07-05 | 前端值形狀修正:DatePicker 的 date/value 容忍 string | Date(尊重 http client 對回應日期的自動轉換,app 層零轉換);form 版表單值定為 Date | null(對齊 Input type=date 慣例),base 版輸出 ISO 字串 | Framework Maintainer + AI Assistant |
| 2026-08-11 | 對齊跨端日期時間契約:LocalDate wire 與前端表單值固定為 YYYY-MM-DD 字串;HTTP client 不再把無時區日期轉成 Date | Framework Maintainer + AI Assistant |
| 2026-08-11 | Picker 契約收斂:DayStatus.date 固定為字串;DatePicker 以必填 type 區分 LocalDate 字串與 Instant Date,日期型 Instant 必須明確指定補時規則 | Framework Maintainer + AI Assistant |
| 2026-08-11 | 日期型 Instant 補時預設調整:DatePicker type="instant" 未指定 instantTime 時採 start-of-day;非預設補時必須由人類指定,或由 AI 建議並經人類確認 | Framework Maintainer + AI Assistant |
文檔維護者: Framework Maintainer + AI Assistant 最後審閱: 2026-07-04