跳轉到

Planner Studio 前端完整實作指南

簡介

Planner Studio 是一款零框架微前端應用,採用 Vanilla JavaScript + Semantic HTML + Modular CSS 設計模式。該應用用於視覺化時間規劃建議、管理日曆事件衝突,並支援多方案互動與即時套用。

零框架設計的價值主張

優勢: - 極速啟動:無編譯步驟,直接解析 ES Modules,首屏時間 < 500ms - 依賴最小:僅依賴 FullCalendar 6.1(CDN 引入)與 RRule 2.8(日期遞迴) - 實時性強:無虛擬 DOM 層,DOM 操作直接,支援 60fps 縮放動畫 - 風格靈活:主題切換零開銷,CSS 變數驅動,支援亮色/深色/綠色三主題

局限性: - 狀態管理依賴手動同步(全域 state 物件) - 大規模表單互動需要手寫事件委派邏輯 - 無內建路由(當前應用單頁,URL 中含 Session ID) - 無自動水合 (Hydration),SSR 不適用


架構設計

Planner Studio
├── 入口層 (Bootstrap)
│   └── main.js: 解析 URL、驗證配置、啟動 ui.init()
│
├── 核心層 (Core)
│   ├── state.js: 全域狀態 + DOM 元素緩存
│   ├── utils.js: 無狀態工具函式(9 個純函式)
│   └── contracts/planner_payload.schema.js: Zod 驗證契約
│
├── 特性層 (Features)
│   ├── view.js: UI 渲染、互動邏輯(991 行)
│   │   ├── 日曆渲染 (FullCalendar 初始化)
│   │   ├── 方案面板更新
│   │   ├── 主題切換
│   │   └── 事件監聽樞紐
│   └── zoom.js: 物理縮放引擎(Shift+Wheel 交互)
│
├── 服務層 (Services)
│   └── api.js: 後端通訊、Payload 規範化、快取管理
│
└── 樣式層 (Styles)
    ├── main.css: 主入口(@import 聚合)
    ├── main-light/dark/green.css: 主題選擇器
    ├── themes/theme.*.css: CSS 變數定義
    ├── base/
    │   ├── layout.css: 主容器、Toolbar、Sidebar
    │   ├── calendar.css: FullCalendar 調整 (280 行)
    │   └── components.css: 按鈕、卡片、標籤
    └── planner_studio.html: 語意化標記 + 內聯樣式

資料流圖

User Action
    ↓
ui.initListeners() → event handler
    ↓
api.shiftVariant() / api.fetchContext() / api.applyCurrentVariant()
    ↓
state.payload 更新
    ↓
ui.renderState() → ui.renderCalendar() →
    ├─ FullCalendar.setOption()
    ├─ ui.renderCalendarsSelect()
    └─ ui.renderCalendarControls()
    ↓
DOM 反映最新狀態

模組分解

1. state.js — 全域狀態與 DOM 參考

職責:集中管理應用狀態與 DOM 元素快取,避免重複查詢 .getElementById()

export const state = {
  payload: null,              // PlannerPayload (完整規劃數據)
  variantIndex: 0,            // 當前方案索引
  calendar: null,             // FullCalendar 實例
  sessionId: null,            // URL 提取的 Session ID
  applying: false,            // 套用中標誌
  hiddenCalendars: new Set(), // 隱藏的日曆 ID
  currentView: "timeGridDay", // 日/週視圖
  currentDate: null,          // YYYY-MM-DD
};

export const elements = {
  prev, next, pill, conflict, applyBtn,
  calendarRoot, side, variantPanel, // 主要容器
  draftText, variantTitle, variantDesc, // 文本區域
  modalBackdrop, // 事件詳情彈窗
  // ... 共 30+ 個元素引用
};

最佳實踐: - 查詢一次後快取,避免 DOM 樹遍歷重複 - 使用 JSDoc Types 標註複雜物件結構 - 所有客戶端狀態集中於 state.payload


2. utils.js — 無狀態工具函式庫

職責:提供跨模組的純函式,無副作用,易於測試。

函式 用途
formatHoursMinutes(date) "14:30" 格式
durationMinutes(start, end) 計算時間差(分鐘)
formatDurationZh(start, end) "1小時30分鐘" 中文語義
checkTimeOverlap(aS, aE, bS, bE) 時間段衝突檢測
parseDate(v) 日期字串 → 毫秒
easeOutCubic(t) 三次緩動函式 (0 to 1)
getCumulativeScaleY(element) 遞迴計算 CSS Transform Y 縮放
normalizeWheelDelta(event) 滑鼠滾輪標準化

設計原則: - 不依賴 DOM 或全域狀態 - 參數型別使用數字或基本型別 - 返回值明確,無 undefined 歧義


3. contracts/planner_payload.schema.js — 前後端契約

職責:使用 Zod 驗證 API 回應,防止 Runtime TypeError。

export const PlannerPayloadSchema = z.object({
  session_id: z.string(),
  created_at: z.string(),
  start_time: z.string(),    // ISO 8601
  end_time: z.string(),
  view_date: z.string(),     // YYYY-MM-DD
  ai_draft: z.string(),      // Markdown 文本
  calendar_events: z.array(PlannerCalendarEventSchema),
  draft_variants: z.array(DraftVariantSchema),
  writable_calendars: z.array(WritableCalendarSchema),
  apply_state: ApplyStateSchema,
  // ... 包含日曆、任務、Moodle 上下文
});

契約詳解

PlannerCalendarEvent — 已存在的日曆事項

{
  id: string;                      // 全域唯一 ID
  calendar_id: string | null;      // Google Calendar ID
  title: string;
  start: string;                   // ISO 8601
  end: string;
  all_day: boolean;
  location: string;
  description: string;
  color_class: string;             // "cal-color-1" ~ "cal-color-8"
  rrule: string | null;            // "FREQ=WEEKLY;BYDAY=MO,WE,FR"
  recurring_event_id: string | null; // 主雜迴事件 ID
  original_start: string | null;   // 異常實例原定時間
  status: string | null;           // "cancelled" 表示被刪除
}

DraftVariant — AI 產生的方案

{
  variant_id: string;
  title: string;                   // "方案 A: 深度工作集中"
  description: string;             // Markdown 敍述+推理
  events: Array<{
    summary: string;
    start: string;                 // ISO 8601
    end: string;
    all_day: boolean;
    location: string | null;
    description: string | null;
    transparency: string;          // "opaque" (占用) | "transparent"
    recurrence: string[] | null;   // ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"]
  }>;
}

ApplyState — 套用履歷

{
  applied: boolean;
  applying: boolean;
  applied_variant_index: number | null;
  applied_calendar_id: string | null;
  applied_at: string | null;       // ISO 8601
}

前後端契約詳解

請求 Schema

1. 初始載入

GET /api/planner/{sessionId}
回應: PlannerPayload

2. 日期導航

POST /api/context/fetch
{
  "start_time": "2026-01-15T00:00:00Z",
  "end_time": "2026-01-22T00:00:00Z",
  "view_date": "2026-01-15",
  "session_id": "sess_abc123"
}

視窗規則: - 日視圖: 前後各 7 天 - 週視圖: 前後各 3 週

3. 套用方案

POST /api/planner/{sessionId}/apply
{
  "variant_index": 0,
  "calendar_id": "user@gmail.com",
  "allow_conflicts": true
}

回應:
{
  "applied_variant_index": 0,
  "calendar_id": "user@gmail.com",
  "created_count": 5
}

互動流程與狀態轉移

使用者操作流圖

┌─ 應用啟動 ──→ loadPayload() ──→ hydrate(payload)
│                                    ↓
│                            renderCalendar()
│                            renderState()
│
├─ 用戶操作
│  ├─ 點擊「下一方案」
│  │  └─ ui.next ──→ api.shiftVariant(+1) ──→  state.variantIndex ++ ──→ ui.renderState()
│  │
│  ├─ Shift + 滾輪
│  │  └─ wheel 事件 ──→ zoom 動畫 ──→ zoomEngine.stepZoomFrame() ──→ calendar.updateSize()
│  │
│  ├─ 點擊日期導航
│  │  └─ btnNextDate ──→ api.fetchContext(newDate) 
│  │     ──→ HTTP POST /api/context/fetch
│  │     ──→ _findContextCache() → _hydratePayload()
│  │
│  ├─ 點擊「確定採用此規劃」
│  │  └─ applyBtn ──→ api.applyCurrentVariant()
│  │     ──→ HTTP POST /api/planner/{id}/apply
│  │     ──→ state.payload.applyState.applied = true
│  │     ──→ ui.renderState() (按鈕變灰、顯示「已套用」)
│  │
│  └─ 主題切換
│     └─ themeToggleBtn ──→ ui.applyTheme(nextMode)
│        ──→ localStorage.setItem()
│        ──→ calendar.render()
│
└─ 清理 (卸載)
   └─ api 停止輪詢、事件監聽移除

效能考量與優化

1. DOM 操作批次化

反面例子

// ❌ 低效:每次迭代新增一個元素
for (const cal of calendars) {
  container.appendChild(createCalendarToggle(cal));  // 觸發 reflow
}

改善

// ✅ 高效:先構建 DOM,集中插入
const fragment = document.createDocumentFragment();
for (const cal of calendars) {
  fragment.appendChild(createCalendarToggle(cal));
}
container.appendChild(fragment);  // 單次 reflow

2. 縮放動畫無卡頓策略

// Shift 鍵時,保持鼠標下的分鐘數恆定
zoomEngine.scrollMinuteToClientY(
  zoom.cursorMinute, 
  zoom.cursorClientY, 
  blend = 1,  // blend 為 1 表示完全追蹤
  currentPpm
);

// 使用 CSS 變數驅動尺寸,讓瀏覽器自動優化
fcRoot.style.setProperty("--slot-height", `${ppm * 15}px`);

3. 快取策略

  • Context 快取:5 分鐘 TTL,大弧度時間窗判定(避免微小日期變更重新請求)
  • SessionStorage 快取:儲存最後一次生成的方案,頁面重整後復原
  • 元素快取elements 物件預快取 30+ 元素引用

4. 常見陷阱

陷阱 症狀 解決方案
Midnight Sliver 文本重疊 跨日期事件文字糊在一起 使用 fc-event-boundary-sliver 類,調整 flex-direction
縮放後日曆不重繪 時槽尺寸變了但 FullCalendar 不覺得 calendar.updateSize() + calendar.render()
主題切換時事件樣式不變 深色模式仍是淺色文字 主題 CSS 使用 var() 變數,在 data-theme 變時更新
快取導致方案舊資料 更新方案後看到舊版本 SessionStorage 快取在 Session 變更時自動清除

開發指南:新增功能

案例 1:新增「導出到 ICS」功能

步驟 1:建立新 Utility

// js/utils.js 新增
export function buildIcsCalendar(events) {
  const ics = [
    'BEGIN:VCALENDAR',
    'VERSION:2.0',
    'PRODID:-//TimeCompass///',
  ];

  for (const e of events) {
    ics.push('BEGIN:VEVENT');
    ics.push(`DTSTART:${toIcsDate(e.start)}`);
    ics.push(`SUMMARY:${escapeIcs(e.summary)}`);
    ics.push('END:VEVENT');
  }

  ics.push('END:VCALENDAR');
  return ics.join('\r\n');
}

步驟 2:在 view.js 掛入事件

// view.js
const exportBtn = document.getElementById("exportBtn");
exportBtn.addEventListener("click", () => {
  const events = state.payload?.draftVariants?.[state.variantIndex]?.events || [];
  const ics = buildIcsCalendar(events);

  const blob = new Blob([ics], { type: 'text/calendar' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `planner_${state.currentDate}.ics`;
  a.click();
});

調試技巧

啟用追蹤日誌

在 URL 後加 ?trace_planner=1,如:

http://localhost:8766/planner/sess_abc123?trace_planner=1

輸出範例:

[planner-trace][view] renderState() variantIndex=0
[planner-trace][api] hydrate-payload sessionId=sess_abc123 draftVariants=3

常用 DevTools 命令

// 檢視目前狀態
console.log(state);

// 手動觸發方案切換
api.shiftVariant(1);

// 清除快取
state.contextCache = [];

// 強制重繪日曆
state.calendar?.render();

最佳實踐總結

通用原則

  1. 狀態單一源:所有應用狀態存放在 state.payload,不要分散
  2. 純函式優先:工具函式應無副作用,易於測試
  3. CSS 變數驅動:動態尺寸、顏色皆透過 --var-name 定義
  4. 事件委派:若有動態 DOM,使用事件委派而非直接綁定
  5. 快取結合 TTL:API 呼叫加快取,但需設定失效期限

命名規範

  • 全域狀態物件state.*
  • UI 渲染方法ui.render*()
  • API 服務方法api.[loadXxx|fetchXxx|applyXxx]()
  • 工具函式[verb][Noun](),如 formatHoursMinutescheckTimeOverlap

附錄:完整模組依賴圖

main.js (入口)
    ↓
state.js (狀態+快取) ←─────────────────────┐
    ↑                                      │
    │                                      │
view.js (UI 渲染)  ←───┐  api.js (服務層) ├─ zoom.js
    │                  │       ↓          │
    └──→ utils.js (工具) → contracts/     ├─ planner_payload.schema.js
         planner_payload.schema.js ←───────┘

更新日期:2026年3月2日 | Planner Studio Frontend Reference