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": {
|
"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",
|
||||||
|
|||||||
@ -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("|");
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)", () => {
|
||||||
|
|||||||
@ -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];
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user