diff --git a/DECISIONS.md b/DECISIONS.md index 77c5c00..d1f55ab 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,6 +1,6 @@ # Product decisions pending sign-off -The implementation resolved four previously-undefined product questions. They +The implementation resolved five previously-undefined product questions. They are reasonable defaults, but they should be confirmed (or changed) by product rather than remaining implicit in the code. Each notes where it lives so a change is a one-line edit. @@ -38,9 +38,39 @@ flat history still tolerates normal PSI jitter. ## 4. Collection starts at the workspace's saved local time The first watched page initializes the workspace to **midnight in that user's -browser timezone**. The Watchlist setting can override both time and IANA +browser timezone**. The Settings screen can override both time and IANA timezone. Active pages receive stable offsets after the chosen start so the workspace does not burst every page or PSI sample simultaneously. -- Where: `src/lib/collectionSchedule.ts`, the Watchlist settings panel, and the +- Where: `src/lib/collectionSchedule.ts`, the Settings screen, and the collector's 15-minute due-page cron. + +## 5. Sensitivity is one control with three positions (option 10b) + +What a site considers worth reporting is **one setting**, not twelve. The three +positions are Only big moves / Normal / Everything, and each resolves to a +complete threshold set. The limits it resolves to are **displayed beneath the +control, in the strings the digest itself writes**, so the abstraction is never +opaque: a reader who wants to know why a line said "above the 250 ms you set" +can see the 250 ms and see which position put it there. + +What the numbers are at each position is the part product should confirm. What +is settled, and should not be reopened without a decision: + +- **Twelve per-metric thresholds** were rejected. Every number honest, and + nobody could say what any of them would do to tonight's digest. +- **No thresholds at all** were rejected. The digest's threshold clause is the + reason a reader trusts the line, and it needs a setting behind it to be true. +- **Per-page sensitivity** does not exist anywhere. S3 removed the page-detail + calibration panel and S8 gives it no new home; a site has one answer to "what + is worth telling you" because the digest that asks it is one message per site. +- **No position resolves the savings gate to 0.** At 0 there is no limit the + reader set, so the digest withholds the clause and there is nothing to show + under the control. "Everything" is 1 ms, which is every measurement there is. +- A site whose thresholds were hand-tuned before this landed is **mapped to the + nearest position and told once**, in the digest footer. Discarding somebody's + configuration silently is worse than the configuration was. + +- Where: `SENSITIVITY_THRESHOLDS` in `src/lib/sensitivity.ts` is the only place + the numbers appear; `DEFAULT_PERFORMANCE_THRESHOLDS` reads the Normal position + from it. The migration is `normalizeState` in `src/lib/store/normalize.ts`. diff --git a/collector-worker/dataStore.ts b/collector-worker/dataStore.ts index 9a073de..75592a1 100644 --- a/collector-worker/dataStore.ts +++ b/collector-worker/dataStore.ts @@ -2,7 +2,7 @@ import { buildInitialState, buildSeedCruxEvidence, DEMO_DATA_VERSION } from "../ import { captureAgentReadiness } from "../src/lib/agentScoring"; import { resolveMarkerIndex } from "../src/lib/followups"; import { mediansOf, pageTrend } from "../src/lib/scoring"; -import { effectivePerformanceThresholds } from "../src/lib/performanceThresholds"; +import { normalizePerformanceThresholds } from "../src/lib/performanceThresholds"; import { normalizeState } from "../src/lib/store/normalize"; import { TENANT, type AppState, type ChangeMarker, type Night } from "../src/lib/types"; import { @@ -217,7 +217,7 @@ export class FdeDataStore { desktop: mediansOf(night.scores.desktop), }; page.agent = agent ?? []; - page.status = pageTrend(page, "mobile", effectivePerformanceThresholds(draft.performanceThresholds, page)); + page.status = pageTrend(page, "mobile", normalizePerformanceThresholds(draft.performanceThresholds)); page.runState = undefined; page.lastRunAt = night.iso ?? new Date().toISOString(); page.lastCollectionStatus = "trusted"; diff --git a/src/app/(app)/pages/[id]/page.tsx b/src/app/(app)/pages/[id]/page.tsx index e67bcc7..9dbe92e 100644 --- a/src/app/(app)/pages/[id]/page.tsx +++ b/src/app/(app)/pages/[id]/page.tsx @@ -8,7 +8,7 @@ import { useIssuesView, useStore } from "@/components/store"; import { CATEGORIES } from "@/lib/types"; import type { CategoryKey, CollectionJob, Night, RangeDays, WatchPage } from "@/lib/types"; import { agentReadinessHistoryPoints } from "@/lib/agentHistory"; -import { effectivePerformanceThresholds } from "@/lib/performanceThresholds"; +import { normalizePerformanceThresholds } from "@/lib/performanceThresholds"; import { historyForStrategy, nightHasStrategy, @@ -798,7 +798,7 @@ function ReadingsSection({ ...run, startsDateGroup: run.dateKey !== runMetadata[index - 1]?.dateKey, })); - const thresholds = effectivePerformanceThresholds(store.performanceThresholds, page); + const thresholds = normalizePerformanceThresholds(store.performanceThresholds); const readinessHistory = agentReadinessHistoryPoints( agentRangeHistory, page.agentIgnores, @@ -1301,7 +1301,7 @@ export default function PageDetail() { const collectionBlocked = page.flag === "paused" || (!!page.runState && page.runState !== "failed"); const activeJob = store.jobs?.find((job) => job.runId === page.runId); const watchedPageHref = /^[a-z][a-z\d+.-]*:\/\//i.test(page.url) ? page.url : `https://${page.url}`; - const thresholds = effectivePerformanceThresholds(store.performanceThresholds, page); + const thresholds = normalizePerformanceThresholds(store.performanceThresholds); // A development-only comparison of the two trend renderings side by side. const isStatusPreview = process.env.NODE_ENV === "development" && searchParams.get("statusPreview") === "compare"; const mobileTrend = isStatusPreview ? "regressing" : pageRangeTrend(page, "mobile", rangeDays, thresholds); diff --git a/src/app/(app)/pages/pages-content.tsx b/src/app/(app)/pages/pages-content.tsx index 4877201..33cfb12 100644 --- a/src/app/(app)/pages/pages-content.tsx +++ b/src/app/(app)/pages/pages-content.tsx @@ -28,7 +28,7 @@ import type { SegmentRole } from "@/components/segmented-control"; import { CATEGORIES } from "@/lib/types"; import type { AgentIgnoreSettings, Night, WebflowRemediationLevel } from "@/lib/types"; import { agentReadinessForNight, summarizeAgentChecks } from "@/lib/agentScoring"; -import { effectivePerformanceThresholds, normalizePerformanceThresholds } from "@/lib/performanceThresholds"; +import { normalizePerformanceThresholds } from "@/lib/performanceThresholds"; import { historyForRange, pageAgentSnapshotForRange, pageRangeLatestNightForStrategy, pageRangeTrend } from "@/lib/scoring"; import { flagChip, savingsValue } from "@/lib/ui"; import { DESTINATION_LABEL, DESTINATION_PATH, QUEUE_LABEL } from "@/lib/vocabulary"; @@ -294,7 +294,7 @@ function DashboardContent({ const nativeElementRollups = siteNativeElementRollups(activePages); const rows = pages.map((p, watchlistOrder) => { - const pageThresholds = effectivePerformanceThresholds(thresholds, p); + const pageThresholds = normalizePerformanceThresholds(thresholds); const mobileTrend = pageRangeTrend(p, "mobile", rangeDays, pageThresholds); const desktopTrend = pageRangeTrend(p, "desktop", rangeDays, pageThresholds); const visitorEvidence = evidenceForPage(visitorExperience, p.id, strategy); diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 4087207..c542e2b 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -1,17 +1,575 @@ "use client"; -import { SettingsPageContent } from "../watchlist/page"; +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { AppearanceControl } from "@/components/appearance"; +import { ExclusionReasonPicker } from "@/components/exclusion-reason-picker"; +import { PageHeader } from "@/components/page-header"; import { ProjectMembers } from "@/components/ProjectMembers"; +import { SegmentedControl } from "@/components/segmented-control"; import { useStore } from "@/components/store"; -import { useRouter } from "next/navigation"; -import { useEffect } from "react"; +import { WebflowConnection } from "@/components/webflow-connection"; +import { AGENT_CHECK_GROUPS, ALL_AGENT_CHECKS } from "@/lib/agentChecks"; +import { agentCheckKey, normalizeAgentIgnoreSettings } from "@/lib/agentScoring"; +import { digestLimit } from "@/lib/digest-copy"; +import { DIGEST_CADENCES, DIGEST_CADENCE_LABEL, normalizeDigestCadence } from "@/lib/digestCadence"; +import { + formatDigestRecipients, + digestRecipientIsValid, + parseDigestRecipients, +} from "@/lib/digestRecipients"; +import { digestSiteOf } from "@/lib/digest"; +import { issueCasesFrom } from "@/lib/issue-cases"; +import { remediationKey } from "@/lib/issue-case"; +import { normalizePerformanceThresholds } from "@/lib/performanceThresholds"; +import { SENSITIVITIES, normalizeSensitivity, type Sensitivity } from "@/lib/sensitivity"; +import { + SENSITIVITY_LABEL, + SETTINGS_APPEARANCE_HELP, + SETTINGS_APPEARANCE_LABEL, + SETTINGS_DIGEST_HELP, + SETTINGS_DIGEST_LABEL, + SETTINGS_DIGEST_RECIPIENTS_EMPTY, + SETTINGS_DIGEST_RECIPIENTS_HELP, + SETTINGS_DIGEST_RECIPIENTS_INVALID, + SETTINGS_DIGEST_RECIPIENTS_LABEL, + SETTINGS_EXCLUDED_EMPTY, + SETTINGS_EXCLUDED_HELP, + SETTINGS_EXCLUDED_LABEL, + SETTINGS_SENSITIVITY_HELP, + SETTINGS_SENSITIVITY_LABEL, + SETTINGS_SENSITIVITY_LIMIT_LABEL, + SETTINGS_SYSTEMS_HELP, + SETTINGS_SYSTEMS_LABEL, + settingsSubtitle, +} from "@/lib/settings-copy"; +import { excludedFromResults, type ExcludedRow } from "@/lib/settings-exclusions"; +import { alertWebhookUrlIsValid } from "@/lib/webhook"; +import { + DESTINATION_LABEL, + applicabilityActionLabel, + type ExclusionReason, +} from "@/lib/vocabulary"; + +/** + * Settings: one page, five groups, no tabs. + * + * The groups are in the order a reader needs them, and the order is an + * argument. What is worth telling you comes first because it is the only + * setting that changes what the product says. The digest is second because it + * is how it says it. What is set aside is third because it is the answer to + * "why am I not seeing X". Connected systems is fourth because it is + * infrastructure. Appearance is last because it is the only one that is not + * about the site at all. + * + * No tabs, deliberately. Five groups fit on one scroll, and a tab is a place to + * hide a setting somebody will later swear does not exist — which is exactly how + * the twelve thresholds this chunk deleted survived as long as they did. + * + * Three things are conspicuously absent and must stay absent: + * + * - Any per-metric threshold. One control, three positions, and the limits it + * resolves to are printed beneath it in the digest's own words. Rebuilding + * the twelve fields somewhere tidier is the same product with a nicer + * drawer. + * - Any per-page sensitivity. S3 removed the page-detail calibration panel; + * this screen does not adopt it. + * - Any weighting, ranking or trust order over the connected systems. The + * evidence ledger exists so that two systems disagreeing is visible rather + * than averaged away, and a control that ordered them would be an average + * with extra steps. + */ + +/* ── Group chrome ───────────────────────────────────────────────────────── */ + +function SettingsGroup({ + id, + label, + help, + action, + children, +}: { + id: string; + label: string; + help: string; + action?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+
+

{label}

+

{help}

+
+ {action ?
{action}
: null} +
+ {children} +
+ ); +} + +/* ── 1. What is worth telling you ───────────────────────────────────────── */ + +const SENSITIVITY_OPTIONS = SENSITIVITIES.map((value) => ({ value, label: SENSITIVITY_LABEL[value] })); + +/** + * The control, and the limits it resolves to, together. + * + * The second half is not decoration. A three-position control over twelve + * numbers is only honest if the reader can see what a position means, and the + * one thing they can check it against is the digest — so the limit printed here + * is the string `digestLimit` gives the digest, not a second formatting of the + * same milliseconds. `settings-sensitivity.test.ts` asserts the two are the + * same characters; if somebody changes the unit in one place, the test fails + * rather than the screen quietly lying. + */ +function SensitivityGroup({ + value, + onChange, + limit, + disabled, +}: { + value: Sensitivity; + onChange: (next: Sensitivity) => void; + limit: string | null; + disabled: boolean; +}) { + return ( + +
+ ({ ...option, disabled }))} + onChange={(next) => onChange(next as Sensitivity)} + /> + {limit ? ( +
+
+
{SETTINGS_SENSITIVITY_LIMIT_LABEL}
+
{limit}
+
+
+ ) : null} +
+
+ ); +} + +/* ── 2. Digest ──────────────────────────────────────────────────────────── */ + +const CADENCE_OPTIONS = DIGEST_CADENCES.map((value) => ({ value, label: DIGEST_CADENCE_LABEL[value] })); + +function DigestGroup({ disabled }: { disabled: boolean }) { + const { digestCadence, digestRecipients, updateDigestSettings } = useStore(); + const cadence = normalizeDigestCadence(digestCadence); + const stored = digestRecipients ?? []; + const storedText = formatDigestRecipients(stored); + // Adjusted during render rather than in an effect: the draft follows the + // stored list when the store changes underneath it, and an effect that called + // setState would render twice for every keystroke's worth of reconciliation. + const [draft, setDraft] = useState(storedText); + const [syncedFrom, setSyncedFrom] = useState(storedText); + if (storedText !== syncedFrom) { + setSyncedFrom(storedText); + setDraft(storedText); + } + + const entered = parseDigestRecipients(draft); + const invalid = entered.find((entry) => !digestRecipientIsValid(entry)); + const dirty = formatDigestRecipients(entered) !== storedText; + + return ( + ({ ...option, disabled }))} + onChange={(next) => updateDigestSettings(normalizeDigestCadence(next), stored)} + /> + } + > +