mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-29 16:04:45 +00:00
feat: data surfacing — intake labels, service metrics, honest capacity
- booking views gain intakeLabels (declared key→label join); dashboard modal + manage view render real question labels with slug fallback - GET /v1/metrics/services aggregates bookings/completed/cancelled/ revenue per service + classBookings; service-report.csv delegates to the same ServiceMetrics aggregation and gains a completed column - services page gains a Service performance card (per-service stat grid, locale CZK revenue, class-bookings footer); demo rows match the fabricated service list - staff capacity utilization subtracts blocked days — tenant-wide and staff-scoped blocks remove that day's rule minutes via blockedAt - tests: blocked-day capacity (tenant + staff scope), service aggregation Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
34becc1bf4
commit
5c860a1264
16 files changed
+560
-69
No files matched your search
@@ -879,3 +879,146 @@ func TestStaffMetricsAggregatesPerStaff(t *testing.T) {
|
||||
t.Fatalf("expected 240000 revenue cents (2×1200 Kč), got %d", row.RevenueCents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaffMetricsBlockedDayReducesCapacity(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil, "http://localhost:3000")
|
||||
ctx := context.Background()
|
||||
principal := domain.Principal{Subject: "demo-owner", Role: "owner"}
|
||||
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
|
||||
staffID := "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0"
|
||||
|
||||
baseline, err := service.StaffMetrics(ctx, principal, 30)
|
||||
if err != nil {
|
||||
t.Fatalf("baseline metrics: %v", err)
|
||||
}
|
||||
capacityBefore := baseline.Staff[0].CapacityMinutes
|
||||
if capacityBefore <= 0 {
|
||||
t.Fatalf("expected positive baseline capacity, got %d", capacityBefore)
|
||||
}
|
||||
|
||||
// Block the most recent weekday inside the window, tenant-wide. The
|
||||
// tenant runs Mon–Fri 09:00–17:00, so one blocked weekday removes
|
||||
// exactly 480 capacity minutes.
|
||||
loc, _ := time.LoadLocation("Europe/Prague")
|
||||
now := time.Now().UTC()
|
||||
var blocked time.Time
|
||||
for i := 1; i <= 7; i++ {
|
||||
day := now.AddDate(0, 0, -i).In(loc)
|
||||
if day.Weekday() >= time.Monday && day.Weekday() <= time.Friday {
|
||||
blocked = time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, err := repo.CreateBlockedDay(ctx, db.CreateBlockedDayParams{
|
||||
TenantID: tenantID,
|
||||
StartsAt: blocked,
|
||||
EndsAt: blocked.AddDate(0, 0, 1),
|
||||
Kind: "closure",
|
||||
}); err != nil {
|
||||
t.Fatalf("create blocked day: %v", err)
|
||||
}
|
||||
|
||||
after, err := service.StaffMetrics(ctx, principal, 30)
|
||||
if err != nil {
|
||||
t.Fatalf("metrics after block: %v", err)
|
||||
}
|
||||
want := capacityBefore - 480
|
||||
if got := after.Staff[0].CapacityMinutes; got != want {
|
||||
t.Fatalf("expected capacity %d after tenant-wide block, got %d", want, got)
|
||||
}
|
||||
|
||||
// A staff-scoped block on a different weekday removes another 480.
|
||||
var secondBlocked time.Time
|
||||
for i := 1; i <= 7; i++ {
|
||||
day := now.AddDate(0, 0, -i).In(loc)
|
||||
dayStart := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)
|
||||
if day.Weekday() >= time.Monday && day.Weekday() <= time.Friday && !dayStart.Equal(blocked) {
|
||||
secondBlocked = dayStart
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, err := repo.CreateBlockedDay(ctx, db.CreateBlockedDayParams{
|
||||
TenantID: tenantID,
|
||||
StaffID: &staffID,
|
||||
StartsAt: secondBlocked,
|
||||
EndsAt: secondBlocked.AddDate(0, 0, 1),
|
||||
Kind: "vacation",
|
||||
}); err != nil {
|
||||
t.Fatalf("create staff block: %v", err)
|
||||
}
|
||||
final, err := service.StaffMetrics(ctx, principal, 30)
|
||||
if err != nil {
|
||||
t.Fatalf("metrics after staff block: %v", err)
|
||||
}
|
||||
if got := final.Staff[0].CapacityMinutes; got != want-480 {
|
||||
t.Fatalf("expected capacity %d after staff block, got %d", want-480, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMetricsAggregatesPerService(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil, "http://localhost:3000")
|
||||
ctx := context.Background()
|
||||
|
||||
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
|
||||
serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39" // Signature treatment, 1200 Kč
|
||||
sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435"
|
||||
now := time.Now().UTC()
|
||||
|
||||
seed := func(service *string, session *string, status string, ref string) {
|
||||
t.Helper()
|
||||
_, err := repo.CreateBooking(ctx, db.CreateBookingParams{
|
||||
TenantID: tenantID,
|
||||
ServiceID: service,
|
||||
ClassSessionID: session,
|
||||
CustomerName: "Client",
|
||||
CustomerEmail: "[email protected]",
|
||||
StartsAt: now.Add(-5 * time.Hour),
|
||||
EndsAt: now.Add(-4 * time.Hour),
|
||||
Status: status,
|
||||
Reference: ref,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed booking: %v", err)
|
||||
}
|
||||
}
|
||||
seed(&serviceID, nil, "confirmed", "BK-SVM-1")
|
||||
seed(&serviceID, nil, "completed", "BK-SVM-2")
|
||||
seed(&serviceID, nil, "cancelled", "BK-SVM-3")
|
||||
seed(nil, &sessionID, "confirmed", "BK-SVM-4")
|
||||
seed(&serviceID, nil, "confirmed", "BK-SVM-OLD")
|
||||
|
||||
// Move the old booking outside the 30-day window via a direct update.
|
||||
old, err := repo.GetBookingByReference(ctx, "BK-SVM-OLD")
|
||||
if err != nil {
|
||||
t.Fatalf("fetch old booking: %v", err)
|
||||
}
|
||||
if err := repo.RescheduleBooking(ctx, old.ID, now.AddDate(0, 0, -40), now.AddDate(0, 0, -40).Add(time.Hour)); err != nil {
|
||||
t.Fatalf("reschedule old booking: %v", err)
|
||||
}
|
||||
|
||||
response, err := service.ServiceMetrics(ctx, domain.Principal{Subject: "demo-owner", Role: "owner"}, 30)
|
||||
if err != nil {
|
||||
t.Fatalf("service metrics: %v", err)
|
||||
}
|
||||
var row domain.ServiceMetric
|
||||
for _, m := range response.Services {
|
||||
if m.ServiceID == serviceID {
|
||||
row = m
|
||||
}
|
||||
}
|
||||
if row.Bookings != 3 {
|
||||
t.Fatalf("expected 3 bookings in window, got %d", row.Bookings)
|
||||
}
|
||||
if row.Completed != 1 || row.Cancelled != 1 {
|
||||
t.Fatalf("expected 1 completed + 1 cancelled, got %d/%d", row.Completed, row.Cancelled)
|
||||
}
|
||||
// Revenue counts non-cancelled bookings at list price: confirmed + completed.
|
||||
if row.RevenueCents != 240000 {
|
||||
t.Fatalf("expected 240000 revenue cents (2×1200 Kč), got %d", row.RevenueCents)
|
||||
}
|
||||
if response.ClassBookings != 1 {
|
||||
t.Fatalf("expected 1 class booking, got %d", response.ClassBookings)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user