SLOPSHOPPER

Clock

Every prompt carries your local date, weekday and the week ranges, and a reply whose dates miss the period you asked about gets flagged

newrowsprompt
v0.1.0Apache-2.0updated 2026-10-04hacksurvivor/claude-mods/mods/clock
A shopper browsing a rack in a slop shop
README

Clock

A Claude Code mod that ends wrong dates. Every prompt quietly carries your local date, time and weekday, with yesterday, this week, last week, this month and last month worked out, so Claude never guesses them. When you ask about a period and the reply's dates miss it, a line under the reply says so.

Clock in the Claude desktop app: a weekly report with the wrong dates and the line under it, and the brief every prompt carries

On a weekend, "last week" means the work week that just ended. Claude is also told to keep city and timezone names out of titles, labels and file names unless you ask.

Markets

If your work depends on the time somewhere else, list those places per folder in ~/.claude/clock/markets.json, and their local times join the brief in that folder:

[{ "folder": "/shop-app", "markets": [
  { "label": "US", "place": "New York", "zone": "America/New_York" },
  { "label": "Czechia", "place": "Prague", "zone": "Europe/Prague" }
] }]

Requirements

Claude Code 2.1.286 or later (mods). The line under a reply is drawn in the Claude desktop app and in the terminal.

Install

In Claude Code:

/plugin marketplace add hacksurvivor/claude-mods
/plugin install clock@hacksurvivor
/reload-plugins

What it runs, reads and sends

  • Reads your computer's clock and timezone, your prompt (to see which period you ask about), the session's folder, and ~/.claude/clock/markets.json.
  • Sends nothing itself. The brief is added to your prompt, which Claude Code sends to Anthropic under your Claude account.
  • Keeps the last 20 flags in memory for the session only.

See PRIVACY.md.

License

Apache-2.0

Source 3 files
hooks/register.tsx 87 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Flag } from '../types'
5import { MARKETS_FILE, anchorOf, brief, isUnder, marketsFor, mismatch, periodAsked, wallClock } from './time'
6import type { Market, Period } from './time'
7
8// Every prompt carries the person's local date, time and weekday, the week
9// ranges worked out once, and the local times of the markets a folder's work
10// depends on, from ~/.claude/clock/markets.json. A reply
11// to a prompt about a period (last week, yesterday, this month) whose dates all
12// miss that period gets a line under it saying so.
13
14const FLAGS_KEPT = 20
15
16const flags = atom({ plugin: 'clock', key: 'flags' } as const, [] as Flag[])
17
18// The period the prompt now running asked about, if any.
19let asked: Period | undefined
20
21// The markets for this session's folder; none without the file.
22async function markets($: EngineInterface): Promise<Market[]> {
23  try {
24    const text = await $.fs.read(`${(await $.env.get('HOME')) ?? ''}/${MARKETS_FILE}`)
25
26    return marketsFor(JSON.parse(text) as unknown, await $.session.cwd())
27  } catch {
28    return []
29  }
30}
31
32const zone = (): string => Intl.DateTimeFormat().resolvedOptions().timeZone
33
34export const register: Register = on => {
35  on('prompt.submit', async ($, e, next) => {
36    if (e.origin?.kind === 'plugin') {
37      return next(e)
38    }
39
40    asked = periodAsked(e.text) ?? (e.turnId === undefined ? undefined : asked)
41    const said = brief(await $.clock.now(), zone(), await markets($))
42
43    return next({ ...e, context: [...(e.context ?? []), said] })
44  })
45
46  on('turn.complete', async ($, e, next) => {
47    const done = await next(e)
48    const period = asked
49
50    if (e.agentId !== undefined || e.reason !== 'answer' || period === undefined) {
51      return done
52    }
53
54    asked = undefined
55    const today = wallClock(await $.clock.now(), zone()).day
56    const text = mismatch(period, e.answer, today)
57
58    if (text !== undefined) {
59      await update($, flags, list => [...list, { anchor: anchorOf(e.answer), text }].slice(-FLAGS_KEPT))
60    }
61
62    return done
63  })
64
65  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
66    const mine = (await read($, flags)).filter(flag => isUnder(flag.anchor, e.props.text))
67
68    if (mine.length === 0) {
69      return next(e)
70    }
71
72    const drawn = await next(e)
73    const { Box, Text } = $.ui.resolve(e)
74
75    return (
76      <Box flexDirection="column">
77        {drawn}
78        {mine.map((flag, at) => (
79          <Box key={`clock-${at}`} marginTop={1} marginLeft={2}>
80            <Text color="warning">{flag.text}</Text>
81          </Box>
82        ))}
83      </Box>
84    )
85  })
86}
87
hooks/time.ts 262 lines
1// Calendar days and the week ranges Clock hands Claude, in the person's own
2// timezone, and the check of a reply's dates against the period asked for.
3
4export type Day = { y: number; m: number; d: number }
5export type Range = { from: Day; to: Day }
6export type Period = 'today' | 'yesterday' | 'thisWeek' | 'lastWeek' | 'thisMonth' | 'lastMonth'
7
8const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
9const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
10
11// A place whose local time a folder's work depends on, from ~/.claude/clock/markets.json.
12export type Market = { label: string; place: string; zone: string }
13export type MarketsByFolder = { folder: string; markets: Market[] }[]
14
15export const MARKETS_FILE = '.claude/clock/markets.json'
16
17// The markets for a folder: every entry whose folder the path contains.
18export function marketsFor(config: unknown, cwd: string): Market[] {
19  if (!Array.isArray(config)) {
20    return []
21  }
22
23  return (config as MarketsByFolder)
24    .filter(entry => typeof entry?.folder === 'string' && cwd.includes(entry.folder) && Array.isArray(entry.markets))
25    .flatMap(entry => entry.markets.filter(market => typeof market?.zone === 'string' && typeof market?.label === 'string'))
26}
27
28export const serial = (day: Day): number => Date.UTC(day.y, day.m - 1, day.d) / 86_400_000
29
30export function dayOf(n: number): Day {
31  const date = new Date(n * 86_400_000)
32
33  return { y: date.getUTCFullYear(), m: date.getUTCMonth() + 1, d: date.getUTCDate() }
34}
35
36// Monday 0 … Sunday 6.
37export const weekday = (day: Day): number => (new Date(serial(day) * 86_400_000).getUTCDay() + 6) % 7
38
39// The wall clock in a zone: its day, time and offset from UTC.
40export function wallClock(now: number, zone: string): { day: Day; hh: number; mm: number; offset: number } {
41  const parts = Object.fromEntries(
42    new Intl.DateTimeFormat('en-US', {
43      timeZone: zone,
44      year: 'numeric',
45      month: 'numeric',
46      day: 'numeric',
47      hour: 'numeric',
48      minute: 'numeric',
49      hourCycle: 'h23',
50    })
51      .formatToParts(new Date(now))
52      .map(part => [part.type, part.value]),
53  )
54  const day = { y: Number(parts.year), m: Number(parts.month), d: Number(parts.day) }
55  const hh = Number(parts.hour)
56  const mm = Number(parts.minute)
57  const asUtc = Date.UTC(day.y, day.m - 1, day.d, hh, mm)
58  const offset = Math.round((asUtc - Math.floor(now / 60_000) * 60_000) / 60_000)
59
60  return { day, hh, mm, offset }
61}
62
63const shift = (day: Day, days: number): Day => dayOf(serial(day) + days)
64
65export function rangeOf(period: Period, today: Day): Range {
66  const monday = shift(today, -weekday(today))
67
68  switch (period) {
69    case 'today':
70      return { from: today, to: today }
71    case 'yesterday':
72      return { from: shift(today, -1), to: shift(today, -1) }
73    case 'thisWeek':
74      return { from: monday, to: shift(monday, 6) }
75    // On a weekend, "last week" is the work week that just ended.
76    case 'lastWeek':
77      return weekday(today) >= 5 ? { from: monday, to: shift(monday, 4) } : { from: shift(monday, -7), to: shift(monday, -3) }
78    case 'thisMonth':
79      return { from: { ...today, d: 1 }, to: shift({ y: today.m === 12 ? today.y + 1 : today.y, m: (today.m % 12) + 1, d: 1 }, -1) }
80    case 'lastMonth': {
81      const first = { ...today, d: 1 }
82      const end = shift(first, -1)
83
84      return { from: { ...end, d: 1 }, to: end }
85    }
86  }
87}
88
89const short = (day: Day): string => `${MONTHS[day.m - 1]?.slice(0, 3)} ${day.d}`
90const dayName = (day: Day): string => WEEKDAYS[weekday(day)]?.slice(0, 3) ?? ''
91
92// "Sep 28 – Oct 2", "Aug 10–14", "Oct 4".
93export function showRange(range: Range): string {
94  if (serial(range.from) === serial(range.to)) {
95    return short(range.from)
96  }
97
98  if (range.from.m === range.to.m && range.from.y === range.to.y) {
99    return `${short(range.from)}–${range.to.d}`
100  }
101
102  return `${short(range.from)} – ${short(range.to)}`
103}
104
105const longDay = (day: Day): string => `${WEEKDAYS[weekday(day)]}, ${day.d} ${MONTHS[day.m - 1]} ${day.y}`
106const pad = (n: number): string => String(n).padStart(2, '0')
107
108function showOffset(minutes: number): string {
109  const sign = minutes < 0 ? '−' : '+'
110  const abs = Math.abs(minutes)
111
112  return `UTC${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
113}
114
115const withDays = (range: Range): string => `${dayName(range.from)} ${showRange(range)}${serial(range.from) === serial(range.to) ? '' : ` (${dayName(range.to)})`}, ${range.to.y}`
116
117// What rides beside each prompt.
118export function brief(now: number, zone: string, markets: Market[]): string {
119  const here = wallClock(now, zone)
120  const today = here.day
121  const calendarLastWeek = { from: shift(today, -weekday(today) - 7), to: shift(today, -weekday(today) - 1) }
122  const isWeekend = weekday(today) >= 5
123
124  const lines = [
125    "Clock, from the person's computer:",
126    `- Now: ${longDay(today)}, ${pad(here.hh)}:${pad(here.mm)} (${showOffset(here.offset)})`,
127    `- Yesterday: ${longDay(shift(today, -1))}`,
128    `- This week: ${withDays(rangeOf('thisWeek', today))}; work days ${showRange({ from: rangeOf('thisWeek', today).from, to: shift(rangeOf('thisWeek', today).from, 4) })}`,
129    isWeekend
130      ? `- "Last week", as the person means it on a weekend: ${withDays(rangeOf('lastWeek', today))}, the work week that just ended. The calendar week before it: ${showRange(calendarLastWeek)}`
131      : `- Last week: work days ${withDays(rangeOf('lastWeek', today))}; calendar week ${showRange(calendarLastWeek)}`,
132    `- This month: ${MONTHS[today.m - 1]} ${today.y}. Last month: ${MONTHS[rangeOf('lastMonth', today).from.m - 1]} ${rangeOf('lastMonth', today).from.y}`,
133  ]
134
135  if (markets.length > 0) {
136    const times = markets.map(market => {
137      const there = wallClock(now, market.zone)
138
139      return `${market.label}${market.place ? ` (${market.place})` : ''} ${dayName(there.day)} ${short(there.day)} ${pad(there.hh)}:${pad(there.mm)}`
140    })
141    lines.push(`- Markets now: ${times.join(' · ')}`)
142  }
143
144  lines.push(
145    'Take dates from here instead of working them out. Keep city and timezone names out of titles, labels, headings and file names unless the person asks for them.',
146  )
147
148  return lines.join('\n')
149}
150
151const ASKED: [Period, RegExp][] = [
152  ['lastWeek', /\b(last|previous|past) week\b|прошл[а-яё]* недел|за недел/i],
153  ['thisWeek', /\bthis week\b|эт[а-яё]* недел|текущ[а-яё]* недел/i],
154  ['lastMonth', /\b(last|previous|past) month\b|прошл[а-яё]* месяц/i],
155  ['thisMonth', /\bthis month\b|эт[а-яё]* месяц|текущ[а-яё]* месяц/i],
156  ['yesterday', /\byesterday\b|вчера/i],
157  ['today', /\btoday\b|сегодня/i],
158]
159
160export function periodAsked(text: string): Period | undefined {
161  return ASKED.find(([, pattern]) => pattern.test(text))?.[0]
162}
163
164const MONTH_PATTERN =
165  '(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?|январ[ья]|феврал[ья]|марта?|апрел[ья]|ма[йя]|июн[ья]|июл[ья]|августа?|сентябр[ья]|октябр[ья]|ноябр[ья]|декабр[ья])'
166const RU_MONTHS = ['янв', 'фев', 'мар', 'апр', 'ма', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
167const EN_MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
168
169function monthOf(word: string): number {
170  const w = word.toLowerCase()
171  const en = EN_MONTHS.findIndex(prefix => w.startsWith(prefix))
172
173  return en >= 0 ? en + 1 : RU_MONTHS.findIndex(prefix => w.startsWith(prefix)) + 1
174}
175
176// A month and day with no year: the year that puts it nearest today.
177function near(m: number, d: number, today: Day): Day {
178  const candidates = [today.y - 1, today.y, today.y + 1].map(y => ({ y, m, d }))
179
180  return candidates.reduce((best, day) => (Math.abs(serial(day) - serial(today)) < Math.abs(serial(best) - serial(today)) ? day : best))
181}
182
183const DASH = '\\s*(?:-|–|—|to|по|до)\\s*'
184
185// Every calendar date a reply names, ranges spread to both ends.
186export function datesIn(text: string, today: Day): Day[] {
187  const found: Day[] = []
188  const valid = (day: Day): boolean => day.m >= 1 && day.m <= 12 && day.d >= 1 && day.d <= 31
189  const add = (day: Day) => valid(day) && found.push(day)
190  let rest = text
191
192  const take = (pattern: RegExp, read: (match: RegExpExecArray) => void) => {
193    rest = rest.replace(pattern, (...args) => {
194      read(args.slice(0, -2) as unknown as RegExpExecArray)
195
196      return ' '
197    })
198  }
199
200  take(/\b(\d{4})-(\d{2})-(\d{2})\b/g, m => add({ y: Number(m[1]), m: Number(m[2]), d: Number(m[3]) }))
201  take(/\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b/g, m => add({ y: Number(m[3]), m: Number(m[2]), d: Number(m[1]) }))
202  // 28 Sep – 2 Oct; Sep 28 – Oct 2
203  take(new RegExp(`\\b(\\d{1,2})\\s+${MONTH_PATTERN}(?![a-zа-яё])\\.?${DASH}(\\d{1,2})\\s+${MONTH_PATTERN}(?![a-zа-яё])`, 'gi'), m => {
204    add(near(monthOf(m[2] ?? ''), Number(m[1]), today))
205    add(near(monthOf(m[4] ?? ''), Number(m[3]), today))
206  })
207  take(new RegExp(`(?<![a-zа-яё])${MONTH_PATTERN}\\.?\\s+(\\d{1,2})${DASH}${MONTH_PATTERN}(?![a-zа-яё])\\.?\\s+(\\d{1,2})\\b`, 'gi'), m => {
208    add(near(monthOf(m[1] ?? ''), Number(m[2]), today))
209    add(near(monthOf(m[3] ?? ''), Number(m[4]), today))
210  })
211  // 10–14 August; Aug 10–14
212  take(new RegExp(`\\b(\\d{1,2})${DASH}(\\d{1,2})\\s+${MONTH_PATTERN}(?![a-zа-яё])`, 'gi'), m => {
213    add(near(monthOf(m[3] ?? ''), Number(m[1]), today))
214    add(near(monthOf(m[3] ?? ''), Number(m[2]), today))
215  })
216  take(new RegExp(`(?<![a-zа-яё])${MONTH_PATTERN}\\.?\\s+(\\d{1,2})${DASH}(\\d{1,2})\\b`, 'gi'), m => {
217    add(near(monthOf(m[1] ?? ''), Number(m[2]), today))
218    add(near(monthOf(m[1] ?? ''), Number(m[3]), today))
219  })
220  // 14 August; Aug 14
221  take(new RegExp(`\\b(\\d{1,2})\\s+${MONTH_PATTERN}(?![a-zа-яё])`, 'gi'), m => add(near(monthOf(m[2] ?? ''), Number(m[1]), today)))
222  take(new RegExp(`(?<![a-zа-яё])${MONTH_PATTERN}\\.?\\s+(\\d{1,2})\\b`, 'gi'), m => add(near(monthOf(m[1] ?? ''), Number(m[2]), today)))
223
224  return found
225}
226
227// The footnote for a reply whose dates all miss the period asked for, if any.
228export function mismatch(period: Period, answer: string, today: Day): string | undefined {
229  const dates = datesIn(answer, today)
230
231  if (dates.length === 0) {
232    return undefined
233  }
234
235  const expected = rangeOf(period, today)
236  // A week asked for counts its whole calendar week; a day or month is exact.
237  const isWeek = period === 'thisWeek' || period === 'lastWeek'
238  const from = isWeek ? serial(expected.from) - weekday(expected.from) : serial(expected.from)
239  const to = isWeek ? from + 6 : serial(expected.to)
240
241  if (dates.some(day => serial(day) >= from && serial(day) <= to)) {
242    return undefined
243  }
244
245  const sorted = [...dates].sort((a, b) => serial(a) - serial(b))
246  const said = showRange({ from: sorted[0] as Day, to: sorted.at(-1) as Day })
247  const name = { today: 'Today is', yesterday: 'Yesterday was', thisWeek: 'This week is', lastWeek: 'Last week was', thisMonth: 'This month is', lastMonth: 'Last month was' }[period]
248
249  return `${name} ${showRange(expected)}. This reply says ${said}`
250}
251
252export function anchorOf(text: string): string {
253  return text.trim().slice(-160)
254}
255
256// The last text block of a reply ends where the turn's answer ends.
257export function isUnder(anchor: string, blockText: string): boolean {
258  const block = anchorOf(blockText)
259
260  return block.length >= 12 && anchor.endsWith(block.slice(-Math.min(block.length, 80)))
261}
262
types/index.d.ts 11 lines
1// A reply whose dates missed the period asked for, and what to say under it.
2export type Flag = { anchor: string; text: string }
3
4declare module 'claude-code' {
5  interface PluginState {
6    clock: {
7      flags: Flag[]
8    }
9  }
10}
11