# 全天事件的「有空」陷阱：預設值會說謊

- URL: https://justfly.idv.tw/%e5%85%a8%e5%a4%a9%e4%ba%8b%e4%bb%b6%e7%9a%84%e3%80%8c%e6%9c%89%e7%a9%ba%e3%80%8d%e9%99%b7%e9%98%b1%ef%bc%9a%e9%a0%90%e8%a8%ad%e5%80%bc%e6%9c%83%e8%aa%aa%e8%ac%8a/
- 日期: 2026-08-24
- 分類: 我知故我在
- 標籤: claude

![全天事件的「有空」陷阱：預設值會說謊]

就像租車公司把所有車預設成「可借出」，但其中一半停在保養廠——系統顯示有車，到現場才發現空手而回。跨平台行事曆的忙碌判斷，出了同樣性質的錯。

##### 技術環境

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](https://zh.wikipedia.org/wiki/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 格式](https://zh.wikipedia.org/wiki/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](https://en.wikipedia.org/wiki/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](https://en.wikipedia.org/wiki/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.

— 邱柏宇
