1- import { useEffect , useMemo , useState } from "react" ;
1+ import { useCallback , useEffect , useMemo , useState } from "react" ;
22import { useNavigate } from "react-router" ;
33import { Card } from "../../shared/components/card" ;
44import { Icon } from "../../shared/components/icon" ;
55import { MonthGrid } from "../../shared/components/month-grid" ;
6- import { FadeUp , Stagger } from "../../shared/components/motion" ;
6+ import { SkeletonBlock } from "../../shared/components/skeleton" ;
7+ import { StateBanner } from "../../shared/components/state-banner" ;
78import { useSettings } from "../../shared/context/settings-context" ;
89import {
910 api ,
@@ -18,6 +19,38 @@ import { ClockRow } from "./_components/clock-row";
1819import { DateHeader } from "./_components/date-header" ;
1920import { GlanceCards } from "./_components/glance-cards" ;
2021
22+ /**
23+ * The dashboard at its own shape, before the data lands.
24+ *
25+ * Sized to the real layout — date header, month grid, event row, glance pair —
26+ * so the popover opens at its final height instead of growing under the
27+ * pointer. This screen used to render a single `…` here, which told the user
28+ * nothing and then jumped.
29+ */
30+ function DashboardSkeleton ( ) {
31+ return (
32+ < div className = "space-y-2.5" aria-hidden >
33+ < div className = "surface-card p-2.5" >
34+ < SkeletonBlock className = "h-5 w-1/2" />
35+ < SkeletonBlock className = "mt-1.5 h-3 w-1/3" />
36+ </ div >
37+ < div className = "surface-card calendar-panel" >
38+ < SkeletonBlock className = "mx-auto h-3 w-2/5" />
39+ < div className = "mt-2.5 grid grid-cols-7 gap-1" >
40+ { Array . from ( { length : 42 } , ( _ , cell ) => (
41+ // biome-ignore lint/suspicious/noArrayIndexKey: fixed grid, never reordered
42+ < SkeletonBlock key = { cell } className = "aspect-square w-full" />
43+ ) ) }
44+ </ div >
45+ </ div >
46+ < div className = "grid grid-cols-2 gap-2.5" >
47+ < SkeletonBlock className = "h-[72px] w-full rounded-[10px]" />
48+ < SkeletonBlock className = "h-[72px] w-full rounded-[10px]" />
49+ </ div >
50+ </ div >
51+ ) ;
52+ }
53+
2154const PROVISIONAL_YEARS = new Set ( [ 2085 , 2086 , 2087 , 2088 , 2089 , 2090 ] ) ;
2255const GREG_MONTHS = [
2356 "Jan" ,
@@ -77,7 +110,8 @@ export function Dashboard() {
77110 return keys ;
78111 } , [ plans ] ) ;
79112
80- useEffect ( ( ) => {
113+ const reload = useCallback ( ( ) => {
114+ setError ( null ) ;
81115 api
82116 . today ( )
83117 . then ( ( value ) => {
@@ -95,6 +129,8 @@ export function Dashboard() {
95129 . catch ( ( ) => setPlans ( [ ] ) ) ;
96130 } , [ ] ) ;
97131
132+ useEffect ( reload , [ reload ] ) ;
133+
98134 useEffect ( ( ) => {
99135 if ( ! cursor ) return ;
100136 api
@@ -126,90 +162,72 @@ export function Dashboard() {
126162 } ;
127163
128164 if ( error ) {
129- return (
130- < Card title = { t ( "state.unavailable" ) } >
131- < p className = "text-text-secondary" > { error } </ p >
132- </ Card >
133- ) ;
134- }
135- if ( ! today || ! month ) {
136- return < p className = "text-text-muted" > …</ p > ;
165+ return < StateBanner state = { { status : "failed" , message : error } } onRetry = { reload } /> ;
137166 }
167+ if ( ! today || ! month ) return < DashboardSkeleton /> ;
138168
139169 const provisional = cursor && PROVISIONAL_YEARS . has ( cursor . year ) ;
140170 const upNext = upcoming [ 0 ] ;
141171
142172 return (
143- < Stagger className = "space-y-2.5" >
144- < FadeUp >
145- < DateHeader today = { today } />
146- </ FadeUp >
173+ < div className = "space-y-2.5" >
174+ < DateHeader today = { today } />
147175
148176 { modules . clocksEnabled && modules . clocks . length > 0 && (
149- < FadeUp >
150- < ClockRow timeZones = { modules . clocks } />
151- </ FadeUp >
177+ < ClockRow timeZones = { modules . clocks } />
152178 ) }
153-
154- < FadeUp >
155- < Card className = "calendar-panel" >
156- < div className = "mb-2 flex items-center justify-between gap-2" >
157- < button
158- type = "button"
159- aria-label = { t ( "calendar.previous-month" ) }
160- onClick = { ( ) => step ( - 1 ) }
161- className = "icon-btn size-7"
162- >
163- < span className = "text-[15px] leading-none" > ‹</ span >
164- </ button >
165- < span className = "min-w-0 flex-1 truncate text-center text-[11px] font-semibold tracking-[0.01em] text-text-secondary" >
166- { month . title }
167- { monthSpan ? ` · ${ monthSpan } ` : "" }
168- </ span >
169- < button
170- type = "button"
171- aria-label = { t ( "calendar.next-month" ) }
172- onClick = { ( ) => step ( 1 ) }
173- className = "icon-btn size-7"
174- >
175- < span className = "text-[15px] leading-none" > ›</ span >
176- </ button >
177- </ div >
178- < MonthGrid
179- month = { month }
180- planDays = { planDays }
181- onSelect = { ( day ) =>
182- day . date && navigate ( `/day?y=${ day . date . year } &m=${ day . date . month } &d=${ day . date . day } ` )
183- }
184- />
185- { provisional && (
186- < p className = "mt-2 text-[10px] text-text-muted" > { t ( "calendar.provisional" ) } </ p >
187- ) }
188- </ Card >
189- </ FadeUp >
190-
191- { upNext && (
192- < FadeUp >
179+ < Card className = "calendar-panel" >
180+ < div className = "mb-2 flex items-center justify-between gap-2" >
193181 < button
194182 type = "button"
195- onClick = { ( ) => navigate ( "/events" ) }
196- className = "surface-card flex w-full items-center gap-2 px-2.5 py-2 text-left transition-transform active:scale-[0.99]"
183+ aria-label = { t ( "calendar.previous-month" ) }
184+ onClick = { ( ) => step ( - 1 ) }
185+ className = "icon-btn size-7"
197186 >
198- < Icon
199- name = "festival"
200- className = "size-3.5 shrink-0 text-[color:var(--color-accent-mark)]"
201- />
202- < span className = "min-w-0 flex-1 truncate text-[12px]" > { upNext . name } </ span >
203- < span className = "shrink-0 text-[11px] text-text-muted" >
204- { relativeText ( upNext . days_away , t , numerals ) } ›
205- </ span >
187+ < span className = "text-[15px] leading-none" > ‹</ span >
206188 </ button >
207- </ FadeUp >
208- ) }
189+ < span className = "min-w-0 flex-1 truncate text-center text-[11px] font-semibold tracking-[0.01em] text-text-secondary" >
190+ { month . title }
191+ { monthSpan ? ` · ${ monthSpan } ` : "" }
192+ </ span >
193+ < button
194+ type = "button"
195+ aria-label = { t ( "calendar.next-month" ) }
196+ onClick = { ( ) => step ( 1 ) }
197+ className = "icon-btn size-7"
198+ >
199+ < span className = "text-[15px] leading-none" > ›</ span >
200+ </ button >
201+ </ div >
202+ < MonthGrid
203+ month = { month }
204+ planDays = { planDays }
205+ onSelect = { ( day ) =>
206+ day . date && navigate ( `/day?y=${ day . date . year } &m=${ day . date . month } &d=${ day . date . day } ` )
207+ }
208+ />
209+ { provisional && (
210+ < p className = "mt-2 text-[10px] text-text-muted" > { t ( "calendar.provisional" ) } </ p >
211+ ) }
212+ </ Card >
209213
210- < FadeUp >
211- < GlanceCards />
212- </ FadeUp >
213- </ Stagger >
214+ { upNext && (
215+ < button
216+ type = "button"
217+ onClick = { ( ) => navigate ( "/events" ) }
218+ className = "surface-card flex w-full items-center gap-2 px-2.5 py-2 text-left transition-transform active:scale-[0.99]"
219+ >
220+ < Icon
221+ name = "festival"
222+ className = "size-3.5 shrink-0 text-[color:var(--color-accent-mark)]"
223+ />
224+ < span className = "min-w-0 flex-1 truncate text-[12px]" > { upNext . name } </ span >
225+ < span className = "shrink-0 text-[11px] text-text-muted" >
226+ { relativeText ( upNext . days_away , t , numerals ) } ›
227+ </ span >
228+ </ button >
229+ ) }
230+ < GlanceCards />
231+ </ div >
214232 ) ;
215233}
0 commit comments