feat(dashboard): real business metrics with week-over-week trends

Dashboard KPIs were three flat counters with no trend data — the cards
rendered a neutral dash where the design intends movement indicators.
Summary now queries three trailing windows and emits four KPIs with
real deltas: bookings this week, completed (new CompletedCount metric
in both repositories), cancellations, and utilization in percentage
points. KPI labels localize in the frontend via a code-to-key map so
Czech operators no longer see English labels on live data.

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 11:34:00 +02:00
1 parent 668a54ae1d
commit 0dc26e0c16
10 files changed
+141 -8

No files matched your search

+29 -3
View File
@@ -569,6 +569,14 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
if err != nil {
return domain.DashboardSummary{}, err
}
previousMetrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now.AddDate(0, 0, -7), now)
if err != nil {
return domain.DashboardSummary{}, err
}
priorMetrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now.AddDate(0, 0, -14), now.AddDate(0, 0, -7))
if err != nil {
return domain.DashboardSummary{}, err
}
upcomingRecords, err := s.repo.ListBookingsByTenantBetween(ctx, membership.Tenant.ID, now, weekEnd)
if err != nil {
return domain.DashboardSummary{}, err
@@ -614,9 +622,10 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
PublicBookingURL: "/book/" + membership.Tenant.Slug,
SetupCompletion: setupCompletion(brandName, len(locationNames), len(serviceNames), len(staffNames), scheduleCount),
KPIs: []domain.DashboardKPI{
{Code: "bookings_this_week", Label: "Bookings this week", Value: fmt.Sprintf("%d", metrics.BookingsCount)},
{Code: "cancellations", Label: "Cancellations", Value: fmt.Sprintf("%d", metrics.CancellationsCount)},
{Code: "utilization", Label: "Utilization", Value: fmt.Sprintf("%d%%", metrics.UtilizationPercent)},
dashboardKPI("bookings_this_week", "Bookings this week", metrics.BookingsCount, previousMetrics.BookingsCount, ""),
dashboardKPI("completed", "Completed", previousMetrics.CompletedCount, priorMetrics.CompletedCount, ""),
dashboardKPI("cancellations", "Cancellations", metrics.CancellationsCount, previousMetrics.CancellationsCount, ""),
dashboardKPI("utilization", "Utilization", metrics.UtilizationPercent, previousMetrics.UtilizationPercent, "%"),
},
UpcomingBookings: upcoming,
AllBookings: allBookings,
@@ -909,3 +918,20 @@ func reminderSchedule(startsAt time.Time) (time.Time, bool) {
return time.Time{}, false
}
}
func dashboardKPI(code, label string, current int, previous int, suffix string) domain.DashboardKPI {
delta := current - previous
change := "0"
trend := "neutral"
if delta > 0 {
change = fmt.Sprintf("+%d", delta)
trend = "up"
} else if delta < 0 {
change = fmt.Sprintf("%d", delta)
trend = "down"
}
if suffix != "" && change != "0" {
change += suffix
}
return domain.DashboardKPI{Code: code, Label: label, Value: fmt.Sprintf("%d%s", current, suffix), Change: change, Trend: trend}
}
@@ -450,3 +450,71 @@ func TestCustomerCancelPromotesWaitlist(t *testing.T) {
t.Fatalf("expected waiter promoted to confirmed, got %q", booking.Status)
}
}
func TestDashboardSummaryComputesKPIsWithTrends(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
ctx := context.Background()
membership, err := repo.GetTenantMembershipBySubject(ctx, "demo-owner")
if err != nil {
t.Fatalf("membership: %v", err)
}
tenantID := membership.Tenant.ID
now := time.Now().UTC()
seed := func(offsetDays int, hours int, status 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",
})
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")
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))
}
byCode := map[string]domain.DashboardKPI{}
for _, kpi := range summary.KPIs {
byCode[kpi.Code] = kpi
}
bookings := byCode["bookings_this_week"]
if bookings.Value != "3" || bookings.Change != "+2" || bookings.Trend != "up" {
t.Fatalf("bookings kpi = %+v", bookings)
}
completed := byCode["completed"]
if completed.Value != "1" || completed.Change != "-1" || completed.Trend != "down" {
t.Fatalf("completed kpi = %+v", completed)
}
cancellations := byCode["cancellations"]
if cancellations.Value != "1" || cancellations.Change != "+1" || cancellations.Trend != "up" {
t.Fatalf("cancellations kpi = %+v", cancellations)
}
utilization := byCode["utilization"]
if utilization.Value != "66%" || utilization.Trend != "down" {
t.Fatalf("utilization kpi = %+v", utilization)
}
}
+3 -1
View File
@@ -179,13 +179,15 @@ func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string,
/ NULLIF(COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3), 0)
),
0
)::integer AS utilization_percent
)::integer AS utilization_percent,
COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'confirmed' AND ends_at < now()) AS completed_count
FROM bookings
WHERE tenant_id = $1
`, tenantID, startsAt, endsAt).Scan(
&metrics.BookingsCount,
&metrics.CancellationsCount,
&metrics.UtilizationPercent,
&metrics.CompletedCount,
)
return metrics, err
}
+4
View File
@@ -394,6 +394,7 @@ type DashboardMetrics struct {
BookingsCount int
CancellationsCount int
UtilizationPercent int
CompletedCount int
}
type ReminderJobParams struct {
@@ -1452,6 +1453,9 @@ func (r *MemoryRepository) GetDashboardMetrics(_ context.Context, tenantID strin
}
if booking.Status == "confirmed" {
metrics.UtilizationPercent++
if booking.EndsAt.Before(time.Now().UTC()) {
metrics.CompletedCount++
}
}
}
if metrics.BookingsCount == 0 {
+5 -3
View File
@@ -10,9 +10,11 @@ type Principal struct {
}
type DashboardKPI struct {
Code string `json:"code"`
Label string `json:"label"`
Value string `json:"value"`
Code string `json:"code"`
Label string `json:"label"`
Value string `json:"value"`
Change string `json:"change,omitempty"`
Trend string `json:"trend,omitempty"`
}
type UpcomingBooking struct {
+5
View File
@@ -1624,6 +1624,11 @@ components:
type: string
value:
type: string
change:
type: string
trend:
type: string
enum: [up, down, neutral]
DashboardSummary:
type: object
required: [tenantName, tenantSlug, locale, timezone, planCode, publicBookingUrl, setupCompletion, kpis]