Skip to content

feat(billing): always-on Free sidebar bar with reset time#2350

Open
k11kirky wants to merge 1 commit into
mainfrom
posthog-code/usage-sidebar-reset-time
Open

feat(billing): always-on Free sidebar bar with reset time#2350
k11kirky wants to merge 1 commit into
mainfrom
posthog-code/usage-sidebar-reset-time

Conversation

@k11kirky
Copy link
Copy Markdown
Contributor

@k11kirky k11kirky commented May 25, 2026

Problem

The sidebar usage bar showed no reset time context, and the existing reset countdown used a rolling resets_in_seconds value that drifts between polls. Free-plan users also saw no loading state while usage data was being fetched — the bar simply didn't render.

Changes

  • Added reset_at (absolute UTC timestamp) and billing_period_end fields to the usage schema so the gateway's stable timestamp can be preferred over the drifting resets_in_seconds countdown.
  • Replaced the local formatResetTime in PlanUsageSettings with a shared formatResetTime utility that accepts both reset_at and a fallback seconds value. The function renders human-friendly labels (Resets in 30m, Resets in 4h 30m, Resets May 3 at 2:00 PM PDT, etc.) and prefers the absolute timestamp when available.
  • Updated SidebarUsageBar to show a skeleton loading state while usage data is in flight, rather than rendering nothing. The bar now also displays the reset time label below the progress bar.
  • Refactored useFreeUsage to return { usage, isLoading } so callers can distinguish between "not eligible" and "eligible but still loading."
  • Added error logging in the usage fetch path for both network errors and non-OK HTTP responses.

How did you test this?

Unit tests were added for formatResetTime covering: sub-hour countdowns, hour+minute formatting, hours-only when minutes round to zero, localized date formatting beyond 24 hours, preference of reset_at over fallback seconds, and handling of already-past reset_at values.

Publish to changelog?

no

Free users now see the usage bar with a reset-time line even while
usage is still loading — the previous silent `return null` on
`!usage` is what hid the bar from Free users in session replays.
`useFreeUsage` exposes an `isLoading` flag so `SidebarUsageBar` can
render a skeleton instead of disappearing. Reset time is rendered
via a shared `formatResetTime` helper that prefers the new gateway
`reset_at` ISO timestamp and falls back to `resets_in_seconds`,
producing "Resets in 4h" / "Resets in 4h 30m" / "Resets May 30 at
12:00am PT" depending on the horizon. The duplicated formatter in
`PlanUsageSettings` is replaced with the shared helper. Pro users
still see nothing here — their notifications come via toasts in B2.
Adds a `log.warn` on `fetchUsage` failures so we can see when the
bar is silently hidden by a network/auth error.

Generated-By: PostHog Code
Task-Id: bac06178-1ab1-4000-9a56-1901215bd4af

Generated-By: PostHog Code
Task-Id: bac06178-1ab1-4000-9a56-1901215bd4af
@k11kirky k11kirky force-pushed the posthog-code/usage-sidebar-reset-time branch from a6fca1b to 211250e Compare May 25, 2026 16:58
@k11kirky k11kirky marked this pull request as ready for review May 26, 2026 09:20
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 26, 2026

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
apps/code/src/renderer/features/billing/utils.ts:22-29
The hours+minutes path produces `"Resets in 0h 60m"` for inputs just below one hour. When `ms` falls in the range ~3,570,000–3,599,999 ms (e.g. `resets_in_seconds = 3570`), `Math.ceil(ms / 60_000)` rounds up to 60, bypassing the minutes-only branch, but then `Math.floor(totalHours) = 0` and `Math.round(0.9917 * 60) = 60`, producing an invalid label. The fix is to derive hours and minutes from integer arithmetic to avoid the floating-point rounding issue.

```suggestion
  const totalHours = ms / 3_600_000;
  if (totalHours < 24) {
    const hours = Math.floor(ms / 3_600_000);
    const minutes = Math.round((ms % 3_600_000) / 60_000);
    const adjustedHours = minutes === 60 ? hours + 1 : hours;
    const adjustedMinutes = minutes === 60 ? 0 : minutes;
    return adjustedMinutes === 0
      ? `Resets in ${adjustedHours}h`
      : `Resets in ${adjustedHours}h ${adjustedMinutes}m`;
  }
```

### Issue 2 of 2
apps/code/src/renderer/features/billing/utils.test.ts:55-86
Non-parameterized tests for `formatResetTime`

All six `it` blocks exercise the same function with different `(resetAtIso, fallbackSeconds, now)` inputs and expected outputs — a textbook fit for `it.each`. Keeping them as separate cases also misses the near-boundary edge (`resets_in_seconds = 3570`) that triggers the "Resets in 0h 60m" bug. A parameterized table would make it straightforward to add and track such boundary inputs.

Reviews (1): Last reviewed commit: "feat(billing): always-on Free sidebar ba..." | Re-trigger Greptile

Comment on lines +22 to +29
const totalHours = ms / 3_600_000;
if (totalHours < 24) {
const hours = Math.floor(totalHours);
const minutes = Math.round((totalHours - hours) * 60);
return minutes === 0
? `Resets in ${hours}h`
: `Resets in ${hours}h ${minutes}m`;
}
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.

P1 The hours+minutes path produces "Resets in 0h 60m" for inputs just below one hour. When ms falls in the range ~3,570,000–3,599,999 ms (e.g. resets_in_seconds = 3570), Math.ceil(ms / 60_000) rounds up to 60, bypassing the minutes-only branch, but then Math.floor(totalHours) = 0 and Math.round(0.9917 * 60) = 60, producing an invalid label. The fix is to derive hours and minutes from integer arithmetic to avoid the floating-point rounding issue.

Suggested change
const totalHours = ms / 3_600_000;
if (totalHours < 24) {
const hours = Math.floor(totalHours);
const minutes = Math.round((totalHours - hours) * 60);
return minutes === 0
? `Resets in ${hours}h`
: `Resets in ${hours}h ${minutes}m`;
}
const totalHours = ms / 3_600_000;
if (totalHours < 24) {
const hours = Math.floor(ms / 3_600_000);
const minutes = Math.round((ms % 3_600_000) / 60_000);
const adjustedHours = minutes === 60 ? hours + 1 : hours;
const adjustedMinutes = minutes === 60 ? 0 : minutes;
return adjustedMinutes === 0
? `Resets in ${adjustedHours}h`
: `Resets in ${adjustedHours}h ${adjustedMinutes}m`;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/billing/utils.ts
Line: 22-29

Comment:
The hours+minutes path produces `"Resets in 0h 60m"` for inputs just below one hour. When `ms` falls in the range ~3,570,000–3,599,999 ms (e.g. `resets_in_seconds = 3570`), `Math.ceil(ms / 60_000)` rounds up to 60, bypassing the minutes-only branch, but then `Math.floor(totalHours) = 0` and `Math.round(0.9917 * 60) = 60`, producing an invalid label. The fix is to derive hours and minutes from integer arithmetic to avoid the floating-point rounding issue.

```suggestion
  const totalHours = ms / 3_600_000;
  if (totalHours < 24) {
    const hours = Math.floor(ms / 3_600_000);
    const minutes = Math.round((ms % 3_600_000) / 60_000);
    const adjustedHours = minutes === 60 ? hours + 1 : hours;
    const adjustedMinutes = minutes === 60 ? 0 : minutes;
    return adjustedMinutes === 0
      ? `Resets in ${adjustedHours}h`
      : `Resets in ${adjustedHours}h ${adjustedMinutes}m`;
  }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +55 to +86
describe("formatResetTime", () => {
const NOW = Date.parse("2026-05-01T12:00:00.000Z");

it("returns minutes-only under 1h", () => {
expect(formatResetTime(undefined, 30 * 60, NOW)).toBe("Resets in 30m");
});

it("returns hours + minutes under 24h", () => {
expect(formatResetTime(undefined, 4 * 3600 + 30 * 60, NOW)).toBe(
"Resets in 4h 30m",
);
});

it("returns hours only when minutes round to 0", () => {
expect(formatResetTime(undefined, 4 * 3600, NOW)).toBe("Resets in 4h");
});

it("returns localized date when over 24h away", () => {
const result = formatResetTime(undefined, 30 * 86400, NOW);
expect(result).toMatch(/^Resets [A-Za-z]+ \d+ at /);
});

it("prefers reset_at over the fallback seconds", () => {
const iso = new Date(NOW + 4 * 3600 * 1000).toISOString();
expect(formatResetTime(iso, 99999, NOW)).toBe("Resets in 4h");
});

it("treats an already-past reset_at as shortly", () => {
const iso = new Date(NOW - 60_000).toISOString();
expect(formatResetTime(iso, 0, NOW)).toBe("Resets shortly");
});
});
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.

P2 Non-parameterized tests for formatResetTime

All six it blocks exercise the same function with different (resetAtIso, fallbackSeconds, now) inputs and expected outputs — a textbook fit for it.each. Keeping them as separate cases also misses the near-boundary edge (resets_in_seconds = 3570) that triggers the "Resets in 0h 60m" bug. A parameterized table would make it straightforward to add and track such boundary inputs.

Context Used: Do not attempt to comment on incorrect alphabetica... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/billing/utils.test.ts
Line: 55-86

Comment:
Non-parameterized tests for `formatResetTime`

All six `it` blocks exercise the same function with different `(resetAtIso, fallbackSeconds, now)` inputs and expected outputs — a textbook fit for `it.each`. Keeping them as separate cases also misses the near-boundary edge (`resets_in_seconds = 3570`) that triggers the "Resets in 0h 60m" bug. A parameterized table would make it straightforward to add and track such boundary inputs.

**Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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.

1 participant