From 3cfa2fbedd1bc642ac5f0610d803bc7ea089884b Mon Sep 17 00:00:00 2001 From: Stefan Kalscheuer Date: Thu, 6 Aug 2026 18:02:40 +0200 Subject: [PATCH 1/3] translate JavaScript sources to TypeScript Modernize the frontend sources and use TypeScript instead of plain JavaScript. We gain some type safety and dev experience from that. Keep the tracking snippet as is, because this one targets maximum compatibility and simply minifying ES5 code does the job pretty well. --- .distignore | 2 + .gitattributes | 1 + .github/workflows/tests.yml | 2 + eslint.config.mjs | 7 +- js/{dashboard.js => dashboard.ts} | 528 +++++++++++++++++------------- js/global.d.ts | 13 + js/{settings.js => settings.ts} | 12 +- js/types.d.ts | 38 +++ package-lock.json | 30 +- package.json | 8 +- tsconfig.json | 18 + 11 files changed, 413 insertions(+), 246 deletions(-) rename js/{dashboard.js => dashboard.ts} (64%) create mode 100644 js/global.d.ts rename js/{settings.js => settings.ts} (89%) create mode 100644 js/types.d.ts create mode 100644 tsconfig.json diff --git a/.distignore b/.distignore index 19666d6d..85528c6a 100644 --- a/.distignore +++ b/.distignore @@ -29,3 +29,5 @@ /phpunit.coverage.xml /phpunit.report.xml /README.md +/js/*.ts +/tsconfig.json diff --git a/.gitattributes b/.gitattributes index 6c7f2e92..2e2887d8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -29,3 +29,4 @@ /phpunit.coverage.xml export-ignore /phpunit.report.xml export-ignore /README.md export-ignore +/tsconfig.json export-ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 23317261..9c01ac4a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -53,6 +53,8 @@ jobs: run: composer test - name: PHP unit tests (multisite) run: composer test-multisite + - name: Check TypeScript + run: npm run check - name: JS unit tests run: npm run test quality: diff --git a/eslint.config.mjs b/eslint.config.mjs index 73bd121a..a35ef32c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,8 +4,11 @@ import globals from 'globals'; export default [ ...wordpress.configs.recommended.map((config) => ({ ...config, - files: ['js/**.js', 'tests/js/*.test.js'], - ignores: ['js/snippet.js'], + files: ['js/**.ts', 'tests/js/*.test.ts'], + })), + ...wordpress.configs.recommended.map((config) => ({ + ...config, + files: ['tests/js/*.test.js'], })), ...wordpress.configs.es5.map((config) => ({ ...config, diff --git a/js/dashboard.js b/js/dashboard.ts similarity index 64% rename from js/dashboard.js rename to js/dashboard.ts index 9dda6248..a52b320c 100644 --- a/js/dashboard.js +++ b/js/dashboard.ts @@ -1,3 +1,5 @@ +import { FlatSeries } from 'chartist'; + { // Initialize DOM elements. const charts = { @@ -12,26 +14,40 @@ }; const tables = { // Dashboard Widget. - referrer: document.querySelector( + referrer: document.querySelector( '#statify_dashboard .table.referrer table tbody' ), - target: document.querySelector( + target: document.querySelector( '#statify_dashboard .table.target table tbody' ), - totals: document.querySelector( + totals: document.querySelector( '#statify_dashboard .table.total table tbody' ), // Extended Evaluation. - yearly: document.getElementById('statify-table-yearly'), - daily: document.getElementById('statify-table-daily'), - content: document.getElementById('statify-table-posts'), - referrers: document.getElementById('statify-table-referrer'), + yearly: document.getElementById( + 'statify-table-yearly' + ) as HTMLTableElement, + daily: document.getElementById( + 'statify-table-daily' + ) as HTMLTableElement, + content: document.getElementById( + 'statify-table-posts' + ) as HTMLTableElement, + referrers: document.getElementById( + 'statify-table-referrer' + ) as HTMLTableElement, }; const controls = { // Extended Evaluation. - postInput: document.getElementById('statify-dashboard-post'), - postList: document.getElementById('statify-dashboard-posts'), - dateRangeSelect: document.getElementById('statify-content-daterange'), + postInput: document.getElementById( + 'statify-dashboard-post' + ) as HTMLInputElement, + postList: document.getElementById( + 'statify-dashboard-posts' + ) as HTMLInputElement, + dateRangeSelect: document.getElementById( + 'statify-content-daterange' + ) as HTMLSelectElement, }; // Initialize number format. @@ -56,10 +72,10 @@ /** * Get user language from WP i18n, converted to BCP-47 notation. * - * @return {string|undefined} BCP-47 language tag, if valid and supported. + * @return BCP-47 language tag, if valid and supported. */ - function getUserLanguage() { - let lc = wp.i18n.getLocaleData()['']?.lang || ''; + function getUserLanguage(): string | undefined { + let lc = (wp.i18n.getLocaleData()[''] as any)?.lang || ''; try { // Get supported value - automatically sanitizes capitalization if necessary. @@ -76,8 +92,8 @@ // Render available charts and tables. if (charts.daily) { - loadDaily(charts.daily.dataset.year).then((data) => { - renderDaily(charts.daily, data); + loadDaily(charts.daily.dataset.year!).then((data) => { + renderDaily(charts.daily!, data); if (charts.monthly) { renderMonthly(charts.monthly, dailyToMonthly(data), false); @@ -90,7 +106,7 @@ } else if (charts.monthly) { loadMonthly() .then((data) => { - renderMonthly(charts.monthly, data); + renderMonthly(charts.monthly!, data); if (charts.yearly) { renderYearly(charts.yearly, data); @@ -103,7 +119,7 @@ .catch(() => { // Failed to load. msg( - charts.monthly, + charts.monthly!, wp.i18n.__('Error loading data.', 'statify') ); }); @@ -124,7 +140,7 @@ if (charts.referrer) { // Limit number of records. data = data.slice(0, 24); - const labels = data.map((d) => d.host); + const labels = data.map((d) => d.host || ''); const values = data.map((d) => d.count); renderBarChart(charts.referrer, labels, values, true); } @@ -133,12 +149,12 @@ // Augment available controls. if (controls.postInput && controls.postList) { - fetchData('posts').then((data) => + fetchData('posts').then((data) => data.forEach((post) => { const opt = document.createElement('option'); opt.value = post.url; opt.textContent = post.title; - controls.postList.appendChild(opt); + controls.postList!.appendChild(opt); }) ); } @@ -152,16 +168,16 @@ /** * Update the dashboard widget * - * @param {boolean} refresh Force refresh. + * @param refresh Force refresh. */ - function updateDashboard(refresh) { + function updateDashboard(refresh: boolean): void { // Load data from API. - fetchData('stats', refresh ? 'refresh=1' : null) + fetchData('stats', refresh ? 'refresh=1' : undefined) .then((data) => { const labels = Object.keys(data.visits); const values = Object.values(data.visits); - render(charts.dashboard, labels, values, false, (date) => + render(charts.dashboard!, labels, values, false, (date) => dateFormatYMD.format(new Date(date)) ); @@ -179,7 +195,7 @@ }) .catch(() => { msg( - charts.dashboard, + charts.dashboard!, wp.i18n.__('Error loading data.', 'statify') ); }); @@ -188,53 +204,53 @@ /** * Load daily statistics. * - * @param {number} year Year to load data for. + * @param year Year to load data for. * - * @return {Promise<{[key: string]: number}>} Data promise from API. + * @return Data promise from API. */ - function loadDaily(year) { - return fetchData('stats/extended', { scope: 'day', year }); + function loadDaily(year: string): Promise { + return fetchData('stats/extended', { scope: 'day', year }); } /** * Load monthly statistics. * - * @return {Promise<{visits: {[key: string]: {[key: string]: number}}}>} Data promise from API. + * @return Data promise from API. */ - function loadMonthly() { - const param = { scope: 'month' }; + function loadMonthly(): Promise { + const param = { scope: 'month' } as Record; const post = new URLSearchParams(window.location.search).get('post'); if (post) { param.post = post; } - return fetchData('stats/extended', param); + return fetchData('stats/extended', param); } /** * Load statistics per post. * - * @return {Promise>} Data promise from API. + * @return Data promise from API. */ - function loadPerPost() { - const param = new URLSearchParams(); + function loadPerPost(): Promise { + const param: Record = {}; const search = new URLSearchParams(window.location.search); ['type', 'start', 'end'].forEach((p) => { const v = search.get(p); if (v) { - param.set(p, v); + param[p] = v; } }); - return fetchData('stats/posts', param); + return fetchData('stats/posts', param); } /** * Load statistics per referrer. * - * @return {Promise>} Data promise from API. + * @return Data promise from API. */ - function loadPerReferrer() { - const param = {}; + function loadPerReferrer(): Promise { + const param: Record = {}; const search = new URLSearchParams(window.location.search); ['post', 'start', 'end'].forEach((p) => { const v = search.get(p); @@ -243,16 +259,16 @@ } }); - return fetchData('stats/referrers', param); + return fetchData('stats/referrers', param); } /** * Render daily statistics. * - * @param {HTMLElement} root Root element. - * @param {{[key: string]: number}} data Data from API. + * @param root Root element. + * @param data Data from API. */ - function renderDaily(root, data) { + function renderDaily(root: HTMLElement, data: DailyStats): void { const labels = Object.keys(data); const values = Object.values(data); const usedLabels = new Set(); @@ -278,17 +294,21 @@ /** * Render monthly statistics. * - * @param {HTMLElement} root Root element. - * @param {{visits: {[key: string]: {[key: string]: number}}}} data Data from API. - * @param {boolean} showYear Show year in label? (default: true) + * @param root Root element. + * @param data Data from API. + * @param showYear Show year in label? (default: true) */ - function renderMonthly(root, data, showYear = true) { + function renderMonthly( + root: HTMLElement, + data: MonthlyStats, + showYear: boolean = true + ): void { const values = Object.values(data.visits).flatMap((y) => Object.values(y) ); - let labelFunc = (l) => l; - let metaFunc = (l) => l; + let labelFunc = (l: string) => l; + let metaFunc = (l: string) => l; let labels = Object.keys(data.visits); if (showYear) { if (labels.length > 2) { @@ -300,10 +320,10 @@ /** * Interpolate labels by year if we show more than 2 years. * - * @param {string} date Date string - * @return {string} Year string + * @param date Date string + * @return Year string */ - labelFunc = (date) => { + labelFunc = (date: string): string => { const d = new Date(date); const m = d.getMonth(); if (m === 0) { @@ -315,14 +335,14 @@ } else { labels = labels.flatMap((y) => Object.keys(data.visits[y]).map((m) => - dateFormatYM.format(new Date(y, m - 1)) + dateFormatYM.format(new Date(Number(y), Number(m) - 1)) ) ); } } else { labels = labels.flatMap((y) => Object.keys(data.visits[y]).map((m) => - dateFormatM.format(new Date(y, m - 1)) + dateFormatM.format(new Date(Number(y), Number(m) - 1)) ) ); } @@ -333,10 +353,10 @@ /** * Render yearly statistics. * - * @param {HTMLElement} root Root element. - * @param {{visits: {[key: string]: {[key: string]: number}}}} data Data from API. + * @param root Root element. + * @param data Data from API. */ - function renderYearly(root, data) { + function renderYearly(root: HTMLElement, data: MonthlyStats): void { const labels = Object.keys(data.visits); const values = Object.values(data.visits).map((y) => Object.values(y).reduce((a, b) => a + b, 0) @@ -348,21 +368,21 @@ /** * Render statistics chart. * - * @param {HTMLElement} root Root element. - * @param {string[]} labels Labels. - * @param {number[]} values Values. - * @param {boolean} showAxis Show X axis? (default: true) - * @param {function(string):string} labelToMetaFunc Meta data (tooltip label) function. (optional) - * @param {function(string):string} labelInterpolationFnc Label interpolation function. (optional) + * @param root Root element. + * @param labels Labels. + * @param values Values. + * @param showAxis Show X axis? (default: true) + * @param labelToMetaFunc Meta data (tooltip label) function. (optional) + * @param labelInterpolationFnc Label interpolation function. (optional) */ function render( - root, - labels, - values, - showAxis = true, - labelToMetaFunc = (l) => l, - labelInterpolationFnc = (l) => l - ) { + root: HTMLElement, + labels: string[], + values: number[], + showAxis: boolean = true, + labelToMetaFunc: (s: string) => string = (l) => l, + labelInterpolationFnc: (s: string) => string = (l) => l + ): void { // Remove the loading content or existing chart. root.innerHTML = ''; @@ -419,7 +439,8 @@ maxValue, ], offset: axisYOffset, - labelInterpolationFnc: (v) => numberFormat.format(v), + labelInterpolationFnc: (v: number) => + numberFormat.format(v), }, plugins: [ [ @@ -448,10 +469,10 @@ wp.i18n._n( '%s view', '%s views', - d.value.y, + (d.value as any)?.y, 'statify' ), - numberFormat.format(d.value.y) + numberFormat.format((d.value as any)?.y) ), 'ct:meta': labelToMetaFunc(labels[d.index]), }, @@ -465,12 +486,17 @@ /** * Render bar chart. * - * @param {HTMLElement} root Root element. - * @param {string[]} labels Labels. - * @param {number[]} values Values. - * @param {boolean} numericLabels Transform labels into numbers and add legend? (default: false) + * @param root Root element. + * @param labels Labels. + * @param values Values. + * @param numericLabels Transform labels into numbers and add legend? (default: false) */ - function renderBarChart(root, labels, values, numericLabels = false) { + function renderBarChart( + root: HTMLElement, + labels: string[], + values: number[], + numericLabels: boolean = false + ): void { // Remove the loading content. root.innerHTML = ''; @@ -485,27 +511,29 @@ // Generate dynamic offset roughly depending on the label length const axisYOffset = Math.max(String(maxValue).length * 8, 24); + let series: number[] | FlatSeries = values; + if (numericLabels) { const container = root.parentElement; - let legend = container.querySelector('.statify-legend'); + let legend = container!.querySelector('.statify-legend'); if (legend) { legend.innerHTML = ''; } else { - legend = document.createElement('OL'); + legend = document.createElement('ol'); legend.classList.add('statify-legend'); - container.appendChild(legend); + container!.appendChild(legend); } labels.forEach((l) => { - const li = document.createElement('LI'); + const li = document.createElement('li'); li.textContent = l; legend.appendChild(li); }); - values = values.map((v, i) => { + series = values.map((v, i) => { return { meta: labels[i], value: v }; }); - labels = labels.map((l, i) => i + 1); + labels = labels.map((l, i) => String(i + 1)); } // Draw chart. @@ -513,7 +541,7 @@ root, { labels, - series: [values], + series: [series], }, { low: 0, @@ -526,7 +554,8 @@ low: 0, onlyInteger: true, offset: axisYOffset, - labelInterpolationFnc: (v) => numberFormat.format(v), + labelInterpolationFnc: (v) => + numberFormat.format(Number(v)), }, plugins: [ [ @@ -535,7 +564,7 @@ appendToBody: true, anchorToPoint: true, class: 'statify-chartist-tooltip', - transformTooltipTextFnc(y) { + transformTooltipTextFnc(y: number): string { return wp.i18n.sprintf( /* translators: %s: Number of page views. */ wp.i18n._n( @@ -557,21 +586,24 @@ /** * Render top list table. * - * @param {HTMLTableElement} table Table element. - * @param {{count: number, url: string, host: ?string, title: ?string}[]} data Data to display. + * @param table Table element. + * @param data Data to display. */ - function renderTopList(table, data) { + function renderTopList( + table: HTMLTableSectionElement, + data: TopStats[] + ): void { const rows = data.map((r) => { - const row = document.createElement('TR'); - let col = document.createElement('TD'); + const row = document.createElement('tr'); + let col = document.createElement('td'); col.classList.add('b'); col.textContent = numberFormat.format(r.count); row.appendChild(col); - col = document.createElement('TD'); + col = document.createElement('td'); col.classList.add('t'); const label = r.title || r.host || r.url; if (/^((https?:)?\/)?\//i.test(r.url)) { - const link = document.createElement('A'); + const link = document.createElement('a'); link.href = r.url; link.target = '_blank'; link.rel = 'noopener noreferrer'; @@ -590,29 +622,33 @@ /** * Render totals table. * - * @param {HTMLTableElement} table Table element. - * @param {{alltime: number, since: string, today: number}} data Totals data. - * @param {number[]} visits Daily visits of the displayed period. + * @param table Table element. + * @param data Totals data. + * @param visits Daily visits of the displayed period. */ - function renderTotals(table, data, visits) { - const rowToday = document.createElement('TR'); - let col = document.createElement('TD'); + function renderTotals( + table: HTMLTableSectionElement, + data: TotalStats, + visits: number[] + ): void { + const rowToday = document.createElement('tr'); + let col = document.createElement('td'); col.classList.add('b'); col.textContent = numberFormat.format(data.today); rowToday.appendChild(col); - col = document.createElement('TD'); + col = document.createElement('td'); col.classList.add('t'); col.textContent = wp.i18n.__('today', 'statify'); rowToday.appendChild(col); - const rowPeriod = document.createElement('TR'); - col = document.createElement('TD'); + const rowPeriod = document.createElement('tr'); + col = document.createElement('td'); col.classList.add('b'); col.textContent = numberFormat.format( visits.reduce((a, b) => a + b, 0) ); rowPeriod.appendChild(col); - col = document.createElement('TD'); + col = document.createElement('td'); col.classList.add('t'); col.textContent = wp.i18n.sprintf( /* translators: %d: Number of days. */ @@ -621,12 +657,12 @@ ); rowPeriod.appendChild(col); - const rowAll = document.createElement('TR'); - col = document.createElement('TD'); + const rowAll = document.createElement('tr'); + col = document.createElement('td'); col.classList.add('b'); col.textContent = numberFormat.format(data.alltime); rowAll.appendChild(col); - col = document.createElement('TD'); + col = document.createElement('td'); col.classList.add('t'); col.textContent = wp.i18n.sprintf( /* translators: %s: Date. */ @@ -641,24 +677,27 @@ /** * Render yearly table. * - * @param {HTMLElement} table Root element. - * @param {any} data Data from API. + * @param table Root element. + * @param data Data from API. */ - function renderYearlyTable(table, data) { - const tbody = table.querySelector('tbody'); + function renderYearlyTable( + table: HTMLTableElement, + data: MonthlyStats + ): void { + const tbody = table.querySelector('tbody')!; tbody.innerHTML = ''; for (const year in data.visits) { - const row = document.createElement('TR'); - let col = document.createElement('TH'); + const row = document.createElement('tr'); + let col = document.createElement('th'); let sum = 0; col.scope = 'row'; col.textContent = year; row.appendChild(col); for (let month = 1; month <= 12; month++) { - col = document.createElement('TD'); + col = document.createElement('td'); col.classList.add('right'); if (month in data.visits[year]) { col.dataset.raw = String(data.visits[year][month]); @@ -673,7 +712,7 @@ sum += data.visits[year][month] || 0; } - col = document.createElement('TD'); + col = document.createElement('td'); col.classList.add('statify-table-sum', 'right'); col.dataset.raw = String(sum); col.textContent = numberFormat.format(sum); @@ -688,13 +727,13 @@ /** * Render yearly table. * - * @param {HTMLTableElement} table Root element. - * @param {any} data Data from API. + * @param table Root element. + * @param data Data from API. */ - function renderDailyTable(table, data) { + function renderDailyTable(table: HTMLTableElement, data: DailyStats): void { const rows = Array.from(table.querySelectorAll('tbody > tr')); const cols = rows.map((row) => Array.from(row.querySelectorAll('td'))); - let out = cols.slice(0, 31); + // let out = cols.slice(0, 31); const sum = new Array(12).fill(0); const vls = new Array(12).fill(0); @@ -708,11 +747,11 @@ ++vls[m]; min[m] = Math.min(min[m], count); max[m] = Math.max(max[m], count); - out[d.getDate() - 1][m].dataset.raw = String(count); - out[d.getDate() - 1][m].textContent = numberFormat.format(count); + cols[d.getDate() - 1][m].dataset.raw = String(count); + cols[d.getDate() - 1][m].textContent = numberFormat.format(count); } - out = + let out = cols[ rows.findIndex((row) => row.classList.contains('statify-table-sum') @@ -782,38 +821,42 @@ /** * Render content table. * - * @param {HTMLTableElement} table Root element. - * @param {Array<{count: number, title: string, type: string, typeName: string, url: string}>} data Data from API. + * @param table Root element. + * @param data Data from API. */ - function renderContentTable(table, data) { + function renderContentTable( + table: HTMLTableElement, + data: PostStats[] + ): void { const tbody = table.querySelector('tbody'); - const sumRow = table.querySelectorAll('tfoot > tr > td'); + const sumRow = + table.querySelectorAll('tfoot > tr > td'); const showType = table.querySelectorAll('thead > tr > th').length > 4; const total = data.map((d) => d.count).reduce((a, b) => a + b, 0); const rows = data.map((d) => { - const row = document.createElement('TR'); - let col = document.createElement('TD'); - const link = document.createElement('A'); + const row = document.createElement('tr'); + let col = document.createElement('td'); + const link = document.createElement('a'); link.href = d.url; link.textContent = d.title; col.append(link); row.appendChild(col); - col = document.createElement('TD'); + col = document.createElement('td'); col.textContent = d.url; row.appendChild(col); if (showType) { - col = document.createElement('TD'); + col = document.createElement('td'); col.textContent = d.typeName; row.appendChild(col); } - col = document.createElement('TD'); + col = document.createElement('td'); col.classList.add('right'); col.dataset.raw = String(d.count); col.textContent = numberFormat.format(d.count); row.appendChild(col); - col = document.createElement('TD'); + col = document.createElement('td'); col.classList.add('right'); col.dataset.raw = String( Math.round((d.count / total) * 10000) / 100 @@ -824,7 +867,7 @@ return row; }); - updateTable(tbody, rows); + updateTable(tbody!, rows); sumRow[sumRow.length - 2].dataset.raw = String(total); sumRow[sumRow.length - 2].textContent = numberFormat.format(total); @@ -837,28 +880,34 @@ /** * Render referrers table. * - * @param {HTMLTableElement} table Root element. - * @param {Array<{count: number, host: string, url: string}>} data Data from API. + * @param table Root element. + * @param data Data from API. */ - function renderReferrersTable(table, data) { + function renderReferrersTable( + table: HTMLTableElement, + data: TopStats[] + ): void { const tbody = table.querySelector('tbody'); + if (!tbody) { + return; + } const sumRow = table.querySelectorAll('tfoot > tr > td'); const total = data.map((d) => d.count).reduce((a, b) => a + b, 0); const rows = data.map((d) => { - const row = document.createElement('TR'); - let col = document.createElement('TD'); - const link = document.createElement('A'); + const row = document.createElement('tr'); + let col = document.createElement('td'); + const link = document.createElement('a'); link.href = d.url; link.textContent = d.host; col.append(link); row.appendChild(col); - col = document.createElement('TD'); + col = document.createElement('td'); col.classList.add('right'); col.textContent = numberFormat.format(d.count); row.appendChild(col); - col = document.createElement('TD'); + col = document.createElement('td'); col.classList.add('right'); col.textContent = numberFormatPercent.format(d.count / total); row.appendChild(col); @@ -876,10 +925,13 @@ /** * Replace or append table rows. * - * @param {HTMLTableElement|HTMLTableSectionElement} table Target table or table body. - * @param {HTMLTableRowElement[]} newRows New row elements. + * @param table Target table or table body. + * @param newRows New row elements. */ - function updateTable(table, newRows) { + function updateTable( + table: HTMLTableElement | HTMLTableSectionElement, + newRows: HTMLTableRowElement[] + ): void { const existingRows = table.querySelectorAll('tr'); // Replace existing rows @@ -900,11 +952,11 @@ /** * Convert daily to monthly data. * - * @param {{[key: string]: number}} data Daily data. - * @return {{visits: {[key: string]: {[key: string]: number}}}} Monthly data. + * @param data Daily data. + * @return Monthly data. */ - function dailyToMonthly(data) { - const monthly = { visits: {} }; + function dailyToMonthly(data: DailyStats): MonthlyStats { + const monthly = { visits: {} } as MonthlyStats; for (const [day, count] of Object.entries(data)) { const date = new Date(day); const year = date.getFullYear(); @@ -928,11 +980,11 @@ /** * Add a CSV export button to a table. * - * @param {HTMLTableElement} table Table to process + * @param table Table to process */ - function addExportButton(table) { + function addExportButton(table: HTMLTableElement): void { const exportBtn = document.createElement('a'); - let csvUrl = null; + let csvUrl: string | null = null; exportBtn.classList.add('button'); exportBtn.href = '#'; @@ -940,12 +992,12 @@ exportBtn.download = statifyDashboard.sitename + '-' + - table.caption.innerText.replaceAll(/\s+/g, '_') + + table.caption?.innerText.replace(/\s+/g, '_') + '-export-' + new Date() .toISOString() .replace('T', '_') - .replaceAll(':', '-') + .replace(':', '-') .substring(0, 16) + '.csv'; @@ -982,79 +1034,86 @@ * Fill start/end based on selected range and revert to "custom" when changed manually. */ function augmentDateRangeControls() { - const start = document.getElementById('statify-content-datestart'); - const end = document.getElementById('statify-content-dateend'); + const start = document.getElementById( + 'statify-content-datestart' + ) as HTMLInputElement; + const end = document.getElementById( + 'statify-content-dateend' + ) as HTMLInputElement; if (start && end) { - const isoDate = (y, m, d) => + const isoDate = (y: number, m: number, d: number): string => new Date(Date.UTC(y, m, d)).toISOString().split('T')[0]; - controls.dateRangeSelect.addEventListener('change', (evt) => { - const now = new Date(), - y = now.getFullYear(), - m = now.getMonth(), - d = now.getDate(), - day = now.getDay(), - monday = d - day + (day === 0 ? -6 : 1); - - switch (evt.target.value) { - case '': - start.value = ''; - end.value = ''; - break; - case 'lastYear': - start.value = isoDate(y - 1, 0, 1); - end.value = isoDate(y - 1, 11, 31); - break; - case 'lastWeek': - start.value = isoDate(y, m, monday - 7); - end.value = isoDate(y, m, monday - 1); - break; - case 'yesterday': - start.value = isoDate(y, m, d - 1); - end.value = isoDate(y, m, d - 1); - break; - case 'today': - start.value = isoDate(y, m, d); - end.value = isoDate(y, m, d); - break; - case 'thisWeek': - start.value = isoDate(y, m, monday); - end.value = isoDate(y, m, monday + 6); - break; - case 'last28days': - start.value = isoDate(y, m, d - 27); - end.value = isoDate(y, m, d); - break; - case 'lastMonth': - start.value = isoDate(y, m - 1, 1); - end.value = isoDate(y, m, 0); - break; - case 'thisMonth': - start.value = isoDate(y, m, 1); - end.value = isoDate(y, m + 1, 0); - break; - case '1stQuarter': - start.value = isoDate(y, 0, 1); - end.value = isoDate(y, 2, 31); - break; - case '2ndQuarter': - start.value = isoDate(y, 3, 1); - end.value = isoDate(y, 5, 30); - break; - case '3rdQuarter': - start.value = isoDate(y, 6, 1); - end.value = isoDate(y, 8, 30); - break; - case '4thQuarter': - start.value = isoDate(y, 9, 1); - end.value = isoDate(y, 11, 31); - break; - case 'thisYear': - start.value = isoDate(y, 0, 1); - end.value = isoDate(y, 11, 31); - break; + controls.dateRangeSelect.addEventListener( + 'change', + (evt: Event) => { + const now = new Date(), + y = now.getFullYear(), + m = now.getMonth(), + d = now.getDate(), + day = now.getDay(), + monday = d - day + (day === 0 ? -6 : 1); + + switch ((evt.target as HTMLSelectElement)?.value) { + case '': + start.value = ''; + end.value = ''; + break; + case 'lastYear': + start.value = isoDate(y - 1, 0, 1); + end.value = isoDate(y - 1, 11, 31); + break; + case 'lastWeek': + start.value = isoDate(y, m, monday - 7); + end.value = isoDate(y, m, monday - 1); + break; + case 'yesterday': + start.value = isoDate(y, m, d - 1); + end.value = isoDate(y, m, d - 1); + break; + case 'today': + start.value = isoDate(y, m, d); + end.value = isoDate(y, m, d); + break; + case 'thisWeek': + start.value = isoDate(y, m, monday); + end.value = isoDate(y, m, monday + 6); + break; + case 'last28days': + start.value = isoDate(y, m, d - 27); + end.value = isoDate(y, m, d); + break; + case 'lastMonth': + start.value = isoDate(y, m - 1, 1); + end.value = isoDate(y, m, 0); + break; + case 'thisMonth': + start.value = isoDate(y, m, 1); + end.value = isoDate(y, m + 1, 0); + break; + case '1stQuarter': + start.value = isoDate(y, 0, 1); + end.value = isoDate(y, 2, 31); + break; + case '2ndQuarter': + start.value = isoDate(y, 3, 1); + end.value = isoDate(y, 5, 30); + break; + case '3rdQuarter': + start.value = isoDate(y, 6, 1); + end.value = isoDate(y, 8, 30); + break; + case '4thQuarter': + start.value = isoDate(y, 9, 1); + end.value = isoDate(y, 11, 31); + break; + case 'thisYear': + start.value = isoDate(y, 0, 1); + end.value = isoDate(y, 11, 31); + break; + } } - }); + ); start.addEventListener('change', () => { controls.dateRangeSelect.value = 'custom'; @@ -1068,12 +1127,15 @@ /** * Fetch data from Statify API. * - * @param {string} path Relative API path. - * @param {string|Record|URLSearchParams|null} param Parameters (optional). - * @return {Promise} Response promise. + * @param path Relative API path. + * @param param Parameters (optional). + * @return Response promise. */ - function fetchData(path, param = undefined) { - return wp.apiFetch({ + function fetchData( + path: string, + param?: string | Record | URLSearchParams + ): Promise { + return wp.apiFetch({ path: `/statify/v1/${path}${param ? '?' + new URLSearchParams(param) : ''}`, }); } @@ -1081,11 +1143,11 @@ /** * Show a text message. * - * @param {HTMLElement} element Element to add the message to. - * @param {string} text Text message. + * @param element Element to add the message to. + * @param text Text message. * @private */ - function msg(element, text) { + function msg(element: HTMLElement, text: string): void { const p = document.createElement('p'); p.textContent = text; element.innerHTML = ''; diff --git a/js/global.d.ts b/js/global.d.ts new file mode 100644 index 00000000..701c26f0 --- /dev/null +++ b/js/global.d.ts @@ -0,0 +1,13 @@ +export {}; + +declare global { + const wp: { + i18n: typeof import('@wordpress/i18n'); + apiFetch: typeof import('@wordpress/api-fetch').default; + }; + const Chartist: typeof import('chartist'); + const ChartistPluginTooltip: typeof import('@stklcode/chartist-plugin-tooltips').default; + const statifyDashboard: { + sitename: string; + }; +} diff --git a/js/settings.js b/js/settings.ts similarity index 89% rename from js/settings.js rename to js/settings.ts index d564f33a..311611fc 100644 --- a/js/settings.js +++ b/js/settings.ts @@ -1,7 +1,5 @@ -(function () { - 'use strict'; - - function initResetButton() { +{ + function initResetButton(): void { const resetButton = document.getElementById('statify-reset-data'); if (!resetButton) { @@ -11,7 +9,7 @@ resetButton.addEventListener('click', (e) => { e.preventDefault(); - const button = e.currentTarget; + const button = e.currentTarget as HTMLButtonElement; if (button.disabled) { return; } @@ -32,7 +30,7 @@ button.disabled = true; button.textContent = wp.i18n.__('Resetting…', 'statify'); - wp.apiFetch({ + wp.apiFetch({ path: '/statify/v1/reset', method: 'POST', }) @@ -52,4 +50,4 @@ } document.addEventListener('DOMContentLoaded', initResetButton); -})(); +} diff --git a/js/types.d.ts b/js/types.d.ts new file mode 100644 index 00000000..6a21218e --- /dev/null +++ b/js/types.d.ts @@ -0,0 +1,38 @@ +type DashboardStats = { + referrer: TopStats[]; + target: TopStats[]; + totals: TotalStats; + visits: DailyStats; +}; + +type TopStats = { + count: number; + host: string | null; + url: string; + title: string | null; +}; + +type TotalStats = { + alltime: number; + since: string; + today: number; +}; + +type MonthlyStats = { + visits: { [key: string]: DailyStats }; +}; + +type DailyStats = { [key: string]: number }; + +type PostStats = { + count: number; + title: string; + type: string; + typeName: string; + url: string; +}; + +type ResetResponse = { + success: boolean; + message: string; +}; diff --git a/package-lock.json b/package-lock.json index e03fc826..83112d4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,19 +4,21 @@ "requires": true, "packages": { "": { - "name": "statify", "dependencies": { "@stklcode/chartist-plugin-tooltips": "^2.0.1", "chartist": "^1.5.0" }, "devDependencies": { + "@wordpress/api-fetch": "^8.1.0", "@wordpress/eslint-plugin": "^28.1.0", + "@wordpress/i18n": "^7.1.0", "@wordpress/stylelint-config": "^27.1.0", "esbuild": "^0.28.2", "eslint": "^10.14.0", "nise": "^6.1.5", "nyc": "^18.0.0", "stylelint": "^17.16.0", + "typescript": "^6.0.3", "vendor-copy": "^3.0.1" } }, @@ -2115,6 +2117,21 @@ } } }, + "node_modules/@wordpress/api-fetch": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-8.1.0.tgz", + "integrity": "sha512-VR9avB/9QkUnPC2Yc3cZLmoZLKoyv9DM6Dmg8ewGiS5NgZER/qaGv0LZkpDHOOsnO63K4s8G8EJ6FJzrZezrPQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/i18n": "^7.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/url": "^5.1.0" + }, + "engines": { + "node": ">=22.13.0" + } + }, "node_modules/@wordpress/compose": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-9.1.0.tgz", @@ -2668,6 +2685,16 @@ "node": ">=22.13.0" } }, + "node_modules/@wordpress/url": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-5.1.0.tgz", + "integrity": "sha512-D46oS8q/g8kK4diyAtwTvFOY43jONdJsx7qOtoWfhpVyvMGemoiNu+MJCswH2XIUG7oTRML9YrNT50mEoCaXLQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=22.13.0" + } + }, "node_modules/acorn": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", @@ -8919,7 +8946,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 9649117e..976be45c 100644 --- a/package.json +++ b/package.json @@ -5,24 +5,28 @@ "@stklcode/chartist-plugin-tooltips": "^2.0.1" }, "devDependencies": { + "@wordpress/api-fetch": "^8.1.0", "@wordpress/eslint-plugin": "^28.1.0", + "@wordpress/i18n": "^7.1.0", "@wordpress/stylelint-config": "^27.1.0", "esbuild": "^0.28.2", "eslint": "^10.14.0", "nise": "^6.1.5", "nyc": "^18.0.0", "stylelint": "^17.16.0", + "typescript": "^6.0.3", "vendor-copy": "^3.0.1" }, "scripts": { "build": "npm run copy-assets && npm run minify", + "check": "tsc --noEmit", "copy-assets": "vendor-copy", "minify": "npm run minify:js && npm run minify:css", "minify:css": "esbuild css/dashboard.css css/settings.css --minify --outdir=css --outbase=. --entry-names=[name].min", - "minify:js": "esbuild js/dashboard.js js/settings.js js/snippet.js --minify --outdir=js --outbase=js --entry-names=[name].min", + "minify:js": "esbuild js/dashboard.ts js/settings.ts js/snippet.js --minify --outdir=js --outbase=js --entry-names=[name].min", "lint": "npm run lint-css && npm run lint-js", "lint-css": "stylelint css/dashboard.css css/settings.css", - "lint-js": "eslint js/dashboard.js js/settings.js js/snippet.js", + "lint-js": "eslint js/dashboard.ts js/settings.ts js/snippet.js", "test": "nyc --reporter=text node --test tests/js/*.test.js" }, "allowScripts": { diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..b240ef21 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "isolatedModules": true, + "lib": [ + "DOM", + "ES2021" + ], + "module": "ESNext", + "moduleDetection": "legacy", + "moduleResolution": "Bundler", + "noEmit": true, + "strict": true, + "target": "ES2021" + }, + "include": [ + "js/*.ts" + ] +} From 04942582f0bc72ef22db765ef543c8923f13fc29 Mon Sep 17 00:00:00 2001 From: Stefan Kalscheuer Date: Tue, 11 Aug 2026 08:39:28 +0200 Subject: [PATCH 2/3] refactor: bundle number and date formatting --- js/dashboard.ts | 118 ++++++++++++++++++++++++++++-------------------- 1 file changed, 68 insertions(+), 50 deletions(-) diff --git a/js/dashboard.ts b/js/dashboard.ts index a52b320c..67f6fd1e 100644 --- a/js/dashboard.ts +++ b/js/dashboard.ts @@ -52,22 +52,44 @@ import { FlatSeries } from 'chartist'; // Initialize number format. const lang = getUserLanguage(); - const numberFormat = new Intl.NumberFormat(lang); - const numberFormatPercent = new Intl.NumberFormat(lang, { - style: 'percent', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); - const dateFormatYMD = new Intl.DateTimeFormat(lang, { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }); - const dateFormatYM = new Intl.DateTimeFormat(lang, { - year: 'numeric', - month: 'short', - }); - const dateFormatM = new Intl.DateTimeFormat(lang, { month: 'short' }); + const fmt = { + number: new Intl.NumberFormat(lang), + numberPercent: new Intl.NumberFormat(lang, { + style: 'percent', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + dateYMD: new Intl.DateTimeFormat(lang, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }), + dateYM: new Intl.DateTimeFormat(lang, { + year: 'numeric', + month: 'short', + }), + dateM: new Intl.DateTimeFormat(lang, { month: 'short' }), + + formatNumber(num: number): string { + return this.number.format(num); + }, + + formatPercent(num: number): string { + return this.numberPercent.format(num); + }, + + formatDateYMD(date: Date): string { + return this.dateYMD.format(date); + }, + + formatDateYM(date: Date): string { + return this.dateYM.format(date); + }, + + formatDateM(date: Date): string { + return this.dateM.format(date); + }, + }; /** * Get user language from WP i18n, converted to BCP-47 notation. @@ -178,7 +200,7 @@ import { FlatSeries } from 'chartist'; const values = Object.values(data.visits); render(charts.dashboard!, labels, values, false, (date) => - dateFormatYMD.format(new Date(date)) + fmt.formatDateYMD(new Date(date)) ); // Render top lists. @@ -278,7 +300,7 @@ import { FlatSeries } from 'chartist'; labels, values, true, - (date) => dateFormatYMD.format(new Date(date)), + (date) => fmt.formatDateYMD(new Date(date)), (day) => { const date = new Date(day); const mon = date.getMonth(); @@ -286,7 +308,7 @@ import { FlatSeries } from 'chartist'; return ''; } usedLabels.add(mon); - return dateFormatM.format(date); + return fmt.formatDateM(date); } ); } @@ -331,18 +353,18 @@ import { FlatSeries } from 'chartist'; } return ''; }; - metaFunc = (date) => dateFormatYM.format(new Date(date)); + metaFunc = (date) => fmt.formatDateYM(new Date(date)); } else { labels = labels.flatMap((y) => Object.keys(data.visits[y]).map((m) => - dateFormatYM.format(new Date(Number(y), Number(m) - 1)) + fmt.formatDateYM(new Date(Number(y), Number(m) - 1)) ) ); } } else { labels = labels.flatMap((y) => Object.keys(data.visits[y]).map((m) => - dateFormatM.format(new Date(Number(y), Number(m) - 1)) + fmt.formatDateM(new Date(Number(y), Number(m) - 1)) ) ); } @@ -439,8 +461,7 @@ import { FlatSeries } from 'chartist'; maxValue, ], offset: axisYOffset, - labelInterpolationFnc: (v: number) => - numberFormat.format(v), + labelInterpolationFnc: (v: number) => fmt.formatNumber(v), }, plugins: [ [ @@ -472,7 +493,7 @@ import { FlatSeries } from 'chartist'; (d.value as any)?.y, 'statify' ), - numberFormat.format((d.value as any)?.y) + fmt.formatNumber((d.value as any)?.y) ), 'ct:meta': labelToMetaFunc(labels[d.index]), }, @@ -554,8 +575,7 @@ import { FlatSeries } from 'chartist'; low: 0, onlyInteger: true, offset: axisYOffset, - labelInterpolationFnc: (v) => - numberFormat.format(Number(v)), + labelInterpolationFnc: (v) => fmt.formatNumber(Number(v)), }, plugins: [ [ @@ -573,7 +593,7 @@ import { FlatSeries } from 'chartist'; y, 'statify' ), - numberFormat.format(y) + fmt.formatNumber(y) ); }, }, @@ -597,7 +617,7 @@ import { FlatSeries } from 'chartist'; const row = document.createElement('tr'); let col = document.createElement('td'); col.classList.add('b'); - col.textContent = numberFormat.format(r.count); + col.textContent = fmt.formatNumber(r.count); row.appendChild(col); col = document.createElement('td'); col.classList.add('t'); @@ -634,7 +654,7 @@ import { FlatSeries } from 'chartist'; const rowToday = document.createElement('tr'); let col = document.createElement('td'); col.classList.add('b'); - col.textContent = numberFormat.format(data.today); + col.textContent = fmt.formatNumber(data.today); rowToday.appendChild(col); col = document.createElement('td'); col.classList.add('t'); @@ -644,7 +664,7 @@ import { FlatSeries } from 'chartist'; const rowPeriod = document.createElement('tr'); col = document.createElement('td'); col.classList.add('b'); - col.textContent = numberFormat.format( + col.textContent = fmt.formatNumber( visits.reduce((a, b) => a + b, 0) ); rowPeriod.appendChild(col); @@ -660,14 +680,14 @@ import { FlatSeries } from 'chartist'; const rowAll = document.createElement('tr'); col = document.createElement('td'); col.classList.add('b'); - col.textContent = numberFormat.format(data.alltime); + col.textContent = fmt.formatNumber(data.alltime); rowAll.appendChild(col); col = document.createElement('td'); col.classList.add('t'); col.textContent = wp.i18n.sprintf( /* translators: %s: Date. */ wp.i18n.__('since %s', 'statify'), - dateFormatYMD.format(new Date(data.since)) + fmt.formatDateYMD(new Date(data.since)) ); rowAll.appendChild(col); @@ -701,7 +721,7 @@ import { FlatSeries } from 'chartist'; col.classList.add('right'); if (month in data.visits[year]) { col.dataset.raw = String(data.visits[year][month]); - col.textContent = numberFormat.format( + col.textContent = fmt.formatNumber( data.visits[year][month] ); } else { @@ -715,7 +735,7 @@ import { FlatSeries } from 'chartist'; col = document.createElement('td'); col.classList.add('statify-table-sum', 'right'); col.dataset.raw = String(sum); - col.textContent = numberFormat.format(sum); + col.textContent = fmt.formatNumber(sum); row.appendChild(col); tbody.insertBefore(row, tbody.firstChild); @@ -748,7 +768,7 @@ import { FlatSeries } from 'chartist'; min[m] = Math.min(min[m], count); max[m] = Math.max(max[m], count); cols[d.getDate() - 1][m].dataset.raw = String(count); - cols[d.getDate() - 1][m].textContent = numberFormat.format(count); + cols[d.getDate() - 1][m].textContent = fmt.formatNumber(count); } let out = @@ -766,11 +786,9 @@ import { FlatSeries } from 'chartist'; for (const [m, s] of sum.entries()) { if (vls[m] > 0) { out[m].dataset.raw = String(s); - out[m].textContent = numberFormat.format(s); + out[m].textContent = fmt.formatNumber(s); avg[m].dataset.raw = String(Math.round(s / vls[m])); - avg[m].textContent = numberFormat.format( - Math.round(s / vls[m]) - ); + avg[m].textContent = fmt.formatNumber(Math.round(s / vls[m])); } else { out[m].dataset.raw = ''; out[m].textContent = '-'; @@ -788,7 +806,7 @@ import { FlatSeries } from 'chartist'; for (const [m, s] of min.entries()) { if (vls[m] > 0) { out[m].dataset.raw = String(s); - out[m].textContent = numberFormat.format(s); + out[m].textContent = fmt.formatNumber(s); } else { out[m].dataset.raw = ''; out[m].textContent = '-'; @@ -804,7 +822,7 @@ import { FlatSeries } from 'chartist'; for (const [m, s] of max.entries()) { if (vls[m] > 0) { out[m].dataset.raw = String(s); - out[m].textContent = numberFormat.format(s); + out[m].textContent = fmt.formatNumber(s); } else { out[m].dataset.raw = ''; out[m].textContent = '-'; @@ -854,14 +872,14 @@ import { FlatSeries } from 'chartist'; col = document.createElement('td'); col.classList.add('right'); col.dataset.raw = String(d.count); - col.textContent = numberFormat.format(d.count); + col.textContent = fmt.formatNumber(d.count); row.appendChild(col); col = document.createElement('td'); col.classList.add('right'); col.dataset.raw = String( Math.round((d.count / total) * 10000) / 100 ); - col.textContent = numberFormatPercent.format(d.count / total); + col.textContent = fmt.formatPercent(d.count / total); row.appendChild(col); return row; @@ -870,9 +888,9 @@ import { FlatSeries } from 'chartist'; updateTable(tbody!, rows); sumRow[sumRow.length - 2].dataset.raw = String(total); - sumRow[sumRow.length - 2].textContent = numberFormat.format(total); + sumRow[sumRow.length - 2].textContent = fmt.formatNumber(total); sumRow[sumRow.length - 1].dataset.raw = '1'; - sumRow[sumRow.length - 1].textContent = numberFormatPercent.format(1); + sumRow[sumRow.length - 1].textContent = fmt.formatPercent(1); addExportButton(table); } @@ -905,19 +923,19 @@ import { FlatSeries } from 'chartist'; row.appendChild(col); col = document.createElement('td'); col.classList.add('right'); - col.textContent = numberFormat.format(d.count); + col.textContent = fmt.formatNumber(d.count); row.appendChild(col); col = document.createElement('td'); col.classList.add('right'); - col.textContent = numberFormatPercent.format(d.count / total); + col.textContent = fmt.formatPercent(d.count / total); row.appendChild(col); return row; }); updateTable(tbody, rows); - sumRow[sumRow.length - 2].textContent = numberFormat.format(total); - sumRow[sumRow.length - 1].textContent = numberFormatPercent.format(1); + sumRow[sumRow.length - 2].textContent = fmt.formatNumber(total); + sumRow[sumRow.length - 1].textContent = fmt.formatPercent(1); addExportButton(table); } From 7dca5957671db778549d259cf10d48d7db67ea45 Mon Sep 17 00:00:00 2001 From: Stefan Kalscheuer Date: Mon, 10 Aug 2026 19:06:37 +0200 Subject: [PATCH 3/3] split dashboard script into modules and bundle The frontend part for dashboard and evaluation has become quite long. Split the different concerns into modules and import them. JS output is bundled by esbuild, so it should not make any difference to the user. --- .distignore | 3 +- eslint.config.mjs | 2 +- js/dashboard.ts | 1062 ++------------------------------------ js/dashboard/api.ts | 96 ++++ js/dashboard/charts.ts | 335 ++++++++++++ js/dashboard/controls.ts | 113 ++++ js/dashboard/formats.ts | 46 ++ js/dashboard/tables.ts | 409 +++++++++++++++ js/dashboard/util.ts | 35 ++ package.json | 4 +- tsconfig.json | 3 +- 11 files changed, 1090 insertions(+), 1018 deletions(-) create mode 100644 js/dashboard/api.ts create mode 100644 js/dashboard/charts.ts create mode 100644 js/dashboard/controls.ts create mode 100644 js/dashboard/formats.ts create mode 100644 js/dashboard/tables.ts create mode 100644 js/dashboard/util.ts diff --git a/.distignore b/.distignore index 85528c6a..d8aea614 100644 --- a/.distignore +++ b/.distignore @@ -29,5 +29,6 @@ /phpunit.coverage.xml /phpunit.report.xml /README.md -/js/*.ts +/js/dashboard +/js/**.ts /tsconfig.json diff --git a/eslint.config.mjs b/eslint.config.mjs index a35ef32c..5a5a9c03 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,7 +4,7 @@ import globals from 'globals'; export default [ ...wordpress.configs.recommended.map((config) => ({ ...config, - files: ['js/**.ts', 'tests/js/*.test.ts'], + files: ['js/*.ts', 'js/**/*.ts', 'tests/js/*.test.ts'], })), ...wordpress.configs.recommended.map((config) => ({ ...config, diff --git a/js/dashboard.ts b/js/dashboard.ts index 67f6fd1e..b3f904bd 100644 --- a/js/dashboard.ts +++ b/js/dashboard.ts @@ -1,4 +1,12 @@ -import { FlatSeries } from 'chartist'; +import * as api from './dashboard/api'; +import * as chart from './dashboard/charts'; +import { + augmentDateRangeControls, + augmentPostSelect, +} from './dashboard/controls'; +import Formats from './dashboard/formats'; +import { dailyToMonthly, msg } from './dashboard/util'; +import * as table from './dashboard/tables'; { // Initialize DOM elements. @@ -50,46 +58,8 @@ import { FlatSeries } from 'chartist'; ) as HTMLSelectElement, }; - // Initialize number format. - const lang = getUserLanguage(); - const fmt = { - number: new Intl.NumberFormat(lang), - numberPercent: new Intl.NumberFormat(lang, { - style: 'percent', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }), - dateYMD: new Intl.DateTimeFormat(lang, { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }), - dateYM: new Intl.DateTimeFormat(lang, { - year: 'numeric', - month: 'short', - }), - dateM: new Intl.DateTimeFormat(lang, { month: 'short' }), - - formatNumber(num: number): string { - return this.number.format(num); - }, - - formatPercent(num: number): string { - return this.numberPercent.format(num); - }, - - formatDateYMD(date: Date): string { - return this.dateYMD.format(date); - }, - - formatDateYM(date: Date): string { - return this.dateYM.format(date); - }, - - formatDateM(date: Date): string { - return this.dateM.format(date); - }, - }; + // Initialize number and date format. + const fmt = new Formats(getUserLanguage()); /** * Get user language from WP i18n, converted to BCP-47 notation. @@ -114,28 +84,33 @@ import { FlatSeries } from 'chartist'; // Render available charts and tables. if (charts.daily) { - loadDaily(charts.daily.dataset.year!).then((data) => { - renderDaily(charts.daily!, data); + api.loadDaily(charts.daily.dataset.year!).then((data) => { + chart.renderDaily(charts.daily!, data, fmt); if (charts.monthly) { - renderMonthly(charts.monthly, dailyToMonthly(data), false); + chart.renderMonthly( + charts.monthly, + dailyToMonthly(data), + fmt, + false + ); } if (tables.daily) { - renderDailyTable(tables.daily, data); + table.renderDaily(tables.daily, data, fmt); } }); } else if (charts.monthly) { - loadMonthly() + api.loadMonthly() .then((data) => { - renderMonthly(charts.monthly!, data); + chart.renderMonthly(charts.monthly!, data, fmt); if (charts.yearly) { - renderYearly(charts.yearly, data); + chart.renderYearly(charts.yearly, data, fmt); } if (tables.yearly) { - renderYearlyTable(tables.yearly, data); + table.renderYearly(tables.yearly, data, fmt); } }) .catch(() => { @@ -146,43 +121,42 @@ import { FlatSeries } from 'chartist'; ); }); } else if (tables.content) { - loadPerPost().then((data) => { - renderContentTable(tables.content, data); + api.loadPerPost().then((data) => { + table.renderContent(tables.content, data, fmt); if (charts.content) { // Limit number of records. data = data.slice(0, 24); const labels = data.map((d) => d.title); const values = data.map((d) => d.count); - renderBarChart(charts.content, labels, values, true); + chart.renderBarChart(charts.content, labels, values, fmt, true); } }); } else if (tables.referrers) { - loadPerReferrer().then((data) => { - renderReferrersTable(tables.referrers, data); + api.loadPerReferrer().then((data) => { + table.renderReferrers(tables.referrers, data, fmt); if (charts.referrer) { // Limit number of records. data = data.slice(0, 24); const labels = data.map((d) => d.host || ''); const values = data.map((d) => d.count); - renderBarChart(charts.referrer, labels, values, true); + chart.renderBarChart( + charts.referrer, + labels, + values, + fmt, + true + ); } }); } // Augment available controls. if (controls.postInput && controls.postList) { - fetchData('posts').then((data) => - data.forEach((post) => { - const opt = document.createElement('option'); - opt.value = post.url; - opt.textContent = post.title; - controls.postList!.appendChild(opt); - }) - ); + augmentPostSelect(controls.postList); } if (controls.dateRangeSelect) { - augmentDateRangeControls(); + augmentDateRangeControls(controls.dateRangeSelect); } /* ------------------------------------------------------------------ */ @@ -194,25 +168,30 @@ import { FlatSeries } from 'chartist'; */ function updateDashboard(refresh: boolean): void { // Load data from API. - fetchData('stats', refresh ? 'refresh=1' : undefined) + api.loadDashboard(refresh) .then((data) => { const labels = Object.keys(data.visits); const values = Object.values(data.visits); - render(charts.dashboard!, labels, values, false, (date) => - fmt.formatDateYMD(new Date(date)) + chart.render( + charts.dashboard!, + labels, + values, + fmt, + false, + (date) => fmt.formatDateYMD(new Date(date)) ); // Render top lists. if (tables.referrer) { - renderTopList(tables.referrer, data.referrer); + table.renderTopList(tables.referrer, data.referrer, fmt); } if (tables.target) { - renderTopList(tables.target, data.target); + table.renderTopList(tables.target, data.target, fmt); } if (tables.totals) { - renderTotals(tables.totals, data.totals, values); + table.renderTotals(tables.totals, data.totals, values, fmt); } }) .catch(() => { @@ -223,952 +202,9 @@ import { FlatSeries } from 'chartist'; }); } - /** - * Load daily statistics. - * - * @param year Year to load data for. - * - * @return Data promise from API. - */ - function loadDaily(year: string): Promise { - return fetchData('stats/extended', { scope: 'day', year }); - } - - /** - * Load monthly statistics. - * - * @return Data promise from API. - */ - function loadMonthly(): Promise { - const param = { scope: 'month' } as Record; - const post = new URLSearchParams(window.location.search).get('post'); - if (post) { - param.post = post; - } - return fetchData('stats/extended', param); - } - - /** - * Load statistics per post. - * - * @return Data promise from API. - */ - function loadPerPost(): Promise { - const param: Record = {}; - const search = new URLSearchParams(window.location.search); - ['type', 'start', 'end'].forEach((p) => { - const v = search.get(p); - if (v) { - param[p] = v; - } - }); - - return fetchData('stats/posts', param); - } - - /** - * Load statistics per referrer. - * - * @return Data promise from API. - */ - function loadPerReferrer(): Promise { - const param: Record = {}; - const search = new URLSearchParams(window.location.search); - ['post', 'start', 'end'].forEach((p) => { - const v = search.get(p); - if (v) { - param[p] = v; - } - }); - - return fetchData('stats/referrers', param); - } - - /** - * Render daily statistics. - * - * @param root Root element. - * @param data Data from API. - */ - function renderDaily(root: HTMLElement, data: DailyStats): void { - const labels = Object.keys(data); - const values = Object.values(data); - const usedLabels = new Set(); - - render( - root, - labels, - values, - true, - (date) => fmt.formatDateYMD(new Date(date)), - (day) => { - const date = new Date(day); - const mon = date.getMonth(); - if (usedLabels.has(mon)) { - return ''; - } - usedLabels.add(mon); - return fmt.formatDateM(date); - } - ); - } - - /** - * Render monthly statistics. - * - * @param root Root element. - * @param data Data from API. - * @param showYear Show year in label? (default: true) - */ - function renderMonthly( - root: HTMLElement, - data: MonthlyStats, - showYear: boolean = true - ): void { - const values = Object.values(data.visits).flatMap((y) => - Object.values(y) - ); - - let labelFunc = (l: string) => l; - let metaFunc = (l: string) => l; - let labels = Object.keys(data.visits); - if (showYear) { - if (labels.length > 2) { - labels = labels.flatMap((y) => - Object.keys(data.visits[y]).map( - (m) => `${y}-${m.padStart(2, '0')}-01` - ) - ); - /** - * Interpolate labels by year if we show more than 2 years. - * - * @param date Date string - * @return Year string - */ - labelFunc = (date: string): string => { - const d = new Date(date); - const m = d.getMonth(); - if (m === 0) { - return String(d.getFullYear()); - } - return ''; - }; - metaFunc = (date) => fmt.formatDateYM(new Date(date)); - } else { - labels = labels.flatMap((y) => - Object.keys(data.visits[y]).map((m) => - fmt.formatDateYM(new Date(Number(y), Number(m) - 1)) - ) - ); - } - } else { - labels = labels.flatMap((y) => - Object.keys(data.visits[y]).map((m) => - fmt.formatDateM(new Date(Number(y), Number(m) - 1)) - ) - ); - } - - render(root, labels, values, true, metaFunc, labelFunc); - } - - /** - * Render yearly statistics. - * - * @param root Root element. - * @param data Data from API. - */ - function renderYearly(root: HTMLElement, data: MonthlyStats): void { - const labels = Object.keys(data.visits); - const values = Object.values(data.visits).map((y) => - Object.values(y).reduce((a, b) => a + b, 0) - ); - - render(root, labels, values); - } - - /** - * Render statistics chart. - * - * @param root Root element. - * @param labels Labels. - * @param values Values. - * @param showAxis Show X axis? (default: true) - * @param labelToMetaFunc Meta data (tooltip label) function. (optional) - * @param labelInterpolationFnc Label interpolation function. (optional) - */ - function render( - root: HTMLElement, - labels: string[], - values: number[], - showAxis: boolean = true, - labelToMetaFunc: (s: string) => string = (l) => l, - labelInterpolationFnc: (s: string) => string = (l) => l - ): void { - // Remove the loading content or existing chart. - root.innerHTML = ''; - - // Adjust display according if there are too many values to display readable. - let fullWidth = true; - let pointRadius = 4; - if (labels.length === 0) { - msg(root, wp.i18n.__('No data available.', 'statify')); - return; - } else if (root.clientWidth < labels.length * 4) { - // Make chart scrollable, if 2px points are overlapping. - fullWidth = false; - pointRadius = 3; - } else if (root.clientWidth < labels.length * 8) { - // Shrink datapoints if 4px is overlapping, but 2 is not. - pointRadius = 2; - } - - // Determine maximum value for scaling. - const maxValue = Math.max(...values); - - // Generate dynamic offset roughly depending on the label length - const axisYOffset = Math.max(String(maxValue).length * 8, 24); - - // Draw chart. - const chart = new Chartist.LineChart( - root, - { - labels, - series: [values], - }, - { - low: 0, - showArea: true, - fullWidth, - width: fullWidth ? undefined : 5 * labels.length, - axisX: { - showGrid: false, - showLabel: showAxis, - offset: showAxis ? 30 : 0, - labelInterpolationFnc, - }, - axisY: { - showGrid: true, - showLabel: true, - type: Chartist.FixedScaleAxis, - low: 0, - high: maxValue + 1, - ticks: [ - 0, - Math.round((maxValue * 1) / 4), - Math.round((maxValue * 2) / 4), - Math.round((maxValue * 3) / 4), - maxValue, - ], - offset: axisYOffset, - labelInterpolationFnc: (v: number) => fmt.formatNumber(v), - }, - plugins: [ - [ - ChartistPluginTooltip, - { - appendToBody: true, - class: 'statify-chartist-tooltip', - }, - ], - ], - } - ); - - // Replace default points with hollow circles, add "pageview(s) to value and append date (label) as metadata. - chart.on('draw', (d) => { - let circle; - if ('point' === d.type) { - circle = new Chartist.Svg( - 'circle', - { - cx: d.x, - cy: d.y, - r: pointRadius, - 'ct:value': wp.i18n.sprintf( - /* translators: %s: Number of page views. */ - wp.i18n._n( - '%s view', - '%s views', - (d.value as any)?.y, - 'statify' - ), - fmt.formatNumber((d.value as any)?.y) - ), - 'ct:meta': labelToMetaFunc(labels[d.index]), - }, - 'ct-point' - ); - d.element.replace(circle); - } - }); - } - - /** - * Render bar chart. - * - * @param root Root element. - * @param labels Labels. - * @param values Values. - * @param numericLabels Transform labels into numbers and add legend? (default: false) - */ - function renderBarChart( - root: HTMLElement, - labels: string[], - values: number[], - numericLabels: boolean = false - ): void { - // Remove the loading content. - root.innerHTML = ''; - - if (labels.length === 0) { - msg(root, wp.i18n.__('No data available.', 'statify')); - return; - } - - // Determine maximum value for scaling. - const maxValue = Math.max(...values); - - // Generate dynamic offset roughly depending on the label length - const axisYOffset = Math.max(String(maxValue).length * 8, 24); - - let series: number[] | FlatSeries = values; - - if (numericLabels) { - const container = root.parentElement; - let legend = container!.querySelector('.statify-legend'); - if (legend) { - legend.innerHTML = ''; - } else { - legend = document.createElement('ol'); - legend.classList.add('statify-legend'); - container!.appendChild(legend); - } - - labels.forEach((l) => { - const li = document.createElement('li'); - li.textContent = l; - legend.appendChild(li); - }); - - series = values.map((v, i) => { - return { meta: labels[i], value: v }; - }); - labels = labels.map((l, i) => String(i + 1)); - } - - // Draw chart. - new Chartist.BarChart( - root, - { - labels, - series: [series], - }, - { - low: 0, - axisX: { - showGrid: false, - }, - axisY: { - showGrid: true, - showLabel: true, - low: 0, - onlyInteger: true, - offset: axisYOffset, - labelInterpolationFnc: (v) => fmt.formatNumber(Number(v)), - }, - plugins: [ - [ - ChartistPluginTooltip, - { - appendToBody: true, - anchorToPoint: true, - class: 'statify-chartist-tooltip', - transformTooltipTextFnc(y: number): string { - return wp.i18n.sprintf( - /* translators: %s: Number of page views. */ - wp.i18n._n( - '%s view', - '%s views', - y, - 'statify' - ), - fmt.formatNumber(y) - ); - }, - }, - ], - ], - } - ); - } - - /** - * Render top list table. - * - * @param table Table element. - * @param data Data to display. - */ - function renderTopList( - table: HTMLTableSectionElement, - data: TopStats[] - ): void { - const rows = data.map((r) => { - const row = document.createElement('tr'); - let col = document.createElement('td'); - col.classList.add('b'); - col.textContent = fmt.formatNumber(r.count); - row.appendChild(col); - col = document.createElement('td'); - col.classList.add('t'); - const label = r.title || r.host || r.url; - if (/^((https?:)?\/)?\//i.test(r.url)) { - const link = document.createElement('a'); - link.href = r.url; - link.target = '_blank'; - link.rel = 'noopener noreferrer'; - link.textContent = label; - col.appendChild(link); - } else { - col.textContent = label; - } - row.appendChild(col); - return row; - }); - - updateTable(table, rows); - } - - /** - * Render totals table. - * - * @param table Table element. - * @param data Totals data. - * @param visits Daily visits of the displayed period. - */ - function renderTotals( - table: HTMLTableSectionElement, - data: TotalStats, - visits: number[] - ): void { - const rowToday = document.createElement('tr'); - let col = document.createElement('td'); - col.classList.add('b'); - col.textContent = fmt.formatNumber(data.today); - rowToday.appendChild(col); - col = document.createElement('td'); - col.classList.add('t'); - col.textContent = wp.i18n.__('today', 'statify'); - rowToday.appendChild(col); - - const rowPeriod = document.createElement('tr'); - col = document.createElement('td'); - col.classList.add('b'); - col.textContent = fmt.formatNumber( - visits.reduce((a, b) => a + b, 0) - ); - rowPeriod.appendChild(col); - col = document.createElement('td'); - col.classList.add('t'); - col.textContent = wp.i18n.sprintf( - /* translators: %d: Number of days. */ - wp.i18n._n('last %d day', 'last %d days', visits.length, 'statify'), - visits.length - ); - rowPeriod.appendChild(col); - - const rowAll = document.createElement('tr'); - col = document.createElement('td'); - col.classList.add('b'); - col.textContent = fmt.formatNumber(data.alltime); - rowAll.appendChild(col); - col = document.createElement('td'); - col.classList.add('t'); - col.textContent = wp.i18n.sprintf( - /* translators: %s: Date. */ - wp.i18n.__('since %s', 'statify'), - fmt.formatDateYMD(new Date(data.since)) - ); - rowAll.appendChild(col); - - updateTable(table, [rowToday, rowPeriod, rowAll]); - } - - /** - * Render yearly table. - * - * @param table Root element. - * @param data Data from API. - */ - function renderYearlyTable( - table: HTMLTableElement, - data: MonthlyStats - ): void { - const tbody = table.querySelector('tbody')!; - - tbody.innerHTML = ''; - - for (const year in data.visits) { - const row = document.createElement('tr'); - let col = document.createElement('th'); - let sum = 0; - col.scope = 'row'; - col.textContent = year; - row.appendChild(col); - - for (let month = 1; month <= 12; month++) { - col = document.createElement('td'); - col.classList.add('right'); - if (month in data.visits[year]) { - col.dataset.raw = String(data.visits[year][month]); - col.textContent = fmt.formatNumber( - data.visits[year][month] - ); - } else { - col.dataset.raw = ''; - col.textContent = '-'; - } - row.appendChild(col); - sum += data.visits[year][month] || 0; - } - - col = document.createElement('td'); - col.classList.add('statify-table-sum', 'right'); - col.dataset.raw = String(sum); - col.textContent = fmt.formatNumber(sum); - row.appendChild(col); - - tbody.insertBefore(row, tbody.firstChild); - } - - addExportButton(table); - } - - /** - * Render yearly table. - * - * @param table Root element. - * @param data Data from API. - */ - function renderDailyTable(table: HTMLTableElement, data: DailyStats): void { - const rows = Array.from(table.querySelectorAll('tbody > tr')); - const cols = rows.map((row) => Array.from(row.querySelectorAll('td'))); - // let out = cols.slice(0, 31); - - const sum = new Array(12).fill(0); - const vls = new Array(12).fill(0); - const min = new Array(12).fill(Number.MAX_SAFE_INTEGER); - const max = new Array(12).fill(0); - - for (const [day, count] of Object.entries(data)) { - const d = new Date(day); - const m = d.getMonth(); - sum[m] += count; - ++vls[m]; - min[m] = Math.min(min[m], count); - max[m] = Math.max(max[m], count); - cols[d.getDate() - 1][m].dataset.raw = String(count); - cols[d.getDate() - 1][m].textContent = fmt.formatNumber(count); - } - - let out = - cols[ - rows.findIndex((row) => - row.classList.contains('statify-table-sum') - ) - ]; - const avg = - cols[ - rows.findIndex((row) => - row.classList.contains('statify-table-avg') - ) - ]; - for (const [m, s] of sum.entries()) { - if (vls[m] > 0) { - out[m].dataset.raw = String(s); - out[m].textContent = fmt.formatNumber(s); - avg[m].dataset.raw = String(Math.round(s / vls[m])); - avg[m].textContent = fmt.formatNumber(Math.round(s / vls[m])); - } else { - out[m].dataset.raw = ''; - out[m].textContent = '-'; - avg[m].dataset.raw = ''; - avg[m].textContent = '-'; - } - } - - out = - cols[ - rows.findIndex((row) => - row.classList.contains('statify-table-min') - ) - ]; - for (const [m, s] of min.entries()) { - if (vls[m] > 0) { - out[m].dataset.raw = String(s); - out[m].textContent = fmt.formatNumber(s); - } else { - out[m].dataset.raw = ''; - out[m].textContent = '-'; - } - } - - out = - cols[ - rows.findIndex((row) => - row.classList.contains('statify-table-max') - ) - ]; - for (const [m, s] of max.entries()) { - if (vls[m] > 0) { - out[m].dataset.raw = String(s); - out[m].textContent = fmt.formatNumber(s); - } else { - out[m].dataset.raw = ''; - out[m].textContent = '-'; - } - } - - for (const row of rows) { - row.classList.remove('placeholder'); - } - - addExportButton(table); - } - - /** - * Render content table. - * - * @param table Root element. - * @param data Data from API. - */ - function renderContentTable( - table: HTMLTableElement, - data: PostStats[] - ): void { - const tbody = table.querySelector('tbody'); - const sumRow = - table.querySelectorAll('tfoot > tr > td'); - const showType = table.querySelectorAll('thead > tr > th').length > 4; - - const total = data.map((d) => d.count).reduce((a, b) => a + b, 0); - - const rows = data.map((d) => { - const row = document.createElement('tr'); - let col = document.createElement('td'); - const link = document.createElement('a'); - link.href = d.url; - link.textContent = d.title; - col.append(link); - row.appendChild(col); - col = document.createElement('td'); - col.textContent = d.url; - row.appendChild(col); - if (showType) { - col = document.createElement('td'); - col.textContent = d.typeName; - row.appendChild(col); - } - col = document.createElement('td'); - col.classList.add('right'); - col.dataset.raw = String(d.count); - col.textContent = fmt.formatNumber(d.count); - row.appendChild(col); - col = document.createElement('td'); - col.classList.add('right'); - col.dataset.raw = String( - Math.round((d.count / total) * 10000) / 100 - ); - col.textContent = fmt.formatPercent(d.count / total); - row.appendChild(col); - - return row; - }); - - updateTable(tbody!, rows); - - sumRow[sumRow.length - 2].dataset.raw = String(total); - sumRow[sumRow.length - 2].textContent = fmt.formatNumber(total); - sumRow[sumRow.length - 1].dataset.raw = '1'; - sumRow[sumRow.length - 1].textContent = fmt.formatPercent(1); - - addExportButton(table); - } - - /** - * Render referrers table. - * - * @param table Root element. - * @param data Data from API. - */ - function renderReferrersTable( - table: HTMLTableElement, - data: TopStats[] - ): void { - const tbody = table.querySelector('tbody'); - if (!tbody) { - return; - } - const sumRow = table.querySelectorAll('tfoot > tr > td'); - - const total = data.map((d) => d.count).reduce((a, b) => a + b, 0); - - const rows = data.map((d) => { - const row = document.createElement('tr'); - let col = document.createElement('td'); - const link = document.createElement('a'); - link.href = d.url; - link.textContent = d.host; - col.append(link); - row.appendChild(col); - col = document.createElement('td'); - col.classList.add('right'); - col.textContent = fmt.formatNumber(d.count); - row.appendChild(col); - col = document.createElement('td'); - col.classList.add('right'); - col.textContent = fmt.formatPercent(d.count / total); - row.appendChild(col); - return row; - }); - - updateTable(tbody, rows); - - sumRow[sumRow.length - 2].textContent = fmt.formatNumber(total); - sumRow[sumRow.length - 1].textContent = fmt.formatPercent(1); - - addExportButton(table); - } - - /** - * Replace or append table rows. - * - * @param table Target table or table body. - * @param newRows New row elements. - */ - function updateTable( - table: HTMLTableElement | HTMLTableSectionElement, - newRows: HTMLTableRowElement[] - ): void { - const existingRows = table.querySelectorAll('tr'); - - // Replace existing rows - newRows.forEach((newRow, idx) => { - if (idx < existingRows.length) { - table.replaceChild(newRow, existingRows[idx]); - } else { - table.appendChild(newRow); - } - }); - - // Remove excess rows - for (let i = newRows.length; i < existingRows.length; i++) { - existingRows[i].remove(); - } - } - - /** - * Convert daily to monthly data. - * - * @param data Daily data. - * @return Monthly data. - */ - function dailyToMonthly(data: DailyStats): MonthlyStats { - const monthly = { visits: {} } as MonthlyStats; - for (const [day, count] of Object.entries(data)) { - const date = new Date(day); - const year = date.getFullYear(); - const month = date.getMonth() + 1; - - if (!(year in monthly.visits)) { - monthly.visits[year] = {}; - } - monthly.visits[year][month] = - count + (monthly.visits[year][month] || 0); - } - return monthly; - } - // Abort if config or target element is not present. if (charts.dashboard) { // Initial update. updateDashboard(false); } - - /** - * Add a CSV export button to a table. - * - * @param table Table to process - */ - function addExportButton(table: HTMLTableElement): void { - const exportBtn = document.createElement('a'); - let csvUrl: string | null = null; - exportBtn.classList.add('button'); - exportBtn.href = '#'; - - // Generate filename from table caption. - exportBtn.download = - statifyDashboard.sitename + - '-' + - table.caption?.innerText.replace(/\s+/g, '_') + - '-export-' + - new Date() - .toISOString() - .replace('T', '_') - .replace(':', '-') - .substring(0, 16) + - '.csv'; - - // Generate CSV on demand. - exportBtn.textContent = wp.i18n.__('Export (CSV)', 'statify'); - exportBtn.addEventListener('click', () => { - if (csvUrl) { - URL.revokeObjectURL(csvUrl); - csvUrl = null; - } - - const csv = Array.from(table.rows) - .map((row) => - Array.from(row.cells) - .map( - (col) => - col.dataset.raw ?? - `"${col.innerText.replaceAll('"', '""')}"` - ) - .join(',') - ) - .join('\r\n'); - - csvUrl = URL.createObjectURL( - new Blob([csv], { type: 'text/csv;charset=utf-8' }) - ); - exportBtn.href = csvUrl; - }); - table.after(exportBtn); - } - - /** - * Augment date range controls. - * Fill start/end based on selected range and revert to "custom" when changed manually. - */ - function augmentDateRangeControls() { - const start = document.getElementById( - 'statify-content-datestart' - ) as HTMLInputElement; - const end = document.getElementById( - 'statify-content-dateend' - ) as HTMLInputElement; - if (start && end) { - const isoDate = (y: number, m: number, d: number): string => - new Date(Date.UTC(y, m, d)).toISOString().split('T')[0]; - - controls.dateRangeSelect.addEventListener( - 'change', - (evt: Event) => { - const now = new Date(), - y = now.getFullYear(), - m = now.getMonth(), - d = now.getDate(), - day = now.getDay(), - monday = d - day + (day === 0 ? -6 : 1); - - switch ((evt.target as HTMLSelectElement)?.value) { - case '': - start.value = ''; - end.value = ''; - break; - case 'lastYear': - start.value = isoDate(y - 1, 0, 1); - end.value = isoDate(y - 1, 11, 31); - break; - case 'lastWeek': - start.value = isoDate(y, m, monday - 7); - end.value = isoDate(y, m, monday - 1); - break; - case 'yesterday': - start.value = isoDate(y, m, d - 1); - end.value = isoDate(y, m, d - 1); - break; - case 'today': - start.value = isoDate(y, m, d); - end.value = isoDate(y, m, d); - break; - case 'thisWeek': - start.value = isoDate(y, m, monday); - end.value = isoDate(y, m, monday + 6); - break; - case 'last28days': - start.value = isoDate(y, m, d - 27); - end.value = isoDate(y, m, d); - break; - case 'lastMonth': - start.value = isoDate(y, m - 1, 1); - end.value = isoDate(y, m, 0); - break; - case 'thisMonth': - start.value = isoDate(y, m, 1); - end.value = isoDate(y, m + 1, 0); - break; - case '1stQuarter': - start.value = isoDate(y, 0, 1); - end.value = isoDate(y, 2, 31); - break; - case '2ndQuarter': - start.value = isoDate(y, 3, 1); - end.value = isoDate(y, 5, 30); - break; - case '3rdQuarter': - start.value = isoDate(y, 6, 1); - end.value = isoDate(y, 8, 30); - break; - case '4thQuarter': - start.value = isoDate(y, 9, 1); - end.value = isoDate(y, 11, 31); - break; - case 'thisYear': - start.value = isoDate(y, 0, 1); - end.value = isoDate(y, 11, 31); - break; - } - } - ); - - start.addEventListener('change', () => { - controls.dateRangeSelect.value = 'custom'; - }); - end.addEventListener('change', () => { - controls.dateRangeSelect.value = 'custom'; - }); - } - } - - /** - * Fetch data from Statify API. - * - * @param path Relative API path. - * @param param Parameters (optional). - * @return Response promise. - */ - function fetchData( - path: string, - param?: string | Record | URLSearchParams - ): Promise { - return wp.apiFetch({ - path: `/statify/v1/${path}${param ? '?' + new URLSearchParams(param) : ''}`, - }); - } - - /** - * Show a text message. - * - * @param element Element to add the message to. - * @param text Text message. - * @private - */ - function msg(element: HTMLElement, text: string): void { - const p = document.createElement('p'); - p.textContent = text; - element.innerHTML = ''; - element.appendChild(p); - } } diff --git a/js/dashboard/api.ts b/js/dashboard/api.ts new file mode 100644 index 00000000..45a79fba --- /dev/null +++ b/js/dashboard/api.ts @@ -0,0 +1,96 @@ +/** + * Load statistics for the dashboard widget. + * + * @param refresh Force fresh data? + */ +export function loadDashboard( + refresh: boolean = false +): Promise { + return fetchData('stats', refresh ? 'refresh=1' : null); +} + +/** + * Load daily statistics. + * + * @param year Year to load data for. + * + * @return Data promise from API. + */ +export function loadDaily(year: string): Promise { + return fetchData('stats/extended', { scope: 'day', year }); +} + +/** + * Load monthly statistics. + * + * @return Data promise from API. + */ +export function loadMonthly(): Promise { + const param = { scope: 'month' } as Record; + const post = new URLSearchParams(window.location.search).get('post'); + if (post) { + param.post = post; + } + return fetchData('stats/extended', param); +} + +/** + * Load statistics per post. + * + * @return Data promise from API. + */ +export function loadPerPost(): Promise { + const param: Record = {}; + const search = new URLSearchParams(window.location.search); + ['type', 'start', 'end'].forEach((p) => { + const v = search.get(p); + if (v) { + param[p] = v; + } + }); + + return fetchData('stats/posts', param); +} + +/** + * Load statistics per referrer. + * + * @return Data promise from API. + */ +export function loadPerReferrer(): Promise { + const param: Record = {}; + const search = new URLSearchParams(window.location.search); + ['post', 'start', 'end'].forEach((p) => { + const v = search.get(p); + if (v) { + param[p] = v; + } + }); + + return fetchData('stats/referrers', param); +} + +/** + * Load statistics for posts. + * + * @return Data promise from API. + */ +export function loadPosts(): Promise { + return fetchData('posts'); +} + +/** + * Fetch data from Statify API. + * + * @param path Relative API path. + * @param param Parameters (optional). + * @return Response promise. + */ +function fetchData( + path: string, + param: string | Record | null = null +): Promise { + return wp.apiFetch({ + path: `/statify/v1/${path}${param ? '?' + new URLSearchParams(param) : ''}`, + }); +} diff --git a/js/dashboard/charts.ts b/js/dashboard/charts.ts new file mode 100644 index 00000000..ed11acc8 --- /dev/null +++ b/js/dashboard/charts.ts @@ -0,0 +1,335 @@ +import { FlatSeries } from 'chartist'; +import Formats from './formats'; +import { msg } from './util'; + +/** + * Render daily statistics. + * + * @param root Root element. + * @param data Data from API. + * @param fmt Number and date format. + */ +export function renderDaily( + root: HTMLElement, + data: DailyStats, + fmt: Formats +): void { + const labels = Object.keys(data); + const values = Object.values(data); + const usedLabels = new Set(); + + render( + root, + labels, + values, + fmt, + true, + (date) => fmt.formatDateYMD(new Date(date)), + (day) => { + const date = new Date(day); + const mon = date.getMonth(); + if (usedLabels.has(mon)) { + return ''; + } + usedLabels.add(mon); + return fmt.formatDateM(date); + } + ); +} + +/** + * Render monthly statistics. + * + * @param root Root element. + * @param data Data from API. + * @param fmt Number and date formats. + * @param showYear Show year in label? (default: true) + */ +export function renderMonthly( + root: HTMLElement, + data: MonthlyStats, + fmt: Formats, + showYear: boolean = true +): void { + const values = Object.values(data.visits).flatMap((y) => Object.values(y)); + + let labelFunc = (l: string) => l; + let metaFunc = (l: string) => l; + let labels = Object.keys(data.visits); + if (showYear) { + if (labels.length > 2) { + labels = labels.flatMap((y) => + Object.keys(data.visits[y]).map( + (m) => `${y}-${m.padStart(2, '0')}-01` + ) + ); + /** + * Interpolate labels by year if we show more than 2 years. + * + * @param date Date string + * @return Year string + */ + labelFunc = (date: string): string => { + const d = new Date(date); + const m = d.getMonth(); + if (m === 0) { + return String(d.getFullYear()); + } + return ''; + }; + metaFunc = (date) => fmt.formatDateYM(new Date(date)); + } else { + labels = labels.flatMap((y) => + Object.keys(data.visits[y]).map((m) => + fmt.formatDateYM(new Date(Number(y), Number(m) - 1)) + ) + ); + } + } else { + labels = labels.flatMap((y) => + Object.keys(data.visits[y]).map((m) => + fmt.formatDateM(new Date(Number(y), Number(m) - 1)) + ) + ); + } + + render(root, labels, values, fmt, true, metaFunc, labelFunc); +} + +/** + * Render yearly statistics. + * + * @param root Root element. + * @param data Data from API. + * @param formats Number and date formats. + */ +export function renderYearly( + root: HTMLElement, + data: MonthlyStats, + formats: Formats +): void { + const labels = Object.keys(data.visits); + const values = Object.values(data.visits).map((y) => + Object.values(y).reduce((a, b) => a + b, 0) + ); + + render(root, labels, values, formats); +} + +/** + * Render statistics chart. + * + * @param root Root element. + * @param labels Labels. + * @param values Values. + * @param fmt Number and date formats. + * @param showAxis Show X axis? (default: true) + * @param labelToMetaFunc Meta data (tooltip label) function. (optional) + * @param labelInterpolationFnc Label interpolation function. (optional) + */ +export function render( + root: HTMLElement, + labels: string[], + values: number[], + fmt: Formats, + showAxis: boolean = true, + labelToMetaFunc: (s: string) => string = (l) => l, + labelInterpolationFnc: (s: string) => string = (l) => l +): void { + // Remove the loading content or existing chart. + root.innerHTML = ''; + + // Adjust display according if there are too many values to display readable. + let fullWidth = true; + let pointRadius = 4; + if (labels.length === 0) { + msg(root, wp.i18n.__('No data available.', 'statify')); + return; + } else if (root.clientWidth < labels.length * 4) { + // Make chart scrollable, if 2px points are overlapping. + fullWidth = false; + pointRadius = 3; + } else if (root.clientWidth < labels.length * 8) { + // Shrink datapoints if 4px is overlapping, but 2 is not. + pointRadius = 2; + } + + // Determine maximum value for scaling. + const maxValue = Math.max(...values); + + // Generate dynamic offset roughly depending on the label length + const axisYOffset = Math.max(String(maxValue).length * 8, 24); + + // Draw chart. + const chart = new Chartist.LineChart( + root, + { + labels, + series: [values], + }, + { + low: 0, + showArea: true, + fullWidth, + width: fullWidth ? undefined : 5 * labels.length, + axisX: { + showGrid: false, + showLabel: showAxis, + offset: showAxis ? 30 : 0, + labelInterpolationFnc, + }, + axisY: { + showGrid: true, + showLabel: true, + type: Chartist.FixedScaleAxis, + low: 0, + high: maxValue + 1, + ticks: [ + 0, + Math.round((maxValue * 1) / 4), + Math.round((maxValue * 2) / 4), + Math.round((maxValue * 3) / 4), + maxValue, + ], + offset: axisYOffset, + labelInterpolationFnc: (v: number) => fmt.formatNumber(v), + }, + plugins: [ + [ + ChartistPluginTooltip, + { + appendToBody: true, + class: 'statify-chartist-tooltip', + }, + ], + ], + } + ); + + // Replace default points with hollow circles, add "pageview(s) to value and append date (label) as metadata. + chart.on('draw', (d) => { + let circle; + if ('point' === d.type) { + circle = new Chartist.Svg( + 'circle', + { + cx: d.x, + cy: d.y, + r: pointRadius, + 'ct:value': wp.i18n.sprintf( + /* translators: %s: Number of page views. */ + wp.i18n._n( + '%s view', + '%s views', + (d.value as any)?.y, + 'statify' + ), + fmt.formatNumber((d.value as any)?.y) + ), + 'ct:meta': labelToMetaFunc(labels[d.index]), + }, + 'ct-point' + ); + d.element.replace(circle); + } + }); +} + +/** + * Render bar chart. + * + * @param root Root element. + * @param labels Labels. + * @param values Values. + * @param fmt Number and date formats. + * @param numericLabels Transform labels into numbers and add legend? (default: false) + */ +export function renderBarChart( + root: HTMLElement, + labels: string[], + values: number[], + fmt: Formats, + numericLabels: boolean = false +): void { + // Remove the loading content. + root.innerHTML = ''; + + if (labels.length === 0) { + msg(root, wp.i18n.__('No data available.', 'statify')); + return; + } + + // Determine maximum value for scaling. + const maxValue = Math.max(...values); + + // Generate dynamic offset roughly depending on the label length + const axisYOffset = Math.max(String(maxValue).length * 8, 24); + + let series: number[] | FlatSeries = values; + + if (numericLabels) { + const container = root.parentElement; + let legend = container!.querySelector('.statify-legend'); + if (legend) { + legend.innerHTML = ''; + } else { + legend = document.createElement('ol'); + legend.classList.add('statify-legend'); + container!.appendChild(legend); + } + + labels.forEach((l) => { + const li = document.createElement('li'); + li.textContent = l; + legend.appendChild(li); + }); + + series = values.map((v, i) => { + return { + meta: labels[i], + value: v, + }; + }); + labels = labels.map((l, i) => String(i + 1)); + } + + // Draw chart. + new Chartist.BarChart( + root, + { + labels, + series: [series], + }, + { + low: 0, + axisX: { + showGrid: false, + }, + axisY: { + showGrid: true, + showLabel: true, + low: 0, + onlyInteger: true, + offset: axisYOffset, + labelInterpolationFnc: (v) => fmt.formatNumber(Number(v)), + }, + plugins: [ + [ + ChartistPluginTooltip, + { + appendToBody: true, + anchorToPoint: true, + class: 'statify-chartist-tooltip', + transformTooltipTextFnc(y: number): string { + return wp.i18n.sprintf( + /* translators: %s: Number of page views. */ + wp.i18n._n('%s view', '%s views', y, 'statify'), + fmt.formatNumber(y) + ); + }, + }, + ], + ], + } + ); +} diff --git a/js/dashboard/controls.ts b/js/dashboard/controls.ts new file mode 100644 index 00000000..71792fa2 --- /dev/null +++ b/js/dashboard/controls.ts @@ -0,0 +1,113 @@ +import * as api from './api'; + +/** + * Augment post selector. + * + * @param postSelect Select element to append post options. + */ +export function augmentPostSelect(postSelect: HTMLInputElement): void { + api.loadPosts().then((data) => + data.forEach((post) => { + const opt = document.createElement('option'); + opt.value = post.url; + opt.textContent = post.title; + postSelect.appendChild(opt); + }) + ); +} + +/** + * Augment date range controls. + * Fill start/end based on selected range and revert to "custom" when changed manually. + * + * @param dateRangeSelect Date range select element. + */ +export function augmentDateRangeControls( + dateRangeSelect: HTMLSelectElement +): void { + const start = document.getElementById( + 'statify-content-datestart' + ) as HTMLInputElement; + const end = document.getElementById( + 'statify-content-dateend' + ) as HTMLInputElement; + if (start && end) { + const isoDate = (y: number, m: number, d: number): string => + new Date(Date.UTC(y, m, d)).toISOString().split('T')[0]; + + dateRangeSelect.addEventListener('change', (evt: Event) => { + const now = new Date(), + y = now.getFullYear(), + m = now.getMonth(), + d = now.getDate(), + day = now.getDay(), + monday = d - day + (day === 0 ? -6 : 1); + + switch ((evt.target as HTMLSelectElement)?.value) { + case '': + start.value = ''; + end.value = ''; + break; + case 'lastYear': + start.value = isoDate(y - 1, 0, 1); + end.value = isoDate(y - 1, 11, 31); + break; + case 'lastWeek': + start.value = isoDate(y, m, monday - 7); + end.value = isoDate(y, m, monday - 1); + break; + case 'yesterday': + start.value = isoDate(y, m, d - 1); + end.value = isoDate(y, m, d - 1); + break; + case 'today': + start.value = isoDate(y, m, d); + end.value = isoDate(y, m, d); + break; + case 'thisWeek': + start.value = isoDate(y, m, monday); + end.value = isoDate(y, m, monday + 6); + break; + case 'last28days': + start.value = isoDate(y, m, d - 27); + end.value = isoDate(y, m, d); + break; + case 'lastMonth': + start.value = isoDate(y, m - 1, 1); + end.value = isoDate(y, m, 0); + break; + case 'thisMonth': + start.value = isoDate(y, m, 1); + end.value = isoDate(y, m + 1, 0); + break; + case '1stQuarter': + start.value = isoDate(y, 0, 1); + end.value = isoDate(y, 2, 31); + break; + case '2ndQuarter': + start.value = isoDate(y, 3, 1); + end.value = isoDate(y, 5, 30); + break; + case '3rdQuarter': + start.value = isoDate(y, 6, 1); + end.value = isoDate(y, 8, 30); + break; + case '4thQuarter': + start.value = isoDate(y, 9, 1); + end.value = isoDate(y, 11, 31); + break; + case 'thisYear': + start.value = isoDate(y, 0, 1); + end.value = isoDate(y, 11, 31); + break; + } + }); + + start.addEventListener('change', () => { + dateRangeSelect.value = 'custom'; + }); + end.addEventListener('change', () => { + dateRangeSelect.value = 'custom'; + }); + } +} diff --git a/js/dashboard/formats.ts b/js/dashboard/formats.ts new file mode 100644 index 00000000..a223d8be --- /dev/null +++ b/js/dashboard/formats.ts @@ -0,0 +1,46 @@ +export default class Formats { + private readonly number: Intl.NumberFormat; + private readonly numberPercent: Intl.NumberFormat; + private readonly dateYMD: Intl.DateTimeFormat; + private readonly dateYM: Intl.DateTimeFormat; + private readonly dateM: Intl.DateTimeFormat; + + constructor(lang: string | undefined) { + this.number = new Intl.NumberFormat(lang); + this.numberPercent = new Intl.NumberFormat(lang, { + style: 'percent', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + this.dateYMD = new Intl.DateTimeFormat(lang, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + this.dateYM = new Intl.DateTimeFormat(lang, { + year: 'numeric', + month: 'short', + }); + this.dateM = new Intl.DateTimeFormat(lang, { month: 'short' }); + } + + public formatNumber(num: number): string { + return this.number.format(num); + } + + public formatPercent(num: number): string { + return this.numberPercent.format(num); + } + + public formatDateYMD(date: Date): string { + return this.dateYMD.format(date); + } + + public formatDateYM(date: Date): string { + return this.dateYM.format(date); + } + + public formatDateM(date: Date): string { + return this.dateM.format(date); + } +} diff --git a/js/dashboard/tables.ts b/js/dashboard/tables.ts new file mode 100644 index 00000000..9a6783bf --- /dev/null +++ b/js/dashboard/tables.ts @@ -0,0 +1,409 @@ +import Formats from './formats'; + +/** + * Render top list table. + * + * @param table Table element. + * @param data Data to display. + * @param fmt Number and date format. + */ +export function renderTopList( + table: HTMLTableSectionElement, + data: TopStats[], + fmt: Formats +): void { + const rows = data.map((r) => { + const row = document.createElement('tr'); + let col = document.createElement('td'); + col.classList.add('b'); + col.textContent = fmt.formatNumber(r.count); + row.appendChild(col); + col = document.createElement('td'); + col.classList.add('t'); + const label = r.title || r.host || r.url; + if (/^((https?:)?\/)?\//i.test(r.url)) { + const link = document.createElement('a'); + link.href = r.url; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.textContent = label; + col.appendChild(link); + } else { + col.textContent = label; + } + row.appendChild(col); + return row; + }); + + updateTable(table, rows); +} + +/** + * Render totals table. + * + * @param table Table element. + * @param data Totals data. + * @param visits Daily visits of the displayed period. + * @param fmt Number and date format. + */ +export function renderTotals( + table: HTMLTableSectionElement, + data: TotalStats, + visits: number[], + fmt: Formats +): void { + const rowToday = document.createElement('tr'); + let col = document.createElement('td'); + col.classList.add('b'); + col.textContent = fmt.formatNumber(data.today); + rowToday.appendChild(col); + col = document.createElement('td'); + col.classList.add('t'); + col.textContent = wp.i18n.__('today', 'statify'); + rowToday.appendChild(col); + + const rowPeriod = document.createElement('tr'); + col = document.createElement('td'); + col.classList.add('b'); + col.textContent = fmt.formatNumber(visits.reduce((a, b) => a + b, 0)); + rowPeriod.appendChild(col); + col = document.createElement('td'); + col.classList.add('t'); + col.textContent = wp.i18n.sprintf( + /* translators: %d: Number of days. */ + wp.i18n._n('last %d day', 'last %d days', visits.length, 'statify'), + visits.length + ); + rowPeriod.appendChild(col); + + const rowAll = document.createElement('tr'); + col = document.createElement('td'); + col.classList.add('b'); + col.textContent = fmt.formatNumber(data.alltime); + rowAll.appendChild(col); + col = document.createElement('td'); + col.classList.add('t'); + col.textContent = wp.i18n.sprintf( + /* translators: %s: Date. */ + wp.i18n.__('since %s', 'statify'), + fmt.formatDateYMD(new Date(data.since)) + ); + rowAll.appendChild(col); + + updateTable(table, [rowToday, rowPeriod, rowAll]); +} + +/** + * Render yearly table. + * + * @param table Root element. + * @param data Data from API. + * @param fmt Number and date format. + */ +export function renderYearly( + table: HTMLTableElement, + data: MonthlyStats, + fmt: Formats +): void { + const tbody = table.querySelector('tbody')!; + + tbody.innerHTML = ''; + + for (const year in data.visits) { + const row = document.createElement('tr'); + let col = document.createElement('th'); + let sum = 0; + col.scope = 'row'; + col.textContent = year; + row.appendChild(col); + + for (let month = 1; month <= 12; month++) { + col = document.createElement('td'); + col.classList.add('right'); + if (month in data.visits[year]) { + col.dataset.raw = String(data.visits[year][month]); + col.textContent = fmt.formatNumber(data.visits[year][month]); + } else { + col.dataset.raw = ''; + col.textContent = '-'; + } + row.appendChild(col); + sum += data.visits[year][month] || 0; + } + + col = document.createElement('td'); + col.classList.add('statify-table-sum', 'right'); + col.dataset.raw = String(sum); + col.textContent = fmt.formatNumber(sum); + row.appendChild(col); + + tbody.insertBefore(row, tbody.firstChild); + } + + addExportButton(table); +} + +/** + * Render daily table. + * + * @param table Root element. + * @param data Data from API. + * @param fmt Number and date format. + */ +export function renderDaily( + table: HTMLTableElement, + data: DailyStats, + fmt: Formats +): void { + const rows = Array.from(table.querySelectorAll('tbody > tr')); + const cols = rows.map((row) => Array.from(row.querySelectorAll('td'))); + // let out = cols.slice(0, 31); + + const sum = new Array(12).fill(0); + const vls = new Array(12).fill(0); + const min = new Array(12).fill(Number.MAX_SAFE_INTEGER); + const max = new Array(12).fill(0); + + for (const [day, count] of Object.entries(data)) { + const d = new Date(day); + const m = d.getMonth(); + sum[m] += count; + ++vls[m]; + min[m] = Math.min(min[m], count); + max[m] = Math.max(max[m], count); + cols[d.getDate() - 1][m].dataset.raw = String(count); + cols[d.getDate() - 1][m].textContent = fmt.formatNumber(count); + } + + let out = + cols[ + rows.findIndex((row) => row.classList.contains('statify-table-sum')) + ]; + const avg = + cols[ + rows.findIndex((row) => row.classList.contains('statify-table-avg')) + ]; + for (const [m, s] of sum.entries()) { + if (vls[m] > 0) { + out[m].dataset.raw = String(s); + out[m].textContent = fmt.formatNumber(s); + avg[m].dataset.raw = String(Math.round(s / vls[m])); + avg[m].textContent = fmt.formatNumber(Math.round(s / vls[m])); + } else { + out[m].dataset.raw = ''; + out[m].textContent = '-'; + avg[m].dataset.raw = ''; + avg[m].textContent = '-'; + } + } + + out = + cols[ + rows.findIndex((row) => row.classList.contains('statify-table-min')) + ]; + for (const [m, s] of min.entries()) { + out[m].dataset.raw = String(s); + out[m].textContent = vls[m] > 0 ? fmt.formatNumber(s) : '-'; + } + + out = + cols[ + rows.findIndex((row) => row.classList.contains('statify-table-max')) + ]; + for (const [m, s] of max.entries()) { + out[m].dataset.raw = String(s); + out[m].textContent = vls[m] > 0 ? fmt.formatNumber(s) : '-'; + } + + for (const row of rows) { + row.classList.remove('placeholder'); + } + + addExportButton(table); +} + +/** + * Render content table. + * + * @param table Root element. + * @param data Data from API. + * @param fmt Number and date format. + */ +export function renderContent( + table: HTMLTableElement, + data: PostStats[], + fmt: Formats +): void { + const tbody = table.querySelector('tbody'); + const sumRow = + table.querySelectorAll('tfoot > tr > td'); + const showType = table.querySelectorAll('thead > tr > th').length > 4; + + const total = data.map((d) => d.count).reduce((a, b) => a + b, 0); + + const rows = data.map((d) => { + const row = document.createElement('tr'); + let col = document.createElement('td'); + const link = document.createElement('a'); + link.href = d.url; + link.textContent = d.title; + col.append(link); + row.appendChild(col); + col = document.createElement('td'); + col.textContent = d.url; + row.appendChild(col); + if (showType) { + col = document.createElement('td'); + col.textContent = d.typeName; + row.appendChild(col); + } + col = document.createElement('td'); + col.classList.add('right'); + col.dataset.raw = String(d.count); + col.textContent = fmt.formatNumber(d.count); + row.appendChild(col); + col = document.createElement('td'); + col.classList.add('right'); + col.dataset.raw = String(Math.round((d.count / total) * 10000) / 100); + col.textContent = fmt.formatPercent(d.count / total); + row.appendChild(col); + + return row; + }); + + updateTable(tbody!, rows); + + sumRow[sumRow.length - 2].dataset.raw = String(total); + sumRow[sumRow.length - 2].textContent = fmt.formatNumber(total); + sumRow[sumRow.length - 1].dataset.raw = '1'; + sumRow[sumRow.length - 1].textContent = fmt.formatPercent(1); + + addExportButton(table); +} + +/** + * Render referrers table. + * + * @param table Root element. + * @param data Data from API. + * @param fmt Number and date format. + */ +export function renderReferrers( + table: HTMLTableElement, + data: TopStats[], + fmt: Formats +): void { + const tbody = table.querySelector('tbody'); + if (!tbody) { + return; + } + const sumRow = table.querySelectorAll('tfoot > tr > td'); + + const total = data.map((d) => d.count).reduce((a, b) => a + b, 0); + + const rows = data.map((d) => { + const row = document.createElement('tr'); + let col = document.createElement('td'); + const link = document.createElement('a'); + link.href = d.url; + link.textContent = d.host; + col.append(link); + row.appendChild(col); + col = document.createElement('td'); + col.classList.add('right'); + col.textContent = fmt.formatNumber(d.count); + row.appendChild(col); + col = document.createElement('td'); + col.classList.add('right'); + col.textContent = fmt.formatPercent(d.count / total); + row.appendChild(col); + return row; + }); + + updateTable(tbody, rows); + + sumRow[sumRow.length - 2].textContent = fmt.formatNumber(total); + sumRow[sumRow.length - 1].textContent = fmt.formatPercent(1); + + addExportButton(table); +} + +/** + * Replace or append table rows. + * + * @param table Target table or table body. + * @param newRows New row elements. + */ +function updateTable( + table: HTMLTableElement | HTMLTableSectionElement, + newRows: HTMLTableRowElement[] +): void { + const existingRows = table.querySelectorAll('tr'); + + // Replace existing rows + newRows.forEach((newRow, idx) => { + if (idx < existingRows.length) { + table.replaceChild(newRow, existingRows[idx]); + } else { + table.appendChild(newRow); + } + }); + + // Remove excess rows + for (let i = newRows.length; i < existingRows.length; i++) { + existingRows[i].remove(); + } +} + +/** + * Add a CSV export button to a table. + * + * @param table Table to process + */ +function addExportButton(table: HTMLTableElement): void { + const exportBtn = document.createElement('a'); + let csvUrl: string | null = null; + exportBtn.classList.add('button'); + exportBtn.href = '#'; + + // Generate filename from table caption. + exportBtn.download = + statifyDashboard.sitename + + '-' + + table.caption?.innerText.replace(/\s+/g, '_') + + '-export-' + + new Date() + .toISOString() + .replace('T', '_') + .replace(':', '-') + .substring(0, 16) + + '.csv'; + + // Generate CSV on demand. + exportBtn.textContent = wp.i18n.__('Export (CSV)', 'statify'); + exportBtn.addEventListener('click', () => { + if (csvUrl) { + URL.revokeObjectURL(csvUrl); + csvUrl = null; + } + + const csv = Array.from(table.rows) + .map((row) => + Array.from(row.cells) + .map( + (col) => + col.dataset.raw ?? + `"${col.innerText.replaceAll('"', '""')}"` + ) + .join(',') + ) + .join('\r\n'); + + csvUrl = URL.createObjectURL( + new Blob([csv], { type: 'text/csv;charset=utf-8' }) + ); + + exportBtn.href = csvUrl; + }); + table.after(exportBtn); +} diff --git a/js/dashboard/util.ts b/js/dashboard/util.ts new file mode 100644 index 00000000..100da7c3 --- /dev/null +++ b/js/dashboard/util.ts @@ -0,0 +1,35 @@ +/** + * Convert daily to monthly data. + * + * @param data Daily data. + * @return Monthly data. + */ +export function dailyToMonthly(data: DailyStats): MonthlyStats { + const monthly = { visits: {} } as MonthlyStats; + for (const [day, count] of Object.entries(data)) { + const date = new Date(day); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + + if (!(year in monthly.visits)) { + monthly.visits[year] = {}; + } + monthly.visits[year][month] = + count + (monthly.visits[year][month] || 0); + } + return monthly; +} + +/** + * Show a text message. + * + * @param element Element to add the message to. + * @param text Text message. + * @private + */ +export function msg(element: HTMLElement, text: string): void { + const p = document.createElement('p'); + p.textContent = text; + element.innerHTML = ''; + element.appendChild(p); +} diff --git a/package.json b/package.json index 976be45c..9c78baec 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,10 @@ "copy-assets": "vendor-copy", "minify": "npm run minify:js && npm run minify:css", "minify:css": "esbuild css/dashboard.css css/settings.css --minify --outdir=css --outbase=. --entry-names=[name].min", - "minify:js": "esbuild js/dashboard.ts js/settings.ts js/snippet.js --minify --outdir=js --outbase=js --entry-names=[name].min", + "minify:js": "esbuild js/dashboard.ts js/settings.ts js/snippet.js --bundle --minify --outdir=js --outbase=js --entry-names=[name].min", "lint": "npm run lint-css && npm run lint-js", "lint-css": "stylelint css/dashboard.css css/settings.css", - "lint-js": "eslint js/dashboard.ts js/settings.ts js/snippet.js", + "lint-js": "eslint js/*.ts js/**/*.ts js/snippet.js", "test": "nyc --reporter=text node --test tests/js/*.test.js" }, "allowScripts": { diff --git a/tsconfig.json b/tsconfig.json index b240ef21..fef5d9aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "target": "ES2021" }, "include": [ - "js/*.ts" + "js/*.ts", + "js/**/*.ts" ] }