From 6bb23bc22a7081f84362e0715f59a5eebc1fc088 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 14:16:08 +0100 Subject: [PATCH 01/22] fix: add service health reporting fix to changelog for clarity --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e82e3d9..421a288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Fixed + +- **More reliable service health reporting.** The backend health check no longer falsely reports the service as unhealthy after sustained traffic. Previously, an internal counter grew over time and would eventually cross a fixed threshold — even under normal load — causing orchestrators to unnecessarily restart the service. + ## [0.12.0-alpha] - 2026-03-01 ### Added -- 2.49.1 From bc5e20ab7bff75d5a3e6dde23bf36d242ab64d56 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 14:29:29 +0100 Subject: [PATCH 02/22] fix: add note on lower resource usage under load to changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 421a288..03aaa8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed - **More reliable service health reporting.** The backend health check no longer falsely reports the service as unhealthy after sustained traffic. Previously, an internal counter grew over time and would eventually cross a fixed threshold — even under normal load — causing orchestrators to unnecessarily restart the service. +- **Lower resource usage under load.** The backend now uses a single shared connection to Redis instead of opening dozens of separate ones. Previously, each rate limiter and internal component created its own connection pool, which could waste resources and risk hitting connection limits during heavy traffic. ## [0.12.0-alpha] - 2026-03-01 -- 2.49.1 From 27158f7bfc1ca7367050bb0d0a5ca3e7efffcae0 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 14:33:28 +0100 Subject: [PATCH 03/22] fix: enhance billing operations and session management in API --- CHANGELOG.md | 2 ++ lib/api/billing.ts | 47 +++++++++------------------------------------- lib/api/user.ts | 20 ++------------------ 3 files changed, 13 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03aaa8b..4dec578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **More reliable service health reporting.** The backend health check no longer falsely reports the service as unhealthy after sustained traffic. Previously, an internal counter grew over time and would eventually cross a fixed threshold — even under normal load — causing orchestrators to unnecessarily restart the service. - **Lower resource usage under load.** The backend now uses a single shared connection to Redis instead of opening dozens of separate ones. Previously, each rate limiter and internal component created its own connection pool, which could waste resources and risk hitting connection limits during heavy traffic. +- **More reliable billing operations.** Billing actions like changing your plan, cancelling, and viewing invoices now benefit from the same automatic session refresh, request tracing, and error handling as the rest of the app. Previously, these used a separate internal path that could fail silently if your session expired mid-action. +- **Session list now correctly highlights your current session.** The active sessions list in settings now properly identifies which session you're currently using. Previously, the "current session" marker never appeared due to an internal mismatch in how sessions were identified. ## [0.12.0-alpha] - 2026-03-01 diff --git a/lib/api/billing.ts b/lib/api/billing.ts index fff2fdc..0bb3245 100644 --- a/lib/api/billing.ts +++ b/lib/api/billing.ts @@ -1,4 +1,4 @@ -import { API_URL } from './client' +import apiRequest from './client' export interface TaxID { type: string @@ -31,39 +31,12 @@ export interface SubscriptionDetails { next_invoice_period_end?: number } -async function billingFetch(endpoint: string, options: RequestInit = {}): Promise { - const url = `${API_URL}${endpoint}` - - const headers: HeadersInit = { - 'Content-Type': 'application/json', - ...options.headers, - } - - const response = await fetch(url, { - ...options, - headers, - credentials: 'include', // Send cookies - }) - - if (!response.ok) { - const errorBody = await response.json().catch(() => ({ - error: 'Unknown error', - message: `HTTP ${response.status}: ${response.statusText}`, - })) - throw new Error(errorBody.message || errorBody.error || 'Request failed') - } - - return response.json() -} - export async function getSubscription(): Promise { - return await billingFetch('/api/billing/subscription', { - method: 'GET', - }) + return apiRequest('/api/billing/subscription') } export async function createPortalSession(): Promise<{ url: string }> { - return await billingFetch<{ url: string }>('/api/billing/portal', { + return apiRequest<{ url: string }>('/api/billing/portal', { method: 'POST', }) } @@ -74,7 +47,7 @@ export interface CancelSubscriptionParams { } export async function cancelSubscription(params?: CancelSubscriptionParams): Promise<{ ok: boolean; at_period_end: boolean }> { - return await billingFetch<{ ok: boolean; at_period_end: boolean }>('/api/billing/cancel', { + return apiRequest<{ ok: boolean; at_period_end: boolean }>('/api/billing/cancel', { method: 'POST', body: JSON.stringify({ at_period_end: params?.at_period_end ?? true }), }) @@ -82,7 +55,7 @@ export async function cancelSubscription(params?: CancelSubscriptionParams): Pro /** Clears cancel_at_period_end so the subscription continues past the current period. */ export async function resumeSubscription(): Promise<{ ok: boolean }> { - return await billingFetch<{ ok: boolean }>('/api/billing/resume', { + return apiRequest<{ ok: boolean }>('/api/billing/resume', { method: 'POST', }) } @@ -100,7 +73,7 @@ export interface PreviewInvoiceResult { } export async function previewInvoice(params: ChangePlanParams): Promise { - const res = await billingFetch>('/api/billing/preview-invoice', { + const res = await apiRequest>('/api/billing/preview-invoice', { method: 'POST', body: JSON.stringify(params), }) @@ -111,7 +84,7 @@ export async function previewInvoice(params: ChangePlanParams): Promise { - return await billingFetch<{ ok: boolean }>('/api/billing/change-plan', { + return apiRequest<{ ok: boolean }>('/api/billing/change-plan', { method: 'POST', body: JSON.stringify(params), }) @@ -124,7 +97,7 @@ export interface CreateCheckoutParams { } export async function createCheckoutSession(params: CreateCheckoutParams): Promise<{ url: string }> { - return await billingFetch<{ url: string }>('/api/billing/checkout', { + return apiRequest<{ url: string }>('/api/billing/checkout', { method: 'POST', body: JSON.stringify(params), }) @@ -142,7 +115,5 @@ export interface Invoice { } export async function getInvoices(): Promise { - return await billingFetch('/api/billing/invoices', { - method: 'GET', - }) + return apiRequest('/api/billing/invoices') } diff --git a/lib/api/user.ts b/lib/api/user.ts index 6268362..17a9872 100644 --- a/lib/api/user.ts +++ b/lib/api/user.ts @@ -18,24 +18,8 @@ export interface Session { } export async function getUserSessions(): Promise<{ sessions: Session[] }> { - // Hash the current refresh token to identify current session - const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refreshToken') : null - let currentTokenHash = '' - - if (refreshToken) { - // Hash the refresh token using SHA-256 - const encoder = new TextEncoder() - const data = encoder.encode(refreshToken) - const hashBuffer = await crypto.subtle.digest('SHA-256', data) - const hashArray = Array.from(new Uint8Array(hashBuffer)) - currentTokenHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') - } - - return apiRequest<{ sessions: Session[] }>('/auth/user/sessions', { - headers: currentTokenHash ? { - 'X-Current-Session-Hash': currentTokenHash, - } : undefined, - }) + // Current session is identified server-side via the httpOnly refresh token cookie + return apiRequest<{ sessions: Session[] }>('/auth/user/sessions') } export async function revokeSession(sessionId: string): Promise { -- 2.49.1 From 5b388808b67d74c16a737e757b05907e4252e1e5 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 14:43:25 +0100 Subject: [PATCH 04/22] fix: update changelog with recent fixes and remove unused icon files --- CHANGELOG.md | 2 ++ public/Icon Padding left & right 192x192.png | Bin 7962 -> 0 bytes public/Icon Padding left & right 512x512.png | Bin 22728 -> 0 bytes 3 files changed, 2 insertions(+) delete mode 100644 public/Icon Padding left & right 192x192.png delete mode 100644 public/Icon Padding left & right 512x512.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dec578..18bc44e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Lower resource usage under load.** The backend now uses a single shared connection to Redis instead of opening dozens of separate ones. Previously, each rate limiter and internal component created its own connection pool, which could waste resources and risk hitting connection limits during heavy traffic. - **More reliable billing operations.** Billing actions like changing your plan, cancelling, and viewing invoices now benefit from the same automatic session refresh, request tracing, and error handling as the rest of the app. Previously, these used a separate internal path that could fail silently if your session expired mid-action. - **Session list now correctly highlights your current session.** The active sessions list in settings now properly identifies which session you're currently using. Previously, the "current session" marker never appeared due to an internal mismatch in how sessions were identified. +- **Notifications no longer fail to load on sign-in.** The notification bell now loads correctly even when the app is still setting up your workspace context. Previously, it could briefly show an error right after signing in. +- **Fixed a service worker error on first visit.** Removed leftover icon files with invalid filenames that caused a caching error in the background. This had no visible effect but produced console warnings. ## [0.12.0-alpha] - 2026-03-01 diff --git a/public/Icon Padding left & right 192x192.png b/public/Icon Padding left & right 192x192.png deleted file mode 100644 index c3b532c41088c454ad16db1f561085474f86dca3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7962 zcma)hcU%))+V&(AsR{zpL_!b|kY0p9AW}uD(wo%KAxJ`#7AexCNfV?O1*Hf`7m1=E z(iN!+B2}ppiV(nW&|RP1eRkj9_s2};ocp@_bK${vKYS8~_xQ{Jm`L-OxCg9ooso6A52y zX@kRD9FTAmX&o^gFBP=2%jKKiXoH)&hW0nz?ByKbN{TcJ{s_>32O4J!^Y?J~#3KBW z@LzTj;Puh8C>-_+f^$Q{O?C8PDj07xOj<-*L=3J-15@yJa70{FRsV-FI6}gmaX2r8 zsHmTxpNOBN2*%q(BNM-EsG_m<-Y#A^7mO$D z$fm6w#s`Ol!$CRhpOJZZ{W0!|{YUm-9Yp#&7zY=}fd7h?K}d=JCmQ6FgDuYX z{|Eo4Lx>~v@2Eg7|KB0~rKEy!$9RL$fmulYiuNy&rizNbH^$M$9h70OX(+=q)l_7} z83z#~-MlGa5AW9~}7?B8vX^EdCbt?==5qPXhIR&w+a#xM_<1 z*)_qzpM4kY2@=#B+*J#M-hKf9$SoI;X@6rF0Gv$tir*QAH67u|QWaC*Q5R5JP{nPtriLD|@18 z0o%5d`@VG{Ogj5&O>;0qO;gyZtPOcqcwAoxYx$_li%S}YgCD-zh@q@VjqP?FoQY}A7)GeX4Lt}=`Dr@pHh;VS zQh0~(4-xi`g#z0k?prI2BHkbN_aS$l#a(~oS{dv-fv@d;bZX)jQD-qkzsP*kq$b89 zMbjU7YnTdmU4?k9$tf*3VU6dRh_w$zOW=U$!bEx~FxTqz_;SjL8SwTZtamk4FB$r0ujB+iaZn5HY-`JG^2-P&e4?iwB!TTf88J#rf^iDoQlqTeX*f|l5*oLXy&}Ds;CC)d4 zwxxTu^&zqIW2XErh&;BLG=l6|BQ06)1ke>gd&({MWbr$x$b@V8 zudKhZmAow_hEPVBT=J)g;D-J~sSg-S zAjKW}`=H46&&lwws{d3)N+4SLj+-sdPyp7)rpM=5{S@?jj+HCI@ikruiIUS7&+l=r zbdD=RULqJCWU;RJS41aT7;&Ia*qkkRwN%Wz^yTE&6H6;ehmB1ufv83Zp}TfrWhZ8g zxNWiegIpz?D&KV!{;X2LdLj-|er!5zaX-eOs4df!ZIj(Kf-3*Qoxai?R|z?Jlk?eQ zMLX#nupsJ)g`&3mKk!RML+p%%{O?!Lh)@T?_x1c*neFP$%K-56R7GZ^lDTB{sKmE% zIP^U2`^xw-k&)Y#J<j_Vw1lk$!9f2l+!EO9x{^$2PV^rvc-6E-M z^qmdP^paFl3NGQJx0x!j5gLkU`9s=ms%4@9!ApW`^J}XSxHO-Y0Rao( z^ZmhvbH>N+6iP$`l#tG3ou;<$s5UO0)&B0KK%cQerR&4wwpN?FF>!_cBiI`kplnhC zPMdEI%xG=Z*`2IjSXtm?SyeOCtWAyO8KZ&N+e_}jTVdXz3Bf8{5khhQY8fJdtf6P;c%bKLoSIQ( zh@q*zuc@c%?u`eQ)A~ySnad3*f3arwu}i0f(m+m0nMd(rx$e_v1dp)UMV%g0GMjhd zCR}6ij1#do{u;Lk`XTs?I&3;MNUMf=;{4+8^HJ$U;#!JOruJW}aiF-+=d9LdLy6Ei zQud*W`-0O?OP{$K`_t)HRLGMqethjvc+X=b+pm@)WqhZhZejfP^E-&2WBHZl#+ufT zXY+4EO*Sver6)>~iY0lrG@q5FY3o&LYWgMWJu=8k3cZ6EGG`mM>nJk-uzp{Zy0wdV|0SnN_=f z+|Aw1Ia?cfVLPX(&Bd~HWPdDt6Rm+?^lCq^b%{KQKew;4;!DcN(m2~*f)&vKAM9vG z76I~d&OZGr9Wx{9%;<9*FlplMP4v97-(6iHgq?O#+9GdhEC`3}BZ@|D!Juzw^A=rV z56zY>vJt++8TJ+##+jatdgIe$Kaa(w-GW2k#1gd{R57}n4O`vXbzy~}pO?QxAAZVM zGZ_(4di#z_+ZR>K&;V|eLY~8Z>R=Tr6chlql2gK zEUp=(*Q}L>Ki}+Z5Pl!BEGD|=H+5Lh9I^{nIYCw=2>o$=jEo9SD7f~aobg_!-vr$M zR5f2GgToXXdHSt&jr81O%0}etHlV*qhs_4LoZj2->t81b?6vI8kJjHwbl>i^SLzz* zJ__nN6ebwRaNR4L@2Y4T%RQof;H>o6_nQ7{54Lcv3zaZ2>ow=P8%-7J(}v1GMcLOC z;PBz{4TqQfdx7dBPlNrqW~7x)PgLG4Kz~J$AhIt8Twx#(8&tFmc`~i42X0A^%!~M& zR-zDYmpA=3J#xlA+RJ~Eh<#ah7Z`@z6AomUEPn2#GFvC7=A8NB`Hh9n59uy>Be`GZ z94B3bh3~zk%9oZtpu@LxpNV4Pj`CIu^$2vFrPnMORmSz8ii5jYuRL>)9B+IkrBr?g zm~SjyhM3+^SL$EP^LCcVnCK{uJ3L+8SX4W9`-7tV9vMLqxDxml_kny}3{j@%R10myTq>!3bGRs$Q`-q_jCucpTWEFiM*|CLp`-VzidFmJ6O9x3}N41~Db9SFm zrL)>RaRX5wEW6?qdssjA8oPLbG@njuDvf>c4tmdmIS=yrLfGFICT?R(u%0lXHKE-0s&BNt~V(O96)I7qhgiTk(lgG{<+Vo;-?Xdv3eF}i_5RI zeB7VOH=-#8MFGHBM@IupEFMUJt{>d;agWQs%^b;c5Qt8Av9D-fE%U&HH?4+@^vn{Jw>S1qx|^Q+cnp@G&UE;8{*%)%Q>j3 z_|;D21gJwtfGqY<`SB!1*62laOYSW==Gn`O?M+Z69s=uAlfvHl*G>r1qdgxkQUlzX zW=)$OKmuf)5$J@losj7{kf48_TEloUCs|~t@4%`)+);$a2gfwk+k5xeE`3`V__Tqb z{M-s8s5@m$3j^E7o-pGNcT>AW6qFZ{veqXh`kfa;lpaS?WV|Yy@zQ z_YVtuZLM@--Z_wX){NQNT_Hm;9{rO6Z(eG6KKI?vBq@IOc78WE$pd$I%5M^AV4=i} zZ+P!Ol3Pn>XOCqrU3H)G?rbr#52`hP8q`vKt&Pa}HoGjTOWec9vX5(Z^WZ#yaj0Rq zT*3OHov`h+W?7L%QlPX+h9^ni^_!N>861-?i>nmQ)K03k0%!=U`RVM*?D=PX6`EWw za7^voRO?MKqmPX}?7A5o^(^9^2W2#{+~dH*z=)!D51mgSE1D0}$?vExQ+CApK9Cxp z$@)0uXl=qKvD7#gd-ocQk$@jNT+WGl+%dWWeAhkLdB+&;cO&u(iKu?Po_^~`P};eP z63fN&P&S5%+~zbx>~x@!{qh-nI-8K|0r&_VRm`hMJ8dY4?mDQgEn|30=fJ1{)FXp6 zRHRR3hjsi26;#toh2lbyje}|TM*=@SZQoc6D*EY}<&Nod;31PBWA54JuXIz5AJ)kq z=I(6Gj@r)3^N(EaTeMV_96mnn-utQW7#t7X9c#dtDNdLNv7*+(zg>Uodc27yc40t~ z)@;Pg{*%6CLaz1ZF@(ux`faLwSjgVciiT=Dg#o~IvQ0r$oVN5ZGCb>4><{$ zeWmx@$HL$$maJ5oJwz)^LL=1Ruu^{Yil00BG->yHf0H>a7yy|C_gJH>@)9R%=CZ0_ zONtj)pSilDe9X*zWR!i-*gZ-w14kitMu@}goW{jgY<=CjC&PiV=p1!6(A=8??vuk- zjo-xP4z&wr70BERyL<&Qls0*xZwQIpOK z?_H4+3`+Io^)XKl73>SMM}y6D`a^L)BDDR^3Y`z!97&EHP0!)AGFYule60)Icd0q> z5=obUL)T{`d#$gRbJ%BuWjXb%jnAgCo_DY)-5$Z#EsW}&=}&Cy{a{Y13Y>X{`zcKe zesPc-kS)CbB<0)TG2-!$0$szIn8{u<&P`TMkCbh60@R zuP=)<%L3Z+;`Wd?X+H-wT(QDK~Ya<)HnoP!p& ztXe`m^Z4Qx_H*9JwSANQWI3NWsl=Ym=c|M|IiYN1GJ^Z~l-OR!)nSJIL|jrPAL!gu zlK(n7GT2N@uq!c~Dxq#()7?6_$gf;UIj0_a<=$7W2DW|;D(m8|?cmG`<_Ic)@Srvk z<6gwKuvXCFB)*g8xB5N@>uv0?<~g@c(#bMx4nIh1l2>{DRrki=&l|XqAJ1y;JOhUF z4hCO)xLTD(pCct|TpeNp_^_cBQ9st8C3Wxokc>ip{0#;>tvYFumGu70XHWW5@;r0;Yk`SZE6`Pq~C^AX86CF*<)v=@8AbmxZFWSgh6DxPnl6eyH`UJ^Y&HsDmi zahJ5vHiPBE>zRC2zIdFM2k+Lmzj&)kI_sj)fcQt5Pwe>_ABy{cc3%P~MW^p$z0DEy zMVYAnGY6uSULhYUiPK`6(8ilvno+9-^R2+-{Ci!s9CjGXG?VU?YN*4zTN{&<=+q^e z4NX6MU)YW$plI?zvQ^}^F))%F0ADGYujaQdv4cw zz=x-mz<5`Fe45nQp-Gi7i@h_&lZ>o~z4}-(sC~BbPy_9dysVQ>3UMJxi?iX9N1U;! zcif>UnQTq0iQk?C4%)c2dE%W=Ma+^@wDGN77NNB0d-tYxW4N+0gMn$p*ZI9e$+=W? zIrj=fzsVLM6D@#jos<=s7PUxrL5RETOhimYe6>70(muPvaqIbd!p`GY@P?Lyu>j0y zu3=7`@`BOKw{BhXqz~*(6KT5-5LVlOELYCbo?FAHw73-1G_WHkYPERYB;PCA51t$Q zMXCWXCZe0&Zr$u$p|zFJ2DlyFr%Kh>y$$_-zpOZbfT__4iKAD>r{@Ic=_|Okeg;b+lz$ zZgNSN^OJOFwYOa&ZO&uapELw%sjV81$nw z4G>YBQ|WwuYu<{+G+S6EE}y0@{P@D+^@m@QcWD&CD7tSOBImPy}VGpI2drYg2ENZmczA-WDu26d^3T^ZAhh+()LZcde7%1&^+ z7|RUrr6bfV8EL9sJ;y5ynB*mIJL$D|!Yi*MRRKyk%a{{BTQ#|j<;5{Zd|Cfx7uFZm zi1P3+!$#`lHeV%i^aHiFK}ASdDtKQ*%|1Ilo_Bb^Q;JWS8Z3(C25xj$gGV{rH@Ykm z*zk3~j_ic9MO$flTwoULeM60(uudle#M{~!wbMqIKywCaF{DW8wHlwhc>+9ijQEoN z%YIeW_U92@R3FsHZSbwMvqowWSEW^fd0lz__Oo;BuC6=)r&A z6-IPw3>9JM;f_es0$bX4iI|Nl(4qLg1+`5%o}v7j5n{SGG{un8jm}*oew9`eMSqh3Mn%l4f}xU6=IdZ zY$9m+pGN3D7Pp=zpBs_ykyEe&v#@*d+wf8+294EkgaC$OY{f%-$OZh9~*59x>N3;Cle&tI+^7W`)5B;}2yzB7-1}-W%4V45*iQZ&9JxT)>*c z#bdRkE}jHkhKImEetdjuOCU80tnLCKkohf&9Ck+v4%j~oU%SSp00(S~%i@9PXse4E zif9@(YLGjyDKnHKa{;&n#iFOkb-6D*)hd7=)h`yR4rRoPy_0%-1`h-K1$t8jUS8&f zGN^ML%|`2Ppu5u_LM$z}WohO&wMMRsTW!PCIQD`h*=bm_@z&SPd(osz8#7uG_PV6VRnt?*dhL{0>rqwX3u&Gf&!& z%hM6Q1Bz6i#em*tB-t++Crpl2%H~J^RBC}JP=3A+>{vE$$8xg_L=k3xLO#|#fJw*` Sz8(Fgt*NG~TB&Rk_WuBsts`~- diff --git a/public/Icon Padding left & right 512x512.png b/public/Icon Padding left & right 512x512.png deleted file mode 100644 index a9bf4b0c063fd675f5935ebf151227caffcb7bbf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22728 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 ik9^ Date: Sun, 1 Mar 2026 15:02:22 +0100 Subject: [PATCH 05/22] fix: enhance security with stricter Content Security Policy and input validation --- CHANGELOG.md | 6 ++++++ next.config.ts | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18bc44e..732b2cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Session list now correctly highlights your current session.** The active sessions list in settings now properly identifies which session you're currently using. Previously, the "current session" marker never appeared due to an internal mismatch in how sessions were identified. - **Notifications no longer fail to load on sign-in.** The notification bell now loads correctly even when the app is still setting up your workspace context. Previously, it could briefly show an error right after signing in. - **Fixed a service worker error on first visit.** Removed leftover icon files with invalid filenames that caused a caching error in the background. This had no visible effect but produced console warnings. +- **Stronger browser protection with Content Security Policy.** The app now tells your browser exactly which resources it's allowed to load — scripts, styles, images, and API connections are restricted to trusted sources only. This adds an extra layer of defence against cross-site scripting (XSS) attacks. +- **Tighter cross-origin request handling.** The backend now only accepts requests from known Pulse and Ciphera origins instead of any website. This prevents other sites from making authenticated requests on your behalf. +- **Stricter environment security checks.** Staging and other non-development deployments now refuse to start if critical secrets haven't been configured, catching configuration mistakes before they reach users. +- **Billing configuration validated at startup.** Stripe payment keys are now checked when the server starts instead of when a billing request comes in. Misconfigured payment settings surface immediately during deployment rather than silently failing when you try to manage your subscription. +- **Faster real-time visitor cleanup.** The background process that keeps active visitor counts accurate no longer briefly blocks other operations while scanning for stale sessions. Cleanup now runs incrementally so your dashboard stays responsive at all times. +- **Stricter input validation on admin pages.** The internal admin panel now validates organisation identifiers before processing requests, preventing malformed data from reaching the database. ## [0.12.0-alpha] - 2026-03-01 diff --git a/next.config.ts b/next.config.ts index 648d8ee..c111fc8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,6 +6,22 @@ const withPWA = require("@ducanh2912/next-pwa").default({ disable: process.env.NODE_ENV === "development", }); +// * CSP directives — restrict resource loading to known origins +const cspDirectives = [ + "default-src 'self'", + // Next.js requires 'unsafe-inline' for its bootstrap scripts; 'unsafe-eval' only in dev (HMR) + `script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https://www.google.com", + "font-src 'self'", + `connect-src 'self' https://*.ciphera.net https://cdn.jsdelivr.net${process.env.NODE_ENV === 'development' ? ' http://localhost:*' : ''}`, + "worker-src 'self'", + "frame-src 'none'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self' https://*.ciphera.net", +].join('; ') + const nextConfig: NextConfig = { reactStrictMode: true, // * Enable standalone output for production deployment @@ -41,6 +57,7 @@ const nextConfig: NextConfig = { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload', }, + { key: 'Content-Security-Policy', value: cspDirectives }, ], }, ] -- 2.49.1 From 95920e4724e5c8a9e424b1e8c486f80fcf305636 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 15:18:56 +0100 Subject: [PATCH 06/22] fix: update changelog with Phase 2 audit fixes --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 732b2cd..3d6d634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Billing configuration validated at startup.** Stripe payment keys are now checked when the server starts instead of when a billing request comes in. Misconfigured payment settings surface immediately during deployment rather than silently failing when you try to manage your subscription. - **Faster real-time visitor cleanup.** The background process that keeps active visitor counts accurate no longer briefly blocks other operations while scanning for stale sessions. Cleanup now runs incrementally so your dashboard stays responsive at all times. - **Stricter input validation on admin pages.** The internal admin panel now validates organisation identifiers before processing requests, preventing malformed data from reaching the database. +- **Error messages no longer reveal internal details.** When you submit an invalid form, the error message now says "Invalid request body" instead of exposing internal field names and validation rules. This makes error messages cleaner while keeping your data safer. +- **Better request cancellation for billing operations.** All billing-related database operations can now be properly cancelled if you navigate away or your connection drops. Previously, some operations would continue running in the background even after you left the page. +- **More reliable database migrations.** The migration system no longer silently skips partially failed database updates. If an update fails, it stops immediately so the issue can be identified and fixed — rather than marking it as complete and moving on with missing changes. +- **More resilient event processing.** The background system that processes your analytics events now automatically recovers from unexpected errors instead of silently stopping. If something goes wrong, it restarts itself and continues processing — so you never lose incoming analytics data. ## [0.12.0-alpha] - 2026-03-01 -- 2.49.1 From c9123832a5cca05f7796ba8f5c0c701bf546ad65 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 15:33:37 +0100 Subject: [PATCH 07/22] fix: fix broken images from CSP, remove dead code, upgrade React types - Add ciphera.net and *.gstatic.com to CSP img-src (fixes app switcher icons and site favicons blocked by Content Security Policy) - Delete 6 unused component/utility files and orphaned test - Upgrade @types/react and @types/react-dom to v19 (matches React 19 runtime) - Fix logger test to use vi.stubEnv for React 19 type compatibility --- CHANGELOG.md | 9 ++ components/PasswordInput.tsx | 109 ------------------ components/WebsiteFooter.tsx | 0 components/WorkspaceSwitcher.tsx | 116 ------------------- components/dashboard/Countries.tsx | 140 ----------------------- components/dashboard/TopPages.tsx | 51 --------- lib/utils/__tests__/errorHandler.test.ts | 95 --------------- lib/utils/__tests__/logger.test.ts | 6 +- lib/utils/errorHandler.ts | 79 ------------- next.config.ts | 6 +- package-lock.json | 26 ++--- package.json | 4 +- 12 files changed, 29 insertions(+), 612 deletions(-) delete mode 100644 components/PasswordInput.tsx delete mode 100644 components/WebsiteFooter.tsx delete mode 100644 components/WorkspaceSwitcher.tsx delete mode 100644 components/dashboard/Countries.tsx delete mode 100644 components/dashboard/TopPages.tsx delete mode 100644 lib/utils/__tests__/errorHandler.test.ts delete mode 100644 lib/utils/errorHandler.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d6d634..fb52c0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- **App switcher and site icons now load correctly.** The Pulse, Drop, and Auth logos in the app switcher — and site favicons on the dashboard — were blocked by the browser's Content Security Policy. The policy now allows images from Ciphera's own domain and Google's favicon service, so all icons display as expected. +- **Safer campaign date handling.** Campaign analytics now use the same date validation as the rest of the app, including checks for invalid ranges and a maximum span of one year. Previously, campaigns used separate date parsing that skipped these checks. +- **Better crash protection in goals and real-time views.** Fixed an issue where the backend could crash under rare conditions when checking permissions for goals or real-time visitor data. The app now handles unexpected values gracefully instead of crashing. +- **Full React 19 type coverage.** Upgraded TypeScript type definitions to match the React 19 runtime. Previously, the type definitions lagged behind at React 18, which could hide bugs and miss new React 19 APIs during development. + +### Removed + +- **Cleaned up unused files.** Removed six leftover component and utility files that were no longer used anywhere in the app, along with dead backend code. This reduces clutter and keeps the codebase easier to navigate. + - **More reliable service health reporting.** The backend health check no longer falsely reports the service as unhealthy after sustained traffic. Previously, an internal counter grew over time and would eventually cross a fixed threshold — even under normal load — causing orchestrators to unnecessarily restart the service. - **Lower resource usage under load.** The backend now uses a single shared connection to Redis instead of opening dozens of separate ones. Previously, each rate limiter and internal component created its own connection pool, which could waste resources and risk hitting connection limits during heavy traffic. - **More reliable billing operations.** Billing actions like changing your plan, cancelling, and viewing invoices now benefit from the same automatic session refresh, request tracing, and error handling as the rest of the app. Previously, these used a separate internal path that could fail silently if your session expired mid-action. diff --git a/components/PasswordInput.tsx b/components/PasswordInput.tsx deleted file mode 100644 index a92ef38..0000000 --- a/components/PasswordInput.tsx +++ /dev/null @@ -1,109 +0,0 @@ -'use client' - -import { useState } from 'react' - -interface PasswordInputProps { - value: string - onChange: (value: string) => void - label?: string - placeholder?: string - error?: string | null - disabled?: boolean - required?: boolean - className?: string - id?: string - autoComplete?: string - minLength?: number - onFocus?: () => void - onBlur?: () => void -} - -export default function PasswordInput({ - value, - onChange, - label = 'Password', - placeholder = 'Enter password', - error, - disabled = false, - required = false, - className = '', - id, - autoComplete, - minLength, - onFocus, - onBlur -}: PasswordInputProps) { - const [showPassword, setShowPassword] = useState(false) - const inputId = id || 'password-input' - const errorId = `${inputId}-error` - - return ( -
- {label && ( - - )} -
- onChange(e.target.value)} - placeholder={placeholder} - disabled={disabled} - autoComplete={autoComplete} - minLength={minLength} - onFocus={onFocus} - onBlur={onBlur} - aria-invalid={!!error} - aria-describedby={error ? errorId : undefined} - className={`w-full pl-11 pr-12 py-3 border rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900 - transition-all duration-200 outline-none disabled:opacity-50 disabled:cursor-not-allowed dark:text-white - ${error - ? 'border-red-300 dark:border-red-800 focus:border-red-500 focus:ring-4 focus:ring-red-500/10' - : 'border-neutral-200 dark:border-neutral-800 hover:border-brand-orange/50 focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10' - }`} - /> - - {/* Lock Icon (Left) */} -
- -
- - {/* Toggle Visibility Button (Right) */} - -
- {error && ( - - )} -
- ) -} diff --git a/components/WebsiteFooter.tsx b/components/WebsiteFooter.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/components/WorkspaceSwitcher.tsx b/components/WorkspaceSwitcher.tsx deleted file mode 100644 index 6d8ff26..0000000 --- a/components/WorkspaceSwitcher.tsx +++ /dev/null @@ -1,116 +0,0 @@ -'use client' - -import { useState } from 'react' -import { useRouter } from 'next/navigation' -import { PlusIcon, PersonIcon, CubeIcon, CheckIcon } from '@radix-ui/react-icons' -import { switchContext, OrganizationMember } from '@/lib/api/organization' -import { setSessionAction } from '@/app/actions/auth' -import { logger } from '@/lib/utils/logger' -import Link from 'next/link' - -export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: OrganizationMember[], activeOrgId: string | null }) { - const router = useRouter() - const [switching, setSwitching] = useState(null) - - const handleSwitch = async (orgId: string | null) => { - setSwitching(orgId || 'personal') - try { - // * If orgId is null, we can't switch context via API in the same way if strict mode is on - // * Pulse doesn't support personal organization context. - // * So we should probably NOT show the "Personal" option in Pulse if strict mode is enforced. - // * However, to match Drop exactly, we might want to show it but have it fail or redirect? - // * Let's assume for now we want to match Drop's UI structure. - - if (!orgId) { - // * Pulse doesn't support personal context. - // * We could redirect to onboarding or show an error. - // * For now, let's just return to avoid breaking. - return - } - - const { access_token } = await switchContext(orgId) - - // * Update session cookie via server action - // * Note: switchContext only returns access_token, we keep existing refresh token - await setSessionAction(access_token) - - sessionStorage.setItem('pulse_switching_org', 'true') - window.location.reload() - - } catch (err) { - logger.error('Failed to switch organization', err) - setSwitching(null) - } - } - - return ( -
- - - {/* Personal organization - HIDDEN IN PULSE (Strict Mode) */} - {/* - - */} - - {/* Organization list */} - {orgs.map((org) => ( - - ))} - - {/* Create New */} - - - Create Organization - -
- ) -} diff --git a/components/dashboard/Countries.tsx b/components/dashboard/Countries.tsx deleted file mode 100644 index 21688b9..0000000 --- a/components/dashboard/Countries.tsx +++ /dev/null @@ -1,140 +0,0 @@ -'use client' - -import { useState } from 'react' -import { formatNumber } from '@ciphera-net/ui' -import * as Flags from 'country-flag-icons/react/3x2' -import WorldMap from './WorldMap' -import { GlobeIcon } from '@ciphera-net/ui' - -interface LocationProps { - countries: Array<{ country: string; pageviews: number }> - cities: Array<{ city: string; country: string; pageviews: number }> -} - -type Tab = 'countries' | 'cities' - -export default function Locations({ countries, cities }: LocationProps) { - const [activeTab, setActiveTab] = useState('countries') - - const getFlagComponent = (countryCode: string) => { - if (!countryCode || countryCode === 'Unknown') return null - // * The API returns 2-letter country codes (e.g. US, DE) - // * We cast it to the flag component name - const FlagComponent = (Flags as Record>)[countryCode] - return FlagComponent ? : null - } - - const getCountryName = (code: string) => { - if (!code || code === 'Unknown') return 'Unknown' - try { - const regionNames = new Intl.DisplayNames(['en'], { type: 'region' }) - return regionNames.of(code) || code - } catch (e) { - return code - } - } - - const renderContent = () => { - if (activeTab === 'countries') { - if (!countries || countries.length === 0) { - return ( -
-
- -
-

- No location data yet -

-

- Visitor locations will appear here based on anonymous geographic data. -

-
- ) - } - return ( -
- -
- {countries.map((country, index) => ( -
-
- {getFlagComponent(country.country)} - {getCountryName(country.country)} -
-
- {formatNumber(country.pageviews)} -
-
- ))} -
-
- ) - } - - if (activeTab === 'cities') { - if (!cities || cities.length === 0) { - return ( -
-
- -
-

- No city data yet -

-

- City-level visitor data will appear as traffic grows. -

-
- ) - } - return ( -
- {cities.map((city, index) => ( -
-
- {getFlagComponent(city.country)} - {city.city === 'Unknown' ? 'Unknown' : city.city} -
-
- {formatNumber(city.pageviews)} -
-
- ))} -
- ) - } - } - - return ( -
-
-

- Locations -

-
- - -
-
- {renderContent()} -
- ) -} diff --git a/components/dashboard/TopPages.tsx b/components/dashboard/TopPages.tsx deleted file mode 100644 index 6976105..0000000 --- a/components/dashboard/TopPages.tsx +++ /dev/null @@ -1,51 +0,0 @@ -'use client' - -import { formatNumber } from '@ciphera-net/ui' -import { LayoutDashboardIcon } from '@ciphera-net/ui' - -interface TopPagesProps { - pages: Array<{ path: string; pageviews: number }> -} - -export default function TopPages({ pages }: TopPagesProps) { - if (!pages || pages.length === 0) { - return ( -
-

- Top Pages -

-
-
- -
-

- No page data yet -

-

- Your most visited pages will appear here as traffic arrives. -

-
-
- ) - } - - return ( -
-

- Top Pages -

-
- {pages.map((page, index) => ( -
-
- {page.path} -
-
- {formatNumber(page.pageviews)} -
-
- ))} -
-
- ) -} diff --git a/lib/utils/__tests__/errorHandler.test.ts b/lib/utils/__tests__/errorHandler.test.ts deleted file mode 100644 index 0e5e4f6..0000000 --- a/lib/utils/__tests__/errorHandler.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { - getRequestIdFromError, - formatErrorMessage, - logErrorWithRequestId, - getSupportMessage, -} from '../errorHandler' -import { setLastRequestId, clearLastRequestId } from '../requestId' - -beforeEach(() => { - clearLastRequestId() -}) - -describe('getRequestIdFromError', () => { - it('extracts request ID from error response body', () => { - const errorData = { error: { request_id: 'REQ123_abc' } } - expect(getRequestIdFromError(errorData)).toBe('REQ123_abc') - }) - - it('falls back to last stored request ID when not in response', () => { - setLastRequestId('REQfallback_xyz') - expect(getRequestIdFromError({ error: {} })).toBe('REQfallback_xyz') - }) - - it('falls back to last stored request ID when no error data', () => { - setLastRequestId('REQfallback_xyz') - expect(getRequestIdFromError()).toBe('REQfallback_xyz') - }) - - it('returns null when no ID available anywhere', () => { - expect(getRequestIdFromError()).toBeNull() - }) -}) - -describe('formatErrorMessage', () => { - it('returns plain message when no request ID available', () => { - expect(formatErrorMessage('Something failed')).toBe('Something failed') - }) - - it('appends request ID in development mode', () => { - const original = process.env.NODE_ENV - process.env.NODE_ENV = 'development' - setLastRequestId('REQ123_abc') - - const msg = formatErrorMessage('Something failed') - expect(msg).toContain('Something failed') - expect(msg).toContain('REQ123_abc') - - process.env.NODE_ENV = original - }) - - it('appends request ID when showRequestId option is set', () => { - setLastRequestId('REQ123_abc') - const msg = formatErrorMessage('Something failed', undefined, { showRequestId: true }) - expect(msg).toContain('REQ123_abc') - }) -}) - -describe('logErrorWithRequestId', () => { - it('logs with request ID when available', () => { - const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) - setLastRequestId('REQ123_abc') - - logErrorWithRequestId('TestContext', new Error('fail')) - - expect(spy).toHaveBeenCalledWith( - expect.stringContaining('REQ123_abc'), - expect.any(Error) - ) - spy.mockRestore() - }) - - it('logs without request ID when not available', () => { - const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - logErrorWithRequestId('TestContext', new Error('fail')) - - expect(spy).toHaveBeenCalledWith('[TestContext]', expect.any(Error)) - spy.mockRestore() - }) -}) - -describe('getSupportMessage', () => { - it('includes request ID when available', () => { - const errorData = { error: { request_id: 'REQ123_abc' } } - const msg = getSupportMessage(errorData) - expect(msg).toContain('REQ123_abc') - expect(msg).toContain('contact support') - }) - - it('returns generic message when no request ID', () => { - const msg = getSupportMessage() - expect(msg).toBe('If this persists, please contact support.') - }) -}) diff --git a/lib/utils/__tests__/logger.test.ts b/lib/utils/__tests__/logger.test.ts index 4092a63..907b5b9 100644 --- a/lib/utils/__tests__/logger.test.ts +++ b/lib/utils/__tests__/logger.test.ts @@ -7,23 +7,25 @@ describe('logger', () => { it('calls console.error in development', async () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) - process.env.NODE_ENV = 'development' + vi.stubEnv('NODE_ENV', 'development') const { logger } = await import('../logger') logger.error('test error') expect(spy).toHaveBeenCalledWith('test error') spy.mockRestore() + vi.unstubAllEnvs() }) it('calls console.warn in development', async () => { const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - process.env.NODE_ENV = 'development' + vi.stubEnv('NODE_ENV', 'development') const { logger } = await import('../logger') logger.warn('test warning') expect(spy).toHaveBeenCalledWith('test warning') spy.mockRestore() + vi.unstubAllEnvs() }) }) diff --git a/lib/utils/errorHandler.ts b/lib/utils/errorHandler.ts deleted file mode 100644 index ee4a15b..0000000 --- a/lib/utils/errorHandler.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Error handling utilities with Request ID extraction - * Helps users report errors with traceable IDs for support - */ - -import { getLastRequestId } from './requestId' - -interface ApiErrorResponse { - error?: { - code?: string - message?: string - details?: unknown - request_id?: string - } -} - -/** - * Extract request ID from error response or use last known request ID - */ -export function getRequestIdFromError(errorData?: ApiErrorResponse): string | null { - // * Try to get from error response body - if (errorData?.error?.request_id) { - return errorData.error.request_id - } - - // * Fallback to last request ID stored during API call - return getLastRequestId() -} - -/** - * Format error message for display with optional request ID - * Shows request ID in development or for specific error types - */ -export function formatErrorMessage( - message: string, - errorData?: ApiErrorResponse, - options: { showRequestId?: boolean } = {} -): string { - const requestId = getRequestIdFromError(errorData) - - // * Always show request ID in development - const isDev = process.env.NODE_ENV === 'development' - - if (requestId && (isDev || options.showRequestId)) { - return `${message}\n\nRequest ID: ${requestId}` - } - - return message -} - -/** - * Log error with request ID for debugging - */ -export function logErrorWithRequestId( - context: string, - error: unknown, - errorData?: ApiErrorResponse -): void { - const requestId = getRequestIdFromError(errorData) - - if (requestId) { - console.error(`[${context}] Request ID: ${requestId}`, error) - } else { - console.error(`[${context}]`, error) - } -} - -/** - * Get support message with request ID for user reports - */ -export function getSupportMessage(errorData?: ApiErrorResponse): string { - const requestId = getRequestIdFromError(errorData) - - if (requestId) { - return `If this persists, contact support with Request ID: ${requestId}` - } - - return 'If this persists, please contact support.' -} diff --git a/next.config.ts b/next.config.ts index c111fc8..694203d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -12,7 +12,7 @@ const cspDirectives = [ // Next.js requires 'unsafe-inline' for its bootstrap scripts; 'unsafe-eval' only in dev (HMR) `script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''}`, "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob: https://www.google.com", + "img-src 'self' data: blob: https://www.google.com https://*.gstatic.com https://ciphera.net", "font-src 'self'", `connect-src 'self' https://*.ciphera.net https://cdn.jsdelivr.net${process.env.NODE_ENV === 'development' ? ' http://localhost:*' : ''}`, "worker-src 'self'", @@ -38,6 +38,10 @@ const nextConfig: NextConfig = { hostname: 'www.google.com', pathname: '/s2/favicons**', }, + { + protocol: 'https', + hostname: 'ciphera.net', + }, ], }, async headers() { diff --git a/package-lock.json b/package-lock.json index 7e8f495..2803960 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,8 +40,8 @@ "@testing-library/react": "^16.3.2", "@types/d3-scale": "^4.0.9", "@types/node": "^20.14.12", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@types/react-simple-maps": "^3.0.6", "@vitejs/plugin-react": "^5.1.4", "autoprefixer": "^10.4.19", @@ -4225,12 +4225,6 @@ "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", "license": "MIT" }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" - }, "node_modules/@types/raf": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", @@ -4239,25 +4233,24 @@ "optional": true }, "node_modules/@types/react": { - "version": "18.3.28", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", - "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", "peer": true, "dependencies": { - "@types/prop-types": "*", "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", "peer": true, "peerDependencies": { - "@types/react": "^18.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/react-simple-maps": { @@ -9115,7 +9108,6 @@ "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", diff --git a/package.json b/package.json index 85611e6..992a20e 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,8 @@ "@testing-library/react": "^16.3.2", "@types/d3-scale": "^4.0.9", "@types/node": "^20.14.12", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@types/react-simple-maps": "^3.0.6", "@vitejs/plugin-react": "^5.1.4", "autoprefixer": "^10.4.19", -- 2.49.1 From fba1fd99c2ee47f356eb6b812f0d3c70aa5bfa04 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 15:44:10 +0100 Subject: [PATCH 08/22] fix: add favicon domains to connect-src for service worker compatibility The PWA service worker (workbox) fetches images via the Fetch API, which is governed by connect-src, not img-src. Add www.google.com, *.gstatic.com, and ciphera.net to connect-src so favicon and app icon fetches succeed. --- next.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next.config.ts b/next.config.ts index 694203d..25cc58f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -14,7 +14,7 @@ const cspDirectives = [ "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob: https://www.google.com https://*.gstatic.com https://ciphera.net", "font-src 'self'", - `connect-src 'self' https://*.ciphera.net https://cdn.jsdelivr.net${process.env.NODE_ENV === 'development' ? ' http://localhost:*' : ''}`, + `connect-src 'self' https://*.ciphera.net https://ciphera.net https://www.google.com https://*.gstatic.com https://cdn.jsdelivr.net${process.env.NODE_ENV === 'development' ? ' http://localhost:*' : ''}`, "worker-src 'self'", "frame-src 'none'", "object-src 'none'", -- 2.49.1 From baceb6e8a8927a26ccacee8d3ac797eabcf14bb4 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 17:51:01 +0100 Subject: [PATCH 09/22] docs: add funnel stats fix to changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb52c0a..c2362d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- **Tracking script now works on all tracked websites.** Page views were silently failing to record when the tracking script ran on your website. The backend was incorrectly rejecting analytics data sent from tracked sites. Your dashboard now receives visits from all registered domains as expected. +- **Funnel details now load correctly.** Opening a funnel showed "Unable to load funnel" with a server error. An internal query was referencing data that no longer existed at that stage of processing, causing it to fail every time. Funnels now load and display step-by-step conversion data as expected. - **App switcher and site icons now load correctly.** The Pulse, Drop, and Auth logos in the app switcher — and site favicons on the dashboard — were blocked by the browser's Content Security Policy. The policy now allows images from Ciphera's own domain and Google's favicon service, so all icons display as expected. - **Safer campaign date handling.** Campaign analytics now use the same date validation as the rest of the app, including checks for invalid ranges and a maximum span of one year. Previously, campaigns used separate date parsing that skipped these checks. - **Better crash protection in goals and real-time views.** Fixed an issue where the backend could crash under rare conditions when checking permissions for goals or real-time visitor data. The app now handles unexpected values gracefully instead of crashing. -- 2.49.1 From 3ecd2abf63001b946477f53467c5bc626648b49c Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 17:55:25 +0100 Subject: [PATCH 10/22] docs: update changelog for event ingestion fix --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2362d8..1ac6947 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed -- **Tracking script now works on all tracked websites.** Page views were silently failing to record when the tracking script ran on your website. The backend was incorrectly rejecting analytics data sent from tracked sites. Your dashboard now receives visits from all registered domains as expected. +- **Tracking script now works on all tracked websites.** Page views were silently failing to record when the tracking script ran on your website. Two issues were at play: the backend was rejecting analytics data sent from tracked sites, and even after that was resolved, events were silently dropped during processing because they were missing a required identifier. Both are now fixed — your dashboard receives visits from all registered domains as expected. - **Funnel details now load correctly.** Opening a funnel showed "Unable to load funnel" with a server error. An internal query was referencing data that no longer existed at that stage of processing, causing it to fail every time. Funnels now load and display step-by-step conversion data as expected. - **App switcher and site icons now load correctly.** The Pulse, Drop, and Auth logos in the app switcher — and site favicons on the dashboard — were blocked by the browser's Content Security Policy. The policy now allows images from Ciphera's own domain and Google's favicon service, so all icons display as expected. - **Safer campaign date handling.** Campaign analytics now use the same date validation as the rest of the app, including checks for invalid ranges and a maximum span of one year. Previously, campaigns used separate date parsing that skipped these checks. -- 2.49.1 From 67c9bdd3e00927580b402c029860d6466b5cc1e9 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 18:07:22 +0100 Subject: [PATCH 11/22] docs: add realtime rate limit fix to changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac6947..cde1b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed - **Tracking script now works on all tracked websites.** Page views were silently failing to record when the tracking script ran on your website. Two issues were at play: the backend was rejecting analytics data sent from tracked sites, and even after that was resolved, events were silently dropped during processing because they were missing a required identifier. Both are now fixed — your dashboard receives visits from all registered domains as expected. +- **Real-time visitor count no longer stops updating.** The dashboard's live visitor counter would quickly hit a rate limit and stop refreshing, showing "Site not found" errors. The limit was too low for normal polling, especially with multiple tabs open. It now has enough headroom for typical usage. - **Funnel details now load correctly.** Opening a funnel showed "Unable to load funnel" with a server error. An internal query was referencing data that no longer existed at that stage of processing, causing it to fail every time. Funnels now load and display step-by-step conversion data as expected. - **App switcher and site icons now load correctly.** The Pulse, Drop, and Auth logos in the app switcher — and site favicons on the dashboard — were blocked by the browser's Content Security Policy. The policy now allows images from Ciphera's own domain and Google's favicon service, so all icons display as expected. - **Safer campaign date handling.** Campaign analytics now use the same date validation as the rest of the app, including checks for invalid ranges and a maximum span of one year. Previously, campaigns used separate date parsing that skipped these checks. -- 2.49.1 From 8a7076ee1b53e76fde8df3c91d291ee273b2dfb6 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 18:42:14 +0100 Subject: [PATCH 12/22] refactor: migrate dashboard to SWR hooks, eliminate all any[] state Replace 22 manual useState + useEffect + setInterval polling with 11 focused SWR hooks. Removes ~85 lines of polling/visibility logic that SWR handles natively. All any[] types replaced with proper interfaces (TopPage, CountryStat, BrowserStat, etc.). Organization state in layout typed as OrganizationMember[]. Resolves F-7, F-8, F-15 from audit report. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 5 + app/layout-content.tsx | 4 +- app/sites/[id]/page.tsx | 355 ++++++++++++++-------------------------- lib/swr/dashboard.ts | 25 ++- 4 files changed, 152 insertions(+), 237 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cde1b25..cfd9f9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Improved + +- **Faster, smarter dashboard data loading.** Your dashboard now loads each section independently using an intelligent caching strategy. Data refreshes happen automatically in the background, and when you switch tabs the app pauses updates to save resources — resuming instantly when you return. This replaces the previous approach where everything loaded in one large batch, meaning your charts, visitor maps, and stats now appear faster and update more reliably. +- **Better data accuracy across the dashboard.** All data displayed on the dashboard — pages, locations, devices, referrers, performance metrics, and goals — is now fully typed end-to-end. This eliminates an entire class of potential display bugs where data could be misinterpreted between the server and your screen. + ### Fixed - **Tracking script now works on all tracked websites.** Page views were silently failing to record when the tracking script ran on your website. Two issues were at play: the backend was rejecting analytics data sent from tracked sites, and even after that was resolved, events were silently dropped during processing because they were missing a required identifier. Both are now fixed — your dashboard receives visits from all registered domains as expected. diff --git a/app/layout-content.tsx b/app/layout-content.tsx index be8772c..8ef4acd 100644 --- a/app/layout-content.tsx +++ b/app/layout-content.tsx @@ -9,7 +9,7 @@ import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus' import Link from 'next/link' import { useEffect, useState } from 'react' import { logger } from '@/lib/utils/logger' -import { getUserOrganizations, switchContext } from '@/lib/api/organization' +import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization' import { setSessionAction } from '@/app/actions/auth' import { LoadingOverlay } from '@ciphera-net/ui' import { useRouter } from 'next/navigation' @@ -48,7 +48,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode const auth = useAuth() const router = useRouter() const isOnline = useOnlineStatus() - const [orgs, setOrgs] = useState([]) + const [orgs, setOrgs] = useState([]) const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => { if (typeof window === 'undefined') return false return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true' diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 56f73fd..31ace64 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -2,15 +2,13 @@ import { useAuth } from '@/lib/auth/context' import { logger } from '@/lib/utils/logger' -import { useCallback, useEffect, useState, useRef } from 'react' +import { useEffect, useState, useMemo } from 'react' import { useParams, useRouter } from 'next/navigation' import { motion } from 'framer-motion' -import { getSite, type Site } from '@/lib/api/sites' -import { getStats, getRealtime, getDailyStats, getTopPages, getTopReferrers, getCountries, getCities, getRegions, getBrowsers, getOS, getDevices, getScreenResolutions, getEntryPages, getExitPages, getDashboard, getCampaigns, getPerformanceByPage, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats' -import { formatNumber, formatDuration, getDateRange } from '@ciphera-net/ui' +import { getPerformanceByPage, type Stats, type DailyStat } from '@/lib/api/stats' +import { getDateRange } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui' -import { getAuthErrorMessage } from '@ciphera-net/ui' -import { LoadingOverlay, Button } from '@ciphera-net/ui' +import { Button } from '@ciphera-net/ui' import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui' import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons' import ExportModal from '@/components/dashboard/ExportModal' @@ -22,6 +20,45 @@ import Chart from '@/components/dashboard/Chart' import PerformanceStats from '@/components/dashboard/PerformanceStats' import GoalStats from '@/components/dashboard/GoalStats' import Campaigns from '@/components/dashboard/Campaigns' +import { + useDashboardOverview, + useDashboardPages, + useDashboardLocations, + useDashboardDevices, + useDashboardReferrers, + useDashboardPerformance, + useDashboardGoals, + useRealtime, + useStats, + useDailyStats, + useCampaigns, +} from '@/lib/swr/dashboard' + +function loadSavedSettings(): { + type?: string + dateRange?: { start: string; end: string } + todayInterval?: 'minute' | 'hour' + multiDayInterval?: 'hour' | 'day' +} | null { + if (typeof window === 'undefined') return null + try { + const saved = localStorage.getItem('pulse_dashboard_settings') + return saved ? JSON.parse(saved) : null + } catch { + return null + } +} + +function getInitialDateRange(): { start: string; end: string } { + const settings = loadSavedSettings() + if (settings?.type === 'today') { + const today = new Date().toISOString().split('T')[0] + return { start: today, end: today } + } + if (settings?.type === '7') return getDateRange(7) + if (settings?.type === 'custom' && settings.dateRange) return settings.dateRange + return getDateRange(30) +} export default function SiteDashboardPage() { const { user } = useAuth() @@ -31,69 +68,75 @@ export default function SiteDashboardPage() { const router = useRouter() const siteId = params.id as string - const [site, setSite] = useState(null) - const [loading, setLoading] = useState(true) - const [stats, setStats] = useState({ pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 }) - const [prevStats, setPrevStats] = useState(undefined) - const [realtime, setRealtime] = useState(0) - const [dailyStats, setDailyStats] = useState([]) - const [prevDailyStats, setPrevDailyStats] = useState(undefined) - const [topPages, setTopPages] = useState([]) - const [entryPages, setEntryPages] = useState([]) - const [exitPages, setExitPages] = useState([]) - const [topReferrers, setTopReferrers] = useState([]) - const [countries, setCountries] = useState([]) - const [cities, setCities] = useState([]) - const [regions, setRegions] = useState([]) - const [browsers, setBrowsers] = useState([]) - const [os, setOS] = useState([]) - const [devices, setDevices] = useState([]) - const [screenResolutions, setScreenResolutions] = useState([]) - const [performance, setPerformance] = useState<{ lcp: number, cls: number, inp: number }>({ lcp: 0, cls: 0, inp: 0 }) - const [performanceByPage, setPerformanceByPage] = useState(null) - const [goalCounts, setGoalCounts] = useState>([]) - const [campaigns, setCampaigns] = useState([]) - const [dateRange, setDateRange] = useState(getDateRange(30)) + // UI state - initialized from localStorage synchronously to avoid double-fetch + const [dateRange, setDateRange] = useState(getInitialDateRange) + const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>( + () => loadSavedSettings()?.todayInterval || 'hour' + ) + const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>( + () => loadSavedSettings()?.multiDayInterval || 'day' + ) const [isDatePickerOpen, setIsDatePickerOpen] = useState(false) const [isExportModalOpen, setIsExportModalOpen] = useState(false) - const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour') - const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>('day') - const [isSettingsLoaded, setIsSettingsLoaded] = useState(false) const [lastUpdatedAt, setLastUpdatedAt] = useState(null) const [, setTick] = useState(0) - // Load settings from localStorage - useEffect(() => { - try { - const savedSettings = localStorage.getItem('pulse_dashboard_settings') - if (savedSettings) { - const settings = JSON.parse(savedSettings) - - // Restore date range - if (settings.type === 'today') { - const today = new Date().toISOString().split('T')[0] - setDateRange({ start: today, end: today }) - } else if (settings.type === '7') { - setDateRange(getDateRange(7)) - } else if (settings.type === '30') { - setDateRange(getDateRange(30)) - } else if (settings.type === 'custom' && settings.dateRange) { - setDateRange(settings.dateRange) - } + const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval - // Restore intervals - if (settings.todayInterval) setTodayInterval(settings.todayInterval) - if (settings.multiDayInterval) setMultiDayInterval(settings.multiDayInterval) - } - } catch (e) { - logger.error('Failed to load dashboard settings', e) - } finally { - setIsSettingsLoaded(true) + // Previous period date range for comparison + const prevRange = useMemo(() => { + const startDate = new Date(dateRange.start) + const endDate = new Date(dateRange.end) + const duration = endDate.getTime() - startDate.getTime() + if (duration === 0) { + const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000) + return { start: prevEnd.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] } } + const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000) + const prevStart = new Date(prevEnd.getTime() - duration) + return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] } + }, [dateRange]) + + // SWR hooks - replace manual useState + useEffect + setInterval polling + // Each hook handles its own refresh interval, deduplication, and error retry + const { data: overview, isLoading: overviewLoading, error: overviewError } = useDashboardOverview(siteId, dateRange.start, dateRange.end, interval) + const { data: pages } = useDashboardPages(siteId, dateRange.start, dateRange.end) + const { data: locations } = useDashboardLocations(siteId, dateRange.start, dateRange.end) + const { data: devicesData } = useDashboardDevices(siteId, dateRange.start, dateRange.end) + const { data: referrers } = useDashboardReferrers(siteId, dateRange.start, dateRange.end) + const { data: performanceData } = useDashboardPerformance(siteId, dateRange.start, dateRange.end) + const { data: goalsData } = useDashboardGoals(siteId, dateRange.start, dateRange.end) + const { data: realtimeData } = useRealtime(siteId) + const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end) + const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval) + const { data: campaigns } = useCampaigns(siteId, dateRange.start, dateRange.end) + + // Derive typed values from SWR data + const site = overview?.site ?? null + const stats: Stats = overview?.stats ?? { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 } + const realtime = realtimeData?.visitors ?? overview?.realtime_visitors ?? 0 + const dailyStats: DailyStat[] = overview?.daily_stats ?? [] + + // Show error toast on fetch failure + useEffect(() => { + if (overviewError) { + toast.error('Failed to load dashboard analytics') + } + }, [overviewError]) + + // Track when data was last updated (for "Live · Xs ago" display) + useEffect(() => { + if (overview) setLastUpdatedAt(Date.now()) + }, [overview]) + + // Tick every 1s so "Live · Xs ago" counts in real time + useEffect(() => { + const timer = setInterval(() => setTick((t) => t + 1), 1000) + return () => clearInterval(timer) }, []) // Save settings to localStorage - const saveSettings = (type: string, newDateRange?: { start: string, end: string }) => { + const saveSettings = (type: string, newDateRange?: { start: string; end: string }) => { try { const settings = { type, @@ -110,9 +153,6 @@ export default function SiteDashboardPage() { // Save intervals when they change useEffect(() => { - if (!isSettingsLoaded) return - - // Determine current type let type = 'custom' const today = new Date().toISOString().split('T')[0] if (dateRange.start === today && dateRange.end === today) type = 'today' @@ -127,160 +167,13 @@ export default function SiteDashboardPage() { lastUpdated: Date.now() } localStorage.setItem('pulse_dashboard_settings', JSON.stringify(settings)) - }, [todayInterval, multiDayInterval, isSettingsLoaded]) // dateRange is handled in saveSettings/onChange - - // * Tick every 1s so "Live · Xs ago" counts in real time - useEffect(() => { - const interval = setInterval(() => setTick((t) => t + 1), 1000) - return () => clearInterval(interval) - }, []) - - const getPreviousDateRange = useCallback((start: string, end: string) => { - const startDate = new Date(start) - const endDate = new Date(end) - const duration = endDate.getTime() - startDate.getTime() - if (duration === 0) { - const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000) - return { start: prevEnd.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] } - } - const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000) - const prevStart = new Date(prevEnd.getTime() - duration) - return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] } - }, []) - - // * Visibility-aware polling intervals - // * Historical data: 60s when visible, paused when hidden - // * Real-time data: 5s when visible, 30s when hidden - const [isVisible, setIsVisible] = useState(true) - const dashboardIntervalRef = useRef(null) - const realtimeIntervalRef = useRef(null) - - // * Track visibility state - useEffect(() => { - const handleVisibilityChange = () => { - const visible = document.visibilityState === 'visible' - setIsVisible(visible) - } - document.addEventListener('visibilitychange', handleVisibilityChange) - return () => document.removeEventListener('visibilitychange', handleVisibilityChange) - }, []) - - const loadData = useCallback(async (silent = false) => { - try { - if (!silent) setLoading(true) - const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval - - const [data, prevStatsData, prevDailyStatsData, campaignsData] = await Promise.all([ - getDashboard(siteId, dateRange.start, dateRange.end, 10, interval), - (async () => { - const prevRange = getPreviousDateRange(dateRange.start, dateRange.end) - return getStats(siteId, prevRange.start, prevRange.end) - })(), - (async () => { - const prevRange = getPreviousDateRange(dateRange.start, dateRange.end) - return getDailyStats(siteId, prevRange.start, prevRange.end, interval) - })(), - getCampaigns(siteId, dateRange.start, dateRange.end, 100), - ]) - - setSite(data.site) - setStats(data.stats || { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 }) - setRealtime(data.realtime_visitors || 0) - setDailyStats(Array.isArray(data.daily_stats) ? data.daily_stats : []) - - setPrevStats(prevStatsData) - setPrevDailyStats(prevDailyStatsData) - - setTopPages(Array.isArray(data.top_pages) ? data.top_pages : []) - setEntryPages(Array.isArray(data.entry_pages) ? data.entry_pages : []) - setExitPages(Array.isArray(data.exit_pages) ? data.exit_pages : []) - setTopReferrers(Array.isArray(data.top_referrers) ? data.top_referrers : []) - setCountries(Array.isArray(data.countries) ? data.countries : []) - setCities(Array.isArray(data.cities) ? data.cities : []) - setRegions(Array.isArray(data.regions) ? data.regions : []) - setBrowsers(Array.isArray(data.browsers) ? data.browsers : []) - setOS(Array.isArray(data.os) ? data.os : []) - setDevices(Array.isArray(data.devices) ? data.devices : []) - setScreenResolutions(Array.isArray(data.screen_resolutions) ? data.screen_resolutions : []) - setPerformance(data.performance || { lcp: 0, cls: 0, inp: 0 }) - setPerformanceByPage(data.performance_by_page ?? null) - setGoalCounts(Array.isArray(data.goal_counts) ? data.goal_counts : []) - setCampaigns(Array.isArray(campaignsData) ? campaignsData : []) - setLastUpdatedAt(Date.now()) - } catch (error: unknown) { - if (!silent) { - toast.error(getAuthErrorMessage(error) || 'Failed to load dashboard analytics') - } - } finally { - if (!silent) setLoading(false) - } - }, [siteId, dateRange, todayInterval, multiDayInterval]) - - const loadRealtime = useCallback(async () => { - try { - const data = await getRealtime(siteId) - setRealtime(data.visitors) - } catch (error) { - // * Silently fail for realtime updates - } - }, [siteId]) - - // * Visibility-aware polling for dashboard data (historical) - // * Refreshes every 60 seconds when tab is visible, pauses when hidden - useEffect(() => { - if (!isSettingsLoaded) return - - // * Initial load - loadData() - - // * Clear existing interval - if (dashboardIntervalRef.current) { - clearInterval(dashboardIntervalRef.current) - } - - // * Only poll when visible (saves server resources when tab is backgrounded) - if (isVisible) { - dashboardIntervalRef.current = setInterval(() => { - loadData(true) - }, 60000) // * 60 seconds for historical data - } - - return () => { - if (dashboardIntervalRef.current) { - clearInterval(dashboardIntervalRef.current) - } - } - }, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, isVisible]) - - // * Visibility-aware polling for realtime data - // * Refreshes every 5 seconds when visible, every 30 seconds when hidden - useEffect(() => { - if (!isSettingsLoaded) return - - // * Clear existing interval - if (realtimeIntervalRef.current) { - clearInterval(realtimeIntervalRef.current) - } - - // * Different intervals based on visibility - const interval = isVisible ? 5000 : 30000 // * 5s visible, 30s hidden - - realtimeIntervalRef.current = setInterval(() => { - loadRealtime() - }, interval) - - return () => { - if (realtimeIntervalRef.current) { - clearInterval(realtimeIntervalRef.current) - } - } - }, [siteId, isSettingsLoaded, loadRealtime, isVisible]) + }, [todayInterval, multiDayInterval]) // eslint-disable-line react-hooks/exhaustive-deps -- dateRange saved via saveSettings useEffect(() => { if (site?.domain) document.title = `${site.domain} | Pulse` }, [site?.domain]) - const showSkeleton = useMinimumLoading(loading) + const showSkeleton = useMinimumLoading(overviewLoading) if (showSkeleton) { return @@ -312,7 +205,7 @@ export default function SiteDashboardPage() { {site.domain}

- + {/* Realtime Indicator */}