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

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:
| Setting | Default | What it is |
|---|---|---|
notion_view_url | empty | Required. 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_server | claude.ai Notion | The Notion MCP server name as /mcp shows it. notion is tried too. |
date_property | Date Collected | The date property rows are counted by. |
source_property | Source | The property that says where a row came from. |
sources | YouTube,Reddit,News,X,LinkedIn | Comma-separated source values to chart and watch. |
routines | empty | Comma-separated Name@HH:MM (24-hour, local time), e.g. Daily Collector@06:00,Writer@21:00. |
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.
Calls notion-query-data-sources (view mode) on your Notion MCP server. Nothing is written.
hooks/register.tsx 182 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>')
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