Skip to content
Merged
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
35 changes: 33 additions & 2 deletions src/client/features/analysis-runs/RecentRunsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ export function RecentRunsList({
staleTime: 60_000,
});

// A failed history fetch is not an empty history. Hiding the section on
// error made a transport failure look like "you have only run this once".
if (query.isError) {
return (
<section className="rounded-lg border border-base-300 bg-base-100 px-3 py-2 text-xs text-base-content/60">
Couldn&rsquo;t load recent runs.{" "}
<button
type="button"
className="link"
onClick={() => void query.refetch()}
>
Retry
</button>
</section>
);
}

const runs = query.data ?? [];
if (runs.length < 2) return null;

Expand Down Expand Up @@ -70,7 +87,11 @@ export function RecentRunsList({
staleTime: 60_000,
})
.then((restored) => {
if (restored == null) {
// `restoreRun` now reports WHY rather than returning
// null, so this checks the status. Anything that is not
// a usable result marks the row expired, which is what
// the old null check meant.
if (restored.status !== "ready") {
setExpiredRunIds((current) => {
const next = new Set(current);
next.add(run.id);
Expand All @@ -80,7 +101,17 @@ export function RecentRunsList({
}
onSelect(run.id);
})
.catch(() => undefined)
.catch(() => {
// A transport failure is not proof the run expired, but
// it is proof we could not open it. Marking the row is
// better than the previous silent no-op, which left the
// button looking like it simply did nothing.
setExpiredRunIds((current) => {
const next = new Set(current);
next.add(run.id);
return next;
});
})
.finally(() => setCheckingRunId(null));
}}
className={`flex w-full items-center justify-between gap-3 px-3 py-2 text-left hover:bg-base-200/60 ${
Expand Down
40 changes: 38 additions & 2 deletions src/client/features/analysis-runs/useAutoRestoredRun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ export function useAutoRestoredRun<T>({
runId?: string | null;
}): {
restored: AutoRestoredRun<T> | null;
/**
* Why `restored` is null. `expired` means the run happened but its stored
* result is gone; `unreadable` means it no longer matches this tab's schema.
* Both are worth saying out loud rather than showing a blank form.
*/
outcome: "none" | "expired" | "unreadable" | "ready" | null;
/** The expired run's own label/date, so the message can name it. */
expired: { label: string; lastRanAt: string } | null;
isRestoring: boolean;
isError: boolean;
isRetrying: boolean;
Expand All @@ -62,8 +70,29 @@ export function useAutoRestoredRun<T>({
staleTime: 60_000,
});

/**
* Why there is nothing to show, when there isn't.
*
* Every one of these used to collapse to `null`, which callers rendered as
* the tab's ordinary "you have never run this" empty state — so a run the
* user definitely performed simply vanished, with no error and nothing to
* act on. `expired` in particular is common rather than exotic: run payloads
* lived under a bucket prefix Cloudflare deletes after 7 days.
*/
const outcome: "none" | "expired" | "unreadable" | "ready" | null =
useMemo(() => {
if (!query.data) return null;
if (query.data.status !== "ready") return query.data.status;
try {
const raw: unknown = JSON.parse(query.data.run.resultJson);
return schema.safeParse(raw).success ? "ready" : "unreadable";
} catch {
return "unreadable";
}
}, [query.data, schema]);

const restored = useMemo(() => {
const row = query.data;
const row = query.data?.status === "ready" ? query.data.run : null;
if (!row) return null;

let raw: unknown;
Expand All @@ -74,7 +103,9 @@ export function useAutoRestoredRun<T>({
}

// A stored payload that no longer matches the schema is dropped rather than
// rendered — the tab just falls back to its empty state.
// rendered. Unlike before, this is now REPORTED through `outcome` above —
// silently swallowing it is what made a schema change look like "this tab
// has never been used".
const parsed = schema.safeParse(raw);
if (!parsed.success) return null;

Expand All @@ -100,6 +131,11 @@ export function useAutoRestoredRun<T>({

return {
restored,
outcome,
expired:
query.data?.status === "expired"
? { label: query.data.label, lastRanAt: query.data.lastRanAt }
: null,
isRestoring: enabled && query.isPending,
isError: enabled && query.isError,
isRetrying: enabled && query.isFetching,
Expand Down
8 changes: 8 additions & 0 deletions src/client/features/competitors/CompetitorsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,14 @@ export function CompetitorsPage({
target={target}
competitor={competitor}
competitorRows={competitorRows}
competitorsState={{
isError: competitorsQuery.isError,
isFetching: competitorsQuery.isFetching,
// A restored past run is a real answer too, even though no live
// query ran for it.
hasResult:
competitorsQuery.data != null || restored?.result != null,
}}
gapQuery={gapQuery}
linkGapQuery={linkGapQuery}
onCompareCompetitor={(domain) => {
Expand Down
77 changes: 77 additions & 0 deletions src/client/features/competitors/CompetitorsTabBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function TabBody({
target,
competitor,
competitorRows,
competitorsState,
gapQuery,
linkGapQuery,
onCompareCompetitor,
Expand All @@ -25,6 +26,19 @@ export function TabBody({
competitor: string;
/** Live rows, or a restored past run's when there is no live query. */
competitorRows: CompetitorRow[];
/**
* Whether those rows are an ANSWER. `competitorRows` is built with
* `data?.rows ?? restored?.result.rows ?? []`, so a failed discovery, one
* that never ran, and a genuine zero all arrive here as an empty array —
* and the table turns that into "No competitors found. Try a domain with
* more organic visibility", which is a claim about the user's site rather
* than about our request. This lets the caller say which it was.
*/
competitorsState: {
isError: boolean;
isFetching: boolean;
hasResult: boolean;
};
gapQuery: ReturnType<typeof useKeywordGapQuery>;
linkGapQuery: ReturnType<typeof useLinkGapQuery>;
onCompareCompetitor: (domain: string) => void;
Expand All @@ -37,6 +51,25 @@ export function TabBody({
<EmptyState message="Enter your domain and hit Analyze to discover organic competitors." />
);
}
// Failure outranks emptiness. Without this, a failed or never-run discovery
// reaches the table as `[]` and it reports "No competitors found. Try a
// domain with more organic visibility" — telling the user their site is
// weak when in fact we never got an answer.
if (competitorRows.length === 0) {
if (competitorsState.isError) {
return (
<EmptyState message="Couldn't load competitors. Nothing was charged for the failed request — press Analyze to try again." />
);
}
if (competitorsState.isFetching) {
return <EmptyState message="Discovering competitors…" />;
}
if (!competitorsState.hasResult) {
return (
<EmptyState message="Press Analyze to discover organic competitors for this domain." />
);
}
}
return (
<CompetitorsTable
rows={competitorRows}
Expand All @@ -57,6 +90,50 @@ export function TabBody({
);
}

// Both tables below state something POSITIVE when handed zero rows -- "No
// keywords found for this comparison", and worse, "No link gap found -- every
// domain linking to this competitor also links to you". Those are claims
// about the world, and `data?.rows ?? []` let a query that never ran, or one
// that failed, make them. A comparison the user has not paid for is not a
// comparison that came back empty.
//
// So the tables are only reached once the provider actually answered. The
// states are checked in the same precedence `resolveQueryState` uses:
// failure outranks emptiness, because a failed query has no rows *because it
// failed*.
const activeQuery = tab === "gap" ? gapQuery : linkGapQuery;

if (activeQuery.isError) {
return (
<EmptyState
message={
tab === "gap"
? "Couldn't load the keyword gap. Nothing was charged for the failed request — press Analyze to try again."
: "Couldn't load the link gap. Nothing was charged for the failed request — press Analyze to try again."
}
/>
);
}

if (activeQuery.isFetching) {
return <EmptyState message="Running the comparison…" />;
}

// Never run. Deliberately NOT auto-fetched: this is a metered comparison and
// switching tab or mode invalidates the previous authorization, so the user
// has to ask for it. Say that, rather than showing an empty result.
if (activeQuery.data == null) {
return (
<EmptyState
message={
tab === "gap"
? "Press Analyze to run this keyword comparison."
: "Press Analyze to run this link comparison."
}
/>
);
}

if (tab === "gap") {
return (
<KeywordGapTable
Expand Down
16 changes: 14 additions & 2 deletions src/client/features/competitors/KeywordGapOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,25 @@ export function KeywordGapOverview({
{meta.label}
</span>
</div>
{/* Only the ACTIVE mode is ever fetched — the other two are
separately metered and must not auto-run. But a disabled query
stays `isPending` forever, so they used to sit on loading dots
indefinitely, which reads as "still working" rather than "not
run". Distinguish the two: dots only while genuinely fetching,
otherwise say it needs a run. */}
<div className="mt-1.5 text-xl font-semibold tabular-nums">
{query.isPending ? (
{query.isFetching ? (
<span className="loading loading-dots loading-xs" />
) : query.isError ? (
<span className="text-sm font-normal text-base-content/60">
Couldn&rsquo;t load
</span>
) : count != null ? (
count.toLocaleString()
) : (
"—"
<span className="text-sm font-normal text-base-content/50">
Select to run
</span>
)}
</div>
<div className="mt-0.5 text-xs text-base-content/50">
Expand Down
14 changes: 13 additions & 1 deletion src/client/features/domain/DomainOverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ function useDomainOverviewState({
// paid for, and can never trigger a metered fetch.
// Which past run the user is looking at; null means "the most recent".
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const { restored } = useAutoRestoredRun({
const { restored, expired: restoreExpired } = useAutoRestoredRun({
projectId,
feature: RUN_FEATURES.domainOverview,
schema: domainOverviewResultSchema,
Expand Down Expand Up @@ -566,6 +566,8 @@ function useDomainOverviewState({
overview,
/** Set when `overview` came from a stored past run rather than a live one. */
restoredRun,
/** Set when a past run EXISTS but its stored result has aged out. */
restoreExpired,
selectedRunId,
setSelectedRunId,
refetchOverview: overviewQuery.refetch,
Expand Down Expand Up @@ -785,6 +787,16 @@ export function DomainOverviewPage({
}}
isBusy={state.isLoading}
/>
{/* A run whose stored result is gone is NOT the same as never having
used this tab, but both used to render exactly this prompt. Say
which it was, so the blank screen stops looking like a bug. */}
{state.restoreExpired ? (
<div className="rounded-lg border border-base-300 bg-base-200/40 px-4 py-3 text-sm text-base-content/70">
Your last run ({state.restoreExpired.label}) is too old to re-open
— stored results are kept for 90 days. Running it again will
refresh it.
</div>
) : null}
<DomainHistorySection
history={state.history}
historyLoaded={state.historyLoaded}
Expand Down
5 changes: 4 additions & 1 deletion src/client/features/insights/useLastRunInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ export function useLastRunInput(
});

return useMemo(() => {
const row = query.data;
// `restoreLatestRun` now reports WHY there is nothing, so an expired or
// missing run is distinguishable. This hook only wants a prefill value and
// has nothing useful to say about either, so both stay null.
const row = query.data?.status === "ready" ? query.data.run : null;
if (!row) return null;

let parsed: unknown;
Expand Down
14 changes: 14 additions & 0 deletions src/client/features/page-explorer/PageExplorerResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,29 @@ export function PageExplorerResults({
hint={`Top ${result.keywords.length} shown`}
tone="info"
/>
{/* The backlink lookup is a separate best-effort subcall, so these two
tiles showed a dash both when the page genuinely has no backlink
data and when the call FAILED. `backlinksStatus` separates them, so
a failure now says so instead of quietly reading as zero. */}
<InsightTile
icon={Link2}
label="Backlinks"
value={formatCount(result.backlinks?.backlinks)}
hint={
result.backlinksStatus === "error"
? "Backlink data couldn't be loaded"
: undefined
}
/>
<InsightTile
icon={Network}
label="Ref. domains"
value={formatCount(result.backlinks?.referringDomains)}
hint={
result.backlinksStatus === "error"
? "Backlink data couldn't be loaded"
: undefined
}
/>
<InsightTile
icon={Award}
Expand Down
Loading
Loading