diff --git a/app/app.vue b/app/app.vue index 71844c0..967eb08 100644 --- a/app/app.vue +++ b/app/app.vue @@ -5,7 +5,7 @@ diff --git a/app/pages/index.vue b/app/pages/index.vue index 7e967df..8375b31 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -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() diff --git a/server/api/service/analytics/landing.post.ts b/server/api/service/analytics/landing.post.ts new file mode 100644 index 0000000..5a38aef --- /dev/null +++ b/server/api/service/analytics/landing.post.ts @@ -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(['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(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 } +}) diff --git a/server/api/service/analytics/product-session.post.ts b/server/api/service/analytics/product-session.post.ts new file mode 100644 index 0000000..d65eb28 --- /dev/null +++ b/server/api/service/analytics/product-session.post.ts @@ -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(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 } +}) diff --git a/server/api/service/cron/waitlist-drip.get.ts b/server/api/service/cron/waitlist-drip.get.ts index b02f171..e8dba73 100644 --- a/server/api/service/cron/waitlist-drip.get.ts +++ b/server/api/service/cron/waitlist-drip.get.ts @@ -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, } }) diff --git a/server/api/service/cron/weekly-kpi-report.get.ts b/server/api/service/cron/weekly-kpi-report.get.ts new file mode 100644 index 0000000..bd1e1f0 --- /dev/null +++ b/server/api/service/cron/weekly-kpi-report.get.ts @@ -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, + } +}) diff --git a/server/api/service/feedback/index.post.ts b/server/api/service/feedback/index.post.ts index 2280e7a..78861ed 100644 --- a/server/api/service/feedback/index.post.ts +++ b/server/api/service/feedback/index.post.ts @@ -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, }) diff --git a/server/api/service/waitlist.post.ts b/server/api/service/waitlist.post.ts index fb3475f..88bb8e0 100644 --- a/server/api/service/waitlist.post.ts +++ b/server/api/service/waitlist.post.ts @@ -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]) diff --git a/server/models/FeedbackSubmission.ts b/server/models/FeedbackSubmission.ts new file mode 100644 index 0000000..e909e9e --- /dev/null +++ b/server/models/FeedbackSubmission.ts @@ -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({ + 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 | undefined) || + mongoose.model('FeedbackSubmission', feedbackSubmissionSchema) diff --git a/server/models/KpiReportDelivery.ts b/server/models/KpiReportDelivery.ts new file mode 100644 index 0000000..717d21e --- /dev/null +++ b/server/models/KpiReportDelivery.ts @@ -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({ + 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 | undefined) || + mongoose.model('KpiReportDelivery', kpiReportDeliverySchema) diff --git a/server/models/LandingAnalyticsEvent.ts b/server/models/LandingAnalyticsEvent.ts new file mode 100644 index 0000000..d58e885 --- /dev/null +++ b/server/models/LandingAnalyticsEvent.ts @@ -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({ + 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 | undefined) || + mongoose.model('LandingAnalyticsEvent', landingAnalyticsEventSchema) diff --git a/server/models/ProductUsageSession.ts b/server/models/ProductUsageSession.ts new file mode 100644 index 0000000..4175d8e --- /dev/null +++ b/server/models/ProductUsageSession.ts @@ -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({ + 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 | undefined) || + mongoose.model('ProductUsageSession', productUsageSessionSchema) diff --git a/server/models/WaitlistEntry.ts b/server/models/WaitlistEntry.ts index 27d19de..1e26c50 100644 --- a/server/models/WaitlistEntry.ts +++ b/server/models/WaitlistEntry.ts @@ -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({ 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() }, diff --git a/server/utils/kpiReport.ts b/server/utils/kpiReport.ts new file mode 100644 index 0000000..624850e --- /dev/null +++ b/server/utils/kpiReport.ts @@ -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 = { 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 = { + 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 { + 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() + 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, '&') + .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}`), + '', + '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) => ` + + ${escapeHtml(goal.label)} + ${escapeHtml(goal.target)} + ${escapeHtml(goal.actual)} + ${goal.met ? 'On track' : 'Off track'} + `) + .join('') + + const productRows = report.products + .map((product) => ` + + ${escapeHtml(product.product === 'classroom' ? '1. Classroom' : '2. LiveATC')} + ${product.waitlistEntries} + ${product.activatedUsers} + ${formatSeconds(product.averageUsageSeconds)} + ${product.feedbackCount} + `) + .join('') + + const trendRows = report.landingTrend + .map((item) => ` + + ${escapeHtml(item.date)} + ${item.views} + ${item.scrolled} + ${item.waitlistEntries} + `) + .join('') + + const feedbackRows = report.feedback.length + ? report.feedback.map((item) => ` + + ${escapeHtml(item.product)} + ${escapeHtml(item.createdAt.slice(0, 10))} + ${item.excitement}/5 + ${escapeHtml(item.from)} + ${escapeHtml(item.summary)} + `).join('') + : 'Kein neues gespeichertes Feedback in diesem Zeitraum.' + + return ` + + + + + + + +
+
+
+

OpenSquawk KPI Report

+

${escapeHtml(report.periodStart.slice(0, 10))} bis ${escapeHtml(report.periodEnd.slice(0, 10))}

+
+
+
Waitlist${report.totals.waitlistEntries}
+
Neue User${report.totals.activatedUsers}
+
Nutzung Ø${formatSeconds(report.totals.averageUsageSeconds)}
+
Feedback${report.totals.feedbackCount}
+
Landing Views${report.totals.landingViews}
+
Scrollrate${report.totals.landingScrollRate}%
+
Waitlist Conv.${report.totals.landingWaitlistConversionRate}%
+
Gesamtzeit${formatSeconds(report.totals.usageSeconds)}
+
+
+

SMART Ziele

+ ${goalRows}
KPIZielIstStatus
+

Produkte

+ ${productRows}
ProduktWaitlistNeue UserNutzung ØFeedback
+

Landing Verlauf

+ ${trendRows}
DatumViewsScrolledWaitlist
+

Feedback

+ ${feedbackRows}
ProduktDatumRatingVonInhalt
+
+
+
+ +` +}