diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index f6e0edb..11d0501 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -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} +} diff --git a/apps/backend/internal/bookings/service_test.go b/apps/backend/internal/bookings/service_test.go index e74ff19..d250946 100644 --- a/apps/backend/internal/bookings/service_test.go +++ b/apps/backend/internal/bookings/service_test.go @@ -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) + } +} diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go index 1e0d92f..d335755 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -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 } diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index 9cde1d2..af82903 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -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 { diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index 245a0b3..a5f08a1 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -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 { diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index 3fb2345..a47e7be 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -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] diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 36d6d0f..61cbb0a 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -295,6 +295,9 @@ const dictionaries = { "dashboard.kpi.bookings": "Celkem rezervací", "dashboard.kpi.cancelled": "Zrušených", "dashboard.kpi.completed": "Dokončených", + "dashboard.kpi.bookingsThisWeek": "Rezervace tento týden", + "dashboard.kpi.cancellations": "Zrušení", + "dashboard.kpi.utilization": "Vytížení", "dashboard.kpi.newClients": "Noví klienti", "dashboard.recentActivity": "Nedávná aktivita", "dashboard.upcomingBookings": "Nadcházející rezervace", @@ -867,6 +870,9 @@ const dictionaries = { "dashboard.kpi.bookings": "Total Bookings", "dashboard.kpi.cancelled": "Cancelled", "dashboard.kpi.completed": "Completed", + "dashboard.kpi.bookingsThisWeek": "Bookings this week", + "dashboard.kpi.cancellations": "Cancellations", + "dashboard.kpi.utilization": "Utilization", "dashboard.kpi.newClients": "New Clients", "dashboard.recentActivity": "Recent Activity", "dashboard.upcomingBookings": "Upcoming Bookings", diff --git a/apps/frontend/src/routes/dashboard/dashboard-data.ts b/apps/frontend/src/routes/dashboard/dashboard-data.ts index ba1a72d..64faf51 100644 --- a/apps/frontend/src/routes/dashboard/dashboard-data.ts +++ b/apps/frontend/src/routes/dashboard/dashboard-data.ts @@ -211,8 +211,17 @@ export function DashboardDataProvider(props: { children: any }) { }; }; + const kpiLabelKeys: Record = { + bookings_this_week: "dashboard.kpi.bookingsThisWeek", + completed: "dashboard.kpi.completed", + cancellations: "dashboard.kpi.cancellations", + utilization: "dashboard.kpi.utilization", + }; const normalizedKpis = createMemo(() => (resolvedSummary()?.kpis ?? []).map((kpi: any) => ({ - ...kpi, trend: kpi.trend ?? "neutral", change: kpi.change ?? "—", + ...kpi, + label: kpi.code && kpiLabelKeys[kpi.code] ? i18n.t(kpiLabelKeys[kpi.code]) : kpi.label, + trend: kpi.trend ?? "neutral", + change: kpi.change ?? "—", }))); const normalizedUpcomingBookings = createMemo(() => (resolvedSummary()?.upcomingBookings ?? []).map((booking: any) => ({ diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index f03edce..34275eb 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -1101,8 +1101,11 @@ export interface components { startsAt: string; }; DashboardKPI: { + change?: string; code: string; label: string; + /** @enum {string} */ + trend?: "up" | "down" | "neutral"; value: string; }; DashboardSummary: { diff --git a/project.md b/project.md index cf5ace0..467faff 100644 --- a/project.md +++ b/project.md @@ -82,6 +82,14 @@ - bookings surface the `waitlisted` status: stats card, filter option, warning-tone badge, demo seed entry; `dashboard.{status}` interpolation resolves the new i18n key automatically - `BookingRecord` now carries `created_at` (needed for fair waitlist ordering); `GetBookingByID` added to the repository surface - remaining: live staging validation; waitlist position display and manual promotion controls are not yet exposed +- `[done/partial]` Milestone 12: Real dashboard business metrics + - `DashboardMetrics` gained `CompletedCount` (confirmed bookings already ended within the window) in both pgx and in-memory repositories + - `DashboardSummary` now queries three trailing windows (next 7d, last 7d, 7-14d ago) and emits four KPIs with real trend deltas: `bookings_this_week`, `completed`, `cancellations`, `utilization` (delta in percentage points) + - `DashboardKPI` carries optional `change`/`trend` (`up|down|neutral`); the KPI card already rendered these, so real data now drives the trend arrows instead of the previous `neutral`/`—` fallback + - KPI labels are localized in the frontend via a `code` → i18n-key map (`dashboard.kpi.bookingsThisWeek`, `cancellations`, `utilization`; `completed` reuses the existing key); backend labels stay English as a fallback + - the booking-trend chart was already bucketing real `allBookings` into the current week — confirmed, no change needed + - demo mode intentionally keeps curated static KPI values; real tenants get the computed set + - remaining: live staging validation; "new clients" KPI still has no backend metric (customer entity lacks first-booking tracking) ## Overview Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.