Consecutive days with a completed turn, in the status line; toasts at 7, 30, 100 and 365 days; /streak summary

Counts consecutive days on which you completed at least one turn.
🔥 N-day streak (🔥 1 day for a single day); hidden when the streak is 0.🔥 N-day streak! when a turn crosses 7, 30, 100 or 365 days.| Command | Effect |
|---|---|
/streak | Shows current streak, longest streak and total active days |
claude --plugin-dir /path/to/ModsTools/mods/streak
claude plugin validate mods/streak
claude plugin test mods/streak # 13 testshooks/register.ts 45 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { addDay, crossedMilestone, dayKey, stats, statusLine, summary } from './days'
4
5const KEY = 'days'
6
7async function load($: EngineInterface): Promise<string[]> {
8 const value = await $.store.get(KEY)
9
10 return Array.isArray(value) ? (value as string[]) : []
11}
12
13async function show($: EngineInterface, days: string[]): Promise<void> {
14 $.ui.status(statusLine(stats(days, dayKey(await $.clock.now())).current))
15}
16
17export const register: Register = on => {
18 on('session.start', async ($, e, next) => {
19 await $.command.register({ name: 'streak', description: 'Streak of consecutive active days: current, longest, total' })
20 await show($, await load($))
21
22 return next(e)
23 })
24
25 on('turn.complete', async ($, e, next) => {
26 if (e.agentId === undefined) {
27 const today = dayKey(await $.clock.now())
28 const days = await load($)
29 const before = stats(days, today).current
30 const updated = addDay(days, today)
31 const after = stats(updated, today).current
32 await $.store.set(KEY, updated)
33 await show($, updated)
34 const milestone = crossedMilestone(before, after)
35 if (milestone !== undefined) $.ui.toast(`🔥 ${milestone}-day streak!`)
36 }
37
38 return next(e)
39 })
40
41 on('command.run', { command: 'streak' }, async $ => ({
42 text: summary(stats(await load($), dayKey(await $.clock.now()))),
43 }))
44}
45hooks/days.ts 57 lines1export const KEEP_DAYS = 400
2export const MILESTONES = [7, 30, 100, 365]
3
4export type Stats = { current: number; longest: number; total: number }
5
6export function dayKey(ms: number): string {
7 const d = new Date(ms)
8
9 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
10}
11
12// Calendar day number, DST-proof: computed from the key's y/m/d in UTC.
13export function dayNumber(key: string): number {
14 const [y = 0, m = 1, d = 1] = key.split('-').map(Number)
15
16 return Date.UTC(y, m - 1, d) / 86_400_000
17}
18
19export function addDay(days: string[], key: string): string[] {
20 const set = new Set(days)
21 set.add(key)
22
23 return [...set].sort().slice(-KEEP_DAYS)
24}
25
26export function stats(days: string[], today: string): Stats {
27 const nums = [...new Set(days.map(dayNumber))].sort((a, b) => a - b)
28 let longest = 0
29 let run = 0
30 let prev = Number.NaN
31 for (const n of nums) {
32 run = n === prev + 1 ? run + 1 : 1
33 longest = Math.max(longest, run)
34 prev = n
35 }
36 // The streak is still alive when its last day is today or yesterday.
37 const last = nums[nums.length - 1]
38 const t = dayNumber(today)
39 const current = last !== undefined && (last === t || last === t - 1) ? run : 0
40
41 return { current, longest, total: nums.length }
42}
43
44export function crossedMilestone(before: number, after: number): number | undefined {
45 return [...MILESTONES].reverse().find(m => before < m && after >= m)
46}
47
48export function statusLine(current: number): string | undefined {
49 if (current <= 0) return undefined
50
51 return current === 1 ? '🔥 1 day' : `🔥 ${current}-day streak`
52}
53
54export function summary(s: Stats): string {
55 return [`Current streak: ${s.current} day${s.current === 1 ? '' : 's'}`, `Longest streak: ${s.longest}`, `Active days: ${s.total}`].join('\n')
56}
57