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:
Tomas DvorakandDevin committed 2026-09-22 20:25:32 +02:00
1 parent 34becc1bf4
commit 5c860a1264
16 files changed
+560 -69

No files matched your search

+98 -3
View File
@@ -548,9 +548,11 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
}
serviceNames := map[string]string{}
serviceIntakeLabels := map[string]map[string]string{}
if services, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID); err == nil {
for _, service := range services {
serviceNames[service.ID] = service.Name
serviceIntakeLabels[service.ID] = shared.IntakeLabels(service.IntakeFields)
}
}
locationNames := map[string]string{}
@@ -580,6 +582,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
}
if booking.ServiceID != nil {
out.Service = serviceNames[*booking.ServiceID]
out.IntakeLabels = serviceIntakeLabels[*booking.ServiceID]
}
if booking.LocationID != nil {
out.Location = locationNames[*booking.LocationID]
@@ -719,11 +722,15 @@ func (s *Service) StaffMetrics(ctx context.Context, principal domain.Principal,
if err != nil {
return domain.StaffMetricsResponse{}, err
}
blockedDays, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, from, to)
if err != nil {
return domain.StaffMetricsResponse{}, err
}
loc, err := time.LoadLocation(membership.Tenant.Timezone)
if err != nil {
loc = time.UTC
}
capacity := staffCapacityMinutes(rules, from, to, loc)
capacity := staffCapacityMinutes(rules, blockedDays, from, to, loc)
metrics := make([]domain.StaffMetric, 0, len(staff))
for _, member := range staff {
@@ -748,17 +755,105 @@ func (s *Service) StaffMetrics(ctx context.Context, principal domain.Principal,
return domain.StaffMetricsResponse{From: from, To: to, Staff: metrics}, nil
}
// ServiceMetrics aggregates appointment bookings per catalog service over a
// trailing window — every service appears, even with zero bookings. Class
// bookings carry no service and are counted separately.
func (s *Service) ServiceMetrics(ctx context.Context, principal domain.Principal, days int) (domain.ServiceMetricsResponse, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ServiceMetricsResponse{}, ErrTenantMembership
}
return domain.ServiceMetricsResponse{}, err
}
if days <= 0 || days > 365 {
days = 30
}
to := time.Now().UTC()
from := to.AddDate(0, 0, -days)
bookingsList, err := s.repo.ListBookingsByTenantBetween(ctx, membership.Tenant.ID, from, to)
if err != nil {
return domain.ServiceMetricsResponse{}, err
}
services, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID)
if err != nil {
return domain.ServiceMetricsResponse{}, err
}
prices := make(map[string]int, len(services))
for _, service := range services {
prices[service.ID] = service.PriceCents
}
type aggregate struct {
bookings int
completed int
cancelled int
revenue int64
}
byService := make(map[string]*aggregate, len(services))
classBookings := 0
for _, booking := range bookingsList {
if booking.ServiceID == nil {
classBookings++
continue
}
agg := byService[*booking.ServiceID]
if agg == nil {
agg = &aggregate{}
byService[*booking.ServiceID] = agg
}
agg.bookings++
switch booking.Status {
case "cancelled":
agg.cancelled++
case "completed":
agg.completed++
}
if booking.Status != "cancelled" {
agg.revenue += int64(prices[*booking.ServiceID])
}
}
metrics := make([]domain.ServiceMetric, 0, len(services))
for _, service := range services {
agg := byService[service.ID]
if agg == nil {
agg = &aggregate{}
}
metrics = append(metrics, domain.ServiceMetric{
ServiceID: service.ID,
Name: service.Name,
Bookings: agg.bookings,
Completed: agg.completed,
Cancelled: agg.cancelled,
RevenueCents: agg.revenue,
})
}
return domain.ServiceMetricsResponse{
From: from,
To: to,
Services: metrics,
ClassBookings: classBookings,
}, nil
}
// staffCapacityMinutes sums each member's availability-rule minutes across the
// window, evaluated on local calendar dates in the tenant timezone. Rules
// without a staff assignment are tenant-wide and don't map to a member.
func staffCapacityMinutes(rules []db.AvailabilityRuleRecord, from, to time.Time, loc *time.Location) map[string]int {
// without a staff assignment are tenant-wide and don't map to a member. A day
// blocked for the member (or tenant-wide) contributes no capacity.
func staffCapacityMinutes(rules []db.AvailabilityRuleRecord, blockedDays []db.BlockedDayRecord, from, to time.Time, loc *time.Location) map[string]int {
byStaff := map[string]int{}
for day := from.In(loc); day.Before(to.In(loc)); day = day.AddDate(0, 0, 1) {
weekday := int(day.Weekday())
dayStart := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)
for _, rule := range rules {
if rule.StaffID == nil || rule.DayOfWeek != weekday {
continue
}
if blockedAt(blockedDays, rule.StaffID, dayStart, dayStart.AddDate(0, 0, 1)) {
continue
}
byStaff[*rule.StaffID] += localMinutesBetween(rule.StartsLocal, rule.EndsLocal)
}
}