Reduce polling and batch observation writes
This commit is contained in:
@@ -102,6 +102,8 @@ const TERMINAL_NAV_ITEMS = [
|
||||
] as const;
|
||||
const AUTH_PROFILE_REQUEST_TIMEOUT_MS = 4500;
|
||||
const AUTH_DECISION_RECOVERY_MS = 10_000;
|
||||
const AUTH_PROFILE_RETRY_INITIAL_DELAY_MS = 5_000;
|
||||
const AUTH_PROFILE_RETRY_POLL_MS = 30_000;
|
||||
const ACTIVE_ACCESS_CACHE_KEY = "polyweather_terminal_active_access_v1";
|
||||
const ACTIVE_ACCESS_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
@@ -1554,10 +1556,10 @@ function ScanTerminalScreen() {
|
||||
|
||||
const firstRetry = window.setTimeout(() => {
|
||||
void retryAuthProfile();
|
||||
}, 1500);
|
||||
}, AUTH_PROFILE_RETRY_INITIAL_DELAY_MS);
|
||||
const interval = window.setInterval(() => {
|
||||
void retryAuthProfile();
|
||||
}, 5000);
|
||||
}, AUTH_PROFILE_RETRY_POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(firstRetry);
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bell, RefreshCcw } from "lucide-react";
|
||||
import type { UserFeedbackEntry, UserFeedbackPayload } from "@/types/ops";
|
||||
import {
|
||||
buildFeedbackNotificationKey,
|
||||
countUnseenFeedbackUpdates,
|
||||
FEEDBACK_STATUS_CACHE_TTL_MS,
|
||||
FEEDBACK_STATUS_POLL_MS,
|
||||
feedbackStatusLabel,
|
||||
feedbackStatusTone,
|
||||
} from "./feedback-status";
|
||||
|
||||
const FEEDBACK_STATUS_SEEN_KEY = "polyweather_feedback_status_seen_v1";
|
||||
const FEEDBACK_STATUS_CACHE_KEY = "polyweather_feedback_status_cache_v1";
|
||||
|
||||
function loadSeenKeys() {
|
||||
if (typeof window === "undefined") return new Set<string>();
|
||||
@@ -34,6 +36,33 @@ function saveSeenKeys(keys: Set<string>) {
|
||||
}
|
||||
}
|
||||
|
||||
function readFeedbackStatusCache(): { entries: UserFeedbackEntry[]; ts: number } | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(FEEDBACK_STATUS_CACHE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
const ts = Number(parsed?.ts || 0);
|
||||
const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
|
||||
if (!ts || Date.now() - ts > FEEDBACK_STATUS_CACHE_TTL_MS) return null;
|
||||
return { entries, ts };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeFeedbackStatusCache(entries: UserFeedbackEntry[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
FEEDBACK_STATUS_CACHE_KEY,
|
||||
JSON.stringify({ entries: entries.slice(0, 20), ts: Date.now() }),
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage failures; status will still refresh over the network.
|
||||
}
|
||||
}
|
||||
|
||||
function compactDate(value?: string) {
|
||||
if (!value) return "";
|
||||
return value.slice(0, 16).replace("T", " ");
|
||||
@@ -57,11 +86,12 @@ export function UserFeedbackStatusButton({
|
||||
refreshKey?: number;
|
||||
}) {
|
||||
const [available, setAvailable] = useState(true);
|
||||
const [entries, setEntries] = useState<UserFeedbackEntry[]>([]);
|
||||
const [entries, setEntries] = useState<UserFeedbackEntry[]>(() => readFeedbackStatusCache()?.entries || []);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [seenKeys, setSeenKeys] = useState<Set<string>>(() => loadSeenKeys());
|
||||
const lastLoadedAtRef = useRef<number>(readFeedbackStatusCache()?.ts || 0);
|
||||
|
||||
const unseenCount = useMemo(
|
||||
() => countUnseenFeedbackUpdates(entries, seenKeys),
|
||||
@@ -75,8 +105,26 @@ export function UserFeedbackStatusButton({
|
||||
? "No submitted feedback yet."
|
||||
: "暂无已提交反馈。";
|
||||
|
||||
const load = useCallback(async (signal?: AbortSignal) => {
|
||||
const load = useCallback(async (
|
||||
signal?: AbortSignal,
|
||||
options?: { force?: boolean },
|
||||
) => {
|
||||
if (typeof fetch !== "function") return;
|
||||
const cached = readFeedbackStatusCache();
|
||||
if (!options?.force && cached) {
|
||||
lastLoadedAtRef.current = cached.ts;
|
||||
setEntries(cached.entries);
|
||||
setAvailable(true);
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (
|
||||
!options?.force &&
|
||||
lastLoadedAtRef.current &&
|
||||
now - lastLoadedAtRef.current < FEEDBACK_STATUS_CACHE_TTL_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/feedback?limit=12", {
|
||||
@@ -93,7 +141,10 @@ export function UserFeedbackStatusButton({
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const payload = (await res.json()) as UserFeedbackPayload;
|
||||
setEntries(Array.isArray(payload.feedback) ? payload.feedback : []);
|
||||
const nextEntries = Array.isArray(payload.feedback) ? payload.feedback : [];
|
||||
setEntries(nextEntries);
|
||||
writeFeedbackStatusCache(nextEntries);
|
||||
lastLoadedAtRef.current = Date.now();
|
||||
setAvailable(true);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
@@ -115,10 +166,15 @@ export function UserFeedbackStatusButton({
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void load(controller.signal);
|
||||
void load(controller.signal, { force: refreshKey > 0 });
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
if (
|
||||
document.visibilityState === "visible" &&
|
||||
Date.now() - lastLoadedAtRef.current >= FEEDBACK_STATUS_CACHE_TTL_MS
|
||||
) {
|
||||
void load();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
const id = window.setInterval(() => {
|
||||
@@ -174,7 +230,7 @@ export function UserFeedbackStatusButton({
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load()}
|
||||
onClick={() => void load(undefined, { force: true })}
|
||||
disabled={loading}
|
||||
className="grid h-7 w-7 place-items-center rounded border border-slate-200 text-slate-500 transition hover:bg-slate-50 disabled:cursor-wait disabled:opacity-60"
|
||||
title={isEn ? "Refresh" : "刷新"}
|
||||
|
||||
+20
-1
@@ -1,6 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
buildFeedbackNotificationKey,
|
||||
countUnseenFeedbackUpdates,
|
||||
FEEDBACK_STATUS_CACHE_TTL_MS,
|
||||
FEEDBACK_STATUS_POLL_MS,
|
||||
feedbackStatusLabel,
|
||||
} from "@/components/dashboard/scan-terminal/feedback-status";
|
||||
@@ -10,7 +14,22 @@ function assert(condition: unknown, message: string): asserts condition {
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
assert(FEEDBACK_STATUS_POLL_MS === 600_000, "feedback bell background polling should run every 10 minutes");
|
||||
const projectRoot = process.cwd();
|
||||
const statusButtonSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "UserFeedbackStatusButton.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(FEEDBACK_STATUS_POLL_MS === 30 * 60 * 1000, "feedback bell background polling should run every 30 minutes");
|
||||
assert(FEEDBACK_STATUS_CACHE_TTL_MS === 10 * 60 * 1000, "feedback bell should reuse a 10 minute local status cache");
|
||||
assert(
|
||||
statusButtonSource.includes("FEEDBACK_STATUS_CACHE_KEY") &&
|
||||
statusButtonSource.includes("readFeedbackStatusCache") &&
|
||||
statusButtonSource.includes("writeFeedbackStatusCache") &&
|
||||
statusButtonSource.includes("lastLoadedAtRef") &&
|
||||
statusButtonSource.includes("FEEDBACK_STATUS_CACHE_TTL_MS"),
|
||||
"feedback status button must use a local cache and avoid refetching on every visibility resume",
|
||||
);
|
||||
assert(feedbackStatusLabel("open", false) === "已收到", "open feedback should read as received to users");
|
||||
assert(feedbackStatusLabel("triaged", false) === "已确认", "triaged feedback should read as confirmed to users");
|
||||
assert(feedbackStatusLabel("investigating", false) === "处理中", "investigating feedback should read as in progress");
|
||||
|
||||
@@ -168,6 +168,12 @@ export async function runTests() {
|
||||
dashboardSource.includes('document.visibilityState === "hidden"'),
|
||||
"terminal online-user presence should refresh slowly and pause while the browser tab is hidden",
|
||||
);
|
||||
assert(
|
||||
dashboardSource.includes("AUTH_PROFILE_RETRY_INITIAL_DELAY_MS = 5_000") &&
|
||||
dashboardSource.includes("AUTH_PROFILE_RETRY_POLL_MS = 30_000") &&
|
||||
!dashboardSource.includes("}, 5000);"),
|
||||
"terminal auth subscription retry should back off after the first retry instead of polling auth/me every 5 seconds",
|
||||
);
|
||||
assert(
|
||||
chartSource.includes("IntersectionObserver") &&
|
||||
chartSource.includes("shouldFetchCityDetailForChart") &&
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { UserFeedbackEntry } from "@/types/ops";
|
||||
|
||||
export const FEEDBACK_STATUS_POLL_MS = 10 * 60 * 1000;
|
||||
export const FEEDBACK_STATUS_POLL_MS = 30 * 60 * 1000;
|
||||
export const FEEDBACK_STATUS_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export function feedbackStatusLabel(status: string | undefined, isEn: boolean) {
|
||||
const key = String(status || "open").toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user