Files
resolutionflow/frontend/src/hooks/useFeatureLimit.ts
Michael Chihlas f1be3abcc5
Some checks failed
CI / e2e (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled
feat: self-serve signup Phase 2 (frontend cutover) (#162)
Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
2026-05-07 18:42:20 +00:00

105 lines
3.4 KiB
TypeScript

import { useEffect, useState } from 'react'
import { useBillingStore } from '@/store/billingStore'
import { usageApi } from '@/api/usage'
const CACHE_TTL_MS = 60 * 1000
interface CacheEntry {
used: number
timestamp: number
}
const cache = new Map<string, CacheEntry>()
/** Clear the usage cache (call on logout to prevent stale data across users). */
export function clearUsageCache() {
cache.clear()
}
export interface FeatureLimitResult {
used: number
limit: number | null
/** null when limit is null (unlimited) or unknown */
percentage: number | null
isAtLimit: boolean
isLoading: boolean
}
function coerceLimit(raw: unknown): number | null {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw
if (raw === null || raw === undefined) return null
// The store types planLimits as Record<string, unknown>; the backend
// currently returns numbers, but defensively handle string ints too.
if (typeof raw === 'string') {
const n = Number(raw)
return Number.isFinite(n) ? n : null
}
return null
}
/**
* Returns progress against a quantitative plan limit.
*
* `limit` comes from `useBillingStore.planLimits[field]`, which is read
* synchronously from the store. `used` is fetched lazily from
* `GET /api/v1/usage/{field}` on mount and cached for 60s in a module-level
* map keyed by field.
*
* Render is non-blocking: the hook returns `isLoading=true` (with `used=0`)
* until the usage fetch resolves. On 404 or any error the hook degrades to
* `used=0` with `isLoading=false` rather than surfacing the error — the
* `/usage/{field}` endpoint is not yet implemented on the backend (planned).
*/
export function useFeatureLimit(field: string): FeatureLimitResult {
const limit = useBillingStore((state) => coerceLimit(state.planLimits[field]))
const [state, setState] = useState(() => {
const existing = cache.get(field)
const fresh = existing && Date.now() - existing.timestamp < CACHE_TTL_MS
return {
field,
used: fresh ? existing.used : 0,
isLoading: !fresh,
}
})
useEffect(() => {
const existing = cache.get(field)
if (existing && Date.now() - existing.timestamp < CACHE_TTL_MS) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync local hook state with fresh module cache on field change
setState({ field, used: existing.used, isLoading: false })
return
}
let cancelled = false
setState({ field, used: 0, isLoading: true })
usageApi
.getCount(field)
.then((result) => {
if (cancelled) return
cache.set(field, { used: result.used, timestamp: Date.now() })
setState({ field, used: result.used, isLoading: false })
})
.catch(() => {
// TODO: backend /usage/{field} endpoint not yet implemented (planned).
// 404s and other errors degrade to used=0 silently — no toast.
if (cancelled) return
setState({ field, used: 0, isLoading: false })
})
return () => {
cancelled = true
}
}, [field])
const used = state.field === field ? state.used : 0
const isLoading = state.field === field ? state.isLoading : true
const percentage =
limit === null || limit <= 0 ? null : Math.min(100, Math.round((used / limit) * 100))
const isAtLimit = limit !== null && used >= limit
return { used, limit, percentage, isAtLimit, isLoading }
}
export default useFeatureLimit