From b63171511223ed40c9554f4953b50e19e012c031 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:02:03 -0400 Subject: [PATCH] Blank page on older browsers: replace ES2023 array methods with Remeda (#535) --- .oxlintrc.json | 2 + .../features/ai-search/PromptExplorerPage.tsx | 5 +- .../features/audit/results/IssuesView.tsx | 4 +- .../billing/BillingFeatureBreakdown.tsx | 17 ++- .../features/dashboard/DashboardPage.tsx | 123 +++++++++--------- .../hooks/useKeywordFiltering.test.ts | 11 +- .../page/KeywordResearchDesktopResults.tsx | 5 +- .../features/projects/ProjectSwitcher.tsx | 4 +- .../rank-tracking/KeywordTrendModal.tsx | 5 +- .../RankTrackingFilters.logic.ts | 12 +- .../RankTrackingHistoryMatrix.tsx | 8 +- .../hooks/usePromptExplorerSearchHistory.ts | 5 +- src/db/schema-parity.test.ts | 3 +- src/routes/_project/p/$projectId/saved.tsx | 5 +- src/server/auth/workspace-merge.ts | 7 +- .../ai-search/services/brandLookup.ts | 9 +- .../ai-search/services/citedSources.test.ts | 11 +- .../dashboard/services/DashboardService.ts | 15 +-- .../features/gsc/searchPerformanceReport.ts | 11 +- .../onboarding/onboardingChatTools.ts | 26 ++-- .../onboarding/onboardingMarketTools.ts | 8 +- src/server/features/sam/samSkills.ts | 11 +- .../mcp/tools/dataforseo-research-tools.ts | 5 +- src/server/mcp/tools/local-seo-tools.test.ts | 12 +- src/server/mcp/tools/site-audit-tools.ts | 19 +-- src/shared/keyword-locations.test.ts | 3 +- tsconfig.json | 5 +- 27 files changed, 197 insertions(+), 154 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index ad87fec..2726285 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -17,6 +17,8 @@ "rules": { "react/react-in-jsx-scope": "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", "typescript/no-explicit-any": "error", "typescript/consistent-type-imports": "error", diff --git a/src/client/features/ai-search/PromptExplorerPage.tsx b/src/client/features/ai-search/PromptExplorerPage.tsx index dac2447..6be5345 100644 --- a/src/client/features/ai-search/PromptExplorerPage.tsx +++ b/src/client/features/ai-search/PromptExplorerPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, type FormEvent } from "react"; import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; +import { identity, sortBy } from "remeda"; import { AlertCircle, ArrowLeft, @@ -90,7 +91,7 @@ function PromptExplorerPageInner({ "prompt-explorer", projectId, trimmedPrompt, - urlState.models.toSorted().join(","), + sortBy(urlState.models, identity()).join(","), urlState.webSearch, urlState.webSearchCountryCode, urlState.highlightBrand.trim(), @@ -132,7 +133,7 @@ function PromptExplorerPageInner({ const key = [ trimmedPrompt, urlState.highlightBrand.trim(), - urlState.models.toSorted().join(","), + sortBy(urlState.models, identity()).join(","), urlState.webSearch, urlState.webSearchCountryCode, ].join("|"); diff --git a/src/client/features/audit/results/IssuesView.tsx b/src/client/features/audit/results/IssuesView.tsx index d507be3..1ef9df2 100644 --- a/src/client/features/audit/results/IssuesView.tsx +++ b/src/client/features/audit/results/IssuesView.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { ChevronRight } from "lucide-react"; +import { sort } from "remeda"; import { getIssueDescriptor, ISSUE_SEVERITY_ORDER, @@ -68,7 +69,8 @@ function groupIssues(issues: AuditIssueRow[]): IssueGroup[] { group.issues.push(issue); } - return Array.from(groups.values()).toSorted( + return sort( + Array.from(groups.values()), (a, b) => ISSUE_SEVERITY_ORDER[a.severity] - ISSUE_SEVERITY_ORDER[b.severity] || b.issues.length - a.issues.length, diff --git a/src/client/features/billing/BillingFeatureBreakdown.tsx b/src/client/features/billing/BillingFeatureBreakdown.tsx index 5f7cdfc..2ad4f75 100644 --- a/src/client/features/billing/BillingFeatureBreakdown.tsx +++ b/src/client/features/billing/BillingFeatureBreakdown.tsx @@ -1,4 +1,5 @@ import { useQuery } from "@tanstack/react-query"; +import { sort } from "remeda"; import { AUTUMN_SEO_DATA_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); } - return [...creditsByLabel.entries()] - .map(([label, credits]) => ({ - label, - usd: autumnSeoDataCreditsToUsd(credits), - })) - .filter((row) => row.usd > 0) - .toSorted((a, b) => b.usd - a.usd); + return sort( + [...creditsByLabel.entries()] + .map(([label, credits]) => ({ + label, + usd: autumnSeoDataCreditsToUsd(credits), + })) + .filter((row) => row.usd > 0), + (a, b) => b.usd - a.usd, + ); } export function BillingFeatureBreakdown() { diff --git a/src/client/features/dashboard/DashboardPage.tsx b/src/client/features/dashboard/DashboardPage.tsx index d559e92..6c26e1b 100644 --- a/src/client/features/dashboard/DashboardPage.tsx +++ b/src/client/features/dashboard/DashboardPage.tsx @@ -3,6 +3,7 @@ import { Link, useNavigate } from "@tanstack/react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { ChevronLeft, ChevronRight, Check } from "lucide-react"; +import { sort } from "remeda"; import { captureClientEvent } from "@/client/lib/posthog"; import { computeNextStep, @@ -301,6 +302,61 @@ export function DashboardPage({ projectId }: { projectId: string }) { const gscConnected = activation.gsc.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: ( + + ), + }, + ]), + { + key: "gsc", + hasData: gscConnected, + node: , + }, + ...(ga4Connected || !activation.ga4.cardDismissedAt + ? [ + { + key: "ga4", + hasData: ga4Connected, + node: , + }, + ] + : []), + { + key: "audit", + hasData: overview?.audit != null, + node: ( + + ), + }, + ...(showBacklinks + ? [ + { + key: "backlinks", + hasData: overview?.backlinks != null || refreshMutation.isPending, + node: ( + + ), + }, + ] + : []), + ]; + return (
@@ -313,70 +369,11 @@ export function DashboardPage({ projectId }: { projectId: string }) { {/* Every card is half width on large screens (only the checklist spans). Cards with data render before setup pitches and empty states. */}
- {[ - // Array order is the within-bucket order after the data-first sort: - // the MCP pitch leads the setup cards. - ...(activation.mcp.firstToolCallAt || activation.mcp.cardDismissedAt - ? [] - : [ - { - key: "mcp", - hasData: false, - node: ( - - ), - }, - ]), - { - key: "gsc", - hasData: gscConnected, - node: , - }, - ...(ga4Connected || !activation.ga4.cardDismissedAt - ? [ - { - key: "ga4", - hasData: ga4Connected, - node: ( - - ), - }, - ] - : []), - { - key: "audit", - hasData: overview?.audit != null, - node: ( - - ), - }, - ...(showBacklinks - ? [ - { - key: "backlinks", - hasData: - overview?.backlinks != null || refreshMutation.isPending, - node: ( - - ), - }, - ] - : []), - ] - .toSorted((a, b) => Number(b.hasData) - Number(a.hasData)) - .map((card) => ( + {sort(cards, (a, b) => Number(b.hasData) - Number(a.hasData)).map( + (card) => (
{card.node}
- ))} + ), + )}
diff --git a/src/client/features/keywords/hooks/useKeywordFiltering.test.ts b/src/client/features/keywords/hooks/useKeywordFiltering.test.ts index 5e1c778..9fb6973 100644 --- a/src/client/features/keywords/hooks/useKeywordFiltering.test.ts +++ b/src/client/features/keywords/hooks/useKeywordFiltering.test.ts @@ -1,3 +1,4 @@ +import { identity, sortBy } from "remeda"; import { describe, expect, it } from "vitest"; import type { KeywordIntent, KeywordResearchRow } from "@/types/keywords"; import { @@ -85,10 +86,12 @@ describe("applyKeywordFiltersAndSort — intent filtering", () => { it("keeps rows matching any of multiple selected intents", () => { const result = filter(rows, { intents: "transactional,commercial" }); - expect(result.map((r) => r.keyword).toSorted()).toEqual([ - "best running shoes", - "buy running shoes", - ]); + expect( + sortBy( + result.map((r) => r.keyword), + identity(), + ), + ).toEqual(["best running shoes", "buy running shoes"]); }); it("combines the intent filter with other filters (AND)", () => { diff --git a/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx b/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx index eb0554b..1dc7bfb 100644 --- a/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx +++ b/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx @@ -8,6 +8,7 @@ import { Sheet, SlidersHorizontal, } from "lucide-react"; +import { sortBy } from "remeda"; import { downloadKeywordResearchCsv, KEYWORD_RESEARCH_HEADERS, @@ -56,9 +57,7 @@ const MONTH_SHORT_LABELS = [ function formatTrendRangeLabel(trend: KeywordResearchRow["trend"]): string { if (trend.length === 0) return "Last 12 available months"; - const sorted = trend.toSorted( - (a, b) => a.year * 100 + a.month - (b.year * 100 + b.month), - ); + const sorted = sortBy(trend, (item) => item.year * 100 + item.month); const last12 = sorted.slice(-12); const start = last12[0]; const end = last12[last12.length - 1]; diff --git a/src/client/features/projects/ProjectSwitcher.tsx b/src/client/features/projects/ProjectSwitcher.tsx index 42453a6..afceeff 100644 --- a/src/client/features/projects/ProjectSwitcher.tsx +++ b/src/client/features/projects/ProjectSwitcher.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { Link, useRouter } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; +import { findLast } from "remeda"; import { Check, ChevronsUpDown, @@ -92,7 +93,8 @@ export function ProjectSwitcher({ // project, so they fall back to their section. Filtering on the path // template rather than match.params matters: the router gives every match // 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.fullPath.includes("$projectId") && match.fullPath diff --git a/src/client/features/rank-tracking/KeywordTrendModal.tsx b/src/client/features/rank-tracking/KeywordTrendModal.tsx index 1db67b8..df2111f 100644 --- a/src/client/features/rank-tracking/KeywordTrendModal.tsx +++ b/src/client/features/rank-tracking/KeywordTrendModal.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { Copy, Download, Loader2 } from "lucide-react"; +import { reverse, sortBy } from "remeda"; import { toast } from "sonner"; import { useQuery } from "@tanstack/react-query"; import { Modal } from "@/client/components/Modal"; @@ -363,7 +364,7 @@ function buildChartData( row[p.device] = p.position === null ? serpDepth : p.position; byTime.set(ts, row); } - return [...byTime.values()].toSorted((a, b) => a.checkedAt - b.checkedAt); + return sortBy([...byTime.values()], (row) => row.checkedAt); } interface HistoryRow { @@ -393,7 +394,7 @@ function buildHistoryRows(points: RankKeywordHistoryPoint[]): HistoryRow[] { }); prevByDevice.set(p.device, p.position); } - return rows.toReversed(); + return reverse(rows); } function slugify(value: string): string { diff --git a/src/client/features/rank-tracking/RankTrackingFilters.logic.ts b/src/client/features/rank-tracking/RankTrackingFilters.logic.ts index 865a1a0..3bfd2ea 100644 --- a/src/client/features/rank-tracking/RankTrackingFilters.logic.ts +++ b/src/client/features/rank-tracking/RankTrackingFilters.logic.ts @@ -1,3 +1,4 @@ +import { sort } from "remeda"; import { LOCATIONS } from "@/client/features/keywords/locations"; import { devicesLabel } from "@/shared/rank-tracking"; import type { @@ -106,10 +107,13 @@ export function getDomainListFilterOptions(configs: DomainFilterableConfig[]): { ); } - const locations = Array.from(locationMap, ([code, label]) => ({ - value: String(code), - label, - })).toSorted((a, b) => a.label.localeCompare(b.label)); + const locations = sort( + Array.from(locationMap, ([code, label]) => ({ + value: String(code), + label, + })), + (a, b) => a.label.localeCompare(b.label), + ); return { devices, locations }; } diff --git a/src/client/features/rank-tracking/RankTrackingHistoryMatrix.tsx b/src/client/features/rank-tracking/RankTrackingHistoryMatrix.tsx index 4898402..f0de564 100644 --- a/src/client/features/rank-tracking/RankTrackingHistoryMatrix.tsx +++ b/src/client/features/rank-tracking/RankTrackingHistoryMatrix.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { Loader2 } from "lucide-react"; +import { sort } from "remeda"; import type { RankPositionMatrixCell } from "@/serverFunctions/rank-tracking"; /** @@ -131,9 +132,10 @@ function buildMatrix(cells: RankPositionMatrixCell[]): { } byRun.set(c.runId, c.position); } - const runs = [...runMap.entries()] - .map(([runId, checkedAt]) => ({ runId, checkedAt })) - .toSorted((a, b) => a.checkedAt.localeCompare(b.checkedAt)); + const runs = sort( + [...runMap.entries()].map(([runId, checkedAt]) => ({ runId, checkedAt })), + (a, b) => a.checkedAt.localeCompare(b.checkedAt), + ); return { runs, cellByKeyword }; } diff --git a/src/client/hooks/usePromptExplorerSearchHistory.ts b/src/client/hooks/usePromptExplorerSearchHistory.ts index 0174e78..a6df3b2 100644 --- a/src/client/hooks/usePromptExplorerSearchHistory.ts +++ b/src/client/hooks/usePromptExplorerSearchHistory.ts @@ -1,3 +1,4 @@ +import { identity, sortBy } from "remeda"; import { z } from "zod"; import { useTimestampedSearchHistory } from "@/client/hooks/useTimestampedSearchHistory"; import { @@ -21,8 +22,8 @@ export type PromptExplorerSearchHistoryItem = PromptExplorerSearchBody & { function sameModels(a: string[], b: string[]): boolean { if (a.length !== b.length) return false; - const sortedA = a.toSorted(); - const sortedB = b.toSorted(); + const sortedA = sortBy(a, identity()); + const sortedB = sortBy(b, identity()); return sortedA.every((model, index) => model === sortedB[index]); } diff --git a/src/db/schema-parity.test.ts b/src/db/schema-parity.test.ts index aa01940..0338732 100644 --- a/src/db/schema-parity.test.ts +++ b/src/db/schema-parity.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { getTableColumns, getTableName, is, Table } from "drizzle-orm"; import { getTableConfig as getSqliteTableConfig } from "drizzle-orm/sqlite-core"; import { getTableConfig as getPgTableConfig } from "drizzle-orm/pg-core"; +import { sort } from "remeda"; import { describe, expect, it } from "vitest"; import * as sqliteApp from "./app.schema"; import * as sqliteProjectContext from "./project-context.schema"; @@ -32,7 +33,7 @@ import * as pgTelemetry from "./pg/telemetry.schema"; type Dialect = "sqlite" | "pg"; const sortStrings = (values: string[]) => - values.toSorted((a, b) => a.localeCompare(b)); + sort(values, (a, b) => a.localeCompare(b)); function asStringArray(value: unknown): string[] | null { if (!Array.isArray(value)) return null; diff --git a/src/routes/_project/p/$projectId/saved.tsx b/src/routes/_project/p/$projectId/saved.tsx index d7cceeb..7156d3f 100644 --- a/src/routes/_project/p/$projectId/saved.tsx +++ b/src/routes/_project/p/$projectId/saved.tsx @@ -1,4 +1,7 @@ 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 { keepPreviousData, useMutation, @@ -130,7 +133,7 @@ function SavedKeywordsPage() { 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), ); }, [selectedRows]); diff --git a/src/server/auth/workspace-merge.ts b/src/server/auth/workspace-merge.ts index 75c03f8..734a654 100644 --- a/src/server/auth/workspace-merge.ts +++ b/src/server/auth/workspace-merge.ts @@ -1,5 +1,6 @@ import { env } from "cloudflare:workers"; import { and, eq, inArray, isNull, like } from "drizzle-orm"; +import { firstBy, identity } from "remeda"; import { db } from "@/db"; import { runBatch } from "@/db/runBatch"; import { getAuthMode } from "@/lib/auth-mode"; @@ -18,8 +19,10 @@ import { SHARED_WORKSPACE_ORGANIZATION_ID } from "./delegated-organization"; // chronological for these). function earliest(values: (string | null)[]) { return ( - values.filter((value): value is string => value !== null).toSorted()[0] ?? - null + firstBy( + values.filter((value): value is string => value !== null), + identity(), + ) ?? null ); } diff --git a/src/server/features/ai-search/services/brandLookup.ts b/src/server/features/ai-search/services/brandLookup.ts index 6ec3e9a..2a7b2b5 100644 --- a/src/server/features/ai-search/services/brandLookup.ts +++ b/src/server/features/ai-search/services/brandLookup.ts @@ -1,4 +1,5 @@ import { waitUntil } from "cloudflare:workers"; +import { identity, sortBy } from "remeda"; import type { BillingCustomerContext } from "@/server/billing/subscription"; import { createDataforseoClient } from "@/server/lib/dataforseo"; import { @@ -74,10 +75,10 @@ export async function getBrandLookup( // are canonical detected values too, so equivalent casing/order shares one // paid cache entry. targetValue: detected.value.toLowerCase(), - competitors: competitorGroups - .map((g) => g.detected.value.toLowerCase()) - .toSorted() - .join("|"), + competitors: sortBy( + competitorGroups.map((g) => g.detected.value.toLowerCase()), + identity(), + ).join("|"), locationCode: input.locationCode, languageCode: input.languageCode, // Scope changes both the provider call (include_subdomains) and the diff --git a/src/server/features/ai-search/services/citedSources.test.ts b/src/server/features/ai-search/services/citedSources.test.ts index dfcc5eb..01e7f0a 100644 --- a/src/server/features/ai-search/services/citedSources.test.ts +++ b/src/server/features/ai-search/services/citedSources.test.ts @@ -1,3 +1,4 @@ +import { identity, sortBy } from "remeda"; import { describe, expect, it } from "vitest"; import { deriveCitedSources } from "./citedSources"; import type { @@ -56,10 +57,12 @@ describe("deriveCitedSources", () => { mentions: 9, capturedVolume: 9000, }); - expect(sources[0].keywords.map((k) => k.question).toSorted()).toEqual([ - "best seo tools", - "cheap seo", - ]); + expect( + sortBy( + sources[0].keywords.map((k) => k.question), + identity(), + ), + ).toEqual(["best seo tools", "cheap seo"]); }); it("dedupes sampled prompt examples and derives domains from urls", () => { diff --git a/src/server/features/dashboard/services/DashboardService.ts b/src/server/features/dashboard/services/DashboardService.ts index 5497fa4..2acff01 100644 --- a/src/server/features/dashboard/services/DashboardService.ts +++ b/src/server/features/dashboard/services/DashboardService.ts @@ -1,3 +1,4 @@ +import { sort } from "remeda"; import type { BillingCustomerContext } from "@/server/billing/subscription"; import { ActivationRepository } from "@/server/features/activation/repositories/ActivationRepository"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; @@ -169,17 +170,15 @@ async function getAuditSummary( const typeRows = await getIssueTypePageCountsForAudit(audit.id); const severityRank = { critical: 0, warning: 1, info: 2 }; - const sorted = typeRows - .map((row) => ({ + const sorted = sort( + typeRows.map((row) => ({ issueType: row.issueType, severity: row.severity, count: row.pages, - })) - .toSorted( - (a, b) => - severityRank[a.severity] - severityRank[b.severity] || - b.count - a.count, - ); + })), + (a, b) => + severityRank[a.severity] - severityRank[b.severity] || b.count - a.count, + ); return { status: audit.status, diff --git a/src/server/features/gsc/searchPerformanceReport.ts b/src/server/features/gsc/searchPerformanceReport.ts index 28e956a..5c841c1 100644 --- a/src/server/features/gsc/searchPerformanceReport.ts +++ b/src/server/features/gsc/searchPerformanceReport.ts @@ -1,3 +1,4 @@ +import { sort } from "remeda"; import type { GscSearchAnalyticsRow } from "@/server/lib/gscClient"; /** @@ -111,14 +112,14 @@ export function buildStrikingDistanceRows( }); } - return Array.from(topPageByQuery.values()) - .filter( + return sort( + Array.from(topPageByQuery.values()).filter( (row) => row.position >= STRIKING_DISTANCE_MIN_POSITION && row.position <= STRIKING_DISTANCE_MAX_POSITION, - ) - .toSorted((a, b) => b.impressions - a.impressions) - .slice(0, limit); + ), + (a, b) => b.impressions - a.impressions, + ).slice(0, limit); } /** The same-length period immediately before [startDate, endDate], for the diff --git a/src/server/features/onboarding/onboardingChatTools.ts b/src/server/features/onboarding/onboardingChatTools.ts index 626c1bd..787f5b9 100644 --- a/src/server/features/onboarding/onboardingChatTools.ts +++ b/src/server/features/onboarding/onboardingChatTools.ts @@ -1,4 +1,5 @@ import { tool, type ToolSet } from "ai"; +import { sort } from "remeda"; import { z } from "zod"; import { AppError } from "@/server/lib/errors"; import { MAX_PAGES, readPages, readSite } from "@/server/lib/scrape"; @@ -225,17 +226,20 @@ function coreSiteTools(ctx: ToolContext): ToolSet { "onboarding", ); - const keywords = researchResult.rows - // Keep only keywords with a real volume — the strategy table shows - // volume + KD, so a null-volume row can't be grounded. - .filter((row) => row.searchVolume != null) - .toSorted((a, b) => (b.searchVolume ?? 0) - (a.searchVolume ?? 0)) - .map((row) => ({ - keyword: row.keyword, - searchVolume: row.searchVolume, - keywordDifficulty: row.keywordDifficulty, - intent: row.intent, - })); + // Keep only keywords with a real volume — the strategy table shows + // volume + KD, so a null-volume row can't be grounded. + const withVolume = researchResult.rows.filter( + (row) => row.searchVolume != null, + ); + const keywords = sort( + withVolume, + (a, b) => (b.searchVolume ?? 0) - (a.searchVolume ?? 0), + ).map((row) => ({ + keyword: row.keyword, + searchVolume: row.searchVolume, + keywordDifficulty: row.keywordDifficulty, + intent: row.intent, + })); return { available: keywords.length > 0, keywords }; } catch (error) { diff --git a/src/server/features/onboarding/onboardingMarketTools.ts b/src/server/features/onboarding/onboardingMarketTools.ts index 2a12e2c..54ebfd0 100644 --- a/src/server/features/onboarding/onboardingMarketTools.ts +++ b/src/server/features/onboarding/onboardingMarketTools.ts @@ -1,4 +1,5 @@ import { tool, type ToolSet } from "ai"; +import { sort } from "remeda"; import { z } from "zod"; import { DomainService } from "@/server/features/domain/services/DomainService"; import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService"; @@ -132,9 +133,10 @@ export function marketTools(ctx: ToolContext): ToolSet { limit: 50, creditFeature: "onboarding", }); - const top = competitors - .filter((c) => !isSameDomain(c.domain)) - .toSorted((a, b) => (b.etv ?? 0) - (a.etv ?? 0)) + const top = sort( + competitors.filter((c) => !isSameDomain(c.domain)), + (a, b) => (b.etv ?? 0) - (a.etv ?? 0), + ) .slice(0, 10) .map((c) => ({ domain: c.domain ?? null, diff --git a/src/server/features/sam/samSkills.ts b/src/server/features/sam/samSkills.ts index 81e9433..4f99f35 100644 --- a/src/server/features/sam/samSkills.ts +++ b/src/server/features/sam/samSkills.ts @@ -1,3 +1,4 @@ +import { sort } from "remeda"; import { parse as parseYaml } from "yaml"; import { z } from "zod"; import type { SkillSource } from "agents/skills"; @@ -75,10 +76,12 @@ let cachedSource: SkillSource | undefined; export function buildSamSkillSource(): SkillSource { if (cachedSource) return cachedSource; - const skills = Object.entries(skillFiles) - .map(([path, raw]) => parseSkill(path, raw)) - .filter((skill): skill is SamSkill => skill !== null) - .toSorted((a, b) => a.name.localeCompare(b.name)); + const skills = sort( + Object.entries(skillFiles) + .map(([path, raw]) => parseSkill(path, raw)) + .filter((skill): skill is SamSkill => skill !== null), + (a, b) => a.name.localeCompare(b.name), + ); return (cachedSource = { id: "openseo-public-skills", diff --git a/src/server/mcp/tools/dataforseo-research-tools.ts b/src/server/mcp/tools/dataforseo-research-tools.ts index 5afc071..72b2208 100644 --- a/src/server/mcp/tools/dataforseo-research-tools.ts +++ b/src/server/mcp/tools/dataforseo-research-tools.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ +import { sort } from "remeda"; import { z } from "zod"; import { createDataforseoClient, @@ -556,7 +557,7 @@ function sortCompetitors( ? "etv" : "visibility"; 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 bValue = typeof b[field] === "number" ? b[field] : 0; return (aValue - bValue) * direction; @@ -590,7 +591,7 @@ function sortKeywordMetricRows( rows: McpKeywordMetricRow[], sortBy: NonNullable = "search_volume", ) { - return rows.toSorted((a, b) => { + return sort(rows, (a, b) => { const aValue = a[sortBy]; const bValue = b[sortBy]; const aNum = typeof aValue === "number" ? aValue : 0; diff --git a/src/server/mcp/tools/local-seo-tools.test.ts b/src/server/mcp/tools/local-seo-tools.test.ts index 84c1816..35fd649 100644 --- a/src/server/mcp/tools/local-seo-tools.test.ts +++ b/src/server/mcp/tools/local-seo-tools.test.ts @@ -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 { AppError } from "@/server/lib/errors"; import { @@ -39,7 +41,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({ const toolContext = makeToolContext(); -const byText = (a: string, b: string) => a.localeCompare(b); +const sorted = (values: string[]) => sort(values, (a, b) => a.localeCompare(b)); beforeEach(() => { 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 // instead of hiding businesses one grid step east or west. expect( - local.mock.calls - .map(([input]) => input.locationCoordinate) - .toSorted(byText), + sorted(local.mock.calls.map(([input]) => input.locationCoordinate)), ).toEqual( - [ + sorted([ "40.0180874,-74.0234532,13z", "40.0180874,-74,13z", "40.0180874,-73.9765468,13z", @@ -266,7 +266,7 @@ describe("get_local_rank_grid", () => { "39.9819126,-74.0234532,13z", "39.9819126,-74,13z", "39.9819126,-73.9765468,13z", - ].toSorted(byText), + ]), ); expect(local).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/server/mcp/tools/site-audit-tools.ts b/src/server/mcp/tools/site-audit-tools.ts index 8c58e1d..728551a 100644 --- a/src/server/mcp/tools/site-audit-tools.ts +++ b/src/server/mcp/tools/site-audit-tools.ts @@ -1,3 +1,4 @@ +import { sort } from "remeda"; import { z } from "zod"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import { AuditService } from "@/server/features/audit/services/AuditService"; @@ -251,7 +252,8 @@ export const getAuditIssuesTool = { issueType: args.issueType, }); // Severity-first so truncation drops info rows, never critical ones. - const rows = unsorted.toSorted( + const rows = sort( + unsorted, (a, b) => ISSUE_SEVERITY_ORDER[a.severity] - ISSUE_SEVERITY_ORDER[b.severity] || a.issueType.localeCompare(b.issueType), @@ -261,8 +263,8 @@ export const getAuditIssuesTool = { for (const row of rows) { counts.set(row.issueType, (counts.get(row.issueType) ?? 0) + 1); } - const summary = Array.from(counts.entries()) - .map(([issueType, count]) => { + const summary = sort( + Array.from(counts.entries()).map(([issueType, count]) => { const descriptor = getIssueDescriptor(issueType); return { issueType, @@ -270,12 +272,11 @@ export const getAuditIssuesTool = { severity: descriptor?.severity ?? "info", count, }; - }) - .toSorted( - (a, b) => - ISSUE_SEVERITY_ORDER[a.severity] - ISSUE_SEVERITY_ORDER[b.severity] || - b.count - a.count, - ); + }), + (a, b) => + ISSUE_SEVERITY_ORDER[a.severity] - ISSUE_SEVERITY_ORDER[b.severity] || + b.count - a.count, + ); const limit = args.limit ?? 200; const issues = rows.slice(0, limit).map((row) => { diff --git a/src/shared/keyword-locations.test.ts b/src/shared/keyword-locations.test.ts index 1689db6..2a8d9fd 100644 --- a/src/shared/keyword-locations.test.ts +++ b/src/shared/keyword-locations.test.ts @@ -1,3 +1,4 @@ +import { sort } from "remeda"; import { describe, expect, it } from "vitest"; import { LABS_LOCATION_OPTIONS, @@ -63,7 +64,7 @@ describe("keyword locations", () => { it("keeps the picker sorted alphabetically with unique codes", () => { 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); expect(new Set(codes).size).toBe(codes.length); }); diff --git a/tsconfig.json b/tsconfig.json index 7867337..f18a0eb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,10 @@ "jsx": "react-jsx", "module": "ESNext", "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, "allowImportingTsExtensions": true, "resolveJsonModule": true,