SLOPSHOPPER

routine-watch

Your scheduled routines at a glance from a Notion database: today's haul by source, the last 7 days, sources that went quiet and next runs

newpaneguardcommandtooltimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · routine-watch
│ ┃ ⚙ Routines ✕ › fix the failing auth test and add an audit log call │ ┃ Set your Notion view URL in /plugin → │ ┃ buildalone-mods → routine-watch ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /routine-watch │ ⎿ routine-watch: Set your Notion view URL in /plugin → buildalone- │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ⚙ Routines
Set your Notion view URL in /plugin → buildalone-mods → routine-watch
README

routine-watch

For people who run scheduled routines (Claude scheduled tasks, cron jobs, automations) that write rows into a Notion database. /routine-watch opens a dashboard with:

  • how many rows came in today, split by source
  • a bar per day for the last 7 days
  • sources with no rows this week, flagged as possibly broken
  • when each routine runs next

Settings

SettingDefaultWhat it is
notion_view_urlemptyRequired. A saved view of the database, as view://…. Ask Claude to fetch your database with the Notion MCP and it will show the view URLs. Sort that view newest first.
notion_serverclaude.ai NotionThe Notion MCP server name as /mcp shows it. notion is tried too.
date_propertyDate CollectedThe date property rows are counted by.
source_propertySourceThe property that says where a row came from.
sourcesYouTube,Reddit,News,X,LinkedInComma-separated source values to chart and watch.
routinesemptyComma-separated Name@HH:MM (24-hour, local time), e.g. Daily Collector@06:00,Writer@21:00.

Why a saved view

On Notion plans below Business, direct data-source queries through the MCP have a quota, and your routines probably use the same quota. Saved-view queries don't count against it. So the mod reads the view 20 rows at a time, newest first, and stops as soon as it reaches rows older than 7 days.

What it touches

Calls notion-query-data-sources (view mode) on your Notion MCP server. Nothing is written.

Source 1 files
hooks/register.tsx 182 lines
1// routine-watch: /routine-watch reads the Notion database your scheduled routines write to and shows
2// today's haul by source, a 7-day chart, sources that came back empty (likely broken) and when
3// each routine runs next.
4import type { Register } from 'claude-code'
5
6const PANE = 'routines'
7const SETUP = 'Set your Notion view URL in /plugin → buildalone-mods → routine-watch'
8const COLORS = ['#d97757', '#c98a3a', '#788c5d', '#6a9bcc', '#a3a199']
9type Row = Record<string, string | undefined>
10let servers: string[] = []
11let viewUrl = ''
12let dateKey = 'date:Date Collected:start'
13let sourceKey = 'Source'
14let sources: [string, string][] = []
15let runs: [string, number, number][] = []
16let rows: Row[] = []
17let at = 0
18let err = ''
19let pulling = false
20
21const list = (s: unknown) => String(s ?? '').split(',').map(x => x.trim()).filter(Boolean)
22const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
23const dateOf = (r: Row) => r[dateKey] ?? ''
24
25async function pull($: any) {
26  if (pulling || !viewUrl) return false
27  pulling = true
28  try { return await pullOnce($) } finally { pulling = false }
29}
30async function query($: any, args: Record<string, unknown>) {
31  let first: unknown
32  for (const server of servers) {
33    try { return await $.mcp.call(server, 'notion-query-data-sources', args) } catch (x) { first ??= x }
34  }
35  throw first ?? new Error('no Notion server')
36}
37// View mode: Notion caps "query data source" calls on non-Business plans (a quota your routines
38// share), but saved-view queries have no quota. Reads the view newest pages first, and stops once
39// it reaches rows older than a week.
40async function pullOnce($: any) {
41  const now = new Date(await $.clock.now())
42  const since = new Date(now); since.setDate(since.getDate() - 7)
43  const cut = day(since)
44  const got: Row[] = []
45  let cursor: string | undefined
46  try {
47    for (let page = 0; page < 15; page++) {
48      const res = await query($, { data: { mode: 'view', view_url: viewUrl, page_size: 20, ...(cursor ? { start_cursor: cursor } : {}) } })
49      const txt = (res.content ?? []).map((c: any) => c.text ?? '').join('')
50      if (res.isError || !txt.trim().startsWith('{')) throw new Error(txt.slice(0, 160))
51      const j = JSON.parse(txt)
52      const rs: Row[] = j.results ?? []
53      got.push(...rs)
54      // newest first: stop once this page reaches rows older than the window
55      if (rs.some(r => (r[dateKey] ?? '9') < cut)) break
56      if (!j.has_more || !j.next_cursor) break
57      cursor = j.next_cursor
58    }
59  } catch (x: any) {
60    err = String(x?.message ?? x).slice(0, 170)
61    $.ui.invalidate('ui.render')
62    return false
63  }
64  rows = got.filter(r => dateOf(r) >= cut)
65  err = ''
66  at = +now
67  $.ui.invalidate('ui.render')
68  return true
69}
70const day = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
71function nextRun(h: number, m: number, now: Date) {
72  const t = new Date(now); t.setHours(h, m, 0, 0)
73  if (t <= now) t.setDate(t.getDate() + 1)
74  const ms = +t - +now, hh = Math.floor(ms / 3.6e6), mm = Math.floor((ms % 3.6e6) / 6e4)
75  return `${hh}h ${mm}m`
76}
77
78const STYLE = `<style>
79  svg{--bg:#ffffff;--fg:#141413;--mu:#73726c;--ln:#e8e6dc;--tr:#f0eee6;--ac:#d97757;--ok:#788c5d;--wa:#c98a3a;--bad:#bf4d43}
80  @media (prefers-color-scheme: dark){svg{--bg:#262624;--fg:#faf9f5;--mu:#a3a199;--ln:#3d3d3a;--tr:#30302e}}
81  .card{fill:var(--bg);stroke:var(--ln);stroke-width:1}
82  .fg{fill:var(--fg)} .mu{fill:var(--mu)} .tr{fill:var(--tr)} .trs{stroke:var(--tr)} .ln{stroke:var(--ln)}
83  .ac{fill:var(--ac)} .acs{stroke:var(--ac)} .ok{fill:var(--ok)} .oks{stroke:var(--ok)} .wa{fill:var(--wa)} .was{stroke:var(--wa)} .bad{fill:var(--bad)} .bads{stroke:var(--bad)}
84  .t{font-family:ui-serif,"Tiempos Text",Georgia,serif}
85  text{font-family:-apple-system,BlinkMacSystemFont,"Inter",sans-serif}
86</style>`
87function setupCard() {
88  return `<svg xmlns="http://www.w3.org/2000/svg" width="460" height="96" viewBox="0 0 460 96">${STYLE}
89  <rect x="0.5" y="0.5" width="459" height="95" rx="14" class="card"/>
90  <text x="24" y="34" font-size="13" class="mu">⚙ Routines</text>
91  <text x="24" y="66" font-size="13" class="fg">${esc(SETUP)}</text></svg>`
92}
93function card(now: Date) {
94  const today = day(now)
95  const todays = rows.filter(r => dateOf(r) === today)
96  const by = (rs: Row[], s: string) => rs.filter(r => r[sourceKey] === s).length
97  const days = [...Array(7)].map((_, i) => { const d = new Date(now); d.setDate(d.getDate() - (6 - i)); return day(d) })
98  const counts = days.map(d => rows.filter(r => dateOf(r) === d).length)
99  const max = Math.max(1, ...counts)
100  const cols = counts.map((c, i) => {
101    const h = Math.max(2, (64 * c) / max), x = 262 + i * 25
102    return `<rect x="${x}" y="${132 - h}" width="15" height="${h}" rx="3" class="${i === 6 ? 'ac' : 'tr'}"/>
103    <text x="${x + 7.5}" y="148" text-anchor="middle" font-size="10" class="mu">${days[i]!.slice(8)}</text>`
104  }).join('')
105  let off = 0
106  const segs = sources.map(([s, c]) => { const n = by(todays, s); const w = todays.length ? (210 * n) / todays.length : 0; const r = `<rect x="${24 + off}" y="104" width="${w}" height="6" fill="${c}"/>`; off += w; return r }).join('')
107  const legend = sources.slice(0, 3).map(([s, c], i) => `<circle cx="${28 + i * 72}" cy="${128}" r="3.5" fill="${c}"/><text x="${36 + i * 72}" y="132" font-size="11" class="mu">${esc(s.slice(0, 7))} ${by(todays, s)}</text>`).join('')
108  const dead = sources.map(([s]) => s).filter(s => rows.length && by(rows, s) === 0)
109  const runRows = runs.length
110    ? runs.map(([n, h, m], i) => `<text x="24" y="${196 + i * 24}" font-size="13" class="fg">${esc(n)}</text><text x="436" y="${196 + i * 24}" text-anchor="end" font-size="12" class="mu">next run in ${nextRun(h, m, now)}</text>`).join('')
111    : `<text x="24" y="196" font-size="12" class="mu">Add your routines' run times in the plugin settings to see next runs.</text>`
112  const lastY = 196 + (Math.max(1, runs.length) - 1) * 24
113  const deadY = lastY + 32
114  const H = dead.length ? deadY + 14 : lastY + 22
115  return `<svg xmlns="http://www.w3.org/2000/svg" width="460" height="${H}" viewBox="0 0 460 ${H}">${STYLE}
116  <rect x="0.5" y="0.5" width="459" height="${H - 1}" rx="14" class="card"/>
117  <text x="24" y="34" font-size="13" class="mu">⚙ Routines</text>
118  <text x="436" y="34" text-anchor="end" font-size="11" class="${at ? 'mu' : 'wa'}">${at ? 'Notion · live' : err ? 'Notion unreachable' : 'loading…'}</text>
119  <text x="24" y="86" font-size="44" font-weight="600" class="fg">${todays.length}</text>
120  <text x="${todays.length >= 10 ? 80 : 56}" y="86" font-size="13" class="mu">collected today</text>
121  <rect x="24" y="104" width="210" height="6" rx="3" class="tr"/>${segs}${legend}
122  <text x="262" y="56" font-size="11" class="mu">Last 7 days</text>${cols}
123  <line x1="24" y1="168" x2="436" y2="168" class="ln"/>
124  ${runRows}
125  ${dead.length ? `<text x="${24}" y="${deadY}" font-size="12" class="wa">No ${esc(dead.join(', '))} items this week: that source may be broken</text>` : ''}
126</svg>`
127}
128
129export const register: Register = (on, options) => {
130  servers = [...new Set([String(options.notion_server ?? '').trim(), 'notion'].filter(Boolean))]
131  viewUrl = String(options.notion_view_url ?? '').trim()
132  dateKey = `date:${String(options.date_property ?? '').trim() || 'Date Collected'}:start`
133  sourceKey = String(options.source_property ?? '').trim() || 'Source'
134  sources = list(options.sources).map((s, i): [string, string] => [s, COLORS[i % COLORS.length]!])
135  // "Name@HH:MM" pairs; entries without a valid time are skipped
136  runs = list(options.routines).flatMap((r): [string, number, number][] => {
137    const m = r.match(/^(.+?)\s*@\s*(\d{1,2}):(\d{2})$/)
138    return m && +m[2]! < 24 && +m[3]! < 60 ? [[m[1]!, +m[2]!, +m[3]!]] : []
139  })
140
141  on('session.start', async ($, e, next) => {
142    await $.tool.register({ name: 'show', description: 'Open the ⚙ Routines pane in Claude Code for the person' })
143    await $.command.register({ name: 'routine-watch', description: 'Show what your scheduled routines collected, and when they run next' })
144    $.clock.every(60000, () => $.ui.invalidate('ui.render'))
145    return next(e)
146  })
147
148  on('command.run', { command: 'routine-watch' }, async $ => {
149    await $.ui.open({ id: PANE, title: '⚙ Routines' })
150    if (!viewUrl) return { text: SETUP }
151    const ok = await pull($)
152    return { text: ok ? `Routine watch: ${rows.length} items this week.` : 'Routine watch: could not reach Notion.' }
153  })
154
155  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
156    if (!viewUrl) {
157      if (e.surface === 'terminal') {
158        const { Text } = $.ui.resolve(e)
159        return <Text>{SETUP}</Text>
160      }
161      const { Svg } = $.ui.resolve(e)
162      return <Svg source={setupCard()} alt={SETUP} width={460} isInteractive />
163    }
164    const now = new Date(await $.clock.now())
165    if (!at && !err) void pull($)
166    if (e.surface === 'terminal') {
167      const { Text } = $.ui.resolve(e)
168      return <Text>{rows.filter(r => dateOf(r) === day(now)).length} collected today</Text>
169    }
170    const { Svg } = $.ui.resolve(e)
171    return <Svg source={card(now)} alt="Scheduled routine summary" width={460} isInteractive />
172  })
173
174  on('tool.call', { tool: 'mcp__routine-watch__show' }, async $ => {
175    try { await $.ui.close({ id: PANE }) } catch {}
176    await $.ui.open({ id: PANE, title: '⚙ Routines' })
177    if (!viewUrl) return { result: `⚙ Routines pane opened. ${SETUP}.` }
178    const ok = (await $.clock.now()) - at < 10 * 60e3 ? true : await pull($)
179    return { result: ok ? `⚙ Routines pane opened: ${rows.length} items this week.` : `⚙ Routines pane opened; Notion read failed: ${err}` }
180  })
181}
182