diff --git a/.github/workflows/reminder-dispatch.yml b/.github/workflows/reminder-dispatch.yml new file mode 100644 index 0000000..750f8fb --- /dev/null +++ b/.github/workflows/reminder-dispatch.yml @@ -0,0 +1,32 @@ +name: Reminder Dispatch + +on: + schedule: + # Every 15 minutes. GitHub scheduled workflows may run a few minutes late; + # reminder jobs are timestamp-based so lateness only delays, never skips. + - cron: "*/15 * * * *" + workflow_dispatch: + +permissions: {} + +jobs: + dispatch: + name: Dispatch due reminders + runs-on: ubuntu-latest + steps: + - name: Call dispatch endpoint + env: + API_URL: ${{ secrets.BOOKRA_API_URL }} + JOB_KEY: ${{ secrets.BOOKRA_JOB_RUNNER_KEY }} + run: | + if [ -z "$API_URL" ] || [ -z "$JOB_KEY" ]; then + echo "BOOKRA_API_URL / BOOKRA_JOB_RUNNER_KEY secrets not configured — skipping." + exit 0 + fi + status=$(curl -sS -o /tmp/dispatch.json -w "%{http_code}" \ + -X POST "$API_URL/v1/internal/jobs/reminders/dispatch" \ + -H "Content-Type: application/json" \ + -H "X-Bookra-Job-Key: $JOB_KEY" \ + -d '{"limit": 100}') + cat /tmp/dispatch.json + test "$status" = "200" diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index 06b1f2d..6e32cfb 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -626,6 +626,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip dashboardKPI("completed", "Completed", previousMetrics.CompletedCount, priorMetrics.CompletedCount, ""), dashboardKPI("cancellations", "Cancellations", metrics.CancellationsCount, previousMetrics.CancellationsCount, ""), dashboardKPI("utilization", "Utilization", metrics.UtilizationPercent, previousMetrics.UtilizationPercent, "%"), + dashboardKPI("new_clients", "New clients", metrics.NewClientsCount, previousMetrics.NewClientsCount, ""), }, UpcomingBookings: upcoming, AllBookings: allBookings, diff --git a/apps/backend/internal/bookings/service_test.go b/apps/backend/internal/bookings/service_test.go index 40ff770..2e38fbd 100644 --- a/apps/backend/internal/bookings/service_test.go +++ b/apps/backend/internal/bookings/service_test.go @@ -463,38 +463,40 @@ func TestDashboardSummaryComputesKPIsWithTrends(t *testing.T) { tenantID := membership.Tenant.ID now := time.Now().UTC() - seed := func(offsetDays int, hours int, status string) { + seed := func(offsetDays int, hours int, status, email 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", + TenantID: tenantID, + BookingMode: "appointment", + CustomerEmail: email, + 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") + // Next 7 days: 3 bookings, one cancelled; two first-time clients + // (b@ returns, having booked 9 days ago). + seed(1, 0, "confirmed", "a@example.com") + seed(2, 0, "confirmed", "b@example.com") + seed(3, 0, "cancelled", "c@example.com") + // Last 7 days: 1 confirmed booking that already ended (returning client). + seed(-2, 0, "confirmed", "d@example.com") + // 7-14 days ago: 2 completed bookings; b@'s first visit plus a new client. + seed(-8, 0, "confirmed", "b@example.com") + seed(-9, 0, "confirmed", "e@example.com") 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)) + if len(summary.KPIs) != 5 { + t.Fatalf("expected 5 kpis, got %d", len(summary.KPIs)) } byCode := map[string]domain.DashboardKPI{} for _, kpi := range summary.KPIs { @@ -517,6 +519,12 @@ func TestDashboardSummaryComputesKPIsWithTrends(t *testing.T) { if utilization.Value != "66%" || utilization.Trend != "down" { t.Fatalf("utilization kpi = %+v", utilization) } + // a@ and c@ have their first-ever booking next week (2); d@ was the + // only first-timer last week (1) — b@ first booked 8 days ago. + newClients := byCode["new_clients"] + if newClients.Value != "2" || newClients.Change != "+1" || newClients.Trend != "up" { + t.Fatalf("new clients kpi = %+v", newClients) + } } func bookAppointmentSlot(t *testing.T, repo db.Repository, service *Service) domain.CreateBookingResponse { diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go index 30d137a..163da14 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -181,7 +181,16 @@ func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string, ), 0 )::integer AS utilization_percent, - COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'confirmed' AND ends_at < now()) AS completed_count + COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'confirmed' AND ends_at < now()) AS completed_count, + COUNT(DISTINCT customer_email) FILTER ( + WHERE starts_at >= $2 AND starts_at < $3 + AND NOT EXISTS ( + SELECT 1 FROM bookings earlier + WHERE earlier.tenant_id = bookings.tenant_id + AND earlier.customer_email = bookings.customer_email + AND earlier.starts_at < $2 + ) + ) AS new_clients_count FROM bookings WHERE tenant_id = $1 `, tenantID, startsAt, endsAt).Scan( @@ -189,6 +198,7 @@ func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string, &metrics.CancellationsCount, &metrics.UtilizationPercent, &metrics.CompletedCount, + &metrics.NewClientsCount, ) return metrics, err } diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index 1d5e4c8..65dc6a5 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -397,6 +397,7 @@ type DashboardMetrics struct { CancellationsCount int UtilizationPercent int CompletedCount int + NewClientsCount int } type ReminderJobParams struct { @@ -1488,6 +1489,21 @@ func (r *MemoryRepository) GetDashboardMetrics(_ context.Context, tenantID strin } } } + firstSeen := map[string]time.Time{} + for _, booking := range r.bookings { + if booking.TenantID != tenantID { + continue + } + first, ok := firstSeen[booking.CustomerEmail] + if !ok || booking.StartsAt.Before(first) { + firstSeen[booking.CustomerEmail] = booking.StartsAt + } + } + for _, first := range firstSeen { + if !first.Before(startsAt) && first.Before(endsAt) { + metrics.NewClientsCount++ + } + } if metrics.BookingsCount == 0 { metrics.UtilizationPercent = 0 } else { diff --git a/apps/frontend/src/routes/dashboard/dashboard-data.ts b/apps/frontend/src/routes/dashboard/dashboard-data.ts index 64faf51..15802b5 100644 --- a/apps/frontend/src/routes/dashboard/dashboard-data.ts +++ b/apps/frontend/src/routes/dashboard/dashboard-data.ts @@ -216,6 +216,7 @@ export function DashboardDataProvider(props: { children: any }) { completed: "dashboard.kpi.completed", cancellations: "dashboard.kpi.cancellations", utilization: "dashboard.kpi.utilization", + new_clients: "dashboard.kpi.newClients", }; const normalizedKpis = createMemo(() => (resolvedSummary()?.kpis ?? []).map((kpi: any) => ({ ...kpi, diff --git a/apps/frontend/src/routes/dashboard/overview-page.tsx b/apps/frontend/src/routes/dashboard/overview-page.tsx index 3f6e640..dad8717 100644 --- a/apps/frontend/src/routes/dashboard/overview-page.tsx +++ b/apps/frontend/src/routes/dashboard/overview-page.tsx @@ -37,7 +37,7 @@ function OverviewPage() { {/* KPI Cards */} -