From 0097639a916c0b30a0ebe7b319563d0231bc2d14 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 27 Jul 2026 18:30:17 +0200 Subject: [PATCH 1/4] feat(client): adopt Fallow quality gate Model Umbraco runtime entry points and generated OpenAPI output in client policy, remove real dead code, consolidate dialog lifecycle duplication, and split dashboard initialization paths. Use Lit-aware 35/40 structural health ceilings and a 65/95 render-only override for the connection editor because its independent provider-capability bindings are declarative form branches. Ignore static CRAP until CI emits real Istanbul coverage. Pin Fallow 3.9.1 and gate pull requests against their base SHA. --- .github/workflows/validate.yml | 6 + .../Client/.fallowrc.json | 74 ++++++++++++ src/TheBuilder.WebAnalytics/Client/.gitignore | 1 + .../Client/package.json | 2 + .../Client/pnpm-lock.yaml | 91 ++++++++++++++ .../analytics-dashboard.controller.ts | 114 ++++++++++-------- .../src/analytics/breakdown-dialog.element.ts | 10 +- .../Client/src/analytics/dialog-lifecycle.ts | 16 +++ .../analytics/event-details-dialog.element.ts | 9 +- .../src/analytics/event-dialog.element.ts | 9 +- .../src/analytics/history-chart.element.ts | 2 +- .../Client/src/analytics/report-error.ts | 4 - .../src/analytics/request-coordinator.ts | 4 - .../Client/src/bundle.manifests.ts | 8 +- .../Client/src/entrypoints/manifest.ts | 2 +- .../Client/src/manifests.test.ts | 6 +- .../Client/src/manifests.ts | 2 +- 17 files changed, 279 insertions(+), 81 deletions(-) create mode 100644 src/TheBuilder.WebAnalytics/Client/.fallowrc.json create mode 100644 src/TheBuilder.WebAnalytics/Client/.gitignore create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/dialog-lifecycle.ts diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 3a60c51..96d4308 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -19,6 +19,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Set up Node.js uses: actions/setup-node@v6 @@ -31,6 +33,10 @@ jobs: - name: Install dependencies run: corepack pnpm install --frozen-lockfile + - name: Audit changed client code + if: github.event_name == 'pull_request' + run: corepack pnpm run audit -- --base '${{ github.event.pull_request.base.sha }}' --format github-annotations + - name: Test client run: corepack pnpm test diff --git a/src/TheBuilder.WebAnalytics/Client/.fallowrc.json b/src/TheBuilder.WebAnalytics/Client/.fallowrc.json new file mode 100644 index 0000000..1411f49 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/.fallowrc.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json", + "entry": [ + "src/bundle.manifests.ts", + "scripts/**/*.js", + "vite.config.ts" + ], + "dynamicallyLoaded": [ + "src/entrypoints/entrypoint.ts", + "src/section/analytics-enabled.condition.ts", + "src/section/analytics-section.element.ts", + "src/settings/settings-dashboard.element.ts", + "src/workspace/analytics-workspace.element.ts", + "src/workspace/document-analytics.condition.ts" + ], + "ignorePatterns": [ + "src/api/**/*.gen.ts", + "src/api/index.ts", + "src/api/client/index.ts" + ], + "ignoreDependencies": [ + // Build-time generator dependencies and the Umbraco-provided runtime peer + // intentionally remain devDependencies; production code is bundled. + "@hey-api/openapi-ts", + "@umbraco-cms/backoffice", + "chalk", + "node-fetch" + ], + "usedClassMembers": [ + "connectedCallback", + "disconnectedCallback", + "focusFirstInvalid", + "styles" + ], + "duplicates": { + // Pair-only similarities are reviewed locally; the gate targets patterns + // copied across three or more locations. + "minOccurrences": 3, + "ignore": [ + "src/api/**/*.gen.ts", + "src/api/index.ts", + "src/api/client/index.ts" + ] + }, + "health": { + // Lit templates count every conditional binding and optional chain as a + // branch. These ceilings still catch structural hotspots without treating + // declarative rendering as equivalent to imperative control flow. + "maxCyclomatic": 35, + "maxCognitive": 40, + // Until CI produces Istanbul coverage, Fallow's static CRAP estimate + // overstates tested Lit and controller code. Gate structural complexity now. + "maxCrap": 1000, + "ignore": [ + "src/api/**/*.gen.ts", + "src/api/index.ts", + "src/api/client/index.ts", + "**/*.test.ts" + ], + "thresholdOverrides": [ + { + "files": ["src/settings/connection-editor.element.ts"], + "functions": ["render"], + "maxCyclomatic": 65, + "maxCognitive": 95, + "reason": "The connection editor is a single declarative Lit form whose conditional bindings mirror independent provider capabilities; extracting them would fragment the form contract without reducing runtime decision-making." + } + ] + }, + "rules": { + "unused-dev-dependencies": "error", + "dev-dependencies-in-production": "error" + } +} diff --git a/src/TheBuilder.WebAnalytics/Client/.gitignore b/src/TheBuilder.WebAnalytics/Client/.gitignore new file mode 100644 index 0000000..54b847d --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/.gitignore @@ -0,0 +1 @@ +.fallow/ diff --git a/src/TheBuilder.WebAnalytics/Client/package.json b/src/TheBuilder.WebAnalytics/Client/package.json index 7068aaf..4e72219 100644 --- a/src/TheBuilder.WebAnalytics/Client/package.json +++ b/src/TheBuilder.WebAnalytics/Client/package.json @@ -9,6 +9,7 @@ "build": "tsc && vite build", "check": "tsc --noEmit", "test": "vitest run", + "audit": "fallow audit --gate new-only", "generate-client": "node scripts/generate-openapi.js" }, "dependencies": { @@ -19,6 +20,7 @@ "@umbraco-cms/backoffice": "^17.1.0", "chalk": "^5.6.2", "cross-env": "^10.1.0", + "fallow": "3.9.1", "jsdom": "^27.4.0", "node-fetch": "^3.3.2", "typescript": "^5.9.3", diff --git a/src/TheBuilder.WebAnalytics/Client/pnpm-lock.yaml b/src/TheBuilder.WebAnalytics/Client/pnpm-lock.yaml index 5771276..3a383ac 100644 --- a/src/TheBuilder.WebAnalytics/Client/pnpm-lock.yaml +++ b/src/TheBuilder.WebAnalytics/Client/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: cross-env: specifier: ^10.1.0 version: 10.1.0 + fallow: + specifier: 3.9.1 + version: 3.9.1 jsdom: specifier: ^27.4.0 version: 27.4.0 @@ -261,6 +264,46 @@ packages: '@noble/hashes': optional: true + '@fallow-cli/darwin-arm64@3.9.1': + resolution: {integrity: sha512-kkOoEd4t/4LrkSHcg8GjeydgKYme1k5YoBhW/fwm2nT/B/uFfK67wd28VrEEzng2seftF2imUBInJjsDdsK4jg==} + cpu: [arm64] + os: [darwin] + + '@fallow-cli/darwin-x64@3.9.1': + resolution: {integrity: sha512-ub9lo4H/tFgZsh22RpyTkNgxWWd/77+zuzHflEND8Hbegrz/WX1hLJtiGfdCBA2meszWg4glTs8pLUx56o2ksw==} + cpu: [x64] + os: [darwin] + + '@fallow-cli/linux-arm64-gnu@3.9.1': + resolution: {integrity: sha512-s/TipX7J+nvctluu/HuqKkztJoxs7dDmsH3LGRhRduq6IelfnFD7p8t/ZmVnA8iE5qQxdOOZi4iF290CcQoxkA==} + cpu: [arm64] + os: [linux] + + '@fallow-cli/linux-arm64-musl@3.9.1': + resolution: {integrity: sha512-UoE+E2gXN/mbvlwbVIioyVsoI/Odqy+8n9q91DkwDCixZ/lTHipNBdmbdRrAyixkL6Sb4WrA1tTrLss+7KQQrQ==} + cpu: [arm64] + os: [linux] + + '@fallow-cli/linux-x64-gnu@3.9.1': + resolution: {integrity: sha512-f4oXqLyJmdZy3cfkhkBJDzWiSHqbWPOERk+yyzUoD7OXsxIiZmXH3DquNoPNygA85CtFUi7W2H506xHtCtDkOg==} + cpu: [x64] + os: [linux] + + '@fallow-cli/linux-x64-musl@3.9.1': + resolution: {integrity: sha512-ofJ+1aoRv7+d5bOJlk043vCCckpFpW7H5MR9kRUU7YV9r1Iuhsq6SbiAwGwrqMODR3zrmVKzEr6cH9IL/f5icA==} + cpu: [x64] + os: [linux] + + '@fallow-cli/win32-arm64-msvc@3.9.1': + resolution: {integrity: sha512-b+AaGm+X0WvFIZ+tOHJr0N4JL4eFWkvMVgT42u0KU+AubkMg01ne2oyEguCJYLyNmxbo18CnJFyXZVx+s7u5Sg==} + cpu: [arm64] + os: [win32] + + '@fallow-cli/win32-x64-msvc@3.9.1': + resolution: {integrity: sha512-E4frwv5/03a2z9MumQOq06OI0JZGxCEyvSBFtONpBZVhUpIF6sbtvSXe3jyJa89lWeYms8g4p+FKtm2G0XF+PA==} + cpu: [x64] + os: [win32] + '@heximal/expressions@0.1.5': resolution: {integrity: sha512-QdWz9vNrdzi24so9KGEM9w4UYLg1yk+LVvYBEDbw9EY1BzKHITWdtYc55xJ3Zuio0/9Naz/D1YtYlCnfsycNDQ==} @@ -1070,6 +1113,10 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + diff@9.0.0: resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} engines: {node: '>=0.3.1'} @@ -1114,6 +1161,11 @@ packages: exsolve@1.1.0: resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} + fallow@3.9.1: + resolution: {integrity: sha512-OVbuoRAz5LDWLAjB64905VnlT1MwPUmuPBBKJ0KYnKejX24edJI0x5degiWf2By6UZQAJBRx2zqflSH2sC+Bcg==} + engines: {node: '>=22'} + hasBin: true + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1769,6 +1821,30 @@ snapshots: '@exodus/bytes@1.15.1': {} + '@fallow-cli/darwin-arm64@3.9.1': + optional: true + + '@fallow-cli/darwin-x64@3.9.1': + optional: true + + '@fallow-cli/linux-arm64-gnu@3.9.1': + optional: true + + '@fallow-cli/linux-arm64-musl@3.9.1': + optional: true + + '@fallow-cli/linux-x64-gnu@3.9.1': + optional: true + + '@fallow-cli/linux-x64-musl@3.9.1': + optional: true + + '@fallow-cli/win32-arm64-msvc@3.9.1': + optional: true + + '@fallow-cli/win32-x64-msvc@3.9.1': + optional: true + '@heximal/expressions@0.1.5': dependencies: tslib: 2.8.1 @@ -2947,6 +3023,8 @@ snapshots: destr@2.0.5: {} + detect-libc@2.1.2: {} + diff@9.0.0: {} dompurify@3.4.12: @@ -3002,6 +3080,19 @@ snapshots: exsolve@1.1.0: {} + fallow@3.9.1: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + '@fallow-cli/darwin-arm64': 3.9.1 + '@fallow-cli/darwin-x64': 3.9.1 + '@fallow-cli/linux-arm64-gnu': 3.9.1 + '@fallow-cli/linux-arm64-musl': 3.9.1 + '@fallow-cli/linux-x64-gnu': 3.9.1 + '@fallow-cli/linux-x64-musl': 3.9.1 + '@fallow-cli/win32-arm64-msvc': 3.9.1 + '@fallow-cli/win32-x64-msvc': 3.9.1 + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts index a714d3f..549757f 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts @@ -463,60 +463,74 @@ export class AnalyticsDashboardController { async #initialize(): Promise { this.#set({ configurationError: undefined, setupRequired: false }); - if (this.#documentId) { - const documentId = this.#documentId; - const result = await this.#initializationRequest.run((signal) => this.#api.documentRoutes({ - path: { documentId }, query: { culture: this.#culture }, signal, - })); - if (result.status === "cancelled" || result.status === "stale") return; - if (result.status === "error") { this.#set({ configurationError: reportErrorMessage(result.error), summary: idleState() }); return; } - const { data, error } = result.value; - const route = !error && data?.length ? activeDocumentRoute(data, this.#culture) : undefined; - if (!route) { - this.#set({ configurationError: "This document is unpublished, unmapped, or its active culture is not configured for analytics.", summary: idleState() }); - return; - } - const selection = normalizeDashboardSelection(this.state, route.capabilities); - this.#set({ route, connection: route.connection, provider: route.provider, capabilities: route.capabilities, ...selection }); - } else { - const result = await this.#initializationRequest.run((signal) => this.#api.connections({ signal })); - if (result.status === "cancelled" || result.status === "stale") return; - if (result.status === "error") { this.#set({ configurationError: reportErrorMessage(result.error), summary: idleState() }); return; } - const { data, error } = result.value; - if (error || !data?.enabled) { - this.#set({ configurationError: "Web Analytics is disabled or unavailable. Ask an administrator to configure a connection.", summary: idleState() }); - return; - } - if (data.connections.length === 0) { - this.#set({ setupRequired: true, summary: idleState() }); - return; - } - let { preset, range } = this.state; - if (!this.#hasUrlDateState) { - preset = [1, 7, 30, 90, 365].includes(data.defaultRangeDays) ? data.defaultRangeDays as Exclude : "custom"; - range = dateRangeForPreset(data.defaultRangeDays); - } - const stored = this.#environment.getStoredConnection(); - const requested = data.connections.some(({ key }) => key === this.state.connection) ? this.state.connection : undefined; - const storedValid = data.connections.some(({ key }) => key === stored) ? stored ?? undefined : undefined; - const connection = requested ?? storedValid ?? data.connections[0]?.key; - const selectedConnection = data.connections.find(({ key }) => key === connection); - const capabilities = selectedConnection?.capabilities ?? unavailableCapabilities; - const selection = normalizeDashboardSelection(this.state, capabilities); - this.#set({ - connections: data.connections, - connection, - provider: selectedConnection?.provider, - capabilities, - ...selection, - preset, - range, - }); - } + const initialized = this.#documentId + ? await this.#initializeDocument(this.#documentId) + : await this.#initializeGlobal(); + if (!initialized) return; this.#syncUrlState(); await this.loadReports(); } + async #initializeDocument(documentId: string): Promise { + const result = await this.#initializationRequest.run((signal) => this.#api.documentRoutes({ + path: { documentId }, query: { culture: this.#culture }, signal, + })); + if (result.status === "cancelled" || result.status === "stale") return false; + if (result.status === "error") { + this.#set({ configurationError: reportErrorMessage(result.error), summary: idleState() }); + return false; + } + const { data, error } = result.value; + const route = !error && data?.length ? activeDocumentRoute(data, this.#culture) : undefined; + if (!route) { + this.#set({ configurationError: "This document is unpublished, unmapped, or its active culture is not configured for analytics.", summary: idleState() }); + return false; + } + const selection = normalizeDashboardSelection(this.state, route.capabilities); + this.#set({ route, connection: route.connection, provider: route.provider, capabilities: route.capabilities, ...selection }); + return true; + } + + async #initializeGlobal(): Promise { + const result = await this.#initializationRequest.run((signal) => this.#api.connections({ signal })); + if (result.status === "cancelled" || result.status === "stale") return false; + if (result.status === "error") { + this.#set({ configurationError: reportErrorMessage(result.error), summary: idleState() }); + return false; + } + const { data, error } = result.value; + if (error || !data?.enabled) { + this.#set({ configurationError: "Web Analytics is disabled or unavailable. Ask an administrator to configure a connection.", summary: idleState() }); + return false; + } + if (data.connections.length === 0) { + this.#set({ setupRequired: true, summary: idleState() }); + return false; + } + let { preset, range } = this.state; + if (!this.#hasUrlDateState) { + preset = [1, 7, 30, 90, 365].includes(data.defaultRangeDays) ? data.defaultRangeDays as Exclude : "custom"; + range = dateRangeForPreset(data.defaultRangeDays); + } + const stored = this.#environment.getStoredConnection(); + const requested = data.connections.some(({ key }) => key === this.state.connection) ? this.state.connection : undefined; + const storedValid = data.connections.some(({ key }) => key === stored) ? stored ?? undefined : undefined; + const connection = requested ?? storedValid ?? data.connections[0]?.key; + const selectedConnection = data.connections.find(({ key }) => key === connection); + const capabilities = selectedConnection?.capabilities ?? unavailableCapabilities; + const selection = normalizeDashboardSelection(this.state, capabilities); + this.#set({ + connections: data.connections, + connection, + provider: selectedConnection?.provider, + capabilities, + ...selection, + preset, + range, + }); + return true; + } + #applyReportUpdate(update: DashboardReportUpdate): void { if (update.panel === "summary") { this.#set({ summary: update.status === "error" ? errorState(update.error, this.state.summary) : successState(update.data) }); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts index c270472..155f4fa 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts @@ -15,6 +15,7 @@ import { analyticsDialogStyles } from "./analytics-dialog.styles.js"; import { breakdownDimensionLabel, type TrafficMetric } from "./breakdown-rows.js"; import { AUDIENCE_OPTIONS, breakdownDialogGroup, referrerDimensionOption, UTM_OPTIONS, type DimensionOption } from "./dashboard-cards.js"; import type { AnalyticsFilter, UtmDimension } from "./dashboard-url-state.js"; +import { cancelDialog, closeDialog, notifyDialogClosed, openDialog } from "./dialog-lifecycle.js"; import { isUtmDimension } from "./utm-capability.js"; import type { ReportTabGroup } from "./report-tabs.js"; import "./breakdown-table.element.js"; @@ -36,20 +37,19 @@ export class WebAnalyticsBreakdownDialogElement extends UmbElementMixin(LitEleme @state() private _utmDimension?: UtmDimension; protected firstUpdated(): void { - this.shadowRoot?.querySelector("dialog")?.showModal(); + openDialog(this); } #close(): void { - this.shadowRoot?.querySelector("dialog")?.close(); + closeDialog(this); } #notifyClosed(): void { - this.dispatchEvent(new CustomEvent("close-breakdown", { bubbles: true, composed: true })); + notifyDialogClosed(this, "close-breakdown"); } #onCancel(event: Event): void { - event.preventDefault(); - this.#close(); + cancelDialog(event, this); } #onSearch(event: Event): void { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dialog-lifecycle.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dialog-lifecycle.ts new file mode 100644 index 0000000..be3cd90 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dialog-lifecycle.ts @@ -0,0 +1,16 @@ +export function openDialog(host: HTMLElement): void { + host.shadowRoot?.querySelector("dialog")?.showModal(); +} + +export function closeDialog(host: HTMLElement): void { + host.shadowRoot?.querySelector("dialog")?.close(); +} + +export function notifyDialogClosed(host: HTMLElement, eventName: string): void { + host.dispatchEvent(new CustomEvent(eventName, { bubbles: true, composed: true })); +} + +export function cancelDialog(event: Event, host: HTMLElement): void { + event.preventDefault(); + closeDialog(host); +} diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.ts index b940402..773e2b4 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.ts @@ -6,6 +6,7 @@ import type { AnalyticsEventDetails, AnalyticsEventProperty } from "../api/types import { renderAnalyticsDialogHeadline } from "./analytics-dialog-headline.js"; import { analyticsDialogStyles, analyticsEventDialogStyles } from "./analytics-dialog.styles.js"; import { analyticsTableSkeletonStyles, renderAnalyticsTableSkeletonRows } from "./analytics-table-skeleton.js"; +import { cancelDialog, closeDialog, notifyDialogClosed, openDialog } from "./dialog-lifecycle.js"; import { renderReportTabs, reportTabsStyles } from "./report-tabs.js"; @customElement("web-analytics-event-details-dialog") @@ -24,10 +25,10 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl @state() private _propertyName?: string; @state() private _search = ""; - protected firstUpdated(): void { this.shadowRoot?.querySelector("dialog")?.showModal(); } - #close(): void { this.shadowRoot?.querySelector("dialog")?.close(); } - #notifyClosed(): void { this.dispatchEvent(new CustomEvent("close-event-details", { bubbles: true, composed: true })); } - #onCancel(event: Event): void { event.preventDefault(); this.#close(); } + protected firstUpdated(): void { openDialog(this); } + #close(): void { closeDialog(this); } + #notifyClosed(): void { notifyDialogClosed(this, "close-event-details"); } + #onCancel(event: Event): void { cancelDialog(event, this); } #backToEvents(): void { this.dispatchEvent(new CustomEvent("back-to-events", { bubbles: true, composed: true })); } #activeProperty(): AnalyticsEventProperty | undefined { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts index c2a0080..a81305d 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts @@ -6,6 +6,7 @@ import type { AnalyticsEventRow } from "../api/types.gen.js"; import { renderAnalyticsDialogHeadline } from "./analytics-dialog-headline.js"; import { analyticsDialogStyles, analyticsEventDialogStyles } from "./analytics-dialog.styles.js"; import type { AnalyticsFilter } from "./dashboard-url-state.js"; +import { cancelDialog, closeDialog, notifyDialogClosed, openDialog } from "./dialog-lifecycle.js"; import "./event-table.element.js"; @customElement("web-analytics-event-dialog") @@ -18,10 +19,10 @@ export class WebAnalyticsEventDialogElement extends UmbElementMixin(LitElement) @property({ type: Boolean }) filteringEnabled = false; @state() private _search = ""; - protected firstUpdated(): void { this.shadowRoot?.querySelector("dialog")?.showModal(); } - #close(): void { this.shadowRoot?.querySelector("dialog")?.close(); } - #notifyClosed(): void { this.dispatchEvent(new CustomEvent("close-events", { bubbles: true, composed: true })); } - #onCancel(event: Event): void { event.preventDefault(); this.#close(); } + protected firstUpdated(): void { openDialog(this); } + #close(): void { closeDialog(this); } + #notifyClosed(): void { notifyDialogClosed(this, "close-events"); } + #onCancel(event: Event): void { cancelDialog(event, this); } #onSearch(event: Event): void { this._search = String((event.target as UUIInputElement).value ?? ""); this.dispatchEvent(new CustomEvent("search-events", { bubbles: true, composed: true, detail: { search: this._search.trim() } })); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/history-chart.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/history-chart.element.ts index f3b0c32..274f156 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/history-chart.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/history-chart.element.ts @@ -24,7 +24,7 @@ import { formatAnalyticsDate, formatAnalyticsTooltipDate, isAnalyticsPeriodInPro Chart.register(LineController, LineElement, PointElement, LinearScale, CategoryScale, Filler, Tooltip); @customElement("web-analytics-history-chart") -export class WebAnalyticsHistoryChartElement extends UmbElementMixin(LitElement) { +class WebAnalyticsHistoryChartElement extends UmbElementMixin(LitElement) { @property({ attribute: false }) points: Array<{ timestamp: string; visitors: number; pageViews?: number; count?: number }> = []; @property() metric: "visitors" | "pageViews" | "count" = "visitors"; @property() interval: AnalyticsInterval = "Day"; diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/report-error.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/report-error.ts index 795b665..b8c411f 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/report-error.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/report-error.ts @@ -1,9 +1,5 @@ import { apiFailure } from "../api/api-failure.js"; -export function reportApiErrorMessage(error: unknown, status: number): string { - return reportErrorMessage(apiFailure(error, status)); -} - export function reportErrorMessage(error: unknown): string { const { code, status } = apiFailure(error); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/request-coordinator.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/request-coordinator.ts index 107cf9c..56c0583 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/request-coordinator.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/request-coordinator.ts @@ -12,10 +12,6 @@ export class RequestCoordinator { #generation = 0; #abort?: AbortController; - get signal(): AbortSignal | undefined { - return this.#abort?.signal; - } - async run(request: (signal: AbortSignal) => Promise): Promise> { this.#abort?.abort(); const generation = ++this.#generation; diff --git a/src/TheBuilder.WebAnalytics/Client/src/bundle.manifests.ts b/src/TheBuilder.WebAnalytics/Client/src/bundle.manifests.ts index e8c6b04..a195c11 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/bundle.manifests.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/bundle.manifests.ts @@ -1,9 +1,9 @@ -import { manifests as entrypoints } from "./entrypoints/manifest.js"; -import { manifests as analytics } from "./manifests.js"; +import { entrypointManifests } from "./entrypoints/manifest.js"; +import { extensionManifests } from "./manifests.js"; // Job of the bundle is to collate all the manifests from different parts of the extension and load other manifests // We load this bundle from umbraco-package.json export const manifests: Array = [ - ...entrypoints, - ...analytics, + ...entrypointManifests, + ...extensionManifests, ]; diff --git a/src/TheBuilder.WebAnalytics/Client/src/entrypoints/manifest.ts b/src/TheBuilder.WebAnalytics/Client/src/entrypoints/manifest.ts index 631aa12..afb3db3 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/entrypoints/manifest.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/entrypoints/manifest.ts @@ -1,4 +1,4 @@ -export const manifests: Array = [ +export const entrypointManifests: Array = [ { name: "Web Analytics Entrypoint", alias: "TheBuilder.WebAnalytics.Entrypoint", diff --git a/src/TheBuilder.WebAnalytics/Client/src/manifests.test.ts b/src/TheBuilder.WebAnalytics/Client/src/manifests.test.ts index e5e6680..6751724 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/manifests.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/manifests.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import { manifests } from "./manifests.js"; +import { extensionManifests } from "./manifests.js"; describe("Web Analytics manifests", () => { it("only exposes the Analytics section when the package is enabled", () => { - const section = manifests.find((manifest) => manifest.alias === "TheBuilder.WebAnalytics.Section") as + const section = extensionManifests.find((manifest) => manifest.alias === "TheBuilder.WebAnalytics.Section") as | { conditions?: Array<{ alias: string }> } | undefined; - const condition = manifests.find((manifest) => manifest.alias === "TheBuilder.WebAnalytics.Condition.AnalyticsEnabled"); + const condition = extensionManifests.find((manifest) => manifest.alias === "TheBuilder.WebAnalytics.Condition.AnalyticsEnabled"); expect(condition?.type).toBe("condition"); expect(section?.conditions).toContainEqual({ alias: "TheBuilder.WebAnalytics.Condition.AnalyticsEnabled" }); diff --git a/src/TheBuilder.WebAnalytics/Client/src/manifests.ts b/src/TheBuilder.WebAnalytics/Client/src/manifests.ts index 0729304..4bbef95 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/manifests.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/manifests.ts @@ -1,4 +1,4 @@ -export const manifests: Array = [ +export const extensionManifests: Array = [ { type: "condition", alias: "TheBuilder.WebAnalytics.Condition.AnalyticsEnabled", From 385d88dfecf2b6eebd8594cc135c52848a9da5e0 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 27 Jul 2026 18:42:33 +0200 Subject: [PATCH 2/4] refactor(client): tighten Fallow policy Restore pair-level duplicate detection and extract all six remaining clone pairs into focused helpers for tabs, dialogs, breakdown footers, date parts, chart state, and initialization results. Lower global health ceilings to cyclomatic 30 and cognitive 35. Keep narrow render-only overrides for the declarative dashboard and connection editor, with finite CRAP ceilings of 300 globally and 900 for the connection form until CI provides real Istanbul coverage. --- .../Client/.fallowrc.json | 20 ++++++++----- .../analytics-breakdown-grid.element.ts | 28 ++++++++++++------- .../analytics-dashboard.controller.ts | 21 +++++++------- .../analytics/analytics-summary.element.ts | 10 ++----- .../src/analytics/breakdown-dialog.element.ts | 11 ++------ .../Client/src/analytics/date-range.ts | 23 +++++++++------ .../Client/src/analytics/dialog-lifecycle.ts | 12 ++++++++ .../src/analytics/event-dialog.element.ts | 7 ++--- .../src/analytics/history-chart.element.ts | 17 +++++------ .../Client/src/analytics/report-tabs.ts | 6 ++-- .../Client/src/analytics/tab-keyboard.ts | 8 ++++++ 11 files changed, 94 insertions(+), 69 deletions(-) create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/tab-keyboard.ts diff --git a/src/TheBuilder.WebAnalytics/Client/.fallowrc.json b/src/TheBuilder.WebAnalytics/Client/.fallowrc.json index 1411f49..74707c9 100644 --- a/src/TheBuilder.WebAnalytics/Client/.fallowrc.json +++ b/src/TheBuilder.WebAnalytics/Client/.fallowrc.json @@ -33,9 +33,7 @@ "styles" ], "duplicates": { - // Pair-only similarities are reviewed locally; the gate targets patterns - // copied across three or more locations. - "minOccurrences": 3, + "minOccurrences": 2, "ignore": [ "src/api/**/*.gen.ts", "src/api/index.ts", @@ -46,11 +44,12 @@ // Lit templates count every conditional binding and optional chain as a // branch. These ceilings still catch structural hotspots without treating // declarative rendering as equivalent to imperative control flow. - "maxCyclomatic": 35, - "maxCognitive": 40, + "maxCyclomatic": 30, + "maxCognitive": 35, // Until CI produces Istanbul coverage, Fallow's static CRAP estimate - // overstates tested Lit and controller code. Gate structural complexity now. - "maxCrap": 1000, + // overstates tested Lit and controller code. Keep a finite ceiling while + // structural complexity remains the primary gate. + "maxCrap": 300, "ignore": [ "src/api/**/*.gen.ts", "src/api/index.ts", @@ -58,11 +57,18 @@ "**/*.test.ts" ], "thresholdOverrides": [ + { + "files": ["src/analytics/analytics-dashboard.element.ts"], + "functions": ["render"], + "maxCyclomatic": 35, + "reason": "The dashboard render method composes independent optional panels and dialog states; its declarative Lit bindings are kept under a narrow render-only ceiling." + }, { "files": ["src/settings/connection-editor.element.ts"], "functions": ["render"], "maxCyclomatic": 65, "maxCognitive": 95, + "maxCrap": 900, "reason": "The connection editor is a single declarative Lit form whose conditional bindings mirror independent provider capabilities; extracting them would fragment the form contract without reducing runtime decision-making." } ] diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts index 3812f5e..ae2eb19 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts @@ -66,6 +66,22 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement }; } + #renderBreakdownFooter( + loading: boolean, + unavailable: string | undefined, + hasRows: boolean, + selected: ReturnType, + ) { + if (!loading && hasRows && !unavailable) { + return html` + this.#dispatch("view-breakdown", selected)}>View all + `; + } + return !loading && unavailable + ? html` this.#dispatch("retry-reports")}>Retry` + : ""; + } + #renderCard(card: DashboardCard) { const selected = selectedCardDimension(card, this.audienceDimension, this.utmDimension); const state = this.breakdowns[selected.dimension]; @@ -97,11 +113,7 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement ${planLimited && unavailable ? html`

UTM reporting availability depends on your analytics plan and reporting window.

` : ""}
- ${!loading && !unavailable && rows.length ? html` - this.#dispatch("view-breakdown", selected)}>View all - ` : !loading && unavailable ? html` - this.#dispatch("retry-reports")}>Retry - ` : ""} + ${this.#renderBreakdownFooter(loading, unavailable, rows.length > 0, selected)}
@@ -139,11 +151,7 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement @heading-tab-change=${(event: CustomEvent<{ value: AcquisitionView }>) => this.#dispatch("acquisition-change", { view: event.detail.value })} @subheading-tab-change=${(event: CustomEvent<{ value: UtmDimension }>) => this.#dispatch("utm-change", { dimension: event.detail.value })}>
- ${!loading && !unavailable && rows.length ? html` - this.#dispatch("view-breakdown", selected)}>View all - ` : !loading && unavailable ? html` - this.#dispatch("retry-reports")}>Retry - ` : ""} + ${this.#renderBreakdownFooter(loading, unavailable, rows.length > 0, selected)}
diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts index 549757f..51f9a9c 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts @@ -29,7 +29,7 @@ import { import { loadDashboardBreakdown, loadDashboardBreakdowns, loadDashboardReports, type DashboardReportQuery, type DashboardReportUpdate } from "./dashboard-report-loader.js"; import { visibleEventRows } from "./event-rows.js"; import { reportErrorMessage } from "./report-error.js"; -import { DebouncedRequest, RequestCoordinator } from "./request-coordinator.js"; +import { DebouncedRequest, RequestCoordinator, type RequestResult } from "./request-coordinator.js"; import { detectUtmCapability, type UtmCapability } from "./utm-capability.js"; import { errorState, idleState, loadingState, successState, type AsyncState } from "./async-state.js"; import { normalizeDashboardSelection, supportsDimension, unavailableCapabilities } from "./dashboard-capabilities.js"; @@ -475,11 +475,7 @@ export class AnalyticsDashboardController { const result = await this.#initializationRequest.run((signal) => this.#api.documentRoutes({ path: { documentId }, query: { culture: this.#culture }, signal, })); - if (result.status === "cancelled" || result.status === "stale") return false; - if (result.status === "error") { - this.#set({ configurationError: reportErrorMessage(result.error), summary: idleState() }); - return false; - } + if (!this.#initializationSucceeded(result)) return false; const { data, error } = result.value; const route = !error && data?.length ? activeDocumentRoute(data, this.#culture) : undefined; if (!route) { @@ -493,11 +489,7 @@ export class AnalyticsDashboardController { async #initializeGlobal(): Promise { const result = await this.#initializationRequest.run((signal) => this.#api.connections({ signal })); - if (result.status === "cancelled" || result.status === "stale") return false; - if (result.status === "error") { - this.#set({ configurationError: reportErrorMessage(result.error), summary: idleState() }); - return false; - } + if (!this.#initializationSucceeded(result)) return false; const { data, error } = result.value; if (error || !data?.enabled) { this.#set({ configurationError: "Web Analytics is disabled or unavailable. Ask an administrator to configure a connection.", summary: idleState() }); @@ -531,6 +523,13 @@ export class AnalyticsDashboardController { return true; } + #initializationSucceeded(result: RequestResult): result is Extract, { status: "success" }> { + if (result.status === "error") { + this.#set({ configurationError: reportErrorMessage(result.error), summary: idleState() }); + } + return result.status === "success"; + } + #applyReportUpdate(update: DashboardReportUpdate): void { if (update.panel === "summary") { this.#set({ summary: update.status === "error" ? errorState(update.error, this.state.summary) : successState(update.data) }); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-summary.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-summary.element.ts index c863806..dd61f66 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-summary.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-summary.element.ts @@ -6,6 +6,7 @@ import { inclusiveRangeDays, type AnalyticsDateRange } from "./date-range.js"; import { metricComparison } from "./metric-comparison.js"; import type { DashboardMetric } from "./dashboard-url-state.js"; import { isInitialLoading, stateData, type AsyncState } from "./async-state.js"; +import { targetTabIndex } from "./tab-keyboard.js"; import "./history-chart.element.js"; @customElement("web-analytics-summary") @@ -30,14 +31,7 @@ export class WebAnalyticsSummaryElement extends UmbElementMixin(LitElement) { if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; event.preventDefault(); const tabs = Array.from(this.shadowRoot?.querySelectorAll("[role=tab]") ?? []); - const currentIndex = tabs.indexOf(event.currentTarget as HTMLButtonElement); - const targetIndex = event.key === "Home" - ? 0 - : event.key === "End" - ? tabs.length - 1 - : event.key === "ArrowLeft" - ? (currentIndex - 1 + tabs.length) % tabs.length - : (currentIndex + 1) % tabs.length; + const targetIndex = targetTabIndex(event, tabs); tabs[targetIndex]?.click(); tabs[targetIndex]?.focus(); } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts index 155f4fa..ada4fbf 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts @@ -8,14 +8,13 @@ import { } from "@umbraco-cms/backoffice/external/lit"; import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; -import type { UUIInputElement } from "@umbraco-cms/backoffice/external/uui"; import type { AnalyticsBreakdownRow, AnalyticsDimension } from "../api/types.gen.js"; import { renderAnalyticsDialogHeadline } from "./analytics-dialog-headline.js"; import { analyticsDialogStyles } from "./analytics-dialog.styles.js"; import { breakdownDimensionLabel, type TrafficMetric } from "./breakdown-rows.js"; import { AUDIENCE_OPTIONS, breakdownDialogGroup, referrerDimensionOption, UTM_OPTIONS, type DimensionOption } from "./dashboard-cards.js"; import type { AnalyticsFilter, UtmDimension } from "./dashboard-url-state.js"; -import { cancelDialog, closeDialog, notifyDialogClosed, openDialog } from "./dialog-lifecycle.js"; +import { cancelDialog, closeDialog, notifyDialogClosed, notifyDialogSearch, openDialog, searchInputValue } from "./dialog-lifecycle.js"; import { isUtmDimension } from "./utm-capability.js"; import type { ReportTabGroup } from "./report-tabs.js"; import "./breakdown-table.element.js"; @@ -53,12 +52,8 @@ export class WebAnalyticsBreakdownDialogElement extends UmbElementMixin(LitEleme } #onSearch(event: Event): void { - this._search = String((event.target as UUIInputElement).value ?? ""); - this.dispatchEvent(new CustomEvent("search-breakdown", { - bubbles: true, - composed: true, - detail: { search: this._search.trim() }, - })); + this._search = searchInputValue(event); + notifyDialogSearch(this, "search-breakdown", this._search); } #selectDimension(option?: DimensionOption): void { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts index 1d7cb9d..6388727 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts @@ -291,14 +291,11 @@ function validIso(value: string): string | undefined { } function startOfZonedHour(date: Date, timeZone: string): Date { - const parts = new Intl.DateTimeFormat("en", { + const value = zonedNumericPartValue(date, timeZone, { minute: "2-digit", second: "2-digit", hourCycle: "h23", - timeZone, - }).formatToParts(date); - const value = (type: Intl.DateTimeFormatPartTypes) => - Number(parts.find((part) => part.type === type)?.value ?? 0); + }); const elapsedInHour = value("minute") * 60_000 + value("second") * 1000 + date.getUTCMilliseconds(); @@ -336,12 +333,20 @@ function zonedMidnightToIso(dateOnly: string, timeZone: string): string | undefi } function timeZoneOffsetMilliseconds(date: Date, timeZone: string): number { - const parts = new Intl.DateTimeFormat("en", { + const value = zonedNumericPartValue(date, timeZone, { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", - hourCycle: "h23", timeZone, - }).formatToParts(date); - const value = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((part) => part.type === type)?.value ?? 0); + hourCycle: "h23", + }); const wallClockAsUtc = Date.UTC(value("year"), value("month") - 1, value("day"), value("hour"), value("minute"), value("second")); return wallClockAsUtc - Math.floor(date.valueOf() / 1000) * 1000; } + +function zonedNumericPartValue( + date: Date, + timeZone: string, + options: Intl.DateTimeFormatOptions, +): (type: Intl.DateTimeFormatPartTypes) => number { + const parts = new Intl.DateTimeFormat("en", { ...options, timeZone }).formatToParts(date); + return (type) => Number(parts.find((part) => part.type === type)?.value ?? 0); +} diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dialog-lifecycle.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dialog-lifecycle.ts index be3cd90..bfe993a 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/dialog-lifecycle.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dialog-lifecycle.ts @@ -14,3 +14,15 @@ export function cancelDialog(event: Event, host: HTMLElement): void { event.preventDefault(); closeDialog(host); } + +export function searchInputValue(event: Event): string { + return String((event.target as HTMLElement & { value?: unknown }).value ?? ""); +} + +export function notifyDialogSearch(host: HTMLElement, eventName: string, search: string): void { + host.dispatchEvent(new CustomEvent(eventName, { + bubbles: true, + composed: true, + detail: { search: search.trim() }, + })); +} diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts index a81305d..20a7fd6 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts @@ -1,12 +1,11 @@ import { LitElement, css, customElement, html, property, state } from "@umbraco-cms/backoffice/external/lit"; import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; -import type { UUIInputElement } from "@umbraco-cms/backoffice/external/uui"; import type { AnalyticsEventRow } from "../api/types.gen.js"; import { renderAnalyticsDialogHeadline } from "./analytics-dialog-headline.js"; import { analyticsDialogStyles, analyticsEventDialogStyles } from "./analytics-dialog.styles.js"; import type { AnalyticsFilter } from "./dashboard-url-state.js"; -import { cancelDialog, closeDialog, notifyDialogClosed, openDialog } from "./dialog-lifecycle.js"; +import { cancelDialog, closeDialog, notifyDialogClosed, notifyDialogSearch, openDialog, searchInputValue } from "./dialog-lifecycle.js"; import "./event-table.element.js"; @customElement("web-analytics-event-dialog") @@ -24,8 +23,8 @@ export class WebAnalyticsEventDialogElement extends UmbElementMixin(LitElement) #notifyClosed(): void { notifyDialogClosed(this, "close-events"); } #onCancel(event: Event): void { cancelDialog(event, this); } #onSearch(event: Event): void { - this._search = String((event.target as UUIInputElement).value ?? ""); - this.dispatchEvent(new CustomEvent("search-events", { bubbles: true, composed: true, detail: { search: this._search.trim() } })); + this._search = searchInputValue(event); + notifyDialogSearch(this, "search-events", this._search); } render() { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/history-chart.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/history-chart.element.ts index 274f156..967e0f7 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/history-chart.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/history-chart.element.ts @@ -55,10 +55,7 @@ class WebAnalyticsHistoryChartElement extends UmbElementMixin(LitElement) { const borderColor = style.getPropertyValue("--uui-color-border").trim() || "#d8d7d9"; const gridColor = `color-mix(in srgb, ${borderColor} 42%, transparent)`; const label = this.#metricLabel(); - const latestPoint = this.points[this.points.length - 1]; - const latestPeriodInProgress = latestPoint - ? isAnalyticsPeriodInProgress(latestPoint.timestamp, this.interval, new Date(), this.timeZone) - : false; + const latestPeriodInProgress = this.#latestPeriodInProgress(); const hoverGuide: Plugin<"line"> = { id: "webAnalyticsHoverGuide", afterDatasetsDraw: (chart) => { @@ -162,10 +159,7 @@ class WebAnalyticsHistoryChartElement extends UmbElementMixin(LitElement) { render() { const label = this.#metricLabel(); - const latestPoint = this.points[this.points.length - 1]; - const latestPeriodInProgress = latestPoint - ? isAnalyticsPeriodInProgress(latestPoint.timestamp, this.interval, new Date(), this.timeZone) - : false; + const latestPeriodInProgress = this.#latestPeriodInProgress(); const progressDescription = latestPeriodInProgress ? ". The final period is still in progress" : ""; return html`