mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
feat(metrics): new-clients KPI and scheduled reminder dispatch
The dashboard promised a new-clients card the API never produced; the metric now counts distinct customer emails whose earliest-ever booking lands in the window, joining the other four KPIs with the same trend comparison. The card grid rebalances for five items. Reminder dispatch also had no trigger — the endpoint existed but nothing called it. A scheduled GitHub Actions workflow now posts to it every 15 minutes using the job-runner key, so reminders actually fire once the two secrets are configured. 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
cdad7875e1
commit
c7172544b8
8 files changed
+93
-20
No files matched your search
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
@@ -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", "[email protected]")
|
||||
seed(2, 0, "confirmed", "[email protected]")
|
||||
seed(3, 0, "cancelled", "[email protected]")
|
||||
// Last 7 days: 1 confirmed booking that already ended (returning client).
|
||||
seed(-2, 0, "confirmed", "[email protected]")
|
||||
// 7-14 days ago: 2 completed bookings; b@'s first visit plus a new client.
|
||||
seed(-8, 0, "confirmed", "[email protected]")
|
||||
seed(-9, 0, "confirmed", "[email protected]")
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -37,7 +37,7 @@ function OverviewPage() {
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
{data.normalizedKpis().map((kpi: any, index: number) => (
|
||||
<KpiCard kpi={kpi} index={index} />
|
||||
))}
|
||||
|
||||
@@ -96,6 +96,11 @@
|
||||
- reschedule hardened with the same validation as booking creation: appointment reschedules must fit an availability rule and avoid blocked days (`validateAppointmentTarget` is now a shared package-level helper); class bookings keep session-defined times
|
||||
- new repository methods `CancelPendingReminderJobsForBooking` / `DeletePendingReminderJobsForBooking` in pgx and in-memory implementations
|
||||
- remaining: live staging validation; SMS reminder channel exists in schema (`channel` column) but only `email` is dispatched — SMS reminders are a separate opt-in feature
|
||||
- `[done/partial]` Milestone 14: Metrics completion + reminder dispatch scheduling
|
||||
- `DashboardMetrics` gained `NewClientsCount` — distinct customer emails whose earliest-ever booking (`starts_at`) lands in the window; a fifth KPI `new_clients` completes the set the demo data promised
|
||||
- KPI grid rebalanced for five cards (`lg:grid-cols-3 xl:grid-cols-5`); `new_clients` maps to the existing `dashboard.kpi.newClients` i18n key
|
||||
- reminder dispatch now has a real trigger: `.github/workflows/reminder-dispatch.yml` calls `POST /v1/internal/jobs/reminders/dispatch` every 15 minutes with `X-Bookra-Job-Key` from secrets (`BOOKRA_API_URL`, `BOOKRA_JOB_RUNNER_KEY`); jobs are timestamp-based so scheduler lateness delays but never skips
|
||||
- remaining: live staging validation; the workflow no-ops gracefully until the two secrets are configured
|
||||
|
||||
## 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.
|
||||
|
||||
Reference in new issue
Block a user