Skip to main content

FS Garage Score Card - Metrics and Calculations

Every number the Score Card shows is computed on the frontend, in a small set of pure, unit-tested functions under lib/scorecard/*.js (base-fs-garage-fe). The backend (fs-garage-base) only stores raw entries and per-cadence targets - see FS Garage Score Card (BE) for the schema those functions read.

Source of truth for the design intent behind these rules: docs/design/scorecard-data-model.md §1 in base-fs-garage-fe (owner: Thanush), whose numbered rules (D1-D12, A11, O2/O3/O5) are cited below and appear verbatim as comments in the code they describe.

Why one shared library

The live scorecard view and the snapshot exporter call the exact same functions (rollupWindow, targetForRangeType, recentPeriodWindows, ...) - so what gets exported can never disagree with what was on screen when it was generated.

1. How numbers are produced​

  1. The frontend fetches a project's metric_categories, metrics, and metric_entries from PocketBase (store/index.js's fetchProjectScorecard).
  2. For a given metric and a selected window, rollupWindow reduces the matching entries to one value using that metric's aggregation.
  3. computeDelta/momentumDirection compare that value against the equal-length preceding window.
  4. targetForRangeType resolves the target for whichever cadence tab is active, and a value-vs-target percentage is derived.
  5. presentMetric (or, at export time, buildSnapshotSections) combines all of the above plus a 5-period sparkline into what a card/row actually renders.

2. Constants and terminology​

ConstantValueMeaning
Sparkline period count5Recent periods plotted on a metric's trend line
Cadence target-suggestion multipliers (from daily)daily 1, weekly 5, monthly 20, quarterly 60, annually 240Used to auto-suggest every other cadence's target once one is entered
Cadence max days (quick-entry range)weekly 7, monthly 31, quarterly 92, annually 366Ceiling day-count a quick-entry range may span for that cadence
Health statuseson_track, off_track, at_risk, pausedFour-state enum, each with its own color pair

3. Roll-up: which entries count for a window (containment, D3 + O2 amendment)​

For a window W = [Ws, We], an entry counts at full value if either:

  • Fully contained: Ws <= entry.period_start && entry.period_end <= We, or
  • Straddles exactly one edge: starts before Ws and ends inside W, or starts inside W and ends after We - it then counts fully in both adjacent windows it touches (e.g. a Jun 28-Jul 4 entry counts fully in both June's and July's totals), not pro-rated.

An entry that overhangs both edges (e.g. a whole month viewed against one of its own weeks) never counts - that's a granularity mismatch, not a boundary straddle. If nothing counts, the roll-up returns null - never 0 - so "no data" can be distinguished from "recorded zero" all the way through delta/momentum/display logic.

4. The three aggregation types​

This is the exact copy shown in the product's own Aggregation Info Drawer (lib/scorecard/aggregationExplain.js), kept in sync with rollup.js by convention so the UI's explanation never drifts from what actually runs:

TypeFormulaMeaningBest for
Sumtotal = Σ entry.valueAdds every entry inside the window togetherCounts/amounts that accumulate - leads generated, deals closed, dollars spent
Day-weighted averagetotal = Σ(entry.value × entry.days) / Σ entry.daysAverages entries weighted by how many days each one covers, not just entry countRates/percentages that hold steady over a span - utilization %, on-time rate
Latestvalue = entry with the latest period_endUses only the most recent entry in the window; earlier ones in the same window are ignoredStanding balances that don't accumulate - headcount, account balance

Worked example (day-weighted average): a 90% entry covering 20 days and a 70% entry covering 5 days average to 86% for the period ((90×20 + 70×5) / 25), not a flat 80% - uneven entry lengths don't skew the result.

5. All-time totals​

The funnel section's "total" column uses rollupAll - the same three formulas above with no window filtering, i.e. every entry ever logged for the metric.

6. Delta and momentum (D12)​

delta = currentWindowValue - previousWindowValue   (null if either side is null)
momentum = "flat" if delta === 0
"up" if delta > 0
"down" if delta < 0
null if delta is null -> chip is hidden, never shown as a fake zero

The "previous window" is always the immediately preceding window of equal length to whatever's currently selected (previousEqualLengthWindow), not a fixed calendar period - this makes delta work identically for a custom range as for a calendar week/month/quarter/year.

7. Value-vs-target percentage​

pct = min(100, round((value / target) * 100))

Capped at 100% for display. target resolves to whichever per-cadence field matches the view's current timeframe tab (target_daily / target_weekly / target_monthly / target_quarterly / target_annually, or target_custom for a custom range) - metrics can hold several cadence targets at once (targetForRangeType).

8. Headline KPI momentum (project listing sparkline)​

For each of the last 4 weeks, weeklyHeadlineMomentum averages (value / target_weekly) × 100 across every project metric flagged is_headline_kpi, then plots one point per week:

weekPercent = average of (rolledUpValue / metric.target_weekly × 100) across all headline metrics
= null if no headline metric has a contained value that week

Always reads target_weekly regardless of which tab the live scorecard is on - a fixed weekly cadence by design, since a headline metric is required to have a target before it can be flagged headline (never a target_weekly = null divide-by-zero case).

9. Target auto-suggestion (D7)​

Entering one cadence's target auto-fills sensible suggestions for the others, on a fixed scale expressed as multipliers from a daily-equivalent (avoids compounding rounding drift from chaining cadence-to-cadence):

CadenceMultiplier from daily
Daily×1
Weekly×5
Monthly×20
Quarterly×60
Annually×240
dailyEquivalent = sourceValue / multiplier[sourceCadence]
suggestion[cadence] = dailyEquivalent × multiplier[cadence]

Rounded to a whole number for count-unit metrics, or 2 decimal places otherwise. target_custom is excluded from the scale (it's a one-off range, not a recurring cadence).

Worked example: entering a weekly target of 150 → daily suggestion 150/5 = 30 → monthly 30×20 = 600, quarterly 30×60 = 1,800, annually 30×240 = 7,200.

When a target is met (currentValue >= target), the UI treats it as a moment to set a new target and keep progress going - there's no separate target-history collection (v1 keeps only the current value per cadence field).

10. Cadence validation for quick entry (A11 amends D7)​

A metric accepts a quick-entry range up to (not only exactly) its cadence's ceiling day-count:

CadenceMax days
Weekly7
Monthly31
Quarterly92
Annually366

Ceiling counts, not calendar-exact - a range longer than the longest possible instance of its cadence is rejected regardless of which specific month/quarter it falls in.

11. Sparkline (5-period trend)​

The sparkline on a metric card plots 5 recent periods at the same granularity as the active timeframe tab (5 weeks for the Weekly tab, 5 months for Monthly, etc.) rather than always the last 4 raw entries - so it visibly changes when switching tabs. A period with no rolled-up value reads as 0. "Custom" ranges have no repeatable period length, so they fall back to the last 4 raw entries instead.

12. Overlap guard (D2)​

Two entries of the same metric may never cover overlapping dates:

conflict if existing.period_start <= new.period_end AND existing.period_end >= new.period_start

Enforced both client-side (findOverlappingEntry, blocks the write before it's sent) and DB-side (the PocketBase create/update rule additionally requires period_start <= period_end on the row itself).

13. Health status​

Four states, each with its own container/foreground color pair: On Track, Off Track (stalled, not failed), At Risk, Paused (inactive by choice). Sort order for the listing's Health column (most urgent first): At Risk → Off Track → On Track → Paused, unset last. The enum maps both ways to/from the legacy Title Case values already used by the existing project_updates.status field.

14. Delete / archive guards (D11)​

  • Category: deletion is blocked while it has child categories or metrics - it must be emptied first.
  • Metric: deletes outright only if it has no entries; a metric with recorded entries archives (is_active = false) instead, preserving its history. Hard-deleting a metric with history is never offered.

15. Role-based restrictions (entitlements, A1/D5/D6)​

role = "leadership"  if user.role in {ceo, c-suite}
"owner" if user.role == "project-lead" OR project.owner === user.id
"member" otherwise

A category is restricted if it's flagged restricted itself, or its parent is (one level of nesting max - restriction always cascades down, never overridden by a child's own flag being false). A metric is restricted if it's flagged itself or its category is. Only leadership/owner can view restricted items, manage the scorecard's structure, share snapshots, or view the project-updates history; member cannot.

16. Snapshot export math​

The export reuses the exact same roll-up/target/pct/sparkline formulas as the live view (buildSnapshotSections calls the same rollupWindow/targetForRangeType/recentPeriodWindows), so what's exported always matches what was on screen at export time. Two additional rules only apply to exports:

  • External audience: every restricted metric (cascade above) is stripped from the row list before building sections - an external snapshot can never leak a metric the live view would also hide from that viewer.
  • Funnel bar width: widthPct = max(8, round(value / maxRowValue × 100)) - bars are scaled relative to the funnel's own largest row (floored at 1 to avoid divide-by-zero), with an 8% minimum so a tiny non-zero value stays visible. A real recorded zero and a true "no data" row render identically muted - the export never visually distinguishes the two.

Page breaks in the exported snapshot are pagination-aware (snapshotPaginate.js): a funnel section and a bar-list card are each one atomic block that never splits across a page, KPI rows pair up 2-per-row, and every block-height estimate is a fixed constant mirrored from the live snapshot components so the export never drifts from what they actually render.

17. Quick Entry behavior​

  • One shared week picker (Sunday-Saturday), defaulting to the current week.
  • Each metric field auto-saves independently on blur/Enter - there is no batch "Save all" step.
  • A blank field is never saved as a zero; it simply isn't submitted.
  • A week that already has an entry renders as a static value that becomes an editable field on click, backed by an update rather than a create.
  • A save error leaves the typed value in the field rather than clearing it, so the user can just retry.

18. Quick formula cheat sheet​

WhatFormula
SumΣ entry.value
Day-weighted averageΣ(value × days) / Σ days
Latestvalue of the entry with max period_end
Deltacurrent - previous (equal-length windows, null-safe)
Value vs. target %min(100, round(value / target × 100))
Headline momentum (weekly)average of value / target_weekly × 100 across headline metrics
Target suggestion(sourceValue / multiplier[source]) × multiplier[target]
Funnel bar width %max(8, round(value / maxRowValue × 100))
Overlap conflictexisting.start <= new.end AND existing.end >= new.start

19. Code map​

ConcernFile
Roll-up (sum/average/latest)lib/scorecard/rollup.js
Delta & momentumlib/scorecard/delta.js
Value-vs-target %, sparkline, metric presentationlib/scorecard/metricPresentation.js
Headline KPI momentumlib/scorecard/momentum.js
Target auto-suggestionlib/scorecard/targetPolicy.js
Cadence range validationlib/scorecard/cadence.js
Window/date mathlib/scorecard/dateUtils.js
Overlap guardlib/scorecard/overlap.js
Delete/archive guardslib/scorecard/guards.js
Role & restriction cascadelib/scorecard/entitlements.js
Health status enum & severitylib/scorecard/healthStatus.js
Snapshot row buildinglib/scorecard/snapshot.js
Snapshot paginationlib/scorecard/snapshotPaginate.js
Aggregation explainer text (UI drawer)lib/scorecard/aggregationExplain.js
Quick Entry state machinelib/scorecard/useQuickEntry.js
Project-listing momentum hooklib/scorecard/useProjectMomentum.js

20. How to verify​

npm test -- lib/scorecard

Runs every formula's unit test (rollup.test.js, delta.test.js, targetPolicy.test.js, healthStatus.test.js, overlap.test.js, cadence.test.js, snapshot.test.js, snapshotPaginate.test.js, dateUtils.test.js, momentum.test.js, metricPresentation.test.js) independently of the UI component suite.