Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.8.1",
"swr": "^2.4.1",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.3.6"
Expand Down
112 changes: 78 additions & 34 deletions src/app/[username]/ProfileClient.tsx
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";
Expand All @@ -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(
() =>
Expand All @@ -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),
Comment on lines +50 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize the SWR key and manual fetch to the same username form.

The component already derives safeUsername, but the SWR key and refreshAnalysis() still use raw username. Visiting /Foo and /foo will create different cache entries and miss the reuse this PR is trying to add. Use one normalized value for both the key and fetchProfileAnalysis(...) so the cache stays coherent.

Also applies to: 98-104

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`[username]/ProfileClient.tsx around lines 50 - 56, The SWR key and
fetcher must use the normalized safeUsername so cache entries for "/Foo" and
"/foo" are the same: change the useSWR call to use isInvalidUsername ? null :
["profile-analysis", safeUsername] and make the fetcher call
fetchProfileAnalysis(safeUsername) (or simply use () =>
fetchProfileAnalysis(safeUsername)); likewise update refreshAnalysis (and any
other manual fetches around lines 98-104 that call
fetchProfileAnalysis(username)) to call fetchProfileAnalysis(safeUsername) or
call mutate() without raw username so all reads/writes use the same normalized
safeUsername.

{
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don’t write nosave responses into the main profile cache.

See Latest Analysis explicitly requests nosave: true, but refreshAnalysis() still mutates the shared ["profile-analysis", ...] entry with that response. After one click, navigating away and back will keep showing the unsaved live analysis from SWR instead of the locked/historical snapshot the server contract was preserving. Keep nosave results out of the shared cache, or store them in separate local preview state.

This follows the upstream src/lib/profile-analysis.ts contract, where nosave is intentionally carried through to /api/analyze as a distinct request mode.

Also applies to: 276-279

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`[username]/ProfileClient.tsx around lines 98 - 105, The
refreshAnalysis function is mutating the shared SWR cache even for temporary
"nosave" responses; change refreshAnalysis (and the other occurrence) so that
when options.nosave === true you DO NOT call mutate(...) on the shared
["profile-analysis", username] entry—instead store the fetched result in a
separate local preview state (e.g., previewAnalysis state) and use that for
immediate UI rendering; keep the existing mutate(...) call only for non-nosave
responses so the shared cache remains the locked/historical snapshot preserved
by fetchProfileAnalysis.

const confetti = (await import("canvas-confetti")).default;
confetti({
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve the current analysis when a manual refresh fails.

This catch path promotes refresh failures into the same top-level error state used for initial-load failures. Because the component hard-switches to the full-page error UI at Line 160 whenever error is set, one transient refresh error hides already-cached analysis and leaves the user with only “Return to Hub”. Keep stale data visible on refresh failures and surface the refresh error separately.

Also applies to: 160-192

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`[username]/ProfileClient.tsx around lines 113 - 127, The catch block
that handles refresh failures currently calls setError (and thus triggers the
full-page error UI) even when stale analysis exists; change this so
manual-refresh failures do not overwrite the top-level error state when there is
already cached analysis: in the catch for refreshError (the branch checking
ProfileAnalysisError && code === "STAR_REQUIRED" can remain), avoid calling
setError(...) directly—instead introduce or use a separate refresh-specific
state (e.g., refreshError or refreshStatus) to surface transient refresh
failures, or only call setError when there is no existing analysis/cached data;
keep setShowStarModal behavior as-is. Ensure you update any rendering logic that
shows the full-page error (the code that checks error) to only switch to
full-page error when error is set and there is no cached analysis, or rely on
the new refresh-specific state to display a non-blocking inline error.

} finally {
setIsRefreshing(false);
}
},
[username],
[mutate, username],
);

useEffect(() => {
const safeUsername = (username || "").toLowerCase();
if (!username || safeUsername === "undefined" || safeUsername === "null") {
if (isInvalidUsername) {
setError("INVALID_ID_SPEC");
return;
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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"
>
Expand All @@ -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"
>
Expand Down
60 changes: 60 additions & 0 deletions src/lib/profile-analysis.ts
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize transport failures into ProfileAnalysisError (Line 38).

fetch() rejections (offline, DNS, timeout, aborted request) currently throw native errors, so this helper does not fully normalize error shape as intended for shared consumers.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const res = await fetch(buildProfileUrl(username, options), {
cache: "no-store",
});
const result = await res.json().catch(() => ({}));
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(() => ({}));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/profile-analysis.ts` around lines 38 - 41, Wrap the fetch call in a
try/catch so network/transport rejections are converted into a
ProfileAnalysisError: replace the direct await fetch(buildProfileUrl(...)) with
a try block that awaits fetch, and in the catch create and throw a new
ProfileAnalysisError (including the original error as cause or in the message
and any useful context like the username/URL). Keep the existing logic that
parses res.json() but ensure transport failures never escape as native errors by
always throwing ProfileAnalysisError from the catch. Use the existing
buildProfileUrl and ProfileAnalysisError symbols to locate where to change.


if (res.status === 403 && result?.error === "Star required") {
throw new ProfileAnalysisError(
result?.message || "Star required",
403,
"STAR_REQUIRED",
);
Comment on lines +43 to +48

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid brittle star-gate matching on literal backend text (Line 43).

The 403 star-gate path is keyed to result?.error === "Star required" only. If backend wording changes (while still signaling the same condition), the modal flow in ProfileClient will stop triggering.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (res.status === 403 && result?.error === "Star required") {
throw new ProfileAnalysisError(
result?.message || "Star required",
403,
"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",
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/profile-analysis.ts` around lines 43 - 48, The current 403 branch is
brittle because it checks result?.error === "Star required"; update that
conditional in the code around the ProfileAnalysisError throw so it detects the
star-gate more robustly: keep the res.status === 403 guard but replace the
strict equality with a normalized, defensive check such as checking for a
structured error code (e.g. result?.code === "STAR_REQUIRED") or, if absent, a
case-insensitive substring match like (result?.error || result?.message ||
"").toLowerCase().includes("star"); then throw ProfileAnalysisError as before
(preserving message and code) so the modal flow in ProfileClient still triggers
even if backend wording changes.

}

if (!res.ok) {
throw new ProfileAnalysisError(
result?.error || result?.message || "Diagnostic matrix failed",
res.status,
result?.error || "ANALYSIS_FAILED",
);
}

return result as AnalysisResult;
}