mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
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:
1 parent
668a54ae1d
commit
0dc26e0c16
10 files changed
+141
-8
No files matched your search
@@ -569,6 +569,14 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.DashboardSummary{}, err
|
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)
|
upcomingRecords, err := s.repo.ListBookingsByTenantBetween(ctx, membership.Tenant.ID, now, weekEnd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.DashboardSummary{}, err
|
return domain.DashboardSummary{}, err
|
||||||
@@ -614,9 +622,10 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
|||||||
PublicBookingURL: "/book/" + membership.Tenant.Slug,
|
PublicBookingURL: "/book/" + membership.Tenant.Slug,
|
||||||
SetupCompletion: setupCompletion(brandName, len(locationNames), len(serviceNames), len(staffNames), scheduleCount),
|
SetupCompletion: setupCompletion(brandName, len(locationNames), len(serviceNames), len(staffNames), scheduleCount),
|
||||||
KPIs: []domain.DashboardKPI{
|
KPIs: []domain.DashboardKPI{
|
||||||
{Code: "bookings_this_week", Label: "Bookings this week", Value: fmt.Sprintf("%d", metrics.BookingsCount)},
|
dashboardKPI("bookings_this_week", "Bookings this week", metrics.BookingsCount, previousMetrics.BookingsCount, ""),
|
||||||
{Code: "cancellations", Label: "Cancellations", Value: fmt.Sprintf("%d", metrics.CancellationsCount)},
|
dashboardKPI("completed", "Completed", previousMetrics.CompletedCount, priorMetrics.CompletedCount, ""),
|
||||||
{Code: "utilization", Label: "Utilization", Value: fmt.Sprintf("%d%%", metrics.UtilizationPercent)},
|
dashboardKPI("cancellations", "Cancellations", metrics.CancellationsCount, previousMetrics.CancellationsCount, ""),
|
||||||
|
dashboardKPI("utilization", "Utilization", metrics.UtilizationPercent, previousMetrics.UtilizationPercent, "%"),
|
||||||
},
|
},
|
||||||
UpcomingBookings: upcoming,
|
UpcomingBookings: upcoming,
|
||||||
AllBookings: allBookings,
|
AllBookings: allBookings,
|
||||||
@@ -909,3 +918,20 @@ func reminderSchedule(startsAt time.Time) (time.Time, bool) {
|
|||||||
return time.Time{}, false
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
/ NULLIF(COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3), 0)
|
||||||
),
|
),
|
||||||
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
|
FROM bookings
|
||||||
WHERE tenant_id = $1
|
WHERE tenant_id = $1
|
||||||
`, tenantID, startsAt, endsAt).Scan(
|
`, tenantID, startsAt, endsAt).Scan(
|
||||||
&metrics.BookingsCount,
|
&metrics.BookingsCount,
|
||||||
&metrics.CancellationsCount,
|
&metrics.CancellationsCount,
|
||||||
&metrics.UtilizationPercent,
|
&metrics.UtilizationPercent,
|
||||||
|
&metrics.CompletedCount,
|
||||||
)
|
)
|
||||||
return metrics, err
|
return metrics, err
|
||||||
}
|
}
|
||||||
@@ -394,6 +394,7 @@ type DashboardMetrics struct {
|
|||||||
BookingsCount int
|
BookingsCount int
|
||||||
CancellationsCount int
|
CancellationsCount int
|
||||||
UtilizationPercent int
|
UtilizationPercent int
|
||||||
|
CompletedCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReminderJobParams struct {
|
type ReminderJobParams struct {
|
||||||
@@ -1452,6 +1453,9 @@ func (r *MemoryRepository) GetDashboardMetrics(_ context.Context, tenantID strin
|
|||||||
}
|
}
|
||||||
if booking.Status == "confirmed" {
|
if booking.Status == "confirmed" {
|
||||||
metrics.UtilizationPercent++
|
metrics.UtilizationPercent++
|
||||||
|
if booking.EndsAt.Before(time.Now().UTC()) {
|
||||||
|
metrics.CompletedCount++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if metrics.BookingsCount == 0 {
|
if metrics.BookingsCount == 0 {
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ type Principal struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DashboardKPI struct {
|
type DashboardKPI struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
|
Change string `json:"change,omitempty"`
|
||||||
|
Trend string `json:"trend,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpcomingBooking struct {
|
type UpcomingBooking struct {
|
||||||
|
|||||||
@@ -1624,6 +1624,11 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
value:
|
value:
|
||||||
type: string
|
type: string
|
||||||
|
change:
|
||||||
|
type: string
|
||||||
|
trend:
|
||||||
|
type: string
|
||||||
|
enum: [up, down, neutral]
|
||||||
DashboardSummary:
|
DashboardSummary:
|
||||||
type: object
|
type: object
|
||||||
required: [tenantName, tenantSlug, locale, timezone, planCode, publicBookingUrl, setupCompletion, kpis]
|
required: [tenantName, tenantSlug, locale, timezone, planCode, publicBookingUrl, setupCompletion, kpis]
|
||||||
|
|||||||
@@ -295,6 +295,9 @@ const dictionaries = {
|
|||||||
"dashboard.kpi.bookings": "Celkem rezervací",
|
"dashboard.kpi.bookings": "Celkem rezervací",
|
||||||
"dashboard.kpi.cancelled": "Zrušených",
|
"dashboard.kpi.cancelled": "Zrušených",
|
||||||
"dashboard.kpi.completed": "Dokonč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.kpi.newClients": "Noví klienti",
|
||||||
"dashboard.recentActivity": "Nedávná aktivita",
|
"dashboard.recentActivity": "Nedávná aktivita",
|
||||||
"dashboard.upcomingBookings": "Nadcházející rezervace",
|
"dashboard.upcomingBookings": "Nadcházející rezervace",
|
||||||
@@ -867,6 +870,9 @@ const dictionaries = {
|
|||||||
"dashboard.kpi.bookings": "Total Bookings",
|
"dashboard.kpi.bookings": "Total Bookings",
|
||||||
"dashboard.kpi.cancelled": "Cancelled",
|
"dashboard.kpi.cancelled": "Cancelled",
|
||||||
"dashboard.kpi.completed": "Completed",
|
"dashboard.kpi.completed": "Completed",
|
||||||
|
"dashboard.kpi.bookingsThisWeek": "Bookings this week",
|
||||||
|
"dashboard.kpi.cancellations": "Cancellations",
|
||||||
|
"dashboard.kpi.utilization": "Utilization",
|
||||||
"dashboard.kpi.newClients": "New Clients",
|
"dashboard.kpi.newClients": "New Clients",
|
||||||
"dashboard.recentActivity": "Recent Activity",
|
"dashboard.recentActivity": "Recent Activity",
|
||||||
"dashboard.upcomingBookings": "Upcoming Bookings",
|
"dashboard.upcomingBookings": "Upcoming Bookings",
|
||||||
|
|||||||
@@ -211,8 +211,17 @@ export function DashboardDataProvider(props: { children: any }) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const kpiLabelKeys: Record<string, string> = {
|
||||||
|
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) => ({
|
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) => ({
|
const normalizedUpcomingBookings = createMemo(() => (resolvedSummary()?.upcomingBookings ?? []).map((booking: any) => ({
|
||||||
|
|||||||
@@ -1101,8 +1101,11 @@ export interface components {
|
|||||||
startsAt: string;
|
startsAt: string;
|
||||||
};
|
};
|
||||||
DashboardKPI: {
|
DashboardKPI: {
|
||||||
|
change?: string;
|
||||||
code: string;
|
code: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
trend?: "up" | "down" | "neutral";
|
||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
DashboardSummary: {
|
DashboardSummary: {
|
||||||
|
|||||||
@@ -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
|
- 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
|
- `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
|
- 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
|
## 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.
|
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.
|
||||||
|
|||||||
Reference in new issue
Block a user