Skip to content

Add precomputed user statistics with delta-based updates - #184

Merged
WolfSoko merged 8 commits into
mainfrom
claude/implement-issue-182-tdd-fRSFD
Apr 5, 2026
Merged

WolfSoko merged 8 commits into
mainfrom
claude/implement-issue-182-tdd-fRSFD

Conversation

@WolfSoko

@WolfSoko WolfSoko commented Apr 5, 2026 •

Copy link
Copy Markdown
Owner

Summary

Introduces a new UserStats model and delta-calculation system to precompute and maintain user statistics (total reps, streaks, heatmaps, best days, etc.) via Cloud Functions. Statistics are updated atomically on every pushup create/update/delete operation, eliminating the need for expensive client-side aggregations.

Key Changes

New Models & Types

  • UserStats model (libs/stats/src/lib/models/user-stats.models.ts): Server-side precomputed statistics including totals, period aggregates (daily/weekly/monthly), streaks, heatmaps, and performance metrics
  • Type definitions for heatmap slots, best day/entry tracking, and metadata

Core Delta Logic

  • user-stats-delta.ts (data-store/functions/src/user-stats-delta.ts): Pure, side-effect-free functions for:
    • berlinParts(): Extract Berlin-local date/time from ISO timestamps
    • isoWeekFromYmd(): Compute ISO week numbers
    • periodKeys(): Build daily/weekly/monthly period keys
    • heatmapSlot(): Format weekday+hour heatmap slots
    • daysBetween(): Calculate calendar day differences
    • updateStreak(): Manage consecutive-day streaks
    • applyDelta(): Core logic for create (+reps), update (diff), and delete (-reps) operations
    • rebuildFromEntries(): Rebuild stats from scratch for backfill and streak recalculation after deletes

Comprehensive Test Coverage

  • 600+ lines of tests (user-stats-delta.spec.ts) covering:
    • Berlin timezone handling and day boundary edge cases
    • ISO week calculations and year rollovers
    • Period key formatting
    • Heatmap slot generation
    • Streak logic (consecutive days, gaps, same-day entries)
    • Delta application for create/update/delete scenarios
    • Best day and best single entry tracking
    • Negative value flooring to prevent data corruption

Cloud Functions Integration

  • Migrated to TypeScript (data-store/functions/src/index.ts): Converted from CommonJS to ES modules with full type safety
  • New UserStatsApiService (libs/data-access/src/lib/api/user-stats-api.service.ts): Client-side service to fetch precomputed stats from Firestore
  • Firestore security rules updated to allow owner read-access to userStats/{userId} collection (Admin SDK write-only)
  • Cloud Functions build configuration: Added esbuild setup with Node 24 runtime support

Store Integration

  • DashboardStore and AnalysisStore updated to consume precomputed UserStats via new UserStatsApiService
  • Eliminates need for client-side stat recalculation on every data load

Build & Configuration

  • Added data-store/functions/project.json with esbuild and Jest configuration
  • Updated firebase.json to point to compiled Cloud Functions output
  • Updated package.json to use Node 24 and correct entry point
  • Added ESLint dependency rules for cloud-functions scope

Implementation Details

Delta-based updates: Instead of recalculating all stats on every change, the system applies incremental deltas:

  • Create: +reps and +1 entry
  • Update: ±(newReps - oldReps) diff
  • Delete: -reps and -1 entry

Period reset logic: When a user moves to a new day/week/month, period counters reset while totals accumulate. Streak updates only on creates/updates; deletes preserve the streak (conservative approach).

Timezone handling: All date calculations use Berlin timezone (Europe/Berlin) via Intl.DateTimeFormat to ensure consistent day boundaries across regions.

Rebuild capability: rebuildFromEntries() allows full recalculation from raw pushup entries for backfill operations and streak recovery after deletes.

https://claude.ai/code/session_01WUgLPxZ66o25ij4BYjqC9H

Summary by CodeRabbit

  • New Features

    • Server-side precomputed per-user stats with rebuild/backfill and delta maintenance
    • Personal statistics: daily/weekly/monthly totals, heatmap, streaks, and personal bests
    • Client stores and components surface server stats, heatmap, and live-triggered refreshes
  • Infrastructure

    • Cloud Functions build/runtime updated to Node 24 and new build output
    • Firebase rule added to restrict userStats reads to the owning user
  • Tests / Documentation

    • Extensive unit tests for stats logic and updated docs for cloud functions and aggregation guidance

claude added 3 commits April 5, 2026 17:07
…ge with delta-based user stats (#182)

- Convert Cloud Functions from plain JS to TypeScript with proper Nx project (cloud-functions)
- Add esbuild build, Jest tests, and ESLint integration
- Define UserStats model with emptyUserStats factory in @pu-stats/models
- Implement pure delta-calculation logic (applyDelta) for create/update/delete
- Add updateUserStatsOnPushupWrite Cloud Function trigger
- Add Firestore security rules for userStats/{userId} (owner read, admin SDK write)
- Update firebase.json to deploy from build output (dist/cloud-functions)
- Add cloud-functions scope to enforce-module-boundaries ESLint rules
- 32 unit tests covering berlinParts, periodKeys, streaks, heatmap, bestDay/bestEntry

https://claude.ai/code/session_01WUgLPxZ66o25ij4BYjqC9H
- Add rebuildFromEntries pure function for full-scan backfill (4 new tests)
- Add rebuildUserStats admin-callable Cloud Function (single user or all)
- Add UserStatsApiService in @pu-stats/data-access for reading userStats/{userId}
- Export UserStatsApiService from library barrel

https://claude.ai/code/session_01WUgLPxZ66o25ij4BYjqC9H
- Add totalDays field to UserStats model for allTimeAvg calculation
- DashboardStore: prefer precomputed UserStats for todayTotal, weekReps,
  monthReps, and currentStreak with client-side fallback
- AnalysisStore: expose heatmapData from precomputed UserStats
- Update component tests to provide UserStatsApiService mock
- rebuildFromEntries now computes totalDays from unique entry dates

https://claude.ai/code/session_01WUgLPxZ66o25ij4BYjqC9H
Copilot AI review requested due to automatic review settings April 5, 2026 18:01
@coderabbitai

coderabbitai Bot commented Apr 5, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Converts Cloud Functions to Node 24 TypeScript/esbuild, adds transactional delta-backed server-side userStats aggregation (trigger + rebuild), surfaces userStats via a new data-access API and frontend wiring, and restricts client writes to the userStats collection via Firestore rules.

Changes

Cohort / File(s) Summary
Firebase config
data-store/firebase.json
Set functions.source to functions-dist and added functions.runtime: "nodejs24".
Firestore rules
data-store/firestore.rules
Added match /userStats/{userId}: allow reads only when request.auth.uid == userId; deny all client writes.
Cloud Functions package & build
data-store/functions/package.json, data-store/functions/project.json, data-store/functions/jest.config.cts, data-store/functions/tsconfig.json, data-store/functions/tsconfig.app.json, data-store/functions/tsconfig.spec.json, data-store/.gitignore
Introduce cloud-functions project, bump Node engine to 24, switch main to index.cjs, add Nx build/test targets (esbuild, jest/ts-jest), tsconfig variants, jest config, and ignore functions-dist/.
Cloud Functions code
data-store/functions/src/index.ts, data-store/functions/src/types.d.ts
Rewrote functions to TS/ESM exports, added web-push typings, added updateUserStatsOnPushupWrite (onDocumentWritten) and rebuildUserStats callable, converted callables/triggers to export const, and tightened types/error handling.
User-stats delta logic & tests
data-store/functions/src/user-stats-delta.ts, data-store/functions/src/user-stats-delta.spec.ts
New pure helpers for Berlin-time period keys, applyDelta for incremental updates, rebuildFromEntries for full rebuilds, and comprehensive unit tests covering date math, deltas, streaks, heatmap, bests, and rebuild behavior.
Stats model & tests
libs/stats/src/lib/models/user-stats.models.ts, libs/stats/src/lib/models/user-stats.models.spec.ts, libs/stats/src/lib/models/stats.models.ts
Add UserStats interface, HeatmapSlot type, emptyUserStats factory, tests, and re-export in stats barrel.
Data-access API & tests
libs/data-access/src/lib/api/user-stats-api.service.ts, libs/data-access/src/lib/api/user-stats-api.service.spec.ts, libs/data-access/src/index.ts
Add UserStatsApiService returning `Observable<UserStats
Frontend stores & UI wiring
web/src/app/stats/analysis.store.ts, web/src/app/stats/dashboard.store.ts, web/src/app/stats/components/analysis-teaser-card/..., web/src/app/stats/shell/*.ts, web/src/app/stats/shell/*.spec.ts, web/src/app/stats/shell/stats-dashboard.component.html
Inject UserStatsApiService/UserContextService, add userStatsResource, expose userStats/heatmapData, prefer server metrics when period-keys match, add refresh signal and propagate refresh paths; update tests and templates to mock/wire new services and inputs.
Nx wiring & lint/docs
data-store/project.json, eslint.config.mjs, CLAUDE.md
Add cloud-functions implicit dependency, make serve/emulate depend on cloud-functions:build, add deploy:functions target, add module-boundary constraint for scope:cloud-functions, and document cloud-functions architecture/testing guidance.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Client as Web Client
    participant Firestore as Firestore DB
    participant CFn as Cloud Functions

    User->>Client: Create/Update pushup
    Client->>Firestore: Write pushups/{pushupId}
    Firestore->>CFn: Trigger onDocumentWritten(pushups/{pushupId})
    CFn->>Firestore: Read userStats/{userId} (transaction)
    Firestore-->>CFn: existing userStats or null
    CFn->>CFn: applyDelta (period keys, heatmap, streak, totals)
    CFn->>Firestore: Commit transactional write to userStats/{userId}
    Client->>Firestore: Read userStats/{userId}
    Firestore-->>Client: Return computed userStats
    Client->>Client: Update local store (userStats, heatmap)
Loading
sequenceDiagram
    participant Admin
    participant CFn as Cloud Functions
    participant Firestore as Firestore DB

    Admin->>CFn: Call rebuildUserStats(optional userId)
    CFn->>Firestore: Query pushups for user(s)
    Firestore-->>CFn: Return pushup entries
    CFn->>CFn: rebuildFromEntries (sort, aggregate, streak, bests)
    CFn->>Firestore: Write rebuilt userStats documents
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 In Berlin hours the deltas play,

Heatmap burrows light the day,
Functions hop, rebuilds mend,
Streaks keep going to the end,
Rabbit cheers — a tidy run!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and specifically summarizes the main change: introducing precomputed user statistics with delta-based updates, which is the core feature of this PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/implement-issue-182-tdd-fRSFD

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment on lines 174 to 183
});

const weekReps = computed(() => {
const us = userStats();
if (us) return us.weeklyReps;

// Fallback: client-side computation
const today = new Date();
const dayOfWeek = (today.getDay() + 6) % 7; // Monday = 0
const monday = new Date(today);

This comment was marked as outdated.

Comment on lines +225 to +229
if (dailyReps > 0 && keys.dailyKey === (base.dailyKey || keys.dailyKey)) {
if (!bestDay || dailyReps > bestDay.total) {
bestDay = { date: keys.dailyKey, total: dailyReps };
}
}

This comment was marked as outdated.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 12134d84ad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread data-store/functions/src/index.ts Outdated
Comment on lines +1117 to +1121
const updated = applyDelta(existing, {
userId,
repsDelta,
entriesDelta,
timestamp,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Split timestamp-changing updates into two deltas

This transaction applies a single applyDelta with one timestamp and repsDelta = newReps - oldReps. If a pushup is edited to a different timestamp/day (supported via PushupUpdate.timestamp), the old day/week/month/heatmap bucket is never decremented correctly and timestamp-only edits (repsDelta = 0) can zero or misplace period counters, so stats drift until a full rebuild.

Useful? React with 👍 / 👎.

Comment on lines +103 to +105
const allTimeDays = computed(
() => userStats()?.totalDays ?? allTimeStats().meta.days
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop trusting totalDays from incremental userStats

The dashboard now prefers userStats.totalDays, but the new delta implementation keeps totalDays unchanged (it is only accurate after rebuild), so this value remains 0 or stale during normal create/update/delete flows. Once userStats exists, allTimeDays and therefore allTimeAvg become incorrect for affected users.

Useful? React with 👍 / 👎.

Comment on lines 176 to +179
const weekReps = computed(() => {
const us = userStats();
if (us) return us.weeklyReps;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate weekly/monthly totals by their period keys

This returns server-side period totals unconditionally; unlike todayTotal, there is no check that the stored weeklyKey/monthlyKey is still the current period. After week/month rollover without a new write, users will see previous-period totals instead of 0 until another pushup event refreshes userStats.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a server-precomputed UserStats document and Cloud Functions delta-update pipeline, then wires the web app to consume these stats (with client fallbacks).

Changes:

  • Introduces UserStats model + API service to fetch userStats/{userId} from Firestore.
  • Adds Cloud Functions delta/rebuild logic and Firestore trigger to maintain userStats.
  • Updates dashboard/analysis stores and emulator/build config (esbuild + TS, rules, firebase.json).

Reviewed changes

Copilot reviewed 23 out of 24 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
web/src/app/stats/shell/stats-dashboard.component.spec.ts Adds UserStatsApiService mock provider for component tests.
web/src/app/stats/shell/analysis-page.component.spec.ts Adds UserStatsApiService + UserContextService mocks for analysis page tests.
web/src/app/stats/dashboard.store.ts Uses UserStatsApiService + resource to prefer precomputed stats (with fallbacks).
web/src/app/stats/analysis.store.ts Adds UserStatsApiService resource and exposes server heatmap data.
libs/stats/src/lib/models/user-stats.models.ts New UserStats interface + emptyUserStats initializer.
libs/stats/src/lib/models/user-stats.models.spec.ts Tests the UserStats model initializer and shape.
libs/stats/src/lib/models/stats.models.ts Re-exports user-stats.models.
libs/data-access/src/lib/api/user-stats-api.service.ts New Firestore read service for userStats/{userId}.
libs/data-access/src/lib/api/user-stats-api.service.spec.ts Tests for UserStatsApiService.
libs/data-access/src/index.ts Exports UserStatsApiService.
eslint.config.mjs Adds Nx module-boundaries rule for scope:cloud-functions.
data-store/project.json Emulators now include functions; adds build dependencies + deploy target.
data-store/functions/tsconfig.spec.json Jest TS config for functions tests.
data-store/functions/tsconfig.json Base TS config for functions project.
data-store/functions/tsconfig.app.json App TS config for functions build.
data-store/functions/src/user-stats-delta.ts Pure delta + rebuild logic for UserStats.
data-store/functions/src/user-stats-delta.spec.ts Large test suite for delta/rebuild logic.
data-store/functions/src/types.d.ts Declares web-push module types.
data-store/functions/src/index.ts Migrates functions to TS/ESM syntax and adds user-stats trigger + admin rebuild callable.
data-store/functions/project.json Nx project for functions with esbuild + jest.
data-store/functions/package.json Updates node engine + entrypoint naming for built functions.
data-store/functions/jest.config.cts Jest config + coverage thresholds for functions project.
data-store/firestore.rules Allows owner-read of userStats/{userId} (Admin SDK write-only).
data-store/firebase.json Points functions source to built output + sets nodejs24 runtime.
Comments suppressed due to low confidence (1)

data-store/functions/src/index.ts:1106

  • updateUserStatsOnPushupWrite picks a single timestamp (prefers after.timestamp) and applies a repsDelta. If an update changes timestamp (UI sends timestamp on update), you must remove the old contribution (old timestamp/day/week/heatmap slot) and add the new one; current logic won’t (and repsDelta can be 0). Handle before.timestamp !== after.timestamp as “delete old + create new” (or trigger rebuildFromEntries).

Comment on lines 151 to +155
const currentStreak = computed(() => {
const us = userStats();
if (us) return us.currentStreak;

// Fallback: client-side computation

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currentStreak returns userStats.currentStreak without checking whether the streak is still valid today. If the last entry was >1 day ago, server-side currentStreak will remain non-zero unless recomputed. Use lastEntryDate vs today/yesterday to decide whether to use userStats.currentStreak, otherwise return 0 / fall back to client calc.

Copilot uses AI. Check for mistakes.
Comment thread web/src/app/stats/dashboard.store.ts Outdated
Comment on lines +176 to +178
const weekReps = computed(() => {
const us = userStats();
if (us) return us.weeklyReps;

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

weekReps returns userStats.weeklyReps unconditionally. If userStats.weeklyKey isn’t the current ISO week (e.g., no entries this week or an older entry was edited), this will show stale data. Compare against the current week key and fall back to computing from entries when it doesn’t match.

Suggested change
const weekReps = computed(() => {
const us = userStats();
if (us) return us.weeklyReps;
const getCurrentIsoWeekKey = () => {
const today = new Date();
const date = new Date(today);
date.setHours(0, 0, 0, 0);
const day = (date.getDay() + 6) % 7;
date.setDate(date.getDate() + 3 - day);
const isoYear = date.getFullYear();
const firstThursday = new Date(isoYear, 0, 4);
firstThursday.setHours(0, 0, 0, 0);
const firstThursdayDay = (firstThursday.getDay() + 6) % 7;
firstThursday.setDate(firstThursday.getDate() + 3 - firstThursdayDay);
const week =
1 +
Math.round(
(date.getTime() - firstThursday.getTime()) / (7 * 24 * 60 * 60 * 1000)
);
return `${isoYear}-W${String(week).padStart(2, '0')}`;
};
const weekReps = computed(() => {
const us = userStats();
if (us && us.weeklyKey === getCurrentIsoWeekKey()) return us.weeklyReps;

Copilot uses AI. Check for mistakes.
Comment on lines 198 to +202
const monthReps = computed(() => {
const us = userStats();
if (us) return us.monthlyReps;

// Fallback: client-side computation

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

monthReps returns userStats.monthlyReps unconditionally. If userStats.monthlyKey isn’t the current month (YYYY-MM), this will show stale data. Compare against the current month key and fall back to computing from entries when it doesn’t match.

Copilot uses AI. Check for mistakes.
Comment on lines +175 to +188
const parts = berlinParts(timestamp);
const keys = periodKeys(parts);
const slot = heatmapSlot(parts.weekday, parts.hour);

// ── Aggregates ──────────────────────────────────────────────────────
const total = Math.max(0, base.total + repsDelta);
const totalEntries = Math.max(0, base.totalEntries + entriesDelta);

const dailyReps =
base.dailyKey === keys.dailyKey
? Math.max(0, base.dailyReps + repsDelta)
: repsDelta > 0
? repsDelta
: 0;

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applyDelta derives dailyKey/weeklyKey/monthlyKey from the mutated entry’s timestamp and rewrites the stored keys/counters. Editing/deleting an old entry (or backdating an entry) can overwrite “current” period stats. Derive current period keys from nowIso (Berlin-local) and only apply repsDelta when the entry belongs to that current period; otherwise leave period counters unchanged.

Copilot uses AI. Check for mistakes.

// ── Best day ────────────────────────────────────────────────────────
let bestDay = base.bestDay ? { ...base.bestDay } : null;
if (dailyReps > 0 && keys.dailyKey === (base.dailyKey || keys.dailyKey)) {

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bestDay update is gated by keys.dailyKey === (base.dailyKey || keys.dailyKey), which blocks updating bestDay when processing the first entry of a new day (because base.dailyKey is still yesterday). Compute bestDay based on the updated day total for keys.dailyKey without referencing base.dailyKey.

Suggested change
if (dailyReps > 0 && keys.dailyKey === (base.dailyKey || keys.dailyKey)) {
if (dailyReps > 0) {

Copilot uses AI. Check for mistakes.
Comment on lines +241 to +242
// totalDays: only accurate via rebuild; delta keeps existing value
const totalDays = base.totalDays;

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

totalDays is never updated in delta mode (const totalDays = base.totalDays). This makes UserStats.totalDays stale/0 unless a rebuild is run, but the dashboard prefers userStats.totalDays. Either maintain it incrementally (requires per-day counts) or avoid reading it from UserStats until a rebuild/backfill guarantees correctness.

Suggested change
// totalDays: only accurate via rebuild; delta keeps existing value
const totalDays = base.totalDays;
// ── Total active days ───────────────────────────────────────────────
const previousDailyTotal = Number(
(base.heatmap as Record<string, number> | undefined)?.[keys.dailyKey] ?? 0
);
const totalDays =
(base.totalDays ?? 0) + (newReps > 0 && previousDailyTotal <= 0 ? 1 : 0);

Copilot uses AI. Check for mistakes.
Comment on lines +372 to +380
/**
* Return an empty UserStats object.
*/
export function emptyUserStats(userId: string): UserStats {
return {
userId,
total: 0,
totalEntries: 0,
totalDays: 0,

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

emptyUserStats is duplicated here and in @pu-stats/models (libs/stats/src/lib/models/user-stats.models.ts). Duplicating the initializer risks drift; prefer importing and reusing the shared helper from the models package so both sides stay in sync.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +41
const { getDoc } = jest.requireMock('@angular/fire/firestore');
const mockStats = {
userId: 'test-uid',
total: 500,
totalEntries: 25,
dailyReps: 30,
dailyKey: '2026-04-05',
};

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test’s mockStats doesn’t match the UserStats contract (many required fields missing), but UserStatsApiService casts Firestore data to UserStats. Use a complete UserStats mock (preferred), or explicitly type the mock as Partial<UserStats> and adjust the service to merge defaults/validate before returning.

Copilot uses AI. Check for mistakes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
web/src/app/stats/dashboard.store.ts (1)

176-200: ⚠️ Potential issue | 🟠 Major

Guard weekly/monthly totals with their period keys.

Line 178 and Line 200 return the stored counters unconditionally. If the calendar rolls over before the next write, the dashboard will keep showing the previous week/month until another entry arrives. Use the same key matches current period check that todayTotal() already applies for dailyKey, then fall through to the existing entries-based calculation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/app/stats/dashboard.store.ts` around lines 176 - 200, Guard using
stored weekly/monthly counters by verifying their period key matches the current
period before returning them: in the computed weekReps and monthReps, after
const us = userStats() check that us exists AND that us.weeklyKey (for weekReps)
/ us.monthlyKey (for monthReps) equals the current period key (compute the same
weekly/monthly key logic used by todayTotal() or derive it from the
monday/sunday or year-month values); only return us.weeklyReps or us.monthlyReps
when the key matches, otherwise fall through to the existing entries-based
fallback calculation.
🧹 Nitpick comments (3)
data-store/functions/src/types.d.ts (1)

13-16: Update sendNotification typing to match the web-push library API.

Current typing is stricter than the actual web-push API. The payload parameter is optional (not required), the function accepts an options argument, and the return type should be Promise<WebPushResponse> instead of Promise<unknown>.

Corrected typing
 declare module 'web-push' {
   interface PushSubscription {
     endpoint: string;
     keys: { p256dh: string; auth: string };
   }

+  interface SendNotificationOptions {
+    [key: string]: unknown;
+  }
+
+  interface WebPushResponse {
+    statusCode: number;
+    headers: Record<string, string>;
+    body: string;
+  }
+
   function setVapidDetails(
     subject: string,
     publicKey: string,
     privateKey: string
   ): void;

   function sendNotification(
     subscription: PushSubscription,
-    payload: string
+    payload?: string | Buffer,
+    options?: SendNotificationOptions
-  ): Promise<unknown>;
+  ): Promise<WebPushResponse>;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data-store/functions/src/types.d.ts` around lines 13 - 16, The
sendNotification declaration is too strict: update the signature of
sendNotification(subscription: PushSubscription, payload?: string, options?:
PushOptions): Promise<WebPushResponse>; so that payload is optional, an options
parameter is accepted, and the return type is Promise<WebPushResponse>; ensure
WebPushResponse (and PushOptions if needed) are imported or declared in the same
types.d.ts and update the function declaration in the file accordingly.
data-store/functions/src/index.ts (1)

1182-1194: Consider memory optimization for large-scale rebuild.

When rebuilding all users, this loads the entire pushups collection into memory to extract unique userIds. For a service with many users/entries, this could cause memory pressure.

A more scalable approach would be to query distinct userIds or iterate through userConfigs instead:

💡 Alternative approach using userConfigs
     // Rebuild for ALL users
-    const allPushups = await db.collection('pushups').get();
-    const userIds = new Set<string>();
-    for (const doc of allPushups.docs) {
-      const uid = doc.data().userId;
-      if (uid) userIds.add(uid);
-    }
+    // Query userConfigs instead of loading all pushups
+    const userConfigsSnap = await db.collection('userConfigs').get();
+    const userIds = userConfigsSnap.docs.map((doc) => doc.id);

     let totalRebuilt = 0;
-    for (const userId of userIds) {
+    for (const userId of userIds) {
       await rebuildForUser(userId);
       totalRebuilt++;
     }

Note: This assumes all users with pushups have a userConfigs document. If not, the current approach is safer but less scalable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data-store/functions/src/index.ts` around lines 1182 - 1194, The current loop
loads the entire pushups collection via db.collection('pushups').get() (variable
allPushups) which can OOM on large datasets; instead iterate user identifiers
without materializing all pushup docs e.g. query userConfigs (or stream pushups
in pages) to collect userIds and then call rebuildForUser(userId); update the
logic that builds userIds (replace the allPushups.docs iteration) to use a
paginated/streaming query or a direct userConfigs collection scan so only small
batches are kept in memory while still invoking rebuildForUser for each unique
userId.
data-store/functions/src/user-stats-delta.ts (1)

372-394: Consider deduplicating emptyUserStats across packages.

This function is duplicated identically in both:

  • libs/stats/src/lib/models/user-stats.models.ts
  • data-store/functions/src/user-stats-delta.ts

The function is accessible to data-store/functions via the monorepo's @pu-stats/models path mapping. To consolidate, first export emptyUserStats from libs/stats/src/index.ts, then import it from @pu-stats/models in the cloud-functions file. The implementation is consistent across both locations and already used 21 times throughout the codebase.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data-store/functions/src/user-stats-delta.ts` around lines 372 - 394, The
emptyUserStats implementation is duplicated; export the existing emptyUserStats
function (and its UserStats type) from the stats package public index (so it is
part of the package API) and remove the local copy in user-stats-delta; then
update the cloud-functions file to import emptyUserStats and UserStats from
`@pu-stats/models` (replace the local function with the imported symbol) so all
uses reference the single exported implementation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@data-store/firebase.json`:
- Around line 3-4: The functions.source value points outside the project
("../dist/cloud-functions"), which Firebase CLI rejects; update functions.source
in firebase.json to a path inside the data-store project (e.g.,
"dist/cloud-functions" or "functions") and ensure your build outputs the
compiled functions into that in-repo directory (move or reconfigure the build
target), leaving the runtime ("nodejs24") unchanged.

In `@libs/data-access/src/lib/api/user-stats-api.service.spec.ts`:
- Around line 33-45: The test currently uses
jest.requireMock('@angular/fire/firestore') to grab getDoc dynamically; replace
that with the statically imported getDoc from the top of the spec and use
jest.mocked(getDoc) to set up its mock. Locate the test "returns UserStats when
document exists" and change the getDoc assignment to use the imported symbol and
call jest.mocked(getDoc).mockResolvedValueOnce({...}) so the spec follows the
repo's static mock convention.

In `@web/src/app/stats/analysis.store.ts`:
- Around line 112-116: The resource currently calls
store._userStatsApi.getUserStats with store._user.userIdSafe() even when it
returns an empty string; update userStatsResource so params() and/or the loader
first check store._user.userIdSafe() and skip the query when it's falsy (e.g.,
return null/undefined or resolve to an empty value) instead of calling
store._userStatsApi.getUserStats(''); specifically guard in the loader that
calls firstValueFrom(store._userStatsApi.getUserStats(params.userId)) to only
invoke getUserStats when params.userId is non-empty, and ensure the loader
returns a safe no-op value when userId is empty.

In `@web/src/app/stats/dashboard.store.ts`:
- Around line 151-154: The computed currentStreak should expire stale
server-side streaks by checking the user's lastEntryDate before returning
userStats().currentStreak; update the currentStreak computed to get us =
userStats(), read us.lastEntryDate and if that date is older than yesterday
(i.e., not within the most recent day/consecutive window) return 0 instead of
us.currentStreak, otherwise return us.currentStreak; reference the currentStreak
computed, userStats(), and lastEntryDate when making the change.

---

Outside diff comments:
In `@web/src/app/stats/dashboard.store.ts`:
- Around line 176-200: Guard using stored weekly/monthly counters by verifying
their period key matches the current period before returning them: in the
computed weekReps and monthReps, after const us = userStats() check that us
exists AND that us.weeklyKey (for weekReps) / us.monthlyKey (for monthReps)
equals the current period key (compute the same weekly/monthly key logic used by
todayTotal() or derive it from the monday/sunday or year-month values); only
return us.weeklyReps or us.monthlyReps when the key matches, otherwise fall
through to the existing entries-based fallback calculation.

---

Nitpick comments:
In `@data-store/functions/src/index.ts`:
- Around line 1182-1194: The current loop loads the entire pushups collection
via db.collection('pushups').get() (variable allPushups) which can OOM on large
datasets; instead iterate user identifiers without materializing all pushup docs
e.g. query userConfigs (or stream pushups in pages) to collect userIds and then
call rebuildForUser(userId); update the logic that builds userIds (replace the
allPushups.docs iteration) to use a paginated/streaming query or a direct
userConfigs collection scan so only small batches are kept in memory while still
invoking rebuildForUser for each unique userId.

In `@data-store/functions/src/types.d.ts`:
- Around line 13-16: The sendNotification declaration is too strict: update the
signature of sendNotification(subscription: PushSubscription, payload?: string,
options?: PushOptions): Promise<WebPushResponse>; so that payload is optional,
an options parameter is accepted, and the return type is
Promise<WebPushResponse>; ensure WebPushResponse (and PushOptions if needed) are
imported or declared in the same types.d.ts and update the function declaration
in the file accordingly.

In `@data-store/functions/src/user-stats-delta.ts`:
- Around line 372-394: The emptyUserStats implementation is duplicated; export
the existing emptyUserStats function (and its UserStats type) from the stats
package public index (so it is part of the package API) and remove the local
copy in user-stats-delta; then update the cloud-functions file to import
emptyUserStats and UserStats from `@pu-stats/models` (replace the local function
with the imported symbol) so all uses reference the single exported
implementation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: a916db51-718c-4a21-9e50-54bd2813a251

📥 Commits

Reviewing files that changed from the base of the PR and between 6b87b94 and 12134d8.

📒 Files selected for processing (24)
  • data-store/firebase.json
  • data-store/firestore.rules
  • data-store/functions/jest.config.cts
  • data-store/functions/package.json
  • data-store/functions/project.json
  • data-store/functions/src/index.ts
  • data-store/functions/src/types.d.ts
  • data-store/functions/src/user-stats-delta.spec.ts
  • data-store/functions/src/user-stats-delta.ts
  • data-store/functions/tsconfig.app.json
  • data-store/functions/tsconfig.json
  • data-store/functions/tsconfig.spec.json
  • data-store/project.json
  • eslint.config.mjs
  • libs/data-access/src/index.ts
  • libs/data-access/src/lib/api/user-stats-api.service.spec.ts
  • libs/data-access/src/lib/api/user-stats-api.service.ts
  • libs/stats/src/lib/models/stats.models.ts
  • libs/stats/src/lib/models/user-stats.models.spec.ts
  • libs/stats/src/lib/models/user-stats.models.ts
  • web/src/app/stats/analysis.store.ts
  • web/src/app/stats/dashboard.store.ts
  • web/src/app/stats/shell/analysis-page.component.spec.ts
  • web/src/app/stats/shell/stats-dashboard.component.spec.ts

Comment thread data-store/firebase.json Outdated
Comment thread libs/data-access/src/lib/api/user-stats-api.service.spec.ts Outdated
Comment thread web/src/app/stats/analysis.store.ts
Comment thread web/src/app/stats/dashboard.store.ts
claude added 2 commits April 5, 2026 18:30
- Fix firebase.json: move functions-dist inside data-store/ (Firebase CLI
  rejects parent-relative paths)
- Fix bestDay bug: remove flawed dailyKey guard that blocked updates on new days
- Add period-key validation: weekReps/monthReps only use UserStats when keys
  match current week/month; streak checks lastEntryDate freshness
- Fix totalDays: maintain incrementally (increment on new day, decrement when
  day goes to zero reps)
- Handle timestamp-changing updates: split into undo-old + apply-new deltas
  so day/week/month/heatmap buckets stay correct
- Remove emptyUserStats duplication: re-export from @pu-stats/models
- Guard against empty userId in dashboard/analysis store loaders
- Fix test: use jest.mocked(getDoc) instead of jest.requireMock, complete
  UserStats mock shape

https://claude.ai/code/session_01WUgLPxZ66o25ij4BYjqC9H
…, and pitfalls

- Document cloud-functions Nx project (TS + esbuild + Jest)
- Add firebase.json deploy path constraint (must be inside project dir)
- Add delta-based aggregation pitfalls (timestamp changes, totalDays, bestDay)
- Add precomputed data staleness validation pattern (period key checks)
- Add @angular/fire/firestore jest.mock pitfall
- Update dependency graph, module boundaries, project names, domain models

https://claude.ai/code/session_01WUgLPxZ66o25ij4BYjqC9H
Copilot AI review requested due to automatic review settings April 5, 2026 18:33
Comment on lines +241 to +248
// totalDays: increment when a new day appears, decrement when a day goes to 0
let totalDays = base.totalDays;
const isNewDay = base.dailyKey !== keys.dailyKey;
if (isNewDay && dailyReps > 0) {
totalDays += 1;
} else if (!isNewDay && dailyReps === 0 && base.dailyReps > 0) {
totalDays = Math.max(0, totalDays - 1);
}

This comment was marked as outdated.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 26 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (2)

data-store/functions/src/index.ts:1126

  • In the timestampChanged branch, oldTimestamp/newTimestamp are typed as string | undefined, but applyDelta requires timestamp: string. TS won’t narrow based on the boolean timestampChanged, so this should be guarded or use non-null assertions after an explicit check (e.g. early-return if either is missing).
    data-store/functions/src/index.ts:1212
  • rebuildUserStats (all users) loads the entire pushups collection and then rebuilds users sequentially. This can exceed function time/memory limits as data grows. Consider paginating/streaming, deriving userIds from userConfigs, and/or processing users in bounded parallel batches (or via Cloud Tasks) to make backfills reliable.

Comment thread data-store/functions/tsconfig.app.json Outdated
"outDir": "../../dist/out-tsc",
"types": ["node"]
},
"include": ["src/**/*.ts"],

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tsconfig.app.json only includes src/**/*.ts, so local declaration files like src/types.d.ts are not part of the app build. If web-push has no installed typings, the functions build will fail. Include src/**/*.d.ts (or explicitly include src/types.d.ts) in the app tsconfig.

Suggested change
"include": ["src/**/*.ts"],
"include": ["src/**/*.ts", "src/**/*.d.ts"],

Copilot uses AI. Check for mistakes.
Comment on lines +209 to +216
if (entriesDelta >= 0) {
streakState = updateStreak(
base.currentStreak,
base.lastEntryDate,
parts.isoDate
);
} else {
// Delete — streak recalculation requires full scan (handled by backfill).

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applyDelta updates streaks on every create/update (entriesDelta >= 0) using updateStreak(base.lastEntryDate, parts.isoDate). If an older entry is edited (or a timestamp is moved backwards), daysBetween becomes negative and updateStreak resets the streak and lastEntryDate to an older date, corrupting streak state. Streak updates should only occur when entryDate >= lastEntryDate (or when processing in chronological order), otherwise keep streak unchanged or trigger a rebuild.

Suggested change
if (entriesDelta >= 0) {
streakState = updateStreak(
base.currentStreak,
base.lastEntryDate,
parts.isoDate
);
} else {
// Delete — streak recalculation requires full scan (handled by backfill).
const canAdvanceStreak =
entriesDelta >= 0 &&
(base.lastEntryDate === null || parts.isoDate >= base.lastEntryDate);
if (canAdvanceStreak) {
streakState = updateStreak(
base.currentStreak,
base.lastEntryDate,
parts.isoDate
);
} else {
// Delete or backdated/out-of-order write — incremental streak update
// is unsafe; keep current state. Full rebuild handled elsewhere.

Copilot uses AI. Check for mistakes.
Comment on lines +241 to +248
// totalDays: increment when a new day appears, decrement when a day goes to 0
let totalDays = base.totalDays;
const isNewDay = base.dailyKey !== keys.dailyKey;
if (isNewDay && dailyReps > 0) {
totalDays += 1;
} else if (!isNewDay && dailyReps === 0 && base.dailyReps > 0) {
totalDays = Math.max(0, totalDays - 1);
}

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

totalDays tracking here can become incorrect on updates/deletes for dates other than base.dailyKey (and when timestamps move), because the code only knows about the current dailyKey/dailyReps. Since totalDays is consumed by the dashboard (average/metadata), this will lead to wrong UI values. Consider maintaining per-day counts (e.g. a map of day→count) or falling back to rebuildFromEntries when a mutation affects a non-current day / a day potentially drops to zero.

Suggested change
// totalDays: increment when a new day appears, decrement when a day goes to 0
let totalDays = base.totalDays;
const isNewDay = base.dailyKey !== keys.dailyKey;
if (isNewDay && dailyReps > 0) {
totalDays += 1;
} else if (!isNewDay && dailyReps === 0 && base.dailyReps > 0) {
totalDays = Math.max(0, totalDays - 1);
}
// totalDays: derive from per-day totals to stay correct for updates/deletes
// on any day and for timestamp moves between days.
const totalDays = Object.values(heatmap).reduce(
(count, reps) => count + (reps > 0 ? 1 : 0),
0
);

Copilot uses AI. Check for mistakes.
Comment on lines 138 to +143
const todayTotal = computed(() => {
const us = userStats();
if (us && us.dailyKey === toLocalIsoDate(new Date())) {
return us.dailyReps;
}
// Fallback: compute from entries

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The staleness checks compare server-computed Berlin period keys (dailyKey, weeklyKey, monthlyKey) against keys computed in the browser’s local timezone (toLocalIsoDate(new Date()), currentIsoWeekKey(), currentMonthKey()). For users not in Europe/Berlin, these comparisons can fail around day/week/month boundaries and force the expensive entry-based fallbacks, undermining the goal of precomputed stats. Consider computing comparison keys in the same timezone as the backend (Europe/Berlin) or switching backend stats to user timezone.

Copilot uses AI. Check for mistakes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@data-store/functions/src/user-stats-delta.ts`:
- Around line 175-177: The current incremental update code (in applyDelta)
recalculates period keys and streaks using berlinParts/periodKeys/heatmapSlot
from the touched entry's timestamp, which allows historical edits/backfills to
rewind dailyKey/weeklyKey/monthlyKey, reset streak state, and overcount
totalDays; change applyDelta to detect non-latest-timestamp writes and, for
those cases, do not perform the incremental path but instead trigger a full
rebuild or require persisted per-day metadata to compute safe diffs; concretely,
add a check in applyDelta (and the branches that call
berlinParts/periodKeys/heatmapSlot and update totalDays/streak) to compare the
touched timestamp against the current snapshot window and either (A) enqueue a
rebuild job for user-stats or (B) use stored per-day summaries to compute
idempotent deltas, ensuring only append-latest writes use the existing
incremental logic.
- Around line 180-181: When computing the updated totals in user-stats-delta
(using base.total, repsDelta, base.totalEntries, entriesDelta -> total and
totalEntries), ensure that if totalEntries === 0 you return
emptyUserStats(userId) instead of carrying over previous streak/best-entry
fields; modify the code path in the function that builds the return object (and
the analogous block that updates stats around the other totals handling) to
short-circuit and return emptyUserStats(userId) when computed totalEntries is
zero so no ghost metadata remains.
- Around line 168-175: In applyDelta, do not seed missing existing state with
emptyUserStats(userId); instead detect when existing === null and either fail
fast or call rebuildFromEntries(userId) to obtain the real aggregate before
applying the delta; modify applyDelta to early-return or throw when existing is
null (for a fail-fast path) or synchronously invoke rebuildFromEntries to
produce a proper UserStats and then continue using that result (referencing
applyDelta, emptyUserStats, and rebuildFromEntries to locate the change).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 2e6aeae5-1754-4815-b7f7-a02b6ab29074

📥 Commits

Reviewing files that changed from the base of the PR and between 12134d8 and ab8682f.

📒 Files selected for processing (10)
  • CLAUDE.md
  • data-store/.gitignore
  • data-store/firebase.json
  • data-store/functions/project.json
  • data-store/functions/src/index.ts
  • data-store/functions/src/user-stats-delta.spec.ts
  • data-store/functions/src/user-stats-delta.ts
  • libs/data-access/src/lib/api/user-stats-api.service.spec.ts
  • web/src/app/stats/analysis.store.ts
  • web/src/app/stats/dashboard.store.ts
✅ Files skipped from review due to trivial changes (4)
  • data-store/.gitignore
  • libs/data-access/src/lib/api/user-stats-api.service.spec.ts
  • data-store/functions/project.json
  • data-store/functions/src/user-stats-delta.spec.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • data-store/firebase.json
  • web/src/app/stats/analysis.store.ts
  • web/src/app/stats/dashboard.store.ts
  • data-store/functions/src/index.ts

Comment thread data-store/functions/src/user-stats-delta.ts
Comment thread data-store/functions/src/user-stats-delta.ts
Comment thread data-store/functions/src/user-stats-delta.ts
@nx-cloud

nx-cloud Bot commented Apr 5, 2026 •

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit 3327625

Command Status Duration Result
nx run web:build -c production-preview ✅ Succeeded 39s View ↗

☁️ Nx Cloud last updated this comment at 2026-04-05 19:14:10 UTC

@github-actions

github-actions Bot commented Apr 5, 2026 •

Copy link
Copy Markdown

Visit the preview URL for this PR (updated for commit 3327625):

https://pushup-stats--pr184-claude-implement-iss-awbuhj1m.web.app

(expires Sun, 12 Apr 2026 19:07:51 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

Sign: 81c1346f18296c5c147c6260f1a724af4cbbd57e

Comment on lines +190 to +200
base.weeklyKey === keys.weeklyKey
? Math.max(0, base.weeklyReps + repsDelta)
: repsDelta > 0
? repsDelta
: 0;
const monthlyReps =
base.monthlyKey === keys.monthlyKey
? Math.max(0, base.monthlyReps + repsDelta)
: repsDelta > 0
? repsDelta
: 0;

This comment was marked as outdated.

…ghost stats (#182)

- Fix CRITICAL: deleting entry from past week/month no longer resets current
  period reps to 0 — keep stored values when entry's period differs
- Fix: editing backdated entries no longer corrupts streak — only advance
  streak for chronological writes (entryDate >= lastEntryDate)
- Fix: return emptyUserStats when totalEntries reaches 0 (no ghost metadata)
- Fix: preserve dailyKey/weeklyKey/monthlyKey when entry is from different period
- Fix: tsconfig.app.json now includes src/**/*.d.ts for web-push types
- Add 3 regression tests: past-period delete, zero-entries reset, backdated edit

https://claude.ai/code/session_01WUgLPxZ66o25ij4BYjqC9H
Copilot AI review requested due to automatic review settings April 5, 2026 18:57
Comment on lines 50 to +58
return Math.round((bd.getTime() - ad.getTime()) / 86_400_000);
}

function currentIsoWeekKey(): string {
const d = new Date();
d.setHours(0, 0, 0, 0);
const day = (d.getDay() + 6) % 7;
d.setDate(d.getDate() + 3 - day);
const isoYear = d.getFullYear();

This comment was marked as outdated.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 26 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

data-store/functions/src/index.ts:1125

  • In the timestampChanged branch, oldTimestamp / newTimestamp are typed as string | undefined and timestampChanged (a boolean) does not narrow them for TypeScript. Passing them into applyDelta (expects string) should be a TS error. Add an explicit guard inside the branch (or ! assertions) so the compiler can prove they’re defined.
    data-store/functions/src/index.ts:1212
  • rebuildUserStats “ALL users” path scans the entire pushups collection and then rebuilds each user sequentially (1 query per user + write). This is likely to hit the 540s timeout / Firestore rate limits as the dataset grows. Consider batching with bounded concurrency, and/or deriving userIds from a smaller collection (e.g. userConfigs) plus pagination.

Comment on lines +258 to +262
// totalDays: increment when a new day appears, decrement when current day goes to 0
let totalDays = base.totalDays;
if (sameDay && dailyReps === 0 && base.dailyReps > 0) {
totalDays = Math.max(0, totalDays - 1);
} else if (!sameDay && repsDelta > 0) {

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

totalDays update is not correct for unique-day counting: else if (!sameDay && repsDelta > 0) totalDays += 1 will overcount on backdated creates/updates (and on timestamp-changed updates) even if that date already has entries. Since the dashboard now prefers userStats.totalDays, this will skew averages. Consider only incrementing on chronological day rollovers (e.g. when the stored dailyKey advances), or maintain per-day counts (map) and/or trigger rebuildFromEntries() for backdated/timestamp-changed writes.

Suggested change
// totalDays: increment when a new day appears, decrement when current day goes to 0
let totalDays = base.totalDays;
if (sameDay && dailyReps === 0 && base.dailyReps > 0) {
totalDays = Math.max(0, totalDays - 1);
} else if (!sameDay && repsDelta > 0) {
// totalDays: decrement when current day goes to 0; increment only on chronological day rollover
let totalDays = base.totalDays;
const dayAdvancedChronologically =
!sameDay && repsDelta > 0 && (!base.dailyKey || dailyKey > base.dailyKey);
if (sameDay && dailyReps === 0 && base.dailyReps > 0) {
totalDays = Math.max(0, totalDays - 1);
} else if (dayAdvancedChronologically) {

Copilot uses AI. Check for mistakes.
Comment thread web/src/app/stats/dashboard.store.ts
- AnalysisTeaserCardComponent: add refreshTrigger input that forces
  statsResource reload when incremented
- StatsDashboardComponent: track refreshCounter signal, increment on
  createEntry, addQuickEntry, and LiveDataStore tick
- AnalysisStore: add refreshAll() method to reload all resources
- AnalysisPageComponent: add LiveDataStore.updateTick() effect to
  auto-refresh chart/stats when new entries arrive via websocket

https://claude.ai/code/session_01WUgLPxZ66o25ij4BYjqC9H
Comment on lines +194 to +201
const dailyReps = sameDay
? Math.max(0, base.dailyReps + repsDelta)
: repsDelta > 0
? repsDelta // new period starts with this entry's reps
: base.dailyReps; // different period delete/update → keep current
const dailyKey = sameDay || repsDelta > 0 ? keys.dailyKey : base.dailyKey;

const weeklyReps = sameWeek

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The applyDelta function incorrectly resets dailyReps when a user edits an old entry, causing today's rep count to be lost and replaced by the rep delta.
Severity: HIGH

Suggested Fix

Modify the logic in applyDelta to differentiate between creating a new entry and updating an old one. The condition should also check entriesDelta. For example, only reset dailyReps when !sameDay && repsDelta > 0 && entriesDelta > 0. When updating an old entry (entriesDelta === 0), the dailyReps and dailyKey for the current day should not be modified.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: data-store/functions/src/user-stats-delta.ts#L194-L201

Potential issue: When a user edits a push-up entry from a previous day to increase the
rep count, the `applyDelta` function incorrectly handles the update. The logic at lines
194-201 checks if `!sameDay && repsDelta > 0` and, if true, sets `dailyReps =
repsDelta`. This is wrong for an update, where `repsDelta` is just the change in reps,
not the total. As a result, the user's statistics for the current day are overwritten
with the small delta value, and the `dailyKey` is incorrectly changed to the old entry's
date, effectively losing all of today's progress.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
web/src/app/stats/shell/stats-dashboard.component.ts (1)

121-123: Extract a helper for refreshAll + counter bump to prevent drift.

The same two-line refresh sequence appears in three places. A small helper keeps behavior consistent when refresh logic evolves.

♻️ Proposed refactor
 export class StatsDashboardComponent {
@@
   readonly refreshCounter = signal(0);
+
+  private triggerRefresh(): void {
+    this.store.refreshAll();
+    this.refreshCounter.update((c) => c + 1);
+  }
@@
     effect(() => {
       if (!isPlatformBrowser(this.platformId)) return;
       const tick = this.live.updateTick();
       if (!tick) return;
-      this.store.refreshAll();
-      this.refreshCounter.update((c) => c + 1);
+      this.triggerRefresh();
     });
@@
   async createEntry(entry: {
@@
     await firstValueFrom(this.api.createPushup(entry));
-    this.store.refreshAll();
-    this.refreshCounter.update((c) => c + 1);
+    this.triggerRefresh();
   }
@@
   async addQuickEntry(reps: number) {
@@
     await firstValueFrom(
       this.api.createPushup({
@@
       })
     );
-    this.store.refreshAll();
-    this.refreshCounter.update((c) => c + 1);
+    this.triggerRefresh();
   }
 }

Also applies to: 162-164, 182-184

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/app/stats/shell/stats-dashboard.component.ts` around lines 121 - 123,
The repeated two-line sequence calling this.store.refreshAll(); followed by
this.refreshCounter.update((c) => c + 1); should be extracted into a single
helper method (e.g., add a private method refreshAllAndBump() on the
StatsDashboardComponent) and replace each occurrence with a call to that helper
to avoid behavioral drift; implement refreshAllAndBump() to call
this.store.refreshAll() then this.refreshCounter.update((c) => c + 1), and
update the three call sites that currently invoke store.refreshAll() +
refreshCounter.update() to call refreshAllAndBump() instead.
data-store/functions/src/user-stats-delta.ts (1)

240-264: Consider documenting the bestDay and totalDays limitations inline.

The delta logic for bestDay (lines 242-245) uses dailyReps, which only reflects the delta entry's reps when !sameDay. Similarly, totalDays (line 263) increments unconditionally for any new day, even if that day already has entries from a non-chronological backfill.

Both are acceptable given the rebuild fallback, but a brief inline comment would help future maintainers understand this is intentional rather than a bug.

📝 Suggested documentation
   // ── Best day ────────────────────────────────────────────────────────
+  // Note: For non-chronological writes to a different day, dailyReps only
+  // reflects this delta, not the true day total. Rebuild corrects this.
   let bestDay = base.bestDay ? { ...base.bestDay } : null;
   if (dailyReps > 0) {
-  // totalDays: increment when a new day appears, decrement when current day goes to 0
+  // totalDays: increment when a new day appears, decrement when current day goes to 0.
+  // Non-chronological writes may over/under-count; rebuild corrects this.
   let totalDays = base.totalDays;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data-store/functions/src/user-stats-delta.ts` around lines 240 - 264, Add
brief inline comments near the bestDay and totalDays update blocks explaining
the known limitations: state that bestDay uses the local dailyReps (which will
reflect only the delta when !sameDay) so a backfilled/non-chronological entry
may not correctly update historical bestDay until a full rebuild, and that
totalDays increments for any new day when repsDelta>0 (potentially
double-counting during non-chronological backfills) but is acceptable because
the rebuild fallback will correct totals; reference the existing symbols
bestDay, base.bestDay, dailyReps, sameDay, repsDelta, and base.totalDays so
maintainers can easily find and understand the intentional behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@data-store/functions/src/user-stats-delta.ts`:
- Around line 240-264: Add brief inline comments near the bestDay and totalDays
update blocks explaining the known limitations: state that bestDay uses the
local dailyReps (which will reflect only the delta when !sameDay) so a
backfilled/non-chronological entry may not correctly update historical bestDay
until a full rebuild, and that totalDays increments for any new day when
repsDelta>0 (potentially double-counting during non-chronological backfills) but
is acceptable because the rebuild fallback will correct totals; reference the
existing symbols bestDay, base.bestDay, dailyReps, sameDay, repsDelta, and
base.totalDays so maintainers can easily find and understand the intentional
behavior.

In `@web/src/app/stats/shell/stats-dashboard.component.ts`:
- Around line 121-123: The repeated two-line sequence calling
this.store.refreshAll(); followed by this.refreshCounter.update((c) => c + 1);
should be extracted into a single helper method (e.g., add a private method
refreshAllAndBump() on the StatsDashboardComponent) and replace each occurrence
with a call to that helper to avoid behavioral drift; implement
refreshAllAndBump() to call this.store.refreshAll() then
this.refreshCounter.update((c) => c + 1), and update the three call sites that
currently invoke store.refreshAll() + refreshCounter.update() to call
refreshAllAndBump() instead.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: c93bf2d9-c6ab-428b-84ad-90c47ad75f48

📥 Commits

Reviewing files that changed from the base of the PR and between ab8682f and 3327625.

📒 Files selected for processing (10)
  • CLAUDE.md
  • data-store/functions/src/index.ts
  • data-store/functions/src/user-stats-delta.spec.ts
  • data-store/functions/src/user-stats-delta.ts
  • data-store/functions/tsconfig.app.json
  • web/src/app/stats/analysis.store.ts
  • web/src/app/stats/components/analysis-teaser-card/analysis-teaser-card.component.ts
  • web/src/app/stats/shell/analysis-page.component.ts
  • web/src/app/stats/shell/stats-dashboard.component.html
  • web/src/app/stats/shell/stats-dashboard.component.ts
✅ Files skipped from review due to trivial changes (3)
  • data-store/functions/tsconfig.app.json
  • CLAUDE.md
  • data-store/functions/src/user-stats-delta.spec.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • web/src/app/stats/analysis.store.ts
  • data-store/functions/src/index.ts

@WolfSoko
WolfSoko merged commit 84b473f into main Apr 5, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants