import type { ISeoPerfEvent, ISeoPerfPoint, ISeoPerformance, SeoPerfJobState, } from '../../service/seo/seo.interface'; /** * The performance dashboard's arithmetic: times and sizes in words, how a * number moved over a week or a month, the plugin and theme changes placed * on the chart's days, and the wait for a measurement. */ const DAY_MS = 24 * 60 * 60 * 1000; /** The numbers of the history a chart or a trend can follow. */ export type SeoPerfMetric = Exclude; /** * @param {number | null | undefined} ms - Milliseconds. * @returns {string} "420 ms", "1.6 s", "—" when unknown. */ export const formatMs = (ms: number | null | undefined): string => { if (ms === null || ms === undefined) { return '—'; } if (ms < 1000) { return `${Math.round(ms)} ms`; } return `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)} s`; }; /** * @param {number | null | undefined} bytes - Bytes. * @returns {string} "353 KB", "1.2 MB", "—" when unknown, in 1024s as WordPress counts. */ export const formatBytes = (bytes: number | null | undefined): string => { if (bytes === null || bytes === undefined) { return '—'; } const units = ['B', 'KB', 'MB', 'GB', 'TB']; let value = bytes; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; } return `${unit === 0 || value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`; }; /** How a number moved between two measurements. */ export interface SeoPerfTrend { from: number; to: number; /** The change in percent, rounded; negative is less time or fewer bytes. */ change: number; } /** * The latest value of a metric against the one measured that long ago: * the last value at or before the start of the window (no older than one * more window), else the first one inside it. * * @param {ISeoPerfPoint[]} history - The snapshots, oldest first. * @param {SeoPerfMetric} metric - What to follow. * @param {number} days - How far back. * @param {number} now - The time to count from. * @returns {SeoPerfTrend | null} Null without a value to compare with. */ export const trendOf = ( history: ISeoPerfPoint[], metric: SeoPerfMetric, days: number, now: number = Date.now() ): SeoPerfTrend | null => { const known = history.filter(point => typeof point[metric] === 'number'); const last = known[known.length - 1]; const since = now - days * DAY_MS; const time = (point: ISeoPerfPoint): number => new Date(point.at).getTime(); const before = known.filter( point => point !== last && time(point) <= since && time(point) >= since - days * DAY_MS ); const base = before[before.length - 1] ?? known.find(point => point !== last && time(point) > since); if (!last || !base) { return null; } const from = base[metric] as number; const to = last[metric] as number; if (from === 0) { return null; } return { from, to, change: Math.round(((to - from) / from) * 100) }; }; /** * Whether a move is worth a colour: times and sizes are better when they * fall, and a tenth either way is noise. * * @param {number} change - Percent. * @returns {'better' | 'worse' | 'same'} */ export const trendTone = (change: number): 'better' | 'worse' | 'same' => { if (change <= -10) { return 'better'; } return change >= 10 ? 'worse' : 'same'; }; /** * @param {string} iso - A time. * @returns {string} Its day, YYYY-MM-DD in UTC, as the snapshots are kept. */ export const dayOf = (iso: string): string => new Date(iso).toISOString().slice(0, 10); /** * The changes on the site placed on the chart: each on the first measured * day on or after it, the first snapshot that carries its effect. A change * after the last measurement waits for the next one. * * @param {ISeoPerfEvent[]} events - The changes, oldest first. * @param {string[]} days - The chart's days, YYYY-MM-DD, oldest first. * @returns {Record} Day => its changes. */ export const eventsByDay = ( events: ISeoPerfEvent[], days: string[] ): Record => { const placed: Record = {}; events.forEach(event => { const day = days.find(candidate => candidate >= dayOf(event.at)); if (day) { placed[day] = [...(placed[day] ?? []), event]; } }); return placed; }; /** * @param {ISeoPerfEvent} event - A change. * @returns {string} "WooCommerce updated to 11.1.2", "Theme switched to Astra". */ export const eventLabel = (event: ISeoPerfEvent): string => { const version = event.version ? ` ${event.version}` : ''; switch (event.type) { case 'plugin_activated': return `${event.name} activated`; case 'plugin_deactivated': return `${event.name} deactivated`; case 'plugin_installed': return `${event.name}${version} installed`; case 'plugin_updated': return `${event.name} updated${version ? ` to${version}` : ''}`; case 'theme_switched': return `Theme switched to ${event.name}`; case 'theme_installed': return `Theme ${event.name}${version} installed`; case 'theme_updated': return `Theme ${event.name} updated${version ? ` to${version}` : ''}`; case 'core_updated': return `WordPress updated${version ? ` to${version}` : ''}`; default: return event.name; } }; /** An error the page reads like the other REST errors: a message and the status in its data. */ const failure = ( message: string, code: string, status: number ): Error & { code: string; data: { status: number } } => Object.assign(new Error(message), { code, data: { status } }); interface WaitOptions { ask: (job: string) => Promise; sleep?: (ms: number) => Promise; now?: () => number; /** True once nobody waits for the answer any more: the page was left. */ cancelled?: () => boolean; } /** How long to wait between two questions, and for the measurement at most. */ export const MEASURE_POLL_MS = 4000; export const MEASURE_WAIT_MS = 12 * 60 * 1000; /** * The dashboard once a measurement has finished: the server measures * after the request that started it has answered, and the page asks every * few seconds. * * @param {string} job - The job's key. * @param {WaitOptions} options - What to ask with and how to wait. * @returns {Promise} The dashboard; a failed job throws its error. */ export const waitForMeasurement = async ( job: string, { ask, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), now = Date.now, cancelled = () => false, }: WaitOptions ): Promise => { const deadline = now() + MEASURE_WAIT_MS; for (;;) { await sleep(MEASURE_POLL_MS); if (cancelled()) { throw failure('Left before the measurement finished.', 'cancelled', 0); } const state = await ask(job); if (state.status === 'done') { return state.performance; } if (state.status === 'failed') { throw failure( state.error.message, state.error.code ?? '', Number(state.error.data?.status ?? 0) ); } if (now() > deadline) { throw failure( 'The measurement has not finished after twelve minutes. The daily one still runs; look again later.', 'recomaze_seo_perf_slow', 504 ); } } };