perf: eliminate demo account switch lag - no more page reload, parallel fetches, settings cache
This commit is contained in:
@@ -20,7 +20,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { userId, user, globalSettings, switchUser, logout, hasAccessTo, loading, trialInfo } = useActiveUser();
|
||||
const { userId, user, globalSettings, switchUser, logout, hasAccessTo, loading, switching, trialInfo } = useActiveUser();
|
||||
const [maintenance, setMaintenance] = useState(false);
|
||||
const [checkingMaintenance, setCheckingMaintenance] = useState(true);
|
||||
|
||||
@@ -75,6 +75,22 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background overflow-hidden text-foreground selection:bg-primary/20">
|
||||
{/* Demo Account Switch — slim top progress bar, no full-page reload */}
|
||||
{switching && (
|
||||
<div className="fixed top-0 left-0 right-0 z-[9999] h-0.5 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#00F0FF] animate-[slideProgress_0.8s_ease-in-out_infinite]"
|
||||
style={{ width: '60%', animation: 'slideProgress 0.9s ease-in-out infinite' }}
|
||||
/>
|
||||
<style>{`
|
||||
@keyframes slideProgress {
|
||||
0% { transform: translateX(-100%); width: 60%; }
|
||||
50% { transform: translateX(70%); width: 80%; }
|
||||
100% { transform: translateX(200%); width: 60%; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)}
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 border-r border-border/60 bg-background/95 backdrop-blur-xl flex flex-col hidden md:flex relative">
|
||||
{/* Subtle gradient overlay */}
|
||||
@@ -350,11 +366,17 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
{/* Demo Switcher — only visible when logged in as a known demo account */}
|
||||
{isDemoUser(userId) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground font-bold hidden lg:inline">Demo:</span>
|
||||
{switching && (
|
||||
<span className="text-xs text-[#00F0FF] font-bold animate-pulse hidden lg:inline">Switching…</span>
|
||||
)}
|
||||
{!switching && (
|
||||
<span className="text-xs text-muted-foreground font-bold hidden lg:inline">Demo:</span>
|
||||
)}
|
||||
<select
|
||||
value={userId}
|
||||
disabled={switching}
|
||||
onChange={(e) => switchUser(e.target.value)}
|
||||
className="bg-secondary border border-border text-foreground px-2.5 py-1.5 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#00F0FF] outline-none cursor-pointer hover:bg-secondary/80 transition-colors"
|
||||
className="bg-secondary border border-border text-foreground px-2.5 py-1.5 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#00F0FF] outline-none cursor-pointer hover:bg-secondary/80 transition-colors disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
<option value={DEMO_USERS.NEHA.id}>Neha Verma (AI Coach)</option>
|
||||
<option value={DEMO_USERS.AI_COACH.id}>Rahul (AI Coach)</option>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
@@ -99,6 +99,29 @@ export function computeTrialInfo(user: UserProfile | null, globalSettings: any):
|
||||
};
|
||||
}
|
||||
|
||||
// ── Global in-memory settings cache ─────────────────────────────────────────
|
||||
// Settings rarely change; cache them for 60s so every user switch doesn't
|
||||
// re-hit the backend. On a full page load the cache is cold — fine.
|
||||
let cachedSettings: any = null;
|
||||
let settingsCacheExpiry = 0;
|
||||
|
||||
async function fetchSettings(): Promise<any> {
|
||||
const now = Date.now();
|
||||
if (cachedSettings && now < settingsCacheExpiry) return cachedSettings;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/settings`, { cache: "no-store" });
|
||||
if (res.ok) {
|
||||
cachedSettings = await res.json();
|
||||
settingsCacheExpiry = now + 60_000; // cache for 60 seconds
|
||||
return cachedSettings;
|
||||
}
|
||||
} catch {
|
||||
// silently return stale cache if available
|
||||
}
|
||||
return cachedSettings ?? null;
|
||||
}
|
||||
|
||||
export function useActiveUser() {
|
||||
const [userId, setUserIdState] = useState<string>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -110,16 +133,24 @@ export function useActiveUser() {
|
||||
const [user, setUser] = useState<UserProfile | null>(null);
|
||||
const [globalSettings, setGlobalSettings] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const failCountRef = { current: 0 };
|
||||
// Use a ref for fail count so it doesn't cause re-renders
|
||||
const failCountRef = useRef(0);
|
||||
// Track the active userId in a ref to avoid stale closures in poll interval
|
||||
const activeUserIdRef = useRef(userId);
|
||||
|
||||
const fetchUserProfile = useCallback(async (id: string, isPoll: boolean = false) => {
|
||||
if (!isPoll) setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/users/${id}`);
|
||||
const settingsRes = await fetch(`/api/admin/settings`, { cache: "no-store" });
|
||||
|
||||
// ── PERF FIX: run both fetches in PARALLEL instead of sequentially ──
|
||||
const [res, settingsData] = await Promise.all([
|
||||
fetch(`/api/users/${id}`),
|
||||
fetchSettings(),
|
||||
]);
|
||||
|
||||
if (!res.ok) {
|
||||
if (!isPoll) {
|
||||
setError("Failed to fetch user profile");
|
||||
@@ -127,13 +158,13 @@ export function useActiveUser() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const data = await res.json();
|
||||
if (settingsRes.ok) {
|
||||
const settingsData = await settingsRes.json();
|
||||
|
||||
if (settingsData) {
|
||||
setGlobalSettings(settingsData);
|
||||
}
|
||||
|
||||
|
||||
failCountRef.current = 0;
|
||||
|
||||
// Single Active Session Check
|
||||
@@ -150,8 +181,7 @@ export function useActiveUser() {
|
||||
|
||||
setUser(data);
|
||||
} catch (err: any) {
|
||||
// Network error (backend down / restarting) — only surface on initial load
|
||||
failCountRef.current = (failCountRef.current || 0) + 1;
|
||||
failCountRef.current += 1;
|
||||
if (!isPoll) {
|
||||
console.warn("Backend unreachable, will retry:", err.message);
|
||||
setError("Backend unreachable — retrying...");
|
||||
@@ -162,55 +192,80 @@ export function useActiveUser() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial fetch
|
||||
// Initial fetch on mount
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const storedId = localStorage.getItem("activeUserId") || DEFAULT_USER_ID;
|
||||
activeUserIdRef.current = storedId;
|
||||
setUserIdState(storedId);
|
||||
fetchUserProfile(storedId);
|
||||
}
|
||||
}, [fetchUserProfile]);
|
||||
|
||||
// Session validity polling — only runs when we already have a user
|
||||
// Session validity polling — runs against the ref so switching userId
|
||||
// doesn't restart the interval and cause an immediate double-fetch
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const handleUpdate = () => fetchUserProfile(userId);
|
||||
const handleUpdate = () => fetchUserProfile(activeUserIdRef.current);
|
||||
window.addEventListener("pragativeer-user-updated", handleUpdate);
|
||||
|
||||
// Poll every 30s (was 10s — less aggressive, kinder on backend restarts)
|
||||
// Poll every 30s (less aggressive, kinder on backend)
|
||||
const interval = setInterval(() => {
|
||||
fetchUserProfile(userId, true);
|
||||
}, 30000);
|
||||
fetchUserProfile(activeUserIdRef.current, true);
|
||||
}, 30_000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pragativeer-user-updated", handleUpdate);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [userId, fetchUserProfile]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Only run once — ref keeps the latest userId
|
||||
|
||||
const switchUser = useCallback(async (newId: string) => {
|
||||
if (typeof window !== "undefined") {
|
||||
document.cookie = `auth_token=${newId}; path=/; max-age=2592000; SameSite=Lax`;
|
||||
const sessionToken = crypto.randomUUID();
|
||||
localStorage.setItem("active_session_token", sessionToken);
|
||||
|
||||
try {
|
||||
await fetch(`/api/users/${newId}/session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_token: sessionToken })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to set session", err);
|
||||
if (typeof window === "undefined" || newId === activeUserIdRef.current) return;
|
||||
|
||||
setSwitching(true);
|
||||
|
||||
// ── Update cookie + localStorage immediately (optimistic) ───────────────
|
||||
document.cookie = `auth_token=${newId}; path=/; max-age=2592000; SameSite=Lax`;
|
||||
const sessionToken = crypto.randomUUID();
|
||||
localStorage.setItem("active_session_token", sessionToken);
|
||||
localStorage.setItem("activeUserId", newId);
|
||||
|
||||
// Update refs and state before awaiting network
|
||||
activeUserIdRef.current = newId;
|
||||
setUserIdState(newId);
|
||||
setUser(null); // Clear old user immediately so stale data isn't shown
|
||||
|
||||
try {
|
||||
// ── Run session update + new user profile fetch IN PARALLEL ─────────
|
||||
const [_, newUserRes] = await Promise.all([
|
||||
// Fire-and-forget session token update
|
||||
fetch(`/api/users/${newId}/session`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_token: sessionToken }),
|
||||
}).catch(() => {}), // ignore failure — non-critical
|
||||
// Fetch the new user's profile immediately
|
||||
fetch(`/api/users/${newId}`),
|
||||
]);
|
||||
|
||||
if (newUserRes && newUserRes.ok) {
|
||||
const data = await newUserRes.json();
|
||||
setUser(data);
|
||||
} else {
|
||||
// Fallback: use the regular fetch path
|
||||
await fetchUserProfile(newId);
|
||||
}
|
||||
|
||||
localStorage.setItem("activeUserId", newId);
|
||||
setUserIdState(newId);
|
||||
window.location.reload();
|
||||
} catch {
|
||||
// Network error — fall back to full fetch
|
||||
await fetchUserProfile(newId);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
}, []);
|
||||
// ── NO window.location.reload() — React state update is instant ─────────
|
||||
}, [fetchUserProfile]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -223,11 +278,11 @@ export function useActiveUser() {
|
||||
|
||||
const hasAccessTo = useCallback((feature: string): boolean => {
|
||||
if (!user) return false;
|
||||
|
||||
|
||||
// Compute effective tier via trial logic
|
||||
const { effectiveTier } = computeTrialInfo(user, globalSettings);
|
||||
const tier = effectiveTier;
|
||||
|
||||
|
||||
// Core restrictions mapping based on PRD feature matrix
|
||||
switch (feature) {
|
||||
case "topic-wise-microtests-unlimited":
|
||||
@@ -241,7 +296,7 @@ export function useActiveUser() {
|
||||
case "offline-mode":
|
||||
case "pdf-export":
|
||||
return tier === "Pro" || tier === "Al_Coach";
|
||||
|
||||
|
||||
case "ai-coach-custom-schedules":
|
||||
case "personalized-study-plan":
|
||||
case "ai-tutor-doubt-resolution":
|
||||
@@ -249,7 +304,7 @@ export function useActiveUser() {
|
||||
case "ai-readiness-prediction":
|
||||
case "ai-tutor-chat":
|
||||
return tier === "Al_Coach";
|
||||
|
||||
|
||||
default:
|
||||
// Free tier features: DPS (1/day), basic dashboard, basic streak tracking, leaderboard
|
||||
return true;
|
||||
@@ -261,6 +316,7 @@ export function useActiveUser() {
|
||||
user,
|
||||
globalSettings,
|
||||
loading,
|
||||
switching, // expose switching state so layout can show a brief spinner
|
||||
error,
|
||||
switchUser,
|
||||
logout,
|
||||
@@ -268,7 +324,7 @@ export function useActiveUser() {
|
||||
trialInfo: computeTrialInfo(user, globalSettings),
|
||||
refreshUser: async () => {
|
||||
await fetchUserProfile(userId);
|
||||
window.dispatchEvent(new CustomEvent('pragativeer-user-updated'));
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("pragativeer-user-updated"));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user