feat(scheduling): tenant scheduling overview/analytics

This commit is contained in:
Ronni Baslund
2026-06-07 09:17:01 +02:00
parent 95cbdc4e3d
commit 8bbb7881a4
4 changed files with 254 additions and 0 deletions
+128
View File
@@ -78,6 +78,50 @@ async function runConfirm() {
}
}
// ── Overview / analytics (read-only, tenant-level) ──
interface HostCount { hostId: string; displayName: string; count: number }
interface DayPoint { date: string; count: number }
interface SchedulingOverview {
totalBookings: number
upcomingCount: number
byStatus: Record<string, number>
byHost: HostCount[]
last30Days: DayPoint[]
}
const overview = ref<SchedulingOverview | null>(null)
async function loadOverview() {
try {
overview.value = (await request(`${base.value}/overview`)) as SchedulingOverview
} catch (err) {
toastErr(err, 'Could not load overview')
}
}
if (slug.value) loadOverview()
watch(slug, (s) => { if (s) loadOverview() })
// Series scaling for the simple bar sparkline.
const seriesMax = computed(() => Math.max(1, ...(overview.value?.last30Days ?? []).map((d) => d.count)))
function barHeight(count: number): number {
// Min 2% so empty days still show a faint baseline tick.
return Math.max(2, Math.round((count / seriesMax.value) * 100))
}
const dayLabel = (date: string) =>
new Date(`${date}T00:00:00Z`).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
// Statuses shown in the breakdown, in a sensible reading order.
const STATUS_ORDER = ['confirmed', 'pending', 'rescheduled', 'cancelled', 'calendar_failed'] as const
const statusLabel: Record<string, string> = {
confirmed: 'Confirmed',
pending: 'Pending',
rescheduled: 'Rescheduled',
cancelled: 'Cancelled',
calendar_failed: 'Calendar failed',
}
const statusBreakdown = computed(() =>
STATUS_ORDER.map((s) => ({ status: s, label: statusLabel[s], count: overview.value?.byStatus?.[s] ?? 0 })).filter(
(r) => r.count > 0,
),
)
const { data: hosts, refresh: refreshHosts } = await useFetch<Host[]>(() => `${base.value}/hosts`, {
key: 'sched-hosts',
default: () => [],
@@ -565,6 +609,70 @@ const maskSecret = (s: string) => (s.length > 12 ? `${s.slice(0, 9)}…${s.slice
</PageHeader>
<div class="content">
<!-- Overview / analytics (read-only) -->
<section v-if="overview" class="overview">
<div class="statcards">
<Card class="statcard">
<div class="statlabel">Total bookings</div>
<div class="statvalue">{{ overview.totalBookings }}</div>
</Card>
<Card class="statcard">
<div class="statlabel">Upcoming</div>
<div class="statvalue">{{ overview.upcomingCount }}</div>
<div class="statsub mute small">confirmed, in the future</div>
</Card>
<Card class="statcard">
<div class="statlabel">Active hosts</div>
<div class="statvalue">{{ overview.byHost.length }}</div>
<div class="statsub mute small">with bookings</div>
</Card>
<Card class="statcard">
<div class="statlabel">Last 30 days</div>
<div class="statvalue">{{ overview.last30Days.reduce((s, d) => s + d.count, 0) }}</div>
<div class="statsub mute small">bookings created</div>
</Card>
</div>
<div class="overgrid">
<Card class="chartcard">
<div class="cardhead">
<Eyebrow>Bookings created · last 30 days</Eyebrow>
</div>
<div class="spark">
<div
v-for="d in overview.last30Days"
:key="d.date"
class="sparkbar"
:style="{ height: barHeight(d.count) + '%' }"
:title="`${dayLabel(d.date)}: ${d.count}`"
/>
</div>
</Card>
<Card class="brkcard">
<div class="cardhead"><Eyebrow>By status</Eyebrow></div>
<p v-if="!statusBreakdown.length" class="mute small">No bookings yet.</p>
<div v-else class="brklist">
<div v-for="r in statusBreakdown" :key="r.status" class="brkrow">
<Badge :tone="r.status === 'confirmed' ? 'ok' : r.status === 'cancelled' || r.status === 'calendar_failed' ? 'bad' : 'neutral'">{{ r.label }}</Badge>
<span class="brkcount">{{ r.count }}</span>
</div>
</div>
</Card>
<Card class="brkcard">
<div class="cardhead"><Eyebrow>By host</Eyebrow></div>
<p v-if="!overview.byHost.length" class="mute small">No bookings yet.</p>
<div v-else class="brklist">
<div v-for="h in overview.byHost" :key="h.hostId" class="brkrow">
<span class="brkname">{{ h.displayName }}</span>
<span class="brkcount">{{ h.count }}</span>
</div>
</div>
</Card>
</div>
</section>
<Card v-if="!hosts || !hosts.length" class="notice">
No bookable hosts yet. Add a host to create their booking pages calendar access is provisioned automatically.
</Card>
@@ -942,6 +1050,26 @@ const maskSecret = (s: string) => (s.length > 12 ? `${s.slice(0, 9)}…${s.slice
<style scoped>
.content { padding: 20px 40px 64px; }
.notice { display: flex; align-items: center; gap: 10px; color: var(--text-mute); padding: 18px; }
.overview { display: flex; flex-direction: column; gap: 16px; margin-bottom: 24px; }
.statcards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
.statcard { padding: 16px 18px; display: flex; flex-direction: column; gap: 4px; }
.statlabel { font-size: 12px; color: var(--text-mute); text-transform: uppercase; letter-spacing: 0.04em; }
.statvalue { font-size: 28px; font-weight: 700; line-height: 1.1; }
.statsub { margin-top: 2px; }
.overgrid { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 14px; }
.chartcard, .brkcard { padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; }
.cardhead { display: flex; align-items: center; justify-content: space-between; }
.spark { display: flex; align-items: flex-end; gap: 2px; height: 96px; }
.sparkbar { flex: 1 1 0; min-width: 0; background: var(--text); opacity: 0.7; border-radius: 2px 2px 0 0; transition: opacity 0.15s; }
.sparkbar:hover { opacity: 1; }
.brklist { display: flex; flex-direction: column; gap: 8px; }
.brkrow { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.brkname { font-size: 13px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.brkcount { font-weight: 600; font-variant-numeric: tabular-nums; }
@media (max-width: 980px) {
.statcards { grid-template-columns: repeat(2, 1fr); }
.overgrid { grid-template-columns: 1fr; }
}
.layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; }
.hosts { display: flex; flex-direction: column; gap: 8px; }
.hostrow { text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; cursor: pointer; display: flex; flex-direction: column; gap: 4px; }