From d99c0bc6be27d5092cb12b297bd4ebf616c35688 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 28 Apr 2026 14:23:43 +0900 Subject: [PATCH] Extract dashboard RoomBoardV2 component (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- apps/dashboard/src/App.tsx | 273 +---------------- apps/dashboard/src/RoomBoardV2.test.ts | 92 ++++++ apps/dashboard/src/RoomBoardV2.tsx | 401 +++++++++++++++++++++++++ 3 files changed, 507 insertions(+), 259 deletions(-) create mode 100644 apps/dashboard/src/RoomBoardV2.test.ts create mode 100644 apps/dashboard/src/RoomBoardV2.tsx diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 84ee950..a4ae937 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -44,10 +44,7 @@ import { type Locale, type Messages, } from './i18n'; -import { - useSelectedRoomActivity, - type RoomActivityMap, -} from './useRoomActivity'; +import { useSelectedRoomActivity } from './useRoomActivity'; import { SectionNav, SideRail, @@ -55,7 +52,7 @@ import { type DashboardView, } from './DashboardNav'; import { isInternalProtocolPayload } from './roomThread'; -import { RoomCardV2, type RoomEntryWithService } from './RoomCardV2'; +import { RoomBoardV2 } from './RoomBoardV2'; import { ParsedBody } from './ParsedBody'; import { redactSecretsForPreview } from './redaction'; import './styles.css'; @@ -426,6 +423,14 @@ function formatLiveElapsed(value: number, t: Messages): string { return `${hours}${t.units.hour} ${remMin}${t.units.minute} ${remSec}${t.units.second}`; } +const ROOM_BOARD_FORMATTERS = { + formatDate, + formatDuration, + formatLiveElapsed, + senderRoleClass, + statusLabel, +}; + function queueLabel( pendingTasks: number, pendingMessages: boolean, @@ -1738,258 +1743,6 @@ function HealthPanel({ ); } -type RoomFilter = 'all' | 'processing' | 'waiting' | 'inactive'; -type RoomSort = 'recent' | 'name' | 'queue'; - -const ROOM_FILTER_ORDER: RoomFilter[] = [ - 'all', - 'processing', - 'waiting', - 'inactive', -]; - -function roomFilterLabel(f: RoomFilter, t: Messages): string { - if (f === 'all') return t.rooms.filterAll; - return statusLabel(f, t); -} - -function roomSortLabel(s: RoomSort, t: Messages): string { - if (s === 'recent') return t.rooms.sortRecent; - if (s === 'queue') return t.rooms.sortQueue; - return t.rooms.sortName; -} - -function RoomBoardV2({ - inbox, - onSendRoomMessage, - pendingMessages, - roomActivity, - roomActivityLoading, - roomMessageKey, - selectedJid, - locale, - onSelectedJidChange, - snapshots, - t, -}: { - inbox: InboxItem[]; - onSendRoomMessage: ( - roomJid: string, - text: string, - requestId: string, - ) => Promise; - pendingMessages: Record< - string, - Array - >; - roomActivity: RoomActivityMap; - roomActivityLoading: boolean; - roomMessageKey: string | null; - selectedJid: string | null; - locale: Locale; - onSelectedJidChange: (jid: string | null) => void; - snapshots: StatusSnapshot[]; - t: Messages; -}) { - const [filter, setFilter] = useState('all'); - const [sort, setSort] = useState('recent'); - const [drafts, setDrafts] = useState>({}); - const [mobileDetailOpen, setMobileDetailOpen] = useState(false); - - const allEntries: RoomEntryWithService[] = snapshots.flatMap((snapshot) => - snapshot.entries.map((entry) => ({ - ...entry, - serviceId: snapshot.serviceId, - })), - ); - - const counts = { - all: allEntries.length, - processing: allEntries.filter((e) => e.status === 'processing').length, - waiting: allEntries.filter((e) => e.status === 'waiting').length, - inactive: allEntries.filter((e) => e.status === 'inactive').length, - }; - - const filtered = allEntries.filter( - (e) => filter === 'all' || e.status === filter, - ); - const sorted = [...filtered].sort((a, b) => { - if (sort === 'name') return a.name.localeCompare(b.name); - if (sort === 'queue') { - const aQ = a.pendingTasks + (a.pendingMessages ? 1 : 0); - const bQ = b.pendingTasks + (b.pendingMessages ? 1 : 0); - return bQ - aQ; - } - const aA = roomActivity[a.jid]?.pairedTask?.updatedAt; - const bA = roomActivity[b.jid]?.pairedTask?.updatedAt; - const aT = aA ? new Date(aA).getTime() : (a.elapsedMs ?? 0); - const bT = bA ? new Date(bA).getTime() : (b.elapsedMs ?? 0); - return bT - aT; - }); - - const selectedEntry = - sorted.find((e) => e.jid === selectedJid) ?? sorted[0] ?? null; - - useEffect(() => { - const nextJid = selectedEntry?.jid ?? null; - if (nextJid !== selectedJid) { - onSelectedJidChange(nextJid); - setMobileDetailOpen(false); - } - }, [onSelectedJidChange, selectedEntry?.jid, selectedJid]); - - if (allEntries.length === 0) { - return {t.rooms.empty}; - } - - function setDraft(jid: string, value: string) { - setDrafts((previous) => ({ ...previous, [jid]: value })); - } - - function scrollDetailToBottom() { - if (typeof window === 'undefined') return; - const detail = document.querySelector( - '.rooms-detail', - ) as HTMLElement | null; - if (!detail) return; - const tick = (n: number) => { - detail.scrollTop = detail.scrollHeight; - if (n > 0) requestAnimationFrame(() => tick(n - 1)); - }; - requestAnimationFrame(() => tick(3)); - } - - async function submitRoomMessage(jid: string) { - const text = drafts[jid]?.trim(); - if (!text) return; - scrollDetailToBottom(); - const success = await onSendRoomMessage(jid, text, makeClientRequestId()); - if (success) { - setDraft(jid, ''); - scrollDetailToBottom(); - } - } - - return ( -
-
-
- {ROOM_FILTER_ORDER.map((f) => ( - - ))} -
- -
- - {sorted.length === 0 ? ( - {t.rooms.empty} - ) : ( -
- -
- - {selectedEntry ? ( - item.roomJid === selectedEntry.jid, - )} - key={`${selectedEntry.serviceId}:${selectedEntry.jid}`} - locale={locale} - onDraftChange={(v) => setDraft(selectedEntry.jid, v)} - onSendMessage={() => void submitRoomMessage(selectedEntry.jid)} - onToggle={() => {}} - pendingMessages={pendingMessages[selectedEntry.jid] ?? []} - pinned={true} - senderRoleClass={senderRoleClass} - statusLabel={statusLabel} - t={t} - /> - ) : ( - {t.rooms.empty} - )} -
-
- )} -
- ); -} - function UsageQuotaMeter({ row, rowName, @@ -3036,14 +2789,16 @@ function App() { {t.panels.queue} 'request-1', + formatDate: (value) => value ?? '-', + formatDuration: (value) => (value === null ? '-' : `${value}`), + formatLiveElapsed: (value) => `${value}`, + inbox: [], + locale: 'ko', + onSelectedJidChange: () => {}, + onSendRoomMessage: async () => true, + pendingMessages: {}, + roomActivity: { 'room-1': activity }, + roomActivityLoading: false, + roomMessageKey: null, + selectedJid: 'room-1', + senderRoleClass: (value) => + (value ?? '').toLowerCase().includes('owner') ? 'role-owner' : 'role-human', + snapshots: [snapshot], + statusLabel: (status) => status, + t, +}; + +describe('RoomBoardV2', () => { + it('renders room list and selected detail thread', () => { + const html = renderToStaticMarkup(createElement(RoomBoardV2, baseProps)); + + expect(html).toContain('eyejokerdb-main'); + expect(html).toContain('TASK_DONE'); + expect(html).toContain('prod 배포 완료'); + }); + + it('renders an empty state without snapshots', () => { + const html = renderToStaticMarkup( + createElement(RoomBoardV2, { ...baseProps, snapshots: [] }), + ); + + expect(html).toContain(t.rooms.empty); + }); +}); diff --git a/apps/dashboard/src/RoomBoardV2.tsx b/apps/dashboard/src/RoomBoardV2.tsx new file mode 100644 index 0000000..70235bb --- /dev/null +++ b/apps/dashboard/src/RoomBoardV2.tsx @@ -0,0 +1,401 @@ +import { useEffect, useState, type ReactNode } from 'react'; + +import type { + DashboardOverview, + DashboardRoomActivity, + StatusSnapshot, +} from './api'; +import type { Locale, Messages } from './i18n'; +import { RoomCardV2, type RoomEntryWithService } from './RoomCardV2'; +import type { RoomActivityMap } from './useRoomActivity'; + +type InboxItem = DashboardOverview['inbox'][number]; +type RoomFilter = 'all' | 'processing' | 'waiting' | 'inactive'; +type RoomSort = 'recent' | 'name' | 'queue'; + +const ROOM_FILTER_ORDER: RoomFilter[] = [ + 'all', + 'processing', + 'waiting', + 'inactive', +]; + +export interface RoomBoardV2Props { + createRequestId: () => string; + formatDate: (value: string | null | undefined, locale: Locale) => string; + formatDuration: (value: number | null, t: Messages) => string; + formatLiveElapsed: (value: number, t: Messages) => string; + inbox: InboxItem[]; + locale: Locale; + onSelectedJidChange: (jid: string | null) => void; + onSendRoomMessage: ( + roomJid: string, + text: string, + requestId: string, + ) => Promise; + pendingMessages: Record< + string, + Array + >; + roomActivity: RoomActivityMap; + roomActivityLoading: boolean; + roomMessageKey: string | null; + selectedJid: string | null; + senderRoleClass: (value: string | null | undefined) => string; + snapshots: StatusSnapshot[]; + statusLabel: (status: string, t: Messages) => string; + t: Messages; +} + +function EmptyState({ children }: { children: ReactNode }) { + return
{children}
; +} + +function roomFilterLabel( + filter: RoomFilter, + statusLabel: (status: string, t: Messages) => string, + t: Messages, +): string { + if (filter === 'all') return t.rooms.filterAll; + return statusLabel(filter, t); +} + +function roomSortLabel(sort: RoomSort, t: Messages): string { + if (sort === 'recent') return t.rooms.sortRecent; + if (sort === 'queue') return t.rooms.sortQueue; + return t.rooms.sortName; +} + +function scrollDetailToBottom() { + if (typeof window === 'undefined') return; + const detail = document.querySelector('.rooms-detail') as HTMLElement | null; + if (!detail) return; + const tick = (n: number) => { + detail.scrollTop = detail.scrollHeight; + if (n > 0) requestAnimationFrame(() => tick(n - 1)); + }; + requestAnimationFrame(() => tick(3)); +} + +interface RoomsToolbarProps { + counts: Record; + filter: RoomFilter; + onFilterChange: (filter: RoomFilter) => void; + onSortChange: (sort: RoomSort) => void; + sort: RoomSort; + statusLabel: (status: string, t: Messages) => string; + t: Messages; +} + +function RoomsToolbar({ + counts, + filter, + onFilterChange, + onSortChange, + sort, + statusLabel, + t, +}: RoomsToolbarProps) { + return ( +
+
+ {ROOM_FILTER_ORDER.map((filterKey) => ( + + ))} +
+ +
+ ); +} + +interface RoomsListProps { + entries: RoomEntryWithService[]; + inbox: InboxItem[]; + onSelect: (jid: string) => void; + selectedJid: string | null; + t: Messages; +} + +function RoomsList({ + entries, + inbox, + onSelect, + selectedJid, + t, +}: RoomsListProps) { + return ( + + ); +} + +interface RoomsDetailProps extends Pick< + RoomBoardV2Props, + | 'formatDate' + | 'formatDuration' + | 'formatLiveElapsed' + | 'locale' + | 'pendingMessages' + | 'roomActivity' + | 'roomActivityLoading' + | 'roomMessageKey' + | 'senderRoleClass' + | 'statusLabel' + | 't' +> { + drafts: Record; + inbox: InboxItem[]; + onBack: () => void; + onDraftChange: (jid: string, value: string) => void; + onSubmit: (jid: string) => void; + selectedEntry: RoomEntryWithService | null; +} + +function RoomsDetail({ + drafts, + formatDate, + formatDuration, + formatLiveElapsed, + inbox, + locale, + onBack, + onDraftChange, + onSubmit, + pendingMessages, + roomActivity, + roomActivityLoading, + roomMessageKey, + selectedEntry, + senderRoleClass, + statusLabel, + t, +}: RoomsDetailProps) { + return ( +
+ + {selectedEntry ? ( + item.roomJid === selectedEntry.jid, + )} + key={`${selectedEntry.serviceId}:${selectedEntry.jid}`} + locale={locale} + onDraftChange={(value) => onDraftChange(selectedEntry.jid, value)} + onSendMessage={() => onSubmit(selectedEntry.jid)} + onToggle={() => {}} + pendingMessages={pendingMessages[selectedEntry.jid] ?? []} + pinned={true} + senderRoleClass={senderRoleClass} + statusLabel={statusLabel} + t={t} + /> + ) : ( + {t.rooms.empty} + )} +
+ ); +} + +export function RoomBoardV2({ + createRequestId, + formatDate, + formatDuration, + formatLiveElapsed, + inbox, + onSendRoomMessage, + pendingMessages, + roomActivity, + roomActivityLoading, + roomMessageKey, + selectedJid, + locale, + onSelectedJidChange, + senderRoleClass, + snapshots, + statusLabel, + t, +}: RoomBoardV2Props) { + const [filter, setFilter] = useState('all'); + const [sort, setSort] = useState('recent'); + const [drafts, setDrafts] = useState>({}); + const [mobileDetailOpen, setMobileDetailOpen] = useState(false); + + const allEntries: RoomEntryWithService[] = snapshots.flatMap((snapshot) => + snapshot.entries.map((entry) => ({ + ...entry, + serviceId: snapshot.serviceId, + })), + ); + + const counts = { + all: allEntries.length, + processing: allEntries.filter((entry) => entry.status === 'processing') + .length, + waiting: allEntries.filter((entry) => entry.status === 'waiting').length, + inactive: allEntries.filter((entry) => entry.status === 'inactive').length, + }; + + const filtered = allEntries.filter( + (entry) => filter === 'all' || entry.status === filter, + ); + const sorted = [...filtered].sort((a, b) => { + if (sort === 'name') return a.name.localeCompare(b.name); + if (sort === 'queue') { + const aQ = a.pendingTasks + (a.pendingMessages ? 1 : 0); + const bQ = b.pendingTasks + (b.pendingMessages ? 1 : 0); + return bQ - aQ; + } + const aA = roomActivity[a.jid]?.pairedTask?.updatedAt; + const bA = roomActivity[b.jid]?.pairedTask?.updatedAt; + const aT = aA ? new Date(aA).getTime() : (a.elapsedMs ?? 0); + const bT = bA ? new Date(bA).getTime() : (b.elapsedMs ?? 0); + return bT - aT; + }); + + const selectedEntry = + sorted.find((entry) => entry.jid === selectedJid) ?? sorted[0] ?? null; + + useEffect(() => { + const nextJid = selectedEntry?.jid ?? null; + if (nextJid !== selectedJid) { + onSelectedJidChange(nextJid); + setMobileDetailOpen(false); + } + }, [onSelectedJidChange, selectedEntry?.jid, selectedJid]); + + if (allEntries.length === 0) { + return {t.rooms.empty}; + } + + function setDraft(jid: string, value: string) { + setDrafts((previous) => ({ ...previous, [jid]: value })); + } + + async function submitRoomMessage(jid: string) { + const text = drafts[jid]?.trim(); + if (!text) return; + scrollDetailToBottom(); + const success = await onSendRoomMessage(jid, text, createRequestId()); + if (success) { + setDraft(jid, ''); + scrollDetailToBottom(); + } + } + + return ( +
+ + + {sorted.length === 0 ? ( + {t.rooms.empty} + ) : ( +
+ { + onSelectedJidChange(jid); + setMobileDetailOpen(true); + }} + selectedJid={selectedEntry?.jid ?? null} + t={t} + /> + setMobileDetailOpen(false)} + onDraftChange={setDraft} + onSubmit={(jid) => void submitRoomMessage(jid)} + pendingMessages={pendingMessages} + roomActivity={roomActivity} + roomActivityLoading={roomActivityLoading} + roomMessageKey={roomMessageKey} + selectedEntry={selectedEntry} + senderRoleClass={senderRoleClass} + statusLabel={statusLabel} + t={t} + /> +
+ )} +
+ ); +}