feat(frontend): add booking stats summary section to GDPR page

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-05 15:40:46 +01:00
co-authored by Sisyphus
parent 5cc3e25a2c
commit 69420298bd
+249 -1
View File
@@ -5,6 +5,7 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
import { Separator } from '$lib/components/ui/separator';
type GdprData = { type GdprData = {
user_profile: { user_profile: {
@@ -265,6 +266,171 @@
gdprData?.payments?.some((p) => p.vat_amount && Number(p.vat_amount) > 0) ?? false gdprData?.payments?.some((p) => p.vat_amount && Number(p.vat_amount) > 0) ?? false
); );
type BookingStats = {
totalBookings: number;
completed: number;
cancelled: number;
noShow: number;
pending: number;
totalSpent: number;
totalRefunded: number;
netSpent: number;
avgBookingValue: number;
memberSince: string;
totalLoyaltyStamps: number;
stampsRedeemed: number;
totalDiscounts: number;
topServices: Array<{ name: string; count: number; total: number }>;
paymentMethods: Array<{ method: string; count: number; total: number }>;
forgivenNoShows: number;
editRequests: number;
patchTests: number;
savedCards: number;
referredBy: string | null;
referredCount: number;
};
type SecondaryStat = {
label: string;
value: string;
subtitle: string;
highlight?: boolean;
};
function computeSecondaryStats(stats: BookingStats, data: GdprData): SecondaryStat[] {
const candidates: SecondaryStat[] = [
{
label: 'Loyalty Stamps',
value: `${stats.totalLoyaltyStamps} total`,
subtitle: `${stats.stampsRedeemed} redeemed, ${data.user_profile.loyalty_stamps} remaining`,
},
{
label: 'Discounts Received',
value: fmt(stats.totalDiscounts),
subtitle: `${data.booking_discounts?.length ?? 0} discount(s) applied`,
highlight: true,
},
{
label: 'Patch Tests',
value: `${stats.patchTests}`,
subtitle: 'completed',
},
{
label: 'Edits or Reschedules',
value: `${stats.editRequests}`,
subtitle: 'submitted',
},
{
label: 'Saved Cards',
value: `${stats.savedCards}`,
subtitle: 'on file',
},
];
const nonZero = candidates.filter((c) => {
const num = parseInt(c.value.replace(/[^0-9.-]+/g, ''), 10);
return num > 0;
});
const zero = candidates.filter((c) => {
const num = parseInt(c.value.replace(/[^0-9.-]+/g, ''), 10);
return num <= 0;
});
const result = [...nonZero, ...zero];
return result.slice(0, 3);
}
function computeBookingStats(data: GdprData | null): BookingStats | null {
if (!data) return null;
const bookings = data.bookings ?? [];
const payments = data.payments ?? [];
const refunds = data.refunds ?? [];
const discounts = data.booking_discounts ?? [];
const redemptions = data.loyalty_redemptions ?? [];
const noShows = data.forgiven_no_shows ?? [];
const edits = data.edit_requests ?? [];
const patchTests = data.patch_tests ?? [];
const completedBookings = bookings.filter((b) => b.status === 'completed');
const cancelledBookings = bookings.filter(
(b) =>
b.status === 'client_cancelled' ||
b.status === 'we_cancelled' ||
b.status === 'no_show'
);
const noShowBookings = bookings.filter((b) => b.status === 'no_show');
const pendingBookings = bookings.filter(
(b) => b.status === 'pending' || b.status === 'confirmed'
);
const totalSpent = payments
.filter((p) => p.status === 'completed')
.reduce((sum, p) => sum + Number(p.amount), 0);
const totalRefunded = refunds
.filter((r) => r.status === 'completed')
.reduce((sum, r) => sum + Number(r.amount), 0);
const serviceMap = new Map<string, { count: number; total: number }>();
for (const b of bookings) {
for (const s of b.services ?? []) {
const existing = serviceMap.get(s.name) ?? { count: 0, total: 0 };
existing.count++;
existing.total += Number(s.price);
serviceMap.set(s.name, existing);
}
}
const topServices = Array.from(serviceMap.entries())
.map(([name, data]) => ({ name, ...data }))
.sort((a, b) => b.count - a.count)
.slice(0, 5);
const methodMap = new Map<string, { count: number; total: number }>();
for (const p of payments.filter((p) => p.status === 'completed')) {
if (p.payment_method === 'discount') continue;
const method = p.payment_method.replace(/_/g, ' ');
const existing = methodMap.get(method) ?? { count: 0, total: 0 };
existing.count++;
existing.total += Number(p.amount);
methodMap.set(method, existing);
}
const paymentMethods = Array.from(methodMap.entries())
.map(([method, data]) => ({ method, ...data }))
.sort((a, b) => b.total - a.total);
const totalDiscounts = discounts.reduce(
(sum, d) => sum + Number(d.discount_amount), 0
);
const stampsRedeemed = redemptions.reduce(
(sum, r) => sum + Number(r.stamps_redeemed), 0
);
return {
totalBookings: bookings.length,
completed: completedBookings.length,
cancelled: cancelledBookings.length,
noShow: noShowBookings.length,
pending: pendingBookings.length,
totalSpent,
totalRefunded,
netSpent: totalSpent - totalRefunded,
avgBookingValue: completedBookings.length > 0
? completedBookings.reduce((s, b) => s + Number(b.total_price), 0) / completedBookings.length
: 0,
memberSince: data.user_profile?.created_at ?? '',
totalLoyaltyStamps: (data.user_profile?.loyalty_stamps ?? 0) + stampsRedeemed,
stampsRedeemed,
totalDiscounts,
topServices,
paymentMethods,
forgivenNoShows: noShows.length,
editRequests: edits.length,
patchTests: patchTests.length,
savedCards: (data.saved_cards ?? []).length,
referredBy: data.referrals?.referred_by?.referrer_name ?? null,
referredCount: data.referrals?.referred_users?.length ?? 0,
};
}
function downloadJson() { function downloadJson() {
if (!gdprData) return; if (!gdprData) return;
const blob = new Blob([JSON.stringify(gdprData, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(gdprData, null, 2)], { type: 'application/json' });
@@ -371,6 +537,88 @@
</div> </div>
</div> </div>
<!-- Summary Stats -->
{#if computeBookingStats(gdprData)}
{@const stats = computeBookingStats(gdprData)}
<h2 class="mb-3 text-xl font-bold">Summary</h2>
<div class="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-xl border bg-card p-4">
<span class="text-xs text-gray-400">Member Since</span>
<p class="mt-1 text-lg font-semibold">{fmtDate(stats.memberSince)}</p>
</div>
<div class="rounded-xl border bg-card p-4">
<span class="text-xs text-gray-400">Total Bookings</span>
<p class="mt-1 text-lg font-semibold">{stats.totalBookings}</p>
<p class="text-xs text-gray-400">
{stats.completed} completed, {stats.cancelled} cancelled
{#if stats.noShow > 0}, {stats.noShow} no-show{/if}
{#if stats.pending > 0}, {stats.pending} pending{/if}
</p>
</div>
<div class="rounded-xl border bg-card p-4">
<span class="text-xs text-gray-400">Total Spent</span>
<p class="mt-1 text-lg font-semibold">{fmt(stats.totalSpent)}</p>
{#if stats.totalRefunded > 0}
<p class="text-xs text-red-500">{fmt(stats.totalRefunded)} refunded</p>
<p class="text-xs text-gray-400">net {fmt(stats.netSpent)}</p>
{/if}
</div>
<div class="rounded-xl border bg-card p-4">
<span class="text-xs text-gray-400">Avg Booking</span>
<p class="mt-1 text-lg font-semibold">{fmt(stats.avgBookingValue)}</p>
<p class="text-xs text-gray-400">per completed booking</p>
</div>
</div>
{#if stats.topServices.length > 0}
<div class="mb-6 rounded-xl border bg-card p-4">
<h3 class="mb-3 text-sm font-semibold text-gray-500">Most Booked Services</h3>
<div class="space-y-2">
{#each stats.topServices as svc (svc.name)}
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-fuchsia-100 text-xs font-semibold text-fuchsia-700">
{svc.count}
</span>
<span class="text-sm">{svc.name}</span>
</div>
<span class="text-sm text-gray-500">{fmt(svc.total)} total</span>
</div>
{/each}
</div>
</div>
{/if}
{#if stats.paymentMethods.length > 0}
<div class="mb-6 rounded-xl border bg-card p-4">
<h3 class="mb-3 text-sm font-semibold text-gray-500">Payment Methods</h3>
<div class="flex flex-wrap gap-3">
{#each stats.paymentMethods as pm (pm.method)}
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-2">
<span class="text-sm font-medium">{pm.method}</span>
<span class="text-xs text-gray-400">({pm.count}×, {fmt(pm.total)})</span>
</div>
{/each}
</div>
</div>
{/if}
{@const secondary = computeSecondaryStats(stats, gdprData)}
<div class="mb-6 grid grid-cols-3 gap-3">
{#each secondary as s (s.label)}
<div class="rounded-xl border bg-card p-4">
<span class="text-xs text-gray-400">{s.label}</span>
<p class="mt-1 text-lg font-semibold {s.highlight ? 'text-green-600' : ''}">{s.value}</p>
<p class="text-xs text-gray-400">{s.subtitle}</p>
</div>
{/each}
</div>
<Separator class="mb-6" />
{/if}
<h2 class="mb-4 text-xl font-bold">Personal Data</h2>
<!-- User Profile --> <!-- User Profile -->
<Card.Root class="mb-4"> <Card.Root class="mb-4">
<Card.Header><Card.Title>User Profile</Card.Title></Card.Header> <Card.Header><Card.Title>User Profile</Card.Title></Card.Header>
@@ -693,7 +941,7 @@
<td class="px-4 py-3">{c.is_default ? 'Yes' : 'No'}</td> <td class="px-4 py-3">{c.is_default ? 'Yes' : 'No'}</td>
<td class="px-4 py-3">{c.deleted_at ? 'Deleted' : 'Active'}</td> <td class="px-4 py-3">{c.deleted_at ? 'Deleted' : 'Active'}</td>
<td class="px-4 py-3 whitespace-nowrap text-gray-500" <td class="px-4 py-3 whitespace-nowrap text-gray-500"
>{c.retained_until ? fmtDate(c.retained_until) : ''}</td >{c.retained_until ? fmtDate(c.retained_until) : '7 years after expiry'}</td
> >
<td class="px-4 py-3 whitespace-nowrap text-gray-500" <td class="px-4 py-3 whitespace-nowrap text-gray-500"
>{fmtDate(c.created_at)}</td >{fmtDate(c.created_at)}</td