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

在 Claude Code 直接寫代辦事項
一個 Claude Code Mod:在面板顯示一週七天的待辦,可以直接打勾、編輯、刪除,也可以用一句話請 Claude 幫你排進去。
寫程式時想到「週五要交報告」,切去別的待辦 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 或點一下面板,就能把焦點移過去。
| 想做的事 | 做法 |
|---|---|
| 用一句話新增 | /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。hooks/register.tsx 574 lines1import { 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}
574hooks/calendar.ts 78 lines1import 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]!)}`
78types/index.d.ts 37 lines1export 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