feat(dashboard): add React foundation and overview

This commit is contained in:
jjbrunton committed 2026-08-08 14:53:53 +01:00
1 parent 59c3a61a88
commit 51e3e28a9b
20 files changed
+3347 -244

No files matched your search

+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();
+46
View File
@@ -0,0 +1,46 @@
import { z } from 'zod';
const activityItemSchema = z.object({
timestamp: z.number(),
type: z.enum(['sent', 'skipped', 'response', 'error', 'info', 'debug']),
message: z.string(),
recipient: z.string().optional(),
imageUrl: z.string().optional(),
});
export const botStatsSchema = z.object({
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.object({ name: z.string(), type: z.enum(['image', 'chat']) })),
targetGenders: z.array(z.string()),
recentActivity: z.array(activityItemSchema),
invitationCooldown: z
.object({
until: z.number(),
reason: z.string(),
redditCode: z.string().nullable(),
})
.nullable(),
});
export const timersSchema = z.object({
nextMessageAvailableAt: z.number().nullable(),
hourlyResetAt: z.number(),
isHourlyLimitReached: z.boolean(),
messagesRemaining: z.number(),
});
export const controlResponseSchema = z.object({
success: z.literal(true),
running: z.boolean(),
});
export type BotStats = z.infer<typeof botStatsSchema>;
export type Timers = z.infer<typeof timersSchema>;
export type ControlResponse = z.infer<typeof controlResponseSchema>;
+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,
);
},
});
}