@@ -31,11 +31,12 @@ import { ArrowsDownUpIcon, CheckIcon, PencilSimpleIcon, PlusIcon, TrashIcon, XIc
3131import type { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema" ;
3232import { useAsyncCallback } from "@stackframe/stack-shared/dist/hooks/use-async-callback" ;
3333import type { SignUpRule , SignUpRuleAction } from "@stackframe/stack-shared/dist/interface/crud/sign-up-rules" ;
34+ import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors" ;
3435import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects" ;
3536import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises" ;
3637import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids" ;
3738import React , { useMemo , useState } from "react" ;
38- import { Area , AreaChart , ResponsiveContainer } from "recharts" ;
39+ import { Area , AreaChart , ResponsiveContainer , YAxis } from "recharts" ;
3940import { AppEnabledGuard } from "../app-enabled-guard" ;
4041import { PageLayout } from "../page-layout" ;
4142import { useAdminApp } from "../use-admin-app" ;
@@ -65,18 +66,32 @@ type ConfigWithSignUpRules = CompleteConfig & {
6566function RuleSparkline ( {
6667 data,
6768 totalCount,
69+ isLoading,
6870} : {
6971 data : { hour : string , count : number } [ ] ,
7072 totalCount : number ,
73+ isLoading : boolean ,
7174} ) {
72- if ( data . length === 0 || totalCount === 0 ) {
73- return null ;
75+ // Show skeleton while loading
76+ if ( isLoading ) {
77+ return (
78+ < div className = "flex items-center gap-1" >
79+ < div className = "w-10 h-4 bg-muted animate-pulse rounded" />
80+ < div className = "w-4 h-3 bg-muted animate-pulse rounded" />
81+ </ div >
82+ ) ;
7483 }
7584
85+ // Ensure we have at least 2 data points for the chart to render a line
86+ const chartData = data . length >= 2 ? data : [ { hour : '0' , count : 0 } , { hour : '1' , count : 0 } ] ;
87+ // Calculate max for Y domain - use at least 1 to avoid divide-by-zero
88+ const maxCount = Math . max ( 1 , ...chartData . map ( d => d . count ) ) ;
89+
7690 return (
77- < div className = "flex items-center gap-1" >
78- < ResponsiveContainer width = { 40 } height = { 20 } >
79- < AreaChart data = { data } margin = { { top : 2 , right : 0 , bottom : 2 , left : 0 } } >
91+ < div className = "flex items-center gap-1" title = "past 48h" >
92+ < ResponsiveContainer width = { 40 } height = { 16 } >
93+ < AreaChart data = { chartData } margin = { { top : 2 , right : 0 , bottom : 2 , left : 0 } } >
94+ < YAxis hide domain = { [ 0 , maxCount ] } />
8095 < Area
8196 type = "monotone"
8297 dataKey = "count"
@@ -85,6 +100,7 @@ function RuleSparkline({
85100 fill = "currentColor"
86101 fillOpacity = { 0.15 }
87102 className = "text-muted-foreground"
103+ isAnimationActive = { false }
88104 />
89105 </ AreaChart >
90106 </ ResponsiveContainer >
@@ -245,6 +261,7 @@ function RuleEditor({
245261function SortableRuleRow ( {
246262 entry,
247263 analytics,
264+ isAnalyticsLoading,
248265 isEditing,
249266 onEdit,
250267 onDelete,
@@ -254,6 +271,7 @@ function SortableRuleRow({
254271} : {
255272 entry : SignUpRuleEntry ,
256273 analytics ?: RuleAnalytics ,
274+ isAnalyticsLoading : boolean ,
257275 isEditing : boolean ,
258276 onEdit : ( ) => void ,
259277 onDelete : ( ) => void ,
@@ -364,15 +382,14 @@ function SortableRuleRow({
364382 onClick = { ( e ) => e . stopPropagation ( ) }
365383 onPointerDown = { ( e ) => e . stopPropagation ( ) }
366384 >
367- { /* Sparkline chart inline */ }
368- { analytics && analytics . totalCount > 0 && (
369- < div className = "hidden sm:flex items-center mr-1" title = { `${ analytics . totalCount } triggers in last 48h` } >
370- < RuleSparkline
371- data = { analytics . hourlyCounts }
372- totalCount = { analytics . totalCount }
373- />
374- </ div >
375- ) }
385+ { /* Sparkline and trigger count */ }
386+ < div className = "hidden sm:flex items-center mr-1" >
387+ < RuleSparkline
388+ data = { analytics ?. hourlyCounts ?? [ ] }
389+ totalCount = { analytics ?. totalCount ?? 0 }
390+ isLoading = { isAnalyticsLoading }
391+ />
392+ </ div >
376393 < Button
377394 variant = "ghost"
378395 size = "sm"
@@ -482,9 +499,11 @@ function DeleteRuleDialog({
482499function useSignUpRulesAnalytics ( ) {
483500 const stackAdminApp = useAdminApp ( ) ;
484501 const [ analytics , setAnalytics ] = useState < Map < string , RuleAnalytics > > ( new Map ( ) ) ;
502+ const [ isLoading , setIsLoading ] = useState ( true ) ;
485503
486504 React . useEffect ( ( ) => {
487505 let cancelled = false ;
506+ setIsLoading ( true ) ;
488507
489508 const fetchAnalytics = async ( ) => {
490509 const response = await ( stackAdminApp as any ) [ stackAppInternalsSymbol ] . sendRequest (
@@ -494,18 +513,23 @@ function useSignUpRulesAnalytics() {
494513 ) ;
495514 if ( cancelled ) return ;
496515
516+ if ( ! response . ok ) {
517+ throw new StackAssertionError ( `Failed to fetch sign-up rules stats: ${ response . status } ${ response . statusText } ` ) ;
518+ }
519+
497520 const data = await response . json ( ) ;
498521
499522 const analyticsMap = new Map < string , RuleAnalytics > ( ) ;
500523 for ( const trigger of data . rule_triggers ?? [ ] ) {
501524 analyticsMap . set ( trigger . rule_id , {
502525 ruleId : trigger . rule_id ,
503526 totalCount : trigger . total_count ,
504- hourlyCounts : trigger . hourly_counts ,
527+ hourlyCounts : trigger . hourly_counts ?? [ ] ,
505528 } ) ;
506529 }
507530
508531 setAnalytics ( analyticsMap ) ;
532+ setIsLoading ( false ) ;
509533 } ;
510534
511535 runAsynchronouslyWithAlert ( fetchAnalytics ) ;
@@ -515,7 +539,7 @@ function useSignUpRulesAnalytics() {
515539 } ;
516540 } , [ stackAdminApp ] ) ;
517541
518- return { analytics } ;
542+ return { analytics, isLoading } ;
519543}
520544
521545export default function PageClient ( ) {
@@ -531,14 +555,14 @@ export default function PageClient() {
531555 const [ ruleToDelete , setRuleToDelete ] = useState < SignUpRuleEntry | null > ( null ) ;
532556
533557 // Fetch analytics data
534- const { analytics : ruleAnalytics } = useSignUpRulesAnalytics ( ) ;
558+ const { analytics : ruleAnalytics , isLoading : isAnalyticsLoading } = useSignUpRulesAnalytics ( ) ;
535559
536560 // Type assertion needed because schema changes take effect at build time
537561 const configWithRules = config as ConfigWithSignUpRules ;
538562
539563 // Server state (source of truth)
540564 const serverRules = useMemo ( ( ) =>
541- typedEntries ( configWithRules . auth . signUpRules ?? { } ) . map ( ( [ id , rule ] ) => ( { id, rule } ) ) ,
565+ typedEntries ( configWithRules . auth . signUpRules ) . map ( ( [ id , rule ] ) => ( { id, rule } ) ) ,
542566 [ configWithRules . auth . signUpRules ]
543567 ) ;
544568 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- TypeScript may not see these as optional due to type assertion
@@ -753,6 +777,7 @@ export default function PageClient() {
753777 key = { entry . id }
754778 entry = { entry }
755779 analytics = { ruleAnalytics . get ( entry . id ) }
780+ isAnalyticsLoading = { isAnalyticsLoading }
756781 isEditing = { editingRuleId === entry . id }
757782 onEdit = { ( ) => {
758783 setEditingRuleId ( entry . id ) ;
@@ -789,6 +814,7 @@ export default function PageClient() {
789814 key = { entry . id }
790815 entry = { entry }
791816 analytics = { ruleAnalytics . get ( entry . id ) }
817+ isAnalyticsLoading = { isAnalyticsLoading }
792818 isEditing = { editingRuleId === entry . id }
793819 onEdit = { ( ) => {
794820 setEditingRuleId ( entry . id ) ;
0 commit comments