Skip to content
Merged
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
591 changes: 405 additions & 186 deletions dashboard/src/v2/SchedulerPage.tsx

Large diffs are not rendered by default.

344 changes: 344 additions & 0 deletions dashboard/src/v2/i18n/messages/scheduler.ts

Large diffs are not rendered by default.

106 changes: 84 additions & 22 deletions dashboard/src/v2/lib/scheduler-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import type {
UpdateSchedulerEntryInput,
} from "../types.js";
import { fetchJson } from "../../lib/api/fetch-json.js";
import { createDashboardFormatters } from "../i18n/formatters.js";
import {
resolveDashboardLocale,
translateDashboardMessage,
type DashboardLocale,
} from "../i18n/locales.js";
import { schedulerMessages } from "../i18n/messages/scheduler.js";

export interface AgentSchedulerSummaryEntry {
id: string;
Expand All @@ -33,31 +40,71 @@ export const fetchProjectSchedule = async (
return fetchJson<SchedulerCollectionResponse>(`${url.pathname}${url.search}`, { signal });
};

const scheduleAnchorOffsetLabel = (offsetMinutes?: number): string => {
const activePresentationLocale = (): DashboardLocale => resolveDashboardLocale(
typeof document === "undefined" ? undefined : document.documentElement.lang,
);

export const formatSchedulerDateValueInTimeZone = (
value: Date | number,
locale: DashboardLocale,
options: Omit<Intl.DateTimeFormatOptions, "timeZone">,
timeZone?: string,
): string => {
const formatters = createDashboardFormatters(locale);
if (timeZone) {
try {
return formatters.formatDate(value, { ...options, timeZone });
} catch {
// Legacy rows may contain timezone identifiers unsupported by the host.
// Keep the identifier verbatim in the UI and format in the active locale.
}
}
return formatters.formatDate(value, options);
};

const scheduleAnchorOffsetLabel = (offsetMinutes: number | undefined, locale: DashboardLocale): string => {
const offset = Math.max(0, Math.floor(Number(offsetMinutes ?? 0)));
if (offset === 0) {
return "";
}
return offset === 1 ? " + 1 minute" : ` + ${offset} minutes`;
return translateDashboardMessage(
schedulerMessages,
locale,
offset === 1 ? "offsetOneMinute" : "offsetManyMinutes",
{ count: offset },
);
};

const formatScheduleDateTime = (iso: string | null | undefined): string => {
export const formatScheduleDateTime = (
iso: string | null | undefined,
locale: DashboardLocale = activePresentationLocale(),
timeZone?: string,
): string => {
if (!iso) {
return "No scheduled time";
return translateDashboardMessage(schedulerMessages, locale, "noScheduledTime");
}
const date = new Date(iso);
if (!Number.isFinite(date.getTime())) {
return "No scheduled time";
return translateDashboardMessage(schedulerMessages, locale, "noScheduledTime");
}
return date.toLocaleString(undefined, {
return formatSchedulerDateValueInTimeZone(date, locale, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}, timeZone);
};

const statusLabel = (status: ScheduleStatus): string => status.replaceAll("_", " ");
const statusLabel = (status: ScheduleStatus, locale: DashboardLocale): string => {
const key = {
scheduled: "statusScheduled",
paused: "statusPaused",
completed: "statusCompleted",
failed: "statusFailed",
cancelled: "statusCancelled",
} as const;
return translateDashboardMessage(schedulerMessages, locale, key[status]);
};

const isAgentSchedulerSource = (entry: SchedulerEntryRecord): boolean => {
if (entry.targetType === "agent_wakeup") {
Expand All @@ -81,39 +128,53 @@ export const isActiveAgentSchedulerEntry = (entry: SchedulerEntryRecord): entry

export const toAgentSchedulerSummaryEntry = (entry: SchedulerEntryRecord & {
targetType: "agent_wakeup" | "task";
}): AgentSchedulerSummaryEntry => {
}, locale: DashboardLocale = activePresentationLocale()): AgentSchedulerSummaryEntry => {
const scheduledAt = entry.nextRunAt ?? entry.scheduledFor ?? null;
const offset = scheduleAnchorOffsetLabel(entry.scheduleAnchor?.offsetMinutes, locale);
const timingSummary = entry.scheduleAnchor?.mode === "after_sprint_end"
? `After source sprint ${entry.scheduleAnchor.sourceSprintId} ends${scheduleAnchorOffsetLabel(entry.scheduleAnchor.offsetMinutes)}`
: `Scheduled for ${formatScheduleDateTime(scheduledAt)}`;
? translateDashboardMessage(schedulerMessages, locale, "anchorAfterSourceSprint", {
sprintId: entry.scheduleAnchor.sourceSprintId,
offset,
})
: entry.scheduleAnchor?.mode === "after_task_end"
? translateDashboardMessage(schedulerMessages, locale, "anchorAfterSourceTask", {
taskId: entry.scheduleAnchor.sourceTaskId,
offset,
})
: translateDashboardMessage(schedulerMessages, locale, "scheduledFor", {
date: formatScheduleDateTime(scheduledAt, locale, entry.timezone),
});

if (entry.targetType === "agent_wakeup") {
return {
id: entry.id,
targetType: entry.targetType,
label: "Agent wakeup",
title: entry.title || entry.agentWakeupTarget?.title || "Agent wakeup",
label: translateDashboardMessage(schedulerMessages, locale, "targetAgentWakeup"),
title: entry.title || entry.agentWakeupTarget?.title || translateDashboardMessage(schedulerMessages, locale, "targetAgentWakeup"),
status: entry.status,
statusLabel: statusLabel(entry.status),
statusLabel: statusLabel(entry.status, locale),
timingSummary,
targetSummary: entry.agentWakeupTarget?.threadId
? `Thread ${entry.agentWakeupTarget.threadId}`
: "Project chat wakeup",
? translateDashboardMessage(schedulerMessages, locale, "threadSummary", { threadId: entry.agentWakeupTarget.threadId })
: translateDashboardMessage(schedulerMessages, locale, "projectChatWakeup"),
scheduledAt,
};
}

return {
id: entry.id,
targetType: entry.targetType,
label: "Task run",
title: entry.title || "Scheduled task run",
label: translateDashboardMessage(schedulerMessages, locale, "taskRun"),
title: entry.title || translateDashboardMessage(schedulerMessages, locale, "scheduledTaskRun"),
status: entry.status,
statusLabel: statusLabel(entry.status),
statusLabel: statusLabel(entry.status, locale),
timingSummary,
targetSummary: entry.taskTarget?.taskId
? `Task ${entry.taskTarget.taskId}${entry.taskTarget.provider ? ` · ${entry.taskTarget.provider}` : ""}`
: "Task rerun",
? translateDashboardMessage(schedulerMessages, locale, "taskSummary", {
taskId: entry.taskTarget.taskId,
provider: entry.taskTarget.provider ? ` · ${entry.taskTarget.provider}` : "",
})
: translateDashboardMessage(schedulerMessages, locale, "taskRerun"),
scheduledAt,
};
};
Expand All @@ -133,12 +194,13 @@ const scheduleSortValue = (entry: AgentSchedulerSummaryEntry): number => {
export const fetchActiveAgentSchedulerEntries = async (
projectId: string,
signal?: AbortSignal,
locale: DashboardLocale = activePresentationLocale(),
): Promise<AgentSchedulerSummaryEntry[]> => {
const window = buildAgentScheduleWindow();
const schedule = await fetchProjectSchedule(projectId, window.from, window.to, signal);
return schedule.entries
.filter(isActiveAgentSchedulerEntry)
.map(toAgentSchedulerSummaryEntry)
.map((entry) => toAgentSchedulerSummaryEntry(entry, locale))
.sort((left, right) => scheduleSortValue(left) - scheduleSortValue(right) || left.title.localeCompare(right.title));
};

Expand Down
2 changes: 2 additions & 0 deletions docs-web/content/docs/user-dashboard-scheduler.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ planning prompts.
- **Calendar** — a month view of upcoming occurrences.
- **Day** — a focused list of what runs on a given day.

The Scheduler follows the dashboard's active English or German locale. Headers, controls, recurrence summaries, validation, confirmations, schedule statuses, sprint statuses in dependent-schedule choices, and announcements are translated, while dates and times use locale-aware formatting. Scheduled-entry details are formatted in the schedule's saved timezone and display its timezone ID unchanged. If a saved timezone ID is invalid or unsupported by the host, the page falls back to safe locale formatting and keeps the saved ID visible without changing it. Changing the dashboard language never changes saved timestamps, recurrence rules, sprint or target enums, payloads, names, prompts, messages, execution output, or server errors.

## Schedule targets

Each scheduler entry has a **target** — the thing that runs when it fires:
Expand Down
2 changes: 2 additions & 0 deletions docs-web/user/dashboard/scheduler.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ planning prompts.
- **Calendar** — a month view of upcoming occurrences.
- **Day** — a focused list of what runs on a given day.

The Scheduler follows the dashboard's active English or German locale. Headers, controls, recurrence summaries, validation, confirmations, schedule statuses, sprint statuses in dependent-schedule choices, and announcements are translated, while dates and times use locale-aware formatting. Scheduled-entry details are formatted in the schedule's saved timezone and display its timezone ID unchanged. If a saved timezone ID is invalid or unsupported by the host, the page falls back to safe locale formatting and keeps the saved ID visible without changing it. Changing the dashboard language never changes saved timestamps, recurrence rules, sprint or target enums, payloads, names, prompts, messages, execution output, or server errors.

## Schedule targets

Each scheduler entry has a **target** — the thing that runs when it fires:
Expand Down
4 changes: 4 additions & 0 deletions docs/dashboard/scheduler.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ The page has two schedule surfaces:

The `Calendar` / `24 Hours` switcher is implemented as a two-tab control. It exposes the active surface with `aria-selected`, points both tabs at the scheduler view panel with `aria-controls`, and supports arrow, Home, and End keyboard movement. During refresh, the view panel keeps cached schedule entries and occurrences visible, marks the panel busy, and announces that cached data is being shown while the latest schedule loads.

All Scheduler-owned interface copy is available in English and German through the dashboard locale provider. Calendar labels, recurrence summaries, validation, confirmations, actions, schedule statuses, sprint statuses in anchored-schedule choices, and live announcements follow the active locale. Presented dates and times use locale-aware `Intl` formatting; scheduled-entry summaries format instants in the entry's persisted timezone and show the timezone ID unchanged. If a legacy entry contains an invalid or host-unsupported timezone ID, presentation falls back to the active locale's safe default timezone while the stored ID remains visible and unchanged. Locale changes do not alter ISO timestamps, recurrence rules, sprint or target enums, payloads, user-authored titles/messages, project data, execution output, or server errors.

The form validates absolute dates, positive recurrence intervals and counts, and recurrence end windows before converting values to ISO. Duplicate submissions are suppressed while a save is in flight, and overlapping refreshes ignore stale responses. Deletion requires confirmation; feedback uses live status or alert semantics for assistive technology.

Operators can create entries for:
- Sprints whose status is not `completed`.
- Built-in or custom quicksprint templates available to the selected project.
Expand Down
72 changes: 72 additions & 0 deletions tests/dashboard/v2/scheduler-i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/** @vitest-environment happy-dom */
import { describe, expect, it } from "vitest";
import {
formatScheduleDateTime,
toAgentSchedulerSummaryEntry,
} from "../../../dashboard/src/v2/lib/scheduler-api.js";
import type { SchedulerEntryRecord } from "../../../dashboard/src/v2/types.js";

const agentEntry = (overrides: Partial<SchedulerEntryRecord> = {}): SchedulerEntryRecord & {
targetType: "agent_wakeup";
} => ({
id: "entry-agent",
projectId: "project-1",
title: "Nutzername bleibt",
targetType: "agent_wakeup",
status: "scheduled",
scheduledFor: "2026-10-25T01:30:00.000Z",
timezone: "Europe/Berlin",
recurrence: { frequency: "none", interval: 1, endMode: "never" },
nextRunAt: "2026-10-25T01:30:00.000Z",
lastRunAt: null,
runCount: 0,
lastError: null,
agentWakeupTarget: {
bodyMarkdown: "Do not translate this message.",
threadId: "thread-verbatim",
origin: "agent_scheduler",
source: "agent_scheduler",
},
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
...overrides,
});

describe("Scheduler locale presentation", () => {
it("formats the same ISO instant in German using the persisted timezone", () => {
expect(formatScheduleDateTime("2026-10-25T01:30:00.000Z", "de", "Europe/Berlin")).toMatch(/02:30/);
expect(formatScheduleDateTime("not-an-iso-date", "de", "Europe/Berlin")).toBe("Keine geplante Zeit");
});

it("falls back to active-locale formatting for an invalid persisted timezone", () => {
const iso = "2026-10-25T01:30:00.000Z";

expect(formatScheduleDateTime(iso, "de", "Mars/Olympus_Mons"))
.toBe(formatScheduleDateTime(iso, "de"));
expect(() => toAgentSchedulerSummaryEntry(agentEntry({ timezone: "Mars/Olympus_Mons" }), "de"))
.not.toThrow();
});

it("localizes agent schedule chrome while keeping titles, IDs, and timezone IDs verbatim", () => {
const summary = toAgentSchedulerSummaryEntry(agentEntry(), "de");

expect(summary.label).toBe("Agenten-Weckruf");
expect(summary.statusLabel).toBe("geplant");
expect(summary.title).toBe("Nutzername bleibt");
expect(summary.targetSummary).toBe("Thread thread-verbatim");
expect(summary.timingSummary).toMatch(/^Geplant für /);
expect(summary.scheduledAt).toBe("2026-10-25T01:30:00.000Z");
});

it("localizes anchored task timing without mutating anchor IDs or offsets", () => {
const summary = toAgentSchedulerSummaryEntry(agentEntry({
scheduleAnchor: {
mode: "after_task_end",
sourceTaskId: "task-source-verbatim",
offsetMinutes: 15,
},
}), "de");

expect(summary.timingSummary).toBe("Nachdem Quell-Aufgabe task-source-verbatim endet + 15 Minuten");
});
});
Loading
Loading