@@ -14,7 +14,12 @@ const POLL_INTERVAL_MS = 60_000;
1414
1515interface Summary {
1616 updatedAt : string ;
17- today : { checks : number ; os : Record < string , number > } ;
17+ today : {
18+ checks : number ;
19+ os : Record < string , number > ;
20+ /** Checks per hour since midnight; last entry = current partial hour. */
21+ hourly ?: number [ ] ;
22+ } ;
1823 lastHour : { checks : number ; devices : number } ;
1924 currentHour : { checks : number ; devices : number } ;
2025 totals : { apps : number ; developers : number ; hotUpdateVersions : number } ;
@@ -35,6 +40,11 @@ const MOCK_SUMMARY: Summary = {
3540 today : {
3641 checks : 8_412_530 ,
3742 os : { ios : 3_182_400 , android : 4_930_130 , harmony : 300_000 } ,
43+ hourly : [
44+ 180_000 , 95_000 , 60_000 , 48_000 , 52_000 , 88_000 , 210_000 , 380_000 ,
45+ 520_000 , 610_000 , 660_000 , 690_000 , 705_000 , 640_000 , 600_000 , 630_000 ,
46+ 655_000 , 480_000 ,
47+ ] ,
3848 } ,
3949 lastHour : { checks : 421_800 , devices : 262_140 } ,
4050 currentHour : { checks : 208_000 , devices : 152_000 } ,
@@ -138,6 +148,123 @@ function formatInt(value: number) {
138148 return Math . floor ( value ) . toLocaleString ( "zh-CN" ) ;
139149}
140150
151+ const DIGITS = "0123456789" ;
152+
153+ /**
154+ * Odometer-style number: each digit is a vertical reel of 0-9 rolled to the
155+ * target via translateY. Columns are keyed from the units digit up, so low
156+ * digits keep their identity (and animate) as the number grows.
157+ */
158+ function RollingNumber ( { value } : { value : number } ) {
159+ const text = formatInt ( value ) ;
160+ const chars = Array . from ( text ) ;
161+ return (
162+ < span className = "pushy-odometer" aria-label = { text } >
163+ { chars . map ( ( char , index ) => {
164+ const key = chars . length - index ;
165+ if ( char < "0" || char > "9" ) {
166+ return (
167+ < span key = { `sep-${ key } ` } className = "pushy-odometer__sep" >
168+ { char }
169+ </ span >
170+ ) ;
171+ }
172+ return (
173+ < span key = { `digit-${ key } ` } className = "pushy-odometer__digit" >
174+ < span
175+ className = "pushy-odometer__reel"
176+ style = { { transform : `translateY(-${ Number ( char ) } em)` } }
177+ >
178+ { DIGITS . split ( "" ) . map ( ( digit ) => (
179+ < span key = { digit } > { digit } </ span >
180+ ) ) }
181+ </ span >
182+ </ span >
183+ ) ;
184+ } ) }
185+ </ span >
186+ ) ;
187+ }
188+
189+ const TREND_W = 100 ;
190+ const TREND_H = 32 ;
191+
192+ /** Catmull-Rom spline through the points, as an SVG cubic-bezier path. */
193+ function smoothPath ( points : Array < [ number , number ] > ) {
194+ let d = `M ${ points [ 0 ] [ 0 ] } ${ points [ 0 ] [ 1 ] } ` ;
195+ for ( let i = 0 ; i < points . length - 1 ; i ++ ) {
196+ const p0 = points [ Math . max ( 0 , i - 1 ) ] ;
197+ const p1 = points [ i ] ;
198+ const p2 = points [ i + 1 ] ;
199+ const p3 = points [ Math . min ( points . length - 1 , i + 2 ) ] ;
200+ const c1x = p1 [ 0 ] + ( p2 [ 0 ] - p0 [ 0 ] ) / 6 ;
201+ const c1y = p1 [ 1 ] + ( p2 [ 1 ] - p0 [ 1 ] ) / 6 ;
202+ const c2x = p2 [ 0 ] - ( p3 [ 0 ] - p1 [ 0 ] ) / 6 ;
203+ const c2y = p2 [ 1 ] - ( p3 [ 1 ] - p1 [ 1 ] ) / 6 ;
204+ d += ` C ${ c1x } ${ c1y } , ${ c2x } ${ c2y } , ${ p2 [ 0 ] } ${ p2 [ 1 ] } ` ;
205+ }
206+ return d ;
207+ }
208+
209+ function TrendLine ( { hourly } : { hourly : number [ ] } ) {
210+ // Drop the current partial hour so the curve doesn't nosedive at the end.
211+ const series = hourly . slice ( 0 , - 1 ) ;
212+ if ( series . length < 3 ) {
213+ return null ;
214+ }
215+ const max = Math . max ( ...series ) ;
216+ if ( max <= 0 ) {
217+ return null ;
218+ }
219+
220+ const points = series . map ( ( count , index ) : [ number , number ] => [
221+ ( index / ( series . length - 1 ) ) * TREND_W ,
222+ TREND_H - 3 - ( count / max ) * ( TREND_H - 8 ) ,
223+ ] ) ;
224+ const line = smoothPath ( points ) ;
225+ const [ endX , endY ] = points [ points . length - 1 ] ;
226+
227+ return (
228+ < div className = "relative mt-7 max-w-md" >
229+ < svg
230+ viewBox = { `0 0 ${ TREND_W } ${ TREND_H } ` }
231+ preserveAspectRatio = "none"
232+ className = "block w-full h-14"
233+ aria-hidden = "true"
234+ >
235+ < defs >
236+ < linearGradient id = "pushy-trend-fill" x1 = "0" y1 = "0" x2 = "0" y2 = "1" >
237+ < stop offset = "0%" stopColor = "#38bdf8" stopOpacity = "0.26" />
238+ < stop offset = "100%" stopColor = "#38bdf8" stopOpacity = "0" />
239+ </ linearGradient >
240+ </ defs >
241+ < path
242+ d = { `${ line } L ${ TREND_W } ${ TREND_H } L 0 ${ TREND_H } Z` }
243+ fill = "url(#pushy-trend-fill)"
244+ />
245+ < path
246+ d = { line }
247+ fill = "none"
248+ stroke = "#38bdf8"
249+ strokeOpacity = "0.85"
250+ strokeWidth = "1.5"
251+ vectorEffect = "non-scaling-stroke"
252+ strokeLinecap = "round"
253+ strokeLinejoin = "round"
254+ />
255+ </ svg >
256+ < span
257+ className = "pushy-live-dot absolute w-1.5 h-1.5 rounded-full bg-sky-300 -translate-x-1/2 -translate-y-1/2"
258+ style = { { left : `${ endX } %` , top : `${ ( endY / TREND_H ) * 100 } %` } }
259+ aria-hidden = "true"
260+ />
261+ < span className = "mt-1.5 block text-xs text-slate-500" >
262+ 今日每小时更新检查
263+ </ span >
264+ </ div >
265+ ) ;
266+ }
267+
141268const PLATFORM_META : Array < { key : string ; label : string ; color : string } > = [
142269 { key : "android" , label : "Android" , color : "#34d399" } ,
143270 { key : "ios" , label : "iOS" , color : "#38bdf8" } ,
@@ -230,14 +357,17 @@ function LiveStats() {
230357 </ span >
231358 </ div >
232359 < div className = "font-mono text-4xl sm:text-5xl lg:text-[3.4rem] font-bold tracking-tight text-slate-50 tabular-nums" >
233- { formatInt ( tickingChecks ) }
360+ < RollingNumber value = { tickingChecks } />
234361 < span className = "ml-3 text-base font-sans font-medium text-slate-500" >
235362 次
236363 </ span >
237364 </ div >
238365 < div className = "mt-5 max-w-sm" >
239366 < PlatformBar os = { summary . today . os } />
240367 </ div >
368+ { Array . isArray ( summary . today . hourly ) && (
369+ < TrendLine hourly = { summary . today . hourly } />
370+ ) }
241371 </ div >
242372
243373 { /* Side stats */ }
0 commit comments