Co-authored-by: Michael Chihlas <michael@resolutionflow.com> Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
105 lines
3.4 KiB
TypeScript
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
|