
就像租車公司把所有車預設成「可借出」,但其中一半停在保養廠——系統顯示有車,到現場才發現空手而回。跨平台行事曆的忙碌判斷,出了同樣性質的錯。
技術環境
CalDAV 同步引擎,跑在 Node.js + TypeScript 後端。資料源來自三個平台客戶端:iOS Calendar、macOS Calendar、Google Calendar。同步流程為輪詢拉取 → 解析 iCalendar(RFC 5545)→ 正規化 → 寫入自家資料庫。VEVENT 區分全天事件(DTSTART/DTEND 為 DATE 型別)與有時段事件(DATETIME 型別),兩者走不同的程式碼分支。問題集中在全天事件的 TRANSP 屬性解析層。
現象:整天出差,系統說有空
同步測試跑下來,真實帳號匯入 30 筆事件,三筆全天事件全數顯示「有空」。事件本身語意清晰——出差日、研討會、家庭日——卻被行事曆系統判讀成空白時段,可以自由安排會議。
有時段的事件(如 09:00–10:00 的會議)反而正確,一律標為忙碌。問題集中在全天事件。
錯誤傳染鏈(時序)
Platform (iOS) CalDAV Server Sync Engine Local DB
| | | |
|── 建立全天事件 ──────>| | |
| DTSTART;VALUE=DATE | | |
| TRANSP:TRANSPARENT | | |
| | | |
| (UI 無切換選項) | | |
| | | |
| |── 同步觸發 ────────>| |
| | |── 解析 VEVENT ────>|
| | | DTSTART 為 DATE |
| | | TRANSP=TRANSPARENT|
| | | |
| | |── 判斷忙碌 ────────>|
| | | 嚴格讀 TRANSP |
| | | → 標記為有空 ✓BUG |
| | | |
| | |── 寫入 DB ────────>|
| | | busy=false |
| | | |
最終 DB 狀態:全天事件 busy=false(與使用者意圖相反)
下游接收端再次同步 → 同樣的錯誤結論
關鍵節點在 sync engine 解析層:把 TRANSP 當唯一判斷依據,沒區分全天 vs 有時段。平台預設值從建立那一刻就進入傳染鏈,一路寫進 DB 與下游接收端。
分界點:TRANSP 屬性與平台預設
CalDAV 所依循的標準 RFC 5545 定義了 TRANSP 屬性:OPAQUE 表示忙碌,TRANSPARENT 表示有空。理論上,這個值應該由使用者或建立事件的系統依語意決定。
實際情況是,某行動平台把所有全天事件(VEVENT 的 DTSTART/DTEND 為 DATE 型別)一律預設帶 TRANSP:TRANSPARENT,且平台 UI 不提供任何切換選項。使用者不知道這個屬性存在,更無從修改。
結果是:「整天開會」跟「國定假日」在這個平台上拿到了完全相同的標記,同步出去之後,接收端嚴格按標準解讀,全部判成「有空」。
Code 對照:修法前後
修法前:直接讀 TRANSP,不管事件類型。
function classifyEvent(event) {
// ← 問題在這裡:把 TRANSP 當唯一判斷依據
if (event.transp === 'TRANSPARENT') {
return { busy: false }; // 全天事件被誤判為有空
}
return { busy: true };
}
修法後:全天事件忽略 TRANSP,一律視為忙碌。
function classifyEvent(event) {
// 全天事件:忽略 TRANSP,一律忙碌
if (event.isAllDay) {
return { busy: true };
}
// 有時段事件:尊重 TRANSP
if (event.transp === 'TRANSPARENT') {
return { busy: false };
}
return { busy: true };
}
容易誤判的地方
第一直覺是「尊重標準屬性」——RFC 明確定義了 TRANSP,按值判斷才叫規範相容。這個邏輯本身沒錯,錯的是前提:假設平台預設值能反映使用者意圖。
當某一平台的全天事件有壓倒性比例預設透明,這個假設就垮了。嚴格遵守標準,反而把忙碌計算搞得完全失準。
台北時區的全天事件還有額外的時間邊界問題:午夜切換點的處理因裝置時區設定而異,加上 DTEND 的 exclusive 語意,以及重複事件走 RRULE 展開的細節,幾條問題同時疊在一起,初期排查時很容易只看到時區那條線而漏掉 TRANSP 這條。
該被隔離的側效應類型
- TRANSP 預設值污染:平台預設帶 TRANSP:TRANSPARENT 寫進 VEVENT,未經標準解析層就污染下游
- 時區邊界運算:台北午夜切換點 + DTEND exclusive 語意,與 TRANSP 問題疊在一起時容易掩蓋主因
- RRULE 展開後的 TRANSP 繼承:重複事件的 TRANSP 是否從母事件繼承,各家實作不一致
- 跨平台接收端的二次判讀:下游行事曆再次同步時,會把污染過的 TRANSP 當標準值解讀
- UI 不可修改:使用者看不到 TRANSP 欄位,也無從手動修正,等於平台單方面寫死
- 同步測試 harness 的盲點:harness 若只用有時段事件驗證,會誤判同步邏輯正確
- 文件與實作落差:RFC 5545 定義清楚,但各家對預設值的詮釋從未公開
判斷標準:如果一個屬性無法被使用者修改、平台預設值又跟使用者意圖相反,sync engine 就不能把它當真理讀。
確認方式
對著真實帳號的原始 iCalendar 資料逐筆比對:全天事件帶 TRANSP:TRANSPARENT,有時段事件帶 TRANSP:OPAQUE。兩類事件的語意差異顯而易見,但 TRANSP 值完全反過來——不是使用者設錯,是平台從建立那一刻就寫死了。
30 筆資料的 harness 跑完,三筆台北午夜邊界的全天事件在修正邏輯後全數正確入庫。確認方式很直白:看原始欄位值,不要信任平台的 UI 呈現。
留給未來的一件事
修法直接:全天事件忽略 TRANSP,一律當忙碌;有明確時段的事件仍尊重該屬性。改動不大,但需要先確認這個分界點才能下手——在知道「平台預設值 ≠ 使用者意圖」之前,不會想到要繞過標準。
跨平台行事曆整合的地雷幾乎都藏在這類「各家都說自己相容標準」的細節裡。iCalendar 格式本身定義清楚,但各家對預設值的詮釋從來不一致。下次接類似整合,第一步是跑真實資料 harness 驗各家實作差異,再決定要遵守標準或繞過地雷——這個順序不能反。
— 邱柏宇
All-Day Events That Lie About Being Free
Like a car rental company that marks every vehicle as “available” while half of them sit in the maintenance yard — the system shows green, arriving at the lot reveals nothing to book. Cross-platform calendar sync ran into exactly this kind of discrepancy.
Technical Environment
CalDAV sync engine running on a Node.js + TypeScript backend. Three platform clients feed data: iOS Calendar, macOS Calendar, Google Calendar. Pipeline: poll → parse iCalendar (RFC 5545) → normalize → write to local DB. VEVENT splits into all-day events (DTSTART/DTEND as DATE) and timed events (DATETIME), each going through separate code branches. The bug lives in the TRANSP parsing layer for all-day events.
What Showed Up
Importing 30 real events into a test harness, three all-day events came back as “free.” The events were semantically unambiguous — business trips, conferences, family days — but the calendar system read them as open slots. Time-bounded events like a 09:00–10:00 meeting showed “busy” correctly. The problem was exclusive to all-day events.
Error Propagation Sequence
Platform (iOS) CalDAV Server Sync Engine Local DB
| | | |
|── create all-day ───>| | |
| DTSTART;VALUE=DATE | | |
| TRANSP:TRANSPARENT | | |
| | | |
| (no UI toggle) | | |
| | | |
| |── sync trigger ───>| |
| | |── parse VEVENT ───>|
| | | DTSTART is DATE |
| | | TRANSP=TRANSPARENT|
| | | |
| | |── classify ────────>|
| | | reads TRANSP |
| | | → marks free ✓BUG|
| | | |
| | |── write to DB ─────>|
| | | busy=false |
| | | |
Final DB: all-day busy=false (opposite of user intent)
Downstream sync → same wrong conclusion
Critical point: the sync engine treats TRANSP as the sole truth without distinguishing all-day from timed events. The platform default enters the propagation chain at creation time and reaches the DB plus downstream consumers intact.
The Dividing Line
The CalDAV standard RFC 5545 defines a TRANSP property: OPAQUE means busy, TRANSPARENT means free. In theory, the value should reflect the event’s actual nature — set by the user or the creating system. In practice, one major mobile platform silently defaults every all-day event (VEVENT with DATE-type DTSTART/DTEND) to TRANSP:TRANSPARENT, and provides no UI toggle to change it.
The field remains invisible. No modification path exists. A full-day conference and a public holiday leave the platform carrying identical metadata. Downstream systems that strictly parse TRANSP by the book declare both “free.”
Code Diff: Before and After
Before: reads TRANSP directly regardless of event type.
function classifyEvent(event) {
// ← bug: treating TRANSP as the sole truth
if (event.transp === 'TRANSPARENT') {
return { busy: false }; // all-day events misclassified
}
return { busy: true };
}
After: all-day events ignore TRANSP, always busy.
function classifyEvent(event) {
// all-day events: ignore TRANSP, always busy
if (event.isAllDay) {
return { busy: true };
}
// timed events: respect TRANSP
if (event.transp === 'TRANSPARENT') {
return { busy: false };
}
return { busy: true };
}
Why It’s Easy to Misread
The first instinct is to trust the standard. RFC 5545 is explicit; parsing TRANSP faithfully looks like correct behavior. The assumption that breaks it: platform defaults accurately represent user intent.
When the overwhelming majority of all-day events from a given platform carry TRANSPARENT by default, that assumption collapses. Strict standards compliance becomes a mechanism for systematic misclassification.
There’s also a compounding factor: all-day events in the Taipei timezone carry midnight-boundary edge cases — DTEND’s exclusive semantics, RRULE expansion for recurring events, device timezone variations. Multiple issues stack. Early investigation tends to chase the timezone thread and miss the TRANSP one entirely.
Side Effects That Should Be Isolated
- TRANSP default pollution: platform writes TRANSP:TRANSPARENT into VEVENT at creation, contaminates downstream before validation
- Timezone boundary math: Taipei midnight crossover + DTEND exclusive semantics stack with TRANSP and obscure the root cause
- TRANSP inheritance after RRULE expansion: recurring events inherit TRANSP inconsistently across implementations
- Downstream re-interpretation: receiving calendars re-read polluted TRANSP as authoritative
- Invisible to users: no UI to view or modify TRANSP, platform writes it unilaterally
- Test harness blind spot: harnesses with only timed events miss the all-day issue entirely
- Spec vs implementation gap: RFC 5545 is clear, but platform default interpretations are undocumented
Rule of thumb: if a property can’t be modified by users and the platform default contradicts user intent, the sync engine shouldn’t treat it as ground truth.
How to Confirm
Pull the raw iCalendar data and inspect field by field. All-day events carry TRANSP:TRANSPARENT; time-bounded events carry TRANSP:OPAQUE. The semantic inversion is obvious once the raw values are visible. This isn’t a user misconfiguration — the platform wrote it in at creation time. After the fix, all three Taipei midnight-boundary all-day events in the 30-item harness imported correctly.
The One Thing Worth Keeping
The fix is narrow: ignore TRANSP for all-day events entirely, treat them as busy; time-bounded events still respect the property. The code change is small. Getting there required knowing that the dividing line existed — that “standards-compliant” and “user-intent-faithful” are two different things when a platform’s defaults don’t reflect how the feature actually gets used.
Cross-platform calendar integration almost always hides problems in exactly this category: everyone claims standards compatibility, but no one documents their defaults. Run a real-data harness against each platform before deciding whether to follow the spec or route around it. Not after.
— 邱柏宇
延伸閱讀
https://justfly.idv.tw/s/9YrNkSe