Blank page on older browsers: replace ES2023 array methods with Remeda (#535)
This commit is contained in:
parent
8db0dd3246
commit
b631715112
@ -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",
|
||||
|
||||
@ -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("|");
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()]
|
||||
return sort(
|
||||
[...creditsByLabel.entries()]
|
||||
.map(([label, 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() {
|
||||
|
||||
@ -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,21 +302,9 @@ export function DashboardPage({ projectId }: { projectId: string }) {
|
||||
const gscConnected = activation.gsc.connected;
|
||||
const ga4Connected = activation.ga4.connected;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h1 className="text-2xl font-semibold">Dashboard</h1>
|
||||
|
||||
<WorkspaceMergeBanner />
|
||||
|
||||
<OnboardingChecklist projectId={projectId} activation={activation} />
|
||||
|
||||
{/* Every card is half width on large screens (only the checklist spans).
|
||||
Cards with data render before setup pitches and empty states. */}
|
||||
<div className="grid items-start gap-5 lg:grid-cols-2">
|
||||
{[
|
||||
// Array order is the within-bucket order after the data-first sort:
|
||||
// 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
|
||||
? []
|
||||
: [
|
||||
@ -323,10 +312,7 @@ export function DashboardPage({ projectId }: { projectId: string }) {
|
||||
key: "mcp",
|
||||
hasData: false,
|
||||
node: (
|
||||
<McpConnectCard
|
||||
projectId={projectId}
|
||||
activation={activation}
|
||||
/>
|
||||
<McpConnectCard projectId={projectId} activation={activation} />
|
||||
),
|
||||
},
|
||||
]),
|
||||
@ -340,9 +326,7 @@ export function DashboardPage({ projectId }: { projectId: string }) {
|
||||
{
|
||||
key: "ga4",
|
||||
hasData: ga4Connected,
|
||||
node: (
|
||||
<Ga4Card projectId={projectId} connected={ga4Connected} />
|
||||
),
|
||||
node: <Ga4Card projectId={projectId} connected={ga4Connected} />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@ -360,8 +344,7 @@ export function DashboardPage({ projectId }: { projectId: string }) {
|
||||
? [
|
||||
{
|
||||
key: "backlinks",
|
||||
hasData:
|
||||
overview?.backlinks != null || refreshMutation.isPending,
|
||||
hasData: overview?.backlinks != null || refreshMutation.isPending,
|
||||
node: (
|
||||
<BacklinkPulseCard
|
||||
projectId={projectId}
|
||||
@ -372,11 +355,25 @@ export function DashboardPage({ projectId }: { projectId: string }) {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
.toSorted((a, b) => Number(b.hasData) - Number(a.hasData))
|
||||
.map((card) => (
|
||||
];
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h1 className="text-2xl font-semibold">Dashboard</h1>
|
||||
|
||||
<WorkspaceMergeBanner />
|
||||
|
||||
<OnboardingChecklist projectId={projectId} activation={activation} />
|
||||
|
||||
{/* Every card is half width on large screens (only the checklist spans).
|
||||
Cards with data render before setup pitches and empty states. */}
|
||||
<div className="grid items-start gap-5 lg:grid-cols-2">
|
||||
{sort(cards, (a, b) => Number(b.hasData) - Number(a.hasData)).map(
|
||||
(card) => (
|
||||
<div key={card.key}>{card.node}</div>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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)", () => {
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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]) => ({
|
||||
const locations = sort(
|
||||
Array.from(locationMap, ([code, label]) => ({
|
||||
value: String(code),
|
||||
label,
|
||||
})).toSorted((a, b) => a.label.localeCompare(b.label));
|
||||
})),
|
||||
(a, b) => a.label.localeCompare(b.label),
|
||||
);
|
||||
|
||||
return { devices, locations };
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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,16 +170,14 @@ 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,
|
||||
severityRank[a.severity] - severityRank[b.severity] || b.count - a.count,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,12 +226,15 @@ 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) => ({
|
||||
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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
const skills = sort(
|
||||
Object.entries(skillFiles)
|
||||
.map(([path, raw]) => parseSkill(path, raw))
|
||||
.filter((skill): skill is SamSkill => skill !== null)
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
.filter((skill): skill is SamSkill => skill !== null),
|
||||
(a, b) => a.name.localeCompare(b.name),
|
||||
);
|
||||
|
||||
return (cachedSource = {
|
||||
id: "openseo-public-skills",
|
||||
|
||||
@ -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<GetKeywordMetricsArgs["sortBy"]> = "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;
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,8 +272,7 @@ 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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user