SLOPSHOPPER

work-diary

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

newpanecommandmodelprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · work-diary
│ ┃ Diario ✕ › fix the failing auth test and add an audit log call │ ┃ Transcript non letti: scan.py è uscito con 0 │ ┃ ● work-diary: work-diary: Transcript non letti: scan.py è uscito con │ ┃ ╭─────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ │ p: ‹ mese OTTOBRE 2025 n: mese › ⎿ Read 6 lines │ ┃ │ lun mar mer gio ven sab ⏺ Update(src/auth.ts) │ ┃ │ cell:2025-10-01 cell:2025 ⎿ Added 2 lines, removed 1 line │ ┃ │ ⏺ Bash(bun test) │ ┃ │ ▸ cell:2025-10-06 cell:2025-10-07 cell:20 ⎿ 3 pass, 1 fail │ ┃ │ │ ┃ │ cell:2025-10-13 cell:2025-10-14 cell:20 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ ┃ │ cell:2025-10-20 cell:2025-10-21 cell:20 ✻ Worked for 42s · done 4:20 PM │ ┃ │ │ ┃ │ cell:2025-10-27 cell:2025-10-28 cell:20 › /diary │ ┃ │ ⎿ work-diary: Diario aperto: clicca un giorno, oppure a/d giorno, │ ┃ │ │ ┃ │ meno più ore · ● oggi │ ┃ ╰─────────────────────────────────────────── │ ┃ │ ┃ a: giorno prima d: giorno dopo w: settiman │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Settimana 6 – 12 ottobre 2025 │ │ ┃ │ │ │ ┃ │ Nessun lavoro con Claude in questa │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Diario
Transcript non letti: scan.py è uscito con 0 ╭─────────────────────────────────────────────────────────── │ p: ‹ mese OTTOBRE 2025 n: mese › │ lun mar mer gio ven sab dom │ cell:2025-10-01 cell:2025-10-02 cell:2025 │ │ ▸ cell:2025-10-06 cell:2025-10-07 cell:2025-10-08 cell:20 │ │ cell:2025-10-13 cell:2025-10-14 cell:2025-10-15 cell:20 │ │ cell:2025-10-20 cell:2025-10-21 cell:2025-10-22 cell:20 │ │ cell:2025-10-27 cell:2025-10-28 cell:2025-10-29 cell:20 │ │ │ meno più ore · ● oggi ╰─────────────────────────────────────────────────────────── a: giorno prima d: giorno dopo w: settimana prima s: sett ╭──────────────────────────────────────────────────────────╮ │ Settimana 6 – 12 ottobre 2025 │ │ │ │ Nessun lavoro con Claude in questa settimana. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Giovedì 9 ottobre │ │ │ │ Nessun lavoro con Claude in questo giorno. │ ╰──────────────────────────────────────────────────────────╯
README

work-diary

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.

Come si usa

/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).

TastoCosa fa
a / dGiorno prima / giorno dopo
w / sSettimana prima / settimana dopo
p / nMese prima / mese dopo
tOggi, rileggendo i transcript
rRiscrive 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.»

Cosa fa da sola

Nulla in sottofondo: lavora solo a pannello aperto.

  • Quando apri /diary o premi t: rilegge i transcript degli ultimi giorni.
  • Quando apri /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.

Cosa legge e cosa manda

  • Lancia 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.
  • Lo script lancia 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.
  • I resoconti li scrive 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.
  • Prima dell'invio ogni sequenza di 32 caratteri o più che sembra un token diventa «…».
  • Tiene i resoconti (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.

Requisiti

  • python3.
  • git 2.31 o successivo, per i commit e per riconoscere i repository; senza, il diario mostra solo le sessioni.

Limiti

  • Copre gli ultimi 30 giorni: oltre, Claude Code non tiene più i transcript.
  • Una pausa di più di 15 minuti tra due messaggi non conta come lavoro. Le sessioni in parallelo si contano una volta sola.
  • I subagent non contano.
  • Conta solo i commit con l'email di git config user.email del repository; un commit visto su due rami conta una volta.
  • La lettura dei transcript ha 2 minuti; ogni git 10 secondi; ogni resoconto 60 secondi e al massimo 600 token.
  • Nel pannello: i primi 5 progetti della settimana, 4 sessioni e 3 commit per progetto.
  • Le ore sono quelle del fuso orario del computer.

Installazione

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.

Sviluppo

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.

Source 8 files
hooks/register.tsx 202 lines
1import { 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}
202
hooks/calendar.ts 90 lines
1import 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}
90
hooks/facts.ts 153 lines
1import 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  )
153
hooks/summary.ts 82 lines
1import 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}
82
hooks/view.tsx 55 lines
1import 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)
55
hooks/month.tsx 104 lines
1import 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}
104
hooks/report.tsx 151 lines
1import 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}
151
types/index.d.ts 66 lines
1/** 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