SLOPSHOPPER

week-calendar

/week: weekly calendar of Claude Code and Codex sessions, with commits, no-commit sessions, hours per project and a 3-line report

newcommandstatusmodelprocess
A shopper browsing a rack in a slop shop
README

week-calendar

Claude Code mod. /week builds a one-file HTML calendar of this week's Claude Code and Codex sessions, writes a 3-line report, opens the calendar, and puts a summary on the status line.

Install

cp -r week-calendar ~/mods/ claude --plugin-dir ~/mods/week-calendar # one session # or permanently: add ~/mods/week-calendar to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json "env" claude plugin validate ~/mods/week-calendar

Requires Claude Code >= 2.1.287, Node, git.

Use

/week this week /week last last week (/week -2 for two weeks back)

Standalone, without Claude Code: node bin/build.mjs [--offset -1] (the Shipped line stays empty).

Files

  • bin/build.mjs: reads ~/.claude/projects/**/*.jsonl (skips subagents/) and ~/.codex/sessions/**/*.jsonl, splits sessions on gaps > 30 min, matches your commits (git config user.email) per block, writes ~/.calendar/week-<monday>.html.
  • bin/calendar.html: template; data is inlined, no network.
  • hooks/register.js: registers /week, calls sonnet once for the Shipped line, patches it into the HTML.

Settings

~/.calendar/config.json is created on first run: theme (dark/light), accent, weekStart (mon/sun), dayStartHour (default 6: work before 06:00 counts toward the previous day), gapMinutes, minNoCommitMinutes (default 15), palette, sources.

Source 1 files
hooks/register.js 79 lines
1// week-calendar mod
2// /week          build this week's calendar, write the 3-line report, open the HTML
3// /week last     same for last week (also accepts -1, -2, ...)
4
5const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
6
7function parseOffset(args) {
8  const a = (args || '').trim()
9  if (a === 'last') return -1
10  const n = parseInt(a, 10)
11  return Number.isFinite(n) && n <= 0 ? n : 0
12}
13
14async function shippedLine($, s) {
15  const lines = Object.entries(s.commitSubjects).map(([p, subj]) => `${p}:\n- ${subj.join('\n- ')}`).join('\n\n')
16  if (!lines) return 'No commits this week'
17  const r = await $.model.complete({
18    model: 'sonnet',
19    system: 'You write one line for a weekly engineering report. Read the commit subjects grouped by project and name the 1 to 3 main things that were finished, as short noun phrases joined by commas. No preamble, no project names unless needed, under 15 words.',
20    prompt: lines,
21    maxTokens: 80,
22    timeoutMs: 20000,
23  })
24  return r.isAnswered && r.text.trim() ? r.text.trim().split('\n')[0] : Object.keys(s.commitSubjects).join(', ')
25}
26
27async function openFile($, file) {
28  for (const cmd of ['open', 'xdg-open']) {
29    try {
30      const r = await $.process.run([cmd, file])
31      if (r.exitCode === 0) return true
32    } catch {}
33  }
34  return false
35}
36
37export function register(on) {
38  on('session.start', async ($, e, next) => {
39    try {
40      await $.command.register({
41        name: 'week',
42        description: 'Weekly session calendar and 3-line report',
43        argumentHint: '[last|-N]',
44        immediate: true,
45      })
46    } catch (err) {
47      $.ui.log('could not register /week: ' + err.message)
48    }
49    return next(e)
50  })
51
52  on('command.run', { command: 'week' }, async ($, e) => {
53    const root = typeof $.plugin.root === 'function' ? await $.plugin.root() : $.plugin.root
54    let r
55    try {
56      r = await $.process.run(['node', root + '/bin/build.mjs', '--offset', String(parseOffset(e.args)), '--json'])
57    } catch (err) {
58      return { text: 'week-calendar: build did not run: ' + err.message }
59    }
60    if (r.exitCode !== 0) return { text: 'week-calendar: build failed:\n' + r.stderr.slice(0, 1500) }
61
62    const s = JSON.parse(r.stdout)
63    const shipped = await shippedLine($, s)
64    const raw = await $.fs.read(s.htmlPath)
65    const html = typeof raw === 'string' ? raw : raw.text
66    await $.fs.write(s.htmlPath, html.replace(/<!--S-->[\s\S]*?<!--\/S-->/, '<!--S-->' + esc(shipped) + '<!--/S-->'))
67
68    const opened = await openFile($, s.htmlPath)
69    $.ui.status(`${s.range}: ${s.total}, ${s.noCommit.length} without commit`)
70
71    const report = [
72      'Shipped: ' + shipped,
73      'Most time: ' + (s.top || 'none'),
74      'Next: ' + (s.noCommit.slice(0, 2).join(', ') || 'nothing left open'),
75    ].join('\n')
76    return { text: report + '\n\n' + (opened ? 'Opened ' : 'Calendar: ') + s.htmlPath }
77  })
78}
79