ปฏิทินกิจกรรมแบบ GitHub 26 สัปดาห์ข้ามทุก session พร้อม streak และวันที่ขยันที่สุด

🎉 Mod ตัวที่ 100 · ปฏิทินกิจกรรมแบบ GitHub contribution ข้ามทุก session: นับรายวันว่าส่ง prompt กี่ครั้ง ใช้ tool เท่าไหร่ แก้ไฟล์กี่ครั้ง Claude ทำงานกี่นาที เสียเงินเท่าไหร่ แล้ววาดตาราง 26 สัปดาห์ สีเขียวเข้มตามความขยัน พร้อม streak วันติดกัน และวันที่ขยันที่สุด
| คำสั่ง | ทำอะไร | ||||
|---|---|---|---|---|---|
/calendar | เปิดปฏิทินกิจกรรม | ||||
| `/calendar prompts\ | tools\ | edits\ | minutes\ | cost` | เลือกสิ่งที่นับ |
/plugin install activity-calendar-th --marketplace tksoft11/claude-code-mods-th
ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)
เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
เก็บใน $.store ของ mod นับเฉพาะเครื่องนี้
hooks/register.tsx 248 lines1/**
2 * ============================================================================
3 * MOD 100: Activity Calendar TH - ปฏิทินกิจกรรมแบบ GitHub ข้ามทุก session (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: mosaic (KOZMOS) และ heatmap ใน awesome-claude-code-mods
6 *
7 * 🎉 Mod ตัวที่ 100 ของ Claude Code Mods TH
8 *
9 * หน้าที่ของ Mod นี้:
10 * 1. นับกิจกรรมรายวันข้ามทุก session: prompt, tool, ไฟล์ที่แก้, นาทีที่ Claude ทำงาน, ค่าใช้จ่าย
11 * 2. /calendar เปิดแผงตารางแบบ GitHub contribution 26 สัปดาห์ สีเขียวเข้มตามความขยัน
12 * 3. สถิติ: streak ปัจจุบัน, streak ยาวสุด, วันที่ขยันที่สุด, รวมทั้งหมด
13 * 4. เปลี่ยนสิ่งที่นับได้: /calendar prompts | tools | edits | minutes | cost
14 */
15
16import type { EngineInterface, Register } from 'claude-code'
17
18const PANE = 'activity-calendar-th'
19const WEEKS = 26
20const DAY_MS = 86_400_000
21
22interface Day {
23 prompts: number
24 tools: number
25 edits: number
26 minutes: number
27 cost: number
28}
29
30type Metric = keyof Day
31
32const METRIC: Record<Metric, string> = { prompts: 'prompt', tools: 'tool', edits: 'การแก้ไฟล์', minutes: 'นาทีทำงาน', cost: 'ดอลลาร์' }
33const SHADES = ['#2d333b', '#0e4429', '#006d32', '#26a641', '#39d353']
34const DOW = ['จ', 'อ', 'พ', 'พฤ', 'ศ', 'ส', 'อา']
35const MONTH = ['ม.ค.', 'ก.พ.', 'มี.ค.', 'เม.ย.', 'พ.ค.', 'มิ.ย.', 'ก.ค.', 'ส.ค.', 'ก.ย.', 'ต.ค.', 'พ.ย.', 'ธ.ค.']
36
37let days: Record<string, Day> = {}
38let metric: Metric = 'prompts'
39let turnStart = 0
40let sessionCost = 0
41
42function key(ms: number): string {
43 const d = new Date(ms)
44 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
45}
46
47function empty(): Day {
48 return { prompts: 0, tools: 0, edits: 0, minutes: 0, cost: 0 }
49}
50
51// ==========================================================================
52// บทที่ 1: นับลงวันนี้
53// ==========================================================================
54async function bump($: EngineInterface, field: Metric, by = 1): Promise<void> {
55 const k = key(await $.clock.now())
56 const d = days[k] ?? empty()
57 days[k] = { ...d, [field]: d[field] + by }
58}
59
60async function save($: EngineInterface): Promise<void> {
61 await $.store.set('days', days)
62}
63
64// ==========================================================================
65// บทที่ 2: ตารางและสถิติ
66// ==========================================================================
67function value(k: string): number {
68 return days[k]?.[metric] ?? 0
69}
70
71function level(v: number, max: number): number {
72 if (v <= 0) return 0
73 return Math.min(4, Math.ceil((v / max) * 4))
74}
75
76// คืนวันจันทร์ของสัปดาห์แรกในตาราง
77function gridStart(now: number): number {
78 const d = new Date(new Date(now).toDateString()).getTime()
79 const dow = (new Date(d).getDay() + 6) % 7 // จันทร์ = 0
80 return d - dow * DAY_MS - (WEEKS - 1) * 7 * DAY_MS
81}
82
83function stats(now: number): { total: number; active: number; streak: number; longest: number; best?: [string, number] } {
84 const keys = Object.keys(days).sort()
85 let total = 0
86 let active = 0
87 let best: [string, number] | undefined
88 for (const k of keys) {
89 const v = value(k)
90 total += v
91 if (v > 0) active++
92 if (v > 0 && (!best || v > best[1])) best = [k, v]
93 }
94 // streak: นับวันติดกันที่มีกิจกรรม (ใช้ prompt เป็นเกณฑ์ว่า "ได้ทำงาน")
95 const worked = (ms: number): boolean => (days[key(ms)]?.prompts ?? 0) > 0
96 let streak = 0
97 let cursor = new Date(new Date(now).toDateString()).getTime()
98 if (!worked(cursor)) cursor -= DAY_MS
99 while (worked(cursor)) {
100 streak++
101 cursor -= DAY_MS
102 }
103 let longest = 0
104 let run = 0
105 let prev = 0
106 for (const k of keys.filter(k => (days[k]?.prompts ?? 0) > 0)) {
107 const t = new Date(`${k}T00:00:00`).getTime()
108 run = prev && Math.round((t - prev) / DAY_MS) === 1 ? run + 1 : 1
109 longest = Math.max(longest, run)
110 prev = t
111 }
112 return { total, active, streak, longest, best }
113}
114
115function fmt(v: number): string {
116 return metric === 'cost' ? `$${v.toFixed(2)}` : metric === 'minutes' ? `${Math.round(v)}` : String(v)
117}
118
119export const register: Register = on => {
120 on('session.start', async ($, e, next) => {
121 days = ((await $.store.get('days')) as Record<string, Day> | undefined) ?? {}
122 metric = ((await $.store.get('metric')) as Metric | undefined) ?? 'prompts'
123 sessionCost = 0
124 await $.command.register({ name: 'calendar', description: 'ปฏิทินกิจกรรมแบบ GitHub ข้ามทุก session + streak (/calendar help)', argumentHint: 'prompts|tools|edits|minutes|cost' })
125 return next(e)
126 })
127
128 // ==========================================================================
129 // บทที่ 3: เก็บตัวเลขจาก event
130 // ==========================================================================
131 on('prompt.submit', async ($, e, next) => {
132 if (e.origin.kind !== 'plugin') await bump($, 'prompts')
133 return next(e)
134 })
135
136 on('tool.call', async ($, e, next) => {
137 const r = await next(e)
138 if (r.deny === undefined) {
139 await bump($, 'tools')
140 if (!r.isError && (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit')) await bump($, 'edits')
141 }
142 return r
143 })
144
145 on('turn.start', async ($, e, next) => {
146 if (!e.agentId) turnStart = await $.clock.now()
147 return next(e)
148 })
149
150 on('turn.complete', async ($, e, next) => {
151 const r = await next(e)
152 if (!e.agentId && turnStart) {
153 await bump($, 'minutes', ((await $.clock.now()) - turnStart) / 60_000)
154 turnStart = 0
155 await save($)
156 }
157 return r
158 })
159
160 // ค่าใช้จ่ายที่ engine รายงานเป็นยอดรวมของ session: บวกเฉพาะส่วนที่เพิ่มขึ้น
161 on('session.measure', async ($, e, next) => {
162 if (e.cost && e.cost.usd > sessionCost) {
163 await bump($, 'cost', e.cost.usd - sessionCost)
164 sessionCost = e.cost.usd
165 }
166 return next(e)
167 })
168
169 on('session.end', async ($, e, next) => {
170 await save($)
171 return next(e)
172 })
173
174 // ==========================================================================
175 // บทที่ 4: วาดตาราง 7 แถว × 26 สัปดาห์
176 // ==========================================================================
177 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
178 const { Box, Text } = $.ui.resolve(e)
179 const now = await $.clock.now()
180 const start = gridStart(now)
181 const today = key(now)
182 const cells = Array.from({ length: WEEKS * 7 }, (_, i) => key(start + (Math.floor(i / 7) * 7 + (i % 7)) * DAY_MS))
183 const max = Math.max(1, ...cells.map(value))
184 // แถวชื่อเดือน: ใส่ชื่อเดือนที่คอลัมน์ที่ขึ้นเดือนใหม่
185 let monthRow = ''
186 let lastMonth = -1
187 for (let w = 0; w < WEEKS; w++) {
188 const m = new Date(start + w * 7 * DAY_MS).getMonth()
189 if (m !== lastMonth && monthRow.length <= w * 2) {
190 monthRow = monthRow.padEnd(w * 2) + MONTH[m]
191 lastMonth = m
192 }
193 }
194 const s = stats(now)
195 return (
196 <Box flexDirection="column">
197 <Text dimColor>{` ${monthRow}`}</Text>
198 {DOW.map((label, row) => (
199 <Text>
200 <Text dimColor>{label.padEnd(3)}</Text>
201 {Array.from({ length: WEEKS }, (_, w) => {
202 const k = cells[w * 7 + row]!
203 const future = k > today
204 return <Text color={future ? '#1c2128' : SHADES[level(value(k), max)]}>{k === today ? '◆ ' : '■ '}</Text>
205 })}
206 </Text>
207 ))}
208 <Text>
209 <Text dimColor>{' น้อย '}</Text>
210 {SHADES.map(c => (
211 <Text color={c}>■ </Text>
212 ))}
213 <Text dimColor>{` มาก · นับ${METRIC[metric]}`}</Text>
214 </Text>
215 <Box flexDirection="column" marginTop={1}>
216 <Text>{`🔥 streak ${s.streak} วัน · ยาวสุด ${s.longest} วัน · ทำงาน ${s.active} วัน`}</Text>
217 <Text>{`📊 รวม ${fmt(s.total)} ${METRIC[metric]}${s.best ? ` · ขยันสุด ${s.best[0]} (${fmt(s.best[1])})` : ''}`}</Text>
218 <Text dimColor>🎉 Mod ที่ 100 · ขอบคุณที่ใช้ Claude Code Mods TH 🇹🇭</Text>
219 </Box>
220 </Box>
221 )
222 })
223
224 on('command.run', { command: 'calendar' }, async ($, e) => {
225 const sub = e.args.trim()
226 if (sub in METRIC) {
227 metric = sub as Metric
228 await $.store.set('metric', metric)
229 } else if (sub) {
230 return { text: '📅 Activity Calendar TH (Mod 100)\n/calendar เปิดปฏิทินกิจกรรม 26 สัปดาห์\n/calendar prompts|tools|edits|minutes|cost เลือกสิ่งที่นับ' }
231 }
232 await save($)
233 await $.ui.open({ id: PANE, title: '📅 ปฏิทินกิจกรรม' })
234 $.ui.invalidate('ui.render')
235 const s = stats(await $.clock.now())
236 return { text: `📅 นับ${METRIC[metric]} · 🔥 streak ${s.streak} วัน (ยาวสุด ${s.longest}) · รวม ${fmt(s.total)} · ทำงาน ${s.active} วัน` }
237 })
238}
239
240/**
241 * ========================================================================
242 * การบ้าน / ต่อยอด
243 * ========================================================================
244 * 1. แยกตามโปรเจกต์: เก็บ cwd ด้วย แล้วกรองดูทีละโปรเจกต์
245 * 2. ส่งออกเป็นรูป SVG ไว้แปะ README ของโปรไฟล์ GitHub
246 * 3. เตือนตอนค่ำถ้าวันนี้ยังไม่ได้ทำงาน จะได้ไม่เสีย streak (ต่อกับ Mod 96)
247 */
248