Skip to content

Commit b809e7d

Browse files
chore(release): v0.12.1
GitOrigin-RevId: bb439120fdee44846c69856f92edfe105fcbde91
1 parent 6779644 commit b809e7d

38 files changed

Lines changed: 857 additions & 123 deletions

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@
22

33
## Unreleased
44

5+
## [0.12.1] - 2026-08-13
6+
7+
- Fixed alert rows to show the triggering keyword's actual location and device instead of a hardcoded market.
8+
9+
- Added location, language, and device columns to check runs so identical keywords across markets remain distinguishable.
10+
11+
- Disclosed the effective country-language metrics scope and reused country-scoped keyword research cache entries across cities.
12+
13+
- Removed unsupported language and refresh columns from the CSV import wizard's advertised format.
14+
15+
- Aligned the served domain onboarding agent skill with the accepted country and device project defaults.
16+
17+
- Removed the Timezone and Language controls from account preferences, while keeping the internal timezone and language values unchanged when saving remaining visible preferences.
18+
19+
- Updated the transitive nanoid dependency to 3.3.18 to address a denial-of-service vulnerability in custom generators.
20+
521
## [0.12.0] - 2026-08-13
622

723
- Added write-scoped Domain Overview API operations for cache-aware estimates, analysis, history, ranked keywords, and relevant pages, with explicit provider-cost caps.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { routerMock } from "@/tests/next-navigation";
2+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
3+
import { describe, expect, it, vi } from "vitest";
4+
import { PreferencesForm } from "./PreferencesForm";
5+
6+
const defaults = {
7+
dateFormat: "eu",
8+
density: "standard",
9+
landing: "overview",
10+
language: "de",
11+
theme: "system",
12+
timezone: "America/New_York",
13+
} as const;
14+
15+
describe("PreferencesForm", () => {
16+
it("does not advertise timezone or language as account preferences", () => {
17+
render(<PreferencesForm defaults={defaults} updatePreferences={vi.fn()} />);
18+
19+
expect(screen.queryByText("Timezone")).not.toBeInTheDocument();
20+
expect(screen.queryByText("Language")).not.toBeInTheDocument();
21+
expect(screen.getByText("Date format")).toBeInTheDocument();
22+
expect(screen.getByText("Default landing page")).toBeInTheDocument();
23+
});
24+
25+
it("preserves internal timezone and language values when saving a visible preference", async () => {
26+
const updatePreferences = vi.fn().mockImplementation(async (input) => input);
27+
render(<PreferencesForm defaults={defaults} updatePreferences={updatePreferences} />);
28+
29+
fireEvent.click(screen.getByRole("radio", { name: "Dark" }));
30+
31+
await waitFor(() =>
32+
expect(updatePreferences).toHaveBeenCalledWith({
33+
...defaults,
34+
theme: "dark",
35+
}),
36+
);
37+
expect(routerMock.refresh).toHaveBeenCalledOnce();
38+
});
39+
});

components/account/PreferencesForm.tsx

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,8 @@ import {
55
dateFormatOptions,
66
densityOptions,
77
landingOptions,
8-
languageOptions,
98
preferencesSchema,
109
themeOptions,
11-
timezoneOptions,
1210
type UserPreferences,
1311
} from "@/lib/account/preferences-shared";
1412
import { zodResolver } from "@/lib/forms/zod-resolver";
@@ -94,15 +92,9 @@ function setPreferenceValue(
9492
if (key === "landing") {
9593
setValue("landing", value as UserPreferences["landing"], options);
9694
}
97-
if (key === "language") {
98-
setValue("language", value as UserPreferences["language"], options);
99-
}
10095
if (key === "theme") {
10196
setValue("theme", value as UserPreferences["theme"], options);
10297
}
103-
if (key === "timezone") {
104-
setValue("timezone", value as UserPreferences["timezone"], options);
105-
}
10698
}
10799

108100
export function PreferencesForm({ defaults, updatePreferences }: Readonly<PreferencesFormProps>) {
@@ -115,8 +107,6 @@ export function PreferencesForm({ defaults, updatePreferences }: Readonly<Prefer
115107
resolver: zodResolver(preferencesSchema),
116108
});
117109
const theme = watch("theme");
118-
const timezone = watch("timezone");
119-
const language = watch("language");
120110
const dateFormat = watch("dateFormat");
121111
const landing = watch("landing");
122112
const density = watch("density");
@@ -161,28 +151,6 @@ export function PreferencesForm({ defaults, updatePreferences }: Readonly<Prefer
161151
/>
162152
</div>
163153
<div className="grid gap-[14px] border-t border-border-soft pt-4 sm:grid-cols-2">
164-
<div className={fieldLabelClass}>
165-
<span>Timezone</span>
166-
<input type="hidden" {...register("timezone")} />
167-
<MenuSelect
168-
ariaLabel="Timezone"
169-
onChange={(value) => persist("timezone", value as UserPreferences["timezone"])}
170-
options={timezoneOptions}
171-
triggerClassName={selectTriggerClass}
172-
value={timezone}
173-
/>
174-
</div>
175-
<div className={fieldLabelClass}>
176-
<span>Language</span>
177-
<input type="hidden" {...register("language")} />
178-
<MenuSelect
179-
ariaLabel="Language"
180-
onChange={(value) => persist("language", value as UserPreferences["language"])}
181-
options={languageOptions}
182-
triggerClassName={selectTriggerClass}
183-
value={language}
184-
/>
185-
</div>
186154
<div className={fieldLabelClass}>
187155
<span>Date format</span>
188156
<input type="hidden" {...register("dateFormat")} />

components/alerts/AlertFeedSections.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Card } from "@/components/ui";
55
import type {
66
AlertDeliveryStateView,
77
AlertSeverity,
8+
Device,
89
TriggeredAlertView,
910
} from "@/lib/alerts/alert-data";
1011
import { severityMeta } from "@/lib/alerts/alert-data";
@@ -25,6 +26,10 @@ const severityIcons: Record<AlertSeverity, Icon> = {
2526
info: Info,
2627
};
2728

29+
function deviceLabel(device: Device) {
30+
return device[0].toUpperCase() + device.slice(1);
31+
}
32+
2833
const deliveryStateMeta = {
2934
dead_letter: { className: "text-red-text", label: "Failed / dead letter" },
3035
delivered: { className: "text-green-text", label: "Delivered" },
@@ -143,7 +148,8 @@ export function AlertFeedRow({
143148
</div>
144149
) : null}
145150
<p className="m-0 mt-2 font-mono text-[10.5px] text-fg-muted">
146-
{meta.label} / {alert.rule} / Google / US / Desktop / {alert.when}
151+
{meta.label} / {alert.rule} / Google / {alert.location} / {deviceLabel(alert.device)} /{" "}
152+
{alert.when}
147153
</p>
148154
<DeliveryStatus alert={alert} />
149155
<AlertRowActions

components/alerts/AlertsPageContent.test.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ const alerts: TriggeredAlertView[] = [
5656
headline: "Ranking dropped",
5757
id: "al_abcdefghijklmnopqrstuvwx",
5858
keyword: "rank tracker",
59+
location: "United States",
60+
device: "desktop",
5961
previous: "#3",
6062
rule: "Slipped",
6163
severity: "urgent",
@@ -132,6 +134,50 @@ describe("AlertsPageContent optimistic rollback", () => {
132134
expect(markAllRead).not.toBeDisabled();
133135
});
134136

137+
it("renders the triggering keyword's real location and device in the meta line", () => {
138+
renderAlerts({
139+
initialAlerts: [
140+
{
141+
...alerts[0],
142+
keyword: "rank tracker",
143+
location: "Warsaw, Poland",
144+
device: "mobile",
145+
},
146+
],
147+
});
148+
149+
const meta = screen.getByText(/Google \/ Warsaw, Poland \/ Mobile \/ 5m ago/);
150+
expect(meta).toBeInTheDocument();
151+
expect(meta.textContent).not.toContain("US");
152+
expect(meta.textContent).not.toContain("Desktop");
153+
});
154+
155+
it("renders distinct meta lines for alerts with different keyword markets", () => {
156+
renderAlerts({
157+
initialAlerts: [
158+
{
159+
...alerts[0],
160+
id: "al_aaaaaaaaaaaaaaaaaaaaaaaa",
161+
keyword: "rank tracker",
162+
location: "Warsaw, Poland",
163+
device: "mobile",
164+
},
165+
{
166+
...alerts[0],
167+
id: "al_bbbbbbbbbbbbbbbbbbbbbbbb",
168+
keyword: "best CRM",
169+
location: "London, United Kingdom",
170+
device: "desktop",
171+
},
172+
],
173+
});
174+
175+
expect(screen.getByText(/Google \/ Warsaw, Poland \/ Mobile \/ 5m ago/)).toBeInTheDocument();
176+
expect(
177+
screen.getByText(/Google \/ London, United Kingdom \/ Desktop \/ 5m ago/),
178+
).toBeInTheDocument();
179+
});
180+
135181
it("renders the terminal delivery state and its affected channel", () => {
136182
renderAlerts();
137183

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import type { CheckRunRow, CheckRunsView } from "@/lib/checks/contract";
2+
import { stubIntersectionObserver, stubResizeObserver } from "@/tests/observers";
3+
import { render, screen, within } from "@testing-library/react";
4+
import { describe, expect, it, vi } from "vitest";
5+
import { CheckRunsTable } from "./CheckRunsTable";
6+
7+
const now = new Date("2026-07-24T14:45:00.000Z");
8+
9+
function marketRow(overrides: Partial<CheckRunRow> = {}): CheckRunRow {
10+
return {
11+
attemptCount: 1,
12+
attempts: [
13+
{
14+
costCents: 0.35,
15+
degradedToCountry: false,
16+
detail: null,
17+
durationMs: 1_900,
18+
outcome: "ok",
19+
provider: "dataforseo",
20+
providerLabel: "DataForSEO",
21+
},
22+
],
23+
checkedAt: "2026-07-24T13:45:00.000Z",
24+
costCents: 0.35,
25+
degradedToCountry: false,
26+
device: "desktop",
27+
durationMs: 1_900,
28+
error: null,
29+
estimatedCostCents: null,
30+
finishedAt: "2026-07-24T13:45:01.900Z",
31+
id: "run",
32+
keyword: "ai meeting notes",
33+
keywordId: "kw",
34+
keywordPublicId: "kw",
35+
languageLabel: "English",
36+
location: "San Francisco, CA, US",
37+
position: 4,
38+
previousPosition: 6,
39+
provider: "dataforseo",
40+
providerLabel: "DataForSEO",
41+
requestedDepth: 20,
42+
startedAt: "2026-07-24T13:45:00.000Z",
43+
status: "completed",
44+
trigger: "scheduled",
45+
viaFallback: false,
46+
...overrides,
47+
};
48+
}
49+
50+
function viewFor(rows: CheckRunRow[]): CheckRunsView {
51+
return {
52+
counts: {
53+
completed: rows.length,
54+
deferred: 0,
55+
failed: 0,
56+
running: 0,
57+
runs: rows.length,
58+
viaFallback: 0,
59+
},
60+
deferredGroups: [],
61+
nextCursor: null,
62+
providerHealth: [],
63+
rows,
64+
spendCents: 0,
65+
};
66+
}
67+
68+
function tableProps(view: CheckRunsView) {
69+
return {
70+
expandedRunIds: new Set<string>(),
71+
filter: "all" as const,
72+
keywordHref: (id: string) => `/app/rank-tracker/${id}`,
73+
now,
74+
onLoadMore: vi.fn(),
75+
onToggleRun: vi.fn(),
76+
view,
77+
};
78+
}
79+
80+
describe("CheckRunsTable", () => {
81+
it("renders distinct Location, Language, and Device columns for rows with the same keyword text", () => {
82+
stubResizeObserver();
83+
stubIntersectionObserver();
84+
85+
const rows = [
86+
marketRow({
87+
id: "run_sf_desktop",
88+
keywordPublicId: "kw_sf_desktop",
89+
location: "San Francisco, CA, US",
90+
languageLabel: "English",
91+
device: "desktop",
92+
}),
93+
marketRow({
94+
id: "run_lon_mobile",
95+
keywordPublicId: "kw_lon_mobile",
96+
location: "London, UK",
97+
languageLabel: "English",
98+
device: "mobile",
99+
}),
100+
];
101+
102+
render(<CheckRunsTable {...tableProps(viewFor(rows))} />);
103+
104+
const table = screen.getByRole("table", { name: "Check runs" });
105+
expect(table).toHaveClass("min-w-[900px]");
106+
const headerCells = within(table).getAllByRole("columnheader");
107+
expect(headerCells.map((cell) => cell.textContent)).toEqual([
108+
"Status",
109+
"Keyword",
110+
"Location",
111+
"Language",
112+
"Device",
113+
"Result",
114+
"Provider",
115+
"Depth",
116+
"Cost",
117+
"When",
118+
"",
119+
]);
120+
121+
expect(screen.getByText("San Francisco, CA, US")).toBeInTheDocument();
122+
expect(screen.getByText("London, UK")).toBeInTheDocument();
123+
expect(screen.getByText("Desktop")).toBeInTheDocument();
124+
expect(screen.getByText("Mobile")).toBeInTheDocument();
125+
});
126+
127+
it("renders a dash for a missing language label", () => {
128+
stubResizeObserver();
129+
stubIntersectionObserver();
130+
131+
const rows = [
132+
marketRow({
133+
id: "run_no_lang",
134+
keywordPublicId: "kw_no_lang",
135+
languageLabel: null,
136+
}),
137+
];
138+
139+
render(<CheckRunsTable {...tableProps(viewFor(rows))} />);
140+
141+
const table = screen.getByRole("table", { name: "Check runs" });
142+
const bodyRows = within(table).getAllByRole("row").slice(1);
143+
expect(bodyRows).toHaveLength(1);
144+
expect(within(bodyRows[0]).getByText("-")).toBeInTheDocument();
145+
});
146+
147+
it("renders two rows with identical keyword text but different markets as visually distinguishable", () => {
148+
stubResizeObserver();
149+
stubIntersectionObserver();
150+
151+
const rows = [
152+
marketRow({
153+
id: "run_sf",
154+
keywordPublicId: "kw_sf",
155+
location: "San Francisco, CA, US",
156+
device: "desktop",
157+
}),
158+
marketRow({
159+
id: "run_lon",
160+
keywordPublicId: "kw_lon",
161+
location: "London, UK",
162+
device: "mobile",
163+
}),
164+
];
165+
166+
render(<CheckRunsTable {...tableProps(viewFor(rows))} />);
167+
168+
const table = screen.getByRole("table", { name: "Check runs" });
169+
const bodyRows = within(table).getAllByRole("row").slice(1);
170+
expect(bodyRows).toHaveLength(2);
171+
172+
const firstMarket = within(bodyRows[0]).getByText("San Francisco, CA, US");
173+
const secondMarket = within(bodyRows[1]).getByText("London, UK");
174+
expect(firstMarket).not.toEqual(secondMarket);
175+
176+
const keywordLinks = within(table).getAllByRole("link", { name: "ai meeting notes" });
177+
expect(keywordLinks).toHaveLength(2);
178+
});
179+
});

0 commit comments

Comments
 (0)