Cron
The application runs three background jobs inside the Next.js server process using node-cron. The schedules are declared in src/server/cron.ts and start automatically the first time the server boots. Killing the Node process stops every job; restarting brings them back. There is no external worker required, no Redis queue, no separate scheduler — the cron lives in the same process as the HTTP server because the workloads are small, idempotent, and bounded by your own data, not by a fleet of users.
This document describes what runs, when it runs, why the schedule was chosen, and how each job behaves operationally. If you ever want to swap the in-process scheduler for a real queue (Vercel Cron, BullMQ, Inngest, etc.) the service functions the cron calls (syncHackerNewsForWorkspace, syncDevtoForWorkspace, refreshDevtoForWorkspace, computeDayActivity, summarizeDay, upsertWorkLog) are all directly callable from anywhere — the cron file is just the thin scheduling layer.
Schedules
The three jobs and their cron expressions:
| Job | Cron expression | When (server local time) |
|---|---|---|
| Hacker News sync | 0 */3 * * * | Every three hours at the top of the hour |
| dev.to sync + metric refresh | 30 3 * * * | Daily at 03:30 |
| Daily work log | 55 23 * * * | Daily at 23:55 |
The cron expressions are evaluated in the server's local time zone. If you deploy to a host whose timezone is UTC and you want the daily worklog to run at 23:55 local for you specifically, either set the host's timezone via TZ env variable or adjust the cron expression to match the offset.
Hacker News sync
Every three hours, the job iterates every workspace where hnEnabled is true and hnUsername is set, and calls syncHackerNewsForWorkspace(workspaceId) for each one. The service walks the submitted item list for each configured username (comma-separated handles supported), pulls every story and comment via the public Firebase API, and upserts a ContentRecord for each one keyed on (workspaceId, externalId) where externalId is hn:<id>. The sync is fully idempotent: re-running it touches existing rows via the update branch of upsert, refreshing upvotes (HN score) and comments (descendant count) while preserving any manually edited fields the user has set.
Three-hour cadence was chosen because HN scores stop moving meaningfully after a story falls off the front page, which usually happens within 24 hours of submission, and intra-day three-hour granularity is enough to capture peak-traffic windows without hammering the Firebase API for nothing. The Firebase API is free and aggressive caching makes our request budget effectively unlimited, but politeness matters.
Failures are logged but never block other workspaces — each workspace gets its own try/catch so one workspace's broken HN handle doesn't stop the others from syncing.
dev.to sync
Once a day at 03:30, the job iterates every workspace where devtoEnabled is true and devtoUsername is set, and runs two passes per workspace: first syncDevtoForWorkspace pulls new articles newest-first (stopping at the first article it has already seen), then refreshDevtoForWorkspace walks every existing DEV_TO content record and refreshes its metrics from the API.
Refresh is selective. It only touches articles the workspace's API key actually owns — the article must appear in the /articles/me/all listing for the key holder. Articles added by URL where the workspace's API key doesn't own them (a public blog you bookmarked from another author) are skipped entirely so their owner-curated metrics aren't overwritten. The shares field is never touched by either pass since dev.to's API doesn't expose per-article bookmark counts — whatever the owner manually entered survives every sync.
Daily cadence was chosen because blog post metrics move slowly. Page views and reactions tick up over days, not minutes, and dev.to's free API tier rate-limits per key. Running the refresh once a day at 03:30 (when nobody is staring at the dashboard) gives clean fresh numbers without ever being intrusive.
Daily work log
Every day at 23:55, the job iterates every non-deleted workspace and tries to write today's work log entry. For each workspace it first checks whether the day already has a non-empty summary — if it does (the owner typed their own writeup), the cron skips that workspace entirely. Otherwise it calls computeDayActivity(workspaceId, today) to count the day's task completions, content creations and publishes, and Linear issue closures, then summarizeDay(activity) to generate prose. If OPENAI_API_KEY is set in env the summarisation hits gpt-4o-mini for a one-paragraph natural-language summary; without the key it falls back to a deterministic template like "Shipped 3 tasks, closed 2 issues, published 1 piece of content." The result is upserted into the WorkLog row keyed on (workspaceId, date).
The schedule runs at 23:55 local time so the day is essentially over but the date hasn't ticked yet — if you finish a task at 23:50 the cron picks it up; if you finish one at 00:05 it goes into tomorrow's log. Adjust the cron expression if you want a different cutoff.
Why these aren't on a real queue
The in-process scheduler is the right choice for RsOpsHub because the jobs are small (each takes seconds, not minutes), bounded (number of workspaces, not number of users), idempotent (re-running on failure is safe), and don't fan out (each workspace is processed sequentially, never in parallel against the same downstream API). A real queue introduces operational complexity (a Redis instance, a worker process, a dead-letter queue, observability) that you don't need at personal scale.
If you outgrow the in-process model — e.g. you want to spread cron across multiple server instances, or you want jobs to retry with exponential backoff — the service functions are designed to be called directly from a queue worker. Wrap them in a BullMQ producer or Inngest handler, comment out startCron() in the server entry, and you're done.
Reddit is not on the cron
Reddit content is added by handle but the refresh runs only on demand. Reddit's anonymous JSON API rate-limits aggressively and the data doesn't change after a post falls off the home feed, so polling earns you nothing. Click Sync on the integration card or the content toolbar to refresh; otherwise add new items by URL.
Linear is not on the cron
Linear sync also runs only on demand. The triggers are: clicking Sync on the Linear integration card, clicking Refresh on the notifications page, posting a new comment from inside the app (the sync is awaited after the post so the thread updates instantly), and the moments where the page needs to be sure the local cache is fresh. Linear's GraphQL API is generous but explicit refresh control is the right shape because most workspace changes happen in Linear itself and we'd rather sync immediately on a write than poll every few hours.
Operational notes
The cron is started exactly once via a globalThis.__cronStarted guard, so Next.js dev mode's hot reloading doesn't spin up duplicate jobs. The guard means that if you ever want to disable the cron in development (to avoid hammering APIs during local work) you can set globalThis.__cronStarted = true before startCron() is called, or just comment out the call in the server entry.
NODE_ENV === "test" short-circuits the entire cron initialisation, so test suites don't accidentally fire real syncs.
If you want to observe the cron in production, the simplest tool is the console — every successful tick logs to stdout ([cron][hn] my-workspace synced 5 items, [cron][devto] my-workspace synced 0 new, refreshed 12, [cron][worklog] my-workspace logged via openai: t=3 l=1 c=2 p=0). Pipe stdout into your host's logging system (Datadog, Better Stack, plain CloudWatch) and you have a free observability layer.
For an external uptime ping, expose a tiny /api/health route that returns 200 and check it from UptimeRobot or BetterStack — if the route stops responding, the cron has stopped too (since both live in the same process).