Extract dashboard TaskPanel component (#63)

* review: harden readonly git checks and lint verification

* test: satisfy readonly reviewer sandbox typing

* test: fix readonly reviewer sandbox assertions

* test: remove impossible readonly sandbox guards

* test: relax readonly sandbox assertions

* test: cast readonly sandbox expectation shape

* test: cast readonly sandbox expectation through unknown

* Phase 0 — STEP_DONE/TASK_DONE split + owner-follow-up integration smoke

* Add STEP_DONE guards, verdict storage, and stale delivery suppression

* style: format paired stepdone telemetry files

* Route STEP_DONE through reviewer

* Add structured Discord attachments

* style: format structured attachment test

* Fix room thread bot output parity

* Remove duplicate work item attachment migration from owner branch

* Remove legacy dashboard rooms renderer

* Extract dashboard parsed body renderer

* Extract dashboard redaction helpers

* Extract dashboard RoomCardV2 component

* Include RoomCardV2 test in vitest suite

* Extract dashboard RoomBoardV2 component

* Extract dashboard EmptyState component

* Extract dashboard InboxPanel component

* Split dashboard InboxPanel card renderer

* Extract dashboard TaskPanel component
This commit is contained in:
Eyejoker
2026-04-28 15:35:02 +09:00
committed by GitHub
parent 6d13c3aa49
commit c19665a134
4 changed files with 723 additions and 511 deletions

View File

@@ -6,8 +6,6 @@ import {
type CreateScheduledTaskInput,
type DashboardInboxAction,
type DashboardRoomActivity,
type DashboardTaskContextMode,
type DashboardTaskScheduleType,
type DashboardTaskAction,
type DashboardOverview,
type DashboardTask,
@@ -51,12 +49,12 @@ import {
type DashboardFreshness,
type DashboardView,
} from './DashboardNav';
import { formatDate, taskActionsFor } from './dashboardHelpers';
import { formatDate, statusLabel } from './dashboardHelpers';
import { InboxPanel, type InboxActionKey, type InboxItem } from './InboxPanel';
import { RoomBoardV2 } from './RoomBoardV2';
import { TaskPanel, type RoomOption, type TaskActionKey } from './TaskPanel';
import { EmptyState } from './EmptyState';
import { ParsedBody } from './ParsedBody';
import { redactSecretsForPreview } from './redaction';
import './styles.css';
interface DashboardState {
@@ -69,12 +67,6 @@ type UsageRow = DashboardOverview['usage']['rows'][number];
type RiskLevel = 'ok' | 'warn' | 'critical';
type UsageGroup = 'primary' | 'codex';
type UsageLimitWindow = 'h5' | 'd7';
type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed';
type TaskResultTone = 'ok' | 'fail' | 'none';
type TaskActionKey =
| 'create'
| `${string}:edit`
| `${string}:${DashboardTaskAction}`;
type ServiceActionKey = 'stack:restart';
type HealthLevel = 'ok' | 'stale' | 'down';
type FreshnessLevel = DashboardFreshness;
@@ -84,12 +76,6 @@ type BeforeInstallPromptEvent = Event & {
userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
};
interface RoomOption {
jid: string;
name: string;
folder: string;
}
const REFRESH_INTERVAL_MS = 15_000;
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2';
const DEFAULT_VIEW: DashboardView = 'rooms';
@@ -206,56 +192,6 @@ function canUsePwaCore(): boolean {
);
}
function formatTaskDate(
value: string | null | undefined,
locale: Locale,
): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const time = new Intl.DateTimeFormat(localeTags[locale], {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
if (locale === 'ko')
return `${date.getMonth() + 1}${date.getDate()}${time}`;
if (locale === 'ja' || locale === 'zh')
return `${date.getMonth() + 1}${date.getDate()}${time}`;
return new Intl.DateTimeFormat(localeTags[locale], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
function formatRelativeDate(
value: string | null | undefined,
locale: Locale,
t: Messages,
): string {
if (!value) return t.tasks.noTime;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const diffMs = date.getTime() - Date.now();
const absMs = Math.abs(diffMs);
if (absMs < 45_000) return t.tasks.now;
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
['day', 86_400_000],
['hour', 3_600_000],
['minute', 60_000],
];
const [unit, unitMs] =
units.find(([, threshold]) => absMs >= threshold) ?? units.at(-1)!;
return new Intl.RelativeTimeFormat(localeTags[locale], {
numeric: 'auto',
style: 'short',
}).format(Math.round(diffMs / unitMs), unit);
}
function formatPct(value: number): string {
if (value < 0) return '-';
return `${Math.round(value)}%`;
@@ -332,11 +268,6 @@ function usageGroup(row: UsageRow): UsageGroup {
return row.name.toLowerCase().startsWith('codex') ? 'codex' : 'primary';
}
function statusLabel(status: string, t: Messages): string {
if (status in t.status) return t.status[status as keyof Messages['status']];
return status;
}
function formatDuration(value: number | null, t: Messages): string {
if (value === null) return '-';
const seconds = Math.floor(value / 1000);
@@ -377,46 +308,6 @@ function queueLabel(
return parts.join(' · ');
}
function safePreview(
value: string | null | undefined,
fallback: string,
): string {
const cleaned = redactSecretsForPreview(value ?? '')
.replace(/<\/?internal[^>]*>/gi, '')
.replace(/\s+/g, ' ')
.trim();
if (!cleaned) return fallback;
return cleaned.length > 120 ? `${cleaned.slice(0, 120)}...` : cleaned;
}
function taskGroupKey(task: DashboardTask): TaskGroupKey {
if (task.status === 'completed') return 'completed';
if (task.status === 'paused') return 'paused';
if (task.isWatcher) return 'watchers';
return 'scheduled';
}
function taskResultTone(task: DashboardTask): TaskResultTone {
if (!task.lastResult) return 'none';
const normalized = task.lastResult.toLowerCase();
if (
normalized.includes('fail') ||
normalized.includes('error') ||
normalized.includes('timeout') ||
normalized.includes('cancel') ||
normalized.includes('reject')
) {
return 'fail';
}
return 'ok';
}
function taskDisplayName(task: DashboardTask, t: Messages): string {
if (task.isWatcher) return t.tasks.ciWatch;
if (task.scheduleType) return task.scheduleType;
return task.id;
}
function buildRoomOptions(snapshots: StatusSnapshot[]): RoomOption[] {
const rooms = new Map<string, RoomOption>();
for (const snapshot of snapshots) {
@@ -435,67 +326,6 @@ function buildRoomOptions(snapshots: StatusSnapshot[]): RoomOption[] {
);
}
function isTaskScheduleType(
value: FormDataEntryValue | null,
): value is DashboardTaskScheduleType {
return value === 'cron' || value === 'interval' || value === 'once';
}
function isTaskContextMode(
value: FormDataEntryValue | null,
): value is DashboardTaskContextMode {
return value === 'group' || value === 'isolated';
}
function readRequiredText(form: FormData, name: string): string | null {
const value = form.get(name);
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function readTaskForm(
form: FormData,
includeRoom: true,
): CreateScheduledTaskInput | null;
function readTaskForm(
form: FormData,
includeRoom: false,
): UpdateScheduledTaskInput | null;
function readTaskForm(
form: FormData,
includeRoom: boolean,
): CreateScheduledTaskInput | UpdateScheduledTaskInput | null {
const prompt = readRequiredText(form, 'prompt');
const scheduleValue = readRequiredText(form, 'scheduleValue');
const scheduleTypeValue = form.get('scheduleType');
if (!scheduleValue || !isTaskScheduleType(scheduleTypeValue)) {
return null;
}
const scheduleType = scheduleTypeValue;
if (!includeRoom) {
return prompt
? { prompt, scheduleType, scheduleValue }
: { scheduleType, scheduleValue };
}
if (!prompt) {
return null;
}
const roomJid = readRequiredText(form, 'roomJid');
const contextMode = form.get('contextMode');
if (!roomJid || !isTaskContextMode(contextMode)) return null;
return {
contextMode,
prompt,
roomJid,
scheduleType,
scheduleValue,
};
}
function serviceAgeMs(
service: DashboardOverview['services'][number],
generatedAt: string,
@@ -1596,344 +1426,6 @@ function UsagePanel({
</div>
);
}
function TaskPanel({
tasks,
rooms,
locale,
onTaskAction,
onTaskCreate,
onTaskUpdate,
taskActionKey,
t,
}: {
tasks: DashboardTask[];
rooms: RoomOption[];
locale: Locale;
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
onTaskCreate: (input: CreateScheduledTaskInput) => void;
onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void;
taskActionKey: TaskActionKey | null;
t: Messages;
}) {
const taskGroups = useMemo(() => {
const groups: Record<TaskGroupKey, DashboardTask[]> = {
watchers: [],
scheduled: [],
paused: [],
completed: [],
};
for (const task of tasks) {
groups[taskGroupKey(task)].push(task);
}
for (const groupTasks of Object.values(groups)) {
groupTasks.sort((a, b) =>
(a.nextRun ?? a.lastRun ?? a.createdAt).localeCompare(
b.nextRun ?? b.lastRun ?? b.createdAt,
),
);
}
return [
{ key: 'watchers' as const, tasks: groups.watchers },
{ key: 'scheduled' as const, tasks: groups.scheduled },
{ key: 'paused' as const, tasks: groups.paused },
{ key: 'completed' as const, tasks: groups.completed },
];
}, [tasks]);
return (
<div className="task-board" aria-label={t.tasks.cardsAria}>
<form
className="task-create-form"
onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const input = readTaskForm(form, true);
if (!input) return;
onTaskCreate(input);
event.currentTarget.reset();
}}
>
<div className="task-create-head">
<span className="eyebrow">{t.tasks.createTitle}</span>
<strong>{t.tasks.createSubtitle}</strong>
</div>
<label>
<span>{t.tasks.room}</span>
<select name="roomJid" required>
<option value="">{t.tasks.selectRoom}</option>
{rooms.map((room) => (
<option key={room.jid} value={room.jid}>
{room.name} · {room.folder}
</option>
))}
</select>
</label>
<label className="task-form-wide">
<span>{t.tasks.prompt}</span>
<textarea
name="prompt"
placeholder={t.tasks.promptPlaceholder}
required
/>
</label>
<label>
<span>{t.tasks.scheduleType}</span>
<select name="scheduleType" required>
<option value="once">{t.tasks.scheduleTypes.once}</option>
<option value="interval">{t.tasks.scheduleTypes.interval}</option>
<option value="cron">{t.tasks.scheduleTypes.cron}</option>
</select>
</label>
<label>
<span>{t.tasks.scheduleValue}</span>
<input
name="scheduleValue"
placeholder={t.tasks.scheduleValueHint}
required
/>
</label>
<label>
<span>{t.tasks.context}</span>
<select name="contextMode" required>
<option value="isolated">{t.tasks.contextModes.isolated}</option>
<option value="group">{t.tasks.contextModes.group}</option>
</select>
</label>
<button disabled={taskActionKey === 'create'} type="submit">
{taskActionKey === 'create'
? t.tasks.actions.busy
: t.tasks.actions.create}
</button>
</form>
{tasks.length === 0 ? <EmptyState>{t.tasks.empty}</EmptyState> : null}
{taskGroups.map((group) => {
const label = t.tasks.groups[group.key];
const groupHead = (
<div className="task-group-head">
<div>
<span className="eyebrow">{label}</span>
<strong>
{group.tasks.length} {t.tasks.count}
</strong>
</div>
<span className={`pill pill-${group.key}`}>
{group.tasks.length}
</span>
</div>
);
const groupBody =
group.tasks.length === 0 ? (
<div className="task-group-empty">{t.tasks.groupEmpty}</div>
) : (
<div className="task-list">
{group.tasks.map((task) => {
const resultTone = taskResultTone(task);
const lastResult = safePreview(
task.lastResult,
t.tasks.noResult,
);
const taskActions = taskActionsFor(task);
return (
<article
className={`task-card task-card-${group.key}`}
key={task.id}
>
<div className="task-card-main">
<div className="task-title">
<strong>{taskDisplayName(task, t)}</strong>
<span className="mono-chip">{task.groupFolder}</span>
</div>
<div className="task-status-line">
<span className={`pill pill-${task.status}`}>
{statusLabel(task.status, t)}
</span>
{task.ciProvider ? (
<span className="task-provider">
{task.ciProvider}
</span>
) : null}
</div>
</div>
{taskActions.length > 0 ? (
<div className="task-actions">
{taskActions.map((action) => {
const actionKey: TaskActionKey = `${task.id}:${action}`;
const busy = taskActionKey === actionKey;
return (
<button
aria-busy={busy || undefined}
className={`task-action task-action-${action}${busy ? ' is-busy' : ''}`}
disabled={busy}
key={action}
onClick={() => onTaskAction(task, action)}
type="button"
>
{busy
? t.tasks.actions.busy
: t.tasks.actions[action]}
</button>
);
})}
</div>
) : null}
<div className="task-time-grid">
<span>
<small>{t.tasks.next}</small>
<strong>{formatTaskDate(task.nextRun, locale)}</strong>
<em>{formatRelativeDate(task.nextRun, locale, t)}</em>
</span>
<span>
<small>{t.tasks.last}</small>
<strong>{formatTaskDate(task.lastRun, locale)}</strong>
<em>{formatRelativeDate(task.lastRun, locale, t)}</em>
</span>
<span>
<small>{t.tasks.schedule}</small>
<strong>{task.scheduleType}</strong>
<em>{task.scheduleValue}</em>
</span>
</div>
{task.suspendedUntil ? (
<div className="task-suspended">
<span>{t.tasks.suspendedUntil}</span>
<strong>
{formatTaskDate(task.suspendedUntil, locale)}
</strong>
<em>
{formatRelativeDate(task.suspendedUntil, locale, t)}
</em>
</div>
) : null}
<div className={`task-result result-${resultTone}`}>
<span>
{resultTone === 'fail'
? t.tasks.resultFail
: resultTone === 'ok'
? t.tasks.resultOk
: t.tasks.result}
</span>
<strong>{lastResult}</strong>
</div>
<details className="task-prompt">
<summary>{t.tasks.prompt}</summary>
<p>
{safePreview(task.promptPreview, t.tasks.emptyPrompt)}
</p>
<small>
{task.id} · {task.contextMode} · {task.promptLength}{' '}
{t.units.chars}
</small>
</details>
{!task.isWatcher && task.status !== 'completed' ? (
<details className="task-edit">
<summary>{t.tasks.actions.edit}</summary>
<form
className="task-edit-form"
onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const input = readTaskForm(form, false);
if (!input) return;
onTaskUpdate(task, input);
}}
>
<label className="task-form-wide">
<span>{t.tasks.prompt}</span>
<textarea
name="prompt"
placeholder={t.tasks.editPromptPlaceholder}
/>
</label>
<label>
<span>{t.tasks.scheduleType}</span>
<select
name="scheduleType"
defaultValue={task.scheduleType}
required
>
<option value="once">
{t.tasks.scheduleTypes.once}
</option>
<option value="interval">
{t.tasks.scheduleTypes.interval}
</option>
<option value="cron">
{t.tasks.scheduleTypes.cron}
</option>
</select>
</label>
<label>
<span>{t.tasks.scheduleValue}</span>
<input
name="scheduleValue"
defaultValue={task.scheduleValue}
required
/>
</label>
<button
disabled={taskActionKey === `${task.id}:edit`}
type="submit"
>
{taskActionKey === `${task.id}:edit`
? t.tasks.actions.busy
: t.tasks.actions.save}
</button>
</form>
</details>
) : null}
</article>
);
})}
</div>
);
if (group.key === 'completed') {
return (
<details
className="task-group task-group-completed"
key={group.key}
>
<summary className="task-group-head">
<div>
<span className="eyebrow">{label}</span>
<strong>
{group.tasks.length} {t.tasks.count}
</strong>
</div>
<span className={`pill pill-${group.key}`}>
{group.tasks.length}
</span>
</summary>
{groupBody}
</details>
);
}
return (
<section
className={`task-group task-group-${group.key}`}
key={group.key}
>
{groupHead}
{groupBody}
</section>
);
})}
</div>
);
}
function DashboardErrorCard({
error,
onRetry,

View File

@@ -0,0 +1,63 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import type { DashboardTask } from './api';
import { messages } from './i18n';
import { TaskPanel, type TaskPanelProps } from './TaskPanel';
const t = messages.en;
const task: DashboardTask = {
agentType: 'codex',
chatJid: 'room-1',
ciMetadata: null,
ciProvider: null,
contextMode: 'group',
createdAt: '2026-04-28T04:00:00.000Z',
groupFolder: 'eyejokerdb',
id: 'task-1',
isWatcher: false,
lastResult: '<internal>hidden</internal>Task completed',
lastRun: '2026-04-28T04:30:00.000Z',
nextRun: '2026-04-28T05:00:00.000Z',
promptLength: 42,
promptPreview: '<internal>hidden</internal>Run production check',
scheduleType: 'interval',
scheduleValue: '10m',
status: 'active',
suspendedUntil: null,
};
const baseProps: TaskPanelProps = {
locale: 'en',
onTaskAction: () => {},
onTaskCreate: () => {},
onTaskUpdate: () => {},
rooms: [{ folder: 'eyejokerdb', jid: 'room-1', name: 'eyejokerdb-main' }],
taskActionKey: null,
tasks: [task],
t,
};
describe('TaskPanel', () => {
it('renders task groups, actions, and sanitized previews', () => {
const html = renderToStaticMarkup(createElement(TaskPanel, baseProps));
expect(html).toContain(t.tasks.groups.scheduled);
expect(html).toContain('eyejokerdb-main');
expect(html).toContain(t.tasks.actions.pause);
expect(html).toContain(t.tasks.actions.cancel);
expect(html).toContain('Task completed');
expect(html).toContain('Run production check');
expect(html).not.toContain('internal');
});
it('renders an empty state without scheduled tasks', () => {
const html = renderToStaticMarkup(
createElement(TaskPanel, { ...baseProps, tasks: [] }),
);
expect(html).toContain(t.tasks.empty);
});
});

View File

@@ -0,0 +1,652 @@
import { useMemo } from 'react';
import type {
CreateScheduledTaskInput,
DashboardTask,
DashboardTaskAction,
DashboardTaskContextMode,
DashboardTaskScheduleType,
UpdateScheduledTaskInput,
} from './api';
import { EmptyState } from './EmptyState';
import { localeTags, type Locale, type Messages } from './i18n';
import { redactSecretsForPreview } from './redaction';
import { statusLabel, taskActionsFor } from './dashboardHelpers';
type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed';
type TaskResultTone = 'ok' | 'fail' | 'none';
export type TaskActionKey =
| 'create'
| `${string}:edit`
| `${string}:${DashboardTaskAction}`;
export interface RoomOption {
jid: string;
name: string;
folder: string;
}
export interface TaskPanelProps {
tasks: DashboardTask[];
rooms: RoomOption[];
locale: Locale;
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
onTaskCreate: (input: CreateScheduledTaskInput) => void;
onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void;
taskActionKey: TaskActionKey | null;
t: Messages;
}
interface TaskGroup {
key: TaskGroupKey;
tasks: DashboardTask[];
}
interface TaskCreateFormProps {
rooms: RoomOption[];
onTaskCreate: (input: CreateScheduledTaskInput) => void;
taskActionKey: TaskActionKey | null;
t: Messages;
}
interface TaskGroupSectionProps {
group: TaskGroup;
locale: Locale;
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void;
taskActionKey: TaskActionKey | null;
t: Messages;
}
interface TaskCardProps {
groupKey: TaskGroupKey;
locale: Locale;
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void;
task: DashboardTask;
taskActionKey: TaskActionKey | null;
t: Messages;
}
interface TaskActionButtonsProps {
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
task: DashboardTask;
taskActionKey: TaskActionKey | null;
t: Messages;
}
interface TaskDateProps {
locale: Locale;
t: Messages;
task: DashboardTask;
}
interface TaskEditFormProps {
onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void;
task: DashboardTask;
taskActionKey: TaskActionKey | null;
t: Messages;
}
function formatTaskDate(
value: string | null | undefined,
locale: Locale,
): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const time = new Intl.DateTimeFormat(localeTags[locale], {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
if (locale === 'ko')
return `${date.getMonth() + 1}${date.getDate()}${time}`;
if (locale === 'ja' || locale === 'zh')
return `${date.getMonth() + 1}${date.getDate()}${time}`;
return new Intl.DateTimeFormat(localeTags[locale], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
function formatRelativeDate(
value: string | null | undefined,
locale: Locale,
t: Messages,
): string {
if (!value) return t.tasks.noTime;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const diffMs = date.getTime() - Date.now();
const absMs = Math.abs(diffMs);
if (absMs < 45_000) return t.tasks.now;
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
['day', 86_400_000],
['hour', 3_600_000],
['minute', 60_000],
];
const [unit, unitMs] =
units.find(([, threshold]) => absMs >= threshold) ?? units.at(-1)!;
return new Intl.RelativeTimeFormat(localeTags[locale], {
numeric: 'auto',
style: 'short',
}).format(Math.round(diffMs / unitMs), unit);
}
function safePreview(
value: string | null | undefined,
fallback: string,
): string {
const cleaned = redactSecretsForPreview(value ?? '')
.replace(/<\/?internal[^>]*>/gi, '')
.replace(/\s+/g, ' ')
.trim();
if (!cleaned) return fallback;
return cleaned.length > 120 ? `${cleaned.slice(0, 120)}...` : cleaned;
}
function taskGroupKey(task: DashboardTask): TaskGroupKey {
if (task.status === 'completed') return 'completed';
if (task.status === 'paused') return 'paused';
if (task.isWatcher) return 'watchers';
return 'scheduled';
}
function taskResultTone(task: DashboardTask): TaskResultTone {
if (!task.lastResult) return 'none';
const normalized = task.lastResult.toLowerCase();
if (
normalized.includes('fail') ||
normalized.includes('error') ||
normalized.includes('timeout') ||
normalized.includes('cancel') ||
normalized.includes('reject')
) {
return 'fail';
}
return 'ok';
}
function taskDisplayName(task: DashboardTask, t: Messages): string {
if (task.isWatcher) return t.tasks.ciWatch;
if (task.scheduleType) return task.scheduleType;
return task.id;
}
function isTaskScheduleType(
value: FormDataEntryValue | null,
): value is DashboardTaskScheduleType {
return value === 'cron' || value === 'interval' || value === 'once';
}
function isTaskContextMode(
value: FormDataEntryValue | null,
): value is DashboardTaskContextMode {
return value === 'group' || value === 'isolated';
}
function readRequiredText(form: FormData, name: string): string | null {
const value = form.get(name);
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function readTaskForm(
form: FormData,
includeRoom: true,
): CreateScheduledTaskInput | null;
function readTaskForm(
form: FormData,
includeRoom: false,
): UpdateScheduledTaskInput | null;
function readTaskForm(
form: FormData,
includeRoom: boolean,
): CreateScheduledTaskInput | UpdateScheduledTaskInput | null {
const prompt = readRequiredText(form, 'prompt');
const scheduleValue = readRequiredText(form, 'scheduleValue');
const scheduleTypeValue = form.get('scheduleType');
if (!scheduleValue || !isTaskScheduleType(scheduleTypeValue)) {
return null;
}
const scheduleType = scheduleTypeValue;
if (!includeRoom) {
return prompt
? { prompt, scheduleType, scheduleValue }
: { scheduleType, scheduleValue };
}
if (!prompt) {
return null;
}
const roomJid = readRequiredText(form, 'roomJid');
const contextMode = form.get('contextMode');
if (!roomJid || !isTaskContextMode(contextMode)) return null;
return {
contextMode,
prompt,
roomJid,
scheduleType,
scheduleValue,
};
}
function buildTaskGroups(tasks: DashboardTask[]): TaskGroup[] {
const groups: Record<TaskGroupKey, DashboardTask[]> = {
watchers: [],
scheduled: [],
paused: [],
completed: [],
};
for (const task of tasks) {
groups[taskGroupKey(task)].push(task);
}
for (const groupTasks of Object.values(groups)) {
groupTasks.sort((a, b) =>
(a.nextRun ?? a.lastRun ?? a.createdAt).localeCompare(
b.nextRun ?? b.lastRun ?? b.createdAt,
),
);
}
return [
{ key: 'watchers', tasks: groups.watchers },
{ key: 'scheduled', tasks: groups.scheduled },
{ key: 'paused', tasks: groups.paused },
{ key: 'completed', tasks: groups.completed },
];
}
function TaskCreateForm({
rooms,
onTaskCreate,
taskActionKey,
t,
}: TaskCreateFormProps) {
return (
<form
className="task-create-form"
onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const input = readTaskForm(form, true);
if (!input) return;
onTaskCreate(input);
event.currentTarget.reset();
}}
>
<div className="task-create-head">
<span className="eyebrow">{t.tasks.createTitle}</span>
<strong>{t.tasks.createSubtitle}</strong>
</div>
<label>
<span>{t.tasks.room}</span>
<select name="roomJid" required>
<option value="">{t.tasks.selectRoom}</option>
{rooms.map((room) => (
<option key={room.jid} value={room.jid}>
{room.name} · {room.folder}
</option>
))}
</select>
</label>
<label className="task-form-wide">
<span>{t.tasks.prompt}</span>
<textarea
name="prompt"
placeholder={t.tasks.promptPlaceholder}
required
/>
</label>
<label>
<span>{t.tasks.scheduleType}</span>
<select name="scheduleType" required>
<option value="once">{t.tasks.scheduleTypes.once}</option>
<option value="interval">{t.tasks.scheduleTypes.interval}</option>
<option value="cron">{t.tasks.scheduleTypes.cron}</option>
</select>
</label>
<label>
<span>{t.tasks.scheduleValue}</span>
<input
name="scheduleValue"
placeholder={t.tasks.scheduleValueHint}
required
/>
</label>
<label>
<span>{t.tasks.context}</span>
<select name="contextMode" required>
<option value="isolated">{t.tasks.contextModes.isolated}</option>
<option value="group">{t.tasks.contextModes.group}</option>
</select>
</label>
<button disabled={taskActionKey === 'create'} type="submit">
{taskActionKey === 'create'
? t.tasks.actions.busy
: t.tasks.actions.create}
</button>
</form>
);
}
function TaskActionButtons({
onTaskAction,
task,
taskActionKey,
t,
}: TaskActionButtonsProps) {
const taskActions = taskActionsFor(task);
if (taskActions.length === 0) return null;
return (
<div className="task-actions">
{taskActions.map((action) => {
const actionKey: TaskActionKey = `${task.id}:${action}`;
const busy = taskActionKey === actionKey;
return (
<button
aria-busy={busy || undefined}
className={`task-action task-action-${action}${busy ? ' is-busy' : ''}`}
disabled={busy}
key={action}
onClick={() => onTaskAction(task, action)}
type="button"
>
{busy ? t.tasks.actions.busy : t.tasks.actions[action]}
</button>
);
})}
</div>
);
}
function TaskTimeGrid({ locale, t, task }: TaskDateProps) {
return (
<div className="task-time-grid">
<span>
<small>{t.tasks.next}</small>
<strong>{formatTaskDate(task.nextRun, locale)}</strong>
<em>{formatRelativeDate(task.nextRun, locale, t)}</em>
</span>
<span>
<small>{t.tasks.last}</small>
<strong>{formatTaskDate(task.lastRun, locale)}</strong>
<em>{formatRelativeDate(task.lastRun, locale, t)}</em>
</span>
<span>
<small>{t.tasks.schedule}</small>
<strong>{task.scheduleType}</strong>
<em>{task.scheduleValue}</em>
</span>
</div>
);
}
function TaskSuspendedUntil({ locale, t, task }: TaskDateProps) {
if (!task.suspendedUntil) return null;
return (
<div className="task-suspended">
<span>{t.tasks.suspendedUntil}</span>
<strong>{formatTaskDate(task.suspendedUntil, locale)}</strong>
<em>{formatRelativeDate(task.suspendedUntil, locale, t)}</em>
</div>
);
}
function TaskResult({ task, t }: { task: DashboardTask; t: Messages }) {
const resultTone = taskResultTone(task);
const lastResult = safePreview(task.lastResult, t.tasks.noResult);
return (
<div className={`task-result result-${resultTone}`}>
<span>
{resultTone === 'fail'
? t.tasks.resultFail
: resultTone === 'ok'
? t.tasks.resultOk
: t.tasks.result}
</span>
<strong>{lastResult}</strong>
</div>
);
}
function TaskPromptDetails({ task, t }: { task: DashboardTask; t: Messages }) {
return (
<details className="task-prompt">
<summary>{t.tasks.prompt}</summary>
<p>{safePreview(task.promptPreview, t.tasks.emptyPrompt)}</p>
<small>
{task.id} · {task.contextMode} · {task.promptLength} {t.units.chars}
</small>
</details>
);
}
function TaskEditForm({
onTaskUpdate,
task,
taskActionKey,
t,
}: TaskEditFormProps) {
if (task.isWatcher || task.status === 'completed') return null;
return (
<details className="task-edit">
<summary>{t.tasks.actions.edit}</summary>
<form
className="task-edit-form"
onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const input = readTaskForm(form, false);
if (!input) return;
onTaskUpdate(task, input);
}}
>
<label className="task-form-wide">
<span>{t.tasks.prompt}</span>
<textarea name="prompt" placeholder={t.tasks.editPromptPlaceholder} />
</label>
<label>
<span>{t.tasks.scheduleType}</span>
<select name="scheduleType" defaultValue={task.scheduleType} required>
<option value="once">{t.tasks.scheduleTypes.once}</option>
<option value="interval">{t.tasks.scheduleTypes.interval}</option>
<option value="cron">{t.tasks.scheduleTypes.cron}</option>
</select>
</label>
<label>
<span>{t.tasks.scheduleValue}</span>
<input
name="scheduleValue"
defaultValue={task.scheduleValue}
required
/>
</label>
<button disabled={taskActionKey === `${task.id}:edit`} type="submit">
{taskActionKey === `${task.id}:edit`
? t.tasks.actions.busy
: t.tasks.actions.save}
</button>
</form>
</details>
);
}
function TaskCard({
groupKey,
locale,
onTaskAction,
onTaskUpdate,
task,
taskActionKey,
t,
}: TaskCardProps) {
return (
<article className={`task-card task-card-${groupKey}`}>
<div className="task-card-main">
<div className="task-title">
<strong>{taskDisplayName(task, t)}</strong>
<span className="mono-chip">{task.groupFolder}</span>
</div>
<div className="task-status-line">
<span className={`pill pill-${task.status}`}>
{statusLabel(task.status, t)}
</span>
{task.ciProvider ? (
<span className="task-provider">{task.ciProvider}</span>
) : null}
</div>
</div>
<TaskActionButtons
onTaskAction={onTaskAction}
task={task}
taskActionKey={taskActionKey}
t={t}
/>
<TaskTimeGrid locale={locale} t={t} task={task} />
<TaskSuspendedUntil locale={locale} t={t} task={task} />
<TaskResult task={task} t={t} />
<TaskPromptDetails task={task} t={t} />
<TaskEditForm
onTaskUpdate={onTaskUpdate}
task={task}
taskActionKey={taskActionKey}
t={t}
/>
</article>
);
}
function TaskGroupBody({
group,
locale,
onTaskAction,
onTaskUpdate,
taskActionKey,
t,
}: TaskGroupSectionProps) {
if (group.tasks.length === 0) {
return <div className="task-group-empty">{t.tasks.groupEmpty}</div>;
}
return (
<div className="task-list">
{group.tasks.map((task) => (
<TaskCard
groupKey={group.key}
key={task.id}
locale={locale}
onTaskAction={onTaskAction}
onTaskUpdate={onTaskUpdate}
task={task}
taskActionKey={taskActionKey}
t={t}
/>
))}
</div>
);
}
function TaskGroupHead({
countLabel,
group,
label,
}: {
countLabel: string;
group: TaskGroup;
label: string;
}) {
return (
<div className="task-group-head">
<div>
<span className="eyebrow">{label}</span>
<strong>
{group.tasks.length} {countLabel}
</strong>
</div>
<span className={`pill pill-${group.key}`}>{group.tasks.length}</span>
</div>
);
}
function TaskGroupSection(props: TaskGroupSectionProps) {
const { group, t } = props;
const label = t.tasks.groups[group.key];
const body = <TaskGroupBody {...props} />;
if (group.key === 'completed') {
return (
<details className="task-group task-group-completed">
<summary className="task-group-head">
<div>
<span className="eyebrow">{label}</span>
<strong>
{group.tasks.length} {t.tasks.count}
</strong>
</div>
<span className={`pill pill-${group.key}`}>{group.tasks.length}</span>
</summary>
{body}
</details>
);
}
return (
<section className={`task-group task-group-${group.key}`}>
<TaskGroupHead countLabel={t.tasks.count} group={group} label={label} />
{body}
</section>
);
}
export function TaskPanel({
tasks,
rooms,
locale,
onTaskAction,
onTaskCreate,
onTaskUpdate,
taskActionKey,
t,
}: TaskPanelProps) {
const taskGroups = useMemo(() => buildTaskGroups(tasks), [tasks]);
return (
<div className="task-board" aria-label={t.tasks.cardsAria}>
<TaskCreateForm
rooms={rooms}
onTaskCreate={onTaskCreate}
taskActionKey={taskActionKey}
t={t}
/>
{tasks.length === 0 ? <EmptyState>{t.tasks.empty}</EmptyState> : null}
{taskGroups.map((group) => (
<TaskGroupSection
group={group}
key={group.key}
locale={locale}
onTaskAction={onTaskAction}
onTaskUpdate={onTaskUpdate}
taskActionKey={taskActionKey}
t={t}
/>
))}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import type { DashboardTask, DashboardTaskAction } from './api';
import { localeTags, type Locale } from './i18n';
import { localeTags, type Locale, type Messages } from './i18n';
export function formatDate(
value: string | null | undefined,
@@ -62,3 +62,8 @@ export function taskActionsFor(task: DashboardTask): DashboardTaskAction[] {
if (task.status === 'paused') return ['resume', 'cancel'];
return [];
}
export function statusLabel(status: string, t: Messages): string {
if (status in t.status) return t.status[status as keyof Messages['status']];
return status;
}