Skip to content

Commit 9da3957

Browse files
AmazingAngclaude
andcommitted
ETag 304 for polling APIs, fix false new market alerts
- /api/markets: server-side ETag with MD5 hash, returns 304 when data unchanged — eliminates ~1-3MB JSON parse per poll on mobile WebViews - /api/smart-money: same ETag/304 mechanism for all response types - Client fetchData/fetchSmartMoney: send If-None-Match, skip JSON parse on 304 — reduces sustained memory pressure that crashed Binance Wallet - Fix false new market alerts: old markets cycling open→closed→open were misdetected as new; now requires createdAt after session start time - Move language setting above auto-refresh in Settings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fc494cd commit 9da3957

4 files changed

Lines changed: 131 additions & 65 deletions

File tree

src/app/api/markets/route.ts

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,59 @@
1-
import { NextResponse } from "next/server";
1+
import { NextRequest, NextResponse } from "next/server";
22
import { readMarketsFromDb } from "@/lib/sync";
33
import { getDb } from "@/lib/db";
44
import { apiError } from "@/lib/apiError";
5+
import { createHash } from "crypto";
56

67
export const dynamic = "force-dynamic";
78

8-
export async function GET() {
9+
let cachedBody: string | null = null;
10+
let cachedEtag: string | null = null;
11+
let cacheTs = 0;
12+
const CACHE_TTL = 10_000; // 10s — matches Cache-Control max-age
13+
14+
export async function GET(request: NextRequest) {
915
try {
10-
const { mapped, unmapped } = readMarketsFromDb();
11-
12-
// Get last successful sync time
13-
let lastSync: string | null = null;
14-
try {
15-
const db = getDb();
16-
const row = db
17-
.prepare(
18-
`SELECT finished_at FROM sync_log WHERE status = 'ok' ORDER BY id DESC LIMIT 1`
19-
)
20-
.get() as { finished_at: string } | undefined;
21-
if (row) lastSync = row.finished_at;
22-
} catch {
23-
// ignore
16+
const now = Date.now();
17+
18+
// Rebuild cached response body if stale
19+
if (!cachedBody || now - cacheTs > CACHE_TTL) {
20+
const { mapped, unmapped } = readMarketsFromDb();
21+
22+
let lastSync: string | null = null;
23+
try {
24+
const db = getDb();
25+
const row = db
26+
.prepare(
27+
`SELECT finished_at FROM sync_log WHERE status = 'ok' ORDER BY id DESC LIMIT 1`
28+
)
29+
.get() as { finished_at: string } | undefined;
30+
if (row) lastSync = row.finished_at;
31+
} catch {
32+
// ignore
33+
}
34+
35+
cachedBody = JSON.stringify({ mapped, unmapped, lastSync });
36+
cachedEtag = `"${createHash("md5").update(cachedBody).digest("hex").slice(0, 16)}"`;
37+
cacheTs = now;
38+
}
39+
40+
// Return 304 if client already has this version
41+
const ifNoneMatch = request.headers.get("if-none-match");
42+
if (ifNoneMatch && ifNoneMatch === cachedEtag) {
43+
return new NextResponse(null, {
44+
status: 304,
45+
headers: { ETag: cachedEtag! },
46+
});
2447
}
2548

26-
return NextResponse.json(
27-
{ mapped, unmapped, lastSync },
28-
{ headers: { "Cache-Control": "public, max-age=10, stale-while-revalidate=30" } }
29-
);
49+
return new NextResponse(cachedBody, {
50+
status: 200,
51+
headers: {
52+
"Content-Type": "application/json",
53+
"Cache-Control": "public, max-age=10, stale-while-revalidate=30",
54+
ETag: cachedEtag!,
55+
},
56+
});
3057
} catch (err) {
3158
return apiError("markets", "Error reading from DB", 500, err);
3259
}

src/app/api/smart-money/route.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { NextResponse } from "next/server";
1+
import { NextRequest, NextResponse } from "next/server";
22
import { getDb } from "@/lib/db";
33
import { fetchMarketTrades } from "@/lib/smartMoney";
44
import type { SmartWallet, WhaleTrade } from "@/types";
55
import { SingleCache } from "@/lib/apiCache";
66
import { apiError } from "@/lib/apiError";
77
import { aggregateFlowByCategory, type CategoryFlow } from "@/lib/flowAnalysis";
8+
import { createHash } from "crypto";
89

910
interface LeaderboardRow {
1011
address: string;
@@ -36,6 +37,31 @@ const tradeCache = new SingleCache<{ whaleTrades: WhaleTrade[]; smartTrades: Wha
3637
const flowCache = new SingleCache<CategoryFlow[]>(120_000); // 2 min cache
3738
let bgFetchInProgress = false;
3839

40+
// ETag cache per response key
41+
const etagCache = new Map<string, { body: string; etag: string; ts: number }>();
42+
const ETAG_TTL = 10_000;
43+
44+
function cachedJsonResponse(key: string, data: unknown, request: NextRequest): NextResponse {
45+
const now = Date.now();
46+
let entry = etagCache.get(key);
47+
if (!entry || now - entry.ts > ETAG_TTL) {
48+
const body = JSON.stringify(data);
49+
const etag = `"${createHash("md5").update(body).digest("hex").slice(0, 16)}"`;
50+
entry = { body, etag, ts: now };
51+
etagCache.set(key, entry);
52+
}
53+
54+
const ifNoneMatch = request.headers.get("if-none-match");
55+
if (ifNoneMatch && ifNoneMatch === entry.etag) {
56+
return new NextResponse(null, { status: 304, headers: { ETag: entry.etag } });
57+
}
58+
59+
return new NextResponse(entry.body, {
60+
status: 200,
61+
headers: { "Content-Type": "application/json", ETag: entry.etag },
62+
});
63+
}
64+
3965
function mapApiTrade(
4066
t: Awaited<ReturnType<typeof fetchMarketTrades>>[number],
4167
smartAddresses: Set<string>,
@@ -153,7 +179,7 @@ function readTradesFromDb(
153179
};
154180
}
155181

156-
export async function GET(request: Request) {
182+
export async function GET(request: NextRequest) {
157183
try {
158184
const db = getDb();
159185
const { searchParams } = new URL(request.url);
@@ -165,7 +191,7 @@ export async function GET(request: Request) {
165191
flows = aggregateFlowByCategory(db, 24);
166192
flowCache.set(flows);
167193
}
168-
return NextResponse.json({ flows });
194+
return cachedJsonResponse("flow", { flows }, request);
169195
}
170196

171197
const period = searchParams.get("period") || "all";
@@ -210,7 +236,7 @@ export async function GET(request: Request) {
210236
// Fast path: only leaderboard data needed (period toggle)
211237
const leaderboardOnly = searchParams.get("leaderboardOnly") === "1";
212238
if (leaderboardOnly) {
213-
return NextResponse.json({ leaderboard });
239+
return cachedJsonResponse(`lb-${timePeriod}`, { leaderboard }, request);
214240
}
215241

216242
// Build smart wallet address set from ALL tracked wallets (PnL >= $100k)
@@ -272,12 +298,14 @@ export async function GET(request: Request) {
272298
.prepare(`SELECT MAX(updated_at) as last_sync FROM smart_wallets`)
273299
.get() as { last_sync: string | null } | undefined;
274300

275-
return NextResponse.json({
301+
const responseData = {
276302
leaderboard,
277303
recentTrades: whaleTrades,
278304
smartTrades,
279305
lastSync: walletMeta?.last_sync || null,
280-
});
306+
};
307+
308+
return cachedJsonResponse(`sm-${timePeriod}`, responseData, request);
281309
} catch (err) {
282310
return apiError("smart-money", "Failed to fetch smart money data", 500, err);
283311
}

src/app/page.tsx

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -392,38 +392,38 @@ export default function Home() {
392392
const seenSignalIds = useRef<Set<string>>(new Set());
393393
const seenMarketIds = useRef<Set<string>>(new Set());
394394
const isFirstLoad = useRef(true);
395+
const sessionStartTime = useRef(Date.now());
395396
const lbCacheRef = useRef<Record<string, import("@/types").SmartWallet[]>>({});
396397

397-
// Quick fingerprint to detect if market data actually changed
398-
const dataFingerprintRef = useRef("");
399-
const marketFingerprint = (arr: ProcessedMarket[]) => {
400-
// count + first/last ID + sum of probs (fast, no allocation)
401-
if (arr.length === 0) return "0";
402-
let probSum = 0;
403-
for (let i = 0; i < arr.length; i++) probSum += (arr[i].prob ?? 0);
404-
return `${arr.length}:${arr[0].id}:${arr[arr.length - 1].id}:${probSum.toFixed(2)}`;
405-
};
398+
// ETag for /api/markets — skip JSON parse when data unchanged (saves ~1-3MB per poll on mobile)
399+
const marketsEtagRef = useRef<string | null>(null);
406400

407401
const fetchData = useCallback(async (signal?: AbortSignal) => {
408402
const { setMapped, setUnmapped, setLoading, setDataMode, setLastSyncTime, setSignals, setNewMarkets, setLastRefresh } = useMarketStore.getState();
409403
setLoading(true);
410404
setNewMarkets([]);
411405
try {
412-
const res = await fetch("/api/markets", { signal });
406+
const headers: HeadersInit = {};
407+
if (marketsEtagRef.current) headers["If-None-Match"] = marketsEtagRef.current;
408+
const res = await fetch("/api/markets", { signal, headers });
409+
410+
// 304 Not Modified — data unchanged, skip parse entirely
411+
if (res.status === 304) {
412+
setLoading(false);
413+
setLastRefresh(new Date().toLocaleTimeString());
414+
return;
415+
}
416+
413417
if (!res.ok) throw new Error("API error");
418+
const etag = res.headers.get("etag");
419+
if (etag) marketsEtagRef.current = etag;
414420
const data = await res.json();
415421
const m: ProcessedMarket[] = data.mapped || [];
416422
const u: ProcessedMarket[] = data.unmapped || [];
417423

418424
if (m.length > 0 || u.length > 0) {
419-
// Skip store update if data hasn't changed — avoids re-render + GeoJSON rebuild
420-
const fp = marketFingerprint(m) + "|" + marketFingerprint(u);
421-
const changed = fp !== dataFingerprintRef.current;
422-
dataFingerprintRef.current = fp;
423-
if (changed) {
424-
setMapped(m);
425-
setUnmapped(u);
426-
}
425+
setMapped(m);
426+
setUnmapped(u);
427427
setDataMode("live");
428428
setRefreshError(false);
429429
if (data.lastSync) setLastSyncTime(data.lastSync);
@@ -444,11 +444,16 @@ export default function Home() {
444444
for (const item of all) seenMarketIds.current.add(item.id);
445445
isFirstLoad.current = false;
446446
} else {
447-
const fresh = all.filter(
448-
(item) => !seenMarketIds.current.has(item.id)
449-
);
447+
// Only treat as "new" if: 1) not seen before AND 2) created after session start
448+
// This prevents old markets that re-enter the active set from triggering alerts
449+
const fresh = all.filter((item) => {
450+
if (seenMarketIds.current.has(item.id)) return false;
451+
seenMarketIds.current.add(item.id);
452+
if (!item.createdAt) return false;
453+
const created = new Date(item.createdAt).getTime();
454+
return created > sessionStartTime.current;
455+
});
450456
if (fresh.length > 0) {
451-
for (const item of fresh) seenMarketIds.current.add(item.id);
452457
setNewMarkets(fresh);
453458
}
454459
}
@@ -469,6 +474,7 @@ export default function Home() {
469474
setLoading(false);
470475
}, []);
471476

477+
const smartMoneyEtagRef = useRef<string | null>(null);
472478
const fetchSmartMoney = useCallback(async (periodOrSignal?: LeaderboardPeriod | AbortSignal, leaderboardOnly?: boolean) => {
473479
// Support being called from useVisibilityPolling (signal) or directly (period)
474480
let period: LeaderboardPeriod | undefined;
@@ -482,8 +488,13 @@ export default function Home() {
482488
const sm = useSmartMoneyStore.getState();
483489
const p = period ?? sm.leaderboardPeriod;
484490
const params = `period=${p}${leaderboardOnly ? "&leaderboardOnly=1" : ""}`;
485-
const res = await fetch(`/api/smart-money?${params}`, { signal });
491+
const headers: HeadersInit = {};
492+
if (!leaderboardOnly && smartMoneyEtagRef.current) headers["If-None-Match"] = smartMoneyEtagRef.current;
493+
const res = await fetch(`/api/smart-money?${params}`, { signal, headers });
494+
if (res.status === 304) return; // data unchanged
486495
if (!res.ok) return;
496+
const etag = res.headers.get("etag");
497+
if (etag && !leaderboardOnly) smartMoneyEtagRef.current = etag;
487498
const data = await res.json();
488499
const lb = data.leaderboard || [];
489500
lbCacheRef.current[p] = lb;

src/components/SettingsModal.tsx

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,22 @@ function GeneralTab({
272272
</div>
273273
</div>
274274

275+
{/* Language */}
276+
<div className="settings-section">
277+
<span className="section-label">{t("settings.language")}</span>
278+
<div className="settings-pill-bar">
279+
{(["en", "zh"] as Locale[]).map((l) => (
280+
<button
281+
key={l}
282+
onClick={() => setLocale(l)}
283+
className={`settings-pill${locale === l ? " active" : ""}`}
284+
>
285+
{l === "en" ? "English" : "中文"}
286+
</button>
287+
))}
288+
</div>
289+
</div>
290+
275291
{/* Auto-refresh */}
276292
<div className="settings-section">
277293
<span className="section-label">{t("settings.autoRefresh")}</span>
@@ -327,22 +343,6 @@ function GeneralTab({
327343
<span className="section-label">{t("settings.theme")}</span>
328344
<div className="settings-info-value">{t("settings.themeDark")}</div>
329345
</div>
330-
331-
{/* Language */}
332-
<div className="settings-section">
333-
<span className="section-label">{t("settings.language")}</span>
334-
<div className="settings-pill-bar">
335-
{(["en", "zh"] as Locale[]).map((l) => (
336-
<button
337-
key={l}
338-
onClick={() => setLocale(l)}
339-
className={`settings-pill${locale === l ? " active" : ""}`}
340-
>
341-
{l === "en" ? "English" : "中文"}
342-
</button>
343-
))}
344-
</div>
345-
</div>
346346
</div>
347347
);
348348
}

0 commit comments

Comments
 (0)