Repository navigation
feat(scheduling): a clickable schedule picker in the kit replaces the Routines cron box; plain-language summaries, server preview, cron-only until the scheduler lands (#18432) #18495
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
00c0a4f
d4cd09c
3308f9e
b74c74f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| <!-- | ||
| Copyright 2025-2026 mrveiss | ||
| SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| SchedulePickerField — the app's wiring of the shared kit SchedulePicker | ||
| (#18432): supplies the i18n translator and the server preview call. This is | ||
| the ONE picker entry point for Routines, the workflow Schedule trigger and | ||
| the automation setup; do not instantiate the kit component directly. | ||
| --> | ||
| <script setup lang="ts"> | ||
| import { SchedulePicker, type ScheduleSpec } from '@autobot/ui' | ||
| import { useScheduleI18n, useSchedulePreviewCall } from '@/composables/useSchedule' | ||
|
|
||
| defineProps<{ | ||
| modelValue: ScheduleSpec | null | ||
| /** The company's IANA zone, once the company record carries one. */ | ||
| companyTimezone?: string | ||
| }>() | ||
| const emit = defineEmits<{ | ||
| 'update:modelValue': [spec: ScheduleSpec] | ||
| 'update:valid': [valid: boolean] | ||
| }>() | ||
|
|
||
| const { translate, locale } = useScheduleI18n() | ||
| const preview = useSchedulePreviewCall() | ||
| </script> | ||
|
|
||
| <template> | ||
| <SchedulePicker | ||
| :model-value="modelValue" | ||
| :translate="translate" | ||
| :locale="locale" | ||
| :company-timezone="companyTimezone" | ||
| :default-timezone="companyTimezone ?? 'UTC'" | ||
| :preview="preview" | ||
| @update:model-value="emit('update:modelValue', $event)" | ||
| @update:valid="emit('update:valid', $event)" | ||
| /> | ||
| </template> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| // Copyright 2025-2026 mrveiss | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // #18432: every key the shared picker can ask for exists in ALL 11 locales, and | ||
| // every summary shape renders as prose (a missing key would render as its name). | ||
|
|
||
| import { describe, it, expect, beforeAll, vi } from 'vitest' | ||
| import { mount, flushPromises } from '@vue/test-utils' | ||
| import { createI18n } from 'vue-i18n' | ||
| import { compilePickerRule, WEEKDAYS, type PickerRule } from '@autobot/ui' | ||
|
|
||
| vi.mock('@/plugins/api', () => ({ useApiClient: () => ({ post: vi.fn().mockRejectedValue(new Error('HTTP 404')) }) })) | ||
|
|
||
| import SchedulePickerField from '../SchedulePickerField.vue' | ||
|
|
||
| const files = import.meta.glob('@/i18n/locales/*.json', { eager: true, import: 'default' }) as Record<string, Record<string, unknown>> | ||
| const locales = Object.fromEntries(Object.entries(files).map(([p, m]) => [p.split('/').pop()!.replace('.json', ''), m])) | ||
|
|
||
| const RULES: PickerRule[] = [ | ||
| { repeat: 'minutes', every: 15 }, | ||
| { repeat: 'minutes', every: 15, days: ['MO', 'WE'] }, | ||
| { repeat: 'minutes', every: 15, window: { from: '08:00', to: '18:00' }, days: [...WEEKDAYS].slice(0, 5) }, | ||
| { repeat: 'hourly', minute: 5 }, | ||
| { repeat: 'daily', time: '09:00', weekdaysOnly: true }, | ||
| { repeat: 'weekly', time: '09:00', days: ['MO', 'FR'] }, | ||
| { repeat: 'monthly', time: '09:00', on: { day: 31 } }, | ||
| { repeat: 'monthly', time: '09:00', on: { lastDay: true } }, | ||
| { repeat: 'monthly', time: '09:00', on: { nth: -1, weekday: 'FR' } }, | ||
| { repeat: 'monthly', time: '09:00', on: { nth: 3, weekday: 'TU' } }, | ||
| { repeat: 'once', date: '2030-01-02', time: '08:00' }, | ||
| ] | ||
|
|
||
| describe('SchedulePickerField i18n (#18432)', () => { | ||
| beforeAll(async () => { | ||
| const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: locales.en as never } }) | ||
| mount(SchedulePickerField, { props: { modelValue: null }, global: { plugins: [i18n] } }) | ||
| await flushPromises() | ||
| }) | ||
|
|
||
| it('ships the schedule namespace in all 11 locales with identical keys', () => { | ||
| expect(Object.keys(locales).sort()).toHaveLength(11) | ||
| const flat = (o: unknown, p = ''): string[] => | ||
| typeof o === 'object' && o ? Object.entries(o).flatMap(([k, v]) => flat(v, p ? `${p}.${k}` : k)) : [p] | ||
| const en = flat((locales.en as Record<string, unknown>).schedule).sort() | ||
| expect(en.length).toBeGreaterThan(70) | ||
| for (const [code, messages] of Object.entries(locales)) { | ||
| expect(flat((messages as Record<string, unknown>).schedule).sort(), code).toEqual(en) | ||
| } | ||
| }) | ||
|
|
||
| it.each(Object.keys(locales))('%s: every summary and the picker chrome render as prose, not keys', async (code) => { | ||
| const i18n = createI18n({ legacy: false, locale: code, fallbackLocale: 'en', missingWarn: false, messages: locales as never }) | ||
| for (const rule of RULES) { | ||
| const wrapper = mount(SchedulePickerField, { props: { modelValue: compilePickerRule(rule, 'UTC') }, global: { plugins: [i18n] } }) | ||
| await flushPromises() | ||
| expect(wrapper.find('.aui-sched__summary').text(), JSON.stringify(rule)).not.toMatch(/schedule\.|\{|\}/) | ||
| expect(wrapper.text(), JSON.stringify(rule)).not.toMatch(/schedule\.[a-zA-Z_]+/) | ||
| wrapper.unmount() | ||
| } | ||
| }) | ||
|
|
||
| it.each(Object.keys(locales))('%s: server error codes render as prose', async (code) => { | ||
| const i18n = createI18n({ legacy: false, locale: code, fallbackLocale: 'en', missingWarn: false, messages: locales as never }) | ||
| const wrapper = mount(SchedulePickerField, { props: { modelValue: { kind: 'once', timezone: 'UTC', at: '2020-01-01T00:00' } }, global: { plugins: [i18n] } }) | ||
| await flushPromises() | ||
| await wrapper.find('.aui-sched__adv .aui-link').trigger('click') | ||
| await wrapper.findAll('.aui-sched__adv .aui-link').at(-1)!.trigger('click') | ||
| for (const [text, token] of [['0 9 * * 5L', '5L'], ['0 0 9 * * 1', '']]) { | ||
| await wrapper.find('input[id$="-cron"]').setValue(text) | ||
| await flushPromises() | ||
| const msg = wrapper.find('[role="alert"]').text() | ||
| expect(msg).not.toMatch(/schedule\./) | ||
| if (token) expect(msg).toContain(token) | ||
| } | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| // Copyright 2025-2026 mrveiss | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // #18432: app glue for the schedule picker. | ||
|
|
||
| import { describe, it, expect, vi } from 'vitest' | ||
|
|
||
| const post = vi.fn() | ||
| vi.mock('@/plugins/api', () => ({ useApiClient: () => ({ post }) })) | ||
|
|
||
| import { routineSpec, schedulePayload, useSchedulePreviewCall } from '../useSchedule' | ||
|
|
||
| describe('routineSpec', () => { | ||
| it('reads a bare cron string as UTC, never the company zone (no silent shift)', () => { | ||
| expect(routineSpec({ cron_schedule: '0 9 * * 1-5' })).toEqual({ kind: 'cron', timezone: 'UTC', cron: '0 9 * * 1-5' }) | ||
| }) | ||
| it('prefers a stored spec', () => { | ||
| const spec = { kind: 'calendar' as const, timezone: 'Europe/Helsinki', rrule: 'FREQ=MONTHLY;BYMONTHDAY=-1;BYHOUR=9;BYMINUTE=0' } | ||
| expect(routineSpec({ cron_schedule: '0 9 1 * *', schedule_spec: spec })).toBe(spec) | ||
| }) | ||
| it('has no spec for a routine with no schedule', () => { | ||
| expect(routineSpec({ cron_schedule: '' })).toBeNull() | ||
| }) | ||
| }) | ||
|
|
||
| describe('useSchedulePreviewCall', () => { | ||
| const spec = { kind: 'cron' as const, timezone: 'UTC', cron: '0 9 * * *' } | ||
| it('posts {spec, count} to the preview endpoint and returns the body', async () => { | ||
| post.mockResolvedValueOnce({ valid: true, next_runs: ['2030-01-01T09:00:00Z'] }) | ||
| expect(await useSchedulePreviewCall()(spec, 3)).toEqual({ valid: true, next_runs: ['2030-01-01T09:00:00Z'] }) | ||
| expect(post).toHaveBeenCalledWith('/api/schedules/preview', { spec, count: 3 }) | ||
| }) | ||
| it('resolves null (preview unavailable) when the endpoint 404s', async () => { | ||
| post.mockRejectedValueOnce(new Error('HTTP 404: Not Found')) | ||
| expect(await useSchedulePreviewCall()(spec, 3)).toBeNull() | ||
| }) | ||
| }) | ||
|
|
||
| describe('schedulePayload', () => { | ||
| it('sets exactly one of cron_schedule / schedule_spec', () => { | ||
| expect(schedulePayload({ kind: 'cron', timezone: 'UTC', cron: '0 9 * * *' })).toEqual({ cron_schedule: '0 9 * * *' }) | ||
| const cal = { kind: 'calendar' as const, timezone: 'UTC', rrule: 'FREQ=MONTHLY;BYMONTHDAY=-1;BYHOUR=9;BYMINUTE=0' } | ||
| expect(schedulePayload(cal)).toEqual({ schedule_spec: cal }) | ||
| const once = { kind: 'once' as const, timezone: 'UTC', at: '2030-01-01T09:00' } | ||
| expect(schedulePayload(once)).toEqual({ schedule_spec: once }) | ||
| expect(schedulePayload(null)).toEqual({}) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,71 @@ | ||||||||||||||||||||||||||||||||||||||||||||
| // Copyright 2025-2026 mrveiss | ||||||||||||||||||||||||||||||||||||||||||||
| // SPDX-License-Identifier: Apache-2.0 | ||||||||||||||||||||||||||||||||||||||||||||
| // AutoBot - AI-Powered Automation Platform | ||||||||||||||||||||||||||||||||||||||||||||
| // Author: mrveiss | ||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||
| * App-side glue for the shared schedule picker (#18432): the server preview | ||||||||||||||||||||||||||||||||||||||||||||
| * call, the i18n translator and the derived summary used by every list. | ||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||
| * Contract: ScheduleSpec / PickerRule live in @autobot/ui (libs/autobot-ui/ | ||||||||||||||||||||||||||||||||||||||||||||
| * src/schedule). The server (POST /api/schedules/preview, the same code the | ||||||||||||||||||||||||||||||||||||||||||||
| * scheduler runs) is the source of truth; the picker's own preview is advisory. | ||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||
| import { useI18n } from 'vue-i18n' | ||||||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||||||
| parseSpecToRule, | ||||||||||||||||||||||||||||||||||||||||||||
| summaryText, | ||||||||||||||||||||||||||||||||||||||||||||
| type PreviewResult, | ||||||||||||||||||||||||||||||||||||||||||||
| type ScheduleSpec, | ||||||||||||||||||||||||||||||||||||||||||||
| type SchedulePreviewFn, | ||||||||||||||||||||||||||||||||||||||||||||
| type ScheduleTranslate, | ||||||||||||||||||||||||||||||||||||||||||||
| } from '@autobot/ui' | ||||||||||||||||||||||||||||||||||||||||||||
| import { useApiClient } from '@/plugins/api' | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||
| * Existing routines carry a bare cron string that the scheduler evaluates in | ||||||||||||||||||||||||||||||||||||||||||||
| * UTC today — it is read as {cron, UTC}, NOT the company zone, so nothing | ||||||||||||||||||||||||||||||||||||||||||||
| * silently shifts. A stored spec, once the backend provides one, wins. | ||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||
| export function routineSpec(routine: { cron_schedule?: string | null; schedule_spec?: ScheduleSpec | null }): ScheduleSpec | null { | ||||||||||||||||||||||||||||||||||||||||||||
| if (routine.schedule_spec) return routine.schedule_spec | ||||||||||||||||||||||||||||||||||||||||||||
| return routine.cron_schedule ? { kind: 'cron', timezone: 'UTC', cron: routine.cron_schedule } : null | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||
| * Routine payload fragment: EXACTLY ONE of `cron_schedule` / `schedule_spec`. | ||||||||||||||||||||||||||||||||||||||||||||
| * A cron spec is today's payload; calendar and once go as `schedule_spec` | ||||||||||||||||||||||||||||||||||||||||||||
| * alone. Sending both is a 422 `schedule.conflicting_fields`. | ||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||
| export function schedulePayload(spec: ScheduleSpec | null): Record<string, unknown> { | ||||||||||||||||||||||||||||||||||||||||||||
| if (!spec) return {} | ||||||||||||||||||||||||||||||||||||||||||||
| return spec.kind === 'cron' ? { cron_schedule: spec.cron } : { schedule_spec: spec } | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| /** Server preview. Resolves null when the endpoint is unavailable (404, offline). */ | ||||||||||||||||||||||||||||||||||||||||||||
| export function useSchedulePreviewCall(): SchedulePreviewFn { | ||||||||||||||||||||||||||||||||||||||||||||
| const api = useApiClient() | ||||||||||||||||||||||||||||||||||||||||||||
| return async (spec, count) => { | ||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||
| return await api.post<PreviewResult>('/api/schedules/preview', { spec, count }) | ||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||
| return null | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+45
to
+54
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Do not convert every preview failure into "unavailable".
Proposed fix- } catch {
- return null
+ } catch (err) {
+ if (err instanceof Error && /\b404\b/.test(err.message)) return null
+ throw err
}As per path instructions: "a caught error that resolves to an empty or default value — a request that failed must stay distinguishable from one that legitimately returned nothing." 📝 Committable suggestion
Suggested change
Source: Path instructions |
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| /** Translator + locale bound to the app's i18n, in the shape the kit expects. */ | ||||||||||||||||||||||||||||||||||||||||||||
| export function useScheduleI18n() { | ||||||||||||||||||||||||||||||||||||||||||||
| const { t, locale } = useI18n() | ||||||||||||||||||||||||||||||||||||||||||||
| const translate: ScheduleTranslate = (key, params) => t(key, params ?? {}) | ||||||||||||||||||||||||||||||||||||||||||||
| return { translate, locale } | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| /** `summarize(spec)` → "Every weekday at 09:00, UTC"; never cron. */ | ||||||||||||||||||||||||||||||||||||||||||||
| export function useScheduleSummary(companyTimezone?: () => string | undefined) { | ||||||||||||||||||||||||||||||||||||||||||||
| const { translate, locale } = useScheduleI18n() | ||||||||||||||||||||||||||||||||||||||||||||
| return (spec: ScheduleSpec | null): string => { | ||||||||||||||||||||||||||||||||||||||||||||
| if (!spec) return '' | ||||||||||||||||||||||||||||||||||||||||||||
| const zone = spec.timezone === companyTimezone?.() ? translate('schedule.zone.company') : spec.timezone | ||||||||||||||||||||||||||||||||||||||||||||
| return summaryText(parseSpecToRule(spec), zone, translate, locale.value) | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🔎 Supported by static analysis
🏁 Script executed:
Repository: mrveiss/AutoBot-AI
Length of output: 6520
🏁 Script executed:
Repository: mrveiss/AutoBot-AI
Length of output: 31103
🏁 Script executed:
Repository: mrveiss/AutoBot-AI
Length of output: 31397
🏁 Script executed:
Repository: mrveiss/AutoBot-AI
Length of output: 15117
🏁 Script executed:
Repository: mrveiss/AutoBot-AI
Length of output: 2218
🏁 Script executed:
Repository: mrveiss/AutoBot-AI
Length of output: 1036
Persist the selected time zone for cron routines.
The picker offers all IANA time zones, but
schedulePayloadsends only the cron expression. The selected time zone is discarded.The routine API stores only
cron_schedule.routineSpecreloads it asUTC, and the scheduler evaluates it from UTC. A user who selects a non-UTC zone can therefore see and execute the routine at the wrong local time.Persist the time zone through the routine API and scheduler, or restrict the routines picker to UTC until that contract is supported.