SLOPSHOPPER

todo-calendar

TODO 週曆面板:在面板顯示一週任務,由 Claude 用工具新增與修改任務

newpaneguardcommandtooltimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · todo-calendar
│ ┃ TODO 週曆 ✕ › fix the failing auth test and add an audit log call │ ┃ 2025 年 10/6 至 10/12 共 0 項,完成 0 │ ┃ p: ◀ 上週 [ 本週 ] n: 下週 ▶ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ 一 10/6 ⏺ Update(src/auth.ts) │ ┃ — ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ 二 10/7 ⎿ 3 pass, 1 fail │ ┃ — │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 三 10/8 │ ┃ — ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ╭──────────────────────────────────────────╮ › /todo │ ┃ │ 四 10/9 ● 今天 │ ⎿ todo-calendar: 已開啟 TODO 週曆面板。 │ ┃ │ — │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ 五 10/10 │ ┃ — │ ┃ │ ┃ 六 10/11 │ ┃ — │ ┃ │ ┃ 日 10/12 │ ┃ — ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · TODO 週曆
2025 年 10/6 至 10/12 共 0 項,完成 0 p: ◀ 上週 [ 本週 ] n: 下週 ▶ 一 10/6 — 二 10/7 — 三 10/8 — ╭──────────────────────────────────────────────────────────╮ │ 四 10/9 ● 今天 │ │ — │ ╰──────────────────────────────────────────────────────────╯ 五 10/10 — 六 10/11 — 日 10/12 —
README

todo-calendar

在 Claude Code 直接寫代辦事項

一個 Claude Code Mod:在面板顯示一週七天的待辦,可以直接打勾、編輯、刪除,也可以用一句話請 Claude 幫你排進去。

Claude Code 2.1.286+ Terminal | Desktop


為什麼需要它

寫程式時想到「週五要交報告」,切去別的待辦 App 記下來,回來就忘了剛剛寫到哪。

todo-calendar 把週曆放在 Claude Code 裡。打一行 /todo-calendar:add-task 週五交報告 就記好了,日期由 Claude 換算。

三個重點

📅 一週一眼看完週一到週日並排,今天那欄有外框。過期的任務是紅色,完成的會劃掉
💬 用說的就好「把報告改到下週一」「看牙醫完成了」,Claude 會呼叫工具幫你改
🔄 每個 session 同一份任務存在一個固定檔案,同時開好幾個 session,30 秒內會互相同步

安裝

在 Claude Code 裡面(終端機和桌面 App 都可以):

/plugin marketplace add jaaaackieLai/claude-mods
/plugin install todo-calendar@claude-mods

或在終端機:

claude plugin marketplace add jaaaackieLai/claude-mods
claude plugin install todo-calendar@claude-mods

更新到最新版:在終端機執行 claude plugin update todo-calendar@claude-mods,然後開一個新 session。

用法

開啟面板

想做的事做法
開啟週曆面板/todo
新 session 不要自動開面板/todo auto off(也可以在 /config 找「新 session 自動開啟面板」)
恢復自動開啟/todo auto on
查看目前設定/todo auto

終端機寬度不到 144 欄時,系統不會自動開面板,打 /todo 就能打開。

在面板上操作

想做的事做法
切換週按「◀ 上週」「本週」「下週 ▶」,或按快速鍵 p t n
標成完成按任務前面的 ○,變成 ✓;再按一次取消
編輯名稱或日期按任務後面的 ⋯,再按「編輯」(e)。改完按 Enter 或「儲存」
刪除按 ⋯,再按「刪除」(d),最後按「確定刪除」(y)
取消選取按「取消」(x),或再按一次 ⋯

快速鍵只在面板有焦點時有效。在終端機按 ctrl+x tab 或點一下面板,就能把焦點移過去。

請 Claude 幫忙

想做的事做法
用一句話新增/todo-calendar:add-task 週五交報告,Claude 會把「週五」換算成日期
修改、完成、刪除、查詢直接說,例如「把報告改到下週一」「列出這週的任務」

「週五」指本週的週五。本週那天已經過了,就排到下週。沒寫日期就排今天。

位置:~/.claude/todo-calendar/tasks.json。有設定 CLAUDE_CONFIG_DIR 時,放在那個目錄底下。

{
  "version": 1,
  "tasks": [
    {
      "id": "dkl007n",
      "title": "執行 stage1-freq plan",
      "date": "2026-10-05",
      "done": false,
      "createdAt": "2026-10-05T13:02:11.000Z"
    }
  ]
}
  • 一個任務只有一個日期,格式是 YYYY-MM-DD。
  • 檔案內容損壞時,mod 不會覆寫它,工具會回報檔案路徑。修好或移走檔案就能繼續用。
  • 要備份或搬到別台電腦,複製這個檔案就好。
  • Claude 只在呼叫工具時讀到任務,不會在對話中自動看到。
Source 3 files
hooks/register.tsx 574 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type { Selection, Task } from '../types'
5import {
6  WEEKDAY_NAMES,
7  isOverdue,
8  isValidDate,
9  localDate,
10  shortDate,
11  tasksBetween,
12  tasksOn,
13  weekDays,
14  weekLabel,
15  weekdayIndex,
16} from './calendar'
17
18const PANE = 'todo-calendar'
19const PANE_TITLE = 'TODO 週曆'
20/** 任務檔相對於 Claude Code 設定目錄的位置 */
21const TASKS_FILE = 'todo-calendar/tasks.json'
22const SYNC_MS = 30_000
23/** 面板內容寬度達到這個值,七天才橫向排列,否則直向排列 */
24const WIDE_COLUMNS = 105
25/** 今天的顏色:深金黃。主題色的 suggestion 給週末、error 給過期,所以用固定色碼 */
26const TODAY_COLOR = '#D4A017'
27
28const tasksAtom = atom({ plugin: 'todo-calendar', key: 'tasks' } as const, [])
29const weekOffsetAtom = atom({ plugin: 'todo-calendar', key: 'weekOffset' } as const, 0)
30const todayAtom = atom({ plugin: 'todo-calendar', key: 'today' } as const, '')
31const selectionAtom = atom({ plugin: 'todo-calendar', key: 'selection' } as const, null)
32
33type Args = Record<string, unknown>
34
35const computeToday = async ($: EngineInterface): Promise<string> => {
36  const now = await $.clock.now()
37
38  return localDate(now, new Date(now).getTimezoneOffset())
39}
40
41// 不用 $.store:它依 mod 的載入方式(--plugin-dir、marketplace)和 Claude Code 版本
42// 分成不同檔案,各 session 會讀到不同資料。改存在設定目錄下的固定檔案。
43const tasksPath = async ($: EngineInterface): Promise<string> => {
44  const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
45  if (configDir !== undefined && configDir !== '') {
46    return `${configDir.replace(/[\\/]+$/, '')}/${TASKS_FILE}`
47  }
48  const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
49
50  return `${home.replace(/[\\/]+$/, '')}/.claude/${TASKS_FILE}`
51}
52
53/** 讀任務檔。檔案不存在時回傳空陣列;內容損壞時丟出錯誤,避免之後的寫入蓋掉它 */
54const loadTasks = async ($: EngineInterface): Promise<Task[]> => {
55  const path = await tasksPath($)
56  if (!(await $.fs.exists(path))) {
57    return []
58  }
59  const text = await $.fs.read(path)
60  try {
61    const parsed = JSON.parse(text) as { tasks?: unknown }
62
63    if (Array.isArray(parsed.tasks)) {
64      return parsed.tasks as Task[]
65    }
66  } catch {
67    // 落到下面的錯誤
68  }
69  throw new Error(`任務檔 ${path} 的內容無法解析,請修正或移走它。`)
70}
71
72const saveTasks = async ($: EngineInterface, tasks: Task[]): Promise<void> => {
73  await $.fs.write(await tasksPath($), `${JSON.stringify({ version: 1, tasks }, null, 2)}
74`)
75  await update($, tasksAtom, () => tasks)
76}
77
78type TaskChange = Partial<Pick<Task, 'title' | 'date' | 'done'>>
79
80/** 面板和工具共用:改一個任務,找不到 id 時回傳 undefined */
81const changeTask = async ($: EngineInterface, id: string | undefined, change: TaskChange) => {
82  const tasks = await loadTasks($)
83  const found = tasks.find(task => task.id === id)
84  if (found === undefined) {
85    return undefined
86  }
87  const changed: Task = { ...found, ...change }
88  await saveTasks($, tasks.map(task => (task.id === found.id ? changed : task)))
89
90  return changed
91}
92
93/** 面板和工具共用:刪除一個任務,找不到 id 時回傳 undefined */
94const removeTask = async ($: EngineInterface, id: string | undefined) => {
95  const tasks = await loadTasks($)
96  const found = tasks.find(task => task.id === id)
97  if (found !== undefined) {
98    await saveTasks($, tasks.filter(task => task.id !== found.id))
99  }
100
101  return found
102}
103
104/** 重讀任務檔和今天日期,有變動才寫進 $.state,觸發重畫 */
105const sync = async ($: EngineInterface): Promise<void> => {
106  const stored = await loadTasks($).catch(() => undefined)
107  if (stored === undefined) {
108    return
109  }
110  const shown = await read($, tasksAtom)
111  if (JSON.stringify(stored) !== JSON.stringify(shown)) {
112    await update($, tasksAtom, () => stored)
113  }
114  const today = await computeToday($)
115  if ((await read($, todayAtom)) !== today) {
116    await update($, todayAtom, () => today)
117  }
118}
119
120const newId = (): string =>
121  Math.random().toString(36).slice(2, 6) + Date.now().toString(36).slice(-3)
122
123const ok = (text: string): ToolCallResult => ({ result: text })
124const fail = (text: string): ToolCallResult => ({ isError: true, result: text, text })
125
126/** 讀寫任務檔失敗時(例如檔案損壞),把錯誤當成工具結果回給模型 */
127const failed = (error: { message?: string }): ToolCallResult =>
128  fail(error.message ?? '讀寫任務檔失敗。')
129
130const describeTask = (task: Task): string =>
131  `[${task.id}] ${task.date} ${task.done ? '(完成) ' : ''}${task.title}`
132
133const notFound = (id: unknown): string =>
134  `找不到 id 為 ${String(id)} 的任務,請先用 list_tasks 查詢。`
135
136const str = (value: unknown): string | undefined =>
137  typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
138
139const dayName = (date: string): string => `週${WEEKDAY_NAMES[weekdayIndex(date)]}`
140
141const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: PANE_TITLE })
142
143/** userConfig 欄位在 /config 的名稱:`<plugin>.<field>` */
144const AUTO_OPEN_KEY = 'todo-calendar.autoOpen'
145
146/** /todo auto [on|off]:透過 /config 的同一列設定讀寫,選單和指令保持一致 */
147const setAutoOpen = async ($: EngineInterface, isAutoOpen: boolean, word: string | undefined) => {
148  if (word === undefined) {
149    return {
150      text: `新 session 自動開啟面板:${isAutoOpen ? '開啟' : '關閉'}。用 /todo auto on 或 /todo auto off 切換。`,
151    }
152  }
153  if (word !== 'on' && word !== 'off') {
154    return { text: '用法:/todo auto on 或 /todo auto off。' }
155  }
156  // 直接寫入,不先查 $.config.list():2.1.289 實測列表裡找不到這一列
157  const reason = await $.config
158    .set({ key: AUTO_OPEN_KEY, value: word === 'on' })
159    .then(result => result.deny)
160    .catch((error: unknown) => (error instanceof Error ? error.message : String(error)))
161  if (reason !== undefined) {
162    return { text: `無法變更設定(${AUTO_OPEN_KEY}):${reason}` }
163  }
164
165  return {
166    text: word === 'on' ? '已開啟:新 session 會自動開啟面板。' : '已關閉:新 session 不會自動開啟面板。用 /todo 手動開啟。',
167  }
168}
169
170export const register: Register = (on, options) => {
171  // userConfig 的 autoOpen;設定改變時引擎會用新值重新載入模組
172  const isAutoOpen = options.autoOpen !== false
173
174  on('session.start', async ($, e, next) => {
175    await $.tool.register({
176      name: 'add_task',
177      description:
178        '新增一個 TODO 任務到使用者的週曆。date 必須是 YYYY-MM-DD;相對日期(例如「週五」「明天」)請先依今天日期換算。',
179      inputSchema: {
180        type: 'object',
181        properties: {
182          title: { type: 'string', description: '任務名稱' },
183          date: { type: 'string', description: '日期,格式 YYYY-MM-DD' },
184        },
185        required: ['title', 'date'],
186      },
187    })
188    await $.tool.register({
189      name: 'update_task',
190      description:
191        '修改一個 TODO 任務的名稱或日期。先用 list_tasks 查到 id。只傳要改的欄位。',
192      inputSchema: {
193        type: 'object',
194        properties: {
195          id: { type: 'string', description: '任務 id,用 list_tasks 查詢' },
196          title: { type: 'string', description: '新的任務名稱' },
197          date: { type: 'string', description: '新的日期,格式 YYYY-MM-DD' },
198        },
199        required: ['id'],
200      },
201    })
202    await $.tool.register({
203      name: 'complete_task',
204      description: '把一個 TODO 任務標成完成(done: true)或未完成(done: false)。先用 list_tasks 查到 id。',
205      inputSchema: {
206        type: 'object',
207        properties: {
208          id: { type: 'string', description: '任務 id' },
209          done: { type: 'boolean', description: 'true 表示完成' },
210        },
211        required: ['id', 'done'],
212      },
213    })
214    await $.tool.register({
215      name: 'delete_task',
216      description: '刪除一個 TODO 任務。先用 list_tasks 查到 id。',
217      inputSchema: {
218        type: 'object',
219        properties: { id: { type: 'string', description: '任務 id' } },
220        required: ['id'],
221      },
222    })
223    await $.tool.register({
224      name: 'list_tasks',
225      description:
226        '列出 TODO 任務(含 id),可用 from / to(YYYY-MM-DD,含當天)限制日期範圍。修改、完成或刪除任務前先用它查 id。',
227      inputSchema: {
228        type: 'object',
229        properties: {
230          from: { type: 'string', description: '起始日期 YYYY-MM-DD' },
231          to: { type: 'string', description: '結束日期 YYYY-MM-DD' },
232        },
233      },
234    })
235    await $.command.register({
236      name: 'todo',
237      description: '開啟 TODO 週曆面板;/todo auto on|off 設定新 session 是否自動開啟',
238      argumentHint: '[auto on|off]',
239    })
240    // /add-task 放在 commands/add-task.md:command.run 回傳的 context
241    // 不會讓模型接著處理(2.1.289 實測 num_turns 為 0),一般指令檔才會開始一輪對話。
242
243    await sync($)
244    // 重新載入時模組會重跑,但舊環境的計時器已被取消,所以這裡重新建立
245    $.clock.every(SYNC_MS, () => {
246      void sync($)
247    })
248
249    if (isAutoOpen) {
250      void openPane($)
251    }
252
253    return next(e)
254  })
255
256  on('command.run', { command: 'todo' }, async ($, e) => {
257    const args = e.args.trim().split(/\s+/).filter(word => word !== '')
258    if (args[0] === 'auto') {
259      return setAutoOpen($, isAutoOpen, args[1])
260    }
261    if (args.length > 0) {
262      return { text: '用法:/todo 開啟面板,/todo auto on|off 設定新 session 是否自動開啟。' }
263    }
264    const opened = await openPane($)
265
266    return { text: opened.isPlaced ? '已開啟 TODO 週曆面板。' : `面板未顯示:${opened.reason}` }
267  })
268
269  on('tool.call', { tool: 'mcp__todo-calendar__add_task' }, async ($, e) => {
270    const args = e as unknown as Args
271    const title = str(args.title)
272    const date = str(args.date)
273    if (title === undefined) {
274      return fail('title 不可空白。')
275    }
276    if (date === undefined || !isValidDate(date)) {
277      return fail(`date 必須是有效的 YYYY-MM-DD,收到:${String(args.date)}`)
278    }
279    const task: Task = {
280      id: newId(),
281      title,
282      date,
283      done: false,
284      createdAt: new Date(await $.clock.now()).toISOString(),
285    }
286    await saveTasks($, [...(await loadTasks($)), task])
287
288    return ok(`已新增:${describeTask(task)}(${dayName(date)})`)
289  }).catch(($, e, next) => failed(next.error))
290
291  on('tool.call', { tool: 'mcp__todo-calendar__update_task' }, async ($, e) => {
292    const args = e as unknown as Args
293    const id = str(args.id)
294    const title = str(args.title)
295    const date = str(args.date)
296    if (date !== undefined && !isValidDate(date)) {
297      return fail(`date 必須是有效的 YYYY-MM-DD,收到:${date}`)
298    }
299    if (title === undefined && date === undefined) {
300      return fail('至少要給 title 或 date 其中一個。')
301    }
302    const changed = await changeTask($, id, { ...(title && { title }), ...(date && { date }) })
303    if (changed === undefined) {
304      return fail(notFound(args.id))
305    }
306
307    return ok(`已修改:${describeTask(changed)}`)
308  }).catch(($, e, next) => failed(next.error))
309
310  on('tool.call', { tool: 'mcp__todo-calendar__complete_task' }, async ($, e) => {
311    const args = e as unknown as Args
312    const id = str(args.id)
313    if (typeof args.done !== 'boolean') {
314      return fail('done 必須是 true 或 false。')
315    }
316    const done = args.done
317    const changed = await changeTask($, id, { done })
318    if (changed === undefined) {
319      return fail(notFound(args.id))
320    }
321
322    return ok(`已標成${done ? '完成' : '未完成'}:${describeTask(changed)}`)
323  }).catch(($, e, next) => failed(next.error))
324
325  on('tool.call', { tool: 'mcp__todo-calendar__delete_task' }, async ($, e) => {
326    const args = e as unknown as Args
327    const id = str(args.id)
328    const removed = await removeTask($, id)
329    if (removed === undefined) {
330      return fail(notFound(args.id))
331    }
332
333    return ok(`已刪除:${describeTask(removed)}`)
334  }).catch(($, e, next) => failed(next.error))
335
336  on('tool.call', { tool: 'mcp__todo-calendar__list_tasks' }, async ($, e) => {
337    const args = e as unknown as Args
338    const from = str(args.from)
339    const to = str(args.to)
340    for (const date of [from, to]) {
341      if (date !== undefined && !isValidDate(date)) {
342        return fail(`from / to 必須是有效的 YYYY-MM-DD,收到:${date}`)
343      }
344    }
345    const today = await computeToday($)
346    const found = tasksBetween(await loadTasks($), from, to)
347    const header = `今天是 ${today}(${dayName(today)})。共 ${found.length} 項任務:`
348
349    return ok([header, ...found.map(describeTask)].join('\n'))
350  }).catch(($, e, next) => failed(next.error))
351
352  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
353    const elements = $.ui.resolve(e)
354    const { Box, Text, Button } = elements
355    // mobile 沒有 Input,那裡不提供編輯
356    const Input = e.surface !== 'mobile' && 'Input' in elements ? elements.Input : undefined
357    const tasks = await read($, tasksAtom)
358    const offset = await read($, weekOffsetAtom)
359    const today = (await read($, todayAtom)) || (await computeToday($))
360    const selection = await read($, selectionAtom)
361    const days = weekDays(today, offset)
362    const weekTasks = tasksBetween(tasks, days[0], days[6])
363    const isWide = e.props.bodyColumns >= WIDE_COLUMNS
364    // 橫排時七欄之間有 6 格空隙
365    const columnWidth = Math.floor((e.props.bodyColumns - 6) / 7)
366
367    const taskTitle = (task: Task) => {
368      if (task.done) {
369        return (
370          <Text dimColor strikethrough wrap="wrap">
371            {task.title}
372          </Text>
373        )
374      }
375      if (isOverdue(task, today)) {
376        return (
377          <Text color="error" wrap="wrap">
378            {task.title}
379          </Text>
380        )
381      }
382
383      return <Text wrap="wrap">{task.title}</Text>
384    }
385
386    const taskLine = (task: Task) => (
387      <Box key={`task-${task.id}`} flexDirection="row" gap={1}>
388        <Button
389          key={`check-${task.id}`}
390          plain
391          label={task.done ? '✓' : '○'}
392          dimColor={task.done}
393          onPress={() => changeTask($, task.id, { done: !task.done })}
394        />
395        <Box flexGrow={1}>{taskTitle(task)}</Box>
396        <Button
397          key={`pick-${task.id}`}
398          plain
399          label="⋯"
400          dimColor={selection?.id !== task.id}
401          onPress={() =>
402            update($, selectionAtom, held =>
403              held?.id === task.id ? null : { id: task.id, mode: 'menu', title: task.title, date: task.date, error: '' },
404            )
405          }
406        />
407      </Box>
408    )
409
410    const selected = tasks.find(task => task.id === selection?.id)
411    const close = () => update($, selectionAtom, () => null)
412    // 週末都用藍色,今天用深金黃,整個面板只有今天最搶眼
413    const headingColor = (date: string, isToday: boolean) => {
414      if (isToday) {
415        return TODAY_COLOR
416      }
417      const index = weekdayIndex(date)
418
419      return index >= 5 ? 'suggestion' : undefined
420    }
421
422    const day = (date: string) => {
423      const isToday = date === today
424      const list = tasksOn(tasks, date)
425      const color = headingColor(date, isToday)
426
427      return (
428        <Box
429          key={`day-${date}`}
430          flexDirection="column"
431          width={isWide ? columnWidth : undefined}
432          borderStyle={isWide || isToday ? 'round' : undefined}
433          borderColor={isToday ? TODAY_COLOR : undefined}
434          borderDimColor={!isToday}
435          paddingX={isWide || isToday ? 1 : 0}
436        >
437          {/* 星期幾、日期標籤、今天的記號,彼此空一格 */}
438          <Box flexDirection="row" flexWrap="wrap" alignItems="center" gap={1}>
439            <Text bold color={color}>
440              {WEEKDAY_NAMES[weekdayIndex(date)]}
441            </Text>
442            {/* 一行高的底色標籤:inverse 把字色當底色,平日用暗色底 */}
443            <Text bold inverse color={color} dimColor={color === undefined}>
444              {` ${shortDate(date)} `}
445            </Text>
446            {isToday && (
447              <Text bold color={color}>
448                ● 今天
449              </Text>
450            )}
451          </Box>
452          {list.length === 0 ? <Text dimColor>—</Text> : list.map(taskLine)}
453        </Box>
454      )
455    }
456
457    const pickedLabel = (picked: Task) =>
458      `${picked.title}(${shortDate(picked.date)} ${WEEKDAY_NAMES[weekdayIndex(picked.date)]})`
459
460    const setDraft = (draft: Partial<Selection>) =>
461      update($, selectionAtom, held => (held === null ? null : { ...held, ...draft }))
462
463    /** 讀最新的草稿再存檔,避免用到畫面當下抓到的舊值 */
464    const saveDraft = async (draft: Partial<Selection> = {}) => {
465      const held = await read($, selectionAtom)
466      if (held === null) {
467        return
468      }
469      const title = (draft.title ?? held.title).trim()
470      const date = (draft.date ?? held.date).trim()
471      if (title === '') {
472        await setDraft({ ...draft, error: '名稱不可空白。' })
473        return
474      }
475      if (!isValidDate(date)) {
476        await setDraft({ ...draft, error: '日期要用 YYYY-MM-DD 格式,例如 2026-10-07。' })
477        return
478      }
479      await changeTask($, held.id, { title, date })
480      await close()
481    }
482
483    const actionBar = (picked: Task, held: Selection) => {
484      if (held.mode === 'edit' && Input !== undefined) {
485        return (
486          <Box flexDirection="column" borderStyle="round" borderColor="permission" paddingX={1} marginTop={1}>
487            <Input
488              key="edit-title"
489              label="名稱 "
490              value={held.title}
491              autoFocus
492              submitLabel="儲存"
493              onInput={(title: string) => setDraft({ title })}
494              onSubmit={(title: string) => saveDraft({ title })}
495            />
496            <Input
497              key="edit-date"
498              label="日期 "
499              value={held.date}
500              placeholder="YYYY-MM-DD"
501              submitLabel="儲存"
502              onInput={(date: string) => setDraft({ date })}
503              onSubmit={(date: string) => saveDraft({ date })}
504            />
505            {held.error !== '' && <Text color="warning">{held.error}</Text>}
506            <Box flexDirection="row" gap={2}>
507              <Button key="save" plain label="儲存" onPress={() => saveDraft()} />
508              <Button key="cancel" plain label="取消" onPress={close} />
509            </Box>
510          </Box>
511        )
512      }
513      if (held.mode === 'confirm') {
514        return (
515          <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1} marginTop={1}>
516            <Text>刪除「{picked.title}」?刪除後無法復原。</Text>
517            <Box flexDirection="row" gap={2}>
518              <Button
519                key="confirm-delete"
520                plain
521                hotkey="y"
522                label="確定刪除"
523                onPress={async () => {
524                  await removeTask($, picked.id)
525                  await close()
526                }}
527              />
528              <Button key="cancel" plain hotkey="x" label="取消" onPress={close} />
529            </Box>
530          </Box>
531        )
532      }
533
534      return (
535        <Box flexDirection="column" borderStyle="round" borderColor="permission" paddingX={1} marginTop={1}>
536          <Text bold>{pickedLabel(picked)}</Text>
537          <Box flexDirection="row" gap={2}>
538            {Input !== undefined && (
539              <Button key="edit" plain hotkey="e" label="編輯" onPress={() => update($, selectionAtom, () => ({ ...held, mode: 'edit' }))} />
540            )}
541            <Button key="delete" plain hotkey="d" label="刪除" onPress={() => update($, selectionAtom, () => ({ ...held, mode: 'confirm' }))} />
542            <Button key="cancel" plain hotkey="x" label="取消" onPress={close} />
543          </Box>
544        </Box>
545      )
546    }
547
548    return (
549      <Box flexDirection="column">
550        <Box flexDirection="row" justifyContent="space-between" flexWrap="wrap">
551          <Text bold>{weekLabel(days)}</Text>
552          <Text dimColor>
553            共 {weekTasks.length} 項,完成 {weekTasks.filter(task => task.done).length}
554          </Text>
555        </Box>
556        <Box flexDirection="row" gap={3} flexWrap="wrap" marginBottom={1}>
557          <Button key="prev" plain hotkey="p" label="◀ 上週" onPress={() => update($, weekOffsetAtom, n => n - 1)} />
558          {/* 看本週時用主要色標出來;plain 會蓋掉 variant,所以那時不加 plain */}
559          {offset === 0 ? (
560            <Button key="this" variant="primary" hotkey="t" label="本週" onPress={() => update($, weekOffsetAtom, () => 0)} />
561          ) : (
562            <Button key="this" plain hotkey="t" label="本週" onPress={() => update($, weekOffsetAtom, () => 0)} />
563          )}
564          <Button key="next" plain hotkey="n" label="下週 ▶" onPress={() => update($, weekOffsetAtom, n => n + 1)} />
565        </Box>
566        <Box key="days" flexDirection={isWide ? 'row' : 'column'} gap={1}>
567          {days.map(day)}
568        </Box>
569        {selected !== undefined && selection !== null && actionBar(selected, selection)}
570      </Box>
571    )
572  })
573}
574
hooks/calendar.ts 78 lines
1import type { Task } from '../types'
2
3// 日期一律用 YYYY-MM-DD 字串,計算時轉成 UTC 午夜,避免時區和夏令時間影響加減天數。
4
5const DAY_MS = 24 * 60 * 60 * 1000
6const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
7
8export const WEEKDAY_NAMES = ['一', '二', '三', '四', '五', '六', '日']
9
10export const isValidDate = (date: string): boolean => {
11  const match = DATE_PATTERN.exec(date)
12  if (match === null) {
13    return false
14  }
15  const [, year, month, day] = match.map(Number)
16  const parsed = new Date(Date.UTC(year!, month! - 1, day!))
17
18  return (
19    parsed.getUTCFullYear() === year &&
20    parsed.getUTCMonth() === month! - 1 &&
21    parsed.getUTCDate() === day
22  )
23}
24
25const toUtcMs = (date: string): number => {
26  const [year, month, day] = date.split('-').map(Number)
27
28  return Date.UTC(year!, month! - 1, day!)
29}
30
31const fromUtcMs = (ms: number): string => new Date(ms).toISOString().slice(0, 10)
32
33export const addDays = (date: string, days: number): string =>
34  fromUtcMs(toUtcMs(date) + days * DAY_MS)
35
36/** 星期一為 0,星期日為 6 */
37export const weekdayIndex = (date: string): number =>
38  (new Date(toUtcMs(date)).getUTCDay() + 6) % 7
39
40export const mondayOf = (date: string): string => addDays(date, -weekdayIndex(date))
41
42/** 某一天所在週,往後或往前 offset 週的七天(週一到週日) */
43export const weekDays = (today: string, offset: number): string[] => {
44  const monday = addDays(mondayOf(today), offset * 7)
45
46  return Array.from({ length: 7 }, (_, i) => addDays(monday, i))
47}
48
49/** 以毫秒時間和時區偏移(分鐘,與 Date#getTimezoneOffset 同號)算出當地日期 */
50export const localDate = (nowMs: number, offsetMinutes: number): string =>
51  fromUtcMs(nowMs - offsetMinutes * 60 * 1000)
52
53export const isOverdue = (task: Task, today: string): boolean =>
54  !task.done && task.date < today
55
56export const tasksOn = (tasks: readonly Task[], date: string): Task[] =>
57  tasks.filter(task => task.date === date)
58
59export const tasksBetween = (
60  tasks: readonly Task[],
61  from?: string,
62  to?: string,
63): Task[] =>
64  tasks
65    .filter(task => (from === undefined || task.date >= from) && (to === undefined || task.date <= to))
66    .sort((a, b) => a.date.localeCompare(b.date) || a.createdAt.localeCompare(b.createdAt))
67
68/** "10/5" 這種短日期 */
69export const shortDate = (date: string): string => {
70  const [, month, day] = date.split('-').map(Number)
71
72  return `${month}/${day}`
73}
74
75/** "2026 年 10/5 至 10/11",年份取週一那天 */
76export const weekLabel = (days: readonly string[]): string =>
77  `${days[0]!.slice(0, 4)} 年 ${shortDate(days[0]!)} 至 ${shortDate(days[6]!)}`
78
types/index.d.ts 37 lines
1export type Task = {
2  id: string
3  title: string
4  /** YYYY-MM-DD */
5  date: string
6  done: boolean
7  /** ISO 8601 */
8  createdAt: string
9}
10
11/** 面板上選取的任務:menu 顯示操作列,edit 顯示輸入框,confirm 等待確認刪除 */
12export type Selection = {
13  id: string
14  mode: 'menu' | 'edit' | 'confirm'
15  /** 編輯中的名稱草稿 */
16  title: string
17  /** 編輯中的日期草稿 */
18  date: string
19  /** 編輯時的錯誤訊息,沒有錯誤時是空字串 */
20  error: string
21}
22
23declare module 'claude-code' {
24  interface PluginState {
25    'todo-calendar': {
26      /** 從任務檔讀到的任務,定時同步 */
27      tasks: Task[]
28      /** 相對於本週的週數:0 本週,-1 上一週,1 下一週 */
29      weekOffset: number
30      /** 今天的日期 YYYY-MM-DD,換日時更新以觸發重畫 */
31      today: string
32      /** 面板上選取的任務,沒有選取時是 null */
33      selection: Selection | null
34    }
35  }
36}
37