mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-06-27 19:05:48 +08:00
Usage tracking: - new UsageEvent collection records every STT/TTS/LLM call per user with provider, model, volume (audio seconds, characters, tokens) and an estimated USD cost; self-hosted providers (Speaches/Piper) and cache hits record at $0 - pricing table for whisper-1, tts-1, gpt-5-nano & co. in server/utils/usage.ts - weekly KPI mail gains an "AI-Nutzung & Kosten" section: weekly and rolling 30-day cost, per-kind breakdown, top 5 users by cost - quota alert mail when rolling 30-day cost exceeds USAGE_ALERT_USD (default $5), at most once per calendar month (UsageAlertDelivery) Hardening: - /api/atc/say now requires an authenticated session (middleware exemption removed); useFlightLabAudio sends the bearer token - /api/service/tools/latency requires auth (was a public LLM endpoint) - per-user rate limits: PTT 20/min, say 60/min, latency 5/min - cron endpoints (waitlist-drip, weekly-kpi-report) require a shared secret via ?secret= or x-cron-secret (CRON_SECRET, falls back to KPI_CRON_SECRET); allowed with a warning while unset so existing deployments keep working - PTT records the actual transcribed audio duration for billing accuracy Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
505 lines
20 KiB
TypeScript
505 lines
20 KiB
TypeScript
import { FeedbackSubmission } from '../models/FeedbackSubmission'
|
|
import { InvitationCode } from '../models/InvitationCode'
|
|
import { LandingAnalyticsEvent } from '../models/LandingAnalyticsEvent'
|
|
import { ProductUsageSession } from '../models/ProductUsageSession'
|
|
import { WaitlistEntry } from '../models/WaitlistEntry'
|
|
import { summarizeUsage, getRollingCostUsd, type UsageSummary } from './usage'
|
|
|
|
export type KpiProduct = 'classroom' | 'liveatc'
|
|
|
|
const DAY_MS = 1000 * 60 * 60 * 24
|
|
const PRODUCTS: KpiProduct[] = ['classroom', 'liveatc']
|
|
|
|
type ProductKpis = {
|
|
product: KpiProduct
|
|
waitlistEntries: number
|
|
activatedUsers: number
|
|
feedbackCount: number
|
|
usageSessions: number
|
|
usageSeconds: number
|
|
averageUsageSeconds: number
|
|
}
|
|
|
|
type SmartGoal = {
|
|
label: string
|
|
target: string
|
|
actual: string
|
|
met: boolean
|
|
}
|
|
|
|
type FeedbackItem = {
|
|
product: KpiProduct
|
|
createdAt: string
|
|
excitement: number
|
|
from: string
|
|
summary: string
|
|
}
|
|
|
|
export type WeeklyKpiReport = {
|
|
generatedAt: string
|
|
periodStart: string
|
|
periodEnd: string
|
|
totals: {
|
|
waitlistEntries: number
|
|
activatedUsers: number
|
|
feedbackCount: number
|
|
landingViews: number
|
|
landingScrolled: number
|
|
landingScrollRate: number
|
|
landingWaitlistConversionRate: number
|
|
usageSeconds: number
|
|
averageUsageSeconds: number
|
|
}
|
|
products: ProductKpis[]
|
|
smartGoals: SmartGoal[]
|
|
landingTrend: { date: string; views: number; scrolled: number; waitlistEntries: number }[]
|
|
feedback: FeedbackItem[]
|
|
aiUsage: UsageSummary & { rolling30dCostUsd: number }
|
|
}
|
|
|
|
function startOfUtcDay(date: Date) {
|
|
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
|
|
}
|
|
|
|
export function getWeeklyReportWindow(now = new Date()) {
|
|
const periodEnd = now
|
|
const periodStart = new Date(periodEnd.getTime() - 7 * DAY_MS)
|
|
return { periodStart, periodEnd }
|
|
}
|
|
|
|
function pct(numerator: number, denominator: number) {
|
|
if (!denominator) return 0
|
|
return Math.round((numerator / denominator) * 1000) / 10
|
|
}
|
|
|
|
function formatSeconds(seconds: number) {
|
|
if (seconds < 60) return `${Math.round(seconds)}s`
|
|
const minutes = seconds / 60
|
|
if (minutes < 60) return `${Math.round(minutes)}m`
|
|
return `${Math.round((minutes / 60) * 10) / 10}h`
|
|
}
|
|
|
|
function getProductCounts(rows: any[], defaultValue = 0) {
|
|
const counts: Record<KpiProduct, number> = { classroom: defaultValue, liveatc: defaultValue }
|
|
for (const row of rows) {
|
|
const product = row._id === 'liveatc' ? 'liveatc' : 'classroom'
|
|
counts[product] = Number(row.count || 0)
|
|
}
|
|
return counts
|
|
}
|
|
|
|
function getUsageByProduct(rows: any[]) {
|
|
const usage: Record<KpiProduct, { sessions: number; seconds: number }> = {
|
|
classroom: { sessions: 0, seconds: 0 },
|
|
liveatc: { sessions: 0, seconds: 0 },
|
|
}
|
|
for (const row of rows) {
|
|
const product = row._id === 'liveatc' ? 'liveatc' : 'classroom'
|
|
usage[product] = {
|
|
sessions: Number(row.sessions || 0),
|
|
seconds: Number(row.seconds || 0),
|
|
}
|
|
}
|
|
return usage
|
|
}
|
|
|
|
function normalizeDateKey(value: unknown) {
|
|
return typeof value === 'string' && value.length >= 10 ? value.slice(0, 10) : ''
|
|
}
|
|
|
|
function pickFeedbackSummary(doc: any) {
|
|
const parts = [
|
|
doc.classroomNotes,
|
|
doc.frictionNotes,
|
|
doc.highlightNotes,
|
|
doc.otherIdeas,
|
|
Array.isArray(doc.frictionSelections) ? doc.frictionSelections.join(', ') : '',
|
|
Array.isArray(doc.highlightSelections) ? doc.highlightSelections.join(', ') : '',
|
|
]
|
|
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
.filter(Boolean)
|
|
|
|
return (parts[0] || 'No written note.').slice(0, 260)
|
|
}
|
|
|
|
export async function buildWeeklyKpiReport(now = new Date()): Promise<WeeklyKpiReport> {
|
|
const { periodStart, periodEnd } = getWeeklyReportWindow(now)
|
|
|
|
const [
|
|
waitlistByProductRows,
|
|
activatedByProductRows,
|
|
feedbackByProductRows,
|
|
usageByProductRows,
|
|
landingViewRows,
|
|
landingScrolledRows,
|
|
landingTrendRows,
|
|
waitlistTrendRows,
|
|
feedbackDocs,
|
|
] = await Promise.all([
|
|
WaitlistEntry.aggregate([
|
|
{ $match: { joinedAt: { $gte: periodStart, $lt: periodEnd } } },
|
|
{ $group: { _id: { $ifNull: ['$product', 'classroom'] }, count: { $sum: 1 } } },
|
|
]),
|
|
InvitationCode.aggregate([
|
|
{ $match: { usedAt: { $gte: periodStart, $lt: periodEnd }, usedBy: { $exists: true, $ne: null } } },
|
|
{
|
|
$lookup: {
|
|
from: 'waitlistentries',
|
|
localField: '_id',
|
|
foreignField: 'invitationCode',
|
|
as: 'waitlist',
|
|
},
|
|
},
|
|
{ $unwind: { path: '$waitlist', preserveNullAndEmptyArrays: true } },
|
|
{ $group: { _id: { $ifNull: ['$waitlist.product', 'classroom'] }, count: { $sum: 1 } } },
|
|
]),
|
|
FeedbackSubmission.aggregate([
|
|
{ $match: { createdAt: { $gte: periodStart, $lt: periodEnd } } },
|
|
{ $group: { _id: '$product', count: { $sum: 1 } } },
|
|
]),
|
|
ProductUsageSession.aggregate([
|
|
{ $match: { endedAt: { $gte: periodStart, $lt: periodEnd } } },
|
|
{ $group: { _id: '$product', sessions: { $sum: 1 }, seconds: { $sum: '$durationSeconds' } } },
|
|
]),
|
|
LandingAnalyticsEvent.aggregate([
|
|
{ $match: { type: 'view', createdAt: { $gte: periodStart, $lt: periodEnd } } },
|
|
{ $group: { _id: '$sessionId', first: { $first: '$sessionId' } } },
|
|
{ $count: 'count' },
|
|
]),
|
|
LandingAnalyticsEvent.aggregate([
|
|
{ $match: { type: 'scrolled', createdAt: { $gte: periodStart, $lt: periodEnd } } },
|
|
{ $group: { _id: '$sessionId', first: { $first: '$sessionId' } } },
|
|
{ $count: 'count' },
|
|
]),
|
|
LandingAnalyticsEvent.aggregate([
|
|
{ $match: { type: { $in: ['view', 'scrolled'] }, createdAt: { $gte: periodStart, $lt: periodEnd } } },
|
|
{
|
|
$group: {
|
|
_id: { date: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } }, type: '$type' },
|
|
sessions: { $addToSet: '$sessionId' },
|
|
},
|
|
},
|
|
{ $project: { _id: 1, count: { $size: '$sessions' } } },
|
|
]),
|
|
WaitlistEntry.aggregate([
|
|
{ $match: { joinedAt: { $gte: periodStart, $lt: periodEnd } } },
|
|
{ $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$joinedAt' } }, count: { $sum: 1 } } },
|
|
]),
|
|
FeedbackSubmission.find({ createdAt: { $gte: periodStart, $lt: periodEnd } })
|
|
.sort({ createdAt: -1 })
|
|
.limit(12)
|
|
.lean(),
|
|
])
|
|
|
|
const [usageSummary, rolling30dCostUsd] = await Promise.all([
|
|
summarizeUsage(periodStart, periodEnd),
|
|
getRollingCostUsd(30, now),
|
|
])
|
|
|
|
const waitlistCounts = getProductCounts(waitlistByProductRows)
|
|
const activatedCounts = getProductCounts(activatedByProductRows)
|
|
const feedbackCounts = getProductCounts(feedbackByProductRows)
|
|
const usage = getUsageByProduct(usageByProductRows)
|
|
|
|
const products = PRODUCTS.map((product) => {
|
|
const usageSeconds = usage[product].seconds
|
|
const usageSessions = usage[product].sessions
|
|
return {
|
|
product,
|
|
waitlistEntries: waitlistCounts[product],
|
|
activatedUsers: activatedCounts[product],
|
|
feedbackCount: feedbackCounts[product],
|
|
usageSessions,
|
|
usageSeconds,
|
|
averageUsageSeconds: usageSessions ? Math.round(usageSeconds / usageSessions) : 0,
|
|
}
|
|
})
|
|
|
|
const totals = products.reduce(
|
|
(acc, product) => {
|
|
acc.waitlistEntries += product.waitlistEntries
|
|
acc.activatedUsers += product.activatedUsers
|
|
acc.feedbackCount += product.feedbackCount
|
|
acc.usageSeconds += product.usageSeconds
|
|
acc.usageSessions += product.usageSessions
|
|
return acc
|
|
},
|
|
{ waitlistEntries: 0, activatedUsers: 0, feedbackCount: 0, usageSeconds: 0, usageSessions: 0 },
|
|
)
|
|
|
|
const landingViews = Number(landingViewRows?.[0]?.count || 0)
|
|
const landingScrolled = Number(landingScrolledRows?.[0]?.count || 0)
|
|
const landingScrollRate = pct(landingScrolled, landingViews)
|
|
const landingWaitlistConversionRate = pct(totals.waitlistEntries, landingViews)
|
|
const averageUsageSeconds = totals.usageSessions ? Math.round(totals.usageSeconds / totals.usageSessions) : 0
|
|
|
|
const trendMap = new Map<string, { date: string; views: number; scrolled: number; waitlistEntries: number }>()
|
|
const firstDay = startOfUtcDay(periodStart)
|
|
for (let i = 0; i < 7; i += 1) {
|
|
const date = new Date(firstDay.getTime() + i * DAY_MS).toISOString().slice(0, 10)
|
|
trendMap.set(date, { date, views: 0, scrolled: 0, waitlistEntries: 0 })
|
|
}
|
|
|
|
for (const row of landingTrendRows) {
|
|
const date = normalizeDateKey(row?._id?.date)
|
|
const item = date ? trendMap.get(date) : null
|
|
if (!item) continue
|
|
if (row._id.type === 'view') item.views = Number(row.count || 0)
|
|
if (row._id.type === 'scrolled') item.scrolled = Number(row.count || 0)
|
|
}
|
|
|
|
for (const row of waitlistTrendRows) {
|
|
const date = normalizeDateKey(row?._id)
|
|
const item = date ? trendMap.get(date) : null
|
|
if (item) item.waitlistEntries = Number(row.count || 0)
|
|
}
|
|
|
|
const smartGoals: SmartGoal[] = [
|
|
{
|
|
label: 'Waitlist growth',
|
|
target: '>= 10 neue Einträge/Woche',
|
|
actual: `${totals.waitlistEntries}`,
|
|
met: totals.waitlistEntries >= 10,
|
|
},
|
|
{
|
|
label: 'Invite activation',
|
|
target: '>= 35% der neuen Waitlist-Einträge aktivieren',
|
|
actual: `${pct(totals.activatedUsers, Math.max(1, totals.waitlistEntries))}%`,
|
|
met: pct(totals.activatedUsers, Math.max(1, totals.waitlistEntries)) >= 35,
|
|
},
|
|
{
|
|
label: 'Product usage',
|
|
target: '>= 20 Minuten durchschnittliche Nutzdauer',
|
|
actual: formatSeconds(averageUsageSeconds),
|
|
met: averageUsageSeconds >= 20 * 60,
|
|
},
|
|
{
|
|
label: 'Feedback loop',
|
|
target: '>= 3 Feedbacks/Woche',
|
|
actual: `${totals.feedbackCount}`,
|
|
met: totals.feedbackCount >= 3,
|
|
},
|
|
{
|
|
label: 'Landing engagement',
|
|
target: '>= 45% Scrollrate und >= 5% Waitlist-Conversion',
|
|
actual: `${landingScrollRate}% Scroll / ${landingWaitlistConversionRate}% Waitlist`,
|
|
met: landingScrollRate >= 45 && landingWaitlistConversionRate >= 5,
|
|
},
|
|
]
|
|
|
|
return {
|
|
generatedAt: now.toISOString(),
|
|
periodStart: periodStart.toISOString(),
|
|
periodEnd: periodEnd.toISOString(),
|
|
totals: {
|
|
waitlistEntries: totals.waitlistEntries,
|
|
activatedUsers: totals.activatedUsers,
|
|
feedbackCount: totals.feedbackCount,
|
|
landingViews,
|
|
landingScrolled,
|
|
landingScrollRate,
|
|
landingWaitlistConversionRate,
|
|
usageSeconds: totals.usageSeconds,
|
|
averageUsageSeconds,
|
|
},
|
|
products,
|
|
smartGoals,
|
|
landingTrend: Array.from(trendMap.values()),
|
|
feedback: feedbackDocs.map((doc: any) => ({
|
|
product: doc.product === 'liveatc' ? 'liveatc' : 'classroom',
|
|
createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : now.toISOString(),
|
|
excitement: Number(doc.excitement || 0),
|
|
from: doc.name || doc.email || doc.discordHandle || 'anonymous',
|
|
summary: pickFeedbackSummary(doc),
|
|
})),
|
|
aiUsage: { ...usageSummary, rolling30dCostUsd },
|
|
}
|
|
}
|
|
|
|
function formatUsd(value: number) {
|
|
return `$${(Math.round(value * 10000) / 10000).toFixed(4)}`
|
|
}
|
|
|
|
function formatTokens(value: number) {
|
|
if (value >= 1_000_000) return `${Math.round((value / 1_000_000) * 10) / 10}M`
|
|
if (value >= 1_000) return `${Math.round((value / 1_000) * 10) / 10}k`
|
|
return String(value)
|
|
}
|
|
|
|
function escapeHtml(value: unknown) {
|
|
return String(value ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|
|
|
|
export function renderWeeklyKpiText(report: WeeklyKpiReport) {
|
|
const lines = [
|
|
`OpenSquawk KPI Report (${report.periodStart.slice(0, 10)} - ${report.periodEnd.slice(0, 10)})`,
|
|
'',
|
|
`Waitlist: ${report.totals.waitlistEntries}`,
|
|
`Neue User durch genutzte Invitations: ${report.totals.activatedUsers}`,
|
|
`Durchschnittliche Nutzdauer: ${formatSeconds(report.totals.averageUsageSeconds)}`,
|
|
`Feedback: ${report.totals.feedbackCount}`,
|
|
`Landing Views: ${report.totals.landingViews}`,
|
|
`Scrollrate: ${report.totals.landingScrollRate}%`,
|
|
`Waitlist Conversion: ${report.totals.landingWaitlistConversionRate}%`,
|
|
'',
|
|
'SMART Ziele:',
|
|
...report.smartGoals.map((goal) => `- ${goal.met ? 'OK' : 'Off track'} ${goal.label}: ${goal.actual} (Ziel: ${goal.target})`),
|
|
'',
|
|
'Produkte:',
|
|
...report.products.map((product) => `- ${product.product}: Waitlist ${product.waitlistEntries}, User ${product.activatedUsers}, Nutzung ${formatSeconds(product.averageUsageSeconds)} avg, Feedback ${product.feedbackCount}`),
|
|
'',
|
|
'AI-Nutzung (diese Woche):',
|
|
`- Kosten: ${formatUsd(report.aiUsage.costUsd)} (letzte 30 Tage: ${formatUsd(report.aiUsage.rolling30dCostUsd)})`,
|
|
`- STT: ${formatSeconds(report.aiUsage.sttSeconds)} Audio (${report.aiUsage.byKind.stt.events} Requests, ${formatUsd(report.aiUsage.byKind.stt.costUsd)})`,
|
|
`- TTS: ${formatTokens(report.aiUsage.ttsCharacters)} Zeichen (${report.aiUsage.byKind.tts.events} Requests, ${formatUsd(report.aiUsage.byKind.tts.costUsd)})`,
|
|
`- LLM: ${formatTokens(report.aiUsage.llmInputTokens)} in / ${formatTokens(report.aiUsage.llmOutputTokens)} out Tokens (${report.aiUsage.byKind.llm.events} Requests, ${formatUsd(report.aiUsage.byKind.llm.costUsd)})`,
|
|
'Top User nach Kosten:',
|
|
...(report.aiUsage.topUsers.length
|
|
? report.aiUsage.topUsers.map((u) => `- ${u.email}: ${formatUsd(u.costUsd)} (${u.events} Requests)`)
|
|
: ['- keine Nutzungsdaten in diesem Zeitraum']),
|
|
'',
|
|
'Feedback:',
|
|
...report.feedback.map((item) => `- ${item.product} ${item.excitement}/5 ${item.from}: ${item.summary}`),
|
|
]
|
|
|
|
return lines.join('\n')
|
|
}
|
|
|
|
export function renderWeeklyKpiEmail(report: WeeklyKpiReport) {
|
|
const goalRows = report.smartGoals
|
|
.map((goal) => `
|
|
<tr>
|
|
<td>${escapeHtml(goal.label)}</td>
|
|
<td>${escapeHtml(goal.target)}</td>
|
|
<td><strong>${escapeHtml(goal.actual)}</strong></td>
|
|
<td><span class="pill ${goal.met ? 'ok' : 'warn'}">${goal.met ? 'On track' : 'Off track'}</span></td>
|
|
</tr>`)
|
|
.join('')
|
|
|
|
const productRows = report.products
|
|
.map((product) => `
|
|
<tr>
|
|
<td><strong>${escapeHtml(product.product === 'classroom' ? '1. Classroom' : '2. LiveATC')}</strong></td>
|
|
<td>${product.waitlistEntries}</td>
|
|
<td>${product.activatedUsers}</td>
|
|
<td>${formatSeconds(product.averageUsageSeconds)}</td>
|
|
<td>${product.feedbackCount}</td>
|
|
</tr>`)
|
|
.join('')
|
|
|
|
const trendRows = report.landingTrend
|
|
.map((item) => `
|
|
<tr>
|
|
<td>${escapeHtml(item.date)}</td>
|
|
<td>${item.views}</td>
|
|
<td>${item.scrolled}</td>
|
|
<td>${item.waitlistEntries}</td>
|
|
</tr>`)
|
|
.join('')
|
|
|
|
const usageTopUserRows = report.aiUsage.topUsers.length
|
|
? report.aiUsage.topUsers.map((u) => `
|
|
<tr>
|
|
<td>${escapeHtml(u.email)}</td>
|
|
<td>${u.events}</td>
|
|
<td>${escapeHtml(formatUsd(u.costUsd))}</td>
|
|
</tr>`).join('')
|
|
: '<tr><td colspan="3">Keine Nutzungsdaten in diesem Zeitraum.</td></tr>'
|
|
|
|
const usageKindRows = (['stt', 'tts', 'llm'] as const)
|
|
.map((kind) => {
|
|
const row = report.aiUsage.byKind[kind]
|
|
const detail = kind === 'stt'
|
|
? `${formatSeconds(report.aiUsage.sttSeconds)} Audio`
|
|
: kind === 'tts'
|
|
? `${formatTokens(report.aiUsage.ttsCharacters)} Zeichen`
|
|
: `${formatTokens(report.aiUsage.llmInputTokens)} in / ${formatTokens(report.aiUsage.llmOutputTokens)} out`
|
|
return `
|
|
<tr>
|
|
<td><strong>${kind.toUpperCase()}</strong></td>
|
|
<td>${row.events}</td>
|
|
<td>${escapeHtml(detail)}</td>
|
|
<td>${escapeHtml(formatUsd(row.costUsd))}</td>
|
|
</tr>`
|
|
})
|
|
.join('')
|
|
|
|
const feedbackRows = report.feedback.length
|
|
? report.feedback.map((item) => `
|
|
<tr>
|
|
<td>${escapeHtml(item.product)}</td>
|
|
<td>${escapeHtml(item.createdAt.slice(0, 10))}</td>
|
|
<td>${item.excitement}/5</td>
|
|
<td>${escapeHtml(item.from)}</td>
|
|
<td>${escapeHtml(item.summary)}</td>
|
|
</tr>`).join('')
|
|
: '<tr><td colspan="5">Kein neues gespeichertes Feedback in diesem Zeitraum.</td></tr>'
|
|
|
|
return `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>
|
|
body { margin: 0; background: #0b1020; color: #e5f7ff; font-family: Arial, sans-serif; }
|
|
.wrap { max-width: 920px; margin: 0 auto; padding: 28px 18px; }
|
|
.panel { background: #0a0f1c; border: 1px solid rgba(255,255,255,.12); border-radius: 18px; overflow: hidden; }
|
|
.hero { padding: 30px; background: linear-gradient(135deg, rgba(34,211,238,.18), rgba(14,165,233,.08)); }
|
|
h1 { margin: 0; font-size: 28px; line-height: 1.2; color: #ffffff; }
|
|
h2 { margin: 30px 0 12px; font-size: 18px; color: #ffffff; }
|
|
.muted { color: rgba(229,247,255,.72); }
|
|
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; padding: 20px 30px 6px; }
|
|
.card { border: 1px solid rgba(255,255,255,.10); border-radius: 14px; padding: 14px; background: rgba(255,255,255,.04); }
|
|
.value { display: block; margin-top: 8px; font-size: 24px; font-weight: 700; color: #67e8f9; }
|
|
.content { padding: 0 30px 30px; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th, td { padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,.10); text-align: left; vertical-align: top; font-size: 13px; }
|
|
th { color: rgba(229,247,255,.68); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
|
|
.pill { display: inline-block; border-radius: 999px; padding: 4px 9px; font-size: 12px; font-weight: 700; }
|
|
.pill.ok { background: rgba(34,197,94,.18); color: #86efac; }
|
|
.pill.warn { background: rgba(245,158,11,.18); color: #fcd34d; }
|
|
@media (max-width: 720px) { .grid { grid-template-columns: repeat(2, 1fr); } .hero, .content, .grid { padding-left: 18px; padding-right: 18px; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<div class="panel">
|
|
<div class="hero">
|
|
<p class="muted">OpenSquawk KPI Report</p>
|
|
<h1>${escapeHtml(report.periodStart.slice(0, 10))} bis ${escapeHtml(report.periodEnd.slice(0, 10))}</h1>
|
|
</div>
|
|
<div class="grid">
|
|
<div class="card"><span class="muted">Waitlist</span><span class="value">${report.totals.waitlistEntries}</span></div>
|
|
<div class="card"><span class="muted">Neue User</span><span class="value">${report.totals.activatedUsers}</span></div>
|
|
<div class="card"><span class="muted">Nutzung Ø</span><span class="value">${formatSeconds(report.totals.averageUsageSeconds)}</span></div>
|
|
<div class="card"><span class="muted">Feedback</span><span class="value">${report.totals.feedbackCount}</span></div>
|
|
<div class="card"><span class="muted">Landing Views</span><span class="value">${report.totals.landingViews}</span></div>
|
|
<div class="card"><span class="muted">Scrollrate</span><span class="value">${report.totals.landingScrollRate}%</span></div>
|
|
<div class="card"><span class="muted">Waitlist Conv.</span><span class="value">${report.totals.landingWaitlistConversionRate}%</span></div>
|
|
<div class="card"><span class="muted">Gesamtzeit</span><span class="value">${formatSeconds(report.totals.usageSeconds)}</span></div>
|
|
</div>
|
|
<div class="content">
|
|
<h2>SMART Ziele</h2>
|
|
<table><thead><tr><th>KPI</th><th>Ziel</th><th>Ist</th><th>Status</th></tr></thead><tbody>${goalRows}</tbody></table>
|
|
<h2>Produkte</h2>
|
|
<table><thead><tr><th>Produkt</th><th>Waitlist</th><th>Neue User</th><th>Nutzung Ø</th><th>Feedback</th></tr></thead><tbody>${productRows}</tbody></table>
|
|
<h2>AI-Nutzung & Kosten</h2>
|
|
<p class="muted">Diese Woche: <strong>${escapeHtml(formatUsd(report.aiUsage.costUsd))}</strong> · Letzte 30 Tage: <strong>${escapeHtml(formatUsd(report.aiUsage.rolling30dCostUsd))}</strong></p>
|
|
<table><thead><tr><th>Art</th><th>Requests</th><th>Volumen</th><th>Kosten</th></tr></thead><tbody>${usageKindRows}</tbody></table>
|
|
<h2>Top User nach Kosten</h2>
|
|
<table><thead><tr><th>User</th><th>Requests</th><th>Kosten</th></tr></thead><tbody>${usageTopUserRows}</tbody></table>
|
|
<h2>Landing Verlauf</h2>
|
|
<table><thead><tr><th>Datum</th><th>Views</th><th>Scrolled</th><th>Waitlist</th></tr></thead><tbody>${trendRows}</tbody></table>
|
|
<h2>Feedback</h2>
|
|
<table><thead><tr><th>Produkt</th><th>Datum</th><th>Rating</th><th>Von</th><th>Inhalt</th></tr></thead><tbody>${feedbackRows}</tbody></table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`
|
|
}
|