Add animated dashboard counters (closes #88)#93
Conversation
🤖 CodeAnt AI — Review Status
Updated in place by CodeAnt AI · last 5 reviews |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds reusable locale-aware number formatting, animated counters with loading and reduced-motion handling, statistic cards, dashboard KPI integration, accessibility behavior, Vitest/jsdom tooling, counter styling, and frontend support updates. ChangesAnimated counter feature
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Dashboard
participant StatisticCard
participant AnimatedCounter
participant useAnimatedCounter
Dashboard->>StatisticCard: provide numeric KPI value
StatisticCard->>AnimatedCounter: pass value and formatting options
AnimatedCounter->>useAnimatedCounter: request animated display value
useAnimatedCounter-->>AnimatedCounter: return interpolated value and animation state
AnimatedCounter-->>StatisticCard: render formatted accessible counter
Possibly related PRs
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
| useEffect(() => { | ||
| const mql = window.matchMedia('(prefers-reduced-motion: reduce)'); | ||
| const listener = (e: MediaQueryListEvent) => setReduced(e.matches); | ||
| mql.addEventListener('change', listener); | ||
| return () => mql.removeEventListener('change', listener); | ||
| }, []); |
There was a problem hiding this comment.
Suggestion: This effect assumes window.matchMedia exists and always supports addEventListener, which is not true in some runtimes (older browsers and certain test environments). Calling it unguarded will throw at runtime and crash the component; add capability checks and a fallback to legacy listener APIs before attaching listeners. [api mismatch]
Severity Level: Critical 🚨
- ❌ Dashboard KPI counters can crash in unsupported browsers.
- ⚠️ Vitest/jsdom runs may fail without matchMedia polyfill.Steps of Reproduction ✅
1. When the dashboard renders, `AnimatedCounter` (at
`frontend/src/components/AnimatedCounter.tsx:27-38`) calls `useAnimatedCounter`, which in
turn calls `usePrefersReducedMotion` from
`frontend/src/hooks/useAnimatedCounter.ts:22-35`.
2. On mount, `usePrefersReducedMotion` first evaluates the state initializer at
`frontend/src/hooks/useAnimatedCounter.ts:23-25`, which calls
`window.matchMedia('(prefers-reduced-motion: reduce)').matches` whenever `typeof window
!== 'undefined'`. In a runtime where `window` exists but `window.matchMedia` is undefined
or not a function (older browsers, some test environments), this throws immediately and
breaks rendering.
3. The subsequent `useEffect` at `frontend/src/hooks/useAnimatedCounter.ts:27-32`
unconditionally does `const mql = window.matchMedia(...)` and then
`mql.addEventListener('change', listener)`. In environments where `matchMedia` exists but
only exposes the legacy `addListener`/`removeListener` API, `mql.addEventListener` is
undefined, so calling it raises a `TypeError` and crashes the component.
4. Because `AnimatedCounter` is used directly in the main dashboard KPI grid
(`frontend/src/pages/Dashboard.tsx:157-159`) without any feature detection around
reduced-motion or `matchMedia`, any runtime lacking full `window.matchMedia` and
`MediaQueryList.addEventListener` support will fail during the initial render of the
dashboard, preventing KPI counters from displaying.(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖
This is a comment left during a code review.
**Path:** frontend/src/hooks/useAnimatedCounter.ts
**Line:** 27:32
**Comment:**
*Api Mismatch: This effect assumes `window.matchMedia` exists and always supports `addEventListener`, which is not true in some runtimes (older browsers and certain test environments). Calling it unguarded will throw at runtime and crash the component; add capability checks and a fallback to legacy listener APIs before attaching listeners.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix| const str = value.toString(); | ||
| const dot = str.indexOf('.'); | ||
| if (dot === -1) return 0; |
There was a problem hiding this comment.
Suggestion: inferDecimals assumes decimal numbers always contain a . in toString(), but JavaScript renders very small/large floats in exponential form (for example 1e-7), so this path returns 0 decimals and formats non-zero values as 0. Handle exponential notation explicitly (or derive precision without string-dot parsing) so scientific-notation inputs are not silently rounded away. [logic error]
Severity Level: Major ⚠️
❌ Dashboard KPIs misrender extremely small non-zero metric values.
⚠️ Screen readers announce zero instead of tiny non-zero values.Steps of Reproduction ✅
1. Load the dashboard page implemented in `frontend/src/pages/Dashboard.tsx` (function
`Dashboard` starting at line 14) so it fetches summary data via `useSpaceSummary()` and
constructs KPI values at lines 23–75.
2. Configure or mock the backend summary response so `space_weather_index` (used as
`kpis[4].value` at lines 59–60 in `Dashboard.tsx`) is a very small non-zero number such as
`1e-7` (scientific notation in JavaScript).
3. Observe that when `summary.isLoading` and `summary.isError` are false, the KPI grid
renders `<AnimatedCounter value={kpi.value} />` at
`frontend/src/pages/Dashboard.tsx:118–120` for finite numeric `kpi.value`, passing the
tiny `1e-7` value into `AnimatedCounter` at
`frontend/src/components/AnimatedCounter.tsx:27–34`.
4. Inside `AnimatedCounter`, `displayText` is computed by `formatNumber(displayValue,
formatOptions)` at `AnimatedCounter.tsx:72`, which calls `inferDecimals(value)` in
`frontend/src/utils/numberFormatter.ts:28–33`; for `1e-7`, `value.toString()` yields an
exponential-form string with no `.` so `inferDecimals` returns `0`, causing
`Intl.NumberFormat` at `numberFormatter.ts:53–58` to round the non-zero value to `"0"` and
the dashboard KPI to display `0` instead of the intended tiny number.(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖
This is a comment left during a code review.
**Path:** frontend/src/utils/numberFormatter.ts
**Line:** 30:32
**Comment:**
*Logic Error: `inferDecimals` assumes decimal numbers always contain a `.` in `toString()`, but JavaScript renders very small/large floats in exponential form (for example `1e-7`), so this path returns `0` decimals and formats non-zero values as `0`. Handle exponential notation explicitly (or derive precision without string-dot parsing) so scientific-notation inputs are not silently rounded away.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix| const formatted = new Intl.NumberFormat(locale, { | ||
| notation: compact ? 'compact' : 'standard', | ||
| minimumFractionDigits: compact ? 0 : decimals, | ||
| maximumFractionDigits: decimals, | ||
| useGrouping, | ||
| }).format(value); |
There was a problem hiding this comment.
Suggestion: This creates a new Intl.NumberFormat instance on every call, and AnimatedCounter calls this repeatedly during rAF updates, causing avoidable allocation and GC churn in a hot render path. Cache/memoize formatters by (locale, compact, useGrouping, decimals) so frame-by-frame formatting stays lightweight. [performance]
Severity Level: Major ⚠️
⚠️ Extra allocations during KPI animations wasting CPU cycles.
⚠️ Potential frame drops on low-end devices or browsers.Steps of Reproduction ✅
1. Open the dashboard implemented in `frontend/src/pages/Dashboard.tsx` and allow
`useSpaceSummary()` to resolve so the `kpis` array is built (lines 23–75) with numeric
`value` fields for tracked satellites, debris objects, alerts, collisions, space weather
index, and agent load.
2. When `summary.isLoading` and `summary.isError` are false, the KPI grid at
`Dashboard.tsx:96–131` renders `<AnimatedCounter value={kpi.value} />` at lines 118–120
for each KPI, causing six `AnimatedCounter` instances in
`frontend/src/components/AnimatedCounter.tsx` to mount.
3. Each `AnimatedCounter` calls `useAnimatedCounter(value, { duration, disableAnimation:
loading })` at `AnimatedCounter.tsx:35–38`; inside `useAnimatedCounter`
(`frontend/src/hooks/useAnimatedCounter.ts:47–139`), the `tick` function uses
`requestAnimationFrame` and `setAnimatedValue` (lines 105–114) to update `animatedValue`
on every frame for the full clamped duration (800–1500ms), triggering a React re-render
for each frame.
4. On every render while animating, `AnimatedCounter` computes `displayText =
formatNumber(displayValue, formatOptions)` at `AnimatedCounter.tsx:72`, which invokes
`formatNumber` in `frontend/src/utils/numberFormatter.ts:43–61`; this function constructs
a fresh `Intl.NumberFormat` instance on line 53 for each call and frame, so a single
dashboard load generates hundreds of formatter allocations across all counters, increasing
allocation and garbage-collection pressure in the hot animation path.(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖
This is a comment left during a code review.
**Path:** frontend/src/utils/numberFormatter.ts
**Line:** 53:58
**Comment:**
*Performance: This creates a new `Intl.NumberFormat` instance on every call, and `AnimatedCounter` calls this repeatedly during rAF updates, causing avoidable allocation and GC churn in a hot render path. Cache/memoize formatters by `(locale, compact, useGrouping, decimals)` so frame-by-frame formatting stays lightweight.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fixThere was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
frontend/src/pages/Dashboard.tsx (1)
66-113: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winHandle non-finite KPI values If a numeric KPI ever comes back non-finite, this falls through to the raw value instead of a placeholder.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/pages/Dashboard.tsx` around lines 66 - 113, Update the kpis definition to detect non-finite numeric values for every numeric KPI and use the placeholder instead of exposing NaN or Infinity. Preserve valid numeric values and the existing zero-value fallbacks for Debris Objects and Agent Runs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/AnimatedCounter.tsx`:
- Around line 43-51: Update the React.useEffect handling highlightOnIncrease in
AnimatedCounter so that when the increase condition is false, it explicitly
clears the highlight state while preserving the existing timer cleanup. Ensure
any non-increase update cancels the pending timer and leaves highlight set to
false, preventing it from remaining active indefinitely.
- Around line 89-104: Update the visually hidden live region in AnimatedCounter
so aria-live remains "polite" continuously, and defer updating its displayed
text until isAnimating becomes false. Use the existing finalText value and an
effect or equivalent settled-state mechanism to ensure each completed animation
produces a content mutation and announces only the final value.
- Around line 72-73: Update AnimatedCounter’s display formatting to derive the
decimal precision once from the settled target value, using the exported
inferDecimals helper from numberFormatter.ts, and pass that precision through
formatOptions when formatting displayValue. Leave finalText’s screen-reader
formatting unchanged.
In `@frontend/src/hooks/useAnimatedCounter.ts`:
- Around line 78-133: Update the animation bookkeeping in the useEffect flow and
tick callback to continuously track the actual displayed value in a ref whenever
setAnimatedValue is called. Use that ref for the unchanged-target check and as
the interpolation start value, while preserving first-render behavior and
syncing it when animations are skipped or completed so interrupted animations
continue from their current value and snap correctly when the target reverts.
In `@frontend/src/pages/Dashboard.tsx`:
- Around line 157-159: Update the KPI display expression in Dashboard’s KPI
rendering block so any non-finite or undefined kpi.value without a defined
fallback renders the established placeholder instead of falling through to the
raw value. Preserve fallback usage when provided and AnimatedCounter for finite
numeric values.
---
Outside diff comments:
In `@frontend/src/pages/Dashboard.tsx`:
- Around line 66-113: Update the kpis definition to detect non-finite numeric
values for every numeric KPI and use the placeholder instead of exposing NaN or
Infinity. Preserve valid numeric values and the existing zero-value fallbacks
for Debris Objects and Agent Runs.
🪄 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: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 37283cde-3a5c-4743-ae66-01f24a36df05
⛔ Files ignored due to path filters (1)
frontend/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (10)
frontend/package.jsonfrontend/src/components/AnimatedCounter.tsxfrontend/src/components/StatisticCard.tsxfrontend/src/components/animated-counter.cssfrontend/src/hooks/useAnimatedCounter.tsfrontend/src/main.tsxfrontend/src/pages/Dashboard.tsxfrontend/src/pages/LandingPage.tsxfrontend/src/styles/index.cssfrontend/src/utils/numberFormatter.ts
💤 Files with no reviewable changes (1)
- frontend/src/pages/LandingPage.tsx
| useEffect(() => { | ||
| if (shouldSkipAnimation) { | ||
| // Keep the refs in sync so a later switch back to animated mode | ||
| // starts from the right value instead of replaying from 0. | ||
| fromRef.current = target; | ||
| isFirstRun.current = false; | ||
| if (rafRef.current !== null) { | ||
| cancelAnimationFrame(rafRef.current); | ||
| rafRef.current = null; | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| // Nothing changed — spec explicitly says don't re-animate. | ||
| if (!isFirstRun.current && fromRef.current === target) { | ||
| return; | ||
| } | ||
|
|
||
| const from = isFirstRun.current ? 0 : fromRef.current; | ||
| const startTime = performance.now(); | ||
|
|
||
| if (rafRef.current !== null) { | ||
| cancelAnimationFrame(rafRef.current); | ||
| } | ||
|
|
||
| let hasStarted = false; | ||
|
|
||
| const tick = (now: number) => { | ||
| if (!hasStarted) { | ||
| hasStarted = true; | ||
| setIsAnimating(true); | ||
| } | ||
| const elapsed = now - startTime; | ||
| const progress = Math.min(elapsed / clampedDuration, 1); | ||
| const eased = easeOutExpo(progress); | ||
| setAnimatedValue(from + (target - from) * eased); | ||
|
|
||
| if (progress < 1) { | ||
| rafRef.current = requestAnimationFrame(tick); | ||
| } else { | ||
| setAnimatedValue(target); | ||
| fromRef.current = target; | ||
| isFirstRun.current = false; | ||
| setIsAnimating(false); | ||
| rafRef.current = null; | ||
| } | ||
| }; | ||
|
|
||
| rafRef.current = requestAnimationFrame(tick); | ||
|
|
||
| return () => { | ||
| if (rafRef.current !== null) { | ||
| cancelAnimationFrame(rafRef.current); | ||
| } | ||
| }; | ||
| }, [target, clampedDuration, shouldSkipAnimation]); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Interrupted animations can jump-restart or freeze mid-value.
fromRef/isFirstRun are only updated when an animation completes (L119-120) or is skipped (L82-83) — never when a new target interrupts one in flight. Two concrete breaks:
- If the very first animation is interrupted before finishing, the next run still treats it as first-run (
from = 0, L96), so the counter visibly jumps back to 0 and restarts instead of continuing from where it was. - If
targetreverts to exactlyfromRef.currentwhile mid-animation, the skip check (L92-94) short-circuits and the display stays frozen at whatever intermediate value it was interrupted at, never snapping to the new target.
Track the actual last-displayed value continuously (e.g. update a ref on every setAnimatedValue call) and use that as the interpolation start point / skip comparison, instead of only updating bookkeeping on natural completion.
♻️ Sketch of a fix
+ const currentValueRef = useRef(shouldSkipAnimation ? target : 0);
+
useEffect(() => {
if (shouldSkipAnimation) {
fromRef.current = target;
+ currentValueRef.current = target;
isFirstRun.current = false;
...
return;
}
- if (!isFirstRun.current && fromRef.current === target) {
+ if (!isFirstRun.current && currentValueRef.current === target) {
return;
}
- const from = isFirstRun.current ? 0 : fromRef.current;
+ const from = isFirstRun.current ? 0 : currentValueRef.current;
...
const tick = (now) => {
...
- setAnimatedValue(from + (target - from) * eased);
+ const next = from + (target - from) * eased;
+ currentValueRef.current = next;
+ setAnimatedValue(next);
if (progress < 1) {
...
} else {
setAnimatedValue(target);
+ currentValueRef.current = target;
fromRef.current = target;
isFirstRun.current = false;
...
}
};🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/hooks/useAnimatedCounter.ts` around lines 78 - 133, Update the
animation bookkeeping in the useEffect flow and tick callback to continuously
track the actual displayed value in a ref whenever setAnimatedValue is called.
Use that ref for the unchanged-target check and as the interpolation start
value, while preserving first-render behavior and syncing it when animations are
skipped or completed so interrupted animations continue from their current value
and snap correctly when the target reverts.
There was a problem hiding this comment.
♻️ Duplicate comments (1)
frontend/src/components/AnimatedCounter.tsx (1)
103-105: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winDo not announce the new target before its animation settles.
After a settled counter receives a new
value,isAnimatingis stillfalsefor that render because the hook sets it only in the next RAF callback. Line 105 therefore updates this established polite region withfinalTextbefore the visual animation begins.Expose a synchronously pending/settled value from
useAnimatedCounter(or synchronously mark a changed target as animating), and update the live-region text only on animation completion.Suggested hook-side direction
- const [isAnimating, setIsAnimating] = useState(false); + const [isAnimating, setIsAnimating] = useState(!shouldSkipAnimation); + const [animationTarget, setAnimationTarget] = useState(target); + + if (!shouldSkipAnimation && animationTarget !== target) { + setAnimationTarget(target); + if (!isAnimating) setIsAnimating(true); + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/AnimatedCounter.tsx` around lines 103 - 105, Update useAnimatedCounter so a changed value is synchronously treated as pending/animating before the next render, preventing finalText from entering the aria-live region immediately; expose the pending/settled state or synchronously mark the target as animating, then update the AnimatedCounter live-region text only once the animation completes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In `@frontend/src/components/AnimatedCounter.tsx`:
- Around line 103-105: Update useAnimatedCounter so a changed value is
synchronously treated as pending/animating before the next render, preventing
finalText from entering the aria-live region immediately; expose the
pending/settled state or synchronously mark the target as animating, then update
the AnimatedCounter live-region text only once the animation completes.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: ad8bd218-b594-4d58-8d4d-b78b76e9c48a
⛔ Files ignored due to path filters (48)
backend/__pycache__/debug_log.cpython-313.pycis excluded by!**/*.pycbackend/ai/__pycache__/__init__.cpython-313.pycis excluded by!**/*.pycbackend/ai/__pycache__/openai_service.cpython-313.pycis excluded by!**/*.pycbackend/ai/__pycache__/workflow.cpython-313.pycis excluded by!**/*.pycbackend/api/__pycache__/__init__.cpython-313.pycis excluded by!**/*.pycbackend/api/__pycache__/router.cpython-313.pycis excluded by!**/*.pycbackend/api/v1/__pycache__/__init__.cpython-313.pycis excluded by!**/*.pycbackend/api/v1/endpoints/__pycache__/__init__.cpython-313.pycis excluded by!**/*.pycbackend/api/v1/endpoints/__pycache__/agents.cpython-313.pycis excluded by!**/*.pycbackend/api/v1/endpoints/__pycache__/auth.cpython-313.pycis excluded by!**/*.pycbackend/api/v1/endpoints/__pycache__/catalog.cpython-313.pycis excluded by!**/*.pycbackend/api/v1/endpoints/__pycache__/collisions.cpython-313.pycis excluded by!**/*.pycbackend/api/v1/endpoints/__pycache__/dashboard.cpython-313.pycis excluded by!**/*.pycbackend/api/v1/endpoints/__pycache__/satellites.cpython-313.pycis excluded by!**/*.pycbackend/api/v1/endpoints/__pycache__/weather.cpython-313.pycis excluded by!**/*.pycbackend/app/__pycache__/__init__.cpython-313.pycis excluded by!**/*.pycbackend/app/__pycache__/main.cpython-313.pycis excluded by!**/*.pycbackend/app/core/__pycache__/__init__.cpython-313.pycis excluded by!**/*.pycbackend/app/core/__pycache__/config.cpython-313.pycis excluded by!**/*.pycbackend/app/core/__pycache__/error_handlers.cpython-313.pycis excluded by!**/*.pycbackend/app/core/__pycache__/exceptions.cpython-313.pycis excluded by!**/*.pycbackend/app/core/__pycache__/scheduler.cpython-313.pycis excluded by!**/*.pycbackend/app/core/__pycache__/security.cpython-313.pycis excluded by!**/*.pycbackend/database/__pycache__/__init__.cpython-313.pycis excluded by!**/*.pycbackend/database/__pycache__/session.cpython-313.pycis excluded by!**/*.pycbackend/models/__pycache__/__init__.cpython-313.pycis excluded by!**/*.pycbackend/models/__pycache__/db_models.cpython-313.pycis excluded by!**/*.pycbackend/orbital/__pycache__/__init__.cpython-313.pycis excluded by!**/*.pycbackend/orbital/__pycache__/collision_engine.cpython-313.pycis excluded by!**/*.pycbackend/orbital/__pycache__/orbit_engine.cpython-313.pycis excluded by!**/*.pycbackend/orbital/__pycache__/spacetrack.cpython-313.pycis excluded by!**/*.pycbackend/orbital/providers/__pycache__/__init__.cpython-313.pycis excluded by!**/*.pycbackend/orbital/providers/__pycache__/base.cpython-313.pycis excluded by!**/*.pycbackend/orbital/providers/__pycache__/cache.cpython-313.pycis excluded by!**/*.pycbackend/orbital/providers/__pycache__/celestrak.cpython-313.pycis excluded by!**/*.pycbackend/orbital/providers/__pycache__/chain.cpython-313.pycis excluded by!**/*.pycbackend/orbital/providers/__pycache__/spacetrack.cpython-313.pycis excluded by!**/*.pycbackend/package-lock.jsonis excluded by!**/package-lock.jsonbackend/schemas/__pycache__/__init__.cpython-313.pycis excluded by!**/*.pycbackend/schemas/__pycache__/api_schemas.cpython-313.pycis excluded by!**/*.pycbackend/services/__pycache__/__init__.cpython-313.pycis excluded by!**/*.pycbackend/services/__pycache__/risk_service.cpython-313.pycis excluded by!**/*.pycbackend/services/__pycache__/simulation_service.cpython-313.pycis excluded by!**/*.pycbackend/services/__pycache__/weather_service.cpython-313.pycis excluded by!**/*.pycbackend/websocket/__pycache__/__init__.cpython-313.pycis excluded by!**/*.pycbackend/websocket/__pycache__/manager.cpython-313.pycis excluded by!**/*.pycfrontend/package-lock.jsonis excluded by!**/package-lock.jsonpackage-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (9)
frontend/package.jsonfrontend/src/components/AnimatedCounter.tsxfrontend/src/hooks/__tests__/useAnimatedCounter.test.tsfrontend/src/hooks/useAnimatedCounter.tsfrontend/src/pages/Dashboard.tsxfrontend/src/utils/__tests__/numberFormatter.test.tsfrontend/src/utils/numberFormatter.tsfrontend/vitest.config.tspackage.json
🚧 Files skipped from review as they are similar to previous changes (2)
- frontend/src/hooks/useAnimatedCounter.ts
- frontend/src/pages/Dashboard.tsx
User description
Description
Adds a reusable
AnimatedCountercomponent for Kepler's dashboard KPI stats(Tracked Satellites, Debris Objects, Active Alerts, Pred. Collisions, Space
Weather, Agent Runs).
useAnimatedCounter— animation logic only (rAF + easing). Animates from 0on first load, from the previous value on updates, skips re-animating on an
unchanged value, respects
prefers-reduced-motion, duration clamped to800–1500ms.
numberFormatter— pure number formatting (grouping, decimals, compactnotation, prefix/suffix), no React dependency.
AnimatedCounter/StatisticCard— presentational components. Includes aloading skeleton and screen-reader support (only the settled final value is
announced, not each animation frame).
Dashboard.tsx's KPI grid.start-from-0 / start-from-previous / no-op / reduced-motion behavior.
While getting a clean build, I also fixed two small pre-existing issues that
were blocking
npm run buildand unrelated to the counter feature itself:Heroimport inLandingPage.tsxcolliding with a localHerocomponent already defined in the same file@applyclasses inindex.cssreferencing undefinedtheme colors (
text-foreground,border-border)Related Issue
Fixes #88
Checklist
Screenshots / Screen Recordings
Recording.2026-07-20.104740.mp4
Breaking Changes
None.
Summary by CodeRabbit
testscript.CodeAnt-AI Description
Add animated dashboard counters with cleaner number display
What Changed
—andIDLEinstead of animating a numberImpact
✅ Clearer dashboard loading✅ Smoother KPI updates✅ Readable values for accessibility💡 Usage Guide
Checking Your Pull Request
Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.
Talking to CodeAnt AI
Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:
This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.
Example
Preserve Org Learnings with CodeAnt
You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:
This helps CodeAnt AI learn and adapt to your team's coding style and standards.
Example
Retrigger review
Ask CodeAnt AI to review the PR again, by typing:
Check Your Repository Health
To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.
Greptile Summary
This PR adds an
AnimatedCountercomponent (backed by auseAnimatedCounterhook and anumberFormatterutility) that animates KPI numbers on the dashboard from 0 on first load and from the previous value on updates, with full reduced-motion and screen-reader support. It also fixes two pre-existing build blockers inindex.cssand a duplicate import inLandingPage.tsx.useAnimatedCounterdrives animation viarequestAnimationFramewith an easeOutExpo curve; duration is clamped to 800–1500 ms,prefers-reduced-motionis respected with both a JS check and a CSS fallback, and decimal places are pinned to the final target value (not the intermediate display value) to avoid mid-animation flicker.AnimatedCounter/numberFormattercover formatting (grouping separators, compact notation, prefix/suffix, exponential edge cases) with a module-levelIntl.NumberFormatcache and 13 Vitest unit tests.Dashboard.tsxwires all six KPI tiles toAnimatedCounter, using static fallback strings ('—','IDLE') for zero-value states that should not display"0".Confidence Score: 5/5
Safe to merge — the changes are additive UI components with no backend impact, the pre-existing build fixes are minimal and correct, and the new animation path is well-tested.
The core animation logic, formatting utility, and dashboard wiring are all well-implemented and covered by unit tests. The only open item is a newly-created StatisticCard component that is not yet wired into the dashboard (dead code), which has no runtime impact.
frontend/src/components/StatisticCard.tsx — created but not imported anywhere; worth confirming whether it should replace the inline AnimatedCounter usage in Dashboard or is intentionally reserved for future use.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A["AnimatedCounter receives value prop"] --> B{"loading?"} B -- yes --> C["Render shimmer skeleton\n(aria-hidden, CSS shimmer)"] B -- no --> D["useAnimatedCounter(value, options)"] D --> E{"shouldSkipAnimation?\n(disableAnimation || prefersReducedMotion)"} E -- yes --> F["Render-time sync:\nanimatedValue = target instantly"] E -- no --> G{"target changed\nsince last render?"} G -- no --> H["No-op — isAnimating stays false"] G -- yes --> I["Start rAF loop from\nlast visual position (fromRef)"] I --> J["Tick: easeOutExpo(elapsed / duration)\n→ setAnimatedValue(next)"] J --> K{"progress < 1?"} K -- yes --> J K -- no --> L["setAnimatedValue(target)\nisAnimating = false"] F --> M["inferDecimals(target) → decimals\nformatNumber(displayValue, decimals)"] H --> M L --> M M --> N["Visual span (aria-hidden): animated text"] M --> O["SR-only span (aria-live=polite):\nempty while animating → final text on settle"]Reviews (5): Last reviewed commit: "Fixes" | Re-trigger Greptile