Wochenreport

This commit is contained in:
itsrubberduck
2026-05-06 17:38:36 +02:00
parent 2bcd27c635
commit f38b47acbd
14 changed files with 954 additions and 1 deletions

View File

@@ -5,7 +5,7 @@
</v-app>
</template>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
import { useHotjar, useRoute, useState } from '#imports';
import { useAuthStore } from '~/stores/auth';
import { HOTJAR_LOCAL_STORAGE_KEY, useCookieConsent } from '~/composables/useCookieConsent';
@@ -26,6 +26,7 @@ const authStore = useAuthStore();
const { hasConsent, analyticsEnabled } = useCookieConsent();
const { initialize } = useHotjar();
const hotjarInitialized = useState('hotjar-initialized', () => false);
const productSession = useState<{ product: 'classroom' | 'liveatc'; path: string; startedAt: number } | null>('product-usage-session', () => null);
const showCookieBanner = computed(() => {
if (!authStore.isAuthenticated) {
@@ -76,6 +77,74 @@ const scheduleHotjarInitialization = () => {
}, HOTJAR_INIT_DELAY);
};
const resolveTrackedProduct = (path: string): 'classroom' | 'liveatc' | null => {
if (path.startsWith('/classroom')) return 'classroom';
if (path.startsWith('/pm') || path.startsWith('/copilot') || path.startsWith('/bridge')) return 'liveatc';
return null;
};
const flushProductSession = () => {
if (typeof window === 'undefined' || !productSession.value) {
return;
}
const session = productSession.value;
productSession.value = null;
const endedAt = Date.now();
const durationSeconds = Math.round((endedAt - session.startedAt) / 1000);
if (durationSeconds < 5) {
return;
}
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (authStore.accessToken) {
headers.Authorization = `Bearer ${authStore.accessToken}`;
}
fetch('/api/service/analytics/product-session', {
method: 'POST',
headers,
body: JSON.stringify({
product: session.product,
path: session.path,
durationSeconds,
startedAt: new Date(session.startedAt).toISOString(),
endedAt: new Date(endedAt).toISOString(),
}),
keepalive: true,
}).catch(() => undefined);
};
const syncProductSession = () => {
if (typeof window === 'undefined') {
return;
}
const product = authStore.isAuthenticated ? resolveTrackedProduct(route.path) : null;
const current = productSession.value;
if (current && (!product || current.product !== product || current.path !== route.path)) {
flushProductSession();
}
if (product && !productSession.value) {
productSession.value = {
product,
path: route.path,
startedAt: Date.now(),
};
}
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
flushProductSession();
} else {
syncProductSession();
}
};
onMounted(() => {
if (typeof window === 'undefined') {
return;
@@ -111,5 +180,22 @@ onMounted(() => {
},
{ immediate: true }
);
watch(
[() => route.path, () => authStore.isAuthenticated],
() => syncProductSession(),
{ immediate: true }
);
window.addEventListener('beforeunload', flushProductSession);
document.addEventListener('visibilitychange', handleVisibilityChange);
});
onBeforeUnmount(() => {
flushProductSession();
if (typeof window !== 'undefined') {
window.removeEventListener('beforeunload', flushProductSession);
document.removeEventListener('visibilitychange', handleVisibilityChange);
}
});
</script>

View File

@@ -918,6 +918,8 @@ interface ScrollOptions {
let sectionElements: { hash: string; element: HTMLElement }[] = []
let isInternalNavigation = false
let parallaxAnimationFrame: number | null = null
const landingAnalyticsSessionId = ref('')
const landingScrollTracked = ref(false)
const clampParallax = (value: number) => Math.max(-PARALLAX_LIMIT, Math.min(PARALLAX_LIMIT, value))
@@ -997,6 +999,7 @@ const updateActiveSection = () => {
const handleScroll = () => {
updateActiveSection()
scheduleParallaxUpdate()
trackLandingScrollIfNeeded()
}
const handleResize = () => {
@@ -1319,6 +1322,49 @@ const activeReferralToken = computed(() => {
})
const supportsNativeShare = computed(() => import.meta.client && typeof navigator !== 'undefined' && typeof navigator.share === 'function')
const getLandingAnalyticsSessionId = () => {
if (!import.meta.client) return ''
const storageKey = 'os_landing_analytics_session'
const existing = window.sessionStorage.getItem(storageKey)
if (existing) return existing
const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`
window.sessionStorage.setItem(storageKey, id)
return id
}
const sendLandingAnalytics = async (type: 'view' | 'scrolled' | 'waitlist_submit', scrollDepth?: number) => {
if (!import.meta.client) return
try {
await api.post(
'/api/service/analytics/landing',
{
type,
product: 'classroom',
sessionId: landingAnalyticsSessionId.value || getLandingAnalyticsSessionId(),
path: route.path,
source: 'landing-phase1-cta',
scrollDepth,
},
{ auth: false },
)
} catch (error) {
console.warn('Could not track landing analytics event', error)
}
}
const trackLandingScrollIfNeeded = () => {
if (!import.meta.client || landingScrollTracked.value) return
const scrollableHeight = Math.max(1, document.documentElement.scrollHeight - window.innerHeight)
const depth = Math.round((window.scrollY / scrollableHeight) * 100)
if (depth >= 50) {
landingScrollTracked.value = true
void sendLandingAnalytics('scrolled', depth)
}
}
const waitlistCountDisplay = computed(() => formatNumber(waitlistStats.value?.displayCount ?? 0))
const waitlistReferralJoinsDisplay = computed(() => formatNumber(waitlistStats.value?.referralJoins ?? 0))
const waitlistLastJoinedFormatted = computed(() => {
@@ -1414,12 +1460,14 @@ async function submitWaitlist() {
consentTerms: waitlistForm.consentTerms,
wantsProductUpdates: waitlistForm.subscribeUpdates,
source: 'landing-phase1-cta',
product: 'classroom',
referralToken: activeReferralToken.value || undefined,
},
{ auth: false },
)
waitlistSuccess.value = true
void sendLandingAnalytics('waitlist_submit')
waitlistReferralToken.value = response.referralToken || ''
waitlistReferralUrl.value = response.referralUrl || ''
await loadWaitlistStats()
@@ -1545,6 +1593,8 @@ onMounted(() => {
if (!import.meta.client) return
rotateCaptchaChallenge(waitlistCaptcha)
landingAnalyticsSessionId.value = getLandingAnalyticsSessionId()
void sendLandingAnalytics('view')
nextTick(() => {
updateHeaderHeight()

View File

@@ -0,0 +1,42 @@
import { createError, readBody } from 'h3'
import { LandingAnalyticsEvent, type LandingAnalyticsEventType } from '../../../models/LandingAnalyticsEvent'
interface LandingAnalyticsBody {
type?: LandingAnalyticsEventType
product?: string
sessionId?: string
path?: string
source?: string
scrollDepth?: number
}
const allowedTypes = new Set<LandingAnalyticsEventType>(['view', 'scrolled', 'waitlist_submit'])
function cleanText(value: unknown, maxLength = 180) {
return typeof value === 'string' ? value.trim().slice(0, maxLength) : undefined
}
export default defineEventHandler(async (event) => {
const body = await readBody<LandingAnalyticsBody>(event).catch(() => ({} as LandingAnalyticsBody))
const type = body.type && allowedTypes.has(body.type) ? body.type : null
if (!type) {
throw createError({ statusCode: 400, statusMessage: 'Valid analytics event type is required.' })
}
const scrollDepth =
typeof body.scrollDepth === 'number' && Number.isFinite(body.scrollDepth)
? Math.max(0, Math.min(100, Math.round(body.scrollDepth)))
: undefined
await LandingAnalyticsEvent.create({
type,
product: body.product === 'liveatc' ? 'liveatc' : 'classroom',
sessionId: cleanText(body.sessionId, 80),
path: cleanText(body.path, 180),
source: cleanText(body.source, 180),
scrollDepth,
})
return { success: true }
})

View File

@@ -0,0 +1,57 @@
import { createError, readBody } from 'h3'
import { getUserFromEvent } from '../../../utils/auth'
import { ProductUsageSession } from '../../../models/ProductUsageSession'
interface ProductSessionBody {
product?: string
path?: string
durationSeconds?: number
startedAt?: string
endedAt?: string
}
function cleanPath(value: unknown) {
return typeof value === 'string' ? value.trim().slice(0, 180) : undefined
}
function parseDate(value: unknown, fallback: Date) {
if (typeof value !== 'string') {
return fallback
}
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? fallback : parsed
}
export default defineEventHandler(async (event) => {
const user = await getUserFromEvent(event)
const body = await readBody<ProductSessionBody>(event).catch(() => ({} as ProductSessionBody))
const product = body.product === 'liveatc' ? 'liveatc' : body.product === 'classroom' ? 'classroom' : null
if (!product) {
throw createError({ statusCode: 400, statusMessage: 'Valid product is required.' })
}
const durationSeconds =
typeof body.durationSeconds === 'number' && Number.isFinite(body.durationSeconds)
? Math.max(1, Math.min(60 * 60 * 6, Math.round(body.durationSeconds)))
: 0
if (durationSeconds < 5) {
return { success: true, ignored: true }
}
const endedAt = parseDate(body.endedAt, new Date())
const startedAt = parseDate(body.startedAt, new Date(endedAt.getTime() - durationSeconds * 1000))
await ProductUsageSession.create({
user: user?._id,
product,
path: cleanPath(body.path),
durationSeconds,
startedAt,
endedAt,
})
return { success: true }
})

View File

@@ -1,6 +1,7 @@
import mongoose from 'mongoose'
import { defineEventHandler } from 'h3'
import { InvitationCode } from '../../../models/InvitationCode'
import { KpiReportDelivery } from '../../../models/KpiReportDelivery'
import { WaitlistEntry } from '../../../models/WaitlistEntry'
import { sendMail } from '../../../utils/notifications'
import {
@@ -10,10 +11,87 @@ import {
renderInvitationEmail,
renderInvitationText,
} from '../../../utils/invitations'
import { buildWeeklyKpiReport, renderWeeklyKpiEmail, renderWeeklyKpiText } from '../../../utils/kpiReport'
const DAY_MS = 1000 * 60 * 60 * 24
const INVITATION_DELAY_DAYS = 5
const FEEDBACK_DELAY_DAYS = 14
const KPI_RECIPIENT = 'opensquawk-kpi@faktorxmensch.com'
const KPI_TIME_ZONE = 'Europe/Berlin'
const KPI_SEND_AFTER_HOUR = 9
function getBerlinDateParts(date: Date) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: KPI_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(date)
const value = (type: string) => Number(parts.find((part) => part.type === type)?.value || 0)
return {
year: value('year'),
month: value('month'),
day: value('day'),
hour: value('hour'),
minute: value('minute'),
}
}
function getWeeklyKpiSchedule(now: Date) {
const local = getBerlinDateParts(now)
const localDay = new Date(Date.UTC(local.year, local.month - 1, local.day))
const dayOfWeek = localDay.getUTCDay() || 7
const monday = new Date(localDay.getTime() - (dayOfWeek - 1) * DAY_MS)
const previousMonday = new Date(monday.getTime() - 7 * DAY_MS)
const canSend = dayOfWeek > 1 || local.hour >= KPI_SEND_AFTER_HOUR
const periodStart = previousMonday
const periodEnd = monday
const weekKey = periodEnd.toISOString().slice(0, 10)
return { canSend, weekKey, periodStart, periodEnd }
}
async function sendWeeklyKpiReportIfDue(now: Date) {
const schedule = getWeeklyKpiSchedule(now)
if (!schedule.canSend) {
return { sent: false, skipped: 'before-weekly-window' }
}
const alreadySent = await KpiReportDelivery.exists({ weekKey: schedule.weekKey })
if (alreadySent) {
return { sent: false, skipped: 'already-sent' }
}
const report = await buildWeeklyKpiReport(schedule.periodEnd)
const subject = `OpenSquawk KPI Report ${report.periodStart.slice(0, 10)} - ${report.periodEnd.slice(0, 10)}`
const mailAccepted = await sendMail({
to: KPI_RECIPIENT,
subject,
text: renderWeeklyKpiText(report),
html: renderWeeklyKpiEmail(report),
})
await KpiReportDelivery.create({
weekKey: schedule.weekKey,
periodStart: new Date(report.periodStart),
periodEnd: new Date(report.periodEnd),
recipient: KPI_RECIPIENT,
sentAt: now,
mailAccepted,
})
return {
sent: true,
mailAccepted,
recipient: KPI_RECIPIENT,
periodStart: report.periodStart,
periodEnd: report.periodEnd,
}
}
export default defineEventHandler(async () => {
const now = new Date()
@@ -107,8 +185,12 @@ export default defineEventHandler(async () => {
console.log(`[waitlist-drip] invitations sent: ${invitationsSent}`)
console.log(`[waitlist-drip] feedback requests sent: ${feedbackRequests}`)
const kpiReport = await sendWeeklyKpiReportIfDue(now)
console.log(`[waitlist-drip] weekly KPI report: ${kpiReport.sent ? 'sent' : `skipped (${kpiReport.skipped})`}`)
return {
invitationsSent,
feedbackRequests,
kpiReport,
}
})

View File

@@ -0,0 +1,38 @@
import { createError, getQuery } from 'h3'
import { sendMail } from '../../../utils/notifications'
import { buildWeeklyKpiReport, renderWeeklyKpiEmail, renderWeeklyKpiText } from '../../../utils/kpiReport'
const DEFAULT_KPI_RECIPIENT = 'opensquawk-kpi@faktorxmensch.com'
export default defineEventHandler(async (event) => {
const secret = process.env.KPI_CRON_SECRET?.trim()
if (secret) {
const query = getQuery(event)
const provided = typeof query.secret === 'string' ? query.secret : ''
if (provided !== secret) {
throw createError({ statusCode: 401, statusMessage: 'Invalid KPI cron secret.' })
}
}
const report = await buildWeeklyKpiReport()
const to = process.env.KPI_EMAIL_TO || DEFAULT_KPI_RECIPIENT
const subject = `OpenSquawk KPI Report ${report.periodStart.slice(0, 10)} - ${report.periodEnd.slice(0, 10)}`
const sent = await sendMail({
to,
subject,
text: renderWeeklyKpiText(report),
html: renderWeeklyKpiEmail(report),
})
return {
success: true,
sent,
to,
periodStart: report.periodStart,
periodEnd: report.periodEnd,
totals: report.totals,
products: report.products,
smartGoals: report.smartGoals,
}
})

View File

@@ -1,4 +1,5 @@
import { createError, readBody } from 'h3'
import { FeedbackSubmission } from '../../../models/FeedbackSubmission'
import { sendAdminNotification } from '../../../utils/notifications'
interface FeedbackRequestBody {
@@ -14,6 +15,7 @@ interface FeedbackRequestBody {
hostingInterest?: string
otherIdeas?: string
contactConsent?: boolean
product?: string
}
function ensureRating(value: unknown) {
@@ -62,8 +64,25 @@ export default defineEventHandler(async (event) => {
const highlightSelections = normaliseArray(body.highlightSelections)
const frictionSelections = normaliseArray(body.frictionSelections)
const allowContact = Boolean(body.contactConsent)
const product = body.product === 'liveatc' ? 'liveatc' : 'classroom'
const fromAddress = email ? (name ? `${name} <${email}>` : email) : undefined
await FeedbackSubmission.create({
product,
name,
email,
discordHandle,
excitement,
highlightSelections,
highlightNotes,
frictionSelections,
frictionNotes,
classroomNotes,
hostingInterest,
otherIdeas,
contactConsent: allowContact,
})
const details: string[] = []
details.push(`Overall excitement: ${excitement}/5`)
details.push(`Highlights: ${highlightSelections.length ? highlightSelections.join(', ') : '—'}`)
@@ -107,6 +126,7 @@ export default defineEventHandler(async (event) => {
['Email', email || '—'],
['Discord', discordHandle || '—'],
['Okay to contact', allowContact ? 'Yes' : 'No'],
['Product', product],
],
replyTo: fromAddress,
})

View File

@@ -1,5 +1,6 @@
import { readBody, createError, getRequestURL, type H3Event } from 'h3'
import { WaitlistEntry, type WaitlistEntryDocument } from '../../models/WaitlistEntry'
import { LandingAnalyticsEvent } from '../../models/LandingAnalyticsEvent'
import { sendAdminNotification } from '../../utils/notifications'
import { registerUpdateSubscriber } from '../../utils/subscribers'
import {
@@ -14,6 +15,7 @@ interface WaitlistRequestBody {
consentPrivacy?: boolean
consentTerms?: boolean
source?: string
product?: string
wantsProductUpdates?: boolean
referralToken?: string
}
@@ -62,6 +64,7 @@ export default defineEventHandler(async (event) => {
const name = body.name?.trim()
const notes = body.notes?.trim()
const source = body.source?.trim() || 'landing'
const product = body.product === 'liveatc' ? 'liveatc' : 'classroom'
const wantsProductUpdates = Boolean(body.wantsProductUpdates)
const normalizedReferralToken = normalizeWaitlistReferralToken(body.referralToken)
const fromAddress = email ? (name ? `${name} <${email}>` : email) : undefined
@@ -102,6 +105,7 @@ export default defineEventHandler(async (event) => {
existing.name = name || existing.name
existing.notes = notes || existing.notes
existing.source = referralSource
existing.product = product
existing.consentPrivacy = true
existing.consentTerms = true
if (wantsProductUpdates && !previouslyWantedUpdates) {
@@ -164,6 +168,7 @@ export default defineEventHandler(async (event) => {
name,
notes,
source: referralSource,
product,
consentPrivacy: true,
consentTerms: true,
joinedAt: now,
@@ -173,6 +178,13 @@ export default defineEventHandler(async (event) => {
referredBy: canAttributeReferral && referrer ? (referrer._id as any) : undefined,
})
await LandingAnalyticsEvent.create({
type: 'waitlist_submit',
product,
path: '/',
source: referralSource,
}).catch(() => undefined)
if (canAttributeReferral) {
await applyReferralAttribution(referrer)
}
@@ -197,6 +209,7 @@ export default defineEventHandler(async (event) => {
dataEntries.push(['Notes', notes])
}
dataEntries.push(['Source', referralSource])
dataEntries.push(['Product', product])
dataEntries.push(['Opt-in', wantsProductUpdates ? 'Product updates' : 'Waitlist only'])
if (canAttributeReferral && referrer) {
dataEntries.push(['Referral', normalizedReferralToken as string])

View File

@@ -0,0 +1,41 @@
import mongoose from 'mongoose'
export type FeedbackProduct = 'classroom' | 'liveatc'
export interface FeedbackSubmissionDocument extends mongoose.Document {
product: FeedbackProduct
name?: string
email?: string
discordHandle?: string
excitement: number
highlightSelections: string[]
highlightNotes?: string
frictionSelections: string[]
frictionNotes?: string
classroomNotes?: string
hostingInterest?: string
otherIdeas?: string
contactConsent: boolean
createdAt: Date
}
const feedbackSubmissionSchema = new mongoose.Schema<FeedbackSubmissionDocument>({
product: { type: String, enum: ['classroom', 'liveatc'], default: 'classroom', index: true },
name: { type: String, trim: true },
email: { type: String, lowercase: true, trim: true },
discordHandle: { type: String, trim: true },
excitement: { type: Number, required: true, min: 1, max: 5 },
highlightSelections: { type: [String], default: () => [] },
highlightNotes: { type: String, trim: true },
frictionSelections: { type: [String], default: () => [] },
frictionNotes: { type: String, trim: true },
classroomNotes: { type: String, trim: true },
hostingInterest: { type: String, trim: true },
otherIdeas: { type: String, trim: true },
contactConsent: { type: Boolean, default: false },
createdAt: { type: Date, default: () => new Date(), index: true },
})
export const FeedbackSubmission =
(mongoose.models.FeedbackSubmission as mongoose.Model<FeedbackSubmissionDocument> | undefined) ||
mongoose.model<FeedbackSubmissionDocument>('FeedbackSubmission', feedbackSubmissionSchema)

View File

@@ -0,0 +1,23 @@
import mongoose from 'mongoose'
export interface KpiReportDeliveryDocument extends mongoose.Document {
weekKey: string
periodStart: Date
periodEnd: Date
recipient: string
sentAt: Date
mailAccepted: boolean
}
const kpiReportDeliverySchema = new mongoose.Schema<KpiReportDeliveryDocument>({
weekKey: { type: String, required: true, unique: true, trim: true, index: true },
periodStart: { type: Date, required: true },
periodEnd: { type: Date, required: true },
recipient: { type: String, required: true, trim: true },
sentAt: { type: Date, default: () => new Date() },
mailAccepted: { type: Boolean, default: false },
})
export const KpiReportDelivery =
(mongoose.models.KpiReportDelivery as mongoose.Model<KpiReportDeliveryDocument> | undefined) ||
mongoose.model<KpiReportDeliveryDocument>('KpiReportDelivery', kpiReportDeliverySchema)

View File

@@ -0,0 +1,28 @@
import mongoose from 'mongoose'
export type LandingAnalyticsEventType = 'view' | 'scrolled' | 'waitlist_submit'
export type AnalyticsProduct = 'classroom' | 'liveatc'
export interface LandingAnalyticsEventDocument extends mongoose.Document {
type: LandingAnalyticsEventType
product: AnalyticsProduct
sessionId?: string
path?: string
source?: string
scrollDepth?: number
createdAt: Date
}
const landingAnalyticsEventSchema = new mongoose.Schema<LandingAnalyticsEventDocument>({
type: { type: String, enum: ['view', 'scrolled', 'waitlist_submit'], required: true, index: true },
product: { type: String, enum: ['classroom', 'liveatc'], default: 'classroom', index: true },
sessionId: { type: String, trim: true, index: true },
path: { type: String, trim: true },
source: { type: String, trim: true },
scrollDepth: { type: Number, min: 0, max: 100 },
createdAt: { type: Date, default: () => new Date(), index: true },
})
export const LandingAnalyticsEvent =
(mongoose.models.LandingAnalyticsEvent as mongoose.Model<LandingAnalyticsEventDocument> | undefined) ||
mongoose.model<LandingAnalyticsEventDocument>('LandingAnalyticsEvent', landingAnalyticsEventSchema)

View File

@@ -0,0 +1,27 @@
import mongoose from 'mongoose'
export type ProductUsageKey = 'classroom' | 'liveatc'
export interface ProductUsageSessionDocument extends mongoose.Document {
user?: mongoose.Types.ObjectId
product: ProductUsageKey
path?: string
durationSeconds: number
startedAt: Date
endedAt: Date
createdAt: Date
}
const productUsageSessionSchema = new mongoose.Schema<ProductUsageSessionDocument>({
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', index: true },
product: { type: String, enum: ['classroom', 'liveatc'], required: true, index: true },
path: { type: String, trim: true },
durationSeconds: { type: Number, required: true, min: 0 },
startedAt: { type: Date, required: true },
endedAt: { type: Date, required: true, index: true },
createdAt: { type: Date, default: () => new Date() },
})
export const ProductUsageSession =
(mongoose.models.ProductUsageSession as mongoose.Model<ProductUsageSessionDocument> | undefined) ||
mongoose.model<ProductUsageSessionDocument>('ProductUsageSession', productUsageSessionSchema)

View File

@@ -7,6 +7,7 @@ export interface WaitlistEntryDocument extends mongoose.Document {
name?: string
notes?: string
source?: string
product: 'classroom' | 'liveatc'
consentPrivacy: boolean
consentTerms: boolean
joinedAt: Date
@@ -28,6 +29,7 @@ const waitlistSchema = new mongoose.Schema<WaitlistEntryDocument>({
name: { type: String, trim: true },
notes: { type: String, trim: true },
source: { type: String, default: 'landing' },
product: { type: String, enum: ['classroom', 'liveatc'], default: 'classroom', index: true },
consentPrivacy: { type: Boolean, default: false },
consentTerms: { type: Boolean, default: false },
joinedAt: { type: Date, default: () => new Date() },

444
server/utils/kpiReport.ts Normal file
View File

@@ -0,0 +1,444 @@
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'
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[]
}
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 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),
})),
}
}
function escapeHtml(value: unknown) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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}`),
'',
'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 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>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>`
}