Skip to content

Commit 249135e

Browse files
committed
feat(dashboard): overhaul performance with caching and loading optimizations
- Implemented `useCachedFetch` for all dashboard pages to enable instant data retrieval from sessionStorage and background revalidation. - Replaced `useEffect` data fetching with `useCachedFetch` in `dashboard`, `overview`, `logs`, `settings`, and `diagnostics` pages. - Added a loading skeleton component for the dashboard route group to enhance user experience during data fetching. - Optimized `/api/dashboard/stats` to reduce query count from 16 to 1 for daily counts and improved contacts counting using `groupBy`. - Introduced server-side caching for slow API endpoints like `/api/instagram/overview` and `/api/instagram/posts` to minimize latency on return visits. - Backfilled missing report slugs in `/api/automations` using a single transaction to improve performance. - Lazy-loaded heavy chart components to reduce initial bundle size for the overview page. - Added cache control headers to various API responses to improve client-side caching behavior.
1 parent 378f980 commit 249135e

16 files changed

Lines changed: 531 additions & 239 deletions

File tree

app/(dashboard)/campaigns/[id]/page.tsx

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
* live in the top bar.
99
*/
1010

11-
import { useEffect, useState } from "react";
11+
import { useEffect, useMemo, useState } from "react";
1212
import { useParams, useRouter } from "next/navigation";
1313
import Link from "next/link";
1414
import CampaignPreview, { type PreviewTab } from "@/components/campaign-preview";
15+
import { readCache, useCachedFetch, writeCache } from "@/lib/client-cache";
1516

1617
interface Campaign {
1718
id: string;
@@ -60,27 +61,49 @@ export default function CampaignDetailPage() {
6061
const router = useRouter();
6162
const { id } = useParams<{ id: string }>();
6263

63-
const [campaign, setCampaign] = useState<Campaign | null>(null);
64-
const [loading, setLoading] = useState(true);
65-
const [notFound, setNotFound] = useState(false);
64+
const cacheKey = id ? `dash:campaign-detail:${id}` : null;
65+
66+
// Seed from cache on first mount so a return visit renders instantly and
67+
// never flashes a "not found" state before the revalidation lands.
68+
const [seedCampaign] = useState<Campaign | null>(() => {
69+
if (!cacheKey) return null;
70+
const cached = readCache<Campaign[]>(cacheKey, 0);
71+
return cached.data?.find((c) => c.id === id) ?? null;
72+
});
6673
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
6774
const [postThumb, setPostThumb] = useState<string | null>(null);
6875
const [tab, setTab] = useState<Tab>("insights");
6976
const [previewTab, setPreviewTab] = useState<PreviewTab>("dm");
7077
const [busy, setBusy] = useState(false);
78+
// Optimistic toggle, applied on top of fetched data so a stale in-flight
79+
// fetch can never revert it.
80+
const [toggledActive, setToggledActive] = useState<boolean | null>(null);
7181

72-
useEffect(() => {
73-
fetch("/api/automations", { cache: "no-store" })
74-
.then((r) => r.json())
75-
.then((payload) => {
76-
if (!payload.success) return setNotFound(true);
77-
const found = (payload.data as Campaign[]).find((c) => c.id === id);
78-
if (!found) return setNotFound(true);
79-
setCampaign(found);
80-
})
81-
.catch(() => setNotFound(true))
82-
.finally(() => setLoading(false));
83-
}, [id]);
82+
const campaignsFetch = useCachedFetch<Campaign[]>(
83+
cacheKey,
84+
async () => {
85+
const res = await fetch("/api/automations", { cache: "no-store" });
86+
const payload = await res.json();
87+
if (!payload.success) {
88+
throw new Error("Failed to load campaigns");
89+
}
90+
return payload.data as Campaign[];
91+
},
92+
{ maxAgeMs: 30_000 }
93+
);
94+
95+
// While the fetch is loading, fall back to the cache seed; once data lands
96+
// it is authoritative (a missing campaign means "not found").
97+
const campaign = useMemo(() => {
98+
const base = campaignsFetch.data;
99+
const found = base
100+
? (base.find((c) => c.id === id) ?? null)
101+
: seedCampaign;
102+
if (!found) return null;
103+
return toggledActive === null
104+
? found
105+
: { ...found, isActive: toggledActive };
106+
}, [campaignsFetch.data, id, seedCampaign, toggledActive]);
84107

85108
useEffect(() => {
86109
if (!campaign) return;
@@ -119,16 +142,27 @@ export default function CampaignDetailPage() {
119142
headers: { "Content-Type": "application/json" },
120143
body: JSON.stringify({ isActive: !campaign.isActive }),
121144
});
122-
setCampaign({ ...campaign, isActive: !campaign.isActive });
145+
const next = { ...campaign, isActive: !campaign.isActive };
146+
setToggledActive(next.isActive);
147+
// Write-through so the cached list stays in sync for the next visit.
148+
if (cacheKey) {
149+
const cached = readCache<Campaign[]>(cacheKey, 0);
150+
if (cached.data) {
151+
writeCache(
152+
cacheKey,
153+
cached.data.map((c) => (c.id === campaign.id ? next : c))
154+
);
155+
}
156+
}
123157
} finally {
124158
setBusy(false);
125159
}
126160
}
127161

128-
if (loading) {
162+
if (campaignsFetch.loading && !campaign) {
129163
return <div className="panel h-64 rounded" />;
130164
}
131-
if (notFound || !campaign) {
165+
if (!campaign) {
132166
return (
133167
<div className="panel rounded p-8 text-center">
134168
<p className="text-sm text-muted">Campaign not found.</p>

app/(dashboard)/campaigns/page.tsx

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
* Shows all campaigns as cards with toggle and delete.
77
*/
88

9-
import { useCallback, useEffect, useState } from "react";
9+
import { useEffect, useMemo, useState } from "react";
1010
import Link from "next/link";
1111
import { useRouter } from "next/navigation";
1212
import AccountSelect, { type AccountOption } from "@/components/account-select";
13-
import { readCache, writeCache } from "@/lib/client-cache";
13+
import { readCache, useCachedFetch, writeCache } from "@/lib/client-cache";
1414

1515
interface Campaign {
1616
id: string;
@@ -64,10 +64,12 @@ interface Campaign {
6464

6565
export default function CampaignsPage() {
6666
const router = useRouter();
67-
const [automations, setAutomations] = useState<Campaign[]>([]);
6867
const [accounts, setAccounts] = useState<AccountOption[]>([]);
6968
const [selectedAccountId, setSelectedAccountId] = useState("all");
70-
const [loading, setLoading] = useState(true);
69+
// Optimistic mutation state, applied as an overlay on top of fetched data so
70+
// an in-flight stale fetch can never revert a toggle/delete.
71+
const [overrides, setOverrides] = useState<Record<string, Campaign>>({});
72+
const [removedIds, setRemovedIds] = useState<ReadonlySet<string>>(new Set());
7173
// postId -> current thumbnail URL, fetched live (Instagram URLs expire, so
7274
// they are never stored on the campaign).
7375
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
@@ -85,8 +87,12 @@ export default function CampaignsPage() {
8587
"all"
8688
);
8789

88-
const fetchAutomations = useCallback(async () => {
89-
try {
90+
// Account filter lives in the cache key: return visits paint instantly from
91+
// the cached list and revalidate in the background (mutations write through).
92+
const cacheKey = `dash:campaigns:${selectedAccountId}`;
93+
const campaignsFetch = useCachedFetch<Campaign[]>(
94+
cacheKey,
95+
async () => {
9096
const params = new URLSearchParams();
9197
if (selectedAccountId !== "all") {
9298
params.set("instagramAccountId", selectedAccountId);
@@ -96,13 +102,23 @@ export default function CampaignsPage() {
96102
{ cache: "no-store" }
97103
);
98104
const data = await res.json();
99-
if (data.success) setAutomations(data.data);
100-
} catch (err) {
101-
console.error("Failed to fetch campaigns:", err);
102-
} finally {
103-
setLoading(false);
105+
if (!data.success) {
106+
throw new Error(data.error ?? "Failed to fetch campaigns");
107+
}
108+
return data.data as Campaign[];
109+
},
110+
{ maxAgeMs: 30_000 }
111+
);
112+
113+
const automations = useMemo(() => {
114+
const base = campaignsFetch.data ?? [];
115+
const next: Campaign[] = [];
116+
for (const campaign of base) {
117+
if (removedIds.has(campaign.id)) continue;
118+
next.push(overrides[campaign.id] ?? campaign);
104119
}
105-
}, [selectedAccountId]);
120+
return next;
121+
}, [campaignsFetch.data, overrides, removedIds]);
106122

107123
useEffect(() => {
108124
fetch("/api/dashboard/stats")
@@ -113,13 +129,6 @@ export default function CampaignsPage() {
113129
.catch(console.error);
114130
}, []);
115131

116-
useEffect(() => {
117-
const timer = window.setTimeout(() => {
118-
void fetchAutomations();
119-
}, 0);
120-
return () => window.clearTimeout(timer);
121-
}, [fetchAutomations]);
122-
123132
// Fetch fresh post thumbnails (and reel video URLs) for the accounts in view
124133
// and map them by postId. Cache-first so they show instantly on a return
125134
// visit. Instagram URLs expire, so they are never stored on the campaign.
@@ -193,7 +202,6 @@ export default function CampaignsPage() {
193202
}, [playingVideo]);
194203

195204
function handleAccountChange(accountId: string) {
196-
setLoading(true);
197205
setSelectedAccountId(accountId);
198206
}
199207

@@ -204,8 +212,13 @@ export default function CampaignsPage() {
204212
headers: { "Content-Type": "application/json" },
205213
body: JSON.stringify({ isActive: !isActive }),
206214
});
207-
setAutomations((prev) =>
208-
prev.map((a) => (a.id === id ? { ...a, isActive: !isActive } : a))
215+
const current = automations.find((a) => a.id === id);
216+
if (!current) return;
217+
const next = { ...current, isActive: !isActive };
218+
setOverrides((prev) => ({ ...prev, [id]: next }));
219+
writeCache(
220+
cacheKey,
221+
automations.map((a) => (a.id === id ? next : a))
209222
);
210223
} catch (err) {
211224
console.error("Failed to toggle:", err);
@@ -231,7 +244,8 @@ export default function CampaignsPage() {
231244
if (!confirm("Delete this campaign? This cannot be undone.")) return;
232245
try {
233246
await fetch(`/api/automations?id=${id}`, { method: "DELETE" });
234-
setAutomations((prev) => prev.filter((a) => a.id !== id));
247+
setRemovedIds((prev) => new Set(prev).add(id));
248+
writeCache(cacheKey, automations.filter((a) => a.id !== id));
235249
} catch (err) {
236250
console.error("Failed to delete:", err);
237251
}
@@ -270,14 +284,14 @@ export default function CampaignsPage() {
270284
}),
271285
});
272286
const data = await res.json();
273-
if (data.success) void fetchAutomations();
287+
if (data.success) campaignsFetch.refresh();
274288
else console.error("Duplicate failed:", data.error);
275289
} catch (err) {
276290
console.error("Failed to duplicate:", err);
277291
}
278292
}
279293

280-
if (loading) {
294+
if (campaignsFetch.loading && !campaignsFetch.data) {
281295
return (
282296
<div className="space-y-4">
283297
{[...Array(3)].map((_, i) => (

app/(dashboard)/dashboard/page.tsx

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
* Overview cards, 7-day chart, and recent activity feed.
77
*/
88

9-
import { useEffect, useState } from "react";
9+
import { useState } from "react";
1010
import AccountSelect, { type AccountOption } from "@/components/account-select";
1111
import StatCard from "@/components/stat-card";
1212
import StatusBadge from "@/components/status-badge";
13+
import { useCachedFetch } from "@/lib/client-cache";
1314

1415
interface DashboardStats {
1516
userName: string | null;
@@ -41,31 +42,36 @@ interface DashboardStats {
4142
}
4243

4344
export default function DashboardPage() {
44-
const [stats, setStats] = useState<DashboardStats | null>(null);
45-
const [loading, setLoading] = useState(true);
4645
const [selectedAccountId, setSelectedAccountId] = useState("all");
4746

48-
useEffect(() => {
49-
const params = new URLSearchParams();
50-
if (selectedAccountId !== "all") {
51-
params.set("instagramAccountId", selectedAccountId);
52-
}
47+
// Cached copy paints instantly on return visits; a background fetch keeps it
48+
// fresh. 30s max age is plenty for dashboard tiles.
49+
const statsFetch = useCachedFetch<DashboardStats>(
50+
`dash:stats:${selectedAccountId}`,
51+
async () => {
52+
const params = new URLSearchParams();
53+
if (selectedAccountId !== "all") {
54+
params.set("instagramAccountId", selectedAccountId);
55+
}
5356

54-
fetch(`/api/dashboard/stats${params.size ? `?${params}` : ""}`)
55-
.then((r) => r.json())
56-
.then((data) => {
57-
if (data.success) setStats(data.data);
58-
})
59-
.catch(console.error)
60-
.finally(() => setLoading(false));
61-
}, [selectedAccountId]);
57+
const res = await fetch(
58+
`/api/dashboard/stats${params.size ? `?${params}` : ""}`
59+
);
60+
const data = await res.json();
61+
if (!data.success) {
62+
throw new Error(data.error ?? "Failed to load stats");
63+
}
64+
return data.data as DashboardStats;
65+
},
66+
{ maxAgeMs: 30_000 }
67+
);
68+
const stats = statsFetch.data;
6269

6370
function handleAccountChange(accountId: string) {
64-
setLoading(true);
6571
setSelectedAccountId(accountId);
6672
}
6773

68-
if (loading) {
74+
if (statsFetch.loading && !stats) {
6975
return (
7076
<div className="space-y-6">
7177
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3 sm:gap-4">
@@ -137,7 +143,7 @@ export default function DashboardPage() {
137143
<div key={day.date} className="min-w-0 flex-1 flex flex-col items-center gap-2">
138144
<span className="text-xs text-muted font-medium">{day.count}</span>
139145
<div
140-
className="w-full rounded-sm bg-accent min-h-[4px]"
146+
className="w-full rounded-sm bg-accent min-h-1"
141147
style={{ height: `${Math.max((day.count / maxDM) * 100, 4)}%` }}
142148
/>
143149
{/* Seven labels share a phone's width, so they must not wrap. */}

app/(dashboard)/diagnostics/page.tsx

Lines changed: 17 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

3-
import { useEffect, useState } from "react";
43
import StatusBadge from "@/components/status-badge";
4+
import { useCachedFetch } from "@/lib/client-cache";
55

66
interface DiagnosticsData {
77
queueCounts: Record<string, number>;
@@ -76,41 +76,27 @@ function Section({
7676
}
7777

7878
export default function DiagnosticsPage() {
79-
const [data, setData] = useState<DiagnosticsData | null>(null);
80-
const [loading, setLoading] = useState(true);
81-
82-
async function refreshDiagnostics() {
83-
setLoading(true);
84-
const response = await fetch("/api/admin/diagnostics");
85-
const payload = await response.json();
86-
if (payload.success) {
87-
setData(payload.data);
88-
}
89-
setLoading(false);
90-
}
91-
92-
useEffect(() => {
93-
let active = true;
94-
95-
async function loadInitialDiagnostics() {
79+
// Cached copy paints instantly on return; Refresh revalidates in the
80+
// background without blanking the page.
81+
const diagnosticsFetch = useCachedFetch<DiagnosticsData>(
82+
"dash:diagnostics",
83+
async () => {
9684
const response = await fetch("/api/admin/diagnostics");
9785
const payload = await response.json();
98-
if (active && payload.success) {
99-
setData(payload.data);
86+
if (!payload.success) {
87+
throw new Error("Failed to load diagnostics");
10088
}
101-
if (active) {
102-
setLoading(false);
103-
}
104-
}
105-
106-
void loadInitialDiagnostics();
89+
return payload.data as DiagnosticsData;
90+
},
91+
{ maxAgeMs: 30_000 }
92+
);
93+
const data = diagnosticsFetch.data;
10794

108-
return () => {
109-
active = false;
110-
};
111-
}, []);
95+
function refreshDiagnostics() {
96+
diagnosticsFetch.refresh();
97+
}
11298

113-
if (loading && !data) {
99+
if (diagnosticsFetch.loading && !data) {
114100
return <div className="panel rounded p-8 h-64" />;
115101
}
116102

0 commit comments

Comments
 (0)