remove inbox top-level navigation

This commit is contained in:
ejclaw
2026-05-02 14:51:55 +09:00
parent 3b9a1dccee
commit 404ea5b515
8 changed files with 162 additions and 186 deletions

View File

@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import { import {
type CreateScheduledTaskInput, type CreateScheduledTaskInput,
type DashboardInboxAction,
type DashboardRoomActivity, type DashboardRoomActivity,
type DashboardTaskAction, type DashboardTaskAction,
type DashboardOverview, type DashboardOverview,
@@ -11,7 +10,6 @@ import {
type StatusSnapshot, type StatusSnapshot,
createScheduledTask, createScheduledTask,
fetchDashboardData, fetchDashboardData,
runInboxAction,
runServiceAction, runServiceAction,
runScheduledTaskAction, runScheduledTaskAction,
sendRoomMessage, sendRoomMessage,
@@ -33,7 +31,6 @@ import {
type DashboardView, type DashboardView,
} from './DashboardNav'; } from './DashboardNav';
import { formatDate, statusLabel } from './dashboardHelpers'; import { formatDate, statusLabel } from './dashboardHelpers';
import { InboxPanel, type InboxActionKey, type InboxItem } from './InboxPanel';
import { RoomBoardV2 } from './RoomBoardV2'; import { RoomBoardV2 } from './RoomBoardV2';
import { ServicePanel, type ServiceActionKey } from './ServicePanel'; import { ServicePanel, type ServiceActionKey } from './ServicePanel';
import { SettingsPanel } from './SettingsPanel'; import { SettingsPanel } from './SettingsPanel';
@@ -68,7 +65,6 @@ function isDashboardView(
): value is DashboardView { ): value is DashboardView {
return ( return (
value === 'usage' || value === 'usage' ||
value === 'inbox' ||
value === 'health' || value === 'health' ||
value === 'rooms' || value === 'rooms' ||
value === 'scheduled' || value === 'scheduled' ||
@@ -82,6 +78,13 @@ function readViewFromHash(): DashboardView {
return isDashboardView(raw) ? raw : DEFAULT_VIEW; return isDashboardView(raw) ? raw : DEFAULT_VIEW;
} }
function normalizeDashboardHash(view: DashboardView): void {
if (typeof window === 'undefined') return;
const raw = window.location.hash.replace(/^#\/?/, '');
if (!raw || isDashboardView(raw)) return;
window.history.replaceState(null, '', `#/${view}`);
}
function readInitialLocale(): Locale { function readInitialLocale(): Locale {
const stored = const stored =
typeof window === 'undefined' typeof window === 'undefined'
@@ -385,9 +388,6 @@ function App() {
const [taskActionKey, setTaskActionKey] = useState<TaskActionKey | null>( const [taskActionKey, setTaskActionKey] = useState<TaskActionKey | null>(
null, null,
); );
const [inboxActionKey, setInboxActionKey] = useState<InboxActionKey | null>(
null,
);
const [serviceActionKey, setServiceActionKey] = const [serviceActionKey, setServiceActionKey] =
useState<ServiceActionKey | null>(null); useState<ServiceActionKey | null>(null);
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null); const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
@@ -490,33 +490,6 @@ function App() {
} }
} }
async function handleInboxAction(
item: InboxItem,
action: DashboardInboxAction,
) {
if (
action === 'decline' &&
typeof window !== 'undefined' &&
!window.confirm(t.inbox.actions.confirmDecline)
) {
return;
}
const actionKey: InboxActionKey = `${item.id}:${action}`;
setInboxActionKey(actionKey);
try {
await runInboxAction(item.id, action, {
lastOccurredAt: item.lastOccurredAt,
requestId: makeClientRequestId(),
});
await refresh(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setInboxActionKey(null);
}
}
async function handleServiceRestart() { async function handleServiceRestart() {
if ( if (
typeof window !== 'undefined' && typeof window !== 'undefined' &&
@@ -715,7 +688,9 @@ function App() {
useEffect(() => { useEffect(() => {
function handleHashChange() { function handleHashChange() {
setActiveView(readViewFromHash()); const nextView = readViewFromHash();
setActiveView(nextView);
normalizeDashboardHash(nextView);
} }
handleHashChange(); handleHashChange();
@@ -840,29 +815,6 @@ function App() {
</> </>
) : null} ) : null}
{activeView === 'inbox' ? (
<section className="panel view-panel" id="inbox">
<div className="panel-title">
<h2>{t.panels.inbox}</h2>
<span>{t.panels.inboxQueue}</span>
</div>
<InboxPanel
inboxActionKey={inboxActionKey}
locale={locale}
onInboxAction={(item, action) =>
void handleInboxAction(item, action)
}
onTaskAction={(task, action) =>
void handleTaskAction(task, action)
}
overview={data.overview}
taskActionKey={taskActionKey}
tasks={data.tasks}
t={t}
/>
</section>
) : null}
{activeView === 'health' ? ( {activeView === 'health' ? (
<section className="panel view-panel" id="health"> <section className="panel view-panel" id="health">
<div className="panel-title"> <div className="panel-title">

View File

@@ -4,7 +4,6 @@ import {
Clock, Clock,
Download, Download,
Gauge, Gauge,
Inbox as InboxIcon,
MessageSquare, MessageSquare,
RefreshCw, RefreshCw,
Settings, Settings,
@@ -15,7 +14,6 @@ import { type Messages } from './i18n';
export type DashboardView = export type DashboardView =
| 'usage' | 'usage'
| 'inbox'
| 'health' | 'health'
| 'rooms' | 'rooms'
| 'scheduled' | 'scheduled'
@@ -29,7 +27,6 @@ interface DashboardNavData {
const NAV_ICONS: Record<DashboardView, ReactNode> = { const NAV_ICONS: Record<DashboardView, ReactNode> = {
usage: <Gauge size={20} strokeWidth={2} aria-hidden />, usage: <Gauge size={20} strokeWidth={2} aria-hidden />,
inbox: <InboxIcon size={20} strokeWidth={2} aria-hidden />,
health: <Activity size={20} strokeWidth={2} aria-hidden />, health: <Activity size={20} strokeWidth={2} aria-hidden />,
rooms: <MessageSquare size={20} strokeWidth={2} aria-hidden />, rooms: <MessageSquare size={20} strokeWidth={2} aria-hidden />,
scheduled: <Clock size={20} strokeWidth={2} aria-hidden />, scheduled: <Clock size={20} strokeWidth={2} aria-hidden />,
@@ -39,7 +36,6 @@ const NAV_ICONS: Record<DashboardView, ReactNode> = {
function navItems(t: Messages) { function navItems(t: Messages) {
return [ return [
{ href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const }, { href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const },
{ href: '#/inbox', label: t.nav.inbox, view: 'inbox' as const },
{ href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const }, { href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const },
{ href: '#/health', label: t.nav.health, view: 'health' as const }, { href: '#/health', label: t.nav.health, view: 'health' as const },
{ href: '#/usage', label: t.nav.usage, view: 'usage' as const }, { href: '#/usage', label: t.nav.usage, view: 'usage' as const },
@@ -59,7 +55,6 @@ function navStats(data: DashboardNavData | null) {
{ processing: 0 }, { processing: 0 },
); );
return { return {
inbox: data.overview.inbox.length,
processing: queue.processing, processing: queue.processing,
}; };
} }
@@ -68,7 +63,6 @@ function navBadge(
view: DashboardView, view: DashboardView,
stats: ReturnType<typeof navStats>, stats: ReturnType<typeof navStats>,
): number | null { ): number | null {
if (view === 'inbox' && stats && stats.inbox > 0) return stats.inbox;
if (view === 'rooms' && stats && stats.processing > 0) { if (view === 'rooms' && stats && stats.processing > 0) {
return stats.processing; return stats.processing;
} }
@@ -100,8 +94,8 @@ function DashboardBrandLink({
return ( return (
<a <a
className="rail-brand" className="rail-brand"
href="#/usage" href="#/rooms"
onClick={() => onNavigate('usage')} onClick={() => onNavigate('rooms')}
title="EJClaw" title="EJClaw"
> >
EJ EJ

View File

@@ -82,6 +82,36 @@ describe('RoomBoardV2', () => {
expect(html).toContain('prod 배포 완료'); expect(html).toContain('prod 배포 완료');
}); });
it('surfaces merge approval items as room action badges', () => {
const html = renderToStaticMarkup(
createElement(RoomBoardV2, {
...baseProps,
inbox: [
{
id: 'paired:merge-1:merge_ready',
groupKey: 'paired:merge-1:merge_ready',
kind: 'approval',
severity: 'warn',
title: 'Ready to merge',
summary: 'merge_ready',
occurredAt: '2026-04-28T04:00:00.000Z',
lastOccurredAt: '2026-04-28T04:00:00.000Z',
createdAt: '2026-04-28T04:00:00.000Z',
occurrences: 1,
source: 'paired-task',
roomJid: 'room-1',
taskId: 'merge-1',
taskStatus: 'merge_ready',
},
],
}),
);
expect(html).toContain(t.inbox.kinds.approval);
expect(html).toContain('room-inbox-pip sev-warn');
expect(html).toContain('rooms-list-bell sev-warn');
});
it('renders an empty state without snapshots', () => { it('renders an empty state without snapshots', () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
createElement(RoomBoardV2, { ...baseProps, snapshots: [] }), createElement(RoomBoardV2, { ...baseProps, snapshots: [] }),

View File

@@ -6,6 +6,7 @@ import process from 'node:process';
import { createServer, type ViteDevServer } from 'vite'; import { createServer, type ViteDevServer } from 'vite';
interface MockApiState { interface MockApiState {
approvalAction: boolean;
codexFeatures: { goals: boolean }; codexFeatures: { goals: boolean };
codexFeatureUpdates: number; codexFeatureUpdates: number;
ciWatcherFailures: number; ciWatcherFailures: number;
@@ -13,6 +14,7 @@ interface MockApiState {
} }
const seriousImpacts = new Set(['serious', 'critical']); const seriousImpacts = new Set(['serious', 'critical']);
const MOCK_TIME = new Date(0).toISOString();
async function main() { async function main() {
const server = await startDashboardServer(); const server = await startDashboardServer();
@@ -112,17 +114,21 @@ async function main() {
); );
await runScenario( await runScenario(
'inbox stays focused on actionable work', 'rooms surface user action badges without inbox navigation',
browser, browser,
baseUrl, baseUrl,
async (page, state) => { async (page, state) => {
state.approvalAction = true;
state.ciWatcherFailures = 2; state.ciWatcherFailures = 2;
await page.goto(new URL('/#/inbox', baseUrl).toString(), { await page.goto(new URL('/#/inbox', baseUrl).toString(), {
waitUntil: 'networkidle', waitUntil: 'networkidle',
}); });
await assertVisible(page.locator('#inbox .empty-state')); await page.waitForURL(/#\/rooms$/);
await assertVisible(page.locator('#rooms .rooms-v2'));
assert.equal(await page.locator('a[href="#/inbox"]').count(), 0);
await assertVisible(page.getByText(/승인|Approval/).first());
assert.equal(await page.getByText(/CI 실패|CI failure/).count(), 0); assert.equal(await page.getByText(/CI 실패|CI failure/).count(), 0);
assert.equal( assert.equal(
await page.getByRole('button', { name: 'Dismiss' }).count(), await page.getByRole('button', { name: 'Dismiss' }).count(),
@@ -179,6 +185,7 @@ async function runScenario(
function createMockApiState(): MockApiState { function createMockApiState(): MockApiState {
return { return {
approvalAction: false,
codexFeatures: { goals: false }, codexFeatures: { goals: false },
codexFeatureUpdates: 0, codexFeatureUpdates: 0,
ciWatcherFailures: 0, ciWatcherFailures: 0,
@@ -229,26 +236,15 @@ async function handleMockApi(route: Route, state: MockApiState) {
const method = request.method(); const method = request.method();
if (method === 'GET' && url.pathname === '/api/overview') { if (method === 'GET' && url.pathname === '/api/overview') {
await fulfillJson(route, { await fulfillJson(route, mockOverview(state));
generatedAt: new Date(0).toISOString(),
services: [],
rooms: { total: 0, active: 0, waiting: 0, inactive: 0 },
tasks: {
total: 0,
active: 0,
paused: state.ciWatcherFailures,
completed: 0,
watchers: { active: 0, paused: state.ciWatcherFailures, completed: 0 },
},
usage: { rows: [], fetchedAt: null },
operations: { serviceRestarts: [] },
inbox: [],
});
return; return;
} }
if (method === 'GET' && url.pathname === '/api/status-snapshots') { if (method === 'GET' && url.pathname === '/api/status-snapshots') {
await fulfillJson(route, []); await fulfillJson(
route,
state.approvalAction ? [mockStatusSnapshot()] : [],
);
return; return;
} }
@@ -371,6 +367,78 @@ async function handleMockApi(route: Route, state: MockApiState) {
); );
} }
function mockOverview(state: MockApiState) {
return {
generatedAt: MOCK_TIME,
services: [
{
serviceId: 'codex-main',
assistantName: 'Codex',
agentType: 'codex',
updatedAt: MOCK_TIME,
totalRooms: state.approvalAction ? 1 : 0,
activeRooms: 0,
},
],
rooms: state.approvalAction
? { total: 1, active: 0, waiting: 0, inactive: 1 }
: { total: 0, active: 0, waiting: 0, inactive: 0 },
tasks: {
total: 0,
active: 0,
paused: state.ciWatcherFailures,
completed: 0,
watchers: { active: 0, paused: state.ciWatcherFailures, completed: 0 },
},
usage: { rows: [], fetchedAt: null },
operations: { serviceRestarts: [] },
inbox: state.approvalAction ? [mockApprovalInboxItem()] : [],
};
}
function mockApprovalInboxItem() {
return {
id: 'paired:merge-1:merge_ready',
groupKey: 'paired:merge-1:merge_ready',
kind: 'approval',
severity: 'warn',
title: 'Ready to merge',
summary: 'merge_ready',
occurredAt: MOCK_TIME,
lastOccurredAt: MOCK_TIME,
createdAt: MOCK_TIME,
occurrences: 1,
source: 'paired-task',
roomJid: 'dc:ops',
roomName: '#ops',
groupFolder: 'ops',
serviceId: 'codex-main',
taskId: 'merge-1',
taskStatus: 'merge_ready',
};
}
function mockStatusSnapshot() {
return {
serviceId: 'codex-main',
assistantName: 'Codex',
agentType: 'codex',
updatedAt: MOCK_TIME,
entries: [
{
jid: 'dc:ops',
name: '#ops',
folder: 'ops',
agentType: 'codex',
status: 'inactive',
elapsedMs: null,
pendingMessages: false,
pendingTasks: 0,
},
],
};
}
function parseJsonBody(body: string | null): Record<string, unknown> { function parseJsonBody(body: string | null): Record<string, unknown> {
if (!body) return {}; if (!body) return {};
const parsed = JSON.parse(body) as unknown; const parsed = JSON.parse(body) as unknown;

View File

@@ -761,7 +761,7 @@ describe('web dashboard room activity data', () => {
}); });
describe('web dashboard inbox data', () => { describe('web dashboard inbox data', () => {
it('builds typed inbox items from pending rooms and paired tasks only', () => { it('builds user-action inbox items from merge-ready paired tasks only', () => {
const snapshots: StatusSnapshot[] = [ const snapshots: StatusSnapshot[] = [
{ {
serviceId: 'codex-main', serviceId: 'codex-main',
@@ -803,6 +803,8 @@ describe('web dashboard inbox data', () => {
review_requested_at: '2026-04-26T05:03:00.000Z', review_requested_at: '2026-04-26T05:03:00.000Z',
}), }),
makePairedTask({ makePairedTask({
chat_jid: 'dc:ops',
group_folder: 'ops',
id: 'merge-1', id: 'merge-1',
status: 'merge_ready', status: 'merge_ready',
title: 'Ready to merge', title: 'Ready to merge',
@@ -839,31 +841,27 @@ describe('web dashboard inbox data', () => {
], ],
}); });
expect(overview.inbox.map((item) => item.kind)).toEqual([ expect(overview.inbox.map((item) => item.kind)).toEqual(['approval']);
'approval',
'reviewer-request',
'pending-room',
]);
expect(overview.inbox).toContainEqual( expect(overview.inbox).toContainEqual(
expect.objectContaining({ expect.objectContaining({
id: 'room:codex-main:dc:ops', id: 'paired:merge-1:merge_ready',
kind: 'pending-room', kind: 'approval',
severity: 'info', severity: 'warn',
roomJid: 'dc:ops', roomJid: 'dc:ops',
taskId: 'merge-1',
serviceId: 'codex-main', serviceId: 'codex-main',
occurredAt: '2026-04-26T05:00:00.000Z', occurredAt: '2026-04-26T05:04:00.000Z',
createdAt: '2026-04-26T05:10:00.000Z', createdAt: '2026-04-26T05:10:00.000Z',
}), }),
); );
expect(overview.inbox).toContainEqual( expect(overview.inbox.some((item) => item.kind === 'pending-room')).toBe(
expect.objectContaining({ false,
id: 'paired:review-1:review_ready', );
kind: 'reviewer-request', expect(
severity: 'warn', overview.inbox.some((item) => item.kind === 'reviewer-request'),
serviceId: 'claude-reviewer', ).toBe(false);
taskStatus: 'review_ready', expect(overview.inbox.some((item) => item.kind === 'arbiter-request')).toBe(
occurredAt: '2026-04-26T05:03:00.000Z', false,
}),
); );
expect(overview.inbox.some((item) => item.kind === 'ci-failure')).toBe( expect(overview.inbox.some((item) => item.kind === 'ci-failure')).toBe(
false, false,

View File

@@ -274,51 +274,15 @@ function pairedTaskInboxKind(
status: PairedTask['status'], status: PairedTask['status'],
): InboxItemKind | null { ): InboxItemKind | null {
if (status === 'merge_ready') return 'approval'; if (status === 'merge_ready') return 'approval';
if (status === 'review_ready' || status === 'in_review') {
return 'reviewer-request';
}
if (status === 'arbiter_requested' || status === 'in_arbitration') {
return 'arbiter-request';
}
return null; return null;
} }
function collectInboxItems(args: { function collectInboxItems(args: {
snapshots: StatusSnapshot[];
tasks: ScheduledTask[];
pairedTasks: PairedTask[]; pairedTasks: PairedTask[];
createdAt: string; createdAt: string;
}): InboxItem[] { }): InboxItem[] {
const items: InboxItem[] = []; const items: InboxItem[] = [];
for (const snapshot of args.snapshots) {
for (const entry of snapshot.entries) {
if (!entry.pendingMessages && entry.pendingTasks === 0) continue;
const parts: string[] = [];
if (entry.pendingMessages) parts.push('pending messages');
if (entry.pendingTasks > 0) parts.push(`${entry.pendingTasks} tasks`);
items.push({
id: `room:${snapshot.serviceId}:${entry.jid}`,
groupKey: `room:${snapshot.serviceId}:${entry.jid}`,
kind: 'pending-room',
severity: entry.pendingTasks > 0 ? 'warn' : 'info',
title: entry.name || entry.folder || entry.jid,
summary: parts.join(' · '),
occurredAt: snapshot.updatedAt,
lastOccurredAt: snapshot.updatedAt,
createdAt: args.createdAt,
occurrences: 1,
source: 'status-snapshot',
roomJid: entry.jid,
roomName: entry.name,
groupFolder: entry.folder,
serviceId: snapshot.serviceId,
});
}
}
for (const task of args.pairedTasks) { for (const task of args.pairedTasks) {
const kind = pairedTaskInboxKind(task.status); const kind = pairedTaskInboxKind(task.status);
if (!kind) continue; if (!kind) continue;
@@ -327,30 +291,17 @@ function collectInboxItems(args: {
id: `paired:${task.id}:${task.status}`, id: `paired:${task.id}:${task.status}`,
groupKey: `paired:${task.id}:${task.status}`, groupKey: `paired:${task.id}:${task.status}`,
kind, kind,
severity: kind === 'arbiter-request' ? 'error' : 'warn', severity: 'warn',
title: task.title || task.group_folder, title: task.title || task.group_folder,
summary: task.status, summary: task.status,
occurredAt: occurredAt: task.updated_at,
kind === 'arbiter-request' lastOccurredAt: task.updated_at,
? (task.arbiter_requested_at ?? task.updated_at)
: kind === 'reviewer-request'
? (task.review_requested_at ?? task.updated_at)
: task.updated_at,
lastOccurredAt:
kind === 'arbiter-request'
? (task.arbiter_requested_at ?? task.updated_at)
: kind === 'reviewer-request'
? (task.review_requested_at ?? task.updated_at)
: task.updated_at,
createdAt: args.createdAt, createdAt: args.createdAt,
occurrences: 1, occurrences: 1,
source: 'paired-task', source: 'paired-task',
roomJid: task.chat_jid, roomJid: task.chat_jid,
groupFolder: task.group_folder, groupFolder: task.group_folder,
serviceId: serviceId: task.owner_service_id,
kind === 'reviewer-request'
? task.reviewer_service_id
: task.owner_service_id,
taskId: task.id, taskId: task.id,
taskStatus: task.status, taskStatus: task.status,
}); });
@@ -793,8 +744,6 @@ export function buildWebDashboardOverview(args: {
fetchedAt: usageFetchedAt, fetchedAt: usageFetchedAt,
}, },
inbox: collectInboxItems({ inbox: collectInboxItems({
snapshots: args.snapshots,
tasks: args.tasks,
pairedTasks: args.pairedTasks ?? [], pairedTasks: args.pairedTasks ?? [],
createdAt: generatedAt, createdAt: generatedAt,
}), }),

View File

@@ -142,7 +142,9 @@ describe('web dashboard overview route', () => {
}, },
]; ];
const overview = route('/api/overview', { const overview = route('/api/overview', {
loadPairedTasks: () => [makePairedTask({ id: 'review-1' })], loadPairedTasks: () => [
makePairedTask({ id: 'merge-1', status: 'merge_ready' }),
],
readSnapshots: () => snapshots, readSnapshots: () => snapshots,
isInboxItemDismissed: () => true, isInboxItemDismissed: () => true,
}); });

View File

@@ -4,7 +4,6 @@ import path from 'path';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import type { StatusSnapshot } from './status-dashboard.js';
import type { NewMessage, PairedTask, RegisteredGroup } from './types.js'; import type { NewMessage, PairedTask, RegisteredGroup } from './types.js';
import { createWebDashboardHandler } from './web-dashboard-server.js'; import { createWebDashboardHandler } from './web-dashboard-server.js';
@@ -74,30 +73,17 @@ describe('web dashboard server handler', () => {
}); });
it('shares inbox dismiss state with overview JSON', async () => { it('shares inbox dismiss state with overview JSON', async () => {
const snapshots: StatusSnapshot[] = [ const pairedTask = makePairedTask({
{ chat_jid: 'dc:ops',
serviceId: 'codex-main', group_folder: 'ops',
agentType: 'codex', id: 'merge-1',
assistantName: 'Codex', status: 'merge_ready',
updatedAt: '2026-04-26T05:00:00.000Z', updated_at: '2026-04-26T05:00:00.000Z',
entries: [ });
{
jid: 'dc:ops',
name: '#ops',
folder: 'ops',
agentType: 'codex',
status: 'waiting',
elapsedMs: 2500,
pendingMessages: true,
pendingTasks: 1,
},
],
},
];
const handler = createWebDashboardHandler({ const handler = createWebDashboardHandler({
readStatusSnapshots: () => snapshots, readStatusSnapshots: () => [],
getTasks: () => [], getTasks: () => [],
getPairedTasks: () => [], getPairedTasks: () => [pairedTask],
now: () => '2026-04-26T05:10:00.000Z', now: () => '2026-04-26T05:10:00.000Z',
startBackgroundCacheRefresh: false, startBackgroundCacheRefresh: false,
}); });
@@ -133,10 +119,7 @@ describe('web dashboard server handler', () => {
inbox: [], inbox: [],
}); });
snapshots[0] = { pairedTask.updated_at = '2026-04-26T05:01:00.000Z';
...snapshots[0]!,
updatedAt: '2026-04-26T05:01:00.000Z',
};
const changedOverview = await handler( const changedOverview = await handler(
new Request('http://localhost/api/overview'), new Request('http://localhost/api/overview'),
); );