Diario e calendario del lavoro fatto con Claude: settimana, mese e giornata, con i riassunti

Un diario del lavoro fatto con Claude: calendario del mese con le ore di ogni giorno e un resoconto breve della giornata e della settimana, ricavati dai transcript di Claude Code e dai tuoi commit.
/diary apre il pannello «Diario». In alto il calendario del mese: ogni giorno con le ore di lavoro, colorato da chiaro a arancione secondo il tempo, ● su oggi. Sotto, la settimana del giorno scelto (totale, resoconto, ore per progetto) e il giorno stesso (resoconto, poi per ogni progetto le sessioni per titolo e gli ultimi commit).
| Tasto | Cosa fa |
|---|---|
a / d | Giorno prima / giorno dopo |
w / s | Settimana prima / settimana dopo |
p / n | Mese prima / mese dopo |
t | Oggi, rileggendo i transcript |
r | Riscrive i resoconti del giorno e della settimana |
Un giorno del calendario si sceglie anche con un clic. Il resoconto è di 3-5 righe per il giorno e 3-6 per la settimana, in seconda persona («hai…»). Finché non c'è: «Resoconto non ancora scritto: premi r.»
Nulla in sottofondo: lavora solo a pannello aperto.
/diary o premi t: rilegge i transcript degli ultimi giorni./diary, premi t o scegli un giorno: scrive i resoconti che mancano o non sono più aggiornati, prima quello del giorno, poi quello della settimana, poi gli altri giorni della settimana. Un resoconto si riscrive quando cambiano i fatti: nuove sessioni, titoli o commit, oppure il tempo di lavoro passa una soglia di mezz'ora. Basta aprire il pannello perché partano le richieste a haiku, fino a otto.python3 -I hooks/scan.py ~/.claude/projects <giorno>. Lo script legge i transcript (~/.claude/projects/*/*.jsonl) e ne tira fuori solo fatti: orari, numero dei tuoi messaggi e dei comandi Bash, titolo della sessione, cartella, ramo, percorsi dei file modificati. Mai il testo dei messaggi o l'output degli strumenti.git -C <cartella> rev-parse --path-format=absolute --git-common-dir, git config user.email e git log --branches --remotes --tags --no-merges --author=<tua email> sui repository delle sessioni.haiku, con $.model.complete. Gli arrivano: data e ore, nomi dei progetti e dei rami, titoli delle sessioni, numero di messaggi e comandi, nomi (non percorsi) di al massimo 8 file per sessione, oggetto di al massimo 25 commit. Per la settimana, i resoconti dei giorni già scritti al posto dei loro fatti.summaries), per giorno e per settimana, nello stato della sessione e nello store della mod ($.store, un file JSON nella cartella di configurazione di Claude Code), così restano tra una sessione e l'altra. Un resoconto viene riscritto solo se i fatti del giorno sono cambiati. Quelli più vecchi di 31 giorni si cancellano.python3.git 2.31 o successivo, per i commit e per riconoscere i repository; senza, il diario mostra solo le sessioni.git config user.email del repository; un commit visto su due rami conta una volta.git 10 secondi; ogni resoconto 60 secondi e al massimo 600 token.claude plugin marketplace add Wowbagger1994/claude-mods
claude plugin install work-diary@enrik-mods
Oppure, dal prompt di Claude Code: /plugin install work-diary --marketplace Wowbagger1994/claude-mods.
Dalla cartella della mod:
claude plugin validate .
claude plugin test .
python3 -I hooks/scan_test.py
Oggi i test sono 35 in TypeScript e 4 in Python.
hooks/register.tsx 202 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Day, DayFacts, Summary } from '../types'
5import { mondayOf, shiftDay, shiftMonth, weekOf } from './calendar'
6import { daysOf, factsKey, factsOf, parseScan } from './facts'
7import { cleanReply, DAY_LINES, dayKey, dayPrompt, KEEP_DAYS, pruneSummaries, SYSTEM, WEEK_LINES, weekKey, weekPrompt } from './summary'
8import type { Actions, DiaryState } from './view'
9import { diaryView } from './view'
10
11const PLUGIN = 'work-diary'
12const PANE = 'work-diary'
13const TITLE = 'Diario'
14const SCAN_TIMEOUT_MS = 120_000
15const SUMMARY_TIMEOUT_MS = 60_000
16const SUMMARY_MAX_TOKENS = 600
17const DAY_MS = 86_400_000
18
19const summaries = atom({ plugin: 'work-diary', key: 'summaries' } as const, {})
20/** Where the summaries outlive the session: `$.state` is the session's, `$.store` the machine's. */
21const STORE_SUMMARIES = 'summaries'
22
23const isSummary = (v: unknown): v is Summary =>
24 typeof v === 'object' && v !== null && typeof (v as Summary).text === 'string' && typeof (v as Summary).factsKey === 'string' && typeof (v as Summary).at === 'number'
25
26/** What the store holds, as far as it is summaries. */
27const savedSummaries = (raw: unknown): Record<string, Summary> =>
28 typeof raw !== 'object' || raw === null ? {} : Object.fromEntries(Object.entries(raw).filter(([, v]) => isSummary(v)))
29
30/** Changes the summaries, and keeps them for the next sessions. */
31const setSummaries = async ($: EngineInterface, fn: (all: Readonly<Record<string, Summary>>) => Record<string, Summary>) => {
32 await update($, summaries, fn)
33 // Another session may have written its own meanwhile: kept too, this session's winning; the old ones go.
34 const merged = { ...savedSummaries(await $.store.get(STORE_SUMMARIES)), ...(await read($, summaries)) }
35 await $.store.set(STORE_SUMMARIES, today === null ? merged : pruneSummaries(merged, today))
36}
37
38/** What the last reading of the transcripts found, and what the pane shows: this session's, not kept. */
39let days: ReadonlyMap<Day, DayFacts> = new Map()
40let today: Day | null = null
41let selected: Day | null = null
42let isScanning = false
43let error: string | null = null
44const writing = new Set<string>()
45/** A summary asked for while others are being written: the writer goes round once more. */
46let isWriting = false
47let isWriteAgain = false
48/** Summaries `r` asked to write anew, by key: kept until written, whatever is selected meanwhile. */
49const forced = new Set<string>()
50
51const hasWork = (f: Pick<DayFacts, 'sessions' | 'commits'>) => f.sessions.length > 0 || f.commits.length > 0
52
53/** One summary to write: its key, the facts it stands on, how to ask for it. */
54type Job = { key: string; facts: DayFacts[]; prompt: () => Promise<string>; lines: number }
55
56/** The selected day first, then its week, then the week's other days, so a press on them finds them written. */
57const jobsFor = async ($: EngineInterface, day: Day): Promise<Job[]> => {
58 const week = weekOf(day).filter(d => today === null || d <= today).map(d => factsOf(days, d))
59 const dayJob = (f: DayFacts): Job => ({ key: dayKey(f.day), facts: [f], prompt: async () => dayPrompt(f), lines: DAY_LINES })
60 const selectedFacts = factsOf(days, day)
61 const weekJob: Job = { key: weekKey(mondayOf(day)), facts: week, prompt: async () => weekPrompt(week, await read($, summaries)), lines: WEEK_LINES }
62 return [dayJob(selectedFacts), weekJob, ...week.filter(f => f.day !== day).map(dayJob)].filter(j => j.facts.some(hasWork))
63}
64
65const writeOne = async ($: EngineInterface, job: Job) => {
66 const key = factsKey(job.facts)
67 const known = (await read($, summaries))[job.key]
68 if (!forced.has(job.key) && known?.factsKey === key) return
69 forced.delete(job.key)
70 writing.add(job.key)
71 $.ui.invalidate('ui.render')
72 try {
73 const reply = await $.model.complete({ model: 'haiku', system: SYSTEM, prompt: await job.prompt(), maxTokens: SUMMARY_MAX_TOKENS, timeoutMs: SUMMARY_TIMEOUT_MS })
74 const text = reply.isAnswered ? cleanReply(reply.text, job.lines) : null
75 if (text === null) {
76 $.ui.log(`${PLUGIN}: riassunto ${job.key} non scritto: ${reply.isAnswered ? 'risposta vuota' : reply.reason}`, { to: 'debug' })
77 return
78 }
79 const at = await $.clock.now()
80 await setSummaries($, all => ({ ...all, [job.key]: { text, factsKey: key, at } }))
81 } finally {
82 writing.delete(job.key)
83 $.ui.invalidate('ui.render')
84 }
85}
86
87/** Writes the summaries the pane shows that are missing or stale, and those `r` asked for anew. */
88const writeSummaries = async ($: EngineInterface) => {
89 if (isWriting) {
90 isWriteAgain = true
91 return
92 }
93 isWriting = true
94 try {
95 do {
96 isWriteAgain = false
97 const day = selected ?? today
98 if (day === null) break
99 for (const job of await jobsFor($, day)) {
100 if (isWriteAgain) break
101 // One summary failing leaves the others to write.
102 await writeOne($, job).catch((cause: unknown) =>
103 $.ui.log(`${PLUGIN}: riassunto ${job.key} non scritto: ${String(cause)}`, { to: 'debug' }),
104 )
105 }
106 } while (isWriteAgain)
107 } catch (cause) {
108 $.ui.log(`${PLUGIN}: riassunti non scritti: ${String(cause)}`, { to: 'debug' })
109 } finally {
110 isWriting = false
111 }
112}
113
114/** Reads the last 30 days of transcripts again, then writes what summaries are missing. */
115const refresh = async ($: EngineInterface) => {
116 if (isScanning) return
117 isScanning = true
118 error = null
119 $.ui.invalidate('ui.render')
120 try {
121 const home = await $.env.get('HOME')
122 if (home === undefined) throw new Error('HOME non impostata')
123 const now = await $.clock.now()
124 const since = new Date(now - KEEP_DAYS * DAY_MS).toISOString().slice(0, 10)
125 const ran = await $.process.run(['python3', '-I', `${$.plugin.root}/hooks/scan.py`, `${home}/.claude/projects`, since], { timeoutMs: SCAN_TIMEOUT_MS })
126 const scan = ran.exitCode === 0 ? parseScan(ran.stdout) : null
127 if (scan === null) throw new Error(ran.stderr.trim().split('\n').pop() || `scan.py è uscito con ${ran.exitCode}`)
128 days = daysOf(scan)
129 today = scan.today
130 selected = selected ?? scan.today
131 await setSummaries($, all => pruneSummaries(all, scan.today))
132 } catch (cause) {
133 error = `Transcript non letti: ${cause instanceof Error ? cause.message : String(cause)}`
134 $.ui.log(`${PLUGIN}: ${error}`, { to: 'debug' })
135 } finally {
136 isScanning = false
137 $.ui.invalidate('ui.render')
138 }
139 if (error === null) await writeSummaries($)
140}
141
142const select = ($: EngineInterface, day: Day) => {
143 selected = day
144 $.ui.invalidate('ui.render')
145 void writeSummaries($)
146}
147
148const actionsFor = ($: EngineInterface): Actions => ({
149 select: day => select($, day),
150 shift: n => {
151 const day = selected ?? today
152 if (day !== null) select($, shiftMonth(day, n))
153 },
154 move: n => {
155 const day = selected ?? today
156 if (day !== null) select($, shiftDay(day, n))
157 },
158 // Today as the clock says it, past a midnight too: the transcripts are read again.
159 today: () => {
160 selected = null
161 return refresh($)
162 },
163 rewrite: async () => {
164 const day = selected ?? today
165 if (day === null) return
166 forced.add(dayKey(day))
167 forced.add(weekKey(mondayOf(day)))
168 await writeSummaries($)
169 },
170})
171
172export const register: Register = on => {
173 on('session.start', async ($, e, next) => {
174 await $.command.register({ name: 'diary', description: 'Il lavoro fatto con Claude: giorno, settimana e mese, con i riassunti' })
175 // The summaries of earlier sessions: this session's own win where both have one.
176 const saved = savedSummaries(await $.store.get(STORE_SUMMARIES))
177 await update($, summaries, all => ({ ...saved, ...all }))
178 return next(e)
179 })
180
181 on('command.run', { command: 'diary' }, async $ => {
182 await $.ui.open({ id: PANE, title: TITLE, focus: true }).catch(() => $.ui.open({ id: PANE, title: TITLE }))
183 void refresh($)
184 return { text: 'Diario aperto: clicca un giorno, oppure a/d giorno, w/s settimana, p/n mese, t oggi.' }
185 })
186
187 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
188 const state: DiaryState = {
189 columns: Math.max(40, e.props.bodyColumns),
190 selected: selected ?? today ?? new Date(await $.clock.now()).toISOString().slice(0, 10),
191 today,
192 oldest: today === null ? null : shiftDay(today, -(KEEP_DAYS - 1)),
193 days,
194 summaries: await read($, summaries),
195 writing,
196 isScanning,
197 error,
198 }
199 return diaryView($.ui.resolve(e), state, actionsFor($))
200 })
201}
202hooks/calendar.ts 90 lines1import type { Day } from '../types'
2
3const DAY_MS = 86_400_000
4const DAY_RE = /^(\d{4})-(\d{2})-(\d{2})$/
5
6export const WEEKDAYS = ['lun', 'mar', 'mer', 'gio', 'ven', 'sab', 'dom'] as const
7const WEEKDAY_NAMES = ['lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato', 'domenica'] as const
8const MONTHS = ['gennaio', 'febbraio', 'marzo', 'aprile', 'maggio', 'giugno', 'luglio', 'agosto', 'settembre', 'ottobre', 'novembre', 'dicembre'] as const
9
10export const isDay = (text: unknown): text is Day => typeof text === 'string' && DAY_RE.test(text)
11
12/** Days are counted on UTC midnights: no time zone or daylight saving shifts them. */
13const msOf = (day: Day): number => {
14 const [, y, m, d] = DAY_RE.exec(day) ?? []
15 return Date.UTC(Number(y), Number(m) - 1, Number(d))
16}
17const dayOfMs = (ms: number): Day => new Date(ms).toISOString().slice(0, 10)
18
19export const shiftDay = (day: Day, n: number): Day => dayOfMs(msOf(day) + n * DAY_MS)
20
21/** 0 Monday … 6 Sunday. */
22export const weekdayOf = (day: Day): number => (new Date(msOf(day)).getUTCDay() + 6) % 7
23
24export const mondayOf = (day: Day): Day => shiftDay(day, -weekdayOf(day))
25
26/** The seven days, Monday to Sunday, of the week holding `day`. */
27export const weekOf = (day: Day): Day[] => Array.from({ length: 7 }, (_, i) => shiftDay(mondayOf(day), i))
28
29export const firstOfMonth = (day: Day): Day => `${day.slice(0, 7)}-01`
30
31/** The same day of another month, or that month's last when it is shorter. */
32export const shiftMonth = (day: Day, n: number): Day => {
33 const date = new Date(msOf(day))
34 const first = Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + n, 1)
35 const last = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + n + 1, 0)).getUTCDate()
36 return dayOfMs(first + (Math.min(date.getUTCDate(), last) - 1) * DAY_MS)
37}
38
39/** The month as rows of seven, Monday first; null outside it. */
40export const monthGrid = (day: Day): (Day | null)[][] => {
41 const first = firstOfMonth(day)
42 const month = first.slice(0, 7)
43 const start = mondayOf(first)
44 const rows: (Day | null)[][] = []
45 for (let week = start; week.slice(0, 7) <= month || rows.length === 0; week = shiftDay(week, 7)) {
46 rows.push(weekOf(week).map(d => (d.slice(0, 7) === month ? d : null)))
47 }
48 return rows
49}
50
51export const dayNumber = (day: Day): number => Number(day.slice(8, 10))
52const monthName = (day: Day): string => MONTHS[Number(day.slice(5, 7)) - 1] ?? ''
53
54/** `sab 10 ottobre`. */
55export const dayLabel = (day: Day): string => `${WEEKDAYS[weekdayOf(day)]} ${dayNumber(day)} ${monthName(day)}`
56
57/** `sabato 10 ottobre`. */
58export const longDayLabel = (day: Day): string => `${WEEKDAY_NAMES[weekdayOf(day)]} ${dayNumber(day)} ${monthName(day)}`
59
60/** `6 – 12 ottobre 2026`, or `29 settembre – 5 ottobre 2026` across two months. */
61export const weekLabel = (day: Day): string => {
62 const days = weekOf(day)
63 const [first, last] = [days[0]!, days[6]!]
64 const year = last.slice(0, 4)
65 return first.slice(0, 7) === last.slice(0, 7)
66 ? `${dayNumber(first)} – ${dayNumber(last)} ${monthName(last)} ${year}`
67 : `${dayNumber(first)} ${monthName(first)} – ${dayNumber(last)} ${monthName(last)} ${year}`
68}
69
70/** `ottobre 2026`. */
71export const monthLabel = (day: Day): string => `${monthName(day)} ${day.slice(0, 4)}`
72
73/** `2h15`, `45m`, `3h`: a calendar cell's width. */
74export const shortMinutes = (minutes: number): string => {
75 const whole = Math.max(0, Math.round(minutes))
76 const hours = Math.floor(whole / 60)
77 const rest = whole % 60
78 if (hours === 0) return `${rest}m`
79 return rest === 0 ? `${hours}h` : `${hours}h${String(rest).padStart(2, '0')}`
80}
81
82/** `2h 15m`, `45m`, `0m`. */
83export const formatMinutes = (minutes: number): string => {
84 const whole = Math.max(0, Math.round(minutes))
85 const hours = Math.floor(whole / 60)
86 const rest = whole % 60
87 if (hours === 0) return `${rest}m`
88 return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`
89}
90hooks/facts.ts 153 lines1import type { Commit, Day, DayFacts, ProjectTime, Scan, SessionDay, Span } from '../types'
2import { formatMinutes, isDay } from './calendar'
3
4const WORKTREE = /\/\.claude\/worktrees\/[^/]+(?:\/.*)?$/
5const JOB = /\/\.claude\/jobs\/([^/]+)(?:\/.*)?$/
6const MINUTE_MS = 60_000
7
8const isString = (v: unknown): v is string => typeof v === 'string'
9const isCount = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0
10const isSpan = (v: unknown): v is Span => Array.isArray(v) && v.length === 2 && v.every(isString)
11
12const isSession = (v: unknown): v is SessionDay => {
13 if (typeof v !== 'object' || v === null) return false
14 const s = v as Record<string, unknown>
15 return (
16 isString(s.sessionId) && isDay(s.day) && isString(s.project) && isString(s.repo) && (s.branch === null || isString(s.branch)) &&
17 (s.title === null || isString(s.title)) && isString(s.start) && isString(s.end) && isCount(s.activeMinutes) &&
18 Array.isArray(s.spans) && s.spans.every(isSpan) && isCount(s.prompts) &&
19 Array.isArray(s.edits) && s.edits.every(isString) && isCount(s.commands)
20 )
21}
22
23const isCommit = (v: unknown): v is Commit => {
24 if (typeof v !== 'object' || v === null) return false
25 const c = v as Record<string, unknown>
26 return isString(c.project) && isDay(c.day) && isString(c.time) && isString(c.hash) && isString(c.subject)
27}
28
29/** The scanner's output, what of it is well formed; null when it is no scan at all. */
30export const parseScan = (text: string): Scan | null => {
31 let raw: unknown
32 try {
33 raw = JSON.parse(text)
34 } catch {
35 return null
36 }
37 if (typeof raw !== 'object' || raw === null) return null
38 const o = raw as Record<string, unknown>
39 if (!isDay(o.today) || !Array.isArray(o.sessions) || !Array.isArray(o.commits)) return null
40 return { today: o.today, sessions: o.sessions.filter(isSession), commits: o.commits.filter(isCommit) }
41}
42
43/** The repository a session's directory belongs to: a worktree counts as its repository. */
44export const repoOf = (path: string): string => path.replace(WORKTREE, '')
45
46/** What the calendar calls a project: the repository's folder; a background job's scratch space, `job <id>`. */
47export const projectName = (path: string): string => {
48 const job = JOB.exec(path)
49 if (job !== null) return `job ${job[1]}`
50 const parts = repoOf(path).split('/').filter(p => p !== '')
51 return parts[parts.length - 1] ?? '/'
52}
53
54/** Minutes covered by the spans together: where they overlap, once. */
55export const unionMinutes = (spans: readonly Span[]): number => {
56 const ranges = spans
57 .map(([a, b]) => [Date.parse(a), Date.parse(b)] as const)
58 .filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b) && b >= a)
59 .sort((x, y) => x[0] - y[0])
60 let total = 0
61 let open: [number, number] | null = null
62 for (const [a, b] of ranges) {
63 if (open !== null && a <= open[1]) open = [open[0], Math.max(open[1], b)]
64 else {
65 if (open !== null) total += open[1] - open[0]
66 open = [a, b]
67 }
68 }
69 if (open !== null) total += open[1] - open[0]
70 return Math.round(total / MINUTE_MS)
71}
72
73/** The same commit seen on two branches (a rebase, a cherry-pick: they keep the author's time) counts once. */
74export const uniqueCommits = (commits: readonly Commit[]): Commit[] => {
75 const sorted = [...commits].sort((a, b) => `${a.day} ${a.time}`.localeCompare(`${b.day} ${b.time}`))
76 const seen = new Set<string>()
77 return sorted.filter(c => {
78 const key = `${c.project}\n${c.day} ${c.time}\n${c.subject}`
79 if (seen.has(key)) return false
80 seen.add(key)
81 return true
82 })
83}
84
85/** Most minutes first; as long, most commits; then by name. */
86const byTime = (a: ProjectTime, b: ProjectTime): number => b.minutes - a.minutes || b.commits - a.commits || a.name.localeCompare(b.name)
87
88export const projectTimes = (sessions: readonly SessionDay[], commits: readonly Commit[]): ProjectTime[] => {
89 const names = [...new Set([...sessions.map(s => projectName(s.repo)), ...commits.map(c => projectName(c.project))])]
90 return names
91 .map(name => {
92 const own = sessions.filter(s => projectName(s.repo) === name)
93 return {
94 name,
95 minutes: unionMinutes(own.flatMap(s => s.spans)),
96 sessions: own.length,
97 commits: commits.filter(c => projectName(c.project) === name).length,
98 }
99 })
100 .sort(byTime)
101}
102
103/** `shop 1h 30m`; a project with commits and no session of its own, `shop 3 commit`. */
104export const projectLabel = (p: ProjectTime): string =>
105 `${p.name} ${p.minutes > 0 || p.commits === 0 ? formatMinutes(p.minutes) : `${p.commits} commit`}`
106
107export const emptyDay = (day: Day): DayFacts => ({ day, activeMinutes: 0, projects: [], sessions: [], commits: [] })
108
109/** The scan by day. */
110export const daysOf = (scan: Scan): Map<Day, DayFacts> => {
111 const commits = uniqueCommits(scan.commits)
112 const days = [...new Set([...scan.sessions.map(s => s.day), ...commits.map(c => c.day)])]
113 return new Map(
114 days.map(day => {
115 const sessions = scan.sessions.filter(s => s.day === day).sort((a, b) => Date.parse(a.start) - Date.parse(b.start))
116 const own = commits.filter(c => c.day === day)
117 return [day, { day, activeMinutes: unionMinutes(sessions.flatMap(s => s.spans)), projects: projectTimes(sessions, own), sessions, commits: own }]
118 }),
119 )
120}
121
122export const factsOf = (days: ReadonlyMap<Day, DayFacts>, day: Day): DayFacts => days.get(day) ?? emptyDay(day)
123
124/** Several days as one: a week's total and its projects. */
125export const mergeDays = (facts: readonly DayFacts[]): Omit<DayFacts, 'day'> => {
126 const sessions = facts.flatMap(f => f.sessions)
127 const commits = facts.flatMap(f => f.commits)
128 return {
129 activeMinutes: facts.reduce((sum, f) => sum + f.activeMinutes, 0),
130 projects: projectTimes(sessions, commits).map(p => ({
131 ...p,
132 // A week's project time is its days' added: the union is taken within each day.
133 minutes: facts.reduce((sum, f) => sum + (f.projects.find(q => q.name === p.name)?.minutes ?? 0), 0),
134 })).sort(byTime),
135 sessions,
136 commits,
137 }
138}
139
140/** Half-hour steps of time: today's summary is not stale at every message. */
141const STALE_STEP_MINUTES = 30
142
143/** Changes when the facts a summary is written from change. */
144export const factsKey = (facts: readonly DayFacts[]): string =>
145 JSON.stringify(
146 facts.map(f => [
147 f.day,
148 Math.floor(f.activeMinutes / STALE_STEP_MINUTES),
149 f.sessions.map(s => [s.sessionId, s.title]),
150 f.commits.map(c => c.hash),
151 ]),
152 )
153hooks/summary.ts 82 lines1import type { Day, DayFacts, Summary } from '../types'
2import { dayLabel, formatMinutes, shiftDay, weekLabel } from './calendar'
3import { projectLabel, projectName } from './facts'
4
5export const DAY_LINES = 5
6export const WEEK_LINES = 6
7/** Summaries older than this go: transcripts are kept 30 days, the diary no longer. */
8export const KEEP_DAYS = 31
9const MAX_FILES = 8
10const MAX_COMMITS = 25
11
12/** A long random-looking run (a token, a key) never reaches the summary's model. */
13const RANDOM_RUN = /[A-Za-z0-9_\-+/=]{32,}/g
14export const scrub = (text: string): string => text.replace(RANDOM_RUN, '…')
15
16export const dayKey = (day: Day): string => `day:${day}`
17export const weekKey = (monday: Day): string => `week:${monday}`
18
19const basename = (path: string): string => path.split('/').filter(p => p !== '').pop() ?? path
20const clock = (iso: string): string => iso.slice(11, 16)
21
22/** The day's facts as the model reads them: no message's text, only what the calendar knows. */
23export const dayFactsText = (f: DayFacts): string => {
24 const sessions = f.sessions.map(s => {
25 const files = s.edits.map(basename)
26 const shown = files.slice(0, MAX_FILES).join(', ') + (files.length > MAX_FILES ? ` e altri ${files.length - MAX_FILES}` : '')
27 return [
28 `- ${clock(s.start)}–${clock(s.end)} ${projectName(s.repo)}${s.branch === null ? '' : ` (${s.branch})`}: «${s.title ?? 'senza titolo'}»`,
29 ` ${formatMinutes(s.activeMinutes)} attivi, ${s.prompts} messaggi, ${s.commands} comandi${files.length > 0 ? `, file modificati: ${shown}` : ''}`,
30 ].join('\n')
31 })
32 const commits = f.commits.slice(0, MAX_COMMITS).map(c => `- ${c.time} ${projectName(c.project)}: ${c.subject}`)
33 return scrub(
34 [
35 `${dayLabel(f.day)}: ${formatMinutes(f.activeMinutes)} di lavoro con Claude`,
36 `Progetti: ${f.projects.map(projectLabel).join(', ') || 'nessuno'}`,
37 `Sessioni:\n${sessions.join('\n') || '- nessuna'}`,
38 `Commit:\n${commits.join('\n') || '- nessuno'}${f.commits.length > MAX_COMMITS ? `\n- e altri ${f.commits.length - MAX_COMMITS}` : ''}`,
39 ].join('\n'),
40 )
41}
42
43export const SYSTEM =
44 'Scrivi il diario di lavoro di uno sviluppatore, in italiano, alla seconda persona («hai…»). ' +
45 'Usa solo i fatti che ti do: non inventare attività, cause o esiti. Frasi corte, per temi, niente titoli né markdown.'
46
47export const dayPrompt = (f: DayFacts): string =>
48 `${dayFactsText(f)}\n\nScrivi da 3 a ${DAY_LINES} righe su che cosa hai fatto in questa giornata, una per tema, ` +
49 'ognuna che inizia con «• ». Raggruppa le sessioni dello stesso lavoro.'
50
51/** The week: each day's summary when there is one, its facts otherwise. */
52export const weekPrompt = (days: readonly DayFacts[], summaries: Readonly<Record<string, Summary>>): string => {
53 const parts = days
54 .filter(d => d.sessions.length > 0 || d.commits.length > 0)
55 .map(d => {
56 const written = summaries[dayKey(d.day)]
57 return written === undefined ? dayFactsText(d) : `${dayLabel(d.day)} (${formatMinutes(d.activeMinutes)}):\n${written.text}`
58 })
59 const monday = days[0]?.day ?? ''
60 return (
61 `Settimana ${weekLabel(monday)}.\n\n${parts.join('\n\n')}\n\nScrivi da 3 a ${WEEK_LINES} righe sulla settimana: ` +
62 'i filoni di lavoro, che cosa è andato avanti, che cosa è rimasto aperto se i fatti lo dicono. Ognuna inizia con «• ».'
63 )
64}
65
66/** The model's reply as the diary shows it: its bullet lines, at most `max`. */
67export const cleanReply = (text: string, max: number): string | null => {
68 const lines = text
69 .split('\n')
70 .map(l => l.trim().replace(/^(?:[-*•]|\d+[.)])\s*/, ''))
71 .filter(l => l !== '' && !l.startsWith('#'))
72 .slice(0, max)
73 .map(l => `• ${l}`)
74 return lines.length === 0 ? null : lines.join('\n')
75}
76
77/** The summaries still worth keeping: the last KEEP_DAYS days'. */
78export const pruneSummaries = (all: Readonly<Record<string, Summary>>, today: Day): Record<string, Summary> => {
79 const oldest = shiftDay(today, -KEEP_DAYS)
80 return Object.fromEntries(Object.entries(all).filter(([key]) => key.slice(key.indexOf(':') + 1) >= oldest))
81}
82hooks/view.tsx 55 lines1import type { ElementTable } from 'claude-code'
2
3import type { Day, DayFacts, Summary } from '../types'
4import { monthCalendar } from './month'
5import { dayReport, weekReport } from './report'
6
7export type Actions = {
8 select: (day: Day) => unknown
9 /** One month back (-1) or ahead (1). */
10 shift: (n: -1 | 1) => unknown
11 /** Days back (negative) or ahead: a day with a and d, a week with w and s. */
12 move: (days: number) => unknown
13 today: () => unknown
14 rewrite: () => unknown
15}
16
17export type DiaryState = {
18 selected: Day
19 /** Null before the first reading of the transcripts. */
20 today: Day | null
21 /** The first day the transcripts still cover. */
22 oldest: Day | null
23 days: ReadonlyMap<Day, DayFacts>
24 summaries: Readonly<Record<string, Summary>>
25 /** Summaries Claude is writing now, by key. */
26 writing: ReadonlySet<string>
27 isScanning: boolean
28 error: string | null
29 /** Cells across the pane. */
30 columns: number
31}
32
33const keysBar = (el: ElementTable, act: Actions) => (
34 <el.Box key="keys" flexDirection="row" columnGap={2} flexWrap="wrap">
35 <el.Button key="day-back" label="giorno prima" hotkey="a" plain dimColor onPress={() => act.move(-1)} />
36 <el.Button key="day-on" label="giorno dopo" hotkey="d" plain dimColor onPress={() => act.move(1)} />
37 <el.Button key="week-back" label="settimana prima" hotkey="w" plain dimColor onPress={() => act.move(-7)} />
38 <el.Button key="week-on" label="settimana dopo" hotkey="s" plain dimColor onPress={() => act.move(7)} />
39 <el.Button key="today" label="oggi" hotkey="t" plain dimColor onPress={() => act.today()} />
40 <el.Button key="rewrite" label="riscrivi i resoconti" hotkey="r" plain dimColor onPress={() => act.rewrite()} />
41 </el.Box>
42)
43
44/** The diary: the month as a calendar, the selected day's week, then the day itself. */
45export const diaryView = (el: ElementTable, s: DiaryState, act: Actions) => (
46 <el.Box flexDirection="column" gap={1}>
47 {s.isScanning && <el.Text dimColor italic>Leggo i transcript degli ultimi 30 giorni…</el.Text>}
48 {s.error !== null && <el.Text color="error">{s.error}</el.Text>}
49 {monthCalendar(el, s, act)}
50 {keysBar(el, act)}
51 {weekReport(el, s)}
52 {dayReport(el, s)}
53 </el.Box>
54)
55hooks/month.tsx 104 lines1import type { ElementTable } from 'claude-code'
2
3import type { Day } from '../types'
4import { dayNumber, monthGrid, monthLabel, shortMinutes, WEEKDAYS, weekOf } from './calendar'
5import { factsOf } from './facts'
6import type { Actions, DiaryState } from './view'
7
8const MIN_CELL = 6
9const MAX_CELL = 12
10/** The arrow left of a week: the one the report is about. */
11const GUTTER = 2
12/** Cells by time worked, light to Claude's orange; the text on them dark, to read on any theme. */
13export const HEAT = [
14 { upTo: 0, bg: undefined },
15 { upTo: 60, bg: '#f6e0d4' },
16 { upTo: 180, bg: '#efbfa5' },
17 { upTo: 360, bg: '#e69a74' },
18 { upTo: Infinity, bg: '#d97757' },
19] as const
20const ON_HEAT = '#1f1410'
21
22export const heatOf = (minutes: number): string | undefined => HEAT.find(level => minutes <= level.upTo)?.bg
23
24/** Cells as wide as the pane allows, seven to a row and a column apart. */
25export const cellWidth = (columns: number): number =>
26 // Less the gutter, the six gaps, the frame and its padding.
27 Math.max(MIN_CELL, Math.min(MAX_CELL, Math.floor((columns - GUTTER - 6 - 4) / 7)))
28
29const pad = (text: string, width: number) => text.padEnd(width).slice(0, width)
30
31type Cell = { day: Day; minutes: number; isSelected: boolean; isToday: boolean; isFuture: boolean }
32
33/** Each week is two rows: the days' numbers (pressable) over their time. */
34const weekRows = (el: ElementTable, cells: (Cell | null)[], w: number, isSelectedWeek: boolean, act: Actions, r: number) => {
35 const style = (c: Cell) => {
36 const bg = heatOf(c.minutes)
37 return c.isSelected
38 ? { inverse: true, bold: true }
39 : { backgroundColor: bg, color: bg === undefined ? undefined : ON_HEAT, dimColor: bg === undefined && c.isFuture }
40 }
41 const gutter = (text: string) => <el.Text color="claude" bold>{pad(text, GUTTER)}</el.Text>
42 return [
43 <el.Box key={`days:${r}`} flexDirection="row" columnGap={1}>
44 {gutter(isSelectedWeek ? '▸' : '')}
45 {cells.map((c, i) =>
46 c === null ? (
47 <el.Text key={`blank:${r}:${i}`}>{pad('', w)}</el.Text>
48 ) : (
49 <el.Button key={`cell:${c.day}`} plain onPress={() => act.select(c.day)}>
50 <el.Text {...style(c)}>{pad(` ${dayNumber(c.day)}${c.isToday ? ' ●' : ''}`, w)}</el.Text>
51 </el.Button>
52 ),
53 )}
54 </el.Box>,
55 <el.Box key={`times:${r}`} flexDirection="row" columnGap={1}>
56 {gutter('')}
57 {cells.map((c, i) => (
58 <el.Text key={`time:${r}:${i}`} {...(c === null ? {} : style(c))}>
59 {pad(c === null || c.minutes === 0 ? '' : ` ${shortMinutes(c.minutes)}`, w)}
60 </el.Text>
61 ))}
62 </el.Box>,
63 ]
64}
65
66/** A wall calendar of the selected day's month, shaded by the hours of work. */
67export const monthCalendar = (el: ElementTable, s: DiaryState, act: Actions) => {
68 const w = cellWidth(s.columns)
69 const week = new Set(weekOf(s.selected))
70 const cellOf = (day: Day | null): Cell | null =>
71 day === null
72 ? null
73 : {
74 day,
75 minutes: factsOf(s.days, day).activeMinutes,
76 isSelected: day === s.selected,
77 isToday: day === s.today,
78 isFuture: s.today !== null && day > s.today,
79 }
80 return (
81 <el.Box key="calendar" flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
82 <el.Box flexDirection="row" justifyContent="space-between">
83 <el.Button key="prev" label="‹ mese" hotkey="p" plain dimColor onPress={() => act.shift(-1)} />
84 <el.Text bold color="claude">{monthLabel(s.selected).toUpperCase()}</el.Text>
85 <el.Button key="next" label="mese ›" hotkey="n" plain dimColor onPress={() => act.shift(1)} />
86 </el.Box>
87 <el.Box flexDirection="row" columnGap={1}>
88 <el.Text>{pad('', GUTTER)}</el.Text>
89 {WEEKDAYS.map(d => (
90 <el.Text key={`wd:${d}`} bold dimColor={d === 'sab' || d === 'dom'}>{pad(` ${d}`, w)}</el.Text>
91 ))}
92 </el.Box>
93 {monthGrid(s.selected).flatMap((row, r) => weekRows(el, row.map(cellOf), w, row.some(d => d !== null && week.has(d)), act, r))}
94 <el.Box flexDirection="row" columnGap={1} marginTop={1}>
95 <el.Text dimColor>meno</el.Text>
96 {HEAT.slice(1).map(level => (
97 <el.Text key={`heat:${level.bg}`} backgroundColor={level.bg}>{' '}</el.Text>
98 ))}
99 <el.Text dimColor>più ore · ● oggi</el.Text>
100 </el.Box>
101 </el.Box>
102 )
103}
104hooks/report.tsx 151 lines1import type { ElementTable } from 'claude-code'
2
3import type { Commit, DayFacts, ProjectTime, SessionDay } from '../types'
4import { formatMinutes, longDayLabel, mondayOf, weekLabel, weekOf } from './calendar'
5import { factsOf, mergeDays, projectName } from './facts'
6import { dayKey, weekKey } from './summary'
7import type { DiaryState } from './view'
8
9const TOP_PROJECTS = 5
10const MAX_SESSIONS = 4
11const MAX_COMMITS = 3
12const NAME_WIDTH = 16
13const BAR_WIDTH = 14
14/** Below this many columns the project bars are shorter. */
15const NARROW = 80
16const NARROW_BAR = 8
17
18const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
19
20/** A bar `minutes` long against the longest, in eighths of a cell. */
21export const relativeBar = (minutes: number, longest: number, width = BAR_WIDTH): string => {
22 if (longest <= 0 || minutes <= 0) return ''
23 const eighths = Math.max(1, Math.round((minutes / longest) * width * 8))
24 return '█'.repeat(Math.floor(eighths / 8)) + (['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'][eighths % 8] ?? '')
25}
26
27const projectCounts = (p: ProjectTime) =>
28 [p.sessions > 0 ? plural(p.sessions, 'sessione', 'sessioni') : '', p.commits > 0 ? plural(p.commits, 'commit', 'commit') : '']
29 .filter(x => x !== '')
30 .join(' · ')
31
32/** Title on the left, figures on the right. */
33const heading = (el: ElementTable, title: string, figures: string) => (
34 <el.Box flexDirection="row" justifyContent="space-between" columnGap={2}>
35 <el.Text bold color="claude">{title}</el.Text>
36 <el.Text bold>{figures}</el.Text>
37 </el.Box>
38)
39
40/** Claude's lines, or why there are none yet. */
41const summaryText = (el: ElementTable, s: DiaryState, key: string) => {
42 const written = s.summaries[key]
43 if (s.writing.has(key) && written === undefined) return <el.Text dimColor italic>Claude sta scrivendo il resoconto…</el.Text>
44 if (written === undefined) return <el.Text dimColor>Resoconto non ancora scritto: premi r.</el.Text>
45 return (
46 <el.Box flexDirection="column">
47 {written.text.split('\n').map((line, i) => (
48 // The bullet apart, so a line that wraps starts under the text, not under it.
49 <el.Box key={`${key}:${i}`} flexDirection="row" columnGap={1}>
50 <el.Text color="claude">•</el.Text>
51 <el.Text wrap="wrap">{line.replace(/^•\s*/, '')}</el.Text>
52 </el.Box>
53 ))}
54 {s.writing.has(key) && <el.Text dimColor italic>Claude lo sta riscrivendo…</el.Text>}
55 </el.Box>
56 )
57}
58
59const projectBars = (el: ElementTable, projects: readonly ProjectTime[], columns: number) => {
60 const longest = projects[0]?.minutes ?? 0
61 const barWidth = columns < NARROW ? NARROW_BAR : BAR_WIDTH
62 return (
63 <el.Box flexDirection="column">
64 {projects.slice(0, TOP_PROJECTS).map(p => (
65 <el.Box key={`bar:${p.name}`} flexDirection="row" columnGap={1}>
66 <el.Text wrap="truncate-end">{p.name.padEnd(NAME_WIDTH).slice(0, NAME_WIDTH)}</el.Text>
67 <el.Text color="claude">{relativeBar(p.minutes, longest, barWidth).padEnd(barWidth)}</el.Text>
68 <el.Text bold>{(p.minutes > 0 ? formatMinutes(p.minutes) : '').padEnd(7)}</el.Text>
69 <el.Text dimColor wrap="truncate-end">{projectCounts(p)}</el.Text>
70 </el.Box>
71 ))}
72 {projects.length > TOP_PROJECTS && <el.Text dimColor>{`… e altri ${projects.length - TOP_PROJECTS} progetti`}</el.Text>}
73 </el.Box>
74 )
75}
76
77/** The week of the selected day: its total, Claude's lines, its projects. */
78export const weekReport = (el: ElementTable, s: DiaryState) => {
79 const week = mergeDays(weekOf(s.selected).map(d => factsOf(s.days, d)))
80 const hasWork = week.sessions.length > 0 || week.commits.length > 0
81 const figures = hasWork ? [formatMinutes(week.activeMinutes), week.commits.length > 0 ? plural(week.commits.length, 'commit', 'commit') : ''].filter(x => x !== '').join(' · ') : ''
82 return (
83 <el.Box key="week" flexDirection="column" borderStyle="round" borderDimColor paddingX={1} gap={1}>
84 {heading(el, `Settimana ${weekLabel(s.selected)}`, figures)}
85 {hasWork ? summaryText(el, s, weekKey(mondayOf(s.selected))) : <el.Text dimColor>Nessun lavoro con Claude in questa settimana.</el.Text>}
86 {hasWork && projectBars(el, week.projects, s.columns)}
87 </el.Box>
88 )
89}
90
91const sessionLines = (el: ElementTable, sessions: readonly SessionDay[]) => [
92 ...sessions.slice(0, MAX_SESSIONS).map(x => (
93 <el.Text key={`session:${x.sessionId}`} wrap="truncate-end">
94 <el.Text dimColor>{` ${formatMinutes(x.activeMinutes).padStart(6)} `}</el.Text>
95 {x.title ?? 'senza titolo'}
96 </el.Text>
97 )),
98 ...(sessions.length > MAX_SESSIONS ? [<el.Text key="more-sessions" dimColor>{` … e altre ${sessions.length - MAX_SESSIONS} sessioni`}</el.Text>] : []),
99]
100
101const commitLines = (el: ElementTable, commits: readonly Commit[], project: string) => [
102 ...commits.slice(-MAX_COMMITS).reverse().map(c => (
103 <el.Text key={`commit:${c.hash}`} wrap="truncate-end">
104 <el.Text color="success">{' ✓ '}</el.Text>
105 <el.Text dimColor>{c.subject}</el.Text>
106 </el.Text>
107 )),
108 ...(commits.length > MAX_COMMITS ? [<el.Text key={`more-commits:${project}`} dimColor>{` … e altri ${commits.length - MAX_COMMITS} commit`}</el.Text>] : []),
109]
110
111/** The day's work project by project: time, sessions by title, latest commits. */
112const dayProjects = (el: ElementTable, f: DayFacts) => (
113 <el.Box flexDirection="column" gap={1}>
114 {f.projects.map(p => {
115 const sessions = f.sessions.filter(x => projectName(x.repo) === p.name)
116 const commits = f.commits.filter(c => projectName(c.project) === p.name)
117 return (
118 <el.Box key={`project:${p.name}`} flexDirection="column">
119 <el.Text>
120 <el.Text bold>{`● ${p.name}`}</el.Text>
121 <el.Text dimColor>{` ${[p.minutes > 0 ? formatMinutes(p.minutes) : '', projectCounts(p)].filter(x => x !== '').join(' · ')}`}</el.Text>
122 </el.Text>
123 {sessionLines(el, sessions)}
124 {commitLines(el, commits, p.name)}
125 </el.Box>
126 )
127 })}
128 </el.Box>
129)
130
131/** The selected day: Claude's lines, then what was done in each project. */
132export const dayReport = (el: ElementTable, s: DiaryState) => {
133 const f = factsOf(s.days, s.selected)
134 const hasWork = f.sessions.length > 0 || f.commits.length > 0
135 const isFuture = s.today !== null && s.selected > s.today
136 const isForgotten = s.oldest !== null && s.selected < s.oldest
137 const why = isFuture
138 ? 'Questo giorno deve ancora venire.'
139 : isForgotten
140 ? 'Oltre i 30 giorni: Claude Code non tiene più i transcript.'
141 : 'Nessun lavoro con Claude in questo giorno.'
142 const title = longDayLabel(s.selected)
143 return (
144 <el.Box key="day" flexDirection="column" borderStyle="round" borderDimColor paddingX={1} gap={1}>
145 {heading(el, `${title.charAt(0).toUpperCase()}${title.slice(1)}`, hasWork ? formatMinutes(f.activeMinutes) : '')}
146 {hasWork ? summaryText(el, s, dayKey(s.selected)) : <el.Text dimColor>{why}</el.Text>}
147 {hasWork && dayProjects(el, f)}
148 </el.Box>
149 )
150}
151types/index.d.ts 66 lines1/** A local calendar day, `YYYY-MM-DD` in the Mac's time zone. */
2export type Day = string
3
4/** A stretch of work, from its first message to its last: ISO times with their offset. */
5export type Span = [string, string]
6
7/** One session's work on one day, read off its transcript: facts only, never the messages' text. */
8export type SessionDay = {
9 sessionId: string
10 day: Day
11 /** The session's working directory. */
12 project: string
13 /** The repository it worked in: its directory's, or the one most of its edits are in; else its directory. */
14 repo: string
15 branch: string | null
16 /** The session's title as Claude Code named it; null when it has none. */
17 title: string | null
18 /** First and last message of that day. */
19 start: string
20 end: string
21 /** The gaps between its messages, each counted up to the idle cap. */
22 activeMinutes: number
23 /** Its stretches of work: parallel sessions overlap, so a day's time is their union. */
24 spans: Span[]
25 /** The person's own messages that day. */
26 prompts: number
27 /** Files Claude edited or wrote that day, each once, absolute paths. */
28 edits: string[]
29 /** Bash calls that day. */
30 commands: number
31}
32
33/** A commit of the person's, in a repository the sessions worked in. */
34export type Commit = { project: string; day: Day; time: string; hash: string; subject: string }
35
36/** What hooks/scan.py prints. */
37export type Scan = { today: Day; sessions: SessionDay[]; commits: Commit[] }
38
39/** One project's share of a day or a week. */
40export type ProjectTime = { name: string; minutes: number; sessions: number; commits: number }
41
42/** Everything known about a day, for the calendar and its summary. */
43export type DayFacts = {
44 day: Day
45 /** The union of the day's spans. */
46 activeMinutes: number
47 /** Most minutes first. */
48 projects: ProjectTime[]
49 /** Earliest first. */
50 sessions: SessionDay[]
51 /** Earliest first. */
52 commits: Commit[]
53}
54
55/** A summary Claude wrote, and the facts it was written from, to know when it is stale. */
56export type Summary = { text: string; factsKey: string; at: number }
57
58declare module 'claude-code' {
59 interface PluginState {
60 'work-diary': {
61 /** By `day:YYYY-MM-DD` or `week:YYYY-MM-DD` (the week's Monday). */
62 summaries: Record<string, Summary>
63 }
64 }
65}
66