-
Notifications
You must be signed in to change notification settings - Fork 6
[codex] cache profile analyses #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect, useState, useCallback } from "react"; | ||
| import useSWR from "swr"; | ||
| import { useRouter } from "next/navigation"; | ||
| import dynamic from "next/dynamic"; | ||
| import { PDFExportButton } from "@/components/PDFExportButton"; | ||
|
|
@@ -18,6 +19,10 @@ import { | |
| } from "lucide-react"; | ||
| import { AnalysisResult } from "@/types"; | ||
| import { fetchAuthIdentity } from "@/lib/client-auth"; | ||
| import { | ||
| fetchProfileAnalysis, | ||
| ProfileAnalysisError, | ||
| } from "@/lib/profile-analysis"; | ||
|
|
||
| const StatsDashboard = dynamic( | ||
| () => | ||
|
|
@@ -37,35 +42,66 @@ interface ProfileClientProps { | |
|
|
||
| export function ProfileClient({ username, initialData }: ProfileClientProps) { | ||
| const router = useRouter(); | ||
| const [data, setData] = useState<AnalysisResult | null>(initialData || null); | ||
| const [error, setError] = useState<string | null>(null); | ||
| const [showStarModal, setShowStarModal] = useState(false); | ||
| const [isOwner, setIsOwner] = useState(false); | ||
| const [isVerifyingAgain, setIsVerifyingAgain] = useState(false); | ||
| const [isRefreshing, setIsRefreshing] = useState(false); | ||
| const safeUsername = (username || "").toLowerCase(); | ||
| const isInvalidUsername = | ||
| !username || safeUsername === "undefined" || safeUsername === "null"; | ||
|
|
||
| const { data, error: fetchError, mutate } = useSWR( | ||
| isInvalidUsername ? null : ["profile-analysis", username], | ||
| ([, activeUsername]) => fetchProfileAnalysis(activeUsername), | ||
| { | ||
| fallbackData: initialData, | ||
| revalidateIfStale: false, | ||
| revalidateOnFocus: false, | ||
| revalidateOnReconnect: false, | ||
| dedupingInterval: 5 * 60 * 1000, | ||
| keepPreviousData: true, | ||
| }, | ||
| ); | ||
|
|
||
| const fetchData = useCallback( | ||
| async (force = false, nosave = false) => { | ||
| try { | ||
| setIsRefreshing(force); | ||
| const baseUrl = `/api/analyze?username=${username}`; | ||
| const res = await fetch( | ||
| `${baseUrl}${force ? "&force=true" : ""}${nosave ? "&nosave=true" : ""}`, | ||
| ); | ||
| const result = await res.json(); | ||
|
|
||
| if (res.status === 403 && result.error === "Star required") { | ||
| setShowStarModal(true); | ||
| return; | ||
| } | ||
| useEffect(() => { | ||
| if (fetchError) { | ||
| if ( | ||
| fetchError instanceof ProfileAnalysisError && | ||
| fetchError.code === "STAR_REQUIRED" | ||
| ) { | ||
| setShowStarModal(true); | ||
| setError(null); | ||
| return; | ||
| } | ||
|
|
||
| if (!res.ok) { | ||
| setError(result.error || "Diagnostic matrix failed"); | ||
| return; | ||
| } | ||
| setShowStarModal(false); | ||
| setError( | ||
| fetchError instanceof Error | ||
| ? fetchError.message | ||
| : "Diagnostic matrix failed", | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| setData(result); | ||
| setError(null); | ||
| if (!isInvalidUsername) { | ||
| setError(null); | ||
| } | ||
| }, [fetchError, isInvalidUsername]); | ||
|
|
||
| useEffect(() => { | ||
| if (data) { | ||
| setShowStarModal(false); | ||
| } | ||
| }, [data]); | ||
|
|
||
| const refreshAnalysis = useCallback( | ||
| async (options: { force?: boolean; nosave?: boolean } = {}) => { | ||
| setIsRefreshing(true); | ||
|
|
||
| try { | ||
| const nextData = await fetchProfileAnalysis(username, options); | ||
| await mutate(nextData, { revalidate: false }); | ||
|
|
||
|
Comment on lines
+98
to
105
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don’t write
This follows the upstream Also applies to: 276-279 🤖 Prompt for AI Agents |
||
| const confetti = (await import("canvas-confetti")).default; | ||
| confetti({ | ||
|
|
@@ -74,18 +110,30 @@ export function ProfileClient({ username, initialData }: ProfileClientProps) { | |
| origin: { y: 0.6 }, | ||
| colors: ["#FFE600", "#FF00E5", "#00F0FF", "#000000"], | ||
| }); | ||
| } catch { | ||
| setError("NETWORK_FAILURE"); | ||
| } catch (refreshError) { | ||
| if ( | ||
| refreshError instanceof ProfileAnalysisError && | ||
| refreshError.code === "STAR_REQUIRED" | ||
| ) { | ||
| setShowStarModal(true); | ||
| setError(null); | ||
| } else { | ||
| setShowStarModal(false); | ||
| setError( | ||
| refreshError instanceof Error | ||
| ? refreshError.message | ||
| : "NETWORK_FAILURE", | ||
| ); | ||
| } | ||
|
Comment on lines
+113
to
+127
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Preserve the current analysis when a manual refresh fails. This catch path promotes refresh failures into the same top-level Also applies to: 160-192 🤖 Prompt for AI Agents |
||
| } finally { | ||
| setIsRefreshing(false); | ||
| } | ||
| }, | ||
| [username], | ||
| [mutate, username], | ||
| ); | ||
|
|
||
| useEffect(() => { | ||
| const safeUsername = (username || "").toLowerCase(); | ||
| if (!username || safeUsername === "undefined" || safeUsername === "null") { | ||
| if (isInvalidUsername) { | ||
| setError("INVALID_ID_SPEC"); | ||
| return; | ||
| } | ||
|
|
@@ -97,17 +145,13 @@ export function ProfileClient({ username, initialData }: ProfileClientProps) { | |
| .catch(() => { | ||
| setIsOwner(false); | ||
| }); | ||
|
|
||
| if (!initialData) { | ||
| void fetchData(); | ||
| } | ||
| }, [username, initialData, fetchData]); | ||
| }, [isInvalidUsername, safeUsername]); | ||
|
|
||
| const handleRecheckStar = async () => { | ||
| setIsVerifyingAgain(true); | ||
| try { | ||
| await new Promise((resolve) => setTimeout(resolve, 1000)); | ||
| await fetchData(); | ||
| await refreshAnalysis(); | ||
| } finally { | ||
| setIsVerifyingAgain(false); | ||
| } | ||
|
|
@@ -231,7 +275,7 @@ export function ProfileClient({ username, initialData }: ProfileClientProps) { | |
|
|
||
| {(data.isLocked || data.isHistorical) && ( | ||
| <button | ||
| onClick={() => fetchData(true, true)} | ||
| onClick={() => refreshAnalysis({ force: true, nosave: true })} | ||
| disabled={isRefreshing} | ||
| className="neo-button bg-neo-yellow text-[10px] md:text-sm flex items-center justify-center gap-2 group shadow-neo-active hover:shadow-neo transition-all disabled:opacity-50" | ||
| > | ||
|
|
@@ -242,7 +286,7 @@ export function ProfileClient({ username, initialData }: ProfileClientProps) { | |
|
|
||
| {(isOwner || (!data.isLocked && !data.isHistorical)) && ( | ||
| <button | ||
| onClick={() => fetchData(true, false)} | ||
| onClick={() => refreshAnalysis({ force: true })} | ||
| disabled={isRefreshing} | ||
| className="neo-button bg-neo-green text-[10px] md:text-sm flex items-center justify-center gap-2 group shadow-neo-active hover:shadow-neo transition-all disabled:opacity-50" | ||
| > | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,60 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||
| import type { AnalysisResult } from "@/types"; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| type ProfileAnalysisOptions = { | ||||||||||||||||||||||||||||||||||||||||||||||||
| force?: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||
| nosave?: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| export class ProfileAnalysisError extends Error { | ||||||||||||||||||||||||||||||||||||||||||||||||
| status: number; | ||||||||||||||||||||||||||||||||||||||||||||||||
| code: string; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| constructor(message: string, status: number, code: string) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| super(message); | ||||||||||||||||||||||||||||||||||||||||||||||||
| this.name = "ProfileAnalysisError"; | ||||||||||||||||||||||||||||||||||||||||||||||||
| this.status = status; | ||||||||||||||||||||||||||||||||||||||||||||||||
| this.code = code; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const buildProfileUrl = (username: string, options: ProfileAnalysisOptions) => { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const params = new URLSearchParams({ username }); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (options.force) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| params.set("force", "true"); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (options.nosave) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| params.set("nosave", "true"); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| return `/api/analyze?${params.toString()}`; | ||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| export async function fetchProfileAnalysis( | ||||||||||||||||||||||||||||||||||||||||||||||||
| username: string, | ||||||||||||||||||||||||||||||||||||||||||||||||
| options: ProfileAnalysisOptions = {}, | ||||||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<AnalysisResult> { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const res = await fetch(buildProfileUrl(username, options), { | ||||||||||||||||||||||||||||||||||||||||||||||||
| cache: "no-store", | ||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const result = await res.json().catch(() => ({})); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+38
to
+41
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Normalize transport failures into
Suggested patch export async function fetchProfileAnalysis(
username: string,
options: ProfileAnalysisOptions = {},
): Promise<AnalysisResult> {
- const res = await fetch(buildProfileUrl(username, options), {
- cache: "no-store",
- });
+ let res: Response;
+ try {
+ res = await fetch(buildProfileUrl(username, options), {
+ cache: "no-store",
+ });
+ } catch (error) {
+ throw new ProfileAnalysisError(
+ error instanceof Error ? error.message : "Network request failed",
+ 0,
+ "NETWORK_ERROR",
+ );
+ }
const result = await res.json().catch(() => ({}));📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (res.status === 403 && result?.error === "Star required") { | ||||||||||||||||||||||||||||||||||||||||||||||||
| throw new ProfileAnalysisError( | ||||||||||||||||||||||||||||||||||||||||||||||||
| result?.message || "Star required", | ||||||||||||||||||||||||||||||||||||||||||||||||
| 403, | ||||||||||||||||||||||||||||||||||||||||||||||||
| "STAR_REQUIRED", | ||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+43
to
+48
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid brittle star-gate matching on literal backend text (Line 43). The 403 star-gate path is keyed to Suggested patch- if (res.status === 403 && result?.error === "Star required") {
+ const errorToken =
+ typeof result?.error === "string"
+ ? result.error
+ : typeof result?.code === "string"
+ ? result.code
+ : "";
+
+ if (
+ res.status === 403 &&
+ (errorToken === "Star required" || errorToken === "STAR_REQUIRED")
+ ) {
throw new ProfileAnalysisError(
result?.message || "Star required",
403,
"STAR_REQUIRED",
);
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (!res.ok) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| throw new ProfileAnalysisError( | ||||||||||||||||||||||||||||||||||||||||||||||||
| result?.error || result?.message || "Diagnostic matrix failed", | ||||||||||||||||||||||||||||||||||||||||||||||||
| res.status, | ||||||||||||||||||||||||||||||||||||||||||||||||
| result?.error || "ANALYSIS_FAILED", | ||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| return result as AnalysisResult; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Normalize the SWR key and manual fetch to the same username form.
The component already derives
safeUsername, but the SWR key andrefreshAnalysis()still use rawusername. Visiting/Fooand/foowill create different cache entries and miss the reuse this PR is trying to add. Use one normalized value for both the key andfetchProfileAnalysis(...)so the cache stays coherent.Also applies to: 98-104
🤖 Prompt for AI Agents