Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
e3d2cf4
feat(engine): competitor structured diff
hoaxnerd Jun 29, 2026
4bd4ffe
feat(engine): competitor rule/alert layer
hoaxnerd Jun 29, 2026
0a7c1d7
feat(db): competitor schema (competitors/sources/snapshots/changes)
hoaxnerd Jun 29, 2026
7ddd9f4
feat(db): competitor queries + PGlite tests
hoaxnerd Jun 29, 2026
357f852
fix(db): remove unused imports + tighten competitor query test assert…
hoaxnerd Jun 29, 2026
c2a3e06
feat(competitor): collector contract + pricing collector
hoaxnerd Jun 29, 2026
d63dea5
feat(competitor): social collector
hoaxnerd Jun 29, 2026
8c5838c
feat(competitor): sync pipeline — snapshot/diff/alert/notify
hoaxnerd Jun 29, 2026
30a6740
feat(competitor): hourly system job runs due competitor syncs
hoaxnerd Jun 29, 2026
56a2e96
feat(competitor): read-only AI tools (list_competitors, list_competit…
hoaxnerd Jun 29, 2026
96d6d77
feat(competitor): register DomainModule (contributor + nav + gating)
hoaxnerd Jun 29, 2026
0eb4b88
fix(competitor): remove hardcoded currency symbol from ai-tools test …
hoaxnerd Jun 29, 2026
cd8bd86
feat(competitor): competitors CRUD API
hoaxnerd Jun 29, 2026
8d8c371
feat(competitor): sources/sync/changes API
hoaxnerd Jun 29, 2026
a4f660b
feat(competitor): SWR hooks (live-updating via mutation bus)
hoaxnerd Jun 29, 2026
7aa7b2b
feat(competitor): competitors list + add UI
hoaxnerd Jun 29, 2026
acb8855
feat(competitor): competitor profile + change timeline UI
hoaxnerd Jun 29, 2026
3324dfb
fix(competitor): render competitor plan prices in their own currency …
hoaxnerd Jun 29, 2026
241ed16
fix(competitor): guard ?limit= NaN edge in changes route
hoaxnerd Jun 29, 2026
696a606
fix(competitor): validate scraped plan currency before formatting (gu…
hoaxnerd Jun 29, 2026
19666c7
fix(competitor): guard pricing collector against noisy/priceless fals…
hoaxnerd Jun 29, 2026
76c3205
feat(competitor): add normalizeHtml engine helper (Tier-1 detection f…
hoaxnerd Jul 4, 2026
0f8db47
feat(competitor): add diffText + contentChangeAlert (Tier-1 change ou…
hoaxnerd Jul 4, 2026
447e70e
fix(competitor): derive per-side truncated in contentChangeAlert
hoaxnerd Jul 4, 2026
f47cbcb
feat(competitor): add normalized text + hash columns to snapshots (ad…
hoaxnerd Jul 4, 2026
4b70eb4
fix(competitor): make insertSnapshot normalized fields required
hoaxnerd Jul 4, 2026
232f27d
feat(competitor): reframe pipeline to Tier-1 content-diff + confidenc…
hoaxnerd Jul 4, 2026
291ec6f
feat(competitor): accept generic page-watcher source type in route sc…
hoaxnerd Jul 4, 2026
ff7767c
test(competitor): allowlist reframe test dirs in no-hardcoded-currenc…
hoaxnerd Jul 4, 2026
89e8b15
test(competitor): scope db currency-guard allowlist to competitor.tes…
hoaxnerd Jul 4, 2026
282c988
refactor(competitor): extract shared analysis body + content-change s…
hoaxnerd Jul 4, 2026
52debe4
fix(competitor): restore RSC refresh on sync in shared analysis body
hoaxnerd Jul 4, 2026
5d17403
feat(competitor): analysis dashboard with expandable per-competitor c…
hoaxnerd Jul 4, 2026
0c6e31e
fix(competitor): valid DOM nesting in analysis card header
hoaxnerd Jul 4, 2026
920b675
feat(competitor): relocate management to /competitors/manage + page-w…
hoaxnerd Jul 4, 2026
a8c65b4
polish(competitor): retitle manage page to disambiguate from dashboard
hoaxnerd Jul 4, 2026
63944ae
fix(competitor): demote react-hooks/purity to warn for Server Compone…
hoaxnerd Jul 4, 2026
8c8f7fd
fix(competitor): suppress spurious content alert on null-normalized r…
hoaxnerd Jul 4, 2026
df6de79
docs(competitor): update SourceDto.type doc comment to include page
hoaxnerd Jul 4, 2026
ecdea16
feat(competitor): feed/sitemap rules with drop-null + anti-flood cap
hoaxnerd Jul 9, 2026
4394f5c
feat(competitor): RSS/Atom feed collector (id-keyed items)
hoaxnerd Jul 9, 2026
24bdf74
test(competitor): allowlist feed.ts CDATA $1 backreference in currenc…
hoaxnerd Jul 9, 2026
8ed18e9
feat(competitor): sitemap.xml collector (url set + count)
hoaxnerd Jul 9, 2026
bf65b94
feat(competitor): deterministic feed/sitemap auto-discovery
hoaxnerd Jul 9, 2026
29235ff
feat(competitor): accept feed/sitemap types + /detect discovery endpoint
hoaxnerd Jul 9, 2026
b1d5d4b
fix(competitor): narrow strict-null regex/index access in feed + disc…
hoaxnerd Jul 9, 2026
10953d7
test(competitor): pipeline integration for feed/sitemap collectors
hoaxnerd Jul 9, 2026
a90ea2e
feat(competitor): Recent posts + Site structure analysis summaries
hoaxnerd Jul 9, 2026
dc44f77
feat(competitor): manual feed/sitemap fields + Detect feeds action
hoaxnerd Jul 9, 2026
e64f88d
fix(competitor): resolve CodeQL sanitization + Sonar engine findings …
hoaxnerd Jul 9, 2026
5020dff
fix(competitor): resolve Sonar findings in collectors + pipeline + di…
hoaxnerd Jul 9, 2026
b74a52b
fix(competitor): resolve Sonar findings in competitor UI + swr (clust…
hoaxnerd Jul 9, 2026
3510bcc
fix(competitor): resolve Sonar route/test/fixture findings (cluster D)
hoaxnerd Jul 9, 2026
eed161a
fix(competitor): strip feed tags after entity-decode to clear CodeQL …
hoaxnerd Jul 9, 2026
baa4c71
fix(competitor): fixpoint tag-strip in feed decode (CodeQL sanitizati…
hoaxnerd Jul 9, 2026
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
8 changes: 5 additions & 3 deletions apps/web/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ const config = [
// Next 16 bundles eslint-plugin-react-hooks v6 (React-Compiler-era) which
// promotes these to errors. Our existing usages are intentional, benign
// patterns (reset-optimistic-overlay-on-SWR-refresh effects; a monotonic
// key counter in a once-only lazy useState initializer) — keep them as
// warnings to match this repo's lenient lint posture. Revisit if we adopt
// the React Compiler.
// key counter in a once-only lazy useState initializer; Date.now() in async
// Server Components whose renders are request-scoped, not pure-functional) —
// keep them as warnings to match this repo's lenient lint posture. Revisit
// if we adopt the React Compiler.
"react-hooks/set-state-in-effect": "warn",
"react-hooks/refs": "warn",
"react-hooks/purity": "warn",
},
},
];
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/__tests__/no-hardcoded-currency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,25 @@ const ALLOWED = [
// static bound labels on range inputs (min/max markers), not user data.
"apps/web/src/app/(dashboard)/funding/funding-details.tsx",

// ── Competitor pricing collector ─────────────────────────────────────────
// pricing.ts: /(\$|€|EUR|£|GBP)?/ and /(\$|€|£)/ regex patterns used to
// PARSE currency symbols from third-party competitor HTML pages.
// symbolToCurrency map normalises parsed glyphs to ISO codes. Not display code.
"apps/web/src/lib/competitor/collectors/pricing.ts",

// ── Competitor collector-reframe tests: scraped-HTML fixtures ─────────────
// The Tier-1/Tier-2 tests use `$29`/`$39`/`$${amount}` INSIDE sample
// competitor HTML strings (what we fetch + normalize + diff) and as expected
// normalized-text assertions — third-party scraped-price INPUT, never app
// display code. Same rationale as the pricing.ts collector entry above.
// pipeline.test.ts (Tier-1/2 HTML fixtures), engine normalize/text-diff tests
// (normalized-text fixtures), db competitor.test.ts (snapshot raw/normalized).
"apps/web/src/lib/competitor/__tests__/",
"packages/engine/src/competitor/__tests__/",
// File-scoped (not the whole db __tests__ dir): only competitor.test.ts holds
// scraped-price fixtures; the DB layer has no display code to guard elsewhere.
"packages/db/src/__tests__/competitor.test.ts",

// ── CSV import parser ─────────────────────────────────────────────────────
// import-flow.tsx line 134: /[$,€£()]/ in a regex to STRIP currency
// characters from user-supplied CSV amounts. Not display code.
Expand Down Expand Up @@ -225,6 +244,11 @@ const ALLOWED = [
"apps/web/src/app/(dashboard)/ai/_components/generative/diff-gate.tsx",
"apps/web/src/__tests__/component-reachability.test.ts",
"apps/web/src/__tests__/no-console-in-production.test.ts",
// feed.ts line 8: `.replace(/<![CDATA[…]]>/g, "$1")` — a String.replace()
// backreference stripping CDATA wrappers from RSS/Atom feed text. `$1` is a
// regex capture-group reference, not a currency amount. Same false-positive
// class as the camelCase-splitter entries above.
"apps/web/src/lib/competitor/collectors/feed.ts",
];

/**
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/__tests__/no-mutation-invalidation-leak.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ const ALLOWED: { match: string; why: string }[] = [
match: "mcp/oauth/callback",
why: "Completes an MCP OAuth handshake and re-probes the connection, invalidating only `mcp-connections` (non-financial). Same rationale as mcp/connections — no financial data changed.",
},
{
match: "competitors",
why: "Competitor CRUD invalidates only the non-financial `competitors` cache (competitor list UI). No financial metric depends on competitor data and there is no MutationSource for competitors, so trackDataMutation must NOT fire — bumping it would start a bogus insight-regeneration grace countdown for changes that affect no financial compute.",
},
];

function isAllowed(rel: string): boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"use client";

/**
* Competitor profile + change timeline view (Task 15). Nested route under
* /competitors — mirrors the cap-table / transactions-accounts nesting
* precedent (back-link header) and the Task-14 competitors-view conventions:
* design-system components only, SSR-seeded SWR (useCompetitorChanges
* fallbackData) for a live-updating timeline, and inline token-styled status
* spans following the StatusPill precedent (no new badge component).
*
* This view is now a thin wrapper: its own back-link + name + external-URL
* header, plus the shared <CompetitorAnalysisBody /> which renders the Sources
* (+ "Sync now"), Latest pricing, and Activity timeline. The same body is
* mounted on the dashboard card, so the analysis renders identically in both.
*
* Pricing snapshots are competitor-scraped data; prices are rendered (inside
* the shared body) via formatCurrency() from @burnless/types using the plan's
* OWN scraped currency (falling back to the company currency) — never a
* hardcoded symbol and never forcing the company currency onto a competitor's
* price.
*/

import Link from "next/link";
import { ArrowLeft, ExternalLink } from "lucide-react";
import type { CompetitorChangesPayload } from "@/lib/swr";
import { CompetitorAnalysisBody } from "../competitor-analysis-body";

// ── JSON-safe prop DTOs (Date → ISO string; mirrors the list-page DTO style) ──

export interface CompetitorProfileDto {
id: string;
name: string;
url: string;
}

export interface SourceDto {
id: string;
/** "pricing" | "social" | "page" */
type: string;
url: string;
enabled: boolean;
lastRunAt: string | null;
lastStatus: string | null;
/** "ok" | "broken" */
healthState: string;
}

interface PlanDto {
name: string;
price: { amount: number | null; currency: string | null; period: string | null };
features: string[];
}

export interface SnapshotDto {
id: string;
sourceId: string;
capturedAt: string;
structured: {
plans?: PlanDto[];
items?: Record<string, { title: string; link: string; publishedAt: string | null }>;
urls?: Record<string, unknown>;
count?: number;
} | null;
}

interface CompetitorProfileViewProps {
readonly competitor: CompetitorProfileDto;
readonly sources: SourceDto[];
/** Latest snapshot per source, aligned by index with `sources`. */
readonly latestSnapshots: (SnapshotDto | null)[];
readonly initialChanges: CompetitorChangesPayload;
}

// ── View ────────────────────────────────────────────────────────────────────

export function CompetitorProfileView({
competitor,
sources,
latestSnapshots,
initialChanges,
}: CompetitorProfileViewProps) {
return (
<div className="space-y-6">
<header className="space-y-1">
<Link
href="/competitors"
className="inline-flex items-center gap-1 text-sm text-surface-500 hover:text-surface-900"
>
<ArrowLeft className="h-4 w-4" />
Back to Competitors
</Link>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-surface-900">
{competitor.name}
</h1>
<a
href={competitor.url}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-sm text-surface-500 hover:text-brand-600 transition-colors"
>
{competitor.url}
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</header>

<CompetitorAnalysisBody
competitor={competitor}
sources={sources}
latestSnapshots={latestSnapshots}
initialChanges={initialChanges}
/>
</div>
);
}
122 changes: 122 additions & 0 deletions apps/web/src/app/(dashboard)/competitors/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
export const dynamic = "force-dynamic";
export const revalidate = 0;

import { Suspense } from "react";
import { notFound } from "next/navigation";
import {
getCompetitor,
listSources,
listChanges,
getLatestSnapshot,
} from "@burnless/db";
import { getCompany } from "@/lib/data";
import { isDomainEnabled } from "@/lib/domain-gating";
import { SetupPrompt } from "@/components/ui/empty-state";
import { ReportContentSkeleton } from "@/components/reports/report-skeleton";
import type { CompetitorChangesPayload } from "@/lib/swr";
import {
CompetitorProfileView,
type CompetitorProfileDto,
type SourceDto,
type SnapshotDto,
} from "./competitor-profile-view";

export default async function CompetitorPage({
params,
}: Readonly<{
params: Promise<{ id: string }>;
}>) {
const { id } = await params;

const company = await getCompany();
if (!company) return <SetupPrompt context="tracking competitors" />;

// Page-level domain gate — mirrors the requireDomainEnabled guard the REST
// routes use and the Task-14 list page. If the competitor domain is off for
// this company/deployment, the route 404s.
if (!(await isDomainEnabled("competitor", { companyId: company.id }))) {
notFound();
}

const competitor = await getCompetitor(id, company.id);
if (!competitor) notFound();

return (
<Suspense fallback={<ReportContentSkeleton />}>
<CompetitorContent companyId={company.id} competitorId={id} competitor={competitor} />
</Suspense>
);
}

async function CompetitorContent({
companyId,
competitorId,
competitor,
}: Readonly<{
companyId: string;
competitorId: string;
competitor: NonNullable<Awaited<ReturnType<typeof getCompetitor>>>;
}>) {
const [sources, changes] = await Promise.all([
listSources(competitorId, companyId),
listChanges(companyId, { competitorId, limit: 50 }),
]);
const snapshots = await Promise.all(sources.map((s) => getLatestSnapshot(s.id)));

// Shape everything to JSON-safe DTOs (Date → ISO string) so the SSR seed
// matches the client SWR fetch exactly (fallbackData applies cleanly) and no
// live Date objects cross the RSC → client boundary.
const competitorDto: CompetitorProfileDto = {
id: competitor.id,
name: competitor.name,
url: competitor.url,
};

const sourceDtos: SourceDto[] = sources.map((s) => ({
id: s.id,
type: s.type,
url: s.url,
enabled: s.enabled,
lastRunAt: s.lastRunAt ? s.lastRunAt.toISOString() : null,
lastStatus: s.lastStatus,
healthState: s.healthState,
}));

const latestSnapshots: (SnapshotDto | null)[] = snapshots.map((snap) =>
snap
? {
id: snap.id,
sourceId: snap.sourceId,
capturedAt: snap.capturedAt.toISOString(),
structured: snap.structured as SnapshotDto["structured"],
}
: null,
);

const initialChanges: CompetitorChangesPayload = {
changes: changes.map((c) => ({
id: c.id,
competitorId: c.competitorId,
sourceId: c.sourceId,
snapshotId: c.snapshotId,
companyId: c.companyId,
detectedAt: c.detectedAt.toISOString(),
changeType: c.changeType,
summary: c.summary,
before: c.before as Record<string, unknown> | null,
after: c.after as Record<string, unknown> | null,
severity: c.severity,
acknowledgedAt: c.acknowledgedAt ? c.acknowledgedAt.toISOString() : null,
createdAt: c.createdAt.toISOString(),
})),
};

return (
<CompetitorProfileView
competitor={competitorDto}
sources={sourceDtos}
latestSnapshots={latestSnapshots}
initialChanges={initialChanges}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { CompetitorAnalysisBody } from "../competitor-analysis-body";

// WidgetCard (design-system) calls useRouter — mount a stub app router.
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));

// happy-dom + SWR fallbackData → no fetch needed.
const base = {
competitor: { id: "c1", name: "Acme", url: "https://acme.com" },
sources: [
{ id: "s1", type: "page", url: "https://acme.com/x", enabled: true, lastRunAt: null, lastStatus: null, healthState: "ok" },
],
latestSnapshots: [null],
};

describe("CompetitorAnalysisBody", () => {
it("renders a content_changed row with its summary and an expandable snippet", () => {
const initialChanges = {
changes: [
{
id: "ch1", competitorId: "c1", sourceId: "s1", snapshotId: "sn1", companyId: "co1",
detectedAt: new Date("2026-07-01").toISOString(),
changeType: "content_changed",
summary: "Page content changed: +1 / -1 lines",
before: { lines: ["Old line"], truncated: false },
after: { lines: ["New line"], truncated: false },
severity: "info", acknowledgedAt: null, createdAt: new Date("2026-07-01").toISOString(),
},
],
};
render(<CompetitorAnalysisBody {...base} initialChanges={initialChanges} />);
expect(screen.getByText(/Page content changed/)).toBeTruthy();
// Disclosure content present (details/summary): added + removed lines.
// The snippet renders each line with a diff prefix ("+ New line" / "- Old
// line"), so match with a regex rather than an exact string.
expect(screen.getByText(/New line/)).toBeTruthy();
expect(screen.getByText(/Old line/)).toBeTruthy();
});

it("renders Recent posts from a feed snapshot and Pages-tracked from a sitemap snapshot", () => {
const props = {
competitor: { id: "c1", name: "Acme", url: "https://acme.com" },
sources: [
{ id: "f1", type: "feed", url: "https://acme.com/rss", enabled: true, lastRunAt: null, lastStatus: null, healthState: "ok" },
{ id: "m1", type: "sitemap", url: "https://acme.com/sitemap.xml", enabled: true, lastRunAt: null, lastStatus: null, healthState: "ok" },
],
latestSnapshots: [
{ id: "sn1", sourceId: "f1", capturedAt: new Date("2026-07-01").toISOString(), structured: { items: { g1: { title: "Launch Day", link: "https://acme.com/p/1", publishedAt: new Date("2026-07-01").toISOString() } } } },
{ id: "sn2", sourceId: "m1", capturedAt: new Date("2026-07-01").toISOString(), structured: { urls: { "https://acme.com/": 1, "https://acme.com/pricing": 1 }, count: 2 } },
],
initialChanges: { changes: [] },
};
render(<CompetitorAnalysisBody {...props} />);
expect(screen.getByText("Launch Day")).toBeTruthy();
expect(screen.getByText(/Pages tracked/i)).toBeTruthy();
expect(screen.getByText("2")).toBeTruthy();
});
});
Loading
Loading