/** * Modular dashboard for wa-agent — HTMX shell + registered cards. * * From v0.2 onward: cards read from D1 via Drizzle. */ import { and, count, countDistinct, eq, gte, isNull, sql, sum } from 'drizzle-orm'; import type { DB } from '../db/client.js'; import { leads } from '../db/schema/leads.js'; import { messages } from '../db/schema/messages.js'; import { messageWindows } from '../db/schema/message_windows.js'; import { messageQueue } from '../db/schema/message_queue.js'; import { engagementAnswers, broadcastLog } from '../db/schema/broadcast.js'; import { plans, userPlans } from '../db/schema/plans.js'; import { featureUsage } from '../db/schema/usage.js'; export interface CardContext { db: DB; env: Record; query: Record; } export interface Card { id: string; refreshSeconds?: number; render: (ctx: CardContext) => string | Promise; } export interface DashboardOptions { title?: string; cards?: Card[]; auth?: { username: string; password: string } | null; theme?: string | null; } /** * Minimal subset of Hono we touch. Avoids a hard import. */ export interface HonoLike { use(path: string, mw: (c: unknown, next: () => Promise) => Promise): unknown; get(path: string, handler: (c: HonoContextLike) => unknown): unknown; } export interface HonoContextLike { html(body: string): unknown; env: { DB: D1Database; [k: string]: unknown }; req: { query(): Record }; } export class Dashboard { readonly title: string; readonly cards: Card[]; readonly auth: { username: string; password: string } | null; readonly theme: string; constructor({ title = 'Dashboard', cards = [], auth = null, theme = null }: DashboardOptions) { this.title = title; this.cards = cards; this.auth = auth; this.theme = theme || DEFAULT_THEME; } async mount(app: HonoLike, base: string = '/dashboard'): Promise { if (this.auth) { const { basicAuth } = (await import('hono/basic-auth')) as unknown as { basicAuth: (opts: { username: string; password: string }) => (c: unknown, next: () => Promise) => Promise; }; app.use(`${base}/*`, basicAuth({ username: this.auth.username, password: this.auth.password })); app.use(base, basicAuth({ username: this.auth.username, password: this.auth.password })); } app.get(base, (c) => c.html(this.renderShell(base))); const { createDb } = await import('../db/client.js'); for (const card of this.cards) { app.get(`${base}/c/${card.id}`, async (c) => { try { const html = await card.render({ db: createDb(c.env.DB), env: c.env, query: c.req.query() }); return c.html(html); } catch (e) { return c.html(`
${escapeHtml(e instanceof Error ? e.message : String(e))}
`); } }); } } renderShell(base: string): string { const cardDivs = this.cards .map((card) => { const trigger = card.refreshSeconds ? `load, every ${card.refreshSeconds}s` : 'load'; return `
Loading…
`; }) .join('\n'); return ` ${escapeHtml(this.title)}

${escapeHtml(this.title)}

${cardDivs}
`; } } // ----- Default cards ----- export function summaryCard({ id = 'summary', title = 'Overview', refreshSeconds = 60 } = {}): Card { return { id, refreshSeconds, async render({ db }) { const [leadStats, msgStats, winStats] = await Promise.all([ db.select({ total: count(), optIn: sum(leads.optIn) }).from(leads), db .select({ total: count(), last24h: sum(sql`CASE WHEN ${messages.createdAt} >= datetime('now', '-1 day') THEN 1 ELSE 0 END`), }) .from(messages), db .select({ total: count(), active: sum(sql`CASE WHEN ${messageWindows.endTime} > datetime('now') THEN 1 ELSE 0 END`), }) .from(messageWindows), ]); const l = leadStats[0] ?? { total: 0, optIn: 0 }; const m = msgStats[0] ?? { total: 0, last24h: 0 }; const w = winStats[0] ?? { total: 0, active: 0 }; return kpiCard(title, [ ['Leads', l.total ?? 0], ['Opt-in', Number(l.optIn ?? 0)], ['Msgs (24h)', Number(m.last24h ?? 0)], ['Active windows', Number(w.active ?? 0)], ]); }, }; } export function queueCard({ id = 'queue', title = 'Queue', refreshSeconds = 30 } = {}): Card { return { id, refreshSeconds, async render({ db }) { const rows = await db.select({ status: messageQueue.status, count: count() }).from(messageQueue).groupBy(messageQueue.status); const body = rows .map((x) => `${escapeHtml(x.status)}${x.count}`) .join(''); return `

${escapeHtml(title)}

${body || ''}
StatusCount
empty
`; }, }; } export function funnelCard({ id = 'funnel', title = 'Funnel', refreshSeconds = 60 } = {}): Card { return { id, refreshSeconds, async render({ db }) { const rows = await db.select({ funnelState: leads.funnelState, count: count() }).from(leads).groupBy(leads.funnelState); const max = Math.max(...rows.map((x) => x.count), 1); const bars = rows .map( (x) => `
${escapeHtml(x.funnelState)}
${x.count}
` ) .join(''); return `

${escapeHtml(title)}

${bars || '
empty
'}`; }, }; } export function messagesChartCard({ id = 'messages', title = 'Messages (7d)', refreshSeconds = 60, days = 7 } = {}): Card { return { id, refreshSeconds, async render({ db }) { const cutoff = sql.raw(`(date('now', '-${days} days'))`); const day = sql`date(${messages.createdAt})`; const rows = await db .select({ day, total: count() }) .from(messages) .where(gte(messages.createdAt, cutoff)) .groupBy(day) .orderBy(day); const labels = rows.map((x) => x.day.slice(5)); const values = rows.map((x) => x.total); const cid = `c_${id}_${Date.now()}`; return `

${escapeHtml(title)}

`; }, }; } /** * Daily-active-users line chart with overlay of new users per day. * Reads `messages` for DAU and `leads.created_at` for new-user counts. */ export function dauCard({ id = 'dau', title = 'Daily active users', refreshSeconds = 60, days = 7 } = {}): Card { return { id, refreshSeconds, async render({ db }) { const cutoff = sql.raw(`(date('now', '-${days} days'))`); const msgDay = sql`date(${messages.createdAt})`; const leadDay = sql`date(${leads.createdAt})`; const [dau, newUsers] = await Promise.all([ db .select({ day: msgDay, users: countDistinct(messages.whatsapp) }) .from(messages) .where(gte(messages.createdAt, cutoff)) .groupBy(msgDay) .orderBy(msgDay), db .select({ day: leadDay, newLeads: count() }) .from(leads) .where(gte(leads.createdAt, cutoff)) .groupBy(leadDay) .orderBy(leadDay), ]); const labels = dau.map((x) => x.day.slice(5)); const dauValues = dau.map((x) => x.users ?? 0); const newValues = labels.map((label) => { const match = newUsers.find((d) => d.day.slice(5) === label); return match?.newLeads ?? 0; }); const cid = `c_${id}_${Date.now()}`; return `

${escapeHtml(title)}

`; }, }; } /** * Re-engagement response rate over the last `days`. Reads `engagement_answers` * (yes/no) and `broadcast_log` (sent count) for the given `topicId`. */ export function engagementCard({ id = 'engagement', title = 'Engagement (7d)', refreshSeconds = 60, days = 7, topicId = 1, channel = 'engagement', } = {}): Card { return { id, refreshSeconds, async render({ db }) { const cutoff = sql.raw(`(date('now', '-${days} days'))`); const [answers, sentRows] = await Promise.all([ db .select({ answer: engagementAnswers.answer, count: count() }) .from(engagementAnswers) .where(and(eq(engagementAnswers.engagementId, topicId), gte(engagementAnswers.date, cutoff))) .groupBy(engagementAnswers.answer), db .select({ sent: count() }) .from(broadcastLog) .where(and(eq(broadcastLog.channel, channel), gte(broadcastLog.date, cutoff))), ]); let yes = 0; let no = 0; for (const row of answers) { if (row.answer === 'a') yes = row.count; else if (row.answer === 'b') no = row.count; } const sent = sentRows[0]?.sent ?? 0; const ignored = Math.max(0, sent - yes - no); const responseRate = sent > 0 ? Math.round(((yes + no) / sent) * 100) : 0; const max = Math.max(yes, no, ignored, 1); const bar = (label: string, value: number, color: string) => `
${escapeHtml(label)}
${value}
`; return `

${escapeHtml(title)}

${responseRate}%
Response rate
${sent}
Sent
${bar('Yes', yes, '#10b981')} ${bar('No', no, '#ef4444')} ${bar('Ignored', ignored, '#64748b')}
`; }, }; } /** * Free→paid conversion funnel for any gated feature. * * Pairs with `UsageCounter`: every time `gate.check()` returns * `{ allowed: false }`, the consumer logs `usage.record(whatsapp, feature)`. * This card aggregates those events: * - users blocked (distinct count) * - users converted (blocked + later moved to `convertedFunnelState`) * - conversion rate (converted / blocked) * - total hits * - daily blocked-users series for the last `days` * * Defaults to feature='ai_gate_blocked' and convertedFunnelState='SUBSCRIBE' * but both are configurable. */ export function gateConversionCard({ id = 'gate_conversion', title = 'AI gate conversion', refreshSeconds = 60, feature = 'ai_gate_blocked', convertedFunnelState = 'SUBSCRIBE', days = 7, } = {}): Card { return { id, refreshSeconds, async render({ db }) { const cutoff = sql.raw(`(date('now', '-${days} days'))`); const day = sql`date(${featureUsage.usedAt})`; const [summaryRows, convRows, seriesRows] = await Promise.all([ db .select({ usersBlocked: countDistinct(featureUsage.whatsapp), totalHits: count() }) .from(featureUsage) .where(eq(featureUsage.feature, feature)), db .select({ converted: countDistinct(featureUsage.whatsapp) }) .from(featureUsage) .innerJoin(leads, eq(leads.whatsapp, featureUsage.whatsapp)) .where(and(eq(featureUsage.feature, feature), eq(leads.funnelState, convertedFunnelState))), db .select({ day, users: countDistinct(featureUsage.whatsapp) }) .from(featureUsage) .where(and(eq(featureUsage.feature, feature), gte(featureUsage.usedAt, cutoff))) .groupBy(day) .orderBy(day), ]); const blocked = summaryRows[0]?.usersBlocked ?? 0; const hits = summaryRows[0]?.totalHits ?? 0; const converted = convRows[0]?.converted ?? 0; const rate = blocked > 0 ? Math.round((converted / blocked) * 100) : 0; const labels = seriesRows.map((x) => String(x.day).slice(5)); const values = seriesRows.map((x) => Number(x.users) || 0); const cid = `c_${id}_${Date.now()}`; return `

${escapeHtml(title)}

${blocked}
Blocked
${converted}
Converted
${rate}%
Rate
${hits}
Total hits
`; }, }; } /** * Sequential-plan enrollment table with completion rate per plan. * Reads `plans` + `user_plans`. */ export function plansCard({ id = 'plans', title = 'Plans', refreshSeconds = 60 } = {}): Card { return { id, refreshSeconds, async render({ db }) { const rows = await db .select({ title: plans.title, durationDays: plans.durationDays, active: sum(sql`CASE WHEN ${userPlans.isActive} = 1 THEN 1 ELSE 0 END`), completed: sum(sql`CASE WHEN ${userPlans.completedAt} IS NOT NULL THEN 1 ELSE 0 END`), total: count(), }) .from(userPlans) .innerJoin(plans, eq(plans.id, userPlans.planId)) .groupBy(userPlans.planId); if (!rows.length) return `

${escapeHtml(title)}

No active plans
`; const head = `PlanDaysActiveDoneTotal%`; const body = rows .map((p) => { const total = Number(p.total ?? 0); const completed = Number(p.completed ?? 0); const active = Number(p.active ?? 0); const pct = total ? Math.round((completed / total) * 100) : 0; return `${escapeHtml(p.title)}${p.durationDays}${active}${completed}${total}${pct}%`; }) .join(''); return `

${escapeHtml(title)}

${head}${body}
`; }, }; } /** * Churn — opt-in users with no open message window. Useful early-warning * indicator: when this number trends up, your re-engagement isn't keeping * users inside the 24h window. */ export function churnCard({ id = 'churn', title = 'Churn (opt-in, no window)', refreshSeconds = 60 } = {}): Card { return { id, refreshSeconds, async render({ db }) { const r = await db .select({ churned: count() }) .from(leads) .leftJoin(messageWindows, and(eq(messageWindows.whatsapp, leads.whatsapp), sql`${messageWindows.endTime} > datetime('now')`)) .where(and(eq(leads.optIn, 1), isNull(messageWindows.id))); return kpiCard(title, [['Churned', r[0]?.churned ?? 0]]); }, }; } export const defaultCards = { all: (): Card[] => [summaryCard(), queueCard(), dauCard(), messagesChartCard(), funnelCard(), engagementCard(), plansCard(), gateConversionCard(), churnCard()], summary: (): Card[] => [summaryCard()], core: (): Card[] => [summaryCard(), queueCard(), messagesChartCard(), funnelCard()], }; function kpiCard(title: string, items: Array<[string, string | number]>): string { const tiles = items .map( ([label, value]) => `
${escapeHtml(String(value))}
${escapeHtml(label)}
` ) .join(''); return `

${escapeHtml(title)}

${tiles}
`; } function escapeHtml(s: string): string { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } const DEFAULT_THEME = ` * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 24px; } h1 { font-size: 1.5rem; margin-bottom: 24px; color: #f8fafc; } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-bottom: 24px; } .card { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; } .card h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; color: #94a3b8; margin-bottom: 12px; } .kpi { display: flex; gap: 16px; flex-wrap: wrap; } .kpi-item { flex: 1; min-width: 80px; } .kpi-value { font-size: 1.8rem; font-weight: 700; color: #f8fafc; } .kpi-label { font-size: 0.75rem; color: #94a3b8; margin-top: 2px; } table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #334155; } th { font-size: 0.75rem; text-transform: uppercase; color: #94a3b8; } td { font-size: 0.9rem; } .loading { color: #64748b; font-size: 0.85rem; padding: 12px; } canvas { max-height: 250px; } .bar-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } .bar-label { font-size: 0.8rem; color: #94a3b8; min-width: 100px; } .bar-fill { height: 18px; background: #3b82f6; border-radius: 4px; min-width: 2px; } .bar-value { font-size: 0.8rem; color: #e2e8f0; } .card-error { color: #f87171; } `;