15 Commits
79 changed files with 8329 additions and 315 deletions

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0b1210" />
<title>Reddit Bot Console</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
import type { ZodType } from 'zod';
import {
botStatsSchema,
controlResponseSchema,
timersSchema,
type BotStats,
type ControlResponse,
type Timers,
} from './contracts.js';
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = 'ApiError';
}
}
interface ErrorPayload {
error?: unknown;
}
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
const payload: unknown = await response.json();
if (!response.ok) {
const errorPayload = payload as ErrorPayload;
const message = typeof errorPayload.error === 'string' ? errorPayload.error : `Request failed (${response.status})`;
throw new ApiError(message, response.status);
}
return schema.parse(payload);
}
export interface DashboardApi {
getStats(): Promise<BotStats>;
getTimers(): Promise<Timers>;
setRunning(isRunning: boolean): Promise<ControlResponse>;
}
export function createDashboardApi(fetchImplementation?: typeof fetch): DashboardApi {
function request(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
return (fetchImplementation ?? globalThis.fetch)(input, init);
}
return {
async getStats() {
return parseResponse(await request('/api/stats'), botStatsSchema);
},
async getTimers() {
return parseResponse(await request('/api/timers'), timersSchema);
},
async setRunning(isRunning) {
const endpoint = isRunning ? '/api/start' : '/api/stop';
return parseResponse(await request(endpoint, { method: 'POST' }), controlResponseSchema);
},
};
}
export const dashboardApi = createDashboardApi();
+11
View File
@@ -0,0 +1,11 @@
export {
ControlResponseSchema as controlResponseSchema,
StatsResponseSchema as botStatsSchema,
TimersResponseSchema as timersSchema,
} from '../../../../packages/api-contracts/src/index.js';
export type {
ControlResponse,
StatsResponse as BotStats,
TimersResponse as Timers,
} from '../../../../packages/api-contracts/src/index.js';
+28
View File
@@ -0,0 +1,28 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { dashboardApi } from './client.js';
import type { BotStats } from './contracts.js';
export const dashboardQueryKeys = {
stats: ['dashboard', 'stats'] as const,
timers: ['dashboard', 'timers'] as const,
};
export function useStatsQuery() {
return useQuery({ queryKey: dashboardQueryKeys.stats, queryFn: dashboardApi.getStats });
}
export function useTimersQuery() {
return useQuery({ queryKey: dashboardQueryKeys.timers, queryFn: dashboardApi.getTimers });
}
export function useSetRunningMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: dashboardApi.setRunning,
onSuccess(response) {
queryClient.setQueryData<BotStats>(dashboardQueryKeys.stats, (current) =>
current ? { ...current, isRunning: response.running } : current,
);
},
});
}
+63
View File
@@ -0,0 +1,63 @@
import { useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { navigationItems } from './navigation.js';
export function AppShell() {
const [isNavigationOpen, setIsNavigationOpen] = useState(false);
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">Skip to content</a>
<aside className={isNavigationOpen ? 'sidebar is-open' : 'sidebar'}>
<div className="brand">
<span className="brand-mark" aria-hidden="true">R</span>
<div>
<strong>Reddit Bot</strong>
<span>Operator console</span>
</div>
</div>
<nav aria-label="Primary navigation" id="primary-navigation">
{navigationItems.map((item) => (
<NavLink
aria-label={item.label}
className={({ isActive }) => (isActive ? 'nav-link is-active' : 'nav-link')}
end={item.path === '/'}
key={item.path}
onClick={() => setIsNavigationOpen(false)}
to={item.path}
>
<span>{item.label}</span>
<small>{item.description}</small>
</NavLink>
))}
</nav>
</aside>
{isNavigationOpen ? (
<button
aria-label="Close navigation backdrop"
className="navigation-backdrop"
onClick={() => setIsNavigationOpen(false)}
type="button"
/>
) : null}
<div className="workspace">
<header className="mobile-header">
<span>Reddit Bot</span>
<button
aria-controls="primary-navigation"
aria-expanded={isNavigationOpen}
aria-label={isNavigationOpen ? 'Close navigation' : 'Open navigation'}
className="icon-button"
onClick={() => setIsNavigationOpen((current) => !current)}
type="button"
>
<span aria-hidden="true">{isNavigationOpen ? '×' : '☰'}</span>
</button>
</header>
<main id="main-content" tabIndex={-1}>
<Outlet />
</main>
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
export interface NavigationItem {
label: string;
path: string;
description: string;
}
export const navigationItems: readonly NavigationItem[] = [
{ label: 'Overview', path: '/', description: 'System health and current activity' },
{ label: 'Conversations', path: '/conversations', description: 'Inbox and message history' },
{ label: 'Media', path: '/media', description: 'Private media and sync controls' },
{ label: 'Performance', path: '/performance', description: 'Response and strategy trends' },
{ label: 'Operations', path: '/operations', description: 'Jobs, events, and diagnostics' },
{ label: 'Settings', path: '/settings', description: 'Runtime and access configuration' },
];
+17
View File
@@ -0,0 +1,17 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { PropsWithChildren } from 'react';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 15_000,
retry: 1,
refetchOnWindowFocus: false,
},
mutations: { retry: false },
},
});
export function AppProviders({ children }: PropsWithChildren) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
+28
View File
@@ -0,0 +1,28 @@
import { createBrowserRouter, type RouteObject } from 'react-router-dom';
import { AppShell } from './app-shell.js';
import { OverviewPage } from '../features/overview/overview-page.js';
import { PlaceholderPage } from '../features/placeholder-page.js';
import { ConversationRoute } from '../features/conversations/conversation-route.js';
import { MediaPage } from '../features/media/media-page.js';
import { OperationsPage } from '../features/operations/operations-page.js';
import { PerformancePage } from '../features/performance/performance-page.js';
export function createDashboardRoutes(): RouteObject[] {
return [
{
path: '/',
element: <AppShell />,
children: [
{ index: true, element: <OverviewPage /> },
{ path: 'conversations', element: <ConversationRoute /> },
{ path: 'conversations/:conversationId', element: <ConversationRoute /> },
{ path: 'media', element: <MediaPage /> },
{ path: 'performance', element: <PerformancePage /> },
{ path: 'operations', element: <OperationsPage /> },
{ path: 'settings', element: <PlaceholderPage title="Settings" description="Manage runtime behavior and Reddit access." /> },
],
},
];
}
export const dashboardRouter = createBrowserRouter(createDashboardRoutes());
@@ -0,0 +1,39 @@
import { useEffect, useRef } from 'react';
interface ConfirmationDialogProps {
action: 'start' | 'stop';
isPending: boolean;
onCancel(): void;
onConfirm(): void;
}
export function ConfirmationDialog({ action, isPending, onCancel, onConfirm }: ConfirmationDialogProps) {
const cancelButtonRef = useRef<HTMLButtonElement>(null);
const isStarting = action === 'start';
useEffect(() => {
cancelButtonRef.current?.focus();
}, []);
return (
<div aria-labelledby="confirmation-title" aria-modal="true" className="dialog-backdrop" role="dialog">
<div className="dialog-card">
<span className="eyebrow">Confirm action</span>
<h2 id="confirmation-title">{isStarting ? 'Start bot?' : 'Stop bot?'}</h2>
<p>
{isStarting
? 'The bot will resume automated processing.'
: 'The bot will pause after its current operation.'}
</p>
<div className="dialog-actions">
<button className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">
Cancel
</button>
<button className={isStarting ? 'button primary' : 'button danger'} disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Applying…' : `Confirm ${action}`}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,85 @@
import type { ZodType } from 'zod';
import {
ConversationsResponseSchema,
ConversationDetailResponseSchema,
ErrorResponseSchema,
SendReplyRequestSchema,
StartConversationRequestSchema,
StartConversationResponseSchema,
SuccessResponseSchema,
SuggestedReplyResponseSchema,
type ActiveChat,
type ConversationDetailResponse,
type SuccessResponse,
} from '../../../../../packages/api-contracts/src/index.js';
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
const payload: unknown = await response.json();
if (!response.ok) {
const parsedError = ErrorResponseSchema.safeParse(payload);
throw new Error(parsedError.success ? parsedError.data.error : `Request failed (${response.status})`);
}
return schema.parse(payload);
}
function conversationPath(id: string): string {
return `/api/conversations/${encodeURIComponent(id)}`;
}
export interface ConversationApi {
list(): Promise<ActiveChat[]>;
detail(id: string): Promise<ConversationDetailResponse>;
suggestReply(id: string): Promise<string>;
sendReply(id: string, message: string): Promise<SuccessResponse>;
startConversation(username: string, message: string): Promise<ActiveChat>;
}
export function createConversationApi(fetchImplementation?: typeof fetch): ConversationApi {
function request(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
return (fetchImplementation ?? globalThis.fetch)(input, init);
}
return {
async list() {
return parseResponse(await request('/api/conversations'), ConversationsResponseSchema);
},
async detail(id) {
return parseResponse(await request(conversationPath(id)), ConversationDetailResponseSchema);
},
async suggestReply(id) {
const response = await parseResponse(
await request(`${conversationPath(id)}/suggest-reply`, { method: 'POST' }),
SuggestedReplyResponseSchema,
);
return response.suggestedReply;
},
async sendReply(id, message) {
const body = SendReplyRequestSchema.parse({ message: message.trim() });
return parseResponse(
await request(`${conversationPath(id)}/send-reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
SuccessResponseSchema,
);
},
async startConversation(username, message) {
const body = StartConversationRequestSchema.parse({
username: username.trim().replace(/^u\//i, ''),
message: message.trim(),
});
const response = await parseResponse(
await request('/api/conversations/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
StartConversationResponseSchema,
);
return response.chat;
},
};
}
export const conversationApi = createConversationApi();
@@ -0,0 +1,162 @@
import { useCallback, useRef, useState } from 'react';
import type { ConversationDetailResponse } from '../../../../../packages/api-contracts/src/index.js';
import { useConversationDetailQuery, useSendReplyMutation, useSuggestReplyMutation } from './conversation-queries.js';
import { SendConfirmationDialog } from './send-confirmation-dialog.js';
interface ConversationDetailProps {
conversationId: string;
onClose(): void;
}
function formatMessageTime(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
}
function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) {
if (!conversation.messages || conversation.messages.length === 0) {
return <div className="conversation-empty-detail">No messages have been loaded for this conversation.</div>;
}
return (
<ol aria-label="Message history" className="conversation-messages">
{conversation.messages.map((message) => (
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={`${message.timestamp}-${message.sender}-${message.text}`}>
<div>
<strong>{message.isSelf ? 'You' : `u/${conversation.username}`}</strong>
<time dateTime={new Date(message.timestamp).toISOString()}>{formatMessageTime(message.timestamp)}</time>
</div>
<p>{message.text}</p>
</li>
))}
</ol>
);
}
function ConversationComposer({ conversation }: { conversation: ConversationDetailResponse }) {
const [draft, setDraft] = useState('');
const [isConfirming, setIsConfirming] = useState(false);
const [wasSent, setWasSent] = useState(false);
const sendButtonRef = useRef<HTMLButtonElement>(null);
const suggestionMutation = useSuggestReplyMutation(conversation.roomId ?? conversation.username);
const sendMutation = useSendReplyMutation(conversation.roomId ?? conversation.username);
const canSend = draft.trim().length > 0 && !sendMutation.isPending;
const closeConfirmation = useCallback(() => {
setIsConfirming(false);
queueMicrotask(() => sendButtonRef.current?.focus());
}, []);
function suggestReply() {
setWasSent(false);
suggestionMutation.mutate(undefined, { onSuccess: (suggestion) => setDraft(suggestion) });
}
function sendReply() {
if (!canSend) return;
sendMutation.mutate(draft, {
onSuccess() {
setDraft('');
setWasSent(true);
},
onSettled: closeConfirmation,
});
}
return (
<section aria-labelledby="reply-composer-heading" className="conversation-composer">
<div className="conversation-section-heading">
<h3 id="reply-composer-heading">Reply</h3>
<button disabled={suggestionMutation.isPending} onClick={suggestReply} type="button">
{suggestionMutation.isPending ? 'Generating…' : 'Suggest reply'}
</button>
</div>
<label className="sr-only" htmlFor="conversation-reply">Reply to u/{conversation.username}</label>
<textarea
id="conversation-reply"
onChange={(event) => {
setDraft(event.target.value);
setWasSent(false);
}}
placeholder="Write or generate a reply"
rows={5}
value={draft}
/>
{suggestionMutation.isError ? <div className="conversation-inline-error" role="alert">{suggestionMutation.error.message}</div> : null}
{sendMutation.isError ? <div className="conversation-inline-error" role="alert">{sendMutation.error.message}</div> : null}
{wasSent ? (
<div aria-label="Reply sent" className="conversation-inline-success" role="status">Reply sent to u/{conversation.username}</div>
) : null}
<div className="conversation-actions">
<span>{draft.length} characters</span>
<button
aria-label="Review and send reply"
className="conversation-primary"
disabled={!canSend}
onClick={() => setIsConfirming(true)}
ref={sendButtonRef}
type="button"
>
Review and send
</button>
</div>
{isConfirming ? (
<SendConfirmationDialog
draft={draft.trim()}
isPending={sendMutation.isPending}
onCancel={closeConfirmation}
onConfirm={sendReply}
/>
) : null}
</section>
);
}
export function ConversationDetail({ conversationId, onClose }: ConversationDetailProps) {
const detailQuery = useConversationDetailQuery(conversationId);
if (detailQuery.isPending) {
return <div aria-label="Loading conversation" className="conversation-detail-state" role="status">Loading conversation…</div>;
}
if (detailQuery.isError) {
return (
<div className="conversation-detail-state conversation-inline-error" role="alert">
<strong>Conversation unavailable</strong>
<span>{detailQuery.error.message}</span>
<button onClick={() => void detailQuery.refetch()} type="button">Retry conversation</button>
</div>
);
}
const conversation = detailQuery.data;
return (
<article className="conversation-detail">
<header>
<div>
<span className="conversation-eyebrow">Conversation</span>
<h2>u/{conversation.username}</h2>
{conversation.unread ? <span className="conversation-badge">Unread</span> : null}
</div>
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
</header>
{conversation.syncInProgress ? (
<div className="conversation-sync-notice" role="status">
A {conversation.syncInProgress.kind ?? 'chat'} sync is in progress. Messages may be stale.
</div>
) : null}
{conversation.triggeringPost ? (
<a
aria-label={conversation.triggeringPost.title ?? `Open triggering post ${conversation.triggeringPost.id}`}
className="triggering-post"
href={conversation.triggeringPost.url}
rel="noreferrer"
target="_blank"
>
<span>Started from {conversation.triggeringPost.subreddit ? `r/${conversation.triggeringPost.subreddit}` : 'Reddit'}</span>
<strong>{conversation.triggeringPost.title ?? conversation.triggeringPost.id}</strong>
</a>
) : null}
<MessageHistory conversation={conversation} />
<ConversationComposer conversation={conversation} />
</article>
);
}
@@ -0,0 +1,47 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { conversationApi } from './conversation-api.js';
export const conversationQueryKeys = {
all: ['conversations'] as const,
detail: (id: string) => ['conversations', 'detail', id] as const,
};
export function useConversationsQuery() {
return useQuery({ queryKey: conversationQueryKeys.all, queryFn: conversationApi.list });
}
export function useConversationDetailQuery(id: string | null) {
return useQuery({
queryKey: conversationQueryKeys.detail(id ?? ''),
queryFn: () => conversationApi.detail(id ?? ''),
enabled: id !== null,
});
}
export function useSuggestReplyMutation(id: string) {
return useMutation({ mutationFn: () => conversationApi.suggestReply(id) });
}
export function useSendReplyMutation(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (message: string) => conversationApi.sendReply(id, message),
async onSuccess() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all }),
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.detail(id) }),
]);
},
});
}
export function useStartConversationMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ username, message }: { username: string; message: string }) =>
conversationApi.startConversation(username, message),
async onSuccess() {
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
},
});
}
@@ -0,0 +1,22 @@
import { useNavigate, useParams } from 'react-router-dom';
import { ConversationsPage } from './conversations-page.js';
export function ConversationRoute() {
const navigate = useNavigate();
const { conversationId } = useParams<{ conversationId: string }>();
function selectConversation(nextConversationId: string | null) {
void navigate(
nextConversationId
? `/conversations/${encodeURIComponent(nextConversationId)}`
: '/conversations',
);
}
return (
<ConversationsPage
selectedConversationId={conversationId ?? null}
onSelectedConversationChange={selectConversation}
/>
);
}
@@ -0,0 +1,151 @@
import { useMemo, useState } from 'react';
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
import { ConversationDetail } from './conversation-detail.js';
import { useConversationsQuery } from './conversation-queries.js';
import { StartConversationForm } from './start-conversation-form.js';
import './conversations.css';
type ConversationFilter = 'all' | 'unread' | 'awaiting-reply';
export interface ConversationsPageProps {
selectedConversationId?: string | null;
onSelectedConversationChange?(conversationId: string | null): void;
}
function conversationId(conversation: ActiveChat): string {
return conversation.roomId ?? conversation.username;
}
function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
if (filter === 'unread') return conversation.unread;
if (filter === 'awaiting-reply') return conversation.isFromUs;
return true;
}
function ConversationListItem({ conversation, isSelected, onSelect }: {
conversation: ActiveChat;
isSelected: boolean;
onSelect(): void;
}) {
const state = conversation.unread ? 'Unread' : conversation.isFromUs ? 'Awaiting reply' : 'Up to date';
return (
<li>
<button
aria-label={`u/${conversation.username}, ${state}`}
aria-pressed={isSelected}
className={isSelected ? 'conversation-list-item is-selected' : 'conversation-list-item'}
onClick={onSelect}
type="button"
>
<span className="conversation-list-heading"><strong>u/{conversation.username}</strong><small>{state}</small></span>
<span>{conversation.lastMessageSnippet || 'No message preview'}</span>
</button>
</li>
);
}
export function ConversationsPage({ selectedConversationId, onSelectedConversationChange }: ConversationsPageProps) {
const conversationsQuery = useConversationsQuery();
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<ConversationFilter>('all');
const [isStartingConversation, setIsStartingConversation] = useState(false);
const activeConversationId = selectedConversationId !== undefined ? selectedConversationId : internalSelectedId;
const visibleConversations = useMemo(() => {
const normalizedSearch = search.trim().toLowerCase();
return (conversationsQuery.data ?? []).filter((conversation) => {
const matchesSearch = normalizedSearch.length === 0
|| conversation.username.toLowerCase().includes(normalizedSearch)
|| conversation.lastMessageSnippet.toLowerCase().includes(normalizedSearch);
return matchesSearch && matchesFilter(conversation, filter);
});
}, [conversationsQuery.data, filter, search]);
function selectConversation(id: string | null) {
if (selectedConversationId === undefined) setInternalSelectedId(id);
onSelectedConversationChange?.(id);
}
return (
<div className="conversations-page">
<header className="conversations-header">
<div>
<span className="conversation-eyebrow">Inbox</span>
<h1>Conversations</h1>
<p>Review replies, draft carefully, and send only after confirmation.</p>
</div>
<button className="conversation-primary" onClick={() => setIsStartingConversation((current) => !current)} type="button">
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
</button>
</header>
{isStartingConversation ? (
<section aria-labelledby="new-conversation-heading" className="new-conversation-panel">
<h2 id="new-conversation-heading">Start a conversation</h2>
<StartConversationForm onStarted={(id) => selectConversation(id)} />
</section>
) : null}
<div className="conversation-workspace">
<section aria-label="Conversation inbox" className="conversation-master">
<div className="conversation-toolbar">
<label className="sr-only" htmlFor="conversation-search">Search conversations</label>
<input
id="conversation-search"
onChange={(event) => setSearch(event.target.value)}
placeholder="Search conversations"
type="search"
value={search}
/>
<label className="sr-only" htmlFor="conversation-filter">Filter conversations</label>
<select
id="conversation-filter"
onChange={(event) => setFilter(event.target.value as ConversationFilter)}
value={filter}
>
<option value="all">All</option>
<option value="unread">Unread</option>
<option value="awaiting-reply">Awaiting reply</option>
</select>
</div>
{conversationsQuery.isPending ? (
<div aria-label="Loading conversations" className="conversation-list-state" role="status">Loading conversations…</div>
) : conversationsQuery.isError ? (
<div className="conversation-list-state conversation-inline-error" role="alert">
<strong>Could not load conversations</strong>
<span>{conversationsQuery.error.message}</span>
<button onClick={() => void conversationsQuery.refetch()} type="button">Retry conversations</button>
</div>
) : conversationsQuery.data.length === 0 ? (
<div className="conversation-list-state"><strong>No conversations yet.</strong><span>Start one when you are ready.</span></div>
) : visibleConversations.length === 0 ? (
<div className="conversation-list-state"><strong>No matching conversations.</strong><span>Try a different search or filter.</span></div>
) : (
<ul className="conversation-list">
{visibleConversations.map((conversation) => (
<ConversationListItem
conversation={conversation}
isSelected={conversationId(conversation) === activeConversationId}
key={conversationId(conversation)}
onSelect={() => selectConversation(conversationId(conversation))}
/>
))}
</ul>
)}
</section>
<section aria-label="Selected conversation" className="conversation-detail-panel">
{activeConversationId ? (
<ConversationDetail conversationId={activeConversationId} key={activeConversationId} onClose={() => selectConversation(null)} />
) : (
<div className="conversation-detail-state">
<strong>Select a conversation</strong>
<span>Message history and reply tools will appear here.</span>
</div>
)}
</section>
</div>
</div>
);
}
@@ -0,0 +1,77 @@
.conversations-page {
width: min(86rem, 100%);
margin: 0 auto;
padding: 1.5rem 1rem 4rem;
}
.conversations-header {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.conversations-header h1 { margin-bottom: .4rem; }
.conversations-header p { margin-bottom: 0; color: var(--muted); }
.conversation-eyebrow { display: block; margin-bottom: .4rem; color: var(--accent); font-size: .72rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
.conversation-primary { border-color: transparent !important; color: #06140b !important; background: var(--accent) !important; font-weight: 750; }
.conversations-page button, .conversations-page input, .conversations-page select, .conversations-page textarea { font: inherit; }
.conversations-page button { min-height: 2.5rem; padding: .5rem .8rem; border: 1px solid var(--border); border-radius: .6rem; color: #e9f1ec; background: var(--surface-raised); }
.conversations-page button:disabled { cursor: wait; opacity: .58; }
.new-conversation-panel { padding: 1rem; margin-bottom: 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
.new-conversation-panel h2 { margin-bottom: 1rem; }
.start-conversation-form { display: grid; gap: .8rem; }
.conversation-field { display: grid; gap: .35rem; }
.conversation-field label { color: var(--muted); font-size: .8rem; font-weight: 700; }
.conversation-field input, .conversation-field textarea, .conversation-toolbar input, .conversation-toolbar select, .conversation-composer textarea {
width: 100%; padding: .7rem .75rem; color: #e9f1ec; background: #0b1410; border: 1px solid var(--border); border-radius: .55rem;
}
.conversation-field input:focus, .conversation-field textarea:focus, .conversation-toolbar input:focus, .conversation-toolbar select:focus, .conversation-composer textarea:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
.conversation-workspace { display: grid; gap: .75rem; }
.conversation-master, .conversation-detail-panel { min-width: 0; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; overflow: hidden; }
.conversation-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem; padding: .75rem; border-bottom: 1px solid var(--border); }
.conversation-list { display: grid; gap: .35rem; max-height: 46rem; padding: .5rem; margin: 0; overflow: auto; list-style: none; }
.conversation-list-item { display: grid; gap: .4rem; width: 100%; padding: .8rem !important; text-align: left; background: transparent !important; border-color: transparent !important; }
.conversation-list-item:hover { background: var(--surface-raised) !important; }
.conversation-list-item.is-selected { background: var(--accent-dark) !important; border-color: #28593d !important; }
.conversation-list-item > span:last-child { overflow: hidden; color: var(--muted); font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
.conversation-list-heading { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
.conversation-list-heading small { color: var(--accent); }
.conversation-list-state, .conversation-detail-state { display: grid; place-items: center; align-content: center; gap: .5rem; min-height: 16rem; padding: 1.5rem; color: var(--muted); text-align: center; }
.conversation-list-state strong, .conversation-detail-state strong { color: #e9f1ec; }
.conversation-detail { min-height: 32rem; }
.conversation-detail > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: 1rem; border-bottom: 1px solid var(--border); }
.conversation-detail h2 { display: inline; margin-right: .6rem; }
.conversation-badge { display: inline-flex; padding: .2rem .45rem; color: #aaf0c4; background: #163a26; border-radius: 999px; font-size: .7rem; font-weight: 800; }
.conversation-sync-notice { padding: .75rem 1rem; color: #ffe1a0; background: #2a2111; border-bottom: 1px solid #59451e; }
.triggering-post { display: grid; gap: .2rem; margin: 1rem; padding: .75rem; color: inherit; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; text-decoration: none; }
.triggering-post span { color: var(--muted); font-size: .75rem; }
.conversation-messages { display: grid; gap: .75rem; max-height: 30rem; padding: 1rem; margin: 0; overflow: auto; list-style: none; }
.conversation-messages li { max-width: 85%; padding: .7rem .8rem; border-radius: .8rem; }
.conversation-messages li.is-self { justify-self: end; background: var(--accent-dark); }
.conversation-messages li.is-recipient { justify-self: start; background: var(--surface-raised); }
.conversation-messages li > div { display: flex; justify-content: space-between; gap: 1rem; margin-bottom: .35rem; }
.conversation-messages time { color: var(--muted); font-size: .7rem; }
.conversation-messages p { margin: 0; line-height: 1.45; white-space: pre-wrap; }
.conversation-empty-detail { padding: 2rem; color: var(--muted); text-align: center; }
.conversation-composer { display: grid; gap: .65rem; padding: 1rem; border-top: 1px solid var(--border); }
.conversation-section-heading, .conversation-actions { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.conversation-section-heading h3 { margin: 0; }
.conversation-actions > span { color: var(--muted); font-size: .75rem; }
.conversation-inline-error, .conversation-inline-success { padding: .65rem .75rem; border-radius: .55rem; }
.conversation-inline-error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
.conversation-inline-success { color: #b9f3ce; background: #163a26; border: 1px solid #28593d; }
.conversation-dialog-backdrop { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; padding: 1rem; background: rgba(0, 0, 0, .74); }
.conversation-dialog { width: min(30rem, 100%); padding: 1.25rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .9rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .45); }
.conversation-dialog p { color: var(--muted); }
.conversation-dialog blockquote { max-height: 12rem; padding: .75rem; margin: 1rem 0; overflow: auto; background: #0b1410; border-left: 3px solid var(--accent); white-space: pre-wrap; }
.conversation-dialog .conversation-actions { justify-content: flex-end; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (min-width: 52rem) {
.conversations-page { padding: 3rem 2rem 5rem; }
.start-conversation-form { grid-template-columns: minmax(12rem, .5fr) minmax(18rem, 1fr) auto; align-items: end; }
.conversation-workspace { grid-template-columns: minmax(17rem, .75fr) minmax(28rem, 1.5fr); }
}
@@ -0,0 +1,38 @@
import { useEffect, useRef } from 'react';
interface SendConfirmationDialogProps {
draft: string;
isPending: boolean;
onCancel(): void;
onConfirm(): void;
}
export function SendConfirmationDialog({ draft, isPending, onCancel, onConfirm }: SendConfirmationDialogProps) {
const cancelButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
cancelButtonRef.current?.focus();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape' && !isPending) onCancel();
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isPending, onCancel]);
return (
<div aria-labelledby="send-confirmation-title" aria-modal="true" className="conversation-dialog-backdrop" role="dialog">
<div className="conversation-dialog">
<span className="conversation-eyebrow">Confirm message</span>
<h2 id="send-confirmation-title">Send this reply?</h2>
<p>This sends immediately through Reddit chat.</p>
<blockquote>{draft}</blockquote>
<div className="conversation-actions">
<button disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">Cancel</button>
<button className="conversation-primary" disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Sending…' : 'Confirm send'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,64 @@
import { useState } from 'react';
import { useStartConversationMutation } from './conversation-queries.js';
interface StartConversationFormProps {
onStarted(conversationId: string): void;
}
export function StartConversationForm({ onStarted }: StartConversationFormProps) {
const [username, setUsername] = useState('');
const [message, setMessage] = useState('');
const [successUsername, setSuccessUsername] = useState<string | null>(null);
const mutation = useStartConversationMutation();
const canSubmit = username.trim().length > 0 && message.trim().length > 0 && !mutation.isPending;
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!canSubmit) return;
mutation.mutate(
{ username, message },
{
onSuccess(chat) {
setSuccessUsername(chat.username);
setUsername('');
setMessage('');
onStarted(chat.roomId ?? chat.username);
},
},
);
}
return (
<form className="start-conversation-form" onSubmit={submit}>
<div className="conversation-field">
<label htmlFor="new-conversation-username">Reddit username</label>
<input
autoComplete="off"
id="new-conversation-username"
onChange={(event) => setUsername(event.target.value)}
placeholder="username"
value={username}
/>
</div>
<div className="conversation-field">
<label htmlFor="new-conversation-message">Opening message</label>
<textarea
id="new-conversation-message"
onChange={(event) => setMessage(event.target.value)}
placeholder="Write a personal opening message"
rows={4}
value={message}
/>
</div>
{mutation.isError ? <div className="conversation-inline-error" role="alert">{mutation.error.message}</div> : null}
{successUsername ? (
<div aria-label="Conversation started" className="conversation-inline-success" role="status">
Conversation started with u/{successUsername}
</div>
) : null}
<button className="conversation-primary" disabled={!canSubmit} type="submit">
{mutation.isPending ? 'Starting…' : 'Start conversation'}
</button>
</form>
);
}
+43
View File
@@ -0,0 +1,43 @@
import type { ZodType } from 'zod';
import {
ErrorResponseSchema,
MediaResponseSchema,
MediaSyncStatusSchema,
MediaSyncTriggerResponseSchema,
type MediaItem,
type MediaSyncStatus,
} from '../../../../../packages/api-contracts/src/index.js';
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
const payload: unknown = await response.json();
if (!response.ok) {
const parsedError = ErrorResponseSchema.safeParse(payload);
throw new Error(parsedError.success ? parsedError.data.error : `Request failed (${response.status})`);
}
return schema.parse(payload);
}
function request(path: string, init?: RequestInit): Promise<Response> {
return globalThis.fetch(path, { credentials: 'same-origin', ...init });
}
export async function fetchMedia(): Promise<MediaItem[]> {
return parseResponse(await request('/api/media'), MediaResponseSchema);
}
export async function fetchMediaSyncStatus(): Promise<MediaSyncStatus> {
return parseResponse(await request('/api/media/sync'), MediaSyncStatusSchema);
}
export async function triggerMediaSync(isFull: boolean): Promise<void> {
const path = isFull ? '/api/media/sync/full' : '/api/media/sync';
const response = await parseResponse(
await request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: isFull ? undefined : JSON.stringify({ full: false }),
}),
MediaSyncTriggerResponseSchema,
);
if (!response.success) throw new Error(response.reason);
}
@@ -0,0 +1,33 @@
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
export interface MediaFilters {
conversation: string;
sender: string;
type: 'all' | 'image' | 'gif';
dateRange: 'all' | '24h' | '7d' | '30d';
}
export function mediaConversation(item: MediaItem): string {
return item.chatUsername ?? item.chatRoomId ?? 'unknown';
}
export function mediaType(item: MediaItem): 'image' | 'gif' {
return item.fileName.toLowerCase().endsWith('.gif') ? 'gif' : 'image';
}
export function filterMedia(items: MediaItem[], filters: MediaFilters, now: number): MediaItem[] {
const rangeMs = {
all: null,
'24h': 86_400_000,
'7d': 7 * 86_400_000,
'30d': 30 * 86_400_000,
}[filters.dateRange];
return items.filter((item) => {
if (filters.conversation !== 'all' && mediaConversation(item) !== filters.conversation) return false;
if (filters.sender !== 'all' && item.sender !== filters.sender) return false;
if (filters.type !== 'all' && mediaType(item) !== filters.type) return false;
if (rangeMs !== null && item.timestamp < now - rangeMs) return false;
return true;
});
}
@@ -0,0 +1,33 @@
import { useEffect, useRef } from 'react';
interface FullSyncDialogProps {
isPending: boolean;
onCancel(): void;
onConfirm(): void;
}
export function FullSyncDialog({ isPending, onCancel, onConfirm }: FullSyncDialogProps) {
const cancelButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
cancelButtonRef.current?.focus();
}, []);
return (
<div aria-labelledby="full-sync-title" aria-modal="true" className="dialog-backdrop" role="dialog">
<div className="dialog-card">
<span className="eyebrow">Confirm expensive action</span>
<h2 id="full-sync-title">Run full media sync?</h2>
<p>This scans every conversation and can take several minutes. Routine refreshes should use Sync new media.</p>
<div className="dialog-actions">
<button className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">
Cancel
</button>
<button className="button danger" disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Syncing…' : 'Run full sync'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,45 @@
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
import { mediaConversation, mediaType } from './filters.js';
interface MediaCardProps {
item: MediaItem;
isRevealed: boolean;
onToggleReveal(): void;
}
function formatMediaDate(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
}
export function MediaCard({ item, isRevealed, onToggleReveal }: MediaCardProps) {
return (
<article className="media-card">
<div className="media-preview">
<img
alt={`Media from ${item.sender}`}
className={isRevealed ? 'media-image' : 'media-image is-blurred'}
loading="lazy"
src={item.urlPath}
/>
{!isRevealed ? <span className="privacy-label">Private media hidden</span> : null}
</div>
<div className="media-card-body">
<div>
<strong>{item.sender}</strong>
<span>{mediaConversation(item)}</span>
</div>
<dl>
<div><dt>Type</dt><dd>{mediaType(item) === 'gif' ? 'GIF' : 'Image'}</dd></div>
<div><dt>Received</dt><dd>{formatMediaDate(item.timestamp)}</dd></div>
</dl>
<button
className="button secondary media-reveal"
onClick={onToggleReveal}
type="button"
>
{isRevealed ? `Hide media from ${item.sender}` : `Reveal media from ${item.sender}`}
</button>
</div>
</article>
);
}
@@ -0,0 +1,208 @@
import { useMemo, useState } from 'react';
import { filterMedia, mediaConversation, type MediaFilters } from './filters.js';
import { FullSyncDialog } from './full-sync-dialog.js';
import { MediaCard } from './media-card.js';
import { useMediaQuery, useMediaSyncStatusQuery, useTriggerMediaSyncMutation } from './queries.js';
import './media.css';
const initialFilters: MediaFilters = {
conversation: 'all',
sender: 'all',
type: 'all',
dateRange: 'all',
};
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
function formatLastSync(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
}
export function MediaPage() {
const mediaQuery = useMediaQuery();
const statusQuery = useMediaSyncStatusQuery();
const syncMutation = useTriggerMediaSyncMutation();
const [filters, setFilters] = useState<MediaFilters>(initialFilters);
const [revealedIds, setRevealedIds] = useState<Set<string>>(() => new Set());
const [isRevealAll, setIsRevealAll] = useState(false);
const [isFullSyncDialogOpen, setIsFullSyncDialogOpen] = useState(false);
const items = mediaQuery.data ?? [];
const visibleItems = useMemo(() => filterMedia(items, filters, Date.now()), [items, filters]);
const conversations = useMemo(
() => [...new Set(items.map(mediaConversation))].sort((left, right) => left.localeCompare(right)),
[items],
);
const senders = useMemo(
() => [...new Set(items.map((item) => item.sender))].sort((left, right) => left.localeCompare(right)),
[items],
);
const isSyncBusy = statusQuery.data?.isSyncing === true || syncMutation.isPending;
function updateFilter<Key extends keyof MediaFilters>(key: Key, value: MediaFilters[Key]) {
setFilters((current) => ({ ...current, [key]: value }));
}
function toggleItem(itemId: string) {
if (isRevealAll) {
setIsRevealAll(false);
setRevealedIds(new Set(items.filter((item) => item.id !== itemId).map((item) => item.id)));
return;
}
setRevealedIds((current) => {
const next = new Set(current);
if (next.has(itemId)) next.delete(itemId);
else next.add(itemId);
return next;
});
}
function runSync(isFull: boolean) {
syncMutation.mutate(isFull, {
onSuccess: () => {
if (isFull) setIsFullSyncDialogOpen(false);
},
onError: () => {
if (isFull) setIsFullSyncDialogOpen(false);
},
});
}
if (mediaQuery.isPending) {
return <div aria-label="Loading media" className="page-state" role="status"><span className="spinner" />Loading media…</div>;
}
if (mediaQuery.isError) {
return (
<div className="page-state error-state" role="alert">
<strong>Media unavailable</strong>
<span>{errorMessage(mediaQuery.error)}</span>
<button className="button secondary" onClick={() => void mediaQuery.refetch()} type="button">Retry media</button>
</div>
);
}
return (
<div className="page media-page">
<header className="page-header">
<div>
<span className="eyebrow">Private gallery</span>
<h1>Media</h1>
<p>Browse authenticated conversation media. Everything stays blurred until you choose to reveal it.</p>
</div>
<div className="header-actions">
{items.length > 0 ? (
<button
className="button secondary"
onClick={() => {
setIsRevealAll((current) => !current);
setRevealedIds(new Set());
}}
type="button"
>
{isRevealAll ? 'Blur all media' : 'Reveal all media'}
</button>
) : null}
<button
className="button secondary"
disabled={isSyncBusy}
onClick={() => {
syncMutation.reset();
setIsFullSyncDialogOpen(true);
}}
type="button"
>
Full sync
</button>
<button className="button primary" disabled={isSyncBusy} onClick={() => runSync(false)} type="button">
{isSyncBusy ? 'Syncing…' : 'Sync new media'}
</button>
</div>
</header>
<section aria-label="Media sync status" className="status-strip media-sync-status">
{statusQuery.isPending ? <span role="status">Loading sync status…</span> : null}
{statusQuery.isError ? (
<div role="alert">
<span>{errorMessage(statusQuery.error)}</span>
<button className="button secondary" onClick={() => void statusQuery.refetch()} type="button">Retry sync status</button>
</div>
) : null}
{statusQuery.data ? (
statusQuery.data.isSyncing ? (
<div role="status"><span className="spinner" /><strong>Media sync in progress</strong></div>
) : (
<div>
<span className="status-dot running" aria-hidden="true" />
<div>
<small>Sync status</small>
<strong>{statusQuery.data.lastSyncChats === null ? 'No completed sync yet' : `Last synced ${statusQuery.data.lastSyncChats} conversations`}</strong>
</div>
</div>
)
) : null}
{statusQuery.data?.lastSyncAt ? <span className="status-note">{formatLastSync(statusQuery.data.lastSyncAt)}</span> : null}
</section>
{statusQuery.data?.lastError ? <div className="cooldown-banner" role="alert"><strong>Last sync failed</strong><span>{statusQuery.data.lastError}</span></div> : null}
{syncMutation.isSuccess ? <div className="media-notice success" role="status">{syncMutation.variables ? 'Full media sync completed.' : 'Media sync completed.'}</div> : null}
{syncMutation.isError ? <div className="media-notice error" role="alert">{errorMessage(syncMutation.error)}</div> : null}
{items.length === 0 ? (
<section className="panel empty-state"><strong>No media yet.</strong><span>Use Sync new media to check recent conversations.</span></section>
) : (
<>
<section aria-label="Media filters" className="panel media-filters">
<label>Conversation
<select value={filters.conversation} onChange={(event) => updateFilter('conversation', event.target.value)}>
<option value="all">All conversations</option>
{conversations.map((conversation) => <option key={conversation} value={conversation}>{conversation}</option>)}
</select>
</label>
<label>Sender
<select value={filters.sender} onChange={(event) => updateFilter('sender', event.target.value)}>
<option value="all">All senders</option>
{senders.map((sender) => <option key={sender} value={sender}>{sender}</option>)}
</select>
</label>
<label>Media type
<select value={filters.type} onChange={(event) => updateFilter('type', event.target.value as MediaFilters['type'])}>
<option value="all">All types</option><option value="image">Images</option><option value="gif">GIFs</option>
</select>
</label>
<label>Date range
<select value={filters.dateRange} onChange={(event) => updateFilter('dateRange', event.target.value as MediaFilters['dateRange'])}>
<option value="all">Any time</option><option value="24h">Past 24 hours</option><option value="7d">Past 7 days</option><option value="30d">Past 30 days</option>
</select>
</label>
</section>
{visibleItems.length === 0 ? (
<section className="panel empty-state"><strong>No media matches these filters.</strong><span>Try broadening one or more filters.</span></section>
) : (
<section aria-label="Media gallery" className="media-grid">
{visibleItems.map((item) => (
<MediaCard
isRevealed={isRevealAll || revealedIds.has(item.id)}
item={item}
key={item.id}
onToggleReveal={() => toggleItem(item.id)}
/>
))}
</section>
)}
</>
)}
{isFullSyncDialogOpen ? (
<FullSyncDialog
isPending={syncMutation.isPending}
onCancel={() => setIsFullSyncDialogOpen(false)}
onConfirm={() => runSync(true)}
/>
) : null}
</div>
);
}
@@ -0,0 +1,35 @@
.media-page { max-width: 86rem; }
.media-sync-status { min-height: 4.4rem; }
.media-sync-status > div[role="alert"] { width: 100%; justify-content: space-between; }
.media-sync-status .spinner { width: 1rem; height: 1rem; }
.media-notice { padding: .8rem 1rem; margin-bottom: 1rem; border-radius: .75rem; font-weight: 700; }
.media-notice.success { color: #aaf0c4; background: #163a26; border: 1px solid #28593d; }
.media-notice.error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
.media-filters { display: grid; grid-template-columns: 1fr; gap: .8rem; margin-bottom: 1rem; }
.media-filters label { display: grid; gap: .35rem; color: var(--muted); font-size: .78rem; font-weight: 750; }
.media-filters select { width: 100%; min-height: 2.6rem; padding: .5rem .7rem; color: #e9f1ec; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .6rem; }
.media-filters select:focus-visible, .media-reveal:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.media-grid { display: grid; grid-template-columns: 1fr; gap: 1rem; }
.media-card { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
.media-preview { position: relative; display: grid; place-items: center; aspect-ratio: 4 / 3; overflow: hidden; background: #050907; }
.media-image { width: 100%; height: 100%; object-fit: cover; transition: filter 160ms ease, transform 160ms ease; }
.media-image.is-blurred { filter: blur(24px) brightness(.55); transform: scale(1.12); }
.privacy-label { position: absolute; padding: .45rem .65rem; color: white; background: rgba(0, 0, 0, .72); border-radius: 999px; font-size: .75rem; font-weight: 800; }
.media-card-body { display: grid; gap: .8rem; padding: .9rem; }
.media-card-body > div:first-child { display: flex; justify-content: space-between; gap: .75rem; }
.media-card-body > div:first-child span { color: var(--muted); }
.media-card-body dl { display: grid; gap: .4rem; margin: 0; }
.media-card-body dl div { display: flex; justify-content: space-between; gap: .75rem; }
.media-card-body dt { color: var(--muted); }
.media-card-body dd { margin: 0; text-align: right; }
.media-reveal { width: 100%; }
@media (min-width: 40rem) {
.media-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.media-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 72rem) {
.media-filters { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.media-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@@ -0,0 +1,41 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { fetchMedia, fetchMediaSyncStatus, triggerMediaSync } from './api.js';
export const mediaQueryKeys = {
items: ['dashboard', 'media'] as const,
syncStatus: ['dashboard', 'media', 'sync-status'] as const,
};
export function useMediaQuery() {
return useQuery({ queryKey: mediaQueryKeys.items, queryFn: fetchMedia });
}
export function useMediaSyncStatusQuery() {
return useQuery({
queryKey: mediaQueryKeys.syncStatus,
queryFn: fetchMediaSyncStatus,
refetchInterval(query) {
return query.state.data?.isSyncing ? 1_000 : 15_000;
},
});
}
export function useTriggerMediaSyncMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn(isFull: boolean) {
const syncRequest = triggerMediaSync(isFull);
void queryClient.invalidateQueries({ queryKey: mediaQueryKeys.syncStatus });
return syncRequest;
},
async onSuccess() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: mediaQueryKeys.items }),
queryClient.invalidateQueries({ queryKey: mediaQueryKeys.syncStatus }),
]);
},
onError() {
void queryClient.invalidateQueries({ queryKey: mediaQueryKeys.syncStatus });
},
});
}
@@ -0,0 +1,115 @@
export type ConsoleLevel = 'debug' | 'info' | 'warn' | 'error';
export type ConsoleLevelFilter = 'all' | ConsoleLevel;
export interface ConsoleEntry {
id: string;
occurredAt: string;
level: ConsoleLevel;
message: string;
data?: unknown;
}
export interface LiveConsoleState {
capacity: number;
entries: readonly ConsoleEntry[];
pendingEntries: readonly ConsoleEntry[];
isPaused: boolean;
isAutoScrollEnabled: boolean;
level: ConsoleLevelFilter;
search: string;
}
export type LiveConsoleAction =
| { type: 'append'; entry: ConsoleEntry }
| { type: 'set-paused'; paused: boolean }
| { type: 'set-auto-scroll'; enabled: boolean }
| { type: 'set-level'; level: ConsoleLevelFilter }
| { type: 'set-search'; search: string };
function boundedAppend(
entries: readonly ConsoleEntry[],
entry: ConsoleEntry,
capacity: number,
): readonly ConsoleEntry[] {
if (entries.some((candidate) => isSameEntry(candidate, entry))) return entries;
return [...entries, entry].slice(-capacity);
}
function isSameEntry(left: ConsoleEntry, right: ConsoleEntry): boolean {
return left.id === right.id || (
left.occurredAt === right.occurredAt &&
left.level === right.level &&
left.message === right.message &&
searchableData(left.data) === searchableData(right.data)
);
}
export function createLiveConsoleState(capacity = 500): LiveConsoleState {
if (!Number.isInteger(capacity) || capacity <= 0) {
throw new RangeError('capacity must be a positive integer');
}
return {
capacity,
entries: [],
pendingEntries: [],
isPaused: false,
isAutoScrollEnabled: true,
level: 'all',
search: '',
};
}
export function reduceLiveConsoleState(
state: LiveConsoleState,
action: LiveConsoleAction,
): LiveConsoleState {
switch (action.type) {
case 'append': {
if (
state.entries.some((entry) => isSameEntry(entry, action.entry)) ||
state.pendingEntries.some((entry) => isSameEntry(entry, action.entry))
) return state;
return state.isPaused
? { ...state, pendingEntries: boundedAppend(state.pendingEntries, action.entry, state.capacity) }
: { ...state, entries: boundedAppend(state.entries, action.entry, state.capacity) };
}
case 'set-paused':
if (action.paused === state.isPaused) return state;
return action.paused
? { ...state, isPaused: true }
: {
...state,
isPaused: false,
entries: [...state.entries, ...state.pendingEntries].slice(-state.capacity),
pendingEntries: [],
};
case 'set-auto-scroll':
return { ...state, isAutoScrollEnabled: action.enabled };
case 'set-level':
return { ...state, level: action.level };
case 'set-search':
return { ...state, search: action.search };
}
}
function searchableData(data: unknown): string {
if (data === undefined) return '';
try {
return JSON.stringify(data);
} catch {
return '';
}
}
export function selectVisibleConsoleEntries(
state: LiveConsoleState,
): readonly ConsoleEntry[] {
const search = state.search.trim().toLocaleLowerCase();
return state.entries.filter((entry) => {
if (state.level !== 'all' && entry.level !== state.level) return false;
if (search.length === 0) return true;
return `${entry.message} ${searchableData(entry.data)}`
.toLocaleLowerCase()
.includes(search);
});
}
@@ -0,0 +1,54 @@
import { z } from 'zod';
import {
ActivityResponseSchema,
ChatSyncStatusSchema,
} from '../../../../../packages/api-contracts/src/index.js';
export const RawLogEntrySchema = z.strictObject({
timestamp: z.number(),
level: z.union([z.literal('debug'), z.literal('info'), z.literal('warn'), z.literal('error')]),
message: z.string(),
data: z.unknown().optional(),
});
export const RawLogsResponseSchema = z.array(RawLogEntrySchema);
export type RawLogEntry = z.infer<typeof RawLogEntrySchema>;
async function parseResponse<T>(
response: Response,
schema: { parse(value: unknown): T },
): Promise<T> {
const payload: unknown = await response.json();
if (!response.ok) {
const message =
typeof payload === 'object' &&
payload !== null &&
'error' in payload &&
typeof payload.error === 'string'
? payload.error
: `Request failed (${response.status})`;
throw new Error(message);
}
return schema.parse(payload);
}
export function createOperationsApi(fetchImplementation?: typeof fetch) {
function request(path: string): Promise<Response> {
return (fetchImplementation ?? globalThis.fetch)(path);
}
return {
async getActivity() {
return parseResponse(await request('/api/activity'), ActivityResponseSchema);
},
async getLogs(limit = 200) {
return parseResponse(
await request(`/api/logs?limit=${limit}`),
RawLogsResponseSchema,
);
},
async getSyncStatus() {
return parseResponse(await request('/api/chats/sync'), ChatSyncStatusSchema);
},
};
}
export const operationsApi = createOperationsApi();
@@ -0,0 +1,179 @@
import { useEffect, useReducer, useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import type { ActivityItem, ChatSyncStatus, DashboardEvent } from '../../../../../packages/api-contracts/src/index.js';
import {
createLiveConsoleState,
reduceLiveConsoleState,
selectVisibleConsoleEntries,
type ConsoleEntry,
type ConsoleLevelFilter,
} from './live-console-model.js';
import { useActivityQuery, useLogsQuery, useSyncStatusQuery } from './operations-queries.js';
import {
createBrowserEventSource,
createDashboardRealtimeClient,
queryKeysForDashboardEvent,
type RealtimeConnectionSnapshot,
} from './realtime-client.js';
import './operations.css';
function formatTimestamp(timestamp: number | string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'medium',
}).format(typeof timestamp === 'number' ? timestamp : new Date(timestamp));
}
function ActivityView({ items }: { items: readonly ActivityItem[] }) {
return items.length === 0 ? (
<p className="feature-empty">No recent operator activity.</p>
) : (
<ol className="operations-activity-list">
{items.map((item) => (
<li key={`${item.timestamp}-${item.message}`}>
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
<div><strong>{item.message}</strong><small>{formatTimestamp(item.timestamp)}{item.recipient ? ` · u/${item.recipient}` : ''}</small></div>
</li>
))}
</ol>
);
}
function SyncSummary({ status }: { status: ChatSyncStatus }) {
const progress = status.progress;
return (
<div className="sync-summary">
<div><span className={`pill ${status.syncing ? 'warning' : 'success'}`}>{status.syncing ? `${status.syncingKind ?? 'Unknown'} sync running` : 'Idle'}</span><strong>{status.cachedChats} cached conversations</strong></div>
{progress ? (
<div>
<div className="sync-progress-label"><span>{progress.label ?? progress.phase}</span><strong>{progress.current} of {progress.total}</strong></div>
<progress aria-label="Sync progress" max={Math.max(1, progress.total)} value={progress.current} />
</div>
) : null}
<dl className="sync-history">
<div><dt>Last delta sync</dt><dd>{status.lastDeltaSyncAt ? formatTimestamp(status.lastDeltaSyncAt) : 'Never'}</dd></div>
<div><dt>Last full sync</dt><dd>{status.lastFullSyncAt ? formatTimestamp(status.lastFullSyncAt) : 'Never'}</dd></div>
</dl>
</div>
);
}
function connectionLabel(snapshot: RealtimeConnectionSnapshot): string {
switch (snapshot.status) {
case 'connected': return 'Realtime connected';
case 'connecting': return 'Connecting to realtime updates';
case 'reconnecting': return 'Realtime reconnecting';
case 'closed': return 'Realtime closed';
case 'idle': return 'Realtime unavailable';
}
}
export interface OperationsPageProps {
realtimeEnabled?: boolean;
}
export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps) {
const queryClient = useQueryClient();
const activityQuery = useActivityQuery();
const logsQuery = useLogsQuery();
const syncQuery = useSyncStatusQuery();
const [consoleState, dispatchConsole] = useReducer(
reduceLiveConsoleState,
createLiveConsoleState(500),
);
const [connection, setConnection] = useState<RealtimeConnectionSnapshot>({
status: 'idle',
isStale: true,
});
const consoleEnd = useRef<HTMLDivElement>(null);
useEffect(() => {
logsQuery.data?.forEach((entry, index) => {
dispatchConsole({
type: 'append',
entry: {
id: `initial-${entry.timestamp}-${index}`,
occurredAt: new Date(entry.timestamp).toISOString(),
level: entry.level,
message: entry.message,
data: entry.data,
},
});
});
}, [logsQuery.data]);
useEffect(() => {
if (!realtimeEnabled || typeof EventSource === 'undefined') {
setConnection({ status: 'idle', isStale: true });
return;
}
function onEvent(event: DashboardEvent): void {
if (event.type === 'log.created') {
const entry: ConsoleEntry = {
id: event.id,
occurredAt: event.occurredAt,
level: event.payload.level,
message: event.payload.message,
data: event.payload.data,
};
dispatchConsole({ type: 'append', entry });
}
for (const queryKey of queryKeysForDashboardEvent(event)) {
void queryClient.invalidateQueries({ queryKey });
}
}
const client = createDashboardRealtimeClient({
sourceFactory: createBrowserEventSource,
onEvent,
onStateChange: setConnection,
});
client.start();
return () => client.stop();
}, [queryClient, realtimeEnabled]);
const visibleEntries = selectVisibleConsoleEntries(consoleState);
useEffect(() => {
if (
consoleState.isAutoScrollEnabled &&
!consoleState.isPaused &&
typeof consoleEnd.current?.scrollIntoView === 'function'
) {
consoleEnd.current.scrollIntoView({ block: 'nearest' });
}
}, [consoleState.entries, consoleState.isAutoScrollEnabled, consoleState.isPaused]);
return (
<div className="page operations-page">
<header className="page-header"><div><span className="eyebrow">Observability</span><h1>Operations</h1><p>Human-readable activity, background work, and raw diagnostic output in distinct views.</p></div></header>
<div aria-label="Realtime connection" className={`connection-banner ${connection.isStale ? 'stale' : ''}`} role="status">
<strong>{connectionLabel(connection)}</strong>
{connection.isStale ? <span>State may be stale</span> : <span>Events are updating live</span>}
</div>
<div className="operations-summary-grid">
<section aria-labelledby="operations-activity-heading" className="panel feature-panel" role="region">
<div className="panel-heading"><div><span className="eyebrow">Readable history</span><h2 id="operations-activity-heading">Activity</h2></div></div>
{activityQuery.isPending ? <p role="status">Loading activity…</p> : activityQuery.isError ? <p role="alert">{activityQuery.error.message}</p> : <ActivityView items={activityQuery.data} />}
</section>
<section aria-labelledby="sync-jobs-heading" className="panel feature-panel" role="region">
<div className="panel-heading"><div><span className="eyebrow">Background work</span><h2 id="sync-jobs-heading">Sync jobs</h2></div></div>
{syncQuery.isPending ? <p role="status">Loading sync state…</p> : syncQuery.isError ? <p role="alert">{syncQuery.error.message}</p> : <SyncSummary status={syncQuery.data} />}
</section>
</div>
<section aria-labelledby="live-console-heading" className="panel console-panel" role="region">
<div className="panel-heading"><div><span className="eyebrow">Raw diagnostics</span><h2 id="live-console-heading">Live console</h2></div><span className="console-count">{consoleState.entries.length} retained</span></div>
<div className="console-controls">
<button aria-pressed={consoleState.isPaused} className="button secondary" onClick={() => dispatchConsole({ type: 'set-paused', paused: !consoleState.isPaused })} type="button">{consoleState.isPaused ? `Resume console (${consoleState.pendingEntries.length})` : 'Pause console'}</button>
<label>Log level<select aria-label="Log level" onChange={(event) => dispatchConsole({ type: 'set-level', level: event.target.value as ConsoleLevelFilter })} value={consoleState.level}><option value="all">All levels</option><option value="debug">Debug</option><option value="info">Info</option><option value="warn">Warning</option><option value="error">Error</option></select></label>
<label>Search logs<input aria-label="Search logs" onChange={(event) => dispatchConsole({ type: 'set-search', search: event.target.value })} placeholder="Message or metadata" type="search" value={consoleState.search} /></label>
<label className="checkbox-control"><input checked={consoleState.isAutoScrollEnabled} onChange={(event) => dispatchConsole({ type: 'set-auto-scroll', enabled: event.target.checked })} type="checkbox" />Auto-scroll</label>
</div>
<div aria-live="polite" className="live-console" role="log">
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => (
<article className={`console-entry ${entry.level}`} key={entry.id}><time dateTime={entry.occurredAt}>{formatTimestamp(entry.occurredAt)}</time><strong>{entry.level.toUpperCase()}</strong><span>{entry.message}</span></article>
))}
<div ref={consoleEnd} />
</div>
</section>
</div>
);
}
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { operationsApi } from './operations-api.js';
export const operationsQueryKeys = {
activity: ['operations', 'activity'] as const,
logs: ['operations', 'logs'] as const,
syncStatus: ['operations', 'sync-status'] as const,
};
export function useActivityQuery() {
return useQuery({ queryKey: operationsQueryKeys.activity, queryFn: operationsApi.getActivity });
}
export function useLogsQuery() {
return useQuery({ queryKey: operationsQueryKeys.logs, queryFn: () => operationsApi.getLogs(200) });
}
export function useSyncStatusQuery() {
return useQuery({ queryKey: operationsQueryKeys.syncStatus, queryFn: operationsApi.getSyncStatus });
}
@@ -0,0 +1,33 @@
.operations-page { display: grid; gap: .8rem; }
.connection-banner { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem 1rem; color: #aaf0c4; background: #163a26; border: 1px solid #28593d; border-radius: .75rem; }
.connection-banner.stale { color: #ffe1a0; background: #2a2111; border-color: #59451e; }
.operations-summary-grid { display: grid; gap: .8rem; }
.operations-activity-list { display: grid; gap: .8rem; padding: 0; margin: 0; list-style: none; }
.operations-activity-list li { display: flex; gap: .75rem; }
.operations-activity-list li div { display: grid; gap: .2rem; }
.operations-activity-list small { color: var(--muted); }
.sync-summary { display: grid; gap: 1rem; }
.sync-summary > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.sync-progress-label { display: flex; justify-content: space-between; gap: .75rem; margin-bottom: .45rem; color: var(--muted); }
.sync-summary progress { width: 100%; accent-color: var(--accent); }
.sync-history { display: grid; gap: .5rem; margin: 0; }
.sync-history div { display: flex; justify-content: space-between; gap: 1rem; padding-top: .6rem; border-top: 1px solid var(--border); }
.sync-history dt { color: var(--muted); }
.sync-history dd { margin: 0; }
.console-count { color: var(--muted); font-size: .8rem; }
.console-controls { display: grid; gap: .65rem; margin-bottom: .8rem; }
.console-controls label { display: grid; gap: .3rem; color: var(--muted); font-size: .78rem; font-weight: 700; }
.console-controls input, .console-controls select { min-height: 2.5rem; padding: .5rem .65rem; color: #e9f1ec; background: #09110e; border: 1px solid var(--border); border-radius: .55rem; }
.console-controls .checkbox-control { display: flex; flex-direction: row; align-items: center; }
.console-controls .checkbox-control input { min-height: auto; }
.live-console { height: min(30rem, 55vh); overflow: auto; padding: .65rem; color: #cbd8d1; background: #050906; border: 1px solid var(--border); border-radius: .6rem; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .78rem; }
.console-entry { display: grid; grid-template-columns: 11rem 3.5rem minmax(0, 1fr); gap: .65rem; padding: .35rem; border-bottom: 1px solid #142019; }
.console-entry time { color: #708178; }
.console-entry strong { color: #8da49a; }
.console-entry.warn strong { color: var(--warning); }
.console-entry.error strong { color: var(--danger); }
.live-console > p { color: var(--muted); text-align: center; }
@media (min-width: 56rem) {
.operations-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.console-controls { grid-template-columns: auto minmax(8rem, .45fr) minmax(14rem, 1fr) auto; align-items: end; }
}
@@ -0,0 +1,143 @@
import type { QueryKey } from '@tanstack/react-query';
import {
DashboardEventSchema,
type DashboardEvent,
} from '../../../../../packages/api-contracts/src/index.js';
export const DASHBOARD_EVENT_TYPES = [
'bot.status.changed',
'message.sent',
'message.failed',
'conversation.updated',
'reply.received',
'sync.started',
'sync.progress',
'sync.completed',
'sync.failed',
'rate-limit.changed',
'reddit.session.changed',
'log.created',
] as const;
export interface DashboardEventSource {
addEventListener(type: string, listener: (event: unknown) => void): void;
close(): void;
}
export interface RealtimeConnectionSnapshot {
status: 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed';
isStale: boolean;
lastEventId?: string;
}
export interface DashboardRealtimeClient {
start(): void;
stop(): void;
getSnapshot(): RealtimeConnectionSnapshot;
}
export interface DashboardRealtimeClientOptions {
url?: string;
sourceFactory(url: string): DashboardEventSource;
onEvent(event: DashboardEvent): void;
onStateChange?(snapshot: RealtimeConnectionSnapshot): void;
onInvalidEvent?(value: unknown): void;
}
function eventData(value: unknown): string | undefined {
if (typeof value !== 'object' || value === null || !('data' in value)) return undefined;
return typeof value.data === 'string' ? value.data : undefined;
}
export function createDashboardRealtimeClient(
options: DashboardRealtimeClientOptions,
): DashboardRealtimeClient {
let source: DashboardEventSource | undefined;
let snapshot: RealtimeConnectionSnapshot = { status: 'idle', isStale: true };
function update(next: RealtimeConnectionSnapshot): void {
snapshot = next;
options.onStateChange?.(snapshot);
}
function receive(value: unknown): void {
const data = eventData(value);
if (data === undefined) {
options.onInvalidEvent?.(value);
return;
}
let decoded: unknown;
try {
decoded = JSON.parse(data);
} catch {
options.onInvalidEvent?.(data);
return;
}
const result = DashboardEventSchema.safeParse(decoded);
if (!result.success) {
options.onInvalidEvent?.(decoded);
return;
}
snapshot = { ...snapshot, lastEventId: result.data.id };
options.onEvent(result.data);
}
return {
start() {
if (source !== undefined) return;
update({ ...snapshot, status: 'connecting', isStale: true });
source = options.sourceFactory(options.url ?? '/api/events/stream');
source.addEventListener('open', () => {
update({ ...snapshot, status: 'connected', isStale: false });
});
source.addEventListener('error', () => {
update({ ...snapshot, status: 'reconnecting', isStale: true });
});
for (const type of DASHBOARD_EVENT_TYPES) source.addEventListener(type, receive);
},
stop() {
source?.close();
source = undefined;
update({ ...snapshot, status: 'closed', isStale: true });
},
getSnapshot() {
return snapshot;
},
};
}
export function queryKeysForDashboardEvent(event: DashboardEvent): readonly QueryKey[] {
switch (event.type) {
case 'bot.status.changed':
return [['dashboard', 'stats']];
case 'rate-limit.changed':
return [['dashboard', 'stats'], ['dashboard', 'timers']];
case 'message.sent':
case 'message.failed':
return [['conversations'], ['performance']];
case 'conversation.updated':
case 'reply.received':
return [['conversations'], ['operations', 'activity']];
case 'sync.started':
case 'sync.progress':
case 'sync.completed':
case 'sync.failed':
return [['operations', 'sync-status'], ['conversations'], ['media']];
case 'reddit.session.changed':
return [['reddit', 'session']];
case 'log.created':
return [];
}
}
export function createBrowserEventSource(url: string): DashboardEventSource {
const source = new EventSource(url);
return {
addEventListener(type, listener) {
source.addEventListener(type, (event) => listener(event));
},
close() {
source.close();
},
};
}
@@ -0,0 +1,199 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { dashboardQueryKeys, useSetRunningMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
import type { BotStats, Timers } from '../../api/contracts.js';
import { ConfirmationDialog } from '../../components/confirmation-dialog.js';
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
function formatTime(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(timestamp);
}
function MetricCard({ label, value, detail, metric }: { label: string; value: string | number; detail: string; metric: string }) {
return (
<article className="metric-card">
<span>{label}</span>
<strong data-metric={metric}>{value}</strong>
<small>{detail}</small>
</article>
);
}
function TimersPanel({ timers }: { timers: Timers }) {
const isReady = timers.nextMessageAvailableAt === null || timers.nextMessageAvailableAt <= Date.now();
return (
<section aria-labelledby="timers-heading" className="panel">
<div className="panel-heading">
<div>
<span className="eyebrow">Rate limits</span>
<h2 id="timers-heading">Sending window</h2>
</div>
<span className={isReady ? 'pill success' : 'pill warning'}>{isReady ? 'Ready' : 'Cooling down'}</span>
</div>
<dl className="definition-list">
<div>
<dt>Next message</dt>
<dd>{isReady ? 'Available now' : formatTime(timers.nextMessageAvailableAt ?? Date.now())}</dd>
</div>
<div>
<dt>Hourly allowance</dt>
<dd>{timers.messagesRemaining} messages remaining</dd>
</div>
<div>
<dt>Allowance resets</dt>
<dd>{formatTime(timers.hourlyResetAt)}</dd>
</div>
</dl>
</section>
);
}
function ActivityPanel({ stats }: { stats: BotStats }) {
return (
<section aria-labelledby="activity-heading" className="panel activity-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">Latest signals</span>
<h2 id="activity-heading">Recent activity</h2>
</div>
</div>
{stats.recentActivity.length === 0 ? (
<div className="empty-state">
<strong>No recent activity yet.</strong>
<span>New bot events will appear here.</span>
</div>
) : (
<ol className="activity-list">
{stats.recentActivity.map((item) => (
<li key={`${item.timestamp}-${item.message}`}>
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
<div><strong>{item.message}</strong><small>{formatTime(item.timestamp)}</small></div>
</li>
))}
</ol>
)}
</section>
);
}
export function OverviewPage() {
const queryClient = useQueryClient();
const statsQuery = useStatsQuery();
const timersQuery = useTimersQuery();
const controlMutation = useSetRunningMutation();
const [confirmationAction, setConfirmationAction] = useState<'start' | 'stop' | null>(null);
const isRefreshing = (statsQuery.isFetching || timersQuery.isFetching) && !statsQuery.isPending;
async function refreshOverview() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: dashboardQueryKeys.stats }),
queryClient.invalidateQueries({ queryKey: dashboardQueryKeys.timers }),
]);
}
function confirmControl() {
if (!confirmationAction) return;
controlMutation.mutate(confirmationAction === 'start', {
onSuccess: () => setConfirmationAction(null),
});
}
if (statsQuery.isPending) {
return <div aria-label="Loading overview" className="page-state" role="status"><span className="spinner" />Loading overview…</div>;
}
if (statsQuery.isError) {
return (
<div className="page-state error-state" role="alert">
<strong>Overview unavailable</strong>
<span>{errorMessage(statsQuery.error)}</span>
<button className="button secondary" onClick={() => void statsQuery.refetch()} type="button">Retry overview</button>
</div>
);
}
const stats = statsQuery.data;
const action = stats.isRunning ? 'stop' : 'start';
return (
<div className="page">
<header className="page-header">
<div>
<span className="eyebrow">Control room</span>
<h1>Overview</h1>
<p>System health, sending limits, and the signals that need attention.</p>
</div>
<div className="header-actions">
<button
aria-label={isRefreshing ? 'Refreshing overview' : 'Refresh overview'}
className="button secondary"
disabled={isRefreshing}
onClick={() => void refreshOverview()}
type="button"
>
{isRefreshing ? 'Refreshing…' : 'Refresh'}
</button>
<button
aria-label={`${action === 'start' ? 'Start' : 'Stop'} bot`}
className={action === 'start' ? 'button primary' : 'button danger'}
onClick={() => setConfirmationAction(action)}
type="button"
>
{action === 'start' ? 'Start bot' : 'Stop bot'}
</button>
</div>
</header>
<section aria-label="Bot state" className="status-strip">
<div>
<span className={`status-dot ${stats.isRunning ? 'running' : 'stopped'}`} aria-hidden="true" />
<div><small>Bot status</small><strong data-bot-status>{stats.isRunning ? 'Running' : 'Stopped'}</strong></div>
</div>
{stats.dryRunMode ? <span className="pill neutral">Dry run</span> : null}
<span className="status-note">{stats.messagesThisHour} of {stats.maxMessagesPerHour} messages used this hour</span>
</section>
{stats.invitationCooldown ? (
<div className="cooldown-banner" role="status">
<strong>Reddit invitation cooldown</strong>
<span>{stats.invitationCooldown.reason}</span>
</div>
) : null}
<section aria-label="Key metrics" className="metrics-grid">
<MetricCard detail="All recorded sends" label="Messages sent" metric="messages-sent" value={stats.totalMessagesSent} />
<MetricCard detail={`${stats.responseRate}% response rate`} label="Responses" metric="responses" value={stats.totalResponses} />
<MetricCard detail="Posts evaluated" label="Processed" metric="processed" value={stats.totalProcessed} />
<MetricCard detail={`Limit ${stats.maxMessagesPerHour}`} label="This hour" metric="messages-hour" value={stats.messagesThisHour} />
</section>
<div className="overview-grid">
{timersQuery.isPending ? (
<section aria-label="Loading timers" className="panel panel-loading" role="status"><span className="spinner" />Loading sending window…</section>
) : timersQuery.isError ? (
<section className="panel error-panel" role="alert">
<strong>Sending window unavailable</strong>
<span>{errorMessage(timersQuery.error)}</span>
<button className="button secondary" onClick={() => void timersQuery.refetch()} type="button">Retry timers</button>
</section>
) : (
<TimersPanel timers={timersQuery.data} />
)}
<ActivityPanel stats={stats} />
</div>
{controlMutation.isError ? <div className="toast error" role="alert">{errorMessage(controlMutation.error)}</div> : null}
{confirmationAction ? (
<ConfirmationDialog
action={confirmationAction}
isPending={controlMutation.isPending}
onCancel={() => setConfirmationAction(null)}
onConfirm={confirmControl}
/>
) : null}
</div>
);
}
@@ -0,0 +1,73 @@
import {
DailyTrendResponseSchema,
StrategyPerformanceResponseSchema,
SubredditPerformanceResponseSchema,
SuccessfulExamplesResponseSchema,
} from '../../../../../packages/api-contracts/src/index.js';
import type { z } from 'zod';
type SubredditPerformanceResponse = z.infer<typeof SubredditPerformanceResponseSchema>;
type StrategyPerformanceResponse = z.infer<typeof StrategyPerformanceResponseSchema>;
type DailyTrendResponse = z.infer<typeof DailyTrendResponseSchema>;
type SuccessfulExamplesResponse = z.infer<typeof SuccessfulExamplesResponseSchema>;
async function parseResponse<T>(
response: Response,
schema: { parse(value: unknown): T },
): Promise<T> {
const payload: unknown = await response.json();
if (!response.ok) {
const message =
typeof payload === 'object' &&
payload !== null &&
'error' in payload &&
typeof payload.error === 'string'
? payload.error
: `Request failed (${response.status})`;
throw new Error(message);
}
return schema.parse(payload);
}
export interface PerformanceApi {
getSubredditPerformance(): Promise<SubredditPerformanceResponse>;
getStrategyPerformance(): Promise<StrategyPerformanceResponse>;
getDailyTrend(days: number): Promise<DailyTrendResponse>;
getSuccessfulExamples(): Promise<SuccessfulExamplesResponse>;
}
export function createPerformanceApi(
fetchImplementation?: typeof fetch,
) {
function request(path: string): Promise<Response> {
return (fetchImplementation ?? globalThis.fetch)(path);
}
return {
async getSubredditPerformance() {
return parseResponse(
await request('/api/analytics/by-subreddit'),
SubredditPerformanceResponseSchema,
);
},
async getStrategyPerformance() {
return parseResponse(
await request('/api/analytics/by-strategy'),
StrategyPerformanceResponseSchema,
);
},
async getDailyTrend(days: number) {
return parseResponse(
await request(`/api/analytics/trend?days=${days}`),
DailyTrendResponseSchema,
);
},
async getSuccessfulExamples() {
return parseResponse(
await request('/api/successful'),
SuccessfulExamplesResponseSchema,
);
},
} satisfies PerformanceApi;
}
export const performanceApi = createPerformanceApi();
@@ -0,0 +1,121 @@
import type { z } from 'zod';
import type {
DailyTrendResponseSchema,
StrategyPerformanceResponseSchema,
SubredditPerformanceResponseSchema,
SuccessfulExample,
} from '../../../../../packages/api-contracts/src/index.js';
import {
useDailyTrendQuery,
useStrategyPerformanceQuery,
useSubredditPerformanceQuery,
useSuccessfulExamplesQuery,
} from './performance-queries.js';
import './performance.css';
const LOW_CONFIDENCE_SAMPLE_SIZE = 20;
type SubredditPerformance = z.infer<typeof SubredditPerformanceResponseSchema>[number];
type StrategyPerformance = z.infer<typeof StrategyPerformanceResponseSchema>[number];
type DailyTrend = z.infer<typeof DailyTrendResponseSchema>[number];
function SampleSize({ totalSent }: { totalSent: number }) {
const isLowConfidence = totalSent < LOW_CONFIDENCE_SAMPLE_SIZE;
return (
<span className="sample-size">
n = {totalSent}
{isLowConfidence ? <span className="confidence-warning">Low confidence</span> : null}
</span>
);
}
function PerformanceTable({
label,
rows,
rowLabel,
}: {
label: string;
rows: readonly (SubredditPerformance | StrategyPerformance)[];
rowLabel: (row: SubredditPerformance | StrategyPerformance) => string;
}) {
return (
<div className="table-scroll">
<table aria-label={label} className="performance-table">
<thead><tr><th scope="col">Name</th><th scope="col">Responses</th><th scope="col">Rate</th><th scope="col">Sample</th></tr></thead>
<tbody>
{rows.map((row) => (
<tr key={rowLabel(row)}>
<th scope="row">
{rowLabel(row)}
{'configured' in row && row.configured ? <span className="configured-label">Configured</span> : null}
</th>
<td>{row.totalResponses}</td>
<td>{row.responseRate}%</td>
<td><SampleSize totalSent={row.totalSent} /></td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function Trend({ rows }: { rows: readonly DailyTrend[] }) {
return (
<ol aria-label="30 day response trend" className="trend-list">
{rows.map((row) => (
<li key={row.date}>
<time dateTime={row.date}>{row.date}</time>
<span className="trend-track" aria-hidden="true"><span style={{ width: `${Math.max(2, Math.min(100, row.responseRate))}%` }} /></span>
<strong>{row.responseRate}%</strong>
<SampleSize totalSent={row.totalSent} />
</li>
))}
</ol>
);
}
function SuccessfulExamples({ examples }: { examples: readonly SuccessfulExample[] }) {
if (examples.length === 0) return <p className="feature-empty">No successful examples have been recorded yet.</p>;
return (
<div className="example-grid">
{examples.map((example) => {
const source = example.subreddit ?? 'unknown source';
return (
<article aria-label={`Successful example from ${source}`} className="successful-example" key={example.id}>
<div><span className="pill success">Response received</span><small>{source}</small></div>
{example.postTitle ? <h3>{example.postTitle}</h3> : null}
<blockquote>{example.originalMessage}</blockquote>
{example.responseSnippet ? <p><strong>Reply:</strong> {example.responseSnippet}</p> : null}
</article>
);
})}
</div>
);
}
export function PerformancePage() {
const subreddits = useSubredditPerformanceQuery();
const strategies = useStrategyPerformanceQuery();
const trend = useDailyTrendQuery(30);
const examples = useSuccessfulExamplesQuery();
const queries = [subreddits, strategies, trend, examples];
if (queries.some((query) => query.isPending)) {
return <div className="page-state" role="status">Loading performance…</div>;
}
const failed = queries.find((query) => query.isError);
if (failed?.error) {
return <div className="page-state error-state" role="alert"><strong>Performance unavailable</strong><span>{failed.error.message}</span></div>;
}
return (
<div className="page performance-page">
<header className="page-header"><div><span className="eyebrow">Decision support</span><h1>Performance</h1><p>Compare response outcomes without hiding the sample sizes behind the percentages.</p></div></header>
<section aria-labelledby="trend-heading" className="panel feature-panel"><div className="panel-heading"><div><span className="eyebrow">Last 30 days</span><h2 id="trend-heading">Response trend</h2></div></div><Trend rows={trend.data ?? []} /></section>
<div className="performance-grid">
<section aria-labelledby="subreddit-heading" className="panel feature-panel"><div className="panel-heading"><h2 id="subreddit-heading">By subreddit</h2></div><PerformanceTable label="Subreddit performance" rows={subreddits.data ?? []} rowLabel={(row) => 'subreddit' in row ? row.subreddit : row.strategyId} /></section>
<section aria-labelledby="strategy-heading" className="panel feature-panel"><div className="panel-heading"><h2 id="strategy-heading">By strategy</h2></div><PerformanceTable label="Strategy performance" rows={strategies.data ?? []} rowLabel={(row) => 'strategyId' in row ? row.strategyId : row.subreddit} /></section>
</div>
<section aria-labelledby="examples-heading" className="panel feature-panel"><div className="panel-heading"><div><span className="eyebrow">What worked</span><h2 id="examples-heading">Successful examples</h2></div></div><SuccessfulExamples examples={examples.data ?? []} /></section>
</div>
);
}
@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query';
import { performanceApi } from './performance-api.js';
export const performanceQueryKeys = {
subreddits: ['performance', 'subreddits'] as const,
strategies: ['performance', 'strategies'] as const,
trend: (days: number) => ['performance', 'trend', days] as const,
successfulExamples: ['performance', 'successful-examples'] as const,
};
export function useSubredditPerformanceQuery() {
return useQuery({
queryKey: performanceQueryKeys.subreddits,
queryFn: performanceApi.getSubredditPerformance,
});
}
export function useStrategyPerformanceQuery() {
return useQuery({
queryKey: performanceQueryKeys.strategies,
queryFn: performanceApi.getStrategyPerformance,
});
}
export function useDailyTrendQuery(days: number) {
return useQuery({
queryKey: performanceQueryKeys.trend(days),
queryFn: () => performanceApi.getDailyTrend(days),
});
}
export function useSuccessfulExamplesQuery() {
return useQuery({
queryKey: performanceQueryKeys.successfulExamples,
queryFn: performanceApi.getSuccessfulExamples,
});
}
@@ -0,0 +1,24 @@
.performance-page { display: grid; gap: .8rem; }
.feature-panel { min-width: 0; }
.performance-grid { display: grid; gap: .8rem; }
.table-scroll { overflow-x: auto; }
.performance-table { width: 100%; border-collapse: collapse; text-align: left; }
.performance-table th, .performance-table td { padding: .7rem .5rem; border-top: 1px solid var(--border); }
.performance-table thead th { border-top: 0; color: var(--muted); font-size: .75rem; text-transform: uppercase; }
.performance-table tbody th { min-width: 9rem; }
.configured-label, .confidence-warning { display: block; margin-top: .2rem; color: var(--accent); font-size: .68rem; font-weight: 750; }
.confidence-warning { color: var(--warning); }
.sample-size { white-space: nowrap; color: var(--muted); }
.trend-list { display: grid; gap: .65rem; padding: 0; margin: 0; list-style: none; }
.trend-list li { display: grid; grid-template-columns: 6.5rem minmax(5rem, 1fr) 3rem 6rem; align-items: center; gap: .75rem; }
.trend-track { height: .5rem; overflow: hidden; background: var(--border); border-radius: 999px; }
.trend-track span { display: block; height: 100%; background: var(--accent); border-radius: inherit; }
.example-grid { display: grid; gap: .75rem; }
.successful-example { padding: 1rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
.successful-example > div { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.successful-example small { color: var(--muted); }
.successful-example h3 { margin-bottom: .5rem; }
.successful-example blockquote { margin: 1rem 0; padding-left: 1rem; border-left: 2px solid var(--accent); }
.successful-example p { margin-bottom: 0; color: var(--muted); }
.feature-empty { color: var(--muted); }
@media (min-width: 64rem) { .performance-grid, .example-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@@ -0,0 +1,15 @@
interface PlaceholderPageProps {
title: string;
description: string;
}
export function PlaceholderPage({ title, description }: PlaceholderPageProps) {
return (
<div className="page">
<header className="page-header">
<div><span className="eyebrow">Workspace</span><h1>{title}</h1><p>{description}</p></div>
</header>
<section className="panel empty-state"><strong>{title} workspace</strong><span>This route is ready for its feature module.</span></section>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { AppProviders } from './app/providers.js';
import { dashboardRouter } from './app/router.js';
import './styles.css';
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Dashboard root element is missing');
createRoot(rootElement).render(
<StrictMode>
<AppProviders>
<RouterProvider router={dashboardRouter} />
</AppProviders>
</StrictMode>,
);
+113
View File
@@ -0,0 +1,113 @@
:root {
color: #e9f1ec;
background: #09110e;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
--surface: #101a16;
--surface-raised: #17231e;
--border: #2a3a33;
--muted: #9aaca3;
--accent: #75d69d;
--accent-dark: #153c28;
--danger: #ff8a84;
--warning: #f3c76b;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
a { color: inherit; }
.skip-link { position: fixed; left: 1rem; top: -5rem; z-index: 100; background: white; color: black; padding: .75rem 1rem; border-radius: .5rem; }
.skip-link:focus { top: 1rem; }
.app-shell { min-height: 100vh; }
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 30; width: 17.5rem; padding: 1.5rem 1rem; visibility: hidden; background: #0c1511; border-right: 1px solid var(--border); transform: translateX(-100%); transition: transform 180ms ease, visibility 180ms; }
.sidebar.is-open { visibility: visible; transform: translateX(0); }
.brand { display: flex; align-items: center; gap: .75rem; padding: 0 .5rem 1.75rem; }
.brand-mark { display: grid; place-items: center; width: 2.4rem; height: 2.4rem; border-radius: .75rem; color: #07130c; background: var(--accent); font-weight: 900; }
.brand div { display: grid; gap: .1rem; }
.brand span:last-child, .brand div span { color: var(--muted); font-size: .75rem; }
nav { display: grid; gap: .35rem; }
.nav-link { display: grid; gap: .2rem; padding: .8rem .75rem; color: var(--muted); text-decoration: none; border-radius: .75rem; border: 1px solid transparent; }
.nav-link span { color: #dfe9e3; font-weight: 650; }
.nav-link small { font-size: .72rem; line-height: 1.3; }
.nav-link:hover { background: var(--surface); }
.nav-link.is-active { background: var(--accent-dark); border-color: #28593d; }
.nav-link.is-active span { color: #b8f1cd; }
.navigation-backdrop { position: fixed; inset: 0; z-index: 20; width: 100%; border: 0; background: rgba(0, 0, 0, .6); }
.workspace { min-height: 100vh; }
.mobile-header { position: sticky; top: 0; z-index: 15; display: flex; align-items: center; justify-content: space-between; height: 4rem; padding: 0 1rem; background: rgba(9, 17, 14, .92); border-bottom: 1px solid var(--border); backdrop-filter: blur(12px); font-weight: 700; }
.icon-button { width: 2.5rem; height: 2.5rem; border: 1px solid var(--border); border-radius: .6rem; color: white; background: var(--surface); font-size: 1.25rem; }
main { min-height: calc(100vh - 4rem); }
.page { width: min(78rem, 100%); margin: 0 auto; padding: 1.5rem 1rem 4rem; }
.page-header { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 1.5rem; margin-bottom: 1.5rem; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: .4rem; font-size: clamp(2rem, 4vw, 3rem); line-height: 1; letter-spacing: -.045em; }
h2 { margin-bottom: 0; font-size: 1.1rem; }
.page-header p { max-width: 42rem; margin-bottom: 0; color: var(--muted); }
.eyebrow { display: block; margin-bottom: .45rem; color: var(--accent); font-size: .72rem; font-weight: 800; letter-spacing: .11em; text-transform: uppercase; }
.header-actions, .dialog-actions { display: flex; gap: .75rem; }
.button { min-height: 2.6rem; padding: .55rem .9rem; border-radius: .65rem; border: 1px solid transparent; color: white; font-weight: 700; }
.button.primary { color: #06140b; background: var(--accent); }
.button.secondary { border-color: var(--border); background: var(--surface-raised); }
.button.danger { color: #2d0907; background: var(--danger); }
.status-strip { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; padding: 1rem; margin-bottom: 1rem; background: linear-gradient(110deg, #13221b, #0f1915); border: 1px solid var(--border); border-radius: .9rem; }
.status-strip > div { display: flex; align-items: center; gap: .75rem; }
.status-strip > div div { display: grid; }
.status-strip small { color: var(--muted); }
.status-dot { width: .7rem; height: .7rem; border-radius: 50%; }
.status-dot.running { background: var(--accent); box-shadow: 0 0 0 .35rem rgba(117, 214, 157, .12); }
.status-dot.stopped { background: var(--danger); box-shadow: 0 0 0 .35rem rgba(255, 138, 132, .1); }
.pill { display: inline-flex; align-items: center; padding: .3rem .55rem; border-radius: 999px; font-size: .75rem; font-weight: 800; }
.pill.neutral { color: #d4dfda; background: #2b3832; }
.pill.success { color: #aaf0c4; background: #163a26; }
.pill.warning { color: #ffe1a0; background: #42351b; }
.status-note { margin-left: auto; color: var(--muted); font-size: .85rem; }
.cooldown-banner { display: grid; gap: .25rem; padding: .9rem 1rem; margin-bottom: 1rem; color: #ffe1a0; background: #2a2111; border: 1px solid #59451e; border-radius: .8rem; }
.cooldown-banner span { font-size: .85rem; }
.metrics-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; margin-bottom: .75rem; }
.metric-card, .panel { background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
.metric-card { display: grid; gap: .3rem; padding: 1rem; }
.metric-card > span, .metric-card small { color: var(--muted); }
.metric-card strong { font-size: 1.75rem; letter-spacing: -.04em; }
.overview-grid { display: grid; gap: .75rem; }
.panel { padding: 1rem; }
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
.definition-list { display: grid; gap: .75rem; margin: 0; }
.definition-list > div { display: flex; justify-content: space-between; gap: 1rem; padding-top: .75rem; border-top: 1px solid var(--border); }
.definition-list dt { color: var(--muted); }
.definition-list dd { margin: 0; text-align: right; font-weight: 700; }
.empty-state { display: grid; place-items: center; gap: .4rem; min-height: 10rem; text-align: center; color: var(--muted); }
.empty-state strong { color: #e9f1ec; }
.activity-list { display: grid; gap: .75rem; padding: 0; margin: 0; list-style: none; }
.activity-list li { display: flex; align-items: flex-start; gap: .75rem; }
.activity-list li div { display: grid; gap: .15rem; }
.activity-list small { color: var(--muted); }
.activity-marker { flex: 0 0 auto; width: .55rem; height: .55rem; margin-top: .3rem; border-radius: 50%; background: var(--muted); }
.activity-marker.sent, .activity-marker.response { background: var(--accent); }
.activity-marker.error { background: var(--danger); }
.page-state { display: grid; place-items: center; align-content: center; gap: 1rem; min-height: 60vh; color: var(--muted); }
.error-state strong, .error-panel strong { color: var(--danger); }
.error-panel, .panel-loading { display: grid; place-items: center; align-content: center; gap: .75rem; min-height: 14rem; text-align: center; color: var(--muted); }
.spinner { width: 1.4rem; height: 1.4rem; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.dialog-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 1rem; background: rgba(0, 0, 0, .72); }
.dialog-card { width: min(28rem, 100%); padding: 1.4rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 1rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .4); }
.dialog-card p { color: var(--muted); }
.dialog-actions { justify-content: flex-end; margin-top: 1.5rem; }
.toast { position: fixed; right: 1rem; bottom: 1rem; max-width: 25rem; padding: .8rem 1rem; border-radius: .7rem; }
.toast.error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
@media (min-width: 48rem) {
.sidebar { visibility: visible; transform: none; }
.workspace { margin-left: 17.5rem; }
.mobile-header { display: none; }
main { min-height: 100vh; }
.page { padding: 3rem 2rem 5rem; }
.navigation-backdrop { display: none; }
.metrics-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.overview-grid { grid-template-columns: minmax(18rem, .8fr) minmax(22rem, 1.2fr); }
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'node:url';
export default defineConfig({
root: fileURLToPath(new URL('.', import.meta.url)),
plugins: [react()],
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
});
+22 -1
View File
@@ -54,7 +54,7 @@ if command -v fluxbox >/dev/null 2>&1; then
fluxbox >/dev/null 2>&1 &
fi
VNC_ARGS=(-display "${DISPLAY}" -forever -shared -rfbport "${VNC_PORT}" -nopw_warn)
VNC_ARGS=(-display "${DISPLAY}" -forever -shared -rfbport "${VNC_PORT}")
if [ -n "${VNC_PASSWORD:-}" ]; then
mkdir -p /root/.vnc
x11vnc -storepasswd "${VNC_PASSWORD}" /root/.vnc/passwd >/dev/null
@@ -68,6 +68,27 @@ fi
x11vnc "${VNC_ARGS[@]}" >/var/log/x11vnc.log 2>&1 &
X11VNC_PID=$!
VNC_READY=0
for i in $(seq 1 50); do
if ! kill -0 "${X11VNC_PID}" 2>/dev/null; then
echo "[entrypoint] FATAL: x11vnc exited before becoming ready" >&2
cat /var/log/x11vnc.log >&2
exit 1
fi
if (: </dev/tcp/127.0.0.1/${VNC_PORT}) 2>/dev/null; then
echo "[entrypoint] VNC server is ready after ${i} checks"
VNC_READY=1
break
fi
sleep 0.2
done
if [ "${VNC_READY}" != "1" ]; then
echo "[entrypoint] FATAL: x11vnc did not listen on port ${VNC_PORT} after 10s" >&2
cat /var/log/x11vnc.log >&2
exit 1
fi
echo "[entrypoint] starting noVNC (websockify) on :${NOVNC_PORT}"
websockify --web "${NOVNC_WEB_DIR}" "${NOVNC_PORT}" "localhost:${VNC_PORT}" \
>/var/log/websockify.log 2>&1 &
+102
View File
@@ -0,0 +1,102 @@
# React Dashboard Migration
## Outcome
Replace the inline dashboard in `public/index.html` with a typed, accessible React
application without disrupting the bot worker or its Express API. Express remains
the authenticated API, SSE, private-media, and static-asset host.
The migration is complete only when the legacy dashboard can be removed without
losing a documented capability and the full test, type-check, production-build,
browser-acceptance, authentication, private-media, and reconnect gates pass.
## Architecture
- `apps/dashboard`: React, TypeScript, Vite, React Router, and TanStack Query.
- `packages/api-contracts`: shared Zod schemas for HTTP and realtime transport.
- `src/web`: existing Express host, gradually adapted to the shared contracts.
- Backend service and database types stay private to the backend. Transport
contracts must not expose database rows as an accidental public API.
- Server state lives in TanStack Query. Route, filter, and selection state lives
in URLs. Local component state is reserved for ephemeral presentation state.
- Do not add a global client-state library until a demonstrated cross-feature
state requirement cannot be met by those boundaries.
## Navigation
The application shell provides a responsive sidebar or drawer with these routes:
| Route | Responsibility |
| --- | --- |
| `/` | Health, metrics, timers, limits, and important recent activity |
| `/conversations` | Searchable conversation inbox |
| `/conversations/:id` | Conversation history, context, suggestions, and sending |
| `/media` | Private blurred-by-default gallery and sync controls |
| `/performance` | Trends, strategies, subreddits, and successful examples |
| `/operations` | Domain activity, background jobs, and diagnostics |
| `/operations/logs` | Filterable bounded realtime technical console |
| `/settings/*` | Monitoring, limits, behaviour, AI, and access settings |
The shell always surfaces bot state, dry-run state, Reddit session state,
realtime connection state, cooldowns, active sync progress, notifications, and
the guarded start/stop control.
## Realtime model
HTTP snapshots are authoritative. A typed SSE event stream updates or invalidates
the relevant query cache. Every event has an ID, type, occurrence timestamp, and
validated payload. The stream supports bounded replay and `Last-Event-ID` resume.
After reconnection the client refetches authoritative snapshots to repair gaps.
Required event families are bot status, message sent or failed, conversation
updated, reply received, sync lifecycle and progress, rate-limit state, Reddit
session state, and technical logs.
Important domain events appear in a readable activity timeline. Raw logs remain
in Operations. Toasts are reserved for user-triggered outcomes requiring timely
feedback. Realtime disconnection never blanks cached content; it marks it stale
and falls back to visibility-aware polling where necessary.
## Feature slices
1. Capture and validate existing HTTP contracts and critical legacy journeys.
2. Add the frontend build, application shell, authentication handling, routing,
error boundaries, design tokens, query client, and test harness.
3. Deliver Overview as the first production vertical slice.
4. Add the typed realtime event core, SSE transport, reconnection, and query
invalidation.
5. Migrate Conversations, including explicit suggestion and send mutations.
6. Migrate Media, preserving authentication and blur-by-default privacy.
7. Migrate Performance with sample sizes and low-confidence warnings.
8. Migrate Operations, access/login, and route-backed validated Settings.
9. Run feature parity and security audits, switch Express to the Vite output,
remove the legacy HTML, and update operator and deployment documentation.
Each slice must be usable independently and must not require a flag-day rewrite.
The legacy dashboard remains available until replacement behavior is proven.
## Engineering gates
- New behavior follows red-green-refactor with Vitest.
- Component tests use Testing Library and test observable behavior.
- HTTP tests use a controlled boundary such as MSW rather than mocking `fetch`
throughout feature tests.
- Contract tests prove representative current responses parse and malformed
payloads fail.
- Realtime tests cover ordering, replay, duplicate handling, reconnection, and
cache repair.
- Browser tests cover authentication, start/stop, conversation inspection,
sending confirmation, sync, settings persistence, and private media.
- Interactive controls are keyboard accessible and restore focus after dialogs.
- No production frontend or backend code uses `any` or `console.log`.
- Mutations display the confirmed server outcome and do not rely on optimistic
success for operational controls.
- Production builds are deterministic and the Docker image serves only generated
frontend assets plus authenticated private content.
## Cutover evidence
Before deleting `public/index.html`, record evidence for every legacy capability,
all API and SSE authentication paths, private media access, container restart and
SSE recovery, responsive navigation, frontend type-check/build, backend build,
the complete Vitest suite, and the critical Playwright journeys.
+2571 -310
View File
File diff suppressed because it is too large. Load diff
+18 -1
View File
@@ -5,7 +5,11 @@
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"build": "npm run build:server && npm run dashboard:build",
"build:server": "tsc",
"dashboard:build": "vite build --config apps/dashboard/vite.config.ts",
"dashboard:dev": "vite --config apps/dashboard/vite.config.ts",
"dashboard:typecheck": "tsc -p apps/dashboard/tsconfig.json --noEmit",
"start": "tsx src/index.ts",
"login": "tsx scripts/login.ts",
"dev": "tsx watch src/index.ts",
@@ -21,6 +25,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@tanstack/react-query": "^5.90.20",
"axios": "^1.13.2",
"better-sqlite3": "^12.5.0",
"dotenv": "^17.2.3",
@@ -29,16 +34,28 @@
"p-queue": "^9.0.1",
"p-retry": "^7.1.1",
"playwright": "^1.57.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1",
"winston": "^3.19.0",
"yaml": "^2.8.2",
"zod": "^4.3.5"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.6",
"@types/node": "^25.0.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"jsdom": "^28.1.0",
"msw": "^2.12.10",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.1.7"
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@reddit-bot/api-contracts",
"version": "0.1.0",
"private": true,
"type": "module",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json"
},
"peerDependencies": {
"zod": "^4.3.5"
}
}
+21
View File
@@ -0,0 +1,21 @@
import { z } from 'zod';
export const ActivityTypeSchema = z.union([
z.literal('sent'),
z.literal('skipped'),
z.literal('response'),
z.literal('error'),
z.literal('info'),
z.literal('debug'),
]);
export const ActivityItemSchema = z.strictObject({
timestamp: z.number(),
type: ActivityTypeSchema,
message: z.string(),
recipient: z.string().optional(),
imageUrl: z.string().optional(),
});
export const ActivityResponseSchema = z.array(ActivityItemSchema);
export type ActivityItem = z.infer<typeof ActivityItemSchema>;
+95
View File
@@ -0,0 +1,95 @@
import { z } from 'zod';
import { ErrorResponseSchema, RejectedActionResponseSchema } from './common.js';
export const ChatMediaSchema = z.strictObject({
originalUrl: z.string(),
localPath: z.string(),
fileName: z.string(),
});
export const ChatMessageSchema = z.strictObject({
sender: z.string(),
text: z.string(),
timestamp: z.number(),
isSelf: z.boolean(),
media: z.array(ChatMediaSchema).optional(),
});
export const ActiveChatSchema = z.strictObject({
username: z.string(),
roomName: z.string(),
lastMessageTime: z.number(),
lastMessageSnippet: z.string(),
isFromUs: z.boolean(),
unread: z.boolean(),
url: z.string().optional(),
roomId: z.string().optional(),
messages: z.array(ChatMessageSchema).optional(),
originMessage: z.string().optional(),
});
export const ChatsResponseSchema = z.array(ActiveChatSchema);
export const ConversationsResponseSchema = ChatsResponseSchema;
export const SyncKindSchema = z.union([z.literal('full'), z.literal('delta')]);
export const SyncPhaseSchema = z.union([
z.literal('starting'),
z.literal('seeding'),
z.literal('listing-rooms'),
z.literal('resolving-usernames'),
z.literal('loading-details'),
z.literal('finalizing'),
z.literal('done'),
]);
export const SyncProgressSchema = z.strictObject({
kind: SyncKindSchema,
phase: SyncPhaseSchema,
current: z.number(),
total: z.number(),
label: z.string().optional(),
startedAt: z.number(),
});
export const ChatSyncStatusSchema = z.strictObject({
syncing: z.boolean(),
syncingKind: SyncKindSchema.nullable(),
lastFullSyncAt: z.number().nullable(),
lastDeltaSyncAt: z.number().nullable(),
hasEverFullSynced: z.boolean(),
cachedChats: z.number(),
progress: SyncProgressSchema.nullable(),
});
export const ConversationDetailResponseSchema = ActiveChatSchema.extend({
triggeringPost: z.strictObject({
id: z.string(),
subreddit: z.string().optional(),
title: z.string().optional(),
url: z.string(),
sentAt: z.number(),
}).nullable(),
syncInProgress: z.strictObject({
kind: SyncKindSchema.nullable(),
progress: SyncProgressSchema.nullable(),
staleMessages: z.boolean(),
}).nullable(),
});
export const SuggestedReplyResponseSchema = z.strictObject({ suggestedReply: z.string() });
export const SendReplyRequestSchema = z.strictObject({ message: z.string().min(1) });
export const StartConversationRequestSchema = z.strictObject({
username: z.string().min(1),
message: z.string().min(1),
});
export const StartConversationResponseSchema = z.strictObject({
success: z.literal(true),
chat: ActiveChatSchema,
});
export const ChatSyncTriggerResponseSchema = z.union([
z.strictObject({ success: z.literal(true), kind: SyncKindSchema }),
RejectedActionResponseSchema,
ErrorResponseSchema,
]);
export const FetchAllChatsResponseSchema = z.strictObject({
success: z.literal(true),
count: z.number(),
});
export type ActiveChat = z.infer<typeof ActiveChatSchema>;
export type ChatSyncStatus = z.infer<typeof ChatSyncStatusSchema>;
export type ConversationDetailResponse = z.infer<typeof ConversationDetailResponseSchema>;
+20
View File
@@ -0,0 +1,20 @@
import { z } from 'zod';
export const ErrorResponseSchema = z.strictObject({
error: z.string(),
detail: z.unknown().optional(),
});
export const SuccessResponseSchema = z.strictObject({ success: z.literal(true) });
export const ControlResponseSchema = z.strictObject({
success: z.literal(true),
running: z.boolean(),
});
export const RejectedActionResponseSchema = z.strictObject({
success: z.literal(false),
reason: z.string(),
});
export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
export type SuccessResponse = z.infer<typeof SuccessResponseSchema>;
export type ControlResponse = z.infer<typeof ControlResponseSchema>;
+53
View File
@@ -0,0 +1,53 @@
import { z } from 'zod';
export const ToneKeySchema = z.union([
z.literal('default'),
z.literal('dominant'),
z.literal('submissive'),
z.literal('playful'),
z.literal('direct'),
z.literal('gentle'),
z.literal('kink-friendly'),
z.literal('teasing'),
]);
export const RateLimitConfigSchema = z.strictObject({
minDelayBetweenMessagesMs: z.number(),
maxMessagesPerHour: z.number(),
jitterMs: z.number(),
});
export const RuntimeRedditConfigSchema = z.strictObject({
prioritizeRecentPosts: z.boolean(),
recentWindowMinutes: z.number(),
});
export const RuntimeConfigSchema = z.strictObject({
rateLimit: RateLimitConfigSchema,
browser: z.strictObject({ dryRun: z.boolean() }),
reddit: RuntimeRedditConfigSchema,
});
export const SubredditConfigSchema = z.strictObject({
name: z.string(),
type: z.union([z.literal('image'), z.literal('chat')]),
weight: z.number().optional(),
tone: ToneKeySchema,
scripted: z.string().optional(),
});
export const ConfigResponseSchema = RuntimeConfigSchema.extend({
subreddits: z.array(SubredditConfigSchema),
tones: z.array(ToneKeySchema),
});
export const ConfigUpdateRequestSchema = z.strictObject({
rateLimit: RateLimitConfigSchema.partial().optional(),
browser: z.strictObject({ dryRun: z.boolean().optional() }).optional(),
reddit: RuntimeRedditConfigSchema.partial().optional(),
subreddits: z.array(SubredditConfigSchema).min(1).optional(),
});
export const ConfigUpdateResponseSchema = z.strictObject({
success: z.literal(true),
config: RuntimeConfigSchema,
subreddits: z.array(SubredditConfigSchema),
});
export type RuntimeConfig = z.infer<typeof RuntimeConfigSchema>;
export type ConfigResponse = z.infer<typeof ConfigResponseSchema>;
export type ConfigUpdateRequest = z.infer<typeof ConfigUpdateRequestSchema>;
+174
View File
@@ -0,0 +1,174 @@
import { z } from 'zod';
import { SyncProgressSchema } from './chats.js';
const EventBase = {
id: z.string(),
occurredAt: z.string().datetime({ offset: true }),
};
export const BotRuntimeStatusSchema = z.union([
z.literal('starting'),
z.literal('running'),
z.literal('stopping'),
z.literal('stopped'),
]);
export const ConversationStatusSchema = z.union([
z.literal('new'),
z.literal('awaiting-reply'),
z.literal('replied'),
z.literal('closed'),
]);
export const RealtimeSyncKindSchema = z.union([
z.literal('media'),
z.literal('messages'),
z.literal('conversations'),
z.literal('full'),
]);
export const RealtimeRedditSessionStatusSchema = z.union([
z.literal('unknown'),
z.literal('authenticating'),
z.literal('authenticated'),
z.literal('expired'),
z.literal('logged-out'),
z.literal('error'),
]);
export const DashboardEventSchema = z.discriminatedUnion('type', [
z.strictObject({
...EventBase,
type: z.literal('bot.status.changed'),
payload: z.strictObject({
status: BotRuntimeStatusSchema,
dryRun: z.boolean(),
reason: z.string().optional(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('message.sent'),
payload: z.strictObject({
messageId: z.string(),
recipient: z.string(),
conversationId: z.string().optional(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('message.failed'),
payload: z.strictObject({
recipient: z.string(),
reason: z.string(),
messageId: z.string().optional(),
conversationId: z.string().optional(),
code: z.string().optional(),
retryable: z.boolean().optional(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('conversation.updated'),
payload: z.strictObject({
conversationId: z.string(),
status: ConversationStatusSchema,
unreadCount: z.number().optional(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('reply.received'),
payload: z.strictObject({
conversationId: z.string(),
replyId: z.string(),
sender: z.string(),
preview: z.string().optional(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('sync.started'),
payload: z.strictObject({
syncId: z.string(),
kind: RealtimeSyncKindSchema,
}),
}),
z.strictObject({
...EventBase,
type: z.literal('sync.progress'),
payload: z.strictObject({
syncId: z.string(),
kind: RealtimeSyncKindSchema,
completed: z.number(),
total: z.number().optional(),
message: z.string().optional(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('sync.completed'),
payload: z.strictObject({
syncId: z.string(),
kind: RealtimeSyncKindSchema,
processed: z.number(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('sync.failed'),
payload: z.strictObject({
syncId: z.string(),
kind: RealtimeSyncKindSchema,
reason: z.string(),
code: z.string().optional(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('rate-limit.changed'),
payload: z.strictObject({
limited: z.boolean(),
remaining: z.number().optional(),
resetsAt: z.string().optional(),
reason: z.string().optional(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('reddit.session.changed'),
payload: z.strictObject({
status: RealtimeRedditSessionStatusSchema,
username: z.string().optional(),
reason: z.string().optional(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('log.created'),
payload: z.strictObject({
level: z.union([z.literal('debug'), z.literal('info'), z.literal('warn'), z.literal('error')]),
message: z.string(),
data: z.unknown().optional(),
}),
}),
]);
/** Existing `/api/logs/stream` frames, retained for an incremental migration. */
export const LegacyDashboardStreamEventSchema = z.discriminatedUnion('type', [
z.strictObject({
type: z.literal('connected'),
timestamp: z.number(),
}),
z.strictObject({
type: z.literal('log'),
timestamp: z.number(),
level: z.union([z.literal('info'), z.literal('warn'), z.literal('error'), z.literal('debug')]),
message: z.string(),
data: z.unknown().optional(),
}),
z.strictObject({
type: z.literal('sync-progress'),
progress: SyncProgressSchema.nullable(),
}),
]);
export type DashboardEvent = z.infer<typeof DashboardEventSchema>;
export type LegacyDashboardStreamEvent = z.infer<typeof LegacyDashboardStreamEventSchema>;
+9
View File
@@ -0,0 +1,9 @@
export * from './activity.js';
export * from './chats.js';
export * from './common.js';
export * from './config.js';
export * from './events.js';
export * from './media.js';
export * from './messages.js';
export * from './session.js';
export * from './stats.js';
+32
View File
@@ -0,0 +1,32 @@
import { z } from 'zod';
import { RejectedActionResponseSchema, SuccessResponseSchema } from './common.js';
export const MediaItemSchema = z.strictObject({
id: z.string(),
chatUsername: z.string().nullable(),
chatRoomId: z.string().nullable(),
sender: z.string(),
timestamp: z.number(),
originalUrl: z.string(),
fileName: z.string(),
relativePath: z.string(),
createdAt: z.number(),
urlPath: z.string(),
});
export const MediaResponseSchema = z.array(MediaItemSchema);
export const MediaSyncStatusSchema = z.strictObject({
isSyncing: z.boolean(),
lastSyncAt: z.number().nullable(),
lastSyncDurationMs: z.number().nullable(),
lastSyncChats: z.number().nullable(),
lastError: z.string().nullable(),
nextSyncAt: z.number().nullable(),
});
export const MediaSyncRequestSchema = z.strictObject({ full: z.boolean().optional() });
export const MediaSyncTriggerResponseSchema = z.union([
SuccessResponseSchema,
RejectedActionResponseSchema,
]);
export type MediaItem = z.infer<typeof MediaItemSchema>;
export type MediaSyncStatus = z.infer<typeof MediaSyncStatusSchema>;
+34
View File
@@ -0,0 +1,34 @@
import { z } from 'zod';
export const SentMessageSchema = z.strictObject({
id: z.number(),
postId: z.string().nullable(),
recipient: z.string(),
subject: z.string(),
message: z.string(),
sentAt: z.number(),
gotResponse: z.boolean(),
responseCheckedAt: z.number().nullable(),
strategyId: z.string().nullable(),
chatRoomId: z.string().nullable(),
subreddit: z.string().optional(),
postTitle: z.string().optional(),
postImageUrl: z.string().optional(),
postImageAnalysis: z.string().optional(),
});
export const MessagesResponseSchema = z.array(SentMessageSchema);
export const SuccessfulExampleSchema = z.strictObject({
id: z.number(),
sentMessageId: z.number().nullable(),
originalMessage: z.string(),
imageAnalysis: z.string().nullable(),
postTitle: z.string().nullable(),
responseSnippet: z.string().nullable(),
subreddit: z.string().nullable(),
createdAt: z.number(),
});
export const SuccessfulExamplesResponseSchema = z.array(SuccessfulExampleSchema);
export type SentMessage = z.infer<typeof SentMessageSchema>;
export type SuccessfulExample = z.infer<typeof SuccessfulExampleSchema>;
+34
View File
@@ -0,0 +1,34 @@
import { z } from 'zod';
export const RedditSessionStateSchema = z.union([
z.literal('unknown'),
z.literal('authenticated'),
z.literal('login-required'),
z.literal('unreachable'),
]);
export const LoginInfoResponseSchema = z.strictObject({
vncEnabled: z.boolean(),
novncPort: z.number(),
publicUrl: z.string().nullable(),
passwordRequired: z.boolean(),
});
export const LoginStatusResponseSchema = z.union([
z.strictObject({ ready: z.literal(false), error: z.string() }),
z.strictObject({
ready: z.literal(true),
loggedIn: z.boolean(),
status: RedditSessionStateSchema,
}),
z.strictObject({
ready: z.literal(true),
loggedIn: z.literal(false),
error: z.string(),
}),
]);
export const LoginStartResponseSchema = z.union([
z.strictObject({ ok: z.literal(true) }),
z.strictObject({ ok: z.literal(false), error: z.string() }),
]);
export type RedditSessionState = z.infer<typeof RedditSessionStateSchema>;
export type LoginStatusResponse = z.infer<typeof LoginStatusResponseSchema>;
+59
View File
@@ -0,0 +1,59 @@
import { z } from 'zod';
import { ActivityItemSchema } from './activity.js';
const PerformanceFields = {
totalSent: z.number(),
totalResponses: z.number(),
responseRate: z.number(),
};
export const StatsResponseSchema = z.strictObject({
isRunning: z.boolean(),
dryRunMode: z.boolean(),
totalProcessed: z.number(),
totalMessagesSent: z.number(),
totalResponses: z.number(),
responseRate: z.number(),
messagesThisHour: z.number(),
maxMessagesPerHour: z.number(),
subreddits: z.array(z.strictObject({
name: z.string(),
type: z.union([z.literal('image'), z.literal('chat')]),
})),
targetGenders: z.array(z.string()),
recentActivity: z.array(ActivityItemSchema),
invitationCooldown: z.strictObject({
until: z.number(),
reason: z.string(),
redditCode: z.string().nullable(),
}).nullable(),
});
export const TimersResponseSchema = z.strictObject({
nextMessageAvailableAt: z.number().nullable(),
hourlyResetAt: z.number(),
isHourlyLimitReached: z.boolean(),
messagesRemaining: z.number(),
});
export const SubredditPerformanceSchema = z.strictObject({
subreddit: z.string(),
...PerformanceFields,
});
export const SubredditPerformanceResponseSchema = z.array(SubredditPerformanceSchema);
export const StrategyPerformanceSchema = z.strictObject({
strategyId: z.string(),
...PerformanceFields,
configured: z.boolean().optional(),
});
export const StrategyPerformanceResponseSchema = z.array(StrategyPerformanceSchema);
export const DailyTrendSchema = z.strictObject({
date: z.string(),
...PerformanceFields,
});
export const DailyTrendResponseSchema = z.array(DailyTrendSchema);
export type StatsResponse = z.infer<typeof StatsResponseSchema>;
export type TimersResponse = z.infer<typeof TimersResponseSchema>;
@@ -0,0 +1,226 @@
import { describe, expect, it } from 'vitest';
import {
ActivityResponseSchema,
ChatSyncStatusSchema,
ChatsResponseSchema,
ChatSyncTriggerResponseSchema,
ConfigResponseSchema,
ConfigUpdateRequestSchema,
ControlResponseSchema,
ConversationDetailResponseSchema,
ConversationsResponseSchema,
DashboardEventSchema,
ErrorResponseSchema,
LoginInfoResponseSchema,
LoginStatusResponseSchema,
LegacyDashboardStreamEventSchema,
MediaResponseSchema,
MediaSyncStatusSchema,
MessagesResponseSchema,
StatsResponseSchema,
StrategyPerformanceResponseSchema,
SubredditPerformanceResponseSchema,
DailyTrendResponseSchema,
SuccessfulExamplesResponseSchema,
TimersResponseSchema,
} from '../src/index.js';
const activity = {
timestamp: 1_700_000_000_000,
type: 'sent',
message: 'Sent message',
recipient: 'alice',
};
const chat = {
username: 'alice',
roomName: 'Alice',
lastMessageTime: 1_700_000_000_000,
lastMessageSnippet: 'hello',
isFromUs: false,
unread: true,
roomId: '!room:reddit.com',
messages: [{ sender: 'alice', text: 'hello', timestamp: 1_700_000_000_000, isSelf: false }],
};
describe('dashboard endpoint contracts', () => {
it('accepts current stats and rejects malformed stats', () => {
const payload = {
isRunning: true,
dryRunMode: false,
totalProcessed: 10,
totalMessagesSent: 8,
totalResponses: 3,
responseRate: 38,
messagesThisHour: 2,
maxMessagesPerHour: 8,
subreddits: [{ name: 'example', type: 'image' }],
targetGenders: ['female'],
recentActivity: [activity],
invitationCooldown: null,
};
expect(StatsResponseSchema.parse(payload)).toEqual(payload);
expect(StatsResponseSchema.safeParse({ ...payload, isRunning: 'yes' }).success).toBe(false);
});
it('validates authoritative start and stop responses', () => {
expect(ControlResponseSchema.safeParse({ success: true, running: true }).success).toBe(true);
expect(ControlResponseSchema.safeParse({ success: true, running: false }).success).toBe(true);
expect(ControlResponseSchema.safeParse({ success: true }).success).toBe(false);
expect(ControlResponseSchema.safeParse({ success: false, running: true }).success).toBe(false);
});
it('validates timer, activity, message, and successful-example payloads', () => {
expect(TimersResponseSchema.safeParse({
nextMessageAvailableAt: null,
hourlyResetAt: 1_700_000_100_000,
isHourlyLimitReached: false,
messagesRemaining: 6,
}).success).toBe(true);
expect(ActivityResponseSchema.safeParse([activity]).success).toBe(true);
expect(MessagesResponseSchema.safeParse([{
id: 1,
postId: 'post-1',
recipient: 'alice',
subject: 'Hi',
message: 'Hello',
sentAt: 1_700_000_000_000,
gotResponse: false,
responseCheckedAt: null,
strategyId: null,
chatRoomId: null,
}]).success).toBe(true);
expect(SuccessfulExamplesResponseSchema.safeParse([{
id: 1,
sentMessageId: null,
originalMessage: 'Hello',
imageAnalysis: null,
postTitle: null,
responseSnippet: 'Hi',
subreddit: 'example',
createdAt: 1_700_000_000_000,
}]).success).toBe(true);
expect(TimersResponseSchema.safeParse({ hourlyResetAt: 'later' }).success).toBe(false);
expect(ActivityResponseSchema.safeParse([{ ...activity, type: 'bogus' }]).success).toBe(false);
expect(MessagesResponseSchema.safeParse([{ id: '1' }]).success).toBe(false);
});
it('validates each analytics response shape', () => {
expect(SubredditPerformanceResponseSchema.safeParse([{
subreddit: 'example', totalSent: 10, totalResponses: 4, responseRate: 40,
}]).success).toBe(true);
expect(StrategyPerformanceResponseSchema.safeParse([{
strategyId: 'direct', totalSent: 10, totalResponses: 4, responseRate: 40, configured: true,
}]).success).toBe(true);
expect(DailyTrendResponseSchema.safeParse([{
date: '2026-08-08', totalSent: 10, totalResponses: 4, responseRate: 40,
}]).success).toBe(true);
expect(DailyTrendResponseSchema.safeParse([{ date: '2026-08-08', totalSent: '10' }]).success).toBe(false);
});
it('validates runtime config returned by the dashboard', () => {
const payload = {
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
browser: { dryRun: false },
reddit: { prioritizeRecentPosts: true, recentWindowMinutes: 60 },
subreddits: [{ name: 'example', type: 'chat', weight: 2, tone: 'direct' }],
tones: ['default', 'direct'],
};
expect(ConfigResponseSchema.parse(payload)).toEqual(payload);
expect(ConfigResponseSchema.safeParse({ ...payload, tones: [1] }).success).toBe(false);
expect(ConfigUpdateRequestSchema.safeParse({
rateLimit: { maxMessagesPerHour: 10 }, browser: { dryRun: true },
}).success).toBe(true);
expect(ConfigUpdateRequestSchema.safeParse({ browser: { dryRun: 'yes' } }).success).toBe(false);
});
it('validates chats, sync state, conversation detail, and malformed messages', () => {
expect(ChatsResponseSchema.safeParse([chat]).success).toBe(true);
expect(ConversationsResponseSchema.safeParse([chat]).success).toBe(true);
expect(ChatSyncStatusSchema.safeParse({
syncing: true,
syncingKind: 'full',
lastFullSyncAt: null,
lastDeltaSyncAt: 1_700_000_000_000,
hasEverFullSynced: false,
cachedChats: 12,
progress: {
kind: 'full', phase: 'loading-details', current: 3, total: 12,
label: 'alice', startedAt: 1_700_000_000_000,
},
}).success).toBe(true);
expect(ConversationDetailResponseSchema.safeParse({
...chat,
triggeringPost: {
id: 'post-1', subreddit: 'example', title: 'Example',
url: 'https://www.reddit.com/r/example/comments/post-1/', sentAt: 1_700_000_000_000,
},
syncInProgress: null,
}).success).toBe(true);
expect(ChatsResponseSchema.safeParse([{ ...chat, messages: [{ sender: 'alice' }] }]).success).toBe(false);
expect(ChatSyncTriggerResponseSchema.safeParse({ success: true, kind: 'delta' }).success).toBe(true);
expect(ChatSyncTriggerResponseSchema.safeParse({ success: false, reason: 'Already running full sync' }).success).toBe(true);
});
it('validates media items and media sync status', () => {
expect(MediaResponseSchema.safeParse([{
id: 'sha1', chatUsername: 'alice', chatRoomId: '!room:reddit.com', sender: 'alice',
timestamp: 1_700_000_000_000, originalUrl: 'https://example.com/a.jpg', fileName: 'a.jpg',
relativePath: 'a.jpg', createdAt: 1_700_000_000_100, urlPath: '/media/a.jpg',
}]).success).toBe(true);
expect(MediaSyncStatusSchema.safeParse({
isSyncing: false, lastSyncAt: null, lastSyncDurationMs: null,
lastSyncChats: null, lastError: null, nextSyncAt: null,
}).success).toBe(true);
expect(MediaResponseSchema.safeParse([{ id: 'sha1', timestamp: 'now' }]).success).toBe(false);
});
it('validates login info and every current login-status branch', () => {
expect(LoginInfoResponseSchema.safeParse({
vncEnabled: true, novncPort: 6080, publicUrl: null, passwordRequired: true,
}).success).toBe(true);
expect(LoginStatusResponseSchema.safeParse({
ready: true, loggedIn: true, status: 'authenticated',
}).success).toBe(true);
expect(LoginStatusResponseSchema.safeParse({ ready: false, error: 'browser not initialised' }).success).toBe(true);
expect(LoginStatusResponseSchema.safeParse({ ready: true, loggedIn: false, error: 'check failed' }).success).toBe(true);
expect(LoginStatusResponseSchema.safeParse({ ready: true, loggedIn: true, status: 'expired' }).success).toBe(false);
});
it('validates the common error response and typed realtime event envelope', () => {
expect(ErrorResponseSchema.safeParse({ error: 'Conversation not found' }).success).toBe(true);
const occurredAt = '2026-08-08T13:55:00.000Z';
const events = [
{ id: '1', occurredAt, type: 'bot.status.changed', payload: { status: 'running', dryRun: false, reason: 'startup complete' } },
{ id: '2', occurredAt, type: 'message.sent', payload: { messageId: 'm1', recipient: 'alice', conversationId: 'c1' } },
{ id: '3', occurredAt, type: 'message.failed', payload: { recipient: 'alice', reason: 'rate limited', messageId: 'm2', conversationId: 'c1', code: 'limited', retryable: true } },
{ id: '4', occurredAt, type: 'conversation.updated', payload: { conversationId: 'c1', status: 'replied', unreadCount: 1 } },
{ id: '5', occurredAt, type: 'reply.received', payload: { conversationId: 'c1', replyId: 'r1', sender: 'alice', preview: 'Hi' } },
{ id: '6', occurredAt, type: 'sync.started', payload: { syncId: 's1', kind: 'media' } },
{ id: '7', occurredAt, type: 'sync.progress', payload: { syncId: 's1', kind: 'media', completed: 3, total: 10, message: 'Downloading' } },
{ id: '8', occurredAt, type: 'sync.completed', payload: { syncId: 's1', kind: 'media', processed: 10 } },
{ id: '9', occurredAt, type: 'sync.failed', payload: { syncId: 's1', kind: 'media', reason: 'network', code: 'offline' } },
{ id: '10', occurredAt, type: 'rate-limit.changed', payload: { limited: true, remaining: 0, resetsAt: occurredAt, reason: 'hourly' } },
{ id: '11', occurredAt, type: 'reddit.session.changed', payload: { status: 'authenticated', username: 'bot-user', reason: 'login completed' } },
{ id: '12', occurredAt, type: 'log.created', payload: { level: 'info', message: 'Started', data: { port: 3000 } } },
];
for (const event of events) {
expect(DashboardEventSchema.safeParse(event).success, event.type).toBe(true);
}
expect(DashboardEventSchema.safeParse({
id: 'event-13', type: 'log.created', occurredAt: 1_700_000_000_000,
payload: { level: 'info', message: 'Started' },
}).success).toBe(false);
expect(DashboardEventSchema.safeParse({
id: 'event-14', type: 'sync.completed', occurredAt,
payload: { syncId: 's1', kind: 'media' },
}).success).toBe(false);
expect(LegacyDashboardStreamEventSchema.safeParse({
type: 'connected', timestamp: 1_700_000_000_000,
}).success).toBe(true);
expect(LegacyDashboardStreamEventSchema.safeParse({
type: 'sync-progress', progress: null,
}).success).toBe(true);
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
root: import.meta.dirname,
test: {
include: ['tests/**/*.test.ts'],
},
});
+34
View File
@@ -0,0 +1,34 @@
import fs from 'node:fs';
import path from 'node:path';
export interface DashboardAssets {
directory: string;
indexFile: string;
isLegacy: boolean;
}
export type FileExists = (filePath: string) => boolean;
export function resolveDashboardAssets(
workspaceDirectory: string,
fileExists: FileExists = fs.existsSync,
): DashboardAssets {
const reactDirectory = path.join(workspaceDirectory, 'apps', 'dashboard', 'dist');
const reactIndexFile = path.join(reactDirectory, 'index.html');
if (fileExists(reactIndexFile)) {
return {
directory: reactDirectory,
indexFile: reactIndexFile,
isLegacy: false,
};
}
const legacyDirectory = path.join(workspaceDirectory, 'public');
return {
directory: legacyDirectory,
indexFile: path.join(legacyDirectory, 'index.html'),
isLegacy: true,
};
}
+35
View File
@@ -0,0 +1,35 @@
export interface EventClock {
now(): Date;
}
export interface EventIdGenerator {
next(occurredAt: Date): string;
}
/** The subset of Winston's logger contract used by the event hub. */
export interface EventHubLogger {
warn(message: string, metadata?: Record<string, unknown>): unknown;
}
export const systemEventClock: EventClock = {
now: () => new Date(),
};
/**
* Generates lexically sortable IDs and remains monotonic if the wall clock
* moves backwards. The per-process sequence disambiguates events in one ms.
*/
export class MonotonicEventIdGenerator implements EventIdGenerator {
private lastTimestamp = 0;
private sequence = 0;
next(occurredAt: Date): string {
const timestamp = Math.max(occurredAt.getTime(), this.lastTimestamp);
this.sequence = timestamp === this.lastTimestamp ? this.sequence + 1 : 0;
this.lastTimestamp = timestamp;
return `${timestamp.toString().padStart(13, '0')}-${this.sequence
.toString()
.padStart(6, '0')}`;
}
}
+101
View File
@@ -0,0 +1,101 @@
export type BotRuntimeStatus = 'starting' | 'running' | 'stopping' | 'stopped';
export type ConversationStatus =
| 'new'
| 'awaiting-reply'
| 'replied'
| 'closed';
export type SyncKind = 'media' | 'messages' | 'conversations' | 'full';
export type RedditSessionStatus =
| 'unknown'
| 'authenticating'
| 'authenticated'
| 'expired'
| 'logged-out'
| 'error';
export interface RealtimeEventMap {
'bot.status.changed': {
status: BotRuntimeStatus;
dryRun: boolean;
reason?: string;
};
'message.sent': {
messageId: string;
recipient: string;
conversationId?: string;
};
'message.failed': {
recipient: string;
reason: string;
messageId?: string;
conversationId?: string;
code?: string;
retryable?: boolean;
};
'conversation.updated': {
conversationId: string;
status: ConversationStatus;
unreadCount?: number;
};
'reply.received': {
conversationId: string;
replyId: string;
sender: string;
preview?: string;
};
'sync.started': {
syncId: string;
kind: SyncKind;
};
'sync.progress': {
syncId: string;
kind: SyncKind;
completed: number;
total?: number;
message?: string;
};
'sync.completed': {
syncId: string;
kind: SyncKind;
processed: number;
};
'sync.failed': {
syncId: string;
kind: SyncKind;
reason: string;
code?: string;
};
'rate-limit.changed': {
limited: boolean;
remaining?: number;
resetsAt?: string;
reason?: string;
};
'reddit.session.changed': {
status: RedditSessionStatus;
username?: string;
reason?: string;
};
'log.created': {
level: 'debug' | 'info' | 'warn' | 'error';
message: string;
data?: unknown;
};
}
export type RealtimeEventType = keyof RealtimeEventMap;
/** A distributive type so the default is a discriminated union. */
export type RealtimeEvent<
TType extends RealtimeEventType = RealtimeEventType,
> = TType extends RealtimeEventType
? Readonly<{
id: string;
occurredAt: string;
type: TType;
payload: Readonly<RealtimeEventMap[TType]>;
}>
: never;
+223
View File
@@ -0,0 +1,223 @@
import type { RequestHandler, Response } from 'express';
import {
systemEventClock,
type EventClock,
} from './event-dependencies.js';
import type { RealtimeEvent } from './event-types.js';
import type {
RealtimeEventHub,
RealtimeEventSubscription,
} from './realtime-event-hub.js';
const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000;
const DEFAULT_MAX_PENDING_FRAMES = 100;
export interface RecurringTask {
cancel(): void;
}
export interface RecurringTaskScheduler {
every(intervalMs: number, callback: () => void): RecurringTask;
}
export const systemRecurringTaskScheduler: RecurringTaskScheduler = {
every(intervalMs, callback) {
const timer = setInterval(callback, intervalMs);
timer.unref();
return { cancel: () => clearInterval(timer) };
},
};
export interface ExpressSseHandlerOptions {
hub: RealtimeEventHub;
scheduler?: RecurringTaskScheduler;
clock?: EventClock;
heartbeatIntervalMs?: number;
maxPendingFrames?: number;
}
function isResponseClosed(response: Response): boolean {
return response.destroyed || response.writableEnded;
}
function serializeEvent(event: RealtimeEvent): string {
const safeId = event.id.replace(/[\r\n\0]/g, '');
return `id: ${safeId}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
}
/**
* Adapts the transport-agnostic realtime hub to an Express SSE endpoint.
* The returned handler owns only the lifetime of its individual connection.
*/
export function createExpressSseHandler(
options: ExpressSseHandlerOptions,
): RequestHandler {
const heartbeatIntervalMs =
options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
const maxPendingFrames =
options.maxPendingFrames ?? DEFAULT_MAX_PENDING_FRAMES;
if (!Number.isInteger(heartbeatIntervalMs) || heartbeatIntervalMs <= 0) {
throw new RangeError('heartbeatIntervalMs must be a positive integer');
}
if (!Number.isInteger(maxPendingFrames) || maxPendingFrames <= 0) {
throw new RangeError('maxPendingFrames must be a positive integer');
}
const scheduler = options.scheduler ?? systemRecurringTaskScheduler;
const clock = options.clock ?? systemEventClock;
return (request, response): void => {
if (isResponseClosed(response)) {
return;
}
let active = true;
let waitingForDrain = false;
let subscription: RealtimeEventSubscription | undefined;
let heartbeat: RecurringTask | undefined;
const pendingFrames: string[] = [];
const removeConnectionListeners = (): void => {
request.removeListener('close', handleConnectionClose);
response.removeListener('close', handleConnectionClose);
response.removeListener('drain', drainPendingFrames);
};
const close = (endResponse: boolean): void => {
if (!active) {
return;
}
active = false;
waitingForDrain = false;
pendingFrames.length = 0;
removeConnectionListeners();
heartbeat?.cancel();
subscription?.unsubscribe();
if (endResponse && !isResponseClosed(response)) {
try {
response.end();
} catch {
// The socket is already unusable; all owned resources are released.
}
}
};
const handleConnectionClose = (): void => close(false);
const waitForDrain = (): void => {
if (active && !waitingForDrain) {
waitingForDrain = true;
response.once('drain', drainPendingFrames);
}
};
const writeFrame = (frame: string): boolean => {
if (!active || isResponseClosed(response)) {
close(false);
return false;
}
try {
if (!response.write(frame)) {
waitForDrain();
}
return true;
} catch {
close(true);
return false;
}
};
const enqueueOrWrite = (frame: string): void => {
if (!active) {
return;
}
if (!waitingForDrain) {
writeFrame(frame);
return;
}
if (pendingFrames.length >= maxPendingFrames) {
close(true);
return;
}
pendingFrames.push(frame);
};
function drainPendingFrames(): void {
if (!active || isResponseClosed(response)) {
close(false);
return;
}
waitingForDrain = false;
while (active && !waitingForDrain && pendingFrames.length > 0) {
const frame = pendingFrames.shift();
if (frame !== undefined) {
writeFrame(frame);
}
}
}
request.once('close', handleConnectionClose);
response.once('close', handleConnectionClose);
try {
response.statusCode = 200;
response.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
response.setHeader('Cache-Control', 'no-cache, no-transform');
response.setHeader('Connection', 'keep-alive');
response.setHeader('X-Accel-Buffering', 'no');
response.flushHeaders();
} catch {
close(true);
return;
}
enqueueOrWrite(': connected\n\n');
if (!active) {
return;
}
try {
heartbeat = scheduler.every(heartbeatIntervalMs, () => {
if (!active || waitingForDrain) {
return;
}
try {
enqueueOrWrite(`: heartbeat ${clock.now().toISOString()}\n\n`);
} catch {
close(true);
}
});
} catch {
close(true);
return;
}
if (!active) {
heartbeat.cancel();
return;
}
try {
subscription = options.hub.subscribe(
(event) => {
if (!active) {
return;
}
try {
enqueueOrWrite(serializeEvent(event));
} catch {
close(true);
}
},
{ lastEventId: request.get('Last-Event-ID') },
);
} catch {
close(true);
return;
}
// Replay is synchronous, so a failed replay can close before assignment.
if (!active) {
subscription.unsubscribe();
}
};
}
+23
View File
@@ -0,0 +1,23 @@
export {
MonotonicEventIdGenerator,
systemEventClock,
type EventClock,
type EventHubLogger,
type EventIdGenerator,
} from './event-dependencies.js';
export {
type BotRuntimeStatus,
type ConversationStatus,
type RealtimeEvent,
type RealtimeEventMap,
type RealtimeEventType,
type RedditSessionStatus,
type SyncKind,
} from './event-types.js';
export {
RealtimeEventHub,
type RealtimeEventHubOptions,
type RealtimeEventListener,
type RealtimeEventSubscription,
type SubscribeOptions,
} from './realtime-event-hub.js';
+222
View File
@@ -0,0 +1,222 @@
import {
MonotonicEventIdGenerator,
systemEventClock,
type EventClock,
type EventHubLogger,
type EventIdGenerator,
} from './event-dependencies.js';
import {
type RealtimeEvent,
type RealtimeEventMap,
type RealtimeEventType,
} from './event-types.js';
import { ReplayBuffer, type Sequenced } from './replay-buffer.js';
export type RealtimeEventListener = (event: RealtimeEvent) => void;
export interface SubscribeOptions {
/** Value from the SSE `Last-Event-ID` request header. */
lastEventId?: string;
}
export interface RealtimeEventSubscription {
readonly closed: boolean;
unsubscribe(): void;
}
export interface RealtimeEventHubOptions {
capacity: number;
clock?: EventClock;
idGenerator?: EventIdGenerator;
logger?: EventHubLogger;
}
interface SubscriberState {
listener: RealtimeEventListener;
closed: boolean;
replaying: boolean;
lastDeliveredSequence: number;
queued: Sequenced<RealtimeEvent>[];
}
class HubSubscription implements RealtimeEventSubscription {
constructor(
private readonly state: SubscriberState,
private readonly remove: () => void,
) {}
get closed(): boolean {
return this.state.closed;
}
unsubscribe(): void {
if (this.state.closed) {
return;
}
this.state.closed = true;
this.remove();
}
}
export class RealtimeEventHub {
private readonly buffer: ReplayBuffer<RealtimeEvent>;
private readonly clock: EventClock;
private readonly idGenerator: EventIdGenerator;
private readonly logger?: EventHubLogger;
private readonly subscribers = new Set<SubscriberState>();
private readonly dispatchQueue: Sequenced<RealtimeEvent>[] = [];
private sequence = 0;
private dispatching = false;
private isClosed = false;
constructor(options: RealtimeEventHubOptions) {
this.buffer = new ReplayBuffer(options.capacity);
this.clock = options.clock ?? systemEventClock;
this.idGenerator = options.idGenerator ?? new MonotonicEventIdGenerator();
this.logger = options.logger;
}
get closed(): boolean {
return this.isClosed;
}
publish<TType extends RealtimeEventType>(
type: TType,
payload: RealtimeEventMap[TType],
): RealtimeEvent<TType> {
this.assertOpen();
const occurredAt = this.clock.now();
if (Number.isNaN(occurredAt.getTime())) {
throw new RangeError('clock returned an invalid date');
}
const id = this.idGenerator.next(occurredAt);
if (id.length === 0) {
throw new Error('idGenerator returned an empty event ID');
}
const event = Object.freeze({
id,
occurredAt: occurredAt.toISOString(),
type,
payload: Object.freeze({ ...payload }),
}) as RealtimeEvent<TType>;
const sequenced: Sequenced<RealtimeEvent> = {
sequence: ++this.sequence,
value: event,
};
this.buffer.push(sequenced);
this.dispatchQueue.push(sequenced);
this.drainDispatchQueue();
return event;
}
subscribe(
listener: RealtimeEventListener,
options: SubscribeOptions = {},
): RealtimeEventSubscription {
this.assertOpen();
const state: SubscriberState = {
listener,
closed: false,
replaying: true,
lastDeliveredSequence: 0,
queued: [],
};
this.subscribers.add(state);
const subscription = new HubSubscription(state, () => {
this.subscribers.delete(state);
state.queued = [];
});
for (const event of this.buffer.after(options.lastEventId)) {
this.deliver(state, event);
if (state.closed) {
return subscription;
}
}
state.replaying = false;
const queued = state.queued;
state.queued = [];
for (const event of queued) {
this.deliver(state, event);
if (state.closed) {
break;
}
}
return subscription;
}
recent(limit = this.buffer.capacity): readonly RealtimeEvent[] {
if (!Number.isInteger(limit) || limit < 0) {
throw new RangeError('limit must be a non-negative integer');
}
return limit === 0 ? [] : this.buffer.recent(limit);
}
close(): void {
if (this.isClosed) {
return;
}
this.isClosed = true;
for (const state of this.subscribers) {
state.closed = true;
state.queued = [];
}
this.subscribers.clear();
this.dispatchQueue.length = 0;
this.buffer.clear();
}
private drainDispatchQueue(): void {
if (this.dispatching) {
return;
}
this.dispatching = true;
try {
let next: Sequenced<RealtimeEvent> | undefined;
while ((next = this.dispatchQueue.shift()) !== undefined) {
for (const state of [...this.subscribers]) {
if (state.replaying) {
state.queued.push(next);
} else {
this.deliver(state, next);
}
}
}
} finally {
this.dispatching = false;
}
}
private deliver(
state: SubscriberState,
event: Sequenced<RealtimeEvent>,
): void {
if (state.closed || event.sequence <= state.lastDeliveredSequence) {
return;
}
state.lastDeliveredSequence = event.sequence;
try {
state.listener(event.value);
} catch (error: unknown) {
state.closed = true;
state.queued = [];
this.subscribers.delete(state);
try {
this.logger?.warn(
'Realtime event subscriber failed; subscription closed',
{ eventId: event.value.id, error },
);
} catch {
// A diagnostic logger must not break event delivery to healthy clients.
}
}
}
private assertOpen(): void {
if (this.isClosed) {
throw new Error('RealtimeEventHub is closed');
}
}
}
+42
View File
@@ -0,0 +1,42 @@
export interface Sequenced<TValue> {
readonly sequence: number;
readonly value: TValue;
}
export class ReplayBuffer<TValue extends { readonly id: string }> {
private values: Sequenced<TValue>[] = [];
constructor(readonly capacity: number) {
if (!Number.isInteger(capacity) || capacity <= 0) {
throw new RangeError('capacity must be a positive integer');
}
}
push(value: Sequenced<TValue>): void {
this.values.push(value);
if (this.values.length > this.capacity) {
this.values.shift();
}
}
after(lastEventId?: string): readonly Sequenced<TValue>[] {
if (lastEventId === undefined || lastEventId.length === 0) {
return [...this.values];
}
const matchingIndex = this.values.findIndex(
({ value }) => value.id === lastEventId,
);
return matchingIndex === -1
? [...this.values]
: this.values.slice(matchingIndex + 1);
}
recent(limit: number): readonly TValue[] {
return this.values.slice(-limit).map(({ value }) => value);
}
clear(): void {
this.values = [];
}
}
+20 -2
View File
@@ -1,5 +1,7 @@
import type { Request, Response } from 'express';
import type { Request, RequestHandler, Response } from 'express';
import type { LogEntry } from './types.js';
import { RealtimeEventHub } from './events/index.js';
import { createExpressSseHandler } from './events/express-sse-adapter.js';
const MAX_LOGS = 500;
const INITIAL_LOGS_ON_CONNECT = 50;
@@ -11,9 +13,24 @@ const INITIAL_LOGS_ON_CONNECT = 50;
export class LogBroadcaster {
private entries: LogEntry[] = [];
private clients = new Set<Response>();
private readonly eventHub: RealtimeEventHub;
readonly events: RequestHandler;
constructor(eventHub?: RealtimeEventHub) {
this.eventHub = eventHub ?? new RealtimeEventHub({ capacity: MAX_LOGS });
this.events = createExpressSseHandler({ hub: this.eventHub });
}
push(level: LogEntry['level'], message: string, data?: unknown): void {
const entry: LogEntry = { timestamp: Date.now(), level, message, data };
const realtimeEvent = this.eventHub.closed
? undefined
: this.eventHub.publish('log.created', { level, message, data });
const entry: LogEntry = {
timestamp: realtimeEvent ? Date.parse(realtimeEvent.occurredAt) : Date.now(),
level,
message,
data,
};
this.entries.push(entry);
if (this.entries.length > MAX_LOGS) {
this.entries = this.entries.slice(-MAX_LOGS);
@@ -76,5 +93,6 @@ export class LogBroadcaster {
}
}
this.clients.clear();
this.eventHub.close();
}
}
+1
View File
@@ -4,6 +4,7 @@ import type { WebContext } from '../context.js';
export function createLogsRouter(ctx: WebContext): Router {
const router = Router();
router.get('/logs/stream', ctx.broadcaster.stream);
router.get('/events/stream', ctx.broadcaster.events);
router.get('/logs', ctx.broadcaster.recent);
return router;
}
+27
View File
@@ -0,0 +1,27 @@
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { resolveDashboardAssets } from '../src/web/dashboard-assets.js';
describe('resolveDashboardAssets', () => {
it('selects the built React dashboard when its index exists', () => {
const workspace = path.resolve('workspace');
const reactIndex = path.join(workspace, 'apps', 'dashboard', 'dist', 'index.html');
const fileExists = vi.fn((candidate: string) => candidate === reactIndex);
expect(resolveDashboardAssets(workspace, fileExists)).toEqual({
directory: path.dirname(reactIndex),
indexFile: reactIndex,
isLegacy: false,
});
});
it('falls back to the legacy dashboard during incremental migration', () => {
const workspace = path.resolve('workspace');
expect(resolveDashboardAssets(workspace, () => false)).toEqual({
directory: path.join(workspace, 'public'),
indexFile: path.join(workspace, 'public', 'index.html'),
isLegacy: true,
});
});
});
+198
View File
@@ -0,0 +1,198 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { createDashboardRoutes } from '../../apps/dashboard/src/app/router.js';
interface TestStatsOverrides {
isRunning?: boolean;
recentActivity?: readonly unknown[];
}
const server = setupServer();
function createStats(overrides: TestStatsOverrides = {}) {
return {
isRunning: overrides.isRunning ?? false,
dryRunMode: true,
totalProcessed: 18,
totalMessagesSent: 12,
totalResponses: 3,
responseRate: 25,
messagesThisHour: 2,
maxMessagesPerHour: 8,
subreddits: [{ name: 'casualconversation', type: 'chat' }],
targetGenders: ['female'],
recentActivity: overrides.recentActivity ?? [],
invitationCooldown: null,
};
}
function installSuccessfulApi(overrides: TestStatsOverrides = {}) {
server.use(
http.get('/api/stats', () => HttpResponse.json(createStats(overrides))),
http.get('/api/timers', () =>
HttpResponse.json({
nextMessageAvailableAt: null,
hourlyResetAt: Date.now() + 3_600_000,
isHourlyLimitReached: false,
messagesRemaining: 6,
}),
),
);
}
function renderDashboard(initialPath = '/') {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const router = createMemoryRouter(createDashboardRoutes(), { initialEntries: [initialPath] });
render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
return { queryClient, router };
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
vi.restoreAllMocks();
});
afterAll(() => server.close());
describe('dashboard foundation', () => {
it('provides responsive navigation with real routes for every platform area', async () => {
installSuccessfulApi();
const user = userEvent.setup();
const { router } = renderDashboard('/');
const navigation = screen.getByRole('navigation', { name: 'Primary navigation' });
for (const label of ['Overview', 'Conversations', 'Media', 'Performance', 'Operations', 'Settings']) {
expect(within(navigation).getByRole('link', { name: label })).toBeInTheDocument();
}
await user.click(screen.getByRole('button', { name: 'Open navigation' }));
expect(screen.getByRole('button', { name: 'Close navigation' })).toHaveAttribute('aria-expanded', 'true');
await user.click(within(navigation).getByRole('link', { name: 'Operations' }));
await waitFor(() => expect(router.state.location.pathname).toBe('/operations'));
expect(screen.getByRole('heading', { name: 'Operations' })).toBeInTheDocument();
});
it('shows loading then renders stats, timers, and an explicit empty activity state', async () => {
let resolveStats: ((response: Response) => void) | undefined;
server.use(
http.get('/api/stats', async () =>
new Promise<Response>((resolve) => {
resolveStats = resolve;
}),
),
http.get('/api/timers', () =>
HttpResponse.json({
nextMessageAvailableAt: null,
hourlyResetAt: Date.now() + 3_600_000,
isHourlyLimitReached: false,
messagesRemaining: 6,
}),
),
);
renderDashboard();
expect(screen.getByRole('status', { name: 'Loading overview' })).toBeInTheDocument();
await waitFor(() => expect(resolveStats).toBeTypeOf('function'));
resolveStats?.(HttpResponse.json(createStats()));
expect(await screen.findByText('12', { selector: '[data-metric="messages-sent"]' })).toBeInTheDocument();
expect(screen.getByText('6 messages remaining')).toBeInTheDocument();
expect(screen.getByText('No recent activity yet.')).toBeInTheDocument();
expect(screen.getByText('Dry run')).toBeInTheDocument();
});
it('keeps stats visible when timers fail and retries only the failed panel', async () => {
let timerRequests = 0;
server.use(
http.get('/api/stats', () => HttpResponse.json(createStats())),
http.get('/api/timers', () => {
timerRequests += 1;
if (timerRequests === 1) return HttpResponse.json({ error: 'Rate limiter not initialized' }, { status: 503 });
return HttpResponse.json({
nextMessageAvailableAt: null,
hourlyResetAt: Date.now() + 3_600_000,
isHourlyLimitReached: false,
messagesRemaining: 6,
});
}),
);
const user = userEvent.setup();
renderDashboard();
expect(await screen.findByText('12', { selector: '[data-metric="messages-sent"]' })).toBeInTheDocument();
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('Rate limiter not initialized');
await user.click(within(alert).getByRole('button', { name: 'Retry timers' }));
expect(await screen.findByText('6 messages remaining')).toBeInTheDocument();
expect(timerRequests).toBe(2);
});
it('shows refresh progress while preserving the current overview', async () => {
installSuccessfulApi();
const user = userEvent.setup();
renderDashboard();
const refreshButton = await screen.findByRole('button', { name: 'Refresh overview' });
let resolveRefresh: ((response: Response) => void) | undefined;
server.use(
http.get('/api/stats', async () =>
new Promise<Response>((resolve) => {
resolveRefresh = resolve;
}),
),
);
await user.click(refreshButton);
expect(screen.getByRole('button', { name: 'Refreshing overview' })).toBeDisabled();
expect(screen.getByText('12', { selector: '[data-metric="messages-sent"]' })).toBeInTheDocument();
resolveRefresh?.(HttpResponse.json(createStats()));
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh overview' })).toBeEnabled());
});
it('requires confirmation before starting and reflects the authoritative response', async () => {
installSuccessfulApi();
let startRequests = 0;
server.use(
http.post('/api/start', () => {
startRequests += 1;
return HttpResponse.json({ success: true, running: true });
}),
);
const user = userEvent.setup();
renderDashboard();
await user.click(await screen.findByRole('button', { name: 'Start bot' }));
const dialog = screen.getByRole('dialog', { name: 'Start bot?' });
expect(dialog).toHaveTextContent('The bot will resume automated processing.');
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
expect(startRequests).toBe(0);
await user.click(screen.getByRole('button', { name: 'Start bot' }));
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Confirm start' }));
expect(await screen.findByText('Running', { selector: '[data-bot-status]' })).toBeInTheDocument();
expect(startRequests).toBe(1);
});
});
+216
View File
@@ -0,0 +1,216 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { ConversationsPage } from '../../apps/dashboard/src/features/conversations/conversations-page.js';
interface ChatFixtureOptions {
username: string;
roomId: string;
unread?: boolean;
isFromUs?: boolean;
lastMessageSnippet?: string;
}
function chatFixture(options: ChatFixtureOptions) {
return {
username: options.username,
roomName: options.username,
roomId: options.roomId,
url: `https://chat.reddit.com/room/${options.roomId}`,
lastMessageTime: 1_786_000_000_000,
lastMessageSnippet: options.lastMessageSnippet ?? `Latest from ${options.username}`,
isFromUs: options.isFromUs ?? false,
unread: options.unread ?? false,
};
}
const alice = chatFixture({ username: 'alice', roomId: 'room-alice', unread: true });
const bob = chatFixture({ username: 'bob', roomId: 'room-bob', isFromUs: true });
function detailFixture(chat = alice) {
return {
...chat,
messages: [
{ sender: 'me', text: 'Hello Alice', timestamp: 1_785_999_900_000, isSelf: true },
{ sender: 'alice', text: 'Good to hear from you', timestamp: 1_786_000_000_000, isSelf: false },
],
triggeringPost: {
id: 'post-1',
subreddit: 'casualconversation',
title: 'Looking for a chat',
url: 'https://reddit.com/r/casualconversation/comments/post-1',
sentAt: 1_785_999_800_000,
},
syncInProgress: null,
};
}
const server = setupServer();
function installConversationList() {
server.use(
http.get('/api/conversations', () => HttpResponse.json([alice, bob])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture())),
);
}
function renderConversations() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
<ConversationsPage />
</QueryClientProvider>,
);
return queryClient;
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
describe('ConversationsPage', () => {
it('shows loading and a clear empty state', async () => {
server.use(http.get('/api/conversations', () => HttpResponse.json([])));
renderConversations();
expect(screen.getByRole('status', { name: 'Loading conversations' })).toBeInTheDocument();
expect(await screen.findByText('No conversations yet.')).toBeInTheDocument();
});
it('recovers from a list error without replacing the page', async () => {
let requests = 0;
server.use(
http.get('/api/conversations', () => {
requests += 1;
if (requests === 1) return HttpResponse.json({ error: 'Chat service unavailable' }, { status: 503 });
return HttpResponse.json([alice]);
}),
);
const user = userEvent.setup();
renderConversations();
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('Chat service unavailable');
await user.click(within(alert).getByRole('button', { name: 'Retry conversations' }));
expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
});
it('searches and filters the master list, then opens conversation detail', async () => {
installConversationList();
const user = userEvent.setup();
renderConversations();
expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
await user.type(screen.getByRole('searchbox', { name: 'Search conversations' }), 'bob');
expect(screen.queryByRole('button', { name: /u\/alice/ })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /u\/bob/ })).toBeInTheDocument();
await user.clear(screen.getByRole('searchbox', { name: 'Search conversations' }));
await user.selectOptions(screen.getByRole('combobox', { name: 'Filter conversations' }), 'unread');
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /u\/alice/ }));
expect(await screen.findByRole('heading', { name: 'u/alice' })).toBeInTheDocument();
expect(screen.getByText('Good to hear from you')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Looking for a chat' })).toHaveAttribute('href', expect.stringContaining('post-1'));
});
it('turns a suggestion into an editable draft and confirms sending without losing failed input', async () => {
installConversationList();
let sendRequests = 0;
server.use(
http.post('/api/conversations/room-alice/suggest-reply', () =>
HttpResponse.json({ suggestedReply: 'That sounds lovely, what happened next?' }),
),
http.post('/api/conversations/room-alice/send-reply', async ({ request }) => {
sendRequests += 1;
const body = await request.json() as { message: string };
expect(body.message).toBe('My edited reply');
return HttpResponse.json({ error: 'Reddit is temporarily unavailable' }, { status: 503 });
}),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await screen.findByRole('heading', { name: 'u/alice' });
await user.click(screen.getByRole('button', { name: 'Suggest reply' }));
const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' });
expect(composer).toHaveValue('That sounds lovely, what happened next?');
await user.clear(composer);
await user.type(composer, 'My edited reply');
const sendButton = screen.getByRole('button', { name: 'Review and send reply' });
await user.click(sendButton);
expect(screen.getByRole('dialog', { name: 'Send this reply?' })).toHaveTextContent('My edited reply');
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(sendButton).toHaveFocus();
expect(sendRequests).toBe(0);
await user.click(sendButton);
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Confirm send' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Reddit is temporarily unavailable');
expect(composer).toHaveValue('My edited reply');
server.use(
http.post('/api/conversations/room-alice/send-reply', () => {
sendRequests += 1;
return HttpResponse.json({ success: true });
}),
);
await user.click(sendButton);
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Confirm send' }));
expect(await screen.findByRole('status', { name: 'Reply sent' })).toHaveTextContent('Reply sent to u/alice');
expect(composer).toHaveValue('');
expect(sendRequests).toBe(2);
});
it('starts a conversation and preserves both fields after a recoverable failure', async () => {
installConversationList();
let startRequests = 0;
server.use(
http.post('/api/conversations/start', () => {
startRequests += 1;
return HttpResponse.json({ error: 'Invitation cooldown active' }, { status: 429 });
}),
);
const user = userEvent.setup();
renderConversations();
await user.click(screen.getByRole('button', { name: 'New conversation' }));
const username = screen.getByRole('textbox', { name: 'Reddit username' });
const message = screen.getByRole('textbox', { name: 'Opening message' });
await user.type(username, 'charlie');
await user.type(message, 'Hello Charlie');
await user.click(screen.getByRole('button', { name: 'Start conversation' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Invitation cooldown active');
expect(username).toHaveValue('charlie');
expect(message).toHaveValue('Hello Charlie');
server.use(
http.post('/api/conversations/start', () => {
startRequests += 1;
return HttpResponse.json({ success: true, chat: chatFixture({ username: 'charlie', roomId: 'room-charlie' }) });
}),
);
await user.click(screen.getByRole('button', { name: 'Start conversation' }));
expect(await screen.findByRole('status', { name: 'Conversation started' })).toHaveTextContent('Conversation started with u/charlie');
expect(username).toHaveValue('');
expect(message).toHaveValue('');
expect(startRequests).toBe(2);
});
});
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import {
createLiveConsoleState,
reduceLiveConsoleState,
selectVisibleConsoleEntries,
} from '../../apps/dashboard/src/features/operations/live-console-model.js';
function entry(id: string, level: 'debug' | 'info' | 'warn' | 'error', message: string) {
return { id, occurredAt: '2026-08-08T12:00:00.000Z', level, message };
}
describe('live console state', () => {
it('bounds entries, rejects duplicate IDs, and filters by level and search', () => {
let state = createLiveConsoleState(2);
state = reduceLiveConsoleState(state, { type: 'append', entry: entry('1', 'info', 'Sync started') });
state = reduceLiveConsoleState(state, { type: 'append', entry: entry('2', 'error', 'Matrix failed') });
state = reduceLiveConsoleState(state, { type: 'append', entry: entry('2', 'error', 'duplicate') });
state = reduceLiveConsoleState(state, { type: 'append', entry: entry('3', 'warn', 'Sync slow') });
state = reduceLiveConsoleState(state, { type: 'set-level', level: 'warn' });
state = reduceLiveConsoleState(state, { type: 'set-search', search: 'sync' });
expect(state.entries.map(({ id }) => id)).toEqual(['2', '3']);
expect(selectVisibleConsoleEntries(state).map(({ id }) => id)).toEqual(['3']);
});
it('holds bounded pending entries while paused and merges them on resume', () => {
let state = createLiveConsoleState(2);
state = reduceLiveConsoleState(state, { type: 'append', entry: entry('1', 'info', 'one') });
state = reduceLiveConsoleState(state, { type: 'set-paused', paused: true });
state = reduceLiveConsoleState(state, { type: 'append', entry: entry('2', 'info', 'two') });
state = reduceLiveConsoleState(state, { type: 'append', entry: entry('3', 'info', 'three') });
state = reduceLiveConsoleState(state, { type: 'append', entry: entry('4', 'info', 'four') });
expect(state.entries.map(({ id }) => id)).toEqual(['1']);
expect(state.pendingEntries.map(({ id }) => id)).toEqual(['3', '4']);
state = reduceLiveConsoleState(state, { type: 'set-paused', paused: false });
expect(state.entries.map(({ id }) => id)).toEqual(['3', '4']);
expect(state.pendingEntries).toEqual([]);
});
it('deduplicates the same log delivered by the initial snapshot and SSE replay', () => {
let state = createLiveConsoleState();
state = reduceLiveConsoleState(state, {
type: 'append',
entry: entry('initial-1', 'info', 'Database initialized'),
});
state = reduceLiveConsoleState(state, {
type: 'append',
entry: entry('realtime-1', 'info', 'Database initialized'),
});
expect(state.entries).toHaveLength(1);
expect(state.entries[0]?.id).toBe('initial-1');
});
});
+250
View File
@@ -0,0 +1,250 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { MediaPage } from '../../apps/dashboard/src/features/media/media-page.js';
const now = Date.now();
const mediaItems = [
{
id: 'one', chatUsername: 'alice', chatRoomId: 'room-a', sender: 'alice',
timestamp: now - 60_000, originalUrl: 'https://media.example/one.jpg', fileName: 'one.jpg',
relativePath: 'one.jpg', createdAt: now - 50_000, urlPath: '/media/one.jpg',
},
{
id: 'two', chatUsername: 'bob', chatRoomId: 'room-b', sender: 'charlie',
timestamp: now - (3 * 86_400_000), originalUrl: 'https://media.example/two.gif', fileName: 'two.gif',
relativePath: 'two.gif', createdAt: now - (3 * 86_400_000), urlPath: '/media/two.gif',
},
{
id: 'three', chatUsername: null, chatRoomId: 'room-c', sender: 'dave',
timestamp: now - (40 * 86_400_000), originalUrl: 'https://media.example/three.png', fileName: 'three.png',
relativePath: 'three.png', createdAt: now - (40 * 86_400_000), urlPath: '/media/three.png',
},
];
const idleStatus = {
isSyncing: false,
lastSyncAt: now - 120_000,
lastSyncDurationMs: 2_400,
lastSyncChats: 3,
lastError: null,
nextSyncAt: now + 1_800_000,
};
const server = setupServer();
function installMediaApi(items = mediaItems) {
server.use(
http.get('/api/media', () => HttpResponse.json(items)),
http.get('/api/media/sync', () => HttpResponse.json(idleStatus)),
);
}
function renderMedia() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const view = render(
<QueryClientProvider client={queryClient}>
<MediaPage />
</QueryClientProvider>,
);
return { ...view, queryClient };
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
vi.restoreAllMocks();
});
afterAll(() => server.close());
describe('media page', () => {
it('loads authenticated media, keeps it blurred, and supports item and session-local reveals', async () => {
let resolveMedia: ((response: Response) => void) | undefined;
const credentials: RequestCredentials[] = [];
server.use(
http.get('/api/media', async ({ request }) => {
credentials.push(request.credentials);
return new Promise<Response>((resolve) => { resolveMedia = resolve; });
}),
http.get('/api/media/sync', ({ request }) => {
credentials.push(request.credentials);
return HttpResponse.json(idleStatus);
}),
);
const user = userEvent.setup();
const firstView = renderMedia();
expect(screen.getByRole('status', { name: 'Loading media' })).toBeInTheDocument();
await waitFor(() => expect(resolveMedia).toBeTypeOf('function'));
resolveMedia?.(HttpResponse.json(mediaItems));
const aliceImage = await screen.findByRole('img', { name: 'Media from alice' });
const charlieImage = screen.getByRole('img', { name: 'Media from charlie' });
expect(aliceImage).toHaveClass('is-blurred');
expect(charlieImage).toHaveClass('is-blurred');
expect(credentials.every((value) => value === 'same-origin')).toBe(true);
await user.click(screen.getByRole('button', { name: 'Reveal media from alice' }));
expect(aliceImage).not.toHaveClass('is-blurred');
expect(charlieImage).toHaveClass('is-blurred');
await user.click(screen.getByRole('button', { name: 'Reveal all media' }));
expect(charlieImage).not.toHaveClass('is-blurred');
firstView.unmount();
installMediaApi();
renderMedia();
expect(await screen.findByRole('img', { name: 'Media from alice' })).toHaveClass('is-blurred');
});
it('filters by conversation, sender, media type, and age with an explicit no-match state', async () => {
installMediaApi();
const user = userEvent.setup();
renderMedia();
expect(await screen.findAllByRole('img')).toHaveLength(3);
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'alice');
expect(screen.getAllByRole('img')).toHaveLength(1);
expect(screen.getByRole('img', { name: 'Media from alice' })).toBeInTheDocument();
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'all');
await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'charlie');
expect(screen.getByRole('img', { name: 'Media from charlie' })).toBeInTheDocument();
await user.selectOptions(screen.getByRole('combobox', { name: 'Sender' }), 'all');
await user.selectOptions(screen.getByRole('combobox', { name: 'Media type' }), 'gif');
expect(screen.getAllByRole('img')).toHaveLength(1);
await user.selectOptions(screen.getByRole('combobox', { name: 'Media type' }), 'all');
await user.selectOptions(screen.getByRole('combobox', { name: 'Date range' }), '7d');
expect(screen.getAllByRole('img')).toHaveLength(2);
await user.selectOptions(screen.getByRole('combobox', { name: 'Conversation' }), 'room-c');
expect(screen.getByText('No media matches these filters.')).toBeInTheDocument();
});
it('shows empty and recoverable error states', async () => {
let requests = 0;
server.use(
http.get('/api/media', () => {
requests += 1;
return requests === 1
? HttpResponse.json({ error: 'Media index unavailable' }, { status: 500 })
: HttpResponse.json([]);
}),
http.get('/api/media/sync', () => HttpResponse.json(idleStatus)),
);
const user = userEvent.setup();
renderMedia();
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('Media index unavailable');
await user.click(within(alert).getByRole('button', { name: 'Retry media' }));
expect(await screen.findByText('No media yet.')).toBeInTheDocument();
expect(requests).toBe(2);
});
it('runs delta sync as the primary action and surfaces query-driven status and mutation outcomes', async () => {
installMediaApi();
let deltaRequests = 0;
server.use(
http.post('/api/media/sync', ({ request }) => {
deltaRequests += 1;
expect(request.credentials).toBe('same-origin');
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
renderMedia();
const deltaButton = await screen.findByRole('button', { name: 'Sync new media' });
expect(deltaButton).toHaveClass('primary');
expect(screen.getByText('Last synced 3 conversations')).toBeInTheDocument();
await user.click(deltaButton);
expect(await screen.findByText('Media sync completed.')).toBeInTheDocument();
expect(deltaRequests).toBe(1);
server.use(
http.post('/api/media/sync', () => HttpResponse.json({ success: false, reason: 'Sync already in progress' })),
);
await user.click(screen.getByRole('button', { name: 'Sync new media' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Sync already in progress');
});
it('refreshes shared sync status as soon as a long-running mutation starts', async () => {
let statusRequests = 0;
let resolveSync: ((response: Response) => void) | undefined;
server.use(
http.get('/api/media', () => HttpResponse.json(mediaItems)),
http.get('/api/media/sync', () => {
statusRequests += 1;
return HttpResponse.json(statusRequests === 1 ? idleStatus : { ...idleStatus, isSyncing: true });
}),
http.post('/api/media/sync', async () =>
new Promise<Response>((resolve) => { resolveSync = resolve; }),
),
);
const user = userEvent.setup();
renderMedia();
await user.click(await screen.findByRole('button', { name: 'Sync new media' }));
expect(await screen.findByText('Media sync in progress')).toBeInTheDocument();
expect(statusRequests).toBeGreaterThan(1);
resolveSync?.(HttpResponse.json({ success: true }));
await waitFor(() => expect(screen.getByText('Media sync completed.')).toBeInTheDocument());
});
it('guards a full sync with explicit confirmation', async () => {
installMediaApi();
let fullRequests = 0;
server.use(
http.post('/api/media/sync/full', () => {
fullRequests += 1;
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
renderMedia();
await user.click(await screen.findByRole('button', { name: 'Full sync' }));
const dialog = screen.getByRole('dialog', { name: 'Run full media sync?' });
expect(dialog).toHaveTextContent('every conversation');
expect(fullRequests).toBe(0);
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
expect(fullRequests).toBe(0);
await user.click(screen.getByRole('button', { name: 'Full sync' }));
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Run full sync' }));
expect(await screen.findByText('Full media sync completed.')).toBeInTheDocument();
expect(fullRequests).toBe(1);
});
it('closes the full-sync dialog so a failed mutation is clearly visible', async () => {
installMediaApi();
server.use(
http.post('/api/media/sync/full', () =>
HttpResponse.json({ error: 'Full sync could not reach Reddit' }, { status: 500 }),
),
);
const user = userEvent.setup();
renderMedia();
await user.click(await screen.findByRole('button', { name: 'Full sync' }));
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Run full sync' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Full sync could not reach Reddit');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,143 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import type { PropsWithChildren, ReactElement } from 'react';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { OperationsPage } from '../../apps/dashboard/src/features/operations/operations-page.js';
import { PerformancePage } from '../../apps/dashboard/src/features/performance/performance-page.js';
const server = setupServer();
function TestProviders({ children }: PropsWithChildren) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
function renderPage(element: ReactElement) {
return render(element, { wrapper: TestProviders });
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
describe('PerformancePage', () => {
it('shows sample sizes, confidence warnings, useful comparisons, and successful examples', async () => {
server.use(
http.get('/api/analytics/by-subreddit', () =>
HttpResponse.json([
{ subreddit: 'askuk', totalSent: 40, totalResponses: 12, responseRate: 30 },
{ subreddit: 'tiny', totalSent: 3, totalResponses: 2, responseRate: 67 },
]),
),
http.get('/api/analytics/by-strategy', () =>
HttpResponse.json([
{ strategyId: 'friendly', totalSent: 25, totalResponses: 10, responseRate: 40, configured: true },
{ strategyId: 'new-test', totalSent: 2, totalResponses: 1, responseRate: 50 },
]),
),
http.get('/api/analytics/trend', ({ request }) => {
expect(new URL(request.url).searchParams.get('days')).toBe('30');
return HttpResponse.json([
{ date: '2026-08-07', totalSent: 10, totalResponses: 2, responseRate: 20 },
{ date: '2026-08-08', totalSent: 8, totalResponses: 4, responseRate: 50 },
]);
}),
http.get('/api/successful', () =>
HttpResponse.json([
{
id: 7,
sentMessageId: 22,
originalMessage: 'A thoughtful opener',
imageAnalysis: null,
postTitle: 'Weekend plans',
responseSnippet: 'That sounds lovely!',
subreddit: 'askuk',
createdAt: Date.parse('2026-08-08T10:00:00Z'),
},
]),
),
);
renderPage(<PerformancePage />);
expect(await screen.findByRole('heading', { name: 'Performance' })).toBeInTheDocument();
const subredditTable = screen.getByRole('table', { name: 'Subreddit performance' });
expect(within(subredditTable).getByText('n = 40')).toBeInTheDocument();
expect(within(subredditTable).getByText('n = 3')).toBeInTheDocument();
expect(within(subredditTable).getByText('Low confidence')).toBeInTheDocument();
const strategyTable = screen.getByRole('table', { name: 'Strategy performance' });
expect(within(strategyTable).getByText('n = 25')).toBeInTheDocument();
expect(within(strategyTable).getByText('Configured')).toBeInTheDocument();
expect(screen.getByRole('list', { name: '30 day response trend' })).toHaveTextContent('50%');
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('A thoughtful opener');
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('That sounds lovely!');
});
});
describe('OperationsPage', () => {
it('keeps readable activity, job state, and raw searchable logs separate', async () => {
server.use(
http.get('/api/activity', () =>
HttpResponse.json([
{ timestamp: Date.parse('2026-08-08T12:00:00Z'), type: 'response', message: 'Reply from u/alex', recipient: 'alex' },
]),
),
http.get('/api/logs', ({ request }) => {
expect(new URL(request.url).searchParams.get('limit')).toBe('200');
return HttpResponse.json([
{ timestamp: Date.parse('2026-08-08T12:00:01Z'), level: 'info', message: 'Sync complete' },
{ timestamp: Date.parse('2026-08-08T12:00:02Z'), level: 'error', message: 'Matrix request failed' },
]);
}),
http.get('/api/chats/sync', () =>
HttpResponse.json({
syncing: true,
syncingKind: 'delta',
lastFullSyncAt: null,
lastDeltaSyncAt: null,
hasEverFullSynced: false,
cachedChats: 18,
progress: {
kind: 'delta',
phase: 'loading-details',
current: 4,
total: 10,
label: 'Loading room history',
startedAt: Date.parse('2026-08-08T11:59:00Z'),
},
}),
),
);
const user = userEvent.setup();
renderPage(<OperationsPage realtimeEnabled={false} />);
expect(await screen.findByRole('heading', { name: 'Operations' })).toBeInTheDocument();
const activityRegion = screen.getByRole('region', { name: 'Activity' });
expect(await within(activityRegion).findByText('Reply from u/alex')).toBeInTheDocument();
const jobsRegion = screen.getByRole('region', { name: 'Sync jobs' });
expect(await within(jobsRegion).findByText('4 of 10')).toBeInTheDocument();
expect(screen.getByRole('status', { name: 'Realtime connection' })).toHaveTextContent('State may be stale');
const consoleRegion = screen.getByRole('region', { name: 'Live console' });
expect(consoleRegion).toHaveTextContent('Sync complete');
expect(consoleRegion).toHaveTextContent('Matrix request failed');
await user.selectOptions(within(consoleRegion).getByLabelText('Log level'), 'error');
expect(consoleRegion).not.toHaveTextContent('Sync complete');
expect(consoleRegion).toHaveTextContent('Matrix request failed');
await user.type(within(consoleRegion).getByLabelText('Search logs'), 'no match');
expect(consoleRegion).toHaveTextContent('No logs match the current filters.');
expect(within(consoleRegion).getByRole('button', { name: 'Pause console' })).toHaveAttribute('aria-pressed', 'false');
expect(within(consoleRegion).getByLabelText('Auto-scroll')).toBeChecked();
});
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest';
import {
createDashboardRealtimeClient,
queryKeysForDashboardEvent,
type DashboardEventSource,
} from '../../apps/dashboard/src/features/operations/realtime-client.js';
class FakeEventSource implements DashboardEventSource {
readonly listeners = new Map<string, Array<(event: unknown) => void>>();
readonly close = vi.fn();
addEventListener(type: string, listener: (event: unknown) => void): void {
const listeners = this.listeners.get(type) ?? [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
emit(type: string, event: unknown = {}): void {
for (const listener of this.listeners.get(type) ?? []) listener(event);
}
}
describe('dashboard realtime client', () => {
it('tracks connect/reconnect state and parses typed events before delivery', () => {
const source = new FakeEventSource();
const states: string[] = [];
const events = vi.fn();
const invalid = vi.fn();
const client = createDashboardRealtimeClient({
sourceFactory: () => source,
onEvent: events,
onStateChange: (state) => states.push(`${state.status}:${state.isStale}`),
onInvalidEvent: invalid,
});
client.start();
source.emit('open');
source.emit('log.created', {
data: JSON.stringify({
id: 'event-1',
occurredAt: '2026-08-08T12:00:00.000Z',
type: 'log.created',
payload: { level: 'info', message: 'Connected' },
}),
});
source.emit('log.created', { data: '{bad json' });
source.emit('error');
expect(states).toEqual(['connecting:true', 'connected:false', 'reconnecting:true']);
expect(events).toHaveBeenCalledOnce();
expect(events).toHaveBeenCalledWith(expect.objectContaining({ id: 'event-1', type: 'log.created' }));
expect(invalid).toHaveBeenCalledOnce();
expect(client.getSnapshot()).toMatchObject({ status: 'reconnecting', isStale: true, lastEventId: 'event-1' });
client.stop();
expect(source.close).toHaveBeenCalledOnce();
expect(client.getSnapshot()).toMatchObject({ status: 'closed', isStale: true });
});
it('maps events to the queries whose authoritative snapshots need refresh', () => {
expect(
queryKeysForDashboardEvent({
id: '1',
occurredAt: '2026-08-08T12:00:00.000Z',
type: 'sync.progress',
payload: { syncId: 'sync-1', kind: 'media', completed: 2, total: 5 },
}),
).toEqual([
['operations', 'sync-status'],
['conversations'],
['media'],
]);
expect(
queryKeysForDashboardEvent({
id: '2',
occurredAt: '2026-08-08T12:00:00.000Z',
type: 'rate-limit.changed',
payload: { limited: true },
}),
).toEqual([
['dashboard', 'stats'],
['dashboard', 'timers'],
]);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { RealtimeEventHub, type RealtimeEvent } from '../src/web/events/index.js';
import { LogBroadcaster } from '../src/web/log-broadcaster.js';
describe('LogBroadcaster realtime bridge', () => {
it('publishes each log through the typed event hub', () => {
const occurredAt = new Date('2026-08-08T15:00:00.000Z');
const hub = new RealtimeEventHub({
capacity: 10,
clock: { now: () => occurredAt },
});
const received: RealtimeEvent[] = [];
let recent: unknown;
hub.subscribe((event) => received.push(event));
const broadcaster = new LogBroadcaster(hub);
broadcaster.push('warn', 'Connection is stale', { attempts: 2 });
broadcaster.recent(
{ query: {} } as never,
{ json: (value: unknown) => { recent = value; } } as never,
);
expect(received).toEqual([
expect.objectContaining({
type: 'log.created',
payload: {
level: 'warn',
message: 'Connection is stale',
data: { attempts: 2 },
},
}),
]);
expect(recent).toEqual([
expect.objectContaining({ timestamp: occurredAt.getTime() }),
]);
});
});
+16
View File
@@ -0,0 +1,16 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
describe('VNC container entrypoint', () => {
it('starts x11vnc with supported arguments and verifies its port before noVNC', () => {
const script = fs.readFileSync(path.resolve('docker/entrypoint.sh'), 'utf8');
expect(script).not.toContain('-nopw_warn');
expect(script).toContain('kill -0 "${X11VNC_PID}"');
expect(script).toContain('/dev/tcp/127.0.0.1/${VNC_PORT}');
expect(script.indexOf('VNC server is ready')).toBeLessThan(
script.indexOf('starting noVNC'),
);
});
});
@@ -0,0 +1,296 @@
import { EventEmitter } from 'node:events';
import type { Request, Response } from 'express';
import { describe, expect, it, vi } from 'vitest';
import {
RealtimeEventHub,
} from '../../../src/web/events/index.js';
import {
createExpressSseHandler,
type RecurringTask,
type RecurringTaskScheduler,
} from '../../../src/web/events/express-sse-adapter.js';
class FakeRequest extends EventEmitter {
constructor(private readonly lastEventId?: string) {
super();
}
get(name: string): string | undefined {
return name.toLowerCase() === 'last-event-id'
? this.lastEventId
: undefined;
}
}
class FakeResponse extends EventEmitter {
readonly headers = new Map<string, string>();
readonly frames: string[] = [];
readonly flushHeaders = vi.fn();
readonly end = vi.fn(() => {
this.writableEnded = true;
return this;
});
destroyed = false;
writableEnded = false;
writeResults: boolean[] = [];
throwOnWriteCall: number | undefined;
private writeCalls = 0;
setHeader(name: string, value: string): this {
this.headers.set(name, value);
return this;
}
write(frame: string): boolean {
this.writeCalls += 1;
if (this.writeCalls === this.throwOnWriteCall) {
throw new Error('socket write failed');
}
this.frames.push(frame);
return this.writeResults.shift() ?? true;
}
}
class FakeRecurringTask implements RecurringTask {
readonly cancel = vi.fn();
}
class FakeScheduler implements RecurringTaskScheduler {
readonly scheduled: Array<{
intervalMs: number;
callback: () => void;
task: FakeRecurringTask;
}> = [];
every(intervalMs: number, callback: () => void): RecurringTask {
const task = new FakeRecurringTask();
this.scheduled.push({ intervalMs, callback, task });
return task;
}
}
function asRequest(request: FakeRequest): Request {
return request as unknown as Request;
}
function asResponse(response: FakeResponse): Response {
return response as unknown as Response;
}
function createHub(logger?: { warn: ReturnType<typeof vi.fn> }) {
let sequence = 0;
return new RealtimeEventHub({
capacity: 10,
clock: { now: () => new Date('2026-08-08T12:00:00.000Z') },
idGenerator: { next: () => `event-${++sequence}` },
logger,
});
}
describe('createExpressSseHandler', () => {
it('sets streaming headers and resumes ordered event frames after Last-Event-ID', () => {
const hub = createHub();
hub.publish('log.created', { level: 'info', message: 'already seen' });
const second = hub.publish('bot.status.changed', {
status: 'running',
dryRun: false,
});
const third = hub.publish('sync.started', {
syncId: 'sync-1',
kind: 'media',
});
const scheduler = new FakeScheduler();
const handler = createExpressSseHandler({ hub, scheduler });
const request = new FakeRequest('event-1');
const response = new FakeResponse();
handler(asRequest(request), asResponse(response), vi.fn());
expect(response.headers).toEqual(
new Map([
['Content-Type', 'text/event-stream; charset=utf-8'],
['Cache-Control', 'no-cache, no-transform'],
['Connection', 'keep-alive'],
['X-Accel-Buffering', 'no'],
]),
);
expect(response.flushHeaders).toHaveBeenCalledOnce();
expect(response.frames).toEqual([
': connected\n\n',
`id: event-2\nevent: bot.status.changed\ndata: ${JSON.stringify(second)}\n\n`,
`id: event-3\nevent: sync.started\ndata: ${JSON.stringify(third)}\n\n`,
]);
});
it('replays the full available window when Last-Event-ID is absent', () => {
const hub = createHub();
hub.publish('log.created', { level: 'info', message: 'one' });
hub.publish('log.created', { level: 'warn', message: 'two' });
const response = new FakeResponse();
createExpressSseHandler({ hub, scheduler: new FakeScheduler() })(
asRequest(new FakeRequest()),
asResponse(response),
vi.fn(),
);
expect(response.frames.map((frame) => frame.split('\n')[0])).toEqual([
': connected',
'id: event-1',
'id: event-2',
]);
});
it('emits timestamped heartbeats through injected scheduling and clock dependencies', () => {
const hub = createHub();
const scheduler = new FakeScheduler();
const response = new FakeResponse();
const handler = createExpressSseHandler({
hub,
scheduler,
clock: { now: () => new Date('2026-08-08T12:34:56.789Z') },
heartbeatIntervalMs: 5_000,
});
handler(asRequest(new FakeRequest()), asResponse(response), vi.fn());
expect(scheduler.scheduled).toHaveLength(1);
expect(scheduler.scheduled[0]?.intervalMs).toBe(5_000);
scheduler.scheduled[0]?.callback();
expect(response.frames.at(-1)).toBe(
': heartbeat 2026-08-08T12:34:56.789Z\n\n',
);
});
it('unsubscribes and cancels its heartbeat when the request closes', () => {
const hub = createHub();
const scheduler = new FakeScheduler();
const response = new FakeResponse();
const request = new FakeRequest();
createExpressSseHandler({ hub, scheduler })(
asRequest(request),
asResponse(response),
vi.fn(),
);
request.emit('close');
request.emit('close');
hub.publish('log.created', { level: 'info', message: 'after close' });
expect(scheduler.scheduled[0]?.task.cancel).toHaveBeenCalledOnce();
expect(response.frames).toEqual([': connected\n\n']);
});
it('queues live events during backpressure and flushes them in order on drain', () => {
const hub = createHub();
const response = new FakeResponse();
response.writeResults = [true, false, true];
createExpressSseHandler({ hub, scheduler: new FakeScheduler() })(
asRequest(new FakeRequest()),
asResponse(response),
vi.fn(),
);
hub.publish('log.created', { level: 'info', message: 'fills buffer' });
hub.publish('log.created', { level: 'info', message: 'queued' });
expect(response.frames.map((frame) => frame.split('\n')[0])).toEqual([
': connected',
'id: event-1',
]);
response.emit('drain');
expect(response.frames.map((frame) => frame.split('\n')[0])).toEqual([
': connected',
'id: event-1',
'id: event-2',
]);
});
it('closes a persistently backpressured response when its bounded queue fills', () => {
const hub = createHub();
const scheduler = new FakeScheduler();
const response = new FakeResponse();
response.writeResults = [true, false];
createExpressSseHandler({
hub,
scheduler,
maxPendingFrames: 1,
})(asRequest(new FakeRequest()), asResponse(response), vi.fn());
hub.publish('log.created', { level: 'info', message: 'buffered by Node' });
hub.publish('log.created', { level: 'info', message: 'adapter queue' });
hub.publish('log.created', { level: 'info', message: 'overflow' });
hub.publish('log.created', { level: 'info', message: 'not delivered' });
expect(response.end).toHaveBeenCalledOnce();
expect(scheduler.scheduled[0]?.task.cancel).toHaveBeenCalledOnce();
expect(response.frames.map((frame) => frame.split('\n')[0])).toEqual([
': connected',
'id: event-1',
]);
});
it('handles closed and throwing responses without leaking subscriber errors', () => {
const logger = { warn: vi.fn() };
const closedHub = createHub(logger);
const closedResponse = new FakeResponse();
closedResponse.destroyed = true;
createExpressSseHandler({
hub: closedHub,
scheduler: new FakeScheduler(),
})(asRequest(new FakeRequest()), asResponse(closedResponse), vi.fn());
closedHub.publish('log.created', { level: 'info', message: 'ignored' });
const throwingHub = createHub(logger);
const scheduler = new FakeScheduler();
const throwingResponse = new FakeResponse();
throwingResponse.throwOnWriteCall = 2;
createExpressSseHandler({ hub: throwingHub, scheduler })(
asRequest(new FakeRequest()),
asResponse(throwingResponse),
vi.fn(),
);
throwingHub.publish('log.created', { level: 'error', message: 'throws' });
throwingHub.publish('log.created', { level: 'info', message: 'ignored' });
expect(logger.warn).not.toHaveBeenCalled();
expect(scheduler.scheduled[0]?.task.cancel).toHaveBeenCalledOnce();
expect(throwingResponse.end).toHaveBeenCalledOnce();
});
it('releases the connection if the event hub cannot accept a subscriber', () => {
const hub = createHub();
hub.close();
const scheduler = new FakeScheduler();
const response = new FakeResponse();
const handler = createExpressSseHandler({ hub, scheduler });
expect(() =>
handler(
asRequest(new FakeRequest()),
asResponse(response),
vi.fn(),
),
).not.toThrow();
expect(response.end).toHaveBeenCalledOnce();
expect(scheduler.scheduled[0]?.task.cancel).toHaveBeenCalledOnce();
});
it('validates adapter limits', () => {
const hub = createHub();
expect(() =>
createExpressSseHandler({
hub,
scheduler: new FakeScheduler(),
heartbeatIntervalMs: 0,
}),
).toThrow('heartbeatIntervalMs must be a positive integer');
expect(() =>
createExpressSseHandler({
hub,
scheduler: new FakeScheduler(),
maxPendingFrames: 0,
}),
).toThrow('maxPendingFrames must be a positive integer');
});
});
+256
View File
@@ -0,0 +1,256 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
import {
MonotonicEventIdGenerator,
RealtimeEventHub,
type RealtimeEvent,
type RealtimeEventMap,
} from '../../../src/web/events/index.js';
function createDependencies() {
let timestamp = Date.parse('2026-08-08T12:00:00.000Z');
let sequence = 0;
return {
clock: {
now: () => new Date(timestamp++),
},
idGenerator: {
next: () => `event-${++sequence}`,
},
};
}
describe('RealtimeEventHub', () => {
it('creates typed envelopes with stable IDs and timestamps', () => {
const hub = new RealtimeEventHub({
capacity: 10,
...createDependencies(),
});
const event = hub.publish('bot.status.changed', {
status: 'running',
dryRun: false,
});
expect(event).toStrictEqual({
id: 'event-1',
occurredAt: '2026-08-08T12:00:00.000Z',
type: 'bot.status.changed',
payload: { status: 'running', dryRun: false },
});
expectTypeOf(event).toEqualTypeOf<
RealtimeEvent<'bot.status.changed'>
>();
});
it('supports every event family through the event map', () => {
expectTypeOf<keyof RealtimeEventMap>().toEqualTypeOf<
| 'bot.status.changed'
| 'message.sent'
| 'message.failed'
| 'conversation.updated'
| 'reply.received'
| 'sync.started'
| 'sync.progress'
| 'sync.completed'
| 'sync.failed'
| 'rate-limit.changed'
| 'reddit.session.changed'
| 'log.created'
>();
});
it('fans out live events and stops delivery after unsubscribe', () => {
const hub = new RealtimeEventHub({
capacity: 10,
...createDependencies(),
});
const listener = vi.fn();
const subscription = hub.subscribe(listener);
hub.publish('message.sent', {
messageId: 'message-1',
recipient: 'redditor',
});
subscription.unsubscribe();
subscription.unsubscribe();
hub.publish('conversation.updated', {
conversationId: 'conversation-1',
status: 'awaiting-reply',
});
expect(listener).toHaveBeenCalledTimes(1);
expect(listener.mock.calls[0]?.[0]).toMatchObject({
id: 'event-1',
type: 'message.sent',
});
expect(subscription.closed).toBe(true);
});
it('retains only the configured replay capacity', () => {
const hub = new RealtimeEventHub({
capacity: 2,
...createDependencies(),
});
hub.publish('log.created', { level: 'info', message: 'one' });
hub.publish('log.created', { level: 'info', message: 'two' });
hub.publish('log.created', { level: 'info', message: 'three' });
expect(hub.recent()).toEqual([
expect.objectContaining({ id: 'event-2' }),
expect.objectContaining({ id: 'event-3' }),
]);
expect(hub.recent(1)).toEqual([
expect.objectContaining({ id: 'event-3' }),
]);
});
it('resumes strictly after Last-Event-ID and replays the available window for an unknown ID', () => {
const hub = new RealtimeEventHub({
capacity: 3,
...createDependencies(),
});
hub.publish('log.created', { level: 'info', message: 'one' });
hub.publish('log.created', { level: 'info', message: 'two' });
hub.publish('log.created', { level: 'info', message: 'three' });
const resumed = vi.fn();
hub.subscribe(resumed, { lastEventId: 'event-1' });
expect(resumed.mock.calls.map(([event]) => event.id)).toEqual([
'event-2',
'event-3',
]);
const boundedHub = new RealtimeEventHub({
capacity: 2,
...createDependencies(),
});
boundedHub.publish('log.created', { level: 'info', message: 'evicted' });
boundedHub.publish('log.created', { level: 'info', message: 'available one' });
boundedHub.publish('log.created', { level: 'info', message: 'available two' });
const stale = vi.fn();
boundedHub.subscribe(stale, { lastEventId: 'event-1' });
expect(stale.mock.calls.map(([event]) => event.id)).toEqual([
'event-2',
'event-3',
]);
});
it('preserves order and prevents duplicate delivery when publish occurs during replay', () => {
const hub = new RealtimeEventHub({
capacity: 10,
...createDependencies(),
});
hub.publish('log.created', { level: 'info', message: 'one' });
hub.publish('log.created', { level: 'info', message: 'two' });
const received: string[] = [];
hub.subscribe((event) => {
received.push(event.id);
if (event.id === 'event-1') {
hub.publish('log.created', { level: 'info', message: 'during replay' });
}
});
expect(received).toEqual(['event-1', 'event-2', 'event-3']);
});
it('keeps every subscriber in order across re-entrant publishes', () => {
const hub = new RealtimeEventHub({
capacity: 10,
...createDependencies(),
});
const firstReceived: string[] = [];
const secondReceived: string[] = [];
hub.subscribe((event) => {
firstReceived.push(event.id);
if (event.id === 'event-1') {
hub.publish('log.created', { level: 'info', message: 'nested' });
}
});
hub.subscribe((event) => secondReceived.push(event.id));
hub.publish('log.created', { level: 'info', message: 'outer' });
expect(firstReceived).toEqual(['event-1', 'event-2']);
expect(secondReceived).toEqual(['event-1', 'event-2']);
});
it('isolates failed subscribers and reports failures through an injected logger', () => {
const logger = { warn: vi.fn() };
const hub = new RealtimeEventHub({
capacity: 10,
logger,
...createDependencies(),
});
const healthyListener = vi.fn();
const failedSubscription = hub.subscribe(() => {
throw new Error('client disconnected');
});
hub.subscribe(healthyListener);
hub.publish('reddit.session.changed', { status: 'authenticated' });
hub.publish('reddit.session.changed', { status: 'expired' });
expect(failedSubscription.closed).toBe(true);
expect(healthyListener).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenCalledOnce();
expect(logger.warn).toHaveBeenCalledWith(
'Realtime event subscriber failed; subscription closed',
expect.objectContaining({ eventId: 'event-1', error: expect.any(Error) }),
);
});
it('closes all subscriptions cleanly and rejects use after close', () => {
const hub = new RealtimeEventHub({
capacity: 10,
...createDependencies(),
});
const first = hub.subscribe(vi.fn());
const second = hub.subscribe(vi.fn());
hub.close();
hub.close();
expect(first.closed).toBe(true);
expect(second.closed).toBe(true);
expect(hub.closed).toBe(true);
expect(() =>
hub.publish('sync.started', { syncId: 'sync-1', kind: 'media' }),
).toThrow('RealtimeEventHub is closed');
expect(() => hub.subscribe(vi.fn())).toThrow(
'RealtimeEventHub is closed',
);
});
it('validates construction and recent limits', () => {
expect(
() =>
new RealtimeEventHub({
capacity: 0,
...createDependencies(),
}),
).toThrow('capacity must be a positive integer');
const hub = new RealtimeEventHub({
capacity: 1,
...createDependencies(),
});
expect(() => hub.recent(-1)).toThrow(
'limit must be a non-negative integer',
);
});
});
describe('MonotonicEventIdGenerator', () => {
it('generates unique sortable IDs when timestamps repeat or move backwards', () => {
const generator = new MonotonicEventIdGenerator();
const first = generator.next(new Date('2026-08-08T12:00:00.001Z'));
const second = generator.next(new Date('2026-08-08T12:00:00.001Z'));
const third = generator.next(new Date('2026-08-08T12:00:00.000Z'));
expect(new Set([first, second, third])).toHaveLength(3);
expect([first, second, third]).toEqual([...[first, second, third]].sort());
});
});
+3 -1
View File
@@ -1,10 +1,12 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: false,
environment: 'node',
include: ['tests/**/*.test.ts'],
include: ['tests/**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],