feat(ui): add Routines page — user-friendly scheduled tasks view#2864
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a Routines management feature: cron-format helpers and tests, RoutineCard and RoutineRunHistory components, a Routines page with state/handlers, protected /routines route and Home integration, i18n keys across locales, a mascot palette fallback, and an observability error-classification tweak. ChangesRoutines Feature Implementation
Bug Fix
Observability
Sequence Diagram(s)sequenceDiagram
participant User
participant RoutinesPage
participant TauriCronAPI
participant RoutineCard
participant RoutineRunHistory
User->>RoutinesPage: open /routines
RoutinesPage->>TauriCronAPI: openhumanCronList()
TauriCronAPI-->>RoutinesPage: jobs[]
RoutinesPage->>RoutineCard: render job props (job, busyKeys, handlers)
User->>RoutineCard: click Run now
RoutineCard->>RoutinesPage: onRunNow(jobId)
RoutinesPage->>TauriCronAPI: openhumanCronRun(jobId)
TauriCronAPI-->>RoutinesPage: run result
RoutinesPage->>TauriCronAPI: openhumanCronRuns(jobId, 10)
TauriCronAPI-->>RoutinesPage: runs[]
RoutinesPage->>RoutineRunHistory: pass runs for display
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/src/AppRoutes.tsx`:
- Around line 130-137: The new Route with path "/routines" inside AppRoutes.tsx
(the JSX Route element mounting <ProtectedRoute requireAuth={true}><Routines
/></ProtectedRoute>) violates the established AppRoutes contract; either remove
this Route or update the route contract in the same change set to declare
"/routines" as an allowed route. Locate the Route node for path="/routines" and
either delete it (and any imports of Routines) or modify the
contract/enumeration that documents supported paths (the routing guideline block
in AppRoutes.tsx) to include "/routines", keeping DefaultRedirect and the
existing allowed paths intact.
In `@app/src/components/routines/routineHelpers.ts`:
- Around line 25-190: The helpers return hard-coded English strings; update
cronToHuman, formatRoutineName, formatNextRun, formatTimeAgo, and formatDuration
to accept a translator function (e.g., t: (key: string, opts?: Record<string,
any>) => string) instead of calling useT in-place, replace all user-facing
literals with i18n keys (e.g., 'routine.every_minute',
'routine.every_N_minutes', 'routine.untitled', 'routine.in_less_than_minute',
'routine.just_now', 'routine.duration_ms', etc.) and pass interpolation
variables (numbers, timeStr, tz) to t for pluralization/formatting; ensure
default behavior remains by keeping a no-op fallback translator that returns the
key or formatted string so existing callers aren’t broken and update call sites
to pass the app's useT-provided translator.
In `@app/src/pages/Routines.tsx`:
- Around line 26-27: The single global busyKey state is not concurrency-safe
because parallel per-job actions overwrite it; replace busyKey/setBusyKey with a
concurrent-aware structure (e.g., busyKeys: Set<string> or Map<string, boolean>
stored via useState or useRef) and update all places that currently call
setBusyKey(key) / setBusyKey(null) to instead add/remove the specific job key
(e.g., busyKeys.add(jobId) and busyKeys.delete(jobId)); change UI checks from
busyKey === id to busyKeys.has(id). Update the routines that perform per-job
actions (e.g., loadRuns, runJob and any handlers referenced where busyKey is
used) to manipulate the new busyKeys collection and to clean up only their own
key in finally blocks so parallel requests don’t clobber each other.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: ebf97149-155d-4af2-84c6-64aab62cfb88
📒 Files selected for processing (23)
app/src/AppRoutes.tsxapp/src/components/routines/RoutineCard.tsxapp/src/components/routines/RoutineRunHistory.tsxapp/src/components/routines/routineHelpers.test.tsapp/src/components/routines/routineHelpers.tsapp/src/features/human/Mascot/mascotPalette.tsapp/src/lib/i18n/chunks/ar-1.tsapp/src/lib/i18n/chunks/bn-1.tsapp/src/lib/i18n/chunks/de-1.tsapp/src/lib/i18n/chunks/en-1.tsapp/src/lib/i18n/chunks/es-1.tsapp/src/lib/i18n/chunks/fr-1.tsapp/src/lib/i18n/chunks/hi-1.tsapp/src/lib/i18n/chunks/id-1.tsapp/src/lib/i18n/chunks/it-1.tsapp/src/lib/i18n/chunks/ko-1.tsapp/src/lib/i18n/chunks/pl-1.tsapp/src/lib/i18n/chunks/pt-1.tsapp/src/lib/i18n/chunks/ru-1.tsapp/src/lib/i18n/chunks/zh-CN-1.tsapp/src/lib/i18n/en.tsapp/src/pages/Home.tsxapp/src/pages/Routines.tsx
| export function cronToHuman(schedule: CoreCronSchedule): string { | ||
| if (schedule.kind === 'at') { | ||
| return `Once at ${new Date(schedule.at).toLocaleString()}`; | ||
| } | ||
|
|
||
| if (schedule.kind === 'every') { | ||
| const ms = schedule.every_ms; | ||
| if (ms < 60_000) { | ||
| const secs = Math.round(ms / 1000); | ||
| return `Every ${secs} second${secs !== 1 ? 's' : ''}`; | ||
| } | ||
| if (ms < 3_600_000) { | ||
| const mins = Math.round(ms / 60_000); | ||
| return `Every ${mins} minute${mins !== 1 ? 's' : ''}`; | ||
| } | ||
| const hrs = Math.round(ms / 3_600_000); | ||
| return `Every ${hrs} hour${hrs !== 1 ? 's' : ''}`; | ||
| } | ||
|
|
||
| // kind === 'cron' | ||
| const expr = schedule.expr.trim(); | ||
| const parts = expr.split(/\s+/); | ||
| if (parts.length < 5) return expr; | ||
|
|
||
| const [minPart, hourPart, domPart, monPart, dowPart] = parts; | ||
|
|
||
| // Every minute: * * * * * | ||
| if (minPart === '*' && hourPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*') | ||
| return 'Every minute'; | ||
|
|
||
| // Every N minutes: */N * * * * | ||
| const everyMinMatch = minPart.match(/^\*\/(\d+)$/); | ||
| if (everyMinMatch && hourPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*') { | ||
| const n = parseInt(everyMinMatch[1], 10); | ||
| return `Every ${n} minute${n !== 1 ? 's' : ''}`; | ||
| } | ||
|
|
||
| // Every hour: 0 * * * * | ||
| if (minPart === '0' && hourPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*') | ||
| return 'Every hour'; | ||
|
|
||
| // Every N hours: 0 */N * * * | ||
| const everyHourMatch = hourPart.match(/^\*\/(\d+)$/); | ||
| if (minPart === '0' && everyHourMatch && domPart === '*' && monPart === '*' && dowPart === '*') { | ||
| const n = parseInt(everyHourMatch[1], 10); | ||
| return `Every ${n} hour${n !== 1 ? 's' : ''}`; | ||
| } | ||
|
|
||
| // Fixed time patterns (minute and hour are numeric) | ||
| const min = parseInt(minPart, 10); | ||
| const hour = parseInt(hourPart, 10); | ||
| if (isNaN(min) || isNaN(hour)) return expr; | ||
|
|
||
| const timeStr = formatHour(hour, min); | ||
| const tz = schedule.tz ? ` (${schedule.tz})` : ''; | ||
|
|
||
| // Every day at H:MM: M H * * * | ||
| if (domPart === '*' && monPart === '*' && dowPart === '*') return `Every day at ${timeStr}${tz}`; | ||
|
|
||
| // Weekdays: M H * * 1-5 | ||
| if (domPart === '*' && monPart === '*' && dowPart === '1-5') return `Weekdays at ${timeStr}${tz}`; | ||
|
|
||
| // Weekends: M H * * 0,6 or 6,0 | ||
| if (domPart === '*' && monPart === '*' && (dowPart === '0,6' || dowPart === '6,0')) | ||
| return `Weekends at ${timeStr}${tz}`; | ||
|
|
||
| // Specific day of week: M H * * D | ||
| if (domPart === '*' && monPart === '*' && /^\d$/.test(dowPart)) { | ||
| const dayIndex = parseInt(dowPart, 10); | ||
| const dayName = WEEKDAY_NAMES[dayIndex]; | ||
| if (dayName) return `Every ${dayName} at ${timeStr}${tz}`; | ||
| } | ||
|
|
||
| // Specific day of month: M H D * * | ||
| if (monPart === '*' && dowPart === '*' && /^\d{1,2}$/.test(domPart)) { | ||
| const day = parseInt(domPart, 10); | ||
| const suffix = | ||
| day === 1 || day === 21 || day === 31 | ||
| ? 'st' | ||
| : day === 2 || day === 22 | ||
| ? 'nd' | ||
| : day === 3 || day === 23 | ||
| ? 'rd' | ||
| : 'th'; | ||
| return `On the ${day}${suffix} of every month at ${timeStr}${tz}`; | ||
| } | ||
|
|
||
| // Fallback | ||
| return expr; | ||
| } | ||
|
|
||
| /** | ||
| * Convert a snake_case or kebab-case job name to Title Case. | ||
| */ | ||
| export function formatRoutineName(name?: string | null): string { | ||
| if (!name) return 'Untitled Routine'; | ||
| return name.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); | ||
| } | ||
|
|
||
| /** | ||
| * Format a future ISO timestamp as a friendly relative string. | ||
| */ | ||
| export function formatNextRun(iso: string): string { | ||
| const target = new Date(iso); | ||
| const now = new Date(); | ||
| const diffMs = target.getTime() - now.getTime(); | ||
|
|
||
| if (diffMs < 0) return target.toLocaleString(); | ||
|
|
||
| const diffMin = Math.round(diffMs / 60_000); | ||
| if (diffMin < 1) return 'in less than a minute'; | ||
| if (diffMin < 60) return `in ${diffMin} minute${diffMin !== 1 ? 's' : ''}`; | ||
|
|
||
| const diffHrs = Math.floor(diffMs / 3_600_000); | ||
| if (diffHrs < 24) return `in ${diffHrs} hour${diffHrs !== 1 ? 's' : ''}`; | ||
|
|
||
| // Check if it's tomorrow | ||
| const tomorrow = new Date(now); | ||
| tomorrow.setDate(tomorrow.getDate() + 1); | ||
| if ( | ||
| target.getDate() === tomorrow.getDate() && | ||
| target.getMonth() === tomorrow.getMonth() && | ||
| target.getFullYear() === tomorrow.getFullYear() | ||
| ) { | ||
| return `Tomorrow at ${formatHour(target.getHours(), target.getMinutes())}`; | ||
| } | ||
|
|
||
| return target.toLocaleString(); | ||
| } | ||
|
|
||
| /** | ||
| * Format a past ISO timestamp as relative time (e.g. "2 hours ago"). | ||
| */ | ||
| export function formatTimeAgo(iso: string): string { | ||
| const past = new Date(iso); | ||
| const now = new Date(); | ||
| const diffMs = now.getTime() - past.getTime(); | ||
|
|
||
| if (diffMs < 0) return past.toLocaleString(); | ||
|
|
||
| const diffSec = Math.floor(diffMs / 1000); | ||
| if (diffSec < 60) return 'just now'; | ||
|
|
||
| const diffMin = Math.floor(diffMs / 60_000); | ||
| if (diffMin < 60) return `${diffMin} minute${diffMin !== 1 ? 's' : ''} ago`; | ||
|
|
||
| const diffHrs = Math.floor(diffMs / 3_600_000); | ||
| if (diffHrs < 24) return `${diffHrs} hour${diffHrs !== 1 ? 's' : ''} ago`; | ||
|
|
||
| const diffDays = Math.floor(diffMs / 86_400_000); | ||
| if (diffDays < 30) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`; | ||
|
|
||
| return past.toLocaleDateString(); | ||
| } | ||
|
|
||
| /** | ||
| * Format duration_ms into a compact string like "3s" or "1m 12s". | ||
| */ | ||
| export function formatDuration(ms: number): string { | ||
| if (ms < 1000) return `${ms}ms`; | ||
| const totalSec = Math.round(ms / 1000); | ||
| if (totalSec < 60) return `${totalSec}s`; | ||
| const min = Math.floor(totalSec / 60); | ||
| const sec = totalSec % 60; | ||
| return sec > 0 ? `${min}m ${sec}s` : `${min}m`; | ||
| } |
There was a problem hiding this comment.
Replace hard-coded helper output strings with i18n keys.
cronToHuman, formatRoutineName, formatNextRun, formatTimeAgo, and formatDuration return English literals (e.g., “Every minute”, “Untitled Routine”, “just now”). These are user-visible and bypass translation entirely.
As per coding guidelines: “Every user-visible string in app/src/** … must go through useT() … hard-coded literals are not allowed.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/src/components/routines/routineHelpers.ts` around lines 25 - 190, The
helpers return hard-coded English strings; update cronToHuman,
formatRoutineName, formatNextRun, formatTimeAgo, and formatDuration to accept a
translator function (e.g., t: (key: string, opts?: Record<string, any>) =>
string) instead of calling useT in-place, replace all user-facing literals with
i18n keys (e.g., 'routine.every_minute', 'routine.every_N_minutes',
'routine.untitled', 'routine.in_less_than_minute', 'routine.just_now',
'routine.duration_ms', etc.) and pass interpolation variables (numbers, timeStr,
tz) to t for pluralization/formatting; ensure default behavior remains by
keeping a no-op fallback translator that returns the key or formatted string so
existing callers aren’t broken and update call sites to pass the app's
useT-provided translator.
There was a problem hiding this comment.
Valid concern, but these are pure utility functions that can't use useT() (React hook). The existing CoreJobList.tsx (developer cron page) has the same pattern — raw schedule text from the same kind of formatting. Threading a t function through every helper adds significant complexity for marginal i18n gain (schedule expressions like "Every day at 7:00 AM" are quasi-universal). Will address in a follow-up if localization of schedule descriptions becomes a priority.
There was a problem hiding this comment.
@graycyrus — understood. The hook constraint is real, and the t-threading approach is a non-trivial refactor. Given the existing precedent in CoreJobList.tsx and the quasi-universal nature of schedule expressions, deferring this is reasonable. Would you like me to open a GitHub issue to track the follow-up ("Localize routineHelpers schedule/time strings") so it isn't lost?
✏️ Learnings added
Learnt from: graycyrus
Repo: tinyhumansai/openhuman PR: 2864
File: app/src/components/routines/routineHelpers.ts:25-190
Timestamp: 2026-05-28T20:58:50.263Z
Learning: In `app/src/components/routines/routineHelpers.ts`, the formatting helpers (`cronToHuman`, `formatRoutineName`, `formatNextRun`, `formatTimeAgo`, `formatDuration`) intentionally return hard-coded English strings. They are pure TypeScript utility functions (not React components) and therefore cannot call `useT()`. The project treats schedule/time expressions as quasi-universal and defers i18n for these helpers. The developer cron page (`CoreJobList.tsx`) follows the same pattern. Localization of these helpers is a future concern, not a current requirement.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
|
Actionable comments posted: 0 |
|
Actionable comments posted: 0 |
2 similar comments
|
Actionable comments posted: 0 |
|
Actionable comments posted: 0 |
86b4d8b to
1526420
Compare
b1d5173 to
a10f2c9
Compare
Introduces /routines as a new top-level page that presents cron jobs in
human-readable language. Converts cron expressions to natural language
("Every day at 7:00 AM"), hides UUIDs, shows toggle switches, color-coded
status dots, and collapsible run history. Adds a "Your Routines" card on
the Home page linking to the new route. Developer Options cron page is
unchanged.
getMascotPalette() crashed with "Cannot read properties of undefined (reading 'bodyFill')" when the persisted mascot color didn't match any key in the palettes record. Fall back to YELLOW_PALETTE instead.
…th catch-all The `is_embedding_backend_auth_failure` predicate (lowercased substring match) was running before `is_session_expired_message`, causing the OpenHuman backend's "Invalid token" 401 via the embedding path (TAURI-RUST-4K5) to be classified as BackendUserError instead of SessionExpired. Move session-expired check earlier so its strict conjunctive anchor takes precedence; third-party BYO-key 401s still fall through to the embedding catch-all.
Addresses CodeRabbit review: a single global busyKey was not concurrency-safe across parallel per-job actions. Now each job's busy state is tracked independently via a Set, so toggling job A doesn't clobber the loading state of job B.
a10f2c9 to
d10224e
Compare
M3gA-Mind
left a comment
There was a problem hiding this comment.
LGTM. Rebased onto upstream/main, resolved observability.rs conflict by keeping upstream's unified SessionExpired classification for both wire shapes. All quality checks pass: typecheck, lint (0 errors), format:check, i18n:check.
M3gA-Mind
left a comment
There was a problem hiding this comment.
All three CodeRabbit CHANGES_REQUESTED items have been addressed:
-
/routinesroute contract — @graycyrus correctly noted CLAUDE.md's route listing is descriptive (a snapshot), not a closed contract. The new/routinesroute follows the exact same<Route>pattern as every other protected route. Non-blocking. -
Hard-coded strings in
routineHelpers.ts— The concern is valid but the hook constraint is real: pure utility functions can't calluseT(). The existingCoreJobList.tsxdeveloper page already uses the same pattern for schedule text. CodeRabbit acknowledged this and agreed to defer. Non-blocking. -
busyKeyconcurrency — Fixed in commit 27b1603 by replacing the singlebusyKey: string | nullwithbusyKeys: Set<string>, so each job's busy state is tracked independently. CodeRabbit confirmed the fix is correct.
The Routines feature itself is clean: good UX for a previously buried capability, i18n keys in all 14 locale chunk files, 20 unit tests for the helpers, and the two bug fixes (mascot palette fallback + observability classifier ordering) are straightforward and well-tested.
Approving. ✅
All three actionable items addressed: busyKey concurrency fixed (Set), route contract note addressed by author, i18n helper precedent acknowledged. Dismissing stale CHANGES_REQUESTED.
Summary
/routinestop-level page that presents cron jobs (scheduled tasks) in human-readable, layman-friendly languageWhat changed
app/src/pages/Routines.tsx— fetches and displays scheduled tasksRoutineCard.tsx,RoutineRunHistory.tsx— card-based UI with toggles and expandable historyroutineHelpers.ts—cronToHuman(),formatRoutineName(),formatNextRun(),formatTimeAgo(),formatDuration()/routinesadded toAppRoutes.tsxas a protected route/routinesroutines.*keys added toen.tsand all 14 locale chunk filesgetMascotPalette()now falls back to default when color is unrecognizedexpected_error_kind()ordering —is_session_expired_messagenow runs beforeis_embedding_backend_auth_failureUser experience before → after
0 7 * * *bbc33fa2-9157-...*/30 * * * *ok/errorTest plan
pnpm typecheckpassespnpm lint— 0 errorspnpm format:check— cleanpnpm i18n:check— all 14 locales in sync, 0 missing keysroutineHelpers(cronToHuman, formatRoutineName, formatDuration)/routinespage renders with human-readable job dataSummary by CodeRabbit
New Features
Bug Fixes
Tests