mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-05-13 01:46:08 +08:00
Wochenreport
This commit is contained in:
88
app/app.vue
88
app/app.vue
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
42
server/api/service/analytics/landing.post.ts
Normal file
42
server/api/service/analytics/landing.post.ts
Normal 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 }
|
||||
})
|
||||
57
server/api/service/analytics/product-session.post.ts
Normal file
57
server/api/service/analytics/product-session.post.ts
Normal 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 }
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
38
server/api/service/cron/weekly-kpi-report.get.ts
Normal file
38
server/api/service/cron/weekly-kpi-report.get.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
|
||||
41
server/models/FeedbackSubmission.ts
Normal file
41
server/models/FeedbackSubmission.ts
Normal 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)
|
||||
23
server/models/KpiReportDelivery.ts
Normal file
23
server/models/KpiReportDelivery.ts
Normal 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)
|
||||
28
server/models/LandingAnalyticsEvent.ts
Normal file
28
server/models/LandingAnalyticsEvent.ts
Normal 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)
|
||||
27
server/models/ProductUsageSession.ts
Normal file
27
server/models/ProductUsageSession.ts
Normal 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)
|
||||
@@ -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
444
server/utils/kpiReport.ts
Normal 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, '&')
|
||||
.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) => `
|
||||
<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>`
|
||||
}
|
||||
Reference in New Issue
Block a user