feat(metrics): new-clients KPI and scheduled reminder dispatch

The dashboard promised a new-clients card the API never produced; the
metric now counts distinct customer emails whose earliest-ever booking
lands in the window, joining the other four KPIs with the same trend
comparison. The card grid rebalances for five items.

Reminder dispatch also had no trigger — the endpoint existed but
nothing called it. A scheduled GitHub Actions workflow now posts to it
every 15 minutes using the job-runner key, so reminders actually fire
once the two secrets are configured.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 12:59:44 +02:00
1 parent cdad7875e1
commit c7172544b8
8 files changed
+93 -20

No files matched your search

@@ -626,6 +626,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
dashboardKPI("completed", "Completed", previousMetrics.CompletedCount, priorMetrics.CompletedCount, ""),
dashboardKPI("cancellations", "Cancellations", metrics.CancellationsCount, previousMetrics.CancellationsCount, ""),
dashboardKPI("utilization", "Utilization", metrics.UtilizationPercent, previousMetrics.UtilizationPercent, "%"),
dashboardKPI("new_clients", "New clients", metrics.NewClientsCount, previousMetrics.NewClientsCount, ""),
},
UpcomingBookings: upcoming,
AllBookings: allBookings,
+26 -18
View File
@@ -463,38 +463,40 @@ func TestDashboardSummaryComputesKPIsWithTrends(t *testing.T) {
tenantID := membership.Tenant.ID
now := time.Now().UTC()
seed := func(offsetDays int, hours int, status string) {
seed := func(offsetDays int, hours int, status, email string) {
t.Helper()
starts := now.AddDate(0, 0, offsetDays).Add(time.Duration(hours) * time.Hour)
_, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: tenantID,
BookingMode: "appointment",
StartsAt: starts,
EndsAt: starts.Add(time.Hour),
Status: status,
Reference: "BK-T",
TenantID: tenantID,
BookingMode: "appointment",
CustomerEmail: email,
StartsAt: starts,
EndsAt: starts.Add(time.Hour),
Status: status,
Reference: "BK-T",
})
if err != nil {
t.Fatalf("seed booking: %v", err)
}
}
// Next 7 days: 3 bookings, one cancelled.
seed(1, 0, "confirmed")
seed(2, 0, "confirmed")
seed(3, 0, "cancelled")
// Last 7 days: 1 confirmed booking that already ended.
seed(-2, 0, "confirmed")
// 7-14 days ago: 2 completed bookings.
seed(-8, 0, "confirmed")
seed(-9, 0, "confirmed")
// Next 7 days: 3 bookings, one cancelled; two first-time clients
// (b@ returns, having booked 9 days ago).
seed(1, 0, "confirmed", "[email protected]")
seed(2, 0, "confirmed", "[email protected]")
seed(3, 0, "cancelled", "[email protected]")
// Last 7 days: 1 confirmed booking that already ended (returning client).
seed(-2, 0, "confirmed", "[email protected]")
// 7-14 days ago: 2 completed bookings; b@'s first visit plus a new client.
seed(-8, 0, "confirmed", "[email protected]")
seed(-9, 0, "confirmed", "[email protected]")
summary, err := service.DashboardSummary(ctx, domain.Principal{Subject: "demo-owner", Role: "owner"})
if err != nil {
t.Fatalf("dashboard summary: %v", err)
}
if len(summary.KPIs) != 4 {
t.Fatalf("expected 4 kpis, got %d", len(summary.KPIs))
if len(summary.KPIs) != 5 {
t.Fatalf("expected 5 kpis, got %d", len(summary.KPIs))
}
byCode := map[string]domain.DashboardKPI{}
for _, kpi := range summary.KPIs {
@@ -517,6 +519,12 @@ func TestDashboardSummaryComputesKPIsWithTrends(t *testing.T) {
if utilization.Value != "66%" || utilization.Trend != "down" {
t.Fatalf("utilization kpi = %+v", utilization)
}
// a@ and c@ have their first-ever booking next week (2); d@ was the
// only first-timer last week (1) — b@ first booked 8 days ago.
newClients := byCode["new_clients"]
if newClients.Value != "2" || newClients.Change != "+1" || newClients.Trend != "up" {
t.Fatalf("new clients kpi = %+v", newClients)
}
}
func bookAppointmentSlot(t *testing.T, repo db.Repository, service *Service) domain.CreateBookingResponse {
+11 -1
View File
@@ -181,7 +181,16 @@ func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string,
),
0
)::integer AS utilization_percent,
COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'confirmed' AND ends_at < now()) AS completed_count
COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'confirmed' AND ends_at < now()) AS completed_count,
COUNT(DISTINCT customer_email) FILTER (
WHERE starts_at >= $2 AND starts_at < $3
AND NOT EXISTS (
SELECT 1 FROM bookings earlier
WHERE earlier.tenant_id = bookings.tenant_id
AND earlier.customer_email = bookings.customer_email
AND earlier.starts_at < $2
)
) AS new_clients_count
FROM bookings
WHERE tenant_id = $1
`, tenantID, startsAt, endsAt).Scan(
@@ -189,6 +198,7 @@ func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string,
&metrics.CancellationsCount,
&metrics.UtilizationPercent,
&metrics.CompletedCount,
&metrics.NewClientsCount,
)
return metrics, err
}
+16
View File
@@ -397,6 +397,7 @@ type DashboardMetrics struct {
CancellationsCount int
UtilizationPercent int
CompletedCount int
NewClientsCount int
}
type ReminderJobParams struct {
@@ -1488,6 +1489,21 @@ func (r *MemoryRepository) GetDashboardMetrics(_ context.Context, tenantID strin
}
}
}
firstSeen := map[string]time.Time{}
for _, booking := range r.bookings {
if booking.TenantID != tenantID {
continue
}
first, ok := firstSeen[booking.CustomerEmail]
if !ok || booking.StartsAt.Before(first) {
firstSeen[booking.CustomerEmail] = booking.StartsAt
}
}
for _, first := range firstSeen {
if !first.Before(startsAt) && first.Before(endsAt) {
metrics.NewClientsCount++
}
}
if metrics.BookingsCount == 0 {
metrics.UtilizationPercent = 0
} else {