Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}
})
})
47 changes: 47 additions & 0 deletions autobot-frontend/src/composables/__tests__/useSchedule.test.ts
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({})
})
})
71 changes: 71 additions & 0 deletions autobot-frontend/src/composables/useSchedule.ts
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 }
}
Comment on lines +34 to +42

Copy link
Copy Markdown
Contributor

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:

sed -n '28,72p' autobot-frontend/src/composables/useSchedule.ts
sed -n '1,115p' autobot-frontend/src/components/schedule/SchedulePickerField.vue
sed -n '193,240p' libs/autobot-ui/src/components/SchedulePicker.vue
rg -n 'cron_schedule|schedule_spec' autobot-frontend/src/views/llc/RoutinesView.vue | head -45

Repository: mrveiss/AutoBot-AI

Length of output: 6520


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- picker timezone and model flow ---'
rg -n -F --glob '*.vue' --glob '*.ts' -- 'timezone' libs/autobot-ui/src/components/SchedulePicker.vue libs/autobot-ui/src autobot-frontend/src/components/schedule autobot-frontend/src/composables/useSchedule.ts | head -160
printf '%s\n' '--- routine save and schedule bindings ---'
rg -n -F --glob '*.vue' --glob '*.ts' --glob '*.py' -- 'schedulePayload' autobot-frontend/src
rg -n -F --glob '*.vue' --glob '*.ts' --glob '*.py' -- 'cron_schedule' autobot-frontend/src autobot-backend 2>/dev/null | head -220
printf '%s\n' '--- schedule contracts and tests ---'
rg -n -i --glob '*.{py,ts,vue}' -- 'schedule_spec|cron.*timezone|timezone.*cron|RoutinesView|SchedulePicker' autobot-backend autobot-frontend libs 2>/dev/null | head -300

Repository: mrveiss/AutoBot-AI

Length of output: 31103


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- zone list and picker state ---'
rg -n -F --glob '*.ts' --glob '*.vue' -- 'function listZones' libs/autobot-ui/src
sed -n '1,105p' libs/autobot-ui/src/composables/useScheduleState.ts
sed -n '1,100p' libs/autobot-ui/src/components/SchedulePicker.vue
sed -n '1,120p' libs/autobot-ui/src/schedule/zones.ts 2>/dev/null || true
printf '%s\n' '--- routine form and persistence flow ---'
sed -n '250,325p' autobot-frontend/src/views/llc/RoutinesView.vue
sed -n '1,130p' autobot-backend/llc/api/routines.py
sed -n '150,265p' autobot-backend/llc/api/routines.py
printf '%s\n' '--- scheduler and relevant tests ---'
sed -n '70,115p' autobot-backend/llc/scheduler/routine_scheduler.py
sed -n '150,210p' autobot-backend/llc/scheduler/routine_scheduler.py
sed -n '1,130p' autobot-frontend/src/views/llc/__tests__/RoutinesView.schedule.test.ts
sed -n '1,65p' autobot-frontend/src/composables/__tests__/useSchedule.test.ts

Repository: mrveiss/AutoBot-AI

Length of output: 31397


🏁 Script executed:

printf '%s\n' '--- imports and zone-list implementation ---'
sed -n '1,75p' libs/autobot-ui/src/components/SchedulePicker.vue
rg -n -F --glob '*.ts' --glob '*.vue' -- 'listZones' libs/autobot-ui
printf '%s\n' '--- routine save/reload ---'
sed -n '285,320p' autobot-frontend/src/views/llc/RoutinesView.vue
sed -n '1,45p' autobot-frontend/src/composables/useSchedule.ts
printf '%s\n' '--- backend contract and execution ---'
sed -n '45,100p' autobot-backend/llc/api/routines.py
sed -n '155,205p' autobot-backend/llc/api/routines.py
sed -n '215,255p' autobot-backend/llc/api/routines.py
sed -n '90,105p' autobot-backend/llc/scheduler/routine_scheduler.py
sed -n '175,201p' autobot-backend/llc/scheduler/routine_scheduler.py

Repository: mrveiss/AutoBot-AI

Length of output: 15117


🏁 Script executed:

rg -n -F --glob '*.ts' --glob '*.vue' -- 'listZones' libs/autobot-ui/src
rg -n -F --glob '*.ts' --glob '*.vue' -- 'supportedValuesOf' libs/autobot-ui/src
sed -n '1,28p' libs/autobot-ui/src/components/SchedulePicker.vue

Repository: mrveiss/AutoBot-AI

Length of output: 2218


🏁 Script executed:

nl -ba libs/autobot-ui/src/schedule/zone.ts | sed -n '68,90p'

Repository: mrveiss/AutoBot-AI

Length of output: 1036


Persist the selected time zone for cron routines.

The picker offers all IANA time zones, but schedulePayload sends only the cron expression. The selected time zone is discarded.

The routine API stores only cron_schedule. routineSpec reloads it as UTC, 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.


/** 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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".

useSchedulePreviewCall catches every error and returns null. The picker then treats a 500, a network timeout and a 404 the same way: it shows the preview as unavailable and enables the cron-only gate. The path instruction for **/*.{ts,vue} requires that "a request that failed must stay distinguishable from one that legitimately returned nothing". Return null only for a 404 (endpoint not deployed). For any other failure, log it or rethrow it. The kit ask() function already converts a rejection into an advisory fallback.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
}
}
}
export function useSchedulePreviewCall(): SchedulePreviewFn {
const api = useApiClient()
return async (spec, count) => {
try {
return await api.post<PreviewResult>('/api/schedules/preview', { spec, count })
} catch (err) {
if (err instanceof Error && /\b404\b/.test(err.message)) return null
throw err
}
}
}

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)
}
}
99 changes: 99 additions & 0 deletions autobot-frontend/src/i18n/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -9636,5 +9636,104 @@
"pushFailed": "لم نتمكن من إضافة هذا التسجيل إلى قاعدة المعرفة. يُرجى المحاولة مرة أخرى.",
"collectionNotJoined": "تم الحفظ، لكن لم يتم العثور على المجموعة المحددة — النص موجود في قاعدة المعرفة وليس في تلك المجموعة."
}
},
"schedule": {
"title": "الجدولة",
"repeats": "التكرار",
"repeat": {
"minutes": "كل بضع دقائق",
"hourly": "كل ساعة",
"daily": "يوميًا",
"weekly": "أسبوعيًا",
"monthly": "شهريًا",
"once": "مرة واحدة"
},
"every": "كل",
"minutesUnit": "دقيقة",
"onlyBetween": "فقط بين",
"windowFrom": "من",
"windowTo": "إلى",
"and": "و",
"onDays": "في الأيام (اختياري)",
"daysHintAny": "عدم تحديد أي يوم يعني كل الأيام.",
"onDaysRequired": "في الأيام",
"atMinute": "عند الدقيقة",
"at": "في الساعة",
"hour": "الساعة",
"minute": "الدقيقة",
"weekdaysOnly": "أيام العمل فقط",
"monthly": {
"which": "أي يوم من الشهر",
"day": "يوم من الشهر",
"lastDay": "اليوم الأخير",
"nth": "يوم من أيام الأسبوع في الشهر",
"onDay": "في اليوم",
"onThe": "في"
},
"nth": {
"1": "الأول",
"2": "الثاني",
"3": "الثالث",
"4": "الرابع",
"last": "الأخير"
},
"weekday": "يوم الأسبوع",
"shortMonths": "الأشهر التي لا تحتوي هذا اليوم تُنفَّذ في آخر يوم من الشهر.",
"date": "التاريخ",
"timezone": "المنطقة الزمنية",
"zone": {
"company": "توقيت الشركة",
"usesCompany": "تستخدم المنطقة الزمنية للشركة."
},
"summary": {
"minutes": "كل {every} دقيقة",
"minutesWeekdays": "كل {every} دقيقة في أيام العمل",
"minutesDays": "كل {every} دقيقة، في أيام: {days}",
"minutesWindow": "كل {every} دقيقة بين {from} و{to}",
"minutesWindowWeekdays": "كل {every} دقيقة بين {from} و{to} في أيام العمل",
"minutesWindowDays": "كل {every} دقيقة بين {from} و{to}، في أيام: {days}",
"hourly": "كل ساعة عند الدقيقة {minute}",
"daily": "كل يوم في الساعة {time}",
"dailyWeekdays": "كل يوم عمل في الساعة {time}",
"weekly": "كل أسبوع: {days}، في الساعة {time}",
"monthlyDay": "في اليوم {day} من كل شهر في الساعة {time}",
"monthlyLastDay": "في آخر يوم من كل شهر في الساعة {time}",
"monthlyNth1": "في {weekday} الأول من كل شهر في الساعة {time}",
"monthlyNth2": "في {weekday} الثاني من كل شهر في الساعة {time}",
"monthlyNth3": "في {weekday} الثالث من كل شهر في الساعة {time}",
"monthlyNth4": "في {weekday} الرابع من كل شهر في الساعة {time}",
"monthlyNthLast": "في {weekday} الأخير من كل شهر في الساعة {time}",
"once": "مرة واحدة في {date} في الساعة {time}",
"withZone": "{summary}، {zone}",
"custom": "جدولة مخصصة"
},
"customNote": "لا يمكن عرض هذه الجدولة بالنقرات.",
"editCron": "تحرير بصيغة cron",
"doneEditing": "إنهاء التحرير",
"nextRuns": "عمليات التشغيل القادمة",
"noRuns": "لا توجد عمليات تشغيل قادمة.",
"previewLoading": "جارٍ التحقق من الخادم...",
"previewApprox": "تقريبي: يؤكد الخادم الأوقات الدقيقة عند الحفظ.",
"previewUnavailable": "لا يمكن معاينة عمليات التشغيل لهذه الجدولة.",
"advanced": "متقدم: تُحفظ بصيغة",
"saved": {
"cron": "cron قياسي",
"calendar": "قاعدة تقويم",
"once": "تشغيل لمرة واحدة"
},
"savedAs": "الجدولة المحفوظة",
"savedNote": {
"once": "تعمل مرة واحدة. لا يُحفظ أي cron متكرر."
},
"cronLabel": "تعبير cron",
"cronHint": "cron قياسي بخمسة حقول فقط: الدقيقة، الساعة، يوم الشهر، الشهر، يوم الأسبوع. يبقى المحدد وهذا الحقل متزامنين.",
"cron_extension_unsupported": "{token} ليس بصيغة cron قياسية. استخدم المحدد لجداول مثل آخر جمعة في الشهر.",
"invalid_timezone": "هذه المنطقة الزمنية غير معروفة.",
"once_in_past": "هذا التاريخ والوقت قد مضيا بالفعل.",
"invalid_rrule": "قاعدة التقويم هذه غير صالحة.",
"invalid_cron": "أدخل cron قياسيًا بخمسة حقول، مثل 0 9 * * 1-5.",
"invalid_generic": "هذه الجدولة غير صالحة.",
"conflicting_fields": "تُحفظ الجدولة إما بصيغة cron أو كقاعدة تقويم أو تشغيل لمرة واحدة، وليس كليهما.",
"gatedNote": "سيتوفر اليوم الأخير ويوم الأسبوع من الشهر والأيام من 29 إلى 31 والتشغيل لمرة واحدة بعد تحديث المجدول."
}
}
Loading
Loading