Counts your prompts per local day (count, characters, hour of day, never the text) and shows today, the last 7 days and an hour histogram with /typing-stats

How much you type to Claude, and when: prompt counts per day, average prompt length and your busiest hours.
| Command | Effect |
|---|---|
/typing-stats | Today, the last 7 days (count per day with a bar), average prompt length, hour-of-day histogram and busiest hour |
/typing-stats week | The same over the last 7 days, without the "Today" line |
/typing-stats month | The same over the last 30 days |
/typing-stats reset | Deletes all recorded stats |
Example:
Today: 8 prompts, 1160 characters (avg 145)
Last 7 days: 15 prompts
Tue 09-29 0
Wed 09-30 0
Thu 10-01 4 ██████████
Fri 10-02 1 ███
Sat 10-03 0
Sun 10-04 2 █████
Mon 10-05 8 ████████████████████
Average prompt length: 133 characters
Hour of day (last 7 days):
▁▁▁▁▁▁▁▁▁▄▃▁▁▁█▁▁▁▁▁▁▁▂▁
0 6 12 18 23
Busiest hour: 14:00-15:00 (9 prompts)
claude --plugin-dir /path/to/ModsTools/mods/typing-stats
▁, any hour with a prompt is at least ▂.claude plugin validate mods/typing-stats
claude plugin test mods/typing-stats # 12 testshooks/register.ts 45 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { dayKey, normalize, promptLength, record, report } from './stats'
4import type { Days } from './stats'
5
6const KEY = 'days'
7// Prompts the person typed: at the terminal, or through Remote Control.
8const PERSON = new Set(['composer', 'bridge'])
9const SLASH = /^\/[\w:.-]+(\s|$)/
10
11async function load($: EngineInterface): Promise<Days> {
12 return normalize(await $.store.get(KEY))
13}
14
15export const register: Register = on => {
16 on('session.start', async ($, e, next) => {
17 await $.command.register({ name: 'typing-stats', description: 'Your prompts per day and hour: [week|month|reset]' })
18
19 return next(e)
20 })
21
22 on('prompt.submit', async ($, e, next) => {
23 const entered = await next(e)
24 if ('drop' in entered && entered.drop !== undefined) return entered
25 if (!PERSON.has(e.origin.kind) || SLASH.test(e.text)) return entered
26 // Only the count, the length and the hour are kept, never the text.
27 await $.store.set(KEY, record(await load($), await $.clock.now(), promptLength(e.text)))
28
29 return entered
30 })
31
32 on('command.run', { command: 'typing-stats' }, async ($, e) => {
33 const arg = e.args.trim().toLowerCase()
34 if (arg === 'reset') {
35 await $.store.delete(KEY)
36
37 return { text: 'Typing stats reset.' }
38 }
39 if (arg !== '' && arg !== 'week' && arg !== 'month') return { text: 'Usage: /typing-stats [week|month|reset]' }
40 const today = dayKey(await $.clock.now())
41
42 return { text: report(await load($), today, arg === '' ? 'default' : arg) }
43 })
44}
45hooks/stats.ts 131 lines1export const KEEP_DAYS = 90
2export const BLOCKS = '▁▂▃▄▅▆▇█'
3const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
4const BAR_WIDTH = 20
5
6/** One local day: prompt count, total characters, prompts per hour (24 buckets). Never the text. */
7export type Day = { count: number; chars: number; hours: number[] }
8export type Days = Record<string, Day>
9
10export function dayKey(ms: number): string {
11 const d = new Date(ms)
12
13 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
14}
15
16// Calendar day number, DST-proof: computed from the key's y/m/d in UTC.
17export function dayNumber(key: string): number {
18 const [y = 0, m = 1, d = 1] = key.split('-').map(Number)
19
20 return Date.UTC(y, m - 1, d) / 86_400_000
21}
22
23function keyOfNumber(n: number): string {
24 return new Date(n * 86_400_000).toISOString().slice(0, 10)
25}
26
27/** The `count` day keys ending today, oldest first. */
28export function lastDays(today: string, count: number): string[] {
29 const t = dayNumber(today)
30
31 return Array.from({ length: count }, (_, i) => keyOfNumber(t - count + 1 + i))
32}
33
34const emptyDay = (): Day => ({ count: 0, chars: 0, hours: Array.from({ length: 24 }, () => 0) })
35
36const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0
37
38/** Whatever the store held, as well-formed days (bad entries dropped). */
39export function normalize(value: unknown): Days {
40 const out: Days = {}
41 if (value === null || typeof value !== 'object' || Array.isArray(value)) return out
42 for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
43 if (!/^\d{4}-\d{2}-\d{2}$/.test(key) || raw === null || typeof raw !== 'object') continue
44 const d = raw as Partial<Day>
45 if (!isNum(d.count) || !isNum(d.chars) || !Array.isArray(d.hours) || d.hours.length !== 24 || !d.hours.every(isNum)) continue
46 out[key] = { count: d.count, chars: d.chars, hours: [...d.hours] }
47 }
48
49 return out
50}
51
52/** Characters of a prompt, counted as code points (an emoji is one). */
53export const promptLength = (text: string) => [...text].length
54
55/** Adds one prompt of `length` characters at local time `ms`; drops days older than KEEP_DAYS. */
56export function record(days: Days, ms: number, length: number): Days {
57 const key = dayKey(ms)
58 const day = days[key] ?? emptyDay()
59 const hours = [...day.hours]
60 const hour = new Date(ms).getHours()
61 hours[hour] = (hours[hour] ?? 0) + 1
62 const next: Days = { ...days, [key]: { count: day.count + 1, chars: day.chars + length, hours } }
63
64 return prune(next, key)
65}
66
67export function prune(days: Days, today: string): Days {
68 const oldest = dayNumber(today) - KEEP_DAYS + 1
69 const out: Days = {}
70 for (const [key, day] of Object.entries(days)) if (dayNumber(key) >= oldest) out[key] = day
71
72 return out
73}
74
75/** 24 columns of ▁..█; an empty hour is ▁, any non-empty hour at least ▂. */
76export function sparkline(values: number[]): string {
77 const max = Math.max(0, ...values)
78
79 return values.map(v => (v <= 0 || max === 0 ? BLOCKS[0] : BLOCKS[Math.max(1, Math.ceil((v / max) * (BLOCKS.length - 1)))])).join('')
80}
81
82export function bar(value: number, max: number): string {
83 if (value <= 0 || max <= 0) return ''
84
85 return '█'.repeat(Math.max(1, Math.round((value / max) * BAR_WIDTH)))
86}
87
88export function busiestHour(hours: number[]): number | undefined {
89 let best: number | undefined
90 hours.forEach((v, h) => {
91 if (v > 0 && (best === undefined || v > (hours[best] ?? 0))) best = h
92 })
93
94 return best
95}
96
97const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
98const hh = (h: number) => `${String(h).padStart(2, '0')}:00`
99
100export type Period = 'default' | 'week' | 'month'
101
102/** The /typing-stats report: today, a per-day bar list, average length and hour histogram over the period. */
103export function report(days: Days, today: string, period: Period): string {
104 const span = period === 'month' ? 30 : 7
105 const keys = lastDays(today, span)
106 const picked = keys.map(k => ({ key: k, day: days[k] ?? emptyDay() }))
107 const count = picked.reduce((s, p) => s + p.day.count, 0)
108 const chars = picked.reduce((s, p) => s + p.day.chars, 0)
109 const hours = Array.from({ length: 24 }, (_, h) => picked.reduce((s, p) => s + (p.day.hours[h] ?? 0), 0))
110 const label = period === 'month' ? 'Last 30 days' : 'Last 7 days'
111 const t = days[today] ?? emptyDay()
112 const lines: string[] = []
113
114 if (period === 'default') {
115 lines.push(`Today: ${plural(t.count, 'prompt')}, ${plural(t.chars, 'character')}${t.count > 0 ? ` (avg ${Math.round(t.chars / t.count)})` : ''}`, '')
116 }
117 lines.push(`${label}: ${plural(count, 'prompt')}`)
118 const max = Math.max(0, ...picked.map(p => p.day.count))
119 for (const p of picked) {
120 const name = WEEKDAYS[new Date(dayNumber(p.key) * 86_400_000).getUTCDay()]
121 const b = bar(p.day.count, max)
122 lines.push(` ${name} ${p.key.slice(5)} ${String(p.day.count).padStart(4)} ${b}`.trimEnd())
123 }
124 lines.push('', `Average prompt length: ${count > 0 ? `${Math.round(chars / count)} characters` : '-'}`)
125 lines.push('', `Hour of day (${label.toLowerCase()}):`, ` ${sparkline(hours)}`, ' 0 6 12 18 23')
126 const busy = busiestHour(hours)
127 lines.push(busy === undefined ? 'Busiest hour: -' : `Busiest hour: ${hh(busy)}-${hh((busy + 1) % 24)} (${plural(hours[busy] ?? 0, 'prompt')})`)
128
129 return lines.join('\n')
130}
131