Extract dashboard InboxPanel component (#62)

* 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
This commit is contained in:
Eyejoker
2026-04-28 15:04:37 +09:00
committed by GitHub
parent 13f05273d7
commit 6d13c3aa49
4 changed files with 441 additions and 308 deletions

View File

@@ -51,7 +51,8 @@ import {
type DashboardFreshness, type DashboardFreshness,
type DashboardView, type DashboardView,
} from './DashboardNav'; } from './DashboardNav';
import { isInternalProtocolPayload } from './roomThread'; import { formatDate, taskActionsFor } from './dashboardHelpers';
import { InboxPanel, type InboxActionKey, type InboxItem } from './InboxPanel';
import { RoomBoardV2 } from './RoomBoardV2'; import { RoomBoardV2 } from './RoomBoardV2';
import { EmptyState } from './EmptyState'; import { EmptyState } from './EmptyState';
import { ParsedBody } from './ParsedBody'; import { ParsedBody } from './ParsedBody';
@@ -65,7 +66,6 @@ interface DashboardState {
} }
type UsageRow = DashboardOverview['usage']['rows'][number]; type UsageRow = DashboardOverview['usage']['rows'][number];
type InboxItem = DashboardOverview['inbox'][number];
type RiskLevel = 'ok' | 'warn' | 'critical'; type RiskLevel = 'ok' | 'warn' | 'critical';
type UsageGroup = 'primary' | 'codex'; type UsageGroup = 'primary' | 'codex';
type UsageLimitWindow = 'h5' | 'd7'; type UsageLimitWindow = 'h5' | 'd7';
@@ -75,9 +75,7 @@ type TaskActionKey =
| 'create' | 'create'
| `${string}:edit` | `${string}:edit`
| `${string}:${DashboardTaskAction}`; | `${string}:${DashboardTaskAction}`;
type InboxActionKey = `${string}:${DashboardInboxAction}`;
type ServiceActionKey = 'stack:restart'; type ServiceActionKey = 'stack:restart';
type InboxFilter = 'all' | InboxItem['kind'];
type HealthLevel = 'ok' | 'stale' | 'down'; type HealthLevel = 'ok' | 'stale' | 'down';
type FreshnessLevel = DashboardFreshness; type FreshnessLevel = DashboardFreshness;
@@ -167,69 +165,6 @@ function senderRoleClass(value: string | null | undefined): string {
return 'role-human'; return 'role-human';
} }
function sanitizeInboxText(value: string | null | undefined): string {
if (!value) return '';
return value
.replace(/<\/?internal[^>]*>/gi, '')
.replace(/<\/?intern\.{3}/gi, '')
.replace(/<\/?[a-z][a-z0-9-]*[^>]*>/gi, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
function formatDate(value: string | null | undefined, locale: Locale): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const now = Date.now();
const ageMs = now - date.getTime();
if (ageMs >= 0 && ageMs < 60_000) {
return locale === 'ko'
? '방금'
: locale === 'ja'
? 'たった今'
: locale === 'zh'
? '刚刚'
: 'just now';
}
if (ageMs >= 0 && ageMs < 3_600_000) {
const mins = Math.floor(ageMs / 60_000);
return locale === 'ko'
? `${mins}분 전`
: locale === 'ja'
? `${mins}分前`
: locale === 'zh'
? `${mins} 分钟前`
: `${mins}m ago`;
}
const sameDay = new Date().toDateString() === date.toDateString();
if (sameDay) {
return new Intl.DateTimeFormat(localeTags[locale], {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
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')
return `${date.getMonth() + 1}${date.getDate()}${time}`;
if (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 dashboardAgeMs(value: string | null | undefined): number | null { function dashboardAgeMs(value: string | null | undefined): number | null {
if (!value) return null; if (!value) return null;
const date = new Date(value); const date = new Date(value);
@@ -482,12 +417,6 @@ function taskDisplayName(task: DashboardTask, t: Messages): string {
return task.id; return task.id;
} }
function taskActionsFor(task: DashboardTask): DashboardTaskAction[] {
if (task.status === 'active') return ['pause', 'cancel'];
if (task.status === 'paused') return ['resume', 'cancel'];
return [];
}
function buildRoomOptions(snapshots: StatusSnapshot[]): RoomOption[] { function buildRoomOptions(snapshots: StatusSnapshot[]): RoomOption[] {
const rooms = new Map<string, RoomOption>(); const rooms = new Map<string, RoomOption>();
for (const snapshot of snapshots) { for (const snapshot of snapshots) {
@@ -567,41 +496,6 @@ function readTaskForm(
}; };
} }
function inboxActionsFor(item: InboxItem): DashboardInboxAction[] {
if (
item.source === 'paired-task' &&
(item.kind === 'reviewer-request' ||
item.kind === 'approval' ||
item.kind === 'arbiter-request')
) {
return ['run', 'decline', 'dismiss'];
}
return ['dismiss'];
}
function inboxActionLabel(
item: InboxItem,
action: DashboardInboxAction,
t: Messages,
): string {
if (action === 'dismiss') return t.inbox.actions.dismiss;
if (action === 'decline') return t.inbox.actions.decline;
if (item.kind === 'reviewer-request') return t.inbox.actions.runReview;
if (item.kind === 'approval') return t.inbox.actions.finalize;
if (item.kind === 'arbiter-request') return t.inbox.actions.runArbiter;
return t.inbox.actions.run;
}
const INBOX_FILTERS: InboxFilter[] = [
'all',
'ci-failure',
'approval',
'reviewer-request',
'arbiter-request',
'pending-room',
'mention',
];
function serviceAgeMs( function serviceAgeMs(
service: DashboardOverview['services'][number], service: DashboardOverview['services'][number],
generatedAt: string, generatedAt: string,
@@ -623,12 +517,6 @@ function serviceHealthLevel(
return 'ok'; return 'ok';
} }
function inboxTargetHref(item: InboxItem): string | null {
if (item.taskId) return '#/scheduled';
if (item.roomJid || item.groupFolder) return '#/rooms';
return null;
}
function LanguageSelector({ function LanguageSelector({
locale, locale,
onLocaleChange, onLocaleChange,
@@ -1368,200 +1256,6 @@ function ControlRail({
); );
} }
function InboxPanel({
overview,
tasks,
locale,
onInboxAction,
onTaskAction,
inboxActionKey,
taskActionKey,
t,
}: {
overview: DashboardOverview;
tasks: DashboardTask[];
locale: Locale;
onInboxAction: (item: InboxItem, action: DashboardInboxAction) => void;
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
inboxActionKey: InboxActionKey | null;
taskActionKey: TaskActionKey | null;
t: Messages;
}) {
const [filter, setFilter] = useState<InboxFilter>('all');
const items = overview.inbox ?? [];
const counts = useMemo(() => {
const next: Record<InboxFilter, number> = {
all: items.length,
'pending-room': 0,
'reviewer-request': 0,
approval: 0,
'arbiter-request': 0,
'ci-failure': 0,
mention: 0,
};
for (const item of items) next[item.kind] += 1;
return next;
}, [items]);
const filteredItems =
filter === 'all' ? items : items.filter((item) => item.kind === filter);
const severityCounts = items.reduce(
(acc, item) => {
acc[item.severity] += 1;
return acc;
},
{ error: 0, warn: 0, info: 0 },
);
if (items.length === 0) {
return <EmptyState>{t.inbox.empty}</EmptyState>;
}
return (
<div className="inbox-board">
<section className="inbox-summary" aria-label={t.inbox.summary}>
<div>
<span>{t.inbox.total}</span>
<strong>{items.length}</strong>
</div>
<div>
<span>{t.inbox.severity.error}</span>
<strong>{severityCounts.error}</strong>
</div>
<div>
<span>{t.inbox.severity.warn}</span>
<strong>{severityCounts.warn}</strong>
</div>
<div>
<span>{t.inbox.severity.info}</span>
<strong>{severityCounts.info}</strong>
</div>
</section>
<div className="inbox-filters" aria-label={t.inbox.filters}>
{INBOX_FILTERS.map((item) => {
if (item !== 'all' && counts[item] === 0) return null;
const label = item === 'all' ? t.inbox.all : t.inbox.kinds[item];
return (
<button
aria-pressed={filter === item}
className={filter === item ? 'is-active' : undefined}
key={item}
onClick={() => setFilter(item)}
type="button"
>
{label}
<span>{counts[item]}</span>
</button>
);
})}
</div>
<div className="inbox-list" aria-label={t.inbox.cardsAria}>
{filteredItems.map((item) => {
const href = inboxTargetHref(item);
const linkedTask =
item.source === 'scheduled-task' && item.taskId
? tasks.find((task) => task.id === item.taskId)
: undefined;
const linkedTaskActions = linkedTask
? taskActionsFor(linkedTask)
: [];
const inboxActions = inboxActionsFor(item);
return (
<article
className={`inbox-card inbox-${item.severity}`}
key={item.id}
>
<div className="inbox-card-head">
<div>
<span className="eyebrow">{t.inbox.kinds[item.kind]}</span>
<strong>{sanitizeInboxText(item.title) || item.title}</strong>
</div>
<div className="inbox-card-badges">
<span className={`pill pill-${item.severity}`}>
{t.inbox.severity[item.severity]}
</span>
{item.occurrences > 1 ? (
<span className="pill pill-info">x{item.occurrences}</span>
) : null}
</div>
</div>
<p>{sanitizeInboxText(item.summary) || t.inbox.noSummary}</p>
<div className="inbox-meta">
<span>
<small>{t.inbox.occurred}</small>
<strong>{formatDate(item.occurredAt, locale)}</strong>
</span>
<span>
<small>{t.inbox.source}</small>
<strong>{item.source}</strong>
</span>
<span>
<small>{t.inbox.target}</small>
<strong>
{item.taskId ??
item.roomName ??
item.groupFolder ??
item.roomJid ??
'-'}
</strong>
</span>
</div>
{href ? (
<a className="inbox-target" href={href}>
{item.taskId ? t.inbox.openTask : t.inbox.openRoom}
</a>
) : null}
{linkedTask && linkedTaskActions.length > 0 ? (
<div className="task-actions inbox-actions">
{linkedTaskActions.map((action) => {
const actionKey: TaskActionKey = `${linkedTask.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(linkedTask, action)}
type="button"
>
{busy ? t.tasks.actions.busy : t.tasks.actions[action]}
</button>
);
})}
</div>
) : null}
{inboxActions.length > 0 ? (
<div className="task-actions inbox-actions">
{inboxActions.map((action) => {
const actionKey: InboxActionKey = `${item.id}:${action}`;
const busy = inboxActionKey === actionKey;
return (
<button
aria-busy={busy || undefined}
className={`task-action task-action-${action}${busy ? ' is-busy' : ''}`}
disabled={busy}
key={action}
onClick={() => onInboxAction(item, action)}
type="button"
>
{busy
? t.inbox.actions.busy
: inboxActionLabel(item, action, t)}
</button>
);
})}
</div>
) : null}
</article>
);
})}
</div>
</div>
);
}
function HealthPanel({ function HealthPanel({
data, data,
locale, locale,

View File

@@ -0,0 +1,82 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import type { DashboardOverview } from './api';
import { InboxPanel, type InboxPanelProps } from './InboxPanel';
import { messages } from './i18n';
const t = messages.en;
function overview(inbox: DashboardOverview['inbox']): DashboardOverview {
return {
generatedAt: '2026-04-28T04:00:00.000Z',
inbox,
operations: { serviceRestarts: [] },
rooms: { active: 0, inactive: 0, total: 0, waiting: 0 },
services: [],
tasks: {
active: 0,
completed: 0,
paused: 0,
total: 0,
watchers: { active: 0, completed: 0, paused: 0 },
},
usage: { fetchedAt: null, rows: [] },
};
}
const inboxItem: DashboardOverview['inbox'][number] = {
createdAt: '2026-04-28T03:55:00.000Z',
groupFolder: 'eyejokerdb',
groupKey: 'paired:room-1',
id: 'inbox-1',
kind: 'reviewer-request',
lastOccurredAt: '2026-04-28T03:59:00.000Z',
occurrences: 2,
occurredAt: '2026-04-28T03:58:00.000Z',
roomJid: 'room-1',
roomName: 'eyejokerdb-main',
severity: 'warn',
source: 'paired-task',
summary: '<internal>hidden</internal>Reviewer needs action',
title: '<internal>hidden</internal>Review requested',
};
const baseProps: InboxPanelProps = {
inboxActionKey: null,
locale: 'en',
onInboxAction: () => {},
onTaskAction: () => {},
overview: overview([inboxItem]),
taskActionKey: null,
tasks: [],
t,
};
describe('InboxPanel', () => {
it('renders inbox summary, filters, and paired task actions', () => {
const html = renderToStaticMarkup(
createElement(InboxPanel, { ...baseProps }),
);
expect(html).toContain(t.inbox.summary);
expect(html).toContain('Review requested');
expect(html).toContain('Reviewer needs action');
expect(html).toContain(t.inbox.actions.runReview);
expect(html).toContain(t.inbox.actions.decline);
expect(html).toContain(t.inbox.actions.dismiss);
expect(html).not.toContain('internal');
});
it('renders empty state when no inbox items exist', () => {
const html = renderToStaticMarkup(
createElement(InboxPanel, {
...baseProps,
overview: overview([]),
}),
);
expect(html).toContain(t.inbox.empty);
});
});

View File

@@ -0,0 +1,293 @@
import { useMemo, useState } from 'react';
import type {
DashboardInboxAction,
DashboardOverview,
DashboardTask,
DashboardTaskAction,
} from './api';
import { formatDate, taskActionsFor } from './dashboardHelpers';
import { EmptyState } from './EmptyState';
import type { Locale, Messages } from './i18n';
export type InboxItem = DashboardOverview['inbox'][number];
export type InboxActionKey = `${string}:${DashboardInboxAction}`;
type InboxFilter = 'all' | InboxItem['kind'];
export interface InboxPanelProps {
inboxActionKey: InboxActionKey | null;
locale: Locale;
onInboxAction: (item: InboxItem, action: DashboardInboxAction) => void;
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
overview: DashboardOverview;
taskActionKey: string | null;
tasks: DashboardTask[];
t: Messages;
}
const INBOX_FILTERS: InboxFilter[] = [
'all',
'ci-failure',
'approval',
'reviewer-request',
'arbiter-request',
'pending-room',
'mention',
];
function sanitizeInboxText(value: string | null | undefined): string {
if (!value) return '';
return value
.replace(/<\/?internal[^>]*>/gi, '')
.replace(/<\/?intern\.{3}/gi, '')
.replace(/<\/?[a-z][a-z0-9-]*[^>]*>/gi, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
function inboxActionsFor(item: InboxItem): DashboardInboxAction[] {
if (
item.source === 'paired-task' &&
(item.kind === 'reviewer-request' ||
item.kind === 'approval' ||
item.kind === 'arbiter-request')
) {
return ['run', 'decline', 'dismiss'];
}
return ['dismiss'];
}
function inboxActionLabel(
item: InboxItem,
action: DashboardInboxAction,
t: Messages,
): string {
if (action === 'dismiss') return t.inbox.actions.dismiss;
if (action === 'decline') return t.inbox.actions.decline;
if (item.kind === 'reviewer-request') return t.inbox.actions.runReview;
if (item.kind === 'approval') return t.inbox.actions.finalize;
if (item.kind === 'arbiter-request') return t.inbox.actions.runArbiter;
return t.inbox.actions.run;
}
function inboxTargetHref(item: InboxItem): string | null {
if (item.taskId) return '#/scheduled';
if (item.roomJid || item.groupFolder) return '#/rooms';
return null;
}
interface InboxCardProps {
inboxActionKey: InboxActionKey | null;
item: InboxItem;
locale: Locale;
onInboxAction: (item: InboxItem, action: DashboardInboxAction) => void;
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
taskActionKey: string | null;
tasks: DashboardTask[];
t: Messages;
}
function InboxCard({
inboxActionKey,
item,
locale,
onInboxAction,
onTaskAction,
taskActionKey,
tasks,
t,
}: InboxCardProps) {
const href = inboxTargetHref(item);
const linkedTask =
item.source === 'scheduled-task' && item.taskId
? tasks.find((task) => task.id === item.taskId)
: undefined;
const linkedTaskActions = linkedTask ? taskActionsFor(linkedTask) : [];
const inboxActions = inboxActionsFor(item);
return (
<article className={`inbox-card inbox-${item.severity}`}>
<div className="inbox-card-head">
<div>
<span className="eyebrow">{t.inbox.kinds[item.kind]}</span>
<strong>{sanitizeInboxText(item.title) || item.title}</strong>
</div>
<div className="inbox-card-badges">
<span className={`pill pill-${item.severity}`}>
{t.inbox.severity[item.severity]}
</span>
{item.occurrences > 1 ? (
<span className="pill pill-info">x{item.occurrences}</span>
) : null}
</div>
</div>
<p>{sanitizeInboxText(item.summary) || t.inbox.noSummary}</p>
<div className="inbox-meta">
<span>
<small>{t.inbox.occurred}</small>
<strong>{formatDate(item.occurredAt, locale)}</strong>
</span>
<span>
<small>{t.inbox.source}</small>
<strong>{item.source}</strong>
</span>
<span>
<small>{t.inbox.target}</small>
<strong>
{item.taskId ??
item.roomName ??
item.groupFolder ??
item.roomJid ??
'-'}
</strong>
</span>
</div>
{href ? (
<a className="inbox-target" href={href}>
{item.taskId ? t.inbox.openTask : t.inbox.openRoom}
</a>
) : null}
{linkedTask && linkedTaskActions.length > 0 ? (
<div className="task-actions inbox-actions">
{linkedTaskActions.map((action) => {
const actionKey = `${linkedTask.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(linkedTask, action)}
type="button"
>
{busy ? t.tasks.actions.busy : t.tasks.actions[action]}
</button>
);
})}
</div>
) : null}
{inboxActions.length > 0 ? (
<div className="task-actions inbox-actions">
{inboxActions.map((action) => {
const actionKey: InboxActionKey = `${item.id}:${action}`;
const busy = inboxActionKey === actionKey;
return (
<button
aria-busy={busy || undefined}
className={`task-action task-action-${action}${busy ? ' is-busy' : ''}`}
disabled={busy}
key={action}
onClick={() => onInboxAction(item, action)}
type="button"
>
{busy
? t.inbox.actions.busy
: inboxActionLabel(item, action, t)}
</button>
);
})}
</div>
) : null}
</article>
);
}
export function InboxPanel({
overview,
tasks,
locale,
onInboxAction,
onTaskAction,
inboxActionKey,
taskActionKey,
t,
}: InboxPanelProps) {
const [filter, setFilter] = useState<InboxFilter>('all');
const items = overview.inbox ?? [];
const counts = useMemo(() => {
const next: Record<InboxFilter, number> = {
all: items.length,
'pending-room': 0,
'reviewer-request': 0,
approval: 0,
'arbiter-request': 0,
'ci-failure': 0,
mention: 0,
};
for (const item of items) next[item.kind] += 1;
return next;
}, [items]);
const filteredItems =
filter === 'all' ? items : items.filter((item) => item.kind === filter);
const severityCounts = items.reduce(
(acc, item) => {
acc[item.severity] += 1;
return acc;
},
{ error: 0, warn: 0, info: 0 },
);
if (items.length === 0) {
return <EmptyState>{t.inbox.empty}</EmptyState>;
}
return (
<div className="inbox-board">
<section className="inbox-summary" aria-label={t.inbox.summary}>
<div>
<span>{t.inbox.total}</span>
<strong>{items.length}</strong>
</div>
<div>
<span>{t.inbox.severity.error}</span>
<strong>{severityCounts.error}</strong>
</div>
<div>
<span>{t.inbox.severity.warn}</span>
<strong>{severityCounts.warn}</strong>
</div>
<div>
<span>{t.inbox.severity.info}</span>
<strong>{severityCounts.info}</strong>
</div>
</section>
<div className="inbox-filters" aria-label={t.inbox.filters}>
{INBOX_FILTERS.map((item) => {
if (item !== 'all' && counts[item] === 0) return null;
const label = item === 'all' ? t.inbox.all : t.inbox.kinds[item];
return (
<button
aria-pressed={filter === item}
className={filter === item ? 'is-active' : undefined}
key={item}
onClick={() => setFilter(item)}
type="button"
>
{label}
<span>{counts[item]}</span>
</button>
);
})}
</div>
<div className="inbox-list" aria-label={t.inbox.cardsAria}>
{filteredItems.map((item) => (
<InboxCard
inboxActionKey={inboxActionKey}
item={item}
key={item.id}
locale={locale}
onInboxAction={onInboxAction}
onTaskAction={onTaskAction}
taskActionKey={taskActionKey}
tasks={tasks}
t={t}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import type { DashboardTask, DashboardTaskAction } from './api';
import { localeTags, type Locale } from './i18n';
export function formatDate(
value: string | null | undefined,
locale: Locale,
): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const now = Date.now();
const ageMs = now - date.getTime();
if (ageMs >= 0 && ageMs < 60_000) {
return locale === 'ko'
? '방금'
: locale === 'ja'
? 'たった今'
: locale === 'zh'
? '刚刚'
: 'just now';
}
if (ageMs >= 0 && ageMs < 3_600_000) {
const mins = Math.floor(ageMs / 60_000);
return locale === 'ko'
? `${mins}분 전`
: locale === 'ja'
? `${mins}分前`
: locale === 'zh'
? `${mins} 分钟前`
: `${mins}m ago`;
}
const sameDay = new Date().toDateString() === date.toDateString();
if (sameDay) {
return new Intl.DateTimeFormat(localeTags[locale], {
hour: '2-digit',
hour12: false,
minute: '2-digit',
}).format(date);
}
const time = new Intl.DateTimeFormat(localeTags[locale], {
hour: '2-digit',
hour12: false,
minute: '2-digit',
}).format(date);
if (locale === 'ko')
return `${date.getMonth() + 1}${date.getDate()}${time}`;
if (locale === 'ja')
return `${date.getMonth() + 1}${date.getDate()}${time}`;
if (locale === 'zh')
return `${date.getMonth() + 1}${date.getDate()}${time}`;
return new Intl.DateTimeFormat(localeTags[locale], {
day: 'numeric',
hour: '2-digit',
hour12: false,
minute: '2-digit',
month: 'short',
}).format(date);
}
export function taskActionsFor(task: DashboardTask): DashboardTaskAction[] {
if (task.status === 'active') return ['pause', 'cancel'];
if (task.status === 'paused') return ['resume', 'cancel'];
return [];
}