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:
@@ -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,
|
||||||
|
|||||||
82
apps/dashboard/src/InboxPanel.test.ts
Normal file
82
apps/dashboard/src/InboxPanel.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
293
apps/dashboard/src/InboxPanel.tsx
Normal file
293
apps/dashboard/src/InboxPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
apps/dashboard/src/dashboardHelpers.ts
Normal file
64
apps/dashboard/src/dashboardHelpers.ts
Normal 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 [];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user