Blank page on older browsers: replace ES2023 array methods with Remeda (#535)

This commit is contained in:
Ben Senescu 2026-08-26 10:02:03 -04:00 committed by GitHub
parent 8db0dd3246
commit b631715112
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 197 additions and 154 deletions

View File

@ -17,6 +17,8 @@
"rules": { "rules": {
"react/react-in-jsx-scope": "off", "react/react-in-jsx-scope": "off",
"react/jsx-uses-react": "off", "react/jsx-uses-react": "off",
// Flags mutating .sort(); its suggested .toSorted() fix doesn't compile
// here (lib is ES2022) fix with Remeda's sort/sortBy instead.
"unicorn/no-array-sort": "error", "unicorn/no-array-sort": "error",
"typescript/no-explicit-any": "error", "typescript/no-explicit-any": "error",
"typescript/consistent-type-imports": "error", "typescript/consistent-type-imports": "error",

View File

@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type FormEvent } from "react"; import { useEffect, useRef, useState, type FormEvent } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { identity, sortBy } from "remeda";
import { import {
AlertCircle, AlertCircle,
ArrowLeft, ArrowLeft,
@ -90,7 +91,7 @@ function PromptExplorerPageInner({
"prompt-explorer", "prompt-explorer",
projectId, projectId,
trimmedPrompt, trimmedPrompt,
urlState.models.toSorted().join(","), sortBy(urlState.models, identity()).join(","),
urlState.webSearch, urlState.webSearch,
urlState.webSearchCountryCode, urlState.webSearchCountryCode,
urlState.highlightBrand.trim(), urlState.highlightBrand.trim(),
@ -132,7 +133,7 @@ function PromptExplorerPageInner({
const key = [ const key = [
trimmedPrompt, trimmedPrompt,
urlState.highlightBrand.trim(), urlState.highlightBrand.trim(),
urlState.models.toSorted().join(","), sortBy(urlState.models, identity()).join(","),
urlState.webSearch, urlState.webSearch,
urlState.webSearchCountryCode, urlState.webSearchCountryCode,
].join("|"); ].join("|");

View File

@ -1,5 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { sort } from "remeda";
import { import {
getIssueDescriptor, getIssueDescriptor,
ISSUE_SEVERITY_ORDER, ISSUE_SEVERITY_ORDER,
@ -68,7 +69,8 @@ function groupIssues(issues: AuditIssueRow[]): IssueGroup[] {
group.issues.push(issue); group.issues.push(issue);
} }
return Array.from(groups.values()).toSorted( return sort(
Array.from(groups.values()),
(a, b) => (a, b) =>
ISSUE_SEVERITY_ORDER[a.severity] - ISSUE_SEVERITY_ORDER[b.severity] || ISSUE_SEVERITY_ORDER[a.severity] - ISSUE_SEVERITY_ORDER[b.severity] ||
b.issues.length - a.issues.length, b.issues.length - a.issues.length,

View File

@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { sort } from "remeda";
import { import {
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
@ -124,13 +125,15 @@ export function getBillingFeatureBreakdownRows(
creditsByLabel.set(label, (creditsByLabel.get(label) ?? 0) + event.value); creditsByLabel.set(label, (creditsByLabel.get(label) ?? 0) + event.value);
} }
return [...creditsByLabel.entries()] return sort(
.map(([label, credits]) => ({ [...creditsByLabel.entries()]
label, .map(([label, credits]) => ({
usd: autumnSeoDataCreditsToUsd(credits), label,
})) usd: autumnSeoDataCreditsToUsd(credits),
.filter((row) => row.usd > 0) }))
.toSorted((a, b) => b.usd - a.usd); .filter((row) => row.usd > 0),
(a, b) => b.usd - a.usd,
);
} }
export function BillingFeatureBreakdown() { export function BillingFeatureBreakdown() {

View File

@ -3,6 +3,7 @@ import { Link, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { ChevronLeft, ChevronRight, Check } from "lucide-react"; import { ChevronLeft, ChevronRight, Check } from "lucide-react";
import { sort } from "remeda";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { import {
computeNextStep, computeNextStep,
@ -301,6 +302,61 @@ export function DashboardPage({ projectId }: { projectId: string }) {
const gscConnected = activation.gsc.connected; const gscConnected = activation.gsc.connected;
const ga4Connected = activation.ga4.connected; const ga4Connected = activation.ga4.connected;
// Array order is the within-bucket order after the data-first sort below:
// the MCP pitch leads the setup cards.
const cards = [
...(activation.mcp.firstToolCallAt || activation.mcp.cardDismissedAt
? []
: [
{
key: "mcp",
hasData: false,
node: (
<McpConnectCard projectId={projectId} activation={activation} />
),
},
]),
{
key: "gsc",
hasData: gscConnected,
node: <GscCard projectId={projectId} connected={gscConnected} />,
},
...(ga4Connected || !activation.ga4.cardDismissedAt
? [
{
key: "ga4",
hasData: ga4Connected,
node: <Ga4Card projectId={projectId} connected={ga4Connected} />,
},
]
: []),
{
key: "audit",
hasData: overview?.audit != null,
node: (
<AuditHealthCard
projectId={projectId}
audit={overview?.audit ?? null}
/>
),
},
...(showBacklinks
? [
{
key: "backlinks",
hasData: overview?.backlinks != null || refreshMutation.isPending,
node: (
<BacklinkPulseCard
projectId={projectId}
backlinks={overview?.backlinks ?? null}
refreshing={refreshMutation.isPending}
/>
),
},
]
: []),
];
return ( return (
<div className="px-4 py-4 pb-24 md:px-6 md:py-6 md:pb-8"> <div className="px-4 py-4 pb-24 md:px-6 md:py-6 md:pb-8">
<div className="mx-auto flex max-w-5xl flex-col gap-5"> <div className="mx-auto flex max-w-5xl flex-col gap-5">
@ -313,70 +369,11 @@ export function DashboardPage({ projectId }: { projectId: string }) {
{/* Every card is half width on large screens (only the checklist spans). {/* Every card is half width on large screens (only the checklist spans).
Cards with data render before setup pitches and empty states. */} Cards with data render before setup pitches and empty states. */}
<div className="grid items-start gap-5 lg:grid-cols-2"> <div className="grid items-start gap-5 lg:grid-cols-2">
{[ {sort(cards, (a, b) => Number(b.hasData) - Number(a.hasData)).map(
// Array order is the within-bucket order after the data-first sort: (card) => (
// the MCP pitch leads the setup cards.
...(activation.mcp.firstToolCallAt || activation.mcp.cardDismissedAt
? []
: [
{
key: "mcp",
hasData: false,
node: (
<McpConnectCard
projectId={projectId}
activation={activation}
/>
),
},
]),
{
key: "gsc",
hasData: gscConnected,
node: <GscCard projectId={projectId} connected={gscConnected} />,
},
...(ga4Connected || !activation.ga4.cardDismissedAt
? [
{
key: "ga4",
hasData: ga4Connected,
node: (
<Ga4Card projectId={projectId} connected={ga4Connected} />
),
},
]
: []),
{
key: "audit",
hasData: overview?.audit != null,
node: (
<AuditHealthCard
projectId={projectId}
audit={overview?.audit ?? null}
/>
),
},
...(showBacklinks
? [
{
key: "backlinks",
hasData:
overview?.backlinks != null || refreshMutation.isPending,
node: (
<BacklinkPulseCard
projectId={projectId}
backlinks={overview?.backlinks ?? null}
refreshing={refreshMutation.isPending}
/>
),
},
]
: []),
]
.toSorted((a, b) => Number(b.hasData) - Number(a.hasData))
.map((card) => (
<div key={card.key}>{card.node}</div> <div key={card.key}>{card.node}</div>
))} ),
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,3 +1,4 @@
import { identity, sortBy } from "remeda";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { KeywordIntent, KeywordResearchRow } from "@/types/keywords"; import type { KeywordIntent, KeywordResearchRow } from "@/types/keywords";
import { import {
@ -85,10 +86,12 @@ describe("applyKeywordFiltersAndSort — intent filtering", () => {
it("keeps rows matching any of multiple selected intents", () => { it("keeps rows matching any of multiple selected intents", () => {
const result = filter(rows, { intents: "transactional,commercial" }); const result = filter(rows, { intents: "transactional,commercial" });
expect(result.map((r) => r.keyword).toSorted()).toEqual([ expect(
"best running shoes", sortBy(
"buy running shoes", result.map((r) => r.keyword),
]); identity(),
),
).toEqual(["best running shoes", "buy running shoes"]);
}); });
it("combines the intent filter with other filters (AND)", () => { it("combines the intent filter with other filters (AND)", () => {

View File

@ -8,6 +8,7 @@ import {
Sheet, Sheet,
SlidersHorizontal, SlidersHorizontal,
} from "lucide-react"; } from "lucide-react";
import { sortBy } from "remeda";
import { import {
downloadKeywordResearchCsv, downloadKeywordResearchCsv,
KEYWORD_RESEARCH_HEADERS, KEYWORD_RESEARCH_HEADERS,
@ -56,9 +57,7 @@ const MONTH_SHORT_LABELS = [
function formatTrendRangeLabel(trend: KeywordResearchRow["trend"]): string { function formatTrendRangeLabel(trend: KeywordResearchRow["trend"]): string {
if (trend.length === 0) return "Last 12 available months"; if (trend.length === 0) return "Last 12 available months";
const sorted = trend.toSorted( const sorted = sortBy(trend, (item) => item.year * 100 + item.month);
(a, b) => a.year * 100 + a.month - (b.year * 100 + b.month),
);
const last12 = sorted.slice(-12); const last12 = sorted.slice(-12);
const start = last12[0]; const start = last12[0];
const end = last12[last12.length - 1]; const end = last12[last12.length - 1];

View File

@ -1,6 +1,7 @@
import * as React from "react"; import * as React from "react";
import { Link, useRouter } from "@tanstack/react-router"; import { Link, useRouter } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { findLast } from "remeda";
import { import {
Check, Check,
ChevronsUpDown, ChevronsUpDown,
@ -92,7 +93,8 @@ export function ProjectSwitcher({
// project, so they fall back to their section. Filtering on the path // project, so they fall back to their section. Filtering on the path
// template rather than match.params matters: the router gives every match // template rather than match.params matters: the router gives every match
// the location's full param set, so params can't tell layers apart. // the location's full param set, so params can't tell layers apart.
const stayable = router.state.matches.findLast( const stayable = findLast(
router.state.matches,
(match) => (match) =>
match.fullPath.includes("$projectId") && match.fullPath.includes("$projectId") &&
match.fullPath match.fullPath

View File

@ -1,5 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Copy, Download, Loader2 } from "lucide-react"; import { Copy, Download, Loader2 } from "lucide-react";
import { reverse, sortBy } from "remeda";
import { toast } from "sonner"; import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Modal } from "@/client/components/Modal"; import { Modal } from "@/client/components/Modal";
@ -363,7 +364,7 @@ function buildChartData(
row[p.device] = p.position === null ? serpDepth : p.position; row[p.device] = p.position === null ? serpDepth : p.position;
byTime.set(ts, row); byTime.set(ts, row);
} }
return [...byTime.values()].toSorted((a, b) => a.checkedAt - b.checkedAt); return sortBy([...byTime.values()], (row) => row.checkedAt);
} }
interface HistoryRow { interface HistoryRow {
@ -393,7 +394,7 @@ function buildHistoryRows(points: RankKeywordHistoryPoint[]): HistoryRow[] {
}); });
prevByDevice.set(p.device, p.position); prevByDevice.set(p.device, p.position);
} }
return rows.toReversed(); return reverse(rows);
} }
function slugify(value: string): string { function slugify(value: string): string {

View File

@ -1,3 +1,4 @@
import { sort } from "remeda";
import { LOCATIONS } from "@/client/features/keywords/locations"; import { LOCATIONS } from "@/client/features/keywords/locations";
import { devicesLabel } from "@/shared/rank-tracking"; import { devicesLabel } from "@/shared/rank-tracking";
import type { import type {
@ -106,10 +107,13 @@ export function getDomainListFilterOptions(configs: DomainFilterableConfig[]): {
); );
} }
const locations = Array.from(locationMap, ([code, label]) => ({ const locations = sort(
value: String(code), Array.from(locationMap, ([code, label]) => ({
label, value: String(code),
})).toSorted((a, b) => a.label.localeCompare(b.label)); label,
})),
(a, b) => a.label.localeCompare(b.label),
);
return { devices, locations }; return { devices, locations };
} }

View File

@ -1,5 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { sort } from "remeda";
import type { RankPositionMatrixCell } from "@/serverFunctions/rank-tracking"; import type { RankPositionMatrixCell } from "@/serverFunctions/rank-tracking";
/** /**
@ -131,9 +132,10 @@ function buildMatrix(cells: RankPositionMatrixCell[]): {
} }
byRun.set(c.runId, c.position); byRun.set(c.runId, c.position);
} }
const runs = [...runMap.entries()] const runs = sort(
.map(([runId, checkedAt]) => ({ runId, checkedAt })) [...runMap.entries()].map(([runId, checkedAt]) => ({ runId, checkedAt })),
.toSorted((a, b) => a.checkedAt.localeCompare(b.checkedAt)); (a, b) => a.checkedAt.localeCompare(b.checkedAt),
);
return { runs, cellByKeyword }; return { runs, cellByKeyword };
} }

View File

@ -1,3 +1,4 @@
import { identity, sortBy } from "remeda";
import { z } from "zod"; import { z } from "zod";
import { useTimestampedSearchHistory } from "@/client/hooks/useTimestampedSearchHistory"; import { useTimestampedSearchHistory } from "@/client/hooks/useTimestampedSearchHistory";
import { import {
@ -21,8 +22,8 @@ export type PromptExplorerSearchHistoryItem = PromptExplorerSearchBody & {
function sameModels(a: string[], b: string[]): boolean { function sameModels(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
const sortedA = a.toSorted(); const sortedA = sortBy(a, identity());
const sortedB = b.toSorted(); const sortedB = sortBy(b, identity());
return sortedA.every((model, index) => model === sortedB[index]); return sortedA.every((model, index) => model === sortedB[index]);
} }

View File

@ -3,6 +3,7 @@ import { join } from "node:path";
import { getTableColumns, getTableName, is, Table } from "drizzle-orm"; import { getTableColumns, getTableName, is, Table } from "drizzle-orm";
import { getTableConfig as getSqliteTableConfig } from "drizzle-orm/sqlite-core"; import { getTableConfig as getSqliteTableConfig } from "drizzle-orm/sqlite-core";
import { getTableConfig as getPgTableConfig } from "drizzle-orm/pg-core"; import { getTableConfig as getPgTableConfig } from "drizzle-orm/pg-core";
import { sort } from "remeda";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import * as sqliteApp from "./app.schema"; import * as sqliteApp from "./app.schema";
import * as sqliteProjectContext from "./project-context.schema"; import * as sqliteProjectContext from "./project-context.schema";
@ -32,7 +33,7 @@ import * as pgTelemetry from "./pg/telemetry.schema";
type Dialect = "sqlite" | "pg"; type Dialect = "sqlite" | "pg";
const sortStrings = (values: string[]) => const sortStrings = (values: string[]) =>
values.toSorted((a, b) => a.localeCompare(b)); sort(values, (a, b) => a.localeCompare(b));
function asStringArray(value: unknown): string[] | null { function asStringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null; if (!Array.isArray(value)) return null;

View File

@ -1,4 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
// Aliased: `SavedKeywordsPage` has a local `sort` const (the saved-keyword
// sort key) that would otherwise shadow this import at the call site.
import { sort as sortArray } from "remeda";
import { import {
keepPreviousData, keepPreviousData,
useMutation, useMutation,
@ -130,7 +133,7 @@ function SavedKeywordsPage() {
if (!map.has(tag.id)) map.set(tag.id, tag); if (!map.has(tag.id)) map.set(tag.id, tag);
} }
} }
return [...map.values()].toSorted((a, b) => return sortArray([...map.values()], (a, b) =>
a.normalizedName.localeCompare(b.normalizedName), a.normalizedName.localeCompare(b.normalizedName),
); );
}, [selectedRows]); }, [selectedRows]);

View File

@ -1,5 +1,6 @@
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import { and, eq, inArray, isNull, like } from "drizzle-orm"; import { and, eq, inArray, isNull, like } from "drizzle-orm";
import { firstBy, identity } from "remeda";
import { db } from "@/db"; import { db } from "@/db";
import { runBatch } from "@/db/runBatch"; import { runBatch } from "@/db/runBatch";
import { getAuthMode } from "@/lib/auth-mode"; import { getAuthMode } from "@/lib/auth-mode";
@ -18,8 +19,10 @@ import { SHARED_WORKSPACE_ORGANIZATION_ID } from "./delegated-organization";
// chronological for these). // chronological for these).
function earliest(values: (string | null)[]) { function earliest(values: (string | null)[]) {
return ( return (
values.filter((value): value is string => value !== null).toSorted()[0] ?? firstBy(
null values.filter((value): value is string => value !== null),
identity(),
) ?? null
); );
} }

View File

@ -1,4 +1,5 @@
import { waitUntil } from "cloudflare:workers"; import { waitUntil } from "cloudflare:workers";
import { identity, sortBy } from "remeda";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import { createDataforseoClient } from "@/server/lib/dataforseo"; import { createDataforseoClient } from "@/server/lib/dataforseo";
import { import {
@ -74,10 +75,10 @@ export async function getBrandLookup(
// are canonical detected values too, so equivalent casing/order shares one // are canonical detected values too, so equivalent casing/order shares one
// paid cache entry. // paid cache entry.
targetValue: detected.value.toLowerCase(), targetValue: detected.value.toLowerCase(),
competitors: competitorGroups competitors: sortBy(
.map((g) => g.detected.value.toLowerCase()) competitorGroups.map((g) => g.detected.value.toLowerCase()),
.toSorted() identity(),
.join("|"), ).join("|"),
locationCode: input.locationCode, locationCode: input.locationCode,
languageCode: input.languageCode, languageCode: input.languageCode,
// Scope changes both the provider call (include_subdomains) and the // Scope changes both the provider call (include_subdomains) and the

View File

@ -1,3 +1,4 @@
import { identity, sortBy } from "remeda";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { deriveCitedSources } from "./citedSources"; import { deriveCitedSources } from "./citedSources";
import type { import type {
@ -56,10 +57,12 @@ describe("deriveCitedSources", () => {
mentions: 9, mentions: 9,
capturedVolume: 9000, capturedVolume: 9000,
}); });
expect(sources[0].keywords.map((k) => k.question).toSorted()).toEqual([ expect(
"best seo tools", sortBy(
"cheap seo", sources[0].keywords.map((k) => k.question),
]); identity(),
),
).toEqual(["best seo tools", "cheap seo"]);
}); });
it("dedupes sampled prompt examples and derives domains from urls", () => { it("dedupes sampled prompt examples and derives domains from urls", () => {

View File

@ -1,3 +1,4 @@
import { sort } from "remeda";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import { ActivationRepository } from "@/server/features/activation/repositories/ActivationRepository"; import { ActivationRepository } from "@/server/features/activation/repositories/ActivationRepository";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
@ -169,17 +170,15 @@ async function getAuditSummary(
const typeRows = await getIssueTypePageCountsForAudit(audit.id); const typeRows = await getIssueTypePageCountsForAudit(audit.id);
const severityRank = { critical: 0, warning: 1, info: 2 }; const severityRank = { critical: 0, warning: 1, info: 2 };
const sorted = typeRows const sorted = sort(
.map((row) => ({ typeRows.map((row) => ({
issueType: row.issueType, issueType: row.issueType,
severity: row.severity, severity: row.severity,
count: row.pages, count: row.pages,
})) })),
.toSorted( (a, b) =>
(a, b) => severityRank[a.severity] - severityRank[b.severity] || b.count - a.count,
severityRank[a.severity] - severityRank[b.severity] || );
b.count - a.count,
);
return { return {
status: audit.status, status: audit.status,

View File

@ -1,3 +1,4 @@
import { sort } from "remeda";
import type { GscSearchAnalyticsRow } from "@/server/lib/gscClient"; import type { GscSearchAnalyticsRow } from "@/server/lib/gscClient";
/** /**
@ -111,14 +112,14 @@ export function buildStrikingDistanceRows(
}); });
} }
return Array.from(topPageByQuery.values()) return sort(
.filter( Array.from(topPageByQuery.values()).filter(
(row) => (row) =>
row.position >= STRIKING_DISTANCE_MIN_POSITION && row.position >= STRIKING_DISTANCE_MIN_POSITION &&
row.position <= STRIKING_DISTANCE_MAX_POSITION, row.position <= STRIKING_DISTANCE_MAX_POSITION,
) ),
.toSorted((a, b) => b.impressions - a.impressions) (a, b) => b.impressions - a.impressions,
.slice(0, limit); ).slice(0, limit);
} }
/** The same-length period immediately before [startDate, endDate], for the /** The same-length period immediately before [startDate, endDate], for the

View File

@ -1,4 +1,5 @@
import { tool, type ToolSet } from "ai"; import { tool, type ToolSet } from "ai";
import { sort } from "remeda";
import { z } from "zod"; import { z } from "zod";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { MAX_PAGES, readPages, readSite } from "@/server/lib/scrape"; import { MAX_PAGES, readPages, readSite } from "@/server/lib/scrape";
@ -225,17 +226,20 @@ function coreSiteTools(ctx: ToolContext): ToolSet {
"onboarding", "onboarding",
); );
const keywords = researchResult.rows // Keep only keywords with a real volume — the strategy table shows
// Keep only keywords with a real volume — the strategy table shows // volume + KD, so a null-volume row can't be grounded.
// volume + KD, so a null-volume row can't be grounded. const withVolume = researchResult.rows.filter(
.filter((row) => row.searchVolume != null) (row) => row.searchVolume != null,
.toSorted((a, b) => (b.searchVolume ?? 0) - (a.searchVolume ?? 0)) );
.map((row) => ({ const keywords = sort(
keyword: row.keyword, withVolume,
searchVolume: row.searchVolume, (a, b) => (b.searchVolume ?? 0) - (a.searchVolume ?? 0),
keywordDifficulty: row.keywordDifficulty, ).map((row) => ({
intent: row.intent, keyword: row.keyword,
})); searchVolume: row.searchVolume,
keywordDifficulty: row.keywordDifficulty,
intent: row.intent,
}));
return { available: keywords.length > 0, keywords }; return { available: keywords.length > 0, keywords };
} catch (error) { } catch (error) {

View File

@ -1,4 +1,5 @@
import { tool, type ToolSet } from "ai"; import { tool, type ToolSet } from "ai";
import { sort } from "remeda";
import { z } from "zod"; import { z } from "zod";
import { DomainService } from "@/server/features/domain/services/DomainService"; import { DomainService } from "@/server/features/domain/services/DomainService";
import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService"; import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService";
@ -132,9 +133,10 @@ export function marketTools(ctx: ToolContext): ToolSet {
limit: 50, limit: 50,
creditFeature: "onboarding", creditFeature: "onboarding",
}); });
const top = competitors const top = sort(
.filter((c) => !isSameDomain(c.domain)) competitors.filter((c) => !isSameDomain(c.domain)),
.toSorted((a, b) => (b.etv ?? 0) - (a.etv ?? 0)) (a, b) => (b.etv ?? 0) - (a.etv ?? 0),
)
.slice(0, 10) .slice(0, 10)
.map((c) => ({ .map((c) => ({
domain: c.domain ?? null, domain: c.domain ?? null,

View File

@ -1,3 +1,4 @@
import { sort } from "remeda";
import { parse as parseYaml } from "yaml"; import { parse as parseYaml } from "yaml";
import { z } from "zod"; import { z } from "zod";
import type { SkillSource } from "agents/skills"; import type { SkillSource } from "agents/skills";
@ -75,10 +76,12 @@ let cachedSource: SkillSource | undefined;
export function buildSamSkillSource(): SkillSource { export function buildSamSkillSource(): SkillSource {
if (cachedSource) return cachedSource; if (cachedSource) return cachedSource;
const skills = Object.entries(skillFiles) const skills = sort(
.map(([path, raw]) => parseSkill(path, raw)) Object.entries(skillFiles)
.filter((skill): skill is SamSkill => skill !== null) .map(([path, raw]) => parseSkill(path, raw))
.toSorted((a, b) => a.name.localeCompare(b.name)); .filter((skill): skill is SamSkill => skill !== null),
(a, b) => a.name.localeCompare(b.name),
);
return (cachedSource = { return (cachedSource = {
id: "openseo-public-skills", id: "openseo-public-skills",

View File

@ -1,4 +1,5 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import { sort } from "remeda";
import { z } from "zod"; import { z } from "zod";
import { import {
createDataforseoClient, createDataforseoClient,
@ -556,7 +557,7 @@ function sortCompetitors(
? "etv" ? "etv"
: "visibility"; : "visibility";
const direction = sortBy === "avg_position" ? 1 : -1; const direction = sortBy === "avg_position" ? 1 : -1;
return items.toSorted((a, b) => { return sort(items, (a, b) => {
const aValue = typeof a[field] === "number" ? a[field] : 0; const aValue = typeof a[field] === "number" ? a[field] : 0;
const bValue = typeof b[field] === "number" ? b[field] : 0; const bValue = typeof b[field] === "number" ? b[field] : 0;
return (aValue - bValue) * direction; return (aValue - bValue) * direction;
@ -590,7 +591,7 @@ function sortKeywordMetricRows(
rows: McpKeywordMetricRow[], rows: McpKeywordMetricRow[],
sortBy: NonNullable<GetKeywordMetricsArgs["sortBy"]> = "search_volume", sortBy: NonNullable<GetKeywordMetricsArgs["sortBy"]> = "search_volume",
) { ) {
return rows.toSorted((a, b) => { return sort(rows, (a, b) => {
const aValue = a[sortBy]; const aValue = a[sortBy];
const bValue = b[sortBy]; const bValue = b[sortBy];
const aNum = typeof aValue === "number" ? aValue : 0; const aNum = typeof aValue === "number" ? aValue : 0;

View File

@ -1,3 +1,5 @@
/* eslint-disable max-lines -- every local-SEO tool is covered in this one spec, matching local-seo-tools.ts */
import { sort } from "remeda";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { import {
@ -39,7 +41,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
const toolContext = makeToolContext(); const toolContext = makeToolContext();
const byText = (a: string, b: string) => a.localeCompare(b); const sorted = (values: string[]) => sort(values, (a, b) => a.localeCompare(b));
beforeEach(() => { beforeEach(() => {
mocks.getProjectForOrganization.mockResolvedValue({ mocks.getProjectForOrganization.mockResolvedValue({
@ -252,11 +254,9 @@ describe("get_local_rank_grid", () => {
// from the spacing (13z here) so each point's viewport spans its neighbours // from the spacing (13z here) so each point's viewport spans its neighbours
// instead of hiding businesses one grid step east or west. // instead of hiding businesses one grid step east or west.
expect( expect(
local.mock.calls sorted(local.mock.calls.map(([input]) => input.locationCoordinate)),
.map(([input]) => input.locationCoordinate)
.toSorted(byText),
).toEqual( ).toEqual(
[ sorted([
"40.0180874,-74.0234532,13z", "40.0180874,-74.0234532,13z",
"40.0180874,-74,13z", "40.0180874,-74,13z",
"40.0180874,-73.9765468,13z", "40.0180874,-73.9765468,13z",
@ -266,7 +266,7 @@ describe("get_local_rank_grid", () => {
"39.9819126,-74.0234532,13z", "39.9819126,-74.0234532,13z",
"39.9819126,-74,13z", "39.9819126,-74,13z",
"39.9819126,-73.9765468,13z", "39.9819126,-73.9765468,13z",
].toSorted(byText), ]),
); );
expect(local).toHaveBeenCalledWith( expect(local).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({

View File

@ -1,3 +1,4 @@
import { sort } from "remeda";
import { z } from "zod"; import { z } from "zod";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import { AuditService } from "@/server/features/audit/services/AuditService"; import { AuditService } from "@/server/features/audit/services/AuditService";
@ -251,7 +252,8 @@ export const getAuditIssuesTool = {
issueType: args.issueType, issueType: args.issueType,
}); });
// Severity-first so truncation drops info rows, never critical ones. // Severity-first so truncation drops info rows, never critical ones.
const rows = unsorted.toSorted( const rows = sort(
unsorted,
(a, b) => (a, b) =>
ISSUE_SEVERITY_ORDER[a.severity] - ISSUE_SEVERITY_ORDER[b.severity] || ISSUE_SEVERITY_ORDER[a.severity] - ISSUE_SEVERITY_ORDER[b.severity] ||
a.issueType.localeCompare(b.issueType), a.issueType.localeCompare(b.issueType),
@ -261,8 +263,8 @@ export const getAuditIssuesTool = {
for (const row of rows) { for (const row of rows) {
counts.set(row.issueType, (counts.get(row.issueType) ?? 0) + 1); counts.set(row.issueType, (counts.get(row.issueType) ?? 0) + 1);
} }
const summary = Array.from(counts.entries()) const summary = sort(
.map(([issueType, count]) => { Array.from(counts.entries()).map(([issueType, count]) => {
const descriptor = getIssueDescriptor(issueType); const descriptor = getIssueDescriptor(issueType);
return { return {
issueType, issueType,
@ -270,12 +272,11 @@ export const getAuditIssuesTool = {
severity: descriptor?.severity ?? "info", severity: descriptor?.severity ?? "info",
count, count,
}; };
}) }),
.toSorted( (a, b) =>
(a, b) => ISSUE_SEVERITY_ORDER[a.severity] - ISSUE_SEVERITY_ORDER[b.severity] ||
ISSUE_SEVERITY_ORDER[a.severity] - ISSUE_SEVERITY_ORDER[b.severity] || b.count - a.count,
b.count - a.count, );
);
const limit = args.limit ?? 200; const limit = args.limit ?? 200;
const issues = rows.slice(0, limit).map((row) => { const issues = rows.slice(0, limit).map((row) => {

View File

@ -1,3 +1,4 @@
import { sort } from "remeda";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
LABS_LOCATION_OPTIONS, LABS_LOCATION_OPTIONS,
@ -63,7 +64,7 @@ describe("keyword locations", () => {
it("keeps the picker sorted alphabetically with unique codes", () => { it("keeps the picker sorted alphabetically with unique codes", () => {
const labels = LOCATION_OPTIONS.map((option) => option.label); const labels = LOCATION_OPTIONS.map((option) => option.label);
expect(labels).toEqual(labels.toSorted((a, b) => a.localeCompare(b))); expect(labels).toEqual(sort(labels, (a, b) => a.localeCompare(b)));
const codes = LOCATION_OPTIONS.map((option) => option.code); const codes = LOCATION_OPTIONS.map((option) => option.code);
expect(new Set(codes).size).toBe(codes.length); expect(new Set(codes).size).toBe(codes.length);
}); });

View File

@ -7,7 +7,10 @@
"jsx": "react-jsx", "jsx": "react-jsx",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"lib": ["DOM", "DOM.Iterable", "ES2023"], // ES2022 is the browser-support floor: ES2023 array methods (toSorted,
// toReversed, findLast, ...) crash Chromium <110. Don't bump this to fix a
// missing-method error use Remeda's sort/sortBy/findLast instead.
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"isolatedModules": true, "isolatedModules": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"resolveJsonModule": true, "resolveJsonModule": true,