จับเวลาหลายตัวพร้อมกัน: นับถอยหลัง จับเวลาเดินหน้า และบันทึกเวลาทำงานแยกโปรเจกต์ พร้อมรายงานรายวัน

จับเวลาหลายตัวพร้อมกัน: นับถอยหลัง ("มาม่า 3m", "ประชุม 1:30:00") ครบแล้วเด้งเตือน, stopwatch ที่หยุดชั่วคราวได้ และบันทึกเวลาทำงานแยกโปรเจกต์ แล้วสรุปเป็นรายงานรายวัน/รายสัปดาห์ แผงแสดงทุกตัวแบบสด
| คำสั่ง | ทำอะไร | |
|---|---|---|
/timer start <ชื่อ> <เวลา> | นับถอยหลัง (90s 25m 1h30m 1:30:00) | |
/timer stopwatch <ชื่อ> | จับเวลาเดินหน้า | |
/timer work <โปรเจกต์> | เริ่มบันทึกเวลาทำงาน | |
| `/timer stop <ชื่อ> \ | stop all` | หยุด (งานจะถูกบันทึก) |
| `/timer pause\ | resume <ชื่อ>` | หยุดชั่วคราว / ต่อ |
/timer report [week] | สรุปเวลาทำงานแยกโปรเจกต์ |
/plugin install timer-th --marketplace tksoft11/claude-code-mods-th
ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)
เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
ตัวจับเวลาทำงานเฉพาะตอนเปิด Claude Code อยู่ ส่วนบันทึกเวลาทำงานที่หยุดแล้วเก็บข้าม session Pomodoro อยู่ใน Mod 31
hooks/register.tsx 232 lines1/**
2 * ============================================================================
3 * MOD 43: Timer TH - จับเวลาหลายตัวพร้อมกัน + บันทึกเวลาทำงาน (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: นาฬิกาจับเวลาบนมือถือ และ Toggl Track
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. นับถอยหลังหลายตัวพร้อมกัน เช่น "ต้มมาม่า 3m", "ประชุม 1h" ครบแล้วเด้งเตือน
9 * 2. จับเวลาเดินหน้า (stopwatch) หยุดชั่วคราว/ต่อได้
10 * 3. บันทึกเวลาทำงานแยกโปรเจกต์ แล้วสรุปรายวัน/รายสัปดาห์
11 * 4. แผงแสดงทุกตัวแบบสด (timer วิ่งเฉพาะตอนมีตัวจับเวลาทำงานอยู่)
12 *
13 * หมายเหตุ: Mod 31 มี Pomodoro อยู่แล้ว ตัวนี้เน้นจับเวลาทั่วไปและเก็บสถิติงาน
14 */
15
16import type { EngineInterface, Register, Timer } from 'claude-code'
17
18interface Clock {
19 name: string
20 kind: 'countdown' | 'stopwatch' | 'work'
21 startedAt: number
22 durationMs?: number // countdown เท่านั้น
23 pausedAt?: number
24 pausedTotal: number
25}
26interface WorkLog {
27 project: string
28 start: number
29 end: number
30}
31
32const PANE = 'timer-th'
33let clocks: Clock[] = []
34let logs: WorkLog[] = []
35let ticker: Timer | undefined
36
37// ==========================================================================
38// บทที่ 1: อ่านระยะเวลาแบบคน: 90s, 25m, 1h30m, 1:30:00
39// ==========================================================================
40function parseDuration(text: string): number | undefined {
41 const colon = /^(?:(\d+):)?(\d{1,2}):(\d{2})$/.exec(text)
42 if (colon) return ((Number(colon[1] ?? 0) * 60 + Number(colon[2])) * 60 + Number(colon[3])) * 1000
43 const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(text)
44 if (!m || !text) return undefined
45 const ms = ((Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0)) * 60 + Number(m[3] ?? 0)) * 1000
46 return ms > 0 ? ms : undefined
47}
48
49function hms(ms: number): string {
50 const s = Math.max(0, Math.floor(ms / 1000))
51 const h = Math.floor(s / 3600)
52 const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0')
53 const ss = String(s % 60).padStart(2, '0')
54 return h ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
55}
56
57// เวลาที่เดินไปแล้ว (หักช่วงที่หยุดชั่วคราว)
58function elapsed(c: Clock, now: number): number {
59 return (c.pausedAt ?? now) - c.startedAt - c.pausedTotal
60}
61
62function line(c: Clock, now: number): string {
63 const icon = c.kind === 'countdown' ? '⏳' : c.kind === 'work' ? '💼' : '⏱️'
64 const value = c.kind === 'countdown' ? hms((c.durationMs ?? 0) - elapsed(c, now)) : hms(elapsed(c, now))
65 return `${icon} ${c.name.padEnd(16)} ${value}${c.pausedAt ? ' ⏸️' : ''}`
66}
67
68// ==========================================================================
69// บทที่ 2: tick ทุก 1 วิ - เช็ค countdown ที่ครบ แล้ววาดแผงใหม่
70// ==========================================================================
71function ensureTicker($: EngineInterface): void {
72 if (clocks.length === 0) {
73 ticker?.cancel()
74 ticker = undefined
75 return
76 }
77 if (!ticker) ticker = $.clock.every(1000, () => void tick($))
78}
79
80async function tick($: EngineInterface): Promise<void> {
81 const now = await $.clock.now()
82 const done = clocks.filter(c => c.kind === 'countdown' && !c.pausedAt && elapsed(c, now) >= (c.durationMs ?? 0))
83 if (done.length) clocks = clocks.filter(c => !done.includes(c))
84 ensureTicker($)
85 $.ui.invalidate('ui.render')
86 for (const c of done) {
87 $.ui.toast(`⏰ หมดเวลา: ${c.name}`, { timeoutMs: 15_000 })
88 // แจ้งเตือนของระบบ (บางที่ไม่มี เช่นตอนรันเทสต์) - พลาดก็ไม่เป็นไร
89 try {
90 void $.ui.notify(`หมดเวลา: ${c.name}`, { title: 'Timer' }).catch(() => undefined)
91 } catch {}
92 }
93}
94
95async function save($: EngineInterface): Promise<void> {
96 await $.store.set('timer', { logs: logs.slice(-2000) })
97}
98
99// ==========================================================================
100// บทที่ 3: รายงานเวลาทำงานแยกโปรเจกต์
101// ==========================================================================
102function report(now: number, days: number): string {
103 const start = new Date(now)
104 start.setHours(0, 0, 0, 0)
105 start.setDate(start.getDate() - (days - 1))
106 const inRange = logs.filter(l => l.end >= start.getTime())
107 const byProject = new Map<string, number>()
108 for (const l of inRange) byProject.set(l.project, (byProject.get(l.project) ?? 0) + (l.end - Math.max(l.start, start.getTime())))
109 const total = [...byProject.values()].reduce((s, v) => s + v, 0)
110 if (total === 0) return `📊 ${days === 1 ? 'วันนี้' : `${days} วันล่าสุด`} ยังไม่มีบันทึก (เริ่มด้วย /timer work <โปรเจกต์>)`
111 const rows = [...byProject.entries()].sort((a, b) => b[1] - a[1]).map(([p, ms]) => ` ${p.padEnd(16)} ${hms(ms).padStart(8)} ${'█'.repeat(Math.max(1, Math.round((ms / total) * 20)))}`)
112 return [`📊 เวลาทำงาน${days === 1 ? 'วันนี้' : ` ${days} วันล่าสุด`} รวม ${hms(total)}`, ...rows].join('\n')
113}
114
115const HELP = `⏱️ Timer TH (Mod 43)
116========================================
117/timer start <ชื่อ> <เวลา> นับถอยหลัง เวลา: 90s 25m 1h30m 1:30:00
118/timer stopwatch <ชื่อ> จับเวลาเดินหน้า
119/timer work <โปรเจกต์> เริ่มบันทึกเวลาทำงาน
120/timer stop <ชื่อ> | stop all หยุด (งานจะถูกบันทึก)
121/timer pause <ชื่อ> | resume <ชื่อ>
122/timer | list ดูทั้งหมด + เปิดแผง
123/timer report [today|week] สรุปเวลาทำงานแยกโปรเจกต์`
124
125export const register: Register = on => {
126 on('session.start', async ($, e, next) => {
127 const saved = (await $.store.get('timer')) as { logs?: WorkLog[] } | undefined
128 logs = saved?.logs ?? []
129 clocks = []
130 await $.command.register({ name: 'timer', description: 'จับเวลาหลายตัว นับถอยหลัง stopwatch และบันทึกเวลาทำงาน (/timer help)', argumentHint: 'start|stopwatch|work|stop|pause|report' })
131 return next(e)
132 })
133
134 // ==========================================================================
135 // บทที่ 4: แผงแสดงตัวจับเวลา
136 // ==========================================================================
137 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
138 const { Box, Text } = $.ui.resolve(e)
139 const now = await $.clock.now()
140 return (
141 <Box flexDirection="column">
142 {clocks.length === 0 && <Text dimColor>ไม่มีตัวจับเวลา /timer start มาม่า 3m</Text>}
143 {clocks.map(c => (
144 <Text color={c.kind === 'countdown' && (c.durationMs ?? 0) - elapsed(c, now) < 60_000 ? '#e53935' : undefined}>{line(c, now)}</Text>
145 ))}
146 </Box>
147 )
148 })
149
150 on('command.run', { command: 'timer' }, async ($, e) => {
151 const [sub = 'list', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
152 const now = await $.clock.now()
153 const find = (name: string) => clocks.find(c => c.name === name)
154
155 const add = async (c: Clock, msg: string) => {
156 if (find(c.name)) return { text: `ℹ️ มี "${c.name}" อยู่แล้ว (/timer stop ${c.name})` }
157 clocks.push(c)
158 ensureTicker($)
159 await $.ui.open({ id: PANE, title: '⏱️ Timer' })
160 $.ui.invalidate('ui.render')
161 return { text: msg }
162 }
163
164 switch (sub) {
165 case 'start': {
166 const duration = parseDuration(rest.at(-1) ?? '')
167 const name = rest.slice(0, -1).join(' ')
168 if (!duration || !name) return { text: 'วิธีใช้: /timer start มาม่า 3m (90s 25m 1h30m 1:30:00)' }
169 return add({ name, kind: 'countdown', startedAt: now, durationMs: duration, pausedTotal: 0 }, `⏳ เริ่มนับถอยหลัง "${name}" ${hms(duration)}`)
170 }
171 case 'stopwatch': {
172 const name = rest.join(' ') || 'stopwatch'
173 return add({ name, kind: 'stopwatch', startedAt: now, pausedTotal: 0 }, `⏱️ เริ่มจับเวลา "${name}"`)
174 }
175 case 'work': {
176 const name = rest.join(' ')
177 if (!name) return { text: 'วิธีใช้: /timer work เว็บลูกค้า A' }
178 // ทำงานได้ทีละโปรเจกต์: เริ่มอันใหม่ = ปิดอันเก่าแล้วบันทึก
179 const current = clocks.find(c => c.kind === 'work')
180 if (current) {
181 logs.push({ project: current.name, start: current.startedAt, end: now - current.pausedTotal })
182 clocks = clocks.filter(c => c !== current)
183 await save($)
184 }
185 return add({ name, kind: 'work', startedAt: now, pausedTotal: 0 }, `💼 เริ่มบันทึกเวลา "${name}"${current ? ` (บันทึก "${current.name}" ${hms(elapsed(current, now))} แล้ว)` : ''}`)
186 }
187 case 'stop': {
188 const targets = rest[0] === 'all' ? [...clocks] : clocks.filter(c => c.name === rest.join(' '))
189 if (targets.length === 0) return { text: `❌ ไม่พบ "${rest.join(' ')}"` }
190 const lines: string[] = []
191 for (const c of targets) {
192 lines.push(`⏹️ ${c.name} ${hms(elapsed(c, now))}`)
193 if (c.kind === 'work') logs.push({ project: c.name, start: c.startedAt, end: (c.pausedAt ?? now) - c.pausedTotal })
194 }
195 clocks = clocks.filter(c => !targets.includes(c))
196 await save($)
197 ensureTicker($)
198 $.ui.invalidate('ui.render')
199 return { text: lines.join('\n') }
200 }
201 case 'pause':
202 case 'resume': {
203 const c = find(rest.join(' '))
204 if (!c) return { text: `❌ ไม่พบ "${rest.join(' ')}"` }
205 if (sub === 'pause' && !c.pausedAt) c.pausedAt = now
206 if (sub === 'resume' && c.pausedAt) {
207 c.pausedTotal += now - c.pausedAt
208 c.pausedAt = undefined
209 }
210 $.ui.invalidate('ui.render')
211 return { text: `${sub === 'pause' ? '⏸️ หยุด' : '▶️ ต่อ'} ${c.name}` }
212 }
213 case 'report':
214 return { text: report(now, rest[0] === 'week' ? 7 : 1) }
215 case 'list':
216 await $.ui.open({ id: PANE, title: '⏱️ Timer' })
217 return { text: clocks.length ? clocks.map(c => line(c, now)).join('\n') : 'ไม่มีตัวจับเวลา /timer help' }
218 default:
219 return { text: HELP }
220 }
221 })
222}
223
224/**
225 * ========================================================================
226 * การบ้าน / ต่อยอด
227 * ========================================================================
228 * 1. เริ่มบันทึกงานอัตโนมัติตามชื่อโฟลเดอร์โปรเจกต์ทุกครั้งที่เปิด Claude Code
229 * 2. ส่งออกเวลาทำงานเป็น CSV ไว้คิดค่าแรงลูกค้า
230 * 3. เตือนไปมือถือเมื่อหมดเวลา (ต่อกับ Mod 22)
231 */
232