Analytics — Behavior
Auth
All routes are authenticated beneath /organizations/:orgId/analytics. The controller calls requireAnalyticsReader: MembershipsService.requireMembership, then PermissionsService.enforce(orgId, role, 'analytics', 'view'). Owner/admin may read; coach/member and non-members are denied.
Definitions
One vocabulary across analytics, the payments dashboard (and its platform-admin copy), the insights, the members screen and the agent tools. A noun means the same set of people, or the same sum of money, wherever it is printed. guardian_only seats are excluded from every row. Member predicates live in apps/api/src/memberships/member-status-predicates.ts (alias-aware: pass MEMBERSHIPS_M for raw SQL written FROM memberships m); money predicates in apps/api/src/payments/services/revenue-metrics.sql.ts.
| Term | Definition | Code | Read by |
|---|---|---|---|
| Active member | A member seat that trains, not deleted, membership active, whose resolved member state is training or leaving: can book today on a live plan, notice given or not. Prospects (no_plan, awaiting_payment, presale), expired, paused, payment_due, non_payment, suspended, invited and cancelled records are out. | trainingMemberCondition: the closed form of memberStatusCondition('training') ∪ memberStatusCondition('leaving') + role + participation (no open non-payment debt, and a subscription that is bookable or carries notice); analytics-vocabulary.int.spec.ts pins it to the roster’s two filters | members summary active / activatedActive, reports dashboard and attendance activeMembers / without-visit, space split, space stats presence |
| Active member at instant t (historical) | A member seat that trains, not invited/pending, joined_at before t, not deleted by t, end_date null or after t. A cancelled row with no end_date (an import-carried inactive member) has no knowable end and is left out. Membership status has no history, so this is the one “active” that is not trainingMemberCondition. | getMembersGrowth active; reports lifecycle | growth panel active line, retention activeMembers / activeAtEnd / cohorts |
| New member | A member seat that trains, not deleted, joined_at in the window, source null or not import, membership still active. The current-window headline only; its month-on-month delta compares it with last month’s Joined (history), not last month’s New member, so joiners who left since don’t shrink the baseline. | newMemberCondition | members summary newThisMonth (newDeltaPct baseline: joinedInWindowCondition) |
| Joined (history) | A member seat that trains, not an invitation still waiting (invited / pending_invitation), joined_at in the window, source null or not import. No current-status or deletion clause: status is today’s state, so a past month’s joined count never shrinks when one of its joiners later leaves. | joinedInWindowCondition | growth joined, reports dashboard newMembersThisMonth and previous.newMembers |
| Left (“עזבו”) | The membership record ended: status='cancelled', end_date set and in the window, joined_at not null, trains, and not source='import' AND end_date <= created_at (a departure that predates its import). Deleted (self-deleted or erased) records count: deletion cancels and stamps end_date, and the retention report’s lifecycle counts the same departures. | MEMBERSHIP_LEFT (analytics.service.ts) | members ribbon “left this month”, growth churned, members summary churnedThisMonth |
| Lapsed | The member’s last plan ended and nothing live replaced it: an activated subscription cancelled (current_period_start set), no live subscription. The enum value stays churned for saved audiences. | ?paymentStatus=churned (memberships.service.ts) | members list filter, members.recent_churn insight |
| At risk | The roster’s inDebt ∪ overdue on a live membership: a member seat, membership active, with a past_due subscription, a debt one, or a balance Outstanding counts (outstandingRowCondition). A leftover balance on a plan cancelled for another reason is not debt here either. Billing only; attendance plays no part. | atRiskMemberCondition, hasDebtCondition | ?paymentStatus=at_risk, roster inDebt bucket, members.at_risk_billing (card count = list size), getAtRiskMembers, MCP at_risk_members |
| Outstanding | One sum: debt_amount_in_cents over non-deleted subscriptions with a positive balance that are not cancelled, or were cancelled for non-payment (cancellation_reason = 'non_payment' or debt_since set); member seats that train, non-deleted memberships of any status (a former member who still owes is still owed). A balance cancelled with its plan for another reason was settled or waived and is out. | sumOutstanding, outstandingRowCondition | revenue summary outstandingInCents, at-risk totalDebtInCents, payments dashboard outstandingInCents / membersInDebt (and platform-admin billing), finance.outstanding_debt |
| MRR | Chargeable price ÷ INTERVAL_MONTHS[interval] summed over active subscriptions on recurring plans (plans.type = 'subscription'). Punch cards, drop-ins and courses are never recurring revenue. | RECURRING_PLAN | revenue summary mrrInCents (+ prior-month delta), payments dashboard mrrInCents |
| Forecasted recurring | The MRR sum on the payments KPIs; the prior-month figure uses active recurring subscriptions created by the end of last month. | getDashboardKPIs | payments “Forecasted recurring” KPI |
| Active subscriptions | Count of active subscriptions on recurring plans. Subscriptions, not members. | RECURRING_PLAN | revenue summary, payments dashboard, plan distribution (per plan) |
| Packs sold | Punch-card and drop-in transactions (plans.type IN ('class_pack','drop_in'), by the transaction’s plan or its subscription’s, the org’s own plans only) on collected revenue’s basis: status IN ('completed','refunded'), token validations out, windowed on COALESCE(completed_at, updated_at). amountInCents is net: pack charges received less refunds of pack charges in the window (a refund row carries its charge’s plan), so a ₪500 pack with ₪200 refunded is ₪300, as in collected. count is distinct packs (COALESCE(subscription_id, id): a pack paid in two charges counts once) among charges still completed: a partly refunded pack is sold, a fully refunded one is not. | sumPacksSold | revenue summary packsSold, overview revenue panel |
Recurring plans only, likewise: plan distribution, presale committed monthly (getPresaleCommitment, committedMonthlyInCents) and the payments recurring-charges (standing orders) list.
Analytics CSV exports carry no per-metric definition column; this table and the UI help text are the definitions.
Basic reports (TKN-24)
These are separate projections in AnalyticsReportsService inside AnalyticsModule. Existing AnalyticsService summaries and eight legacy export tabs below are not replaced.
Queries
- Report data routes and report exports check membership and
analytics:view, as every analytics route does; there is no per-organization availability gate. GET /attendance/report?from=YYYY-MM-DD&to=YYYY-MM-DD&classTypeId=<uuid>andGET /revenue/report?from=YYYY-MM-DD&to=YYYY-MM-DD&method=<payment-method>requirefrom/to; the class/method filters are optional.- Dates are inclusive org-local calendar dates, ordered
from <= to, with at most 366 days. Bounds are local midnight through the next local midnight, DST-aware and capped at now for observed data. Invalid dates/ranges, class UUIDs, or methods return 400. GET /members/cohorts?months=6&asOf=YYYY-MM-DDdefaults to six months through today.monthsmust be an integer 1–12; optionalasOfis capped at today. Invalid inputs return 400, not legacy permissive defaults.
Studio dashboard
GET /dashboard returns six KPIs with timezone, generation time, month, and explicit week bounds. Optional weekStartsOn=0|1 (default Sunday) names the week start; the web passes the same value its schedule uses:
- Active members:
trainingMemberCondition(definitions table; not the legacyactivatedActivecount). - Revenue MTD: actual completed/refunded receipts minus refunds, separately per currency; not recurring revenue or MRR.
- Classes this week: published, non-deleted sessions in the full org-local week, including upcoming sessions.
- Attendance MTD: attended/booked for ended, published, non-deleted sessions, using the attendance denominator below.
- New members MTD: Joined (history) —
joinedInWindowCondition— over the month to date (definitions table);previous.newMembersthe same over last month’s equal span. - Active members with no attended booking this month.
previous repeats revenue, new members, attendance rate and members without a visit for the previous month up to the same day of month, so the web can show month-to-date change badges that compare equal spans.
Attendance, class ranking, and capacity
GET /attendance/report returns totals and class rows ordered by booked count descending, then name and ID. Only ended, published, non-deleted sessions starting within the selected window participate.
- Booked = non-deleted bookings with status
confirmed,attended, orno_show; cancelled/waitlisted bookings are excluded. Confirmed bookings are unmarked; attendance rate isattended / booked × 100. - Historical membership joins enforce organization ownership only. A member ending/deleting their membership or now holding a staff role does not erase a real visit.
- Finite positive session capacity is summed once per session, including empty sessions. Utilization is weighted
capacityBooked / summedCapacity × 100, not an average of session percentages. Unlimited (null) and non-positive capacities are excluded from that ratio; unlimited sessions are counted separately. Overbooking may exceed 100%. - Undefined rates are
null, not zero. CurrentexternalSessionOccupancyis not a historical attendance source. membersnames who came:activeMembersis the active members now (definitions table),visitorsthe distinct memberships with an attended booking in the window,withoutVisitthe active members with none, andvisitsPerActiveMemberattended ÷ active members. The dashboard repeatswithoutVisitfor the month to date asmembersWithoutVisit.dailylists booked and attended per org-local date already started in the window, zero-padded;slotslists attended bookings and sessions by org-local weekday and start hour for the busiest-hours grid.
Revenue reports
GET /revenue/report returns totals, actual payment-method breakdowns, and monthly trend rows, each separated by currency without conversion or cross-currency sums. It reuses canonical receipts.sql.ts: completed/refunded transactions, refunds signed negative, metadata.tokenOnly excluded, and settlement time COALESCE(completed_at, updated_at) rather than creation time.
Collected and refunded cents are shown separately; net cents may be negative. Monthly rows expose previousNetInCents for the prior full month under the same method filter. Partial selected/current months have partial=true and deltaPct=null; full-month deltas are also null when the prior net is non-positive.
Membership cohorts
Monthly cohorts use member joinedAt as their denominator, excluding invitation shells. Retained means not ended/deleted by the observation end. Lifecycle end is deletion or end_date, with no updated_at fallback. Trains members only; shells nobody joined (joined_at null), cancelled rows with no end_date (import-carried inactive members) and departures that predate their import (source='import' AND end_date <= created_at) are left out.
monthly gives joined, left and memberships alive at each month end over the cohort window (the last month ends at the observation day). summary sits on the same lifecycle basis over the cohort window: memberships alive at the observation day, joined, left, net change, mean tenure of those who left, and the share of members who joined 90 to 180 days before the observation day and were still alive 90 days after joining (retained90Pct over retained90Sample).
The response declares basis='membership_lifecycle': this is a lifecycle estimate, not paid retention. There is no suspension/reactivation or historical paid-subscription ledger. Empty/future cells have null retained/rate values; the current observation month is marked partial.
Web and report CSV
/[lang]/dashboard/analytics renders task-focused areas, not per-projection navigation tabs. TKN-24 supplies the first three areas and seven projections; Trials is the fourth:
Area / URL tab | Projections | Unchanged CSV tab identifiers | Shared dataset |
|---|---|---|---|
Attendance / attendance | Attendance, class ranking, class capacity | attendance-report, class-ranking, class-capacity | /attendance/report |
Revenue / revenue | Currency totals, payment methods, monthly revenue | revenue-report, revenue-methods, revenue-monthly | /revenue/report |
Retention / retention | Membership lifecycle cohorts | member-cohorts | /members/cohorts |
Trials / trials | Funnel tiles; by source, class, day/time, coach, rep; attended, not converted | None | /analytics/trials (Trials) |
Only the selected area’s report dataset is fetched; its tables share that result rather than fetching once per projection. The reports-only Studio snapshot has its own /dashboard request, and Attendance loads class-picker options separately. Shared notes, empty/error states, and generation metadata appear once per area.
The sidebar entry, the four areas, and the StudioKpiSnapshot inside reports require analytics:view. Unauthorized users make no report data requests and see the no-access state. OverviewV2 mounts neither the Studio snapshot nor a report link and does not initiate their requests; existing overview panels remain unchanged.
URL state and filters
The URL is the source of truth, including initial navigation, reload, and browser back/forward. A fully pinned example is:
/en/dashboard/analytics?tab=attendance&from=2026-09-01&to=2026-09-09&classTypeId=11111111-1111-4111-8111-111111111111&method=cash&months=6&asOf=2026-09-09tabselects anattendance,revenue,retention, ortrialsarea. Missing or unknown areas normalize to Attendance.- Attendance uses
from,to, and optionalclassTypeId; Revenue usesfrom,to, and optionalmethod; Retention usesmonthsandasOf; Trials usesfromandto(class dates). Other areas’ filters remain in the URL when switching, but are not sent to an unrelated dataset or export. - Missing dates default to the organization’s current month start/today;
monthsdefaults to six andasOfto org-local today.router.replacepins these implicit defaults and the area into the URL without adding a history entry. This reproduces the selected period, not a frozen dataset. - Area/filter edits use
router.push; back/forward restores controls, selected area, queries, and matching CSV filters from current search parameters, not stale local state. - The selected area’s shared schema validates the full relevant input. Invalid/inverted/overlong ranges, invalid class UUIDs or payment methods, and invalid cohort inputs stay in the URL with a localized error; no selected-area dataset request or usable export is issued until corrected. This guard does not suppress the independent snapshot or class-options request. A valid historical class UUID absent from current picker options remains a usable filter and is explicitly labeled rather than reset to all classes.
English, Hebrew/RTL, and Russian labels and formatting are supported. Loading/error states never relabel cached previous-filter results as current. Each projection exports the same filtered dataset through the existing /export endpoint, retaining all seven identifiers above. Report CSV uses stable headers even for empty data, UTF-8 BOM, CRLF, proper quote/comma/newline escaping, and formula protection for dangerous text cells. Numeric negative money stays numeric; null values are empty cells. The eight legacy identifiers and their formatting below are unchanged.
Accounting audit deferrals
Report organization and action routing do not change accounting semantics. Canonical receipts.sql.ts was already the source for report revenue and Studio receipts: actual money movement, signed refunds, token-only exclusion, and settlement timestamp. Sharing those expressions does not make all money APIs interchangeable: currency shape, time windows, price basis, and historical reconstruction still differ.
| Deferred concern | Unchanged boundary |
|---|---|
| Legacy scalar multi-currency summaries | Legacy money summaries/trends can expose a single scalar across currencies. Reports instead keep each currency separate; there is no FX conversion or claim that legacy scalars equal report currency totals. |
PaymentAnalyticsService date windows | Prior-month host-local boundary construction, fixed-24-hour yesterday arithmetic, and host-local monthly-trend window construction remain audit items. Report DST-aware calendar bounds do not repair these legacy windows. |
| Plan distribution versus MRR | Legacy plan distribution normalizes plan list price × active subscription count; current MRR uses chargeable subscription pricing. These are not interchangeable measures, and distribution is not converted to chargeable MRR here. |
| Platform-admin cross-org aggregation | Raw status='completed' / createdAt aggregation remains outside this work; it is not retrofitted to the canonical org-report receipt definition. |
| Historical paid retention | No historical paid-retention ledger is introduced. Membership lifecycle cohorts cannot establish paid continuity, suspension/reactivation history, or a paid-retention curve. |
Existing money APIs, agent summary tools, and legacy exports remain available with their current contracts. None of these deferred concerns is claimed fixed by basic reports.
Summary analytics
Revenue
GET /analytics/revenue/summary
Returns:
{
mrrInCents: number;
mrrDeltaPct: number | null; // null when previous month had zero
collectedThisMonthInCents: number;
collectedDeltaPct: number | null;
outstandingInCents: number;
activeSubscriptions: number;
packsSold: { count: number; amountInCents: number };
committedMonthlyInCents: number; // FIT-287 presale: `scheduled` subs at their chargeable price
committedFrom: string | null; // `YYYY-MM-DD` (org tz) = opens_at while still ahead and scheduled revenue exists; null otherwise
}- MRR sums the chargeable price (
chargeableAmountInCents: negotiated > intro > list)/ INTERVAL_MONTHS[interval]overactivenon-deleted subscriptions on recurring plans (RECURRING_PLAN,plans.type = 'subscription'). The prior-month MRR behind the delta uses the same plan filter. INTERVAL_MONTHS = { weekly: 0.2308, monthly: 1, quarterly: 3, semi_annual: 6, yearly: 12 }.committedMonthlyInCentsis the same sum overscheduledrecurring subscriptions — sold, card on file, first charge waiting for opening day. Kept apart from MRR because nothing is billing yet; the overview’s revenue panel draws it as the projection fromcommittedFrom.committedFromis present only while the opening/first-charge day is ahead and the scheduled total is positive. A presale gym otherwise reads “₪0 recurring” while holding every founding member’s card.- Collected this month = net receipts per
apps/api/src/payments/services/receipts.sql.ts, the single definition shared with the payments page:status IN ('completed','refunded'), refunds (type='refund') signed negative,metadata.tokenOnlyrows (presale ₪0/₪1 card validations) excluded, windowed onCOALESCE(completed_at, updated_at). Regression watch: the overview once summedstatus='completed'bycreated_at, which counted a ₪1,713 refund as August’s best month. packsSold= Packs sold this month (definitions table), a slice of collected; the overview’s revenue panel names it under the collected headline.- Outstanding and active subscriptions: definitions table.
- Deltas compare to last month’s collected / MRR.
GET /analytics/revenue/trend?months=12
Returns RevenueTrendPoint[] — month-start bucket, label, and net cents received that month (same receipt rule as the summary; a month can be negative after a refund). Every month in the window is present, zero-padded.
GET /analytics/revenue/plan-distribution
Per-plan { planId, planName, interval, activeSubscriptions, monthlyRevenueInCents } over active subscriptions on recurring plans only; a punch card, drop-in or course never appears. activeSubscriptions counts subscriptions, not members. monthlyRevenueInCents normalizes plan list price × active subscription count, not each subscription’s chargeable MRR; that distinction is a deferred audit concern above.
Members
GET /analytics/members/summary
{
total: number;
active: number;
activatedActive: number; // active AND has Clerk account — distinguishes real users from CSV imports
newThisMonth: number;
newDeltaPct: number | null;
churnedThisMonth: number;
churnedDeltaPct: number | null;
}active and activatedActive read trainingMemberCondition, in one pass (COUNT(*) and COUNT(*) FILTER (WHERE clerk_id IS NOT NULL)); newThisMonth reads newMemberCondition; newDeltaPct compares it with last month’s joinedInWindowCondition (Joined (history)), so a month’s baseline does not shrink as its joiners leave. Example: September 10 joined and 3 of them have since cancelled, October 8 joined → -20%, not +14.3%. churnedThisMonth is Left. total is every roster row (member seats that train, not deleted, any status). activatedActive is the legacy summary’s engagement discriminator. It is not the denominator for TKN-24 booked attendance or membership lifecycle cohorts.
GET /analytics/members/activation
{ total, imported, invited, lapsed, activated } over the live roster (member seats that train, not deleted, membership not cancelled; managed members excluded):
imported— no Clerk user AND no pending invitation (typically CSV).invited— pendinginvitationsrow.lapsed— every invitation expired or was revoked (an invitation lapse, unrelated to the Lapsed churn metric).activated— has a Clerk account.
GET /analytics/members/growth?months=6
Exactly months rows (YYYY-MM, org-local), each { joined, churned, active }:
joined— Joined (history) by month (definitions table).churned— Left by month (definitions table):status='cancelled',end_datein the month,joined_atnot null, trains, deleted records included, and notsource='import' AND end_date <= created_at.active— the historical active member at month-end (definitions table).
end_date is written by every cancellation path and cleared by every re-activation; MEMBERSHIP_ENDED_AT = end_date.
Regression watch: month-end bounds are 23:59:59.999 local. The tz-offset probe must carry the milliseconds, or the bound lands one second into the next month and generate_series emits a phantom future bucket (+0 joined for next month on the overview).
GET /analytics/members/at-risk?sample=5
{ count, totalDebtInCents, sample: AtRiskMember[] }. count is the at-risk set (definitions table), the same members ?paymentStatus=at_risk lists and members.at_risk_billing counts. sample is capped at sample, ordered by the member’s own outstanding balance then name; each row carries reasons (past_due, debt, or has_debt_amount when only a balance flags them). totalDebtInCents is the org’s Outstanding, not the sum of the sample: it includes a former member who still owes.
Classes
GET /analytics/classes/popular?days=30&limit=10
Top class types by booking count over the past days.
GET /analytics/classes/utilization?days=30
Returns { overall, byClassType }. Capacity fill rate per class type plus the aggregate.
Workouts
GET /analytics/workouts/summary
{ assignedMtd, completedMtd, prsMtd, completionRate, ... }. Source: workout_assignments, workout_results, personal_records.
Coach overview
GET /analytics/coaches/overview
Per-coach: assigned athletes, recent activity, programming load. Drives the dashboard’s “coach health” view.
Legacy CSV export
GET /analytics/export?tab=<tab>
The eight existing synchronous tabs (new report tabs are described above):
revenue-summary→ 1-row CSV of the summary object;packsSoldbecomes two columns in its place,packsSoldCountandpacksSoldAmountInCents.revenue-trend→ trend points.plan-distribution→ per-plan rows.members-summary→ 1-row CSV.members-growth→ trend points.popular-classes→ top-N list.class-utilization→byClassType[]rows (filename:class-utilization-by-type-<date>.csv).workouts-summary→ 1-row CSV.
tab outside the allowed set → 400.
Legacy CSV builder (not the new analyticsReportCsv helper):
- Headers union of all row keys (in insertion order).
- Values JSON-stringified for objects.
- Cells with
",,, newline are wrapped in double-quotes with"escaped. Content-Type: text/csv; charset=utf-8.Content-Disposition: attachment; filename="<tab>-<YYYY-MM-DD>.csv".
Spotter agent surface
apps/api/src/ai/agent/tools/leaves/analytics.tools.ts defines twelve analytics actions, each a thin wrapper over a service method:
revenue_summary,revenue_trend,members_summary,members_growth,at_risk_members,popular_classes,class_utilization,coach_overview,org_insights(delegates toInsightsService),plan_distribution,members_activation,workouts_summary.
Their summary strings use the nouns of the definitions table: members_summary returns counts, at_risk_members is billing-based, plan_distribution counts recurring subscriptions, revenue_summary lists exactly the fields the summary returns (no refund totals).
All marked sideEffects: read and confirm: never. Visible to staff roles only (owner | admin | coach). The coach role only sees the tools that make sense for coaches at the prompt layer; server-side role gates run inside each underlying service.
Performance
- All queries are point-in-time SQL aggregations.
- Index dependencies:
subscriptions(membership_id, status, deleted_at),payment_transactions(organization_id, created_at, status),memberships(organization_id, status, role),class_sessions(organization_id, starts_at),bookings(class_session_id, status). - The member predicates correlate every subquery on the membership id alone (a subscription’s organization is reached through its own membership row) and keep the active member’s subqueries at the top level, so Postgres plans them as semi- and anti-joins. A subquery that also read
m.organization_id, or sat under anOR, ran as a SubPlan per membership row; its estimated cost crossedjit_above_cost(100k) at about a thousand members and every dashboard read paid JIT compilation.active-member-plan.int.spec.tskeeps the members-summary active count below that threshold on a 2,500-member org. - Measured on a synthetic org (PG 16, default JIT; ~1.3 subscriptions per member, ledger rows, two bookings per member): the members-summary active count costs ~2k at 5k members and runs in ~50 ms at 20k (was 287k / 1.1M cost, ~600 ms with JIT); the attendance people query ~65 ms at 20k (was ~1.3 s); the space-stats presence join ~80 ms (was ~1.2 s). Attendance computes active and without-visit in one scan; space-stats materializes the active set once before joining bookings.
- No caching layer; no production-scale latency claim follows from these local measurements.
Failure modes
| Failure | Surface | Recovery |
|---|---|---|
| Caller is not owner/admin | 403 | Sign in as the right role. |
Legacy months / limit not a number | Falls back to default. | New report schemas instead reject invalid input with 400. |
Unknown tab on export | 400 with allowed list. | Use a valid tab. |
| Query timeout | 500 / 504 | Investigate index health; potentially add caching. |
Managed members
Member counts include only memberships.participation = 'trains', so guardian_only adults (members only to manage a child) are excluded everywhere (rosterMemberCondition, trainingMemberCondition, atRiskMemberCondition, newMemberCondition, joinedInWindowCondition in apps/api/src/memberships/member-status-predicates.ts; sumOutstanding in apps/api/src/payments/services/revenue-metrics.sql.ts; reports lifecycle). Children who train are counted. Ungated. Spec: managed-members/behavior.md §4.1.