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.
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
- The frontend fetches a project's
metric_categories,metrics, andmetric_entriesfrom PocketBase (store/index.js'sfetchProjectScorecard). - For a given metric and a selected window,
rollupWindowreduces the matching entries to one value using that metric'saggregation. computeDelta/momentumDirectioncompare that value against the equal-length preceding window.targetForRangeTyperesolves the target for whichever cadence tab is active, and a value-vs-target percentage is derived.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
| Constant | Value | Meaning |
|---|---|---|
| Sparkline period count | 5 | Recent periods plotted on a metric's trend line |
| Cadence target-suggestion multipliers (from daily) | daily 1, weekly 5, monthly 20, quarterly 60, annually 240 | Used 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 366 | Ceiling day-count a quick-entry range may span for that cadence |
| Health statuses | on_track, off_track, at_risk, paused | Four-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
Wsand ends insideW, or starts insideWand ends afterWe- 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:
| Type | Formula | Meaning | Best for |
|---|---|---|---|
| Sum | total = Σ entry.value | Adds every entry inside the window together | Counts/amounts that accumulate - leads generated, deals closed, dollars spent |
| Day-weighted average | total = Σ(entry.value × entry.days) / Σ entry.days | Averages entries weighted by how many days each one covers, not just entry count | Rates/percentages that hold steady over a span - utilization %, on-time rate |
| Latest | value = entry with the latest period_end | Uses only the most recent entry in the window; earlier ones in the same window are ignored | Standing 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):
| Cadence | Multiplier 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:
| Cadence | Max days |
|---|---|
| Weekly | 7 |
| Monthly | 31 |
| Quarterly | 92 |
| Annually | 366 |
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
| What | Formula |
|---|---|
| Sum | Σ entry.value |
| Day-weighted average | Σ(value × days) / Σ days |
| Latest | value of the entry with max period_end |
| Delta | current - 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 conflict | existing.start <= new.end AND existing.end >= new.start |
19. Code map
| Concern | File |
|---|---|
| Roll-up (sum/average/latest) | lib/scorecard/rollup.js |
| Delta & momentum | lib/scorecard/delta.js |
| Value-vs-target %, sparkline, metric presentation | lib/scorecard/metricPresentation.js |
| Headline KPI momentum | lib/scorecard/momentum.js |
| Target auto-suggestion | lib/scorecard/targetPolicy.js |
| Cadence range validation | lib/scorecard/cadence.js |
| Window/date math | lib/scorecard/dateUtils.js |
| Overlap guard | lib/scorecard/overlap.js |
| Delete/archive guards | lib/scorecard/guards.js |
| Role & restriction cascade | lib/scorecard/entitlements.js |
| Health status enum & severity | lib/scorecard/healthStatus.js |
| Snapshot row building | lib/scorecard/snapshot.js |
| Snapshot pagination | lib/scorecard/snapshotPaginate.js |
| Aggregation explainer text (UI drawer) | lib/scorecard/aggregationExplain.js |
| Quick Entry state machine | lib/scorecard/useQuickEntry.js |
| Project-listing momentum hook | lib/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.
Related pages
- Project Overview
- FE Architecture - components, routes, data flow
- BE API Reference - the schema these formulas read