From effdefe168f734bbfaaf74b39f1c92acea5caf41 Mon Sep 17 00:00:00 2001 From: Pau Oliva Date: Thu, 16 Apr 2026 09:45:10 +0200 Subject: [PATCH 1/4] Only display 1 value per day for each average + toggle to display all days --- src/Components/ChartPanel.tsx | 6 ++- src/Components/FilterPanel.tsx | 20 ++++++++-- src/Helpers/Types.tsx | 6 ++- src/Workers/chartWorker.ts | 71 +++++++++++++++++++++++++++------- 4 files changed, 83 insertions(+), 20 deletions(-) diff --git a/src/Components/ChartPanel.tsx b/src/Components/ChartPanel.tsx index 99ab640e..7c8f7474 100644 --- a/src/Components/ChartPanel.tsx +++ b/src/Components/ChartPanel.tsx @@ -100,6 +100,7 @@ export class ChartPanel extends React.Component, + "Record History: All Days", + "When on, the History of Records chart shows every calendar day from the first to the last solve. Days with no solve data carry forward the last known record value. When off (default), only days where a new record was set are shown." + )} + {this.createFilterHtml(
@@ -922,6 +935,7 @@ export class FilterPanel extends React.Component diff --git a/src/Helpers/Types.tsx b/src/Helpers/Types.tsx index e095b85c..e4ff1788 100644 --- a/src/Helpers/Types.tsx +++ b/src/Helpers/Types.tsx @@ -177,7 +177,8 @@ export interface FilterPanelState { goodTime: number, method: Option, useLogScale: boolean, - use4SegmentTiming: boolean + use4SegmentTiming: boolean, + recordHistoryAllDays: boolean } export interface FileInputProps { @@ -204,7 +205,8 @@ export interface ChartPanelProps { methodName: MethodName, steps: StepName[], useLogScale: boolean, - use4SegmentTiming: boolean + use4SegmentTiming: boolean, + recordHistoryAllDays: boolean } export interface ChartPanelState { diff --git a/src/Workers/chartWorker.ts b/src/Workers/chartWorker.ts index ba997368..35cef6de 100644 --- a/src/Workers/chartWorker.ts +++ b/src/Workers/chartWorker.ts @@ -51,6 +51,7 @@ interface WorkerInput { methodName: MethodName; use4SegmentTiming: boolean; isDark: boolean; + recordHistoryAllDays: boolean; } // ── Streak helpers ─────────────────────────────────────────────────────────── @@ -121,17 +122,59 @@ function buildRecordRows(solves: Solve[]): RecordRow[] { ]; } -function buildRecordDataset(dates: Date[], times: number[]) { - const records = [{ x: dates[0], y: times[0] }]; - for (let i = 1; i < times.length; i++) { - if (times[i] < records[records.length - 1].y) { - records.push({ x: dates[i], y: times[i] }); +function buildRecordDatasetDaily(dates: Date[], times: number[], allDays: boolean) { + if (dates.length === 0) return []; + + // Step 1: group by day, keep the minimum value per day + const dayMap: { [key: string]: { date: Date; value: number } } = {}; + for (let i = 0; i < dates.length; i++) { + const day = dates[i].toLocaleDateString('en-CA'); + if (!dayMap[day] || times[i] < dayMap[day].value) { + dayMap[day] = { date: dates[i], value: times[i] }; + } + } + const sortedKeys = Object.keys(dayMap).sort(); + const dayData = sortedKeys.map(k => dayMap[k]); + + // Step 2: build the record history (only keep days that set a new PB) + const records: { date: Date; value: number }[] = [dayData[0]]; + for (let i = 1; i < dayData.length; i++) { + if (dayData[i].value < records[records.length - 1].value) { + records.push(dayData[i]); + } + } + + if (!allDays) { + return records.map(r => ({ x: r.date, y: r.value })); + } + + // Step 3: allDays mode — emit one point per calendar day, carrying the current record forward + const result: { x: Date; y: number }[] = []; + let recordIdx = 0; + + const cursor = new Date(dayData[0].date); + cursor.setHours(0, 0, 0, 0); + const endDate = new Date(dayData[dayData.length - 1].date); + endDate.setHours(0, 0, 0, 0); + + while (cursor <= endDate) { + const cursorDay = cursor.toLocaleDateString('en-CA'); + // Advance to the latest record that was set on or before today + while (recordIdx + 1 < records.length && + records[recordIdx + 1].date.toLocaleDateString('en-CA') <= cursorDay) { + recordIdx++; } + // Only emit once the first record day has been reached + if (records[recordIdx].date.toLocaleDateString('en-CA') <= cursorDay) { + result.push({ x: new Date(cursor), y: records[recordIdx].value }); + } + cursor.setDate(cursor.getDate() + 1); } - return records; + + return result; } -function buildRecordHistory(solves: Solve[]) { +function buildRecordHistory(solves: Solve[], allDays: boolean) { const dates = solves.map(x => x.date); const times = solves.map(x => x.time); const ao5 = calculateMovingAverage(times, 5); @@ -140,11 +183,11 @@ function buildRecordHistory(solves: Solve[]) { //const ao1000 = calculateMovingAverageChopped(times, 1000, 50); return { datasets: [ - { label: 'Record Single', data: buildRecordDataset(dates, times) }, - { label: 'Record Ao5', data: buildRecordDataset(dates.slice(4), ao5) }, - { label: 'Record Ao12', data: buildRecordDataset(dates.slice(11), ao12) }, - { label: 'Record Ao100', data: buildRecordDataset(dates.slice(99), ao100) }, - //{ label: 'Record Ao1000', data: buildRecordDataset(dates.slice(999), ao1000) }, + { label: 'Record Single', data: buildRecordDatasetDaily(dates, times, allDays) }, + { label: 'Record Ao5', data: buildRecordDatasetDaily(dates.slice(4), ao5, allDays) }, + { label: 'Record Ao12', data: buildRecordDatasetDaily(dates.slice(11), ao12, allDays) }, + { label: 'Record Ao100', data: buildRecordDatasetDaily(dates.slice(99), ao100, allDays) }, + //{ label: 'Record Ao1000', data: buildRecordDatasetDaily(dates.slice(999), ao1000, allDays) }, ], }; } @@ -342,7 +385,7 @@ function computeBestSolvesData(solves: Solve[]): FastestSolve[] { // ── Main computation ────────────────────────────────────────────────────────── function computeAllChartData(input: WorkerInput): Record { - const { solves, windowSize, pointsPerGraph, steps, goodTime, badTime, methodName, use4SegmentTiming, isDark } = input; + const { solves, windowSize, pointsPerGraph, steps, goodTime, badTime, methodName, use4SegmentTiming, isDark, recordHistoryAllDays } = input; const hasOll = steps.includes(StepName.OLL); const hasPll = steps.includes(StepName.PLL); @@ -371,7 +414,7 @@ function computeAllChartData(input: WorkerInput): Record { streakRows: buildAllStreakRows(solves), recordRows: buildRecordRows(solves), goodBad: buildGoodBadData(solves, windowSize, pointsPerGraph, goodTime, badTime), - recordHistory: buildRecordHistory(solves), + recordHistory: buildRecordHistory(solves, recordHistoryAllDays), stepPercentages: buildStepPercentages(solves, steps, windowSize), typicalCompare: buildTypicalCompare(solves, windowSize), bestSolvesData: computeBestSolvesData(solves), From 260a1b11b01db2b9701b1815bd1b7d18227f7c65 Mon Sep 17 00:00:00 2001 From: Pau Oliva Date: Thu, 16 Apr 2026 10:47:08 +0200 Subject: [PATCH 2/4] don't show all datapoints + show ao1000 when available --- src/Components/ChartPanel.tsx | 14 +++++++- src/Components/FilterPanel.tsx | 12 +++---- src/Workers/chartWorker.ts | 66 +++++++++++++++------------------- 3 files changed, 48 insertions(+), 44 deletions(-) diff --git a/src/Components/ChartPanel.tsx b/src/Components/ChartPanel.tsx index 7c8f7474..8ea0f5e3 100644 --- a/src/Components/ChartPanel.tsx +++ b/src/Components/ChartPanel.tsx @@ -273,7 +273,19 @@ export class ChartPanel extends React.Component} options={createOptions(ChartType.Line, "Solve Number", "Percentage", p.useLogScale, true, false, isDark)} />, "Percentage of 'Good' and 'Bad' Solves", "This chart shows your running average of solves considered 'good' and 'bad'. This can be configured in the filter panel. Just set the good and bad values to times you feel are correct")); - charts.push(buildChartHtml(} options={createOptions(ChartType.Line, "Date", "Time (s)", p.useLogScale, true, true, isDark)} />, "History of Records", "This chart shows your history of PBs. Note that this will only show solves that meet the criteria in your filters, so don't be alarmed if you don't see your PB here. As a note, Ao12 removes the best and worst solves of the 12. Ao100 removes the best and worst 5. Ao1000 removes the best and worst 50.")); + { + const rh = c.recordHistory as { datasets: unknown[]; xAxisMin?: Date; xAxisMax?: Date }; + const rhOptions = createOptions(ChartType.Line, "Date", "Time (s)", p.useLogScale, true, true, isDark); + if (p.recordHistoryAllDays) { + // 'timeseries' evenly spaces data points so gaps disappear; 'time' renders + // a continuous timeline. Also pin min/max so the axis spans all solve dates. + (rhOptions as any).scales.x.type = 'time'; + const DAY_MS = 86_400_000; + if (rh.xAxisMin) (rhOptions as any).scales.x.min = rh.xAxisMin.valueOf() - 3 * DAY_MS; + if (rh.xAxisMax) (rhOptions as any).scales.x.max = rh.xAxisMax.valueOf() + 3 * DAY_MS; + } + charts.push(buildChartHtml(} options={rhOptions} />, "History of Records", "This chart shows your history of PBs. Note that this will only show solves that meet the criteria in your filters, so don't be alarmed if you don't see your PB here. As a note, Ao12 removes the best and worst solves of the 12. Ao100 removes the best and worst 5. Ao1000 removes the best and worst 50.")); + } } return ( diff --git a/src/Components/FilterPanel.tsx b/src/Components/FilterPanel.tsx index e1167658..64ed2e00 100644 --- a/src/Components/FilterPanel.tsx +++ b/src/Components/FilterPanel.tsx @@ -7,7 +7,7 @@ import { MultiSelect } from "react-multi-select-component"; import { CrossColor, FilterPanelProps, FilterPanelState, Filters, getStep, MethodName, Option, Solve, SolveCleanliness, SolveLuckiness, Step, StepName } from "../Helpers/Types"; import { ChartPanel } from "./ChartPanel"; import { calculateMovingAverage, calculateMovingStdDev } from "../Helpers/MathHelpers"; -import { FormControl, Card, Row, Offcanvas, Col, Button, Tooltip, OverlayTrigger, Alert, Container, CardText, Spinner } from 'react-bootstrap'; +import { FormControl, Card, Row, Offcanvas, Col, Button, Tooltip, OverlayTrigger, Alert, Container, Spinner } from 'react-bootstrap'; import { Const } from "../Helpers/Constants"; import { CalculateAllSessionOptions, CalculateBenchmarkTimes, CalculateWindowSize } from "../Helpers/CubeHelpers"; import ReactSwitch from "react-switch"; @@ -88,7 +88,7 @@ export class FilterPanel extends React.Component, "Record History: All Days", - "When on, the History of Records chart shows every calendar day from the first to the last solve. Days with no solve data carry forward the last known record value. When off (default), only days where a new record was set are shown." + "When on, the History of Records chart shows every calendar day from the first to the last solve. When off (default), only days where a new record was set are shown." )} {this.createFilterHtml( diff --git a/src/Workers/chartWorker.ts b/src/Workers/chartWorker.ts index 35cef6de..f5c68114 100644 --- a/src/Workers/chartWorker.ts +++ b/src/Workers/chartWorker.ts @@ -112,17 +112,17 @@ function buildRecordRows(solves: Solve[]): RecordRow[] { const ao5 = Math.min.apply(null, calculateMovingAverage(times, 5)); const ao12 = Math.min.apply(null, calculateMovingAverageChopped(times, 12, 1)); const ao100 = Math.min.apply(null, calculateMovingAverageChopped(times, 100, 5)); - //const ao1000 = Math.min.apply(null, calculateMovingAverageChopped(times, 1000, 50)); + const ao1000 = Math.min.apply(null, calculateMovingAverageChopped(times, 1000, 50)); return [ { recordType: 'Single', time: single.toFixed(3) }, { recordType: 'Ao5', time: ao5.toFixed(3) }, { recordType: 'Ao12', time: ao12.toFixed(3) }, { recordType: 'Ao100', time: ao100.toFixed(3) }, - //{ recordType: 'Ao1000', time: ao1000.toFixed(3) }, + { recordType: 'Ao1000', time: ao1000.toFixed(3) }, ]; } -function buildRecordDatasetDaily(dates: Date[], times: number[], allDays: boolean) { +function buildRecordDatasetDaily(dates: Date[], times: number[]) { if (dates.length === 0) return []; // Step 1: group by day, keep the minimum value per day @@ -144,34 +144,7 @@ function buildRecordDatasetDaily(dates: Date[], times: number[], allDays: boolea } } - if (!allDays) { - return records.map(r => ({ x: r.date, y: r.value })); - } - - // Step 3: allDays mode — emit one point per calendar day, carrying the current record forward - const result: { x: Date; y: number }[] = []; - let recordIdx = 0; - - const cursor = new Date(dayData[0].date); - cursor.setHours(0, 0, 0, 0); - const endDate = new Date(dayData[dayData.length - 1].date); - endDate.setHours(0, 0, 0, 0); - - while (cursor <= endDate) { - const cursorDay = cursor.toLocaleDateString('en-CA'); - // Advance to the latest record that was set on or before today - while (recordIdx + 1 < records.length && - records[recordIdx + 1].date.toLocaleDateString('en-CA') <= cursorDay) { - recordIdx++; - } - // Only emit once the first record day has been reached - if (records[recordIdx].date.toLocaleDateString('en-CA') <= cursorDay) { - result.push({ x: new Date(cursor), y: records[recordIdx].value }); - } - cursor.setDate(cursor.getDate() + 1); - } - - return result; + return records.map(r => ({ x: r.date, y: r.value })); } function buildRecordHistory(solves: Solve[], allDays: boolean) { @@ -180,14 +153,33 @@ function buildRecordHistory(solves: Solve[], allDays: boolean) { const ao5 = calculateMovingAverage(times, 5); const ao12 = calculateMovingAverageChopped(times, 12, 1); const ao100 = calculateMovingAverageChopped(times, 100, 5); - //const ao1000 = calculateMovingAverageChopped(times, 1000, 50); + const ao1000 = calculateMovingAverageChopped(times, 1000, 50); + + // When allDays is on, expose the full solve date range so ChartPanel can set + // min/max on the 'time' scale — no null anchors needed, avoids rendering issues. + let xAxisMin: Date | undefined; + let xAxisMax: Date | undefined; + if (allDays && dates.length > 0) { + let minMs = dates[0].valueOf(); + let maxMs = dates[0].valueOf(); + for (let i = 1; i < dates.length; i++) { + const ms = dates[i].valueOf(); + if (ms < minMs) minMs = ms; + if (ms > maxMs) maxMs = ms; + } + xAxisMin = new Date(minMs); + xAxisMax = new Date(maxMs); + } + return { + xAxisMin, + xAxisMax, datasets: [ - { label: 'Record Single', data: buildRecordDatasetDaily(dates, times, allDays) }, - { label: 'Record Ao5', data: buildRecordDatasetDaily(dates.slice(4), ao5, allDays) }, - { label: 'Record Ao12', data: buildRecordDatasetDaily(dates.slice(11), ao12, allDays) }, - { label: 'Record Ao100', data: buildRecordDatasetDaily(dates.slice(99), ao100, allDays) }, - //{ label: 'Record Ao1000', data: buildRecordDatasetDaily(dates.slice(999), ao1000, allDays) }, + { label: 'Record Single', data: buildRecordDatasetDaily(dates, times) }, + { label: 'Record Ao5', data: buildRecordDatasetDaily(dates.slice(4), ao5) }, + { label: 'Record Ao12', data: buildRecordDatasetDaily(dates.slice(11), ao12) }, + { label: 'Record Ao100', data: buildRecordDatasetDaily(dates.slice(99), ao100) }, + { label: 'Record Ao1000', data: buildRecordDatasetDaily(dates.slice(999), ao1000) }, ], }; } From db8258598a633bb36b14f63f9860dd7b5c53c37c Mon Sep 17 00:00:00 2001 From: Pau Oliva Date: Thu, 16 Apr 2026 10:58:19 +0200 Subject: [PATCH 3/4] fix eslint warnings --- src/Helpers/CsvParser.tsx | 2 +- src/Helpers/MathHelpers.tsx | 2 -- src/tests/CubeHelpers.test.tsx | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Helpers/CsvParser.tsx b/src/Helpers/CsvParser.tsx index 2b21c75c..9187810c 100644 --- a/src/Helpers/CsvParser.tsx +++ b/src/Helpers/CsvParser.tsx @@ -260,7 +260,7 @@ function parseCubeastCsv(stringVal: string, splitter: string): Solve[] { "date": (obj, value) => { obj.date = moment.utc(value, 'YYYY-MM-DD hh:mm:ss').toDate(); }, "solution_rotation": (obj, value) => { obj.crossColor = Const.crossMappings.get(value) ?? CrossColor.Unknown; - if (obj.crossColor == CrossColor.Unknown) { + if (obj.crossColor === CrossColor.Unknown) { //console.log("Unknown solution rotation: ", value); //obj.isCorrupt = true; }; diff --git a/src/Helpers/MathHelpers.tsx b/src/Helpers/MathHelpers.tsx index dee346a5..286052ca 100644 --- a/src/Helpers/MathHelpers.tsx +++ b/src/Helpers/MathHelpers.tsx @@ -1,7 +1,5 @@ import { Deque } from "@datastructures-js/deque"; import { Const } from "./Constants"; -import { Records } from "./Types"; -var Set = require("sorted-set"); export function sumArray(data: number[]): number { return data.reduce((acc, curr) => acc + curr, 0); diff --git a/src/tests/CubeHelpers.test.tsx b/src/tests/CubeHelpers.test.tsx index 8f8a2539..ff2e4b0c 100644 --- a/src/tests/CubeHelpers.test.tsx +++ b/src/tests/CubeHelpers.test.tsx @@ -1,5 +1,5 @@ import { GetEmptyStep, GetEmptySolve, CalculateBenchmarkTimes, CalculateWindowSize, CalculateMostUsedMethod, CalculateAllSessionOptions } from '../Helpers/CubeHelpers'; -import { describe, expect, test } from '@jest/globals'; +import { expect, test } from '@jest/globals'; import { MethodName, Solve } from '../Helpers/Types'; test('GetEmptyStep returns an empty step', () => { From 312bbdd10779f8aefef40496308372c65fd9f0dc Mon Sep 17 00:00:00 2001 From: Pau Oliva Date: Thu, 16 Apr 2026 12:08:21 +0200 Subject: [PATCH 4/4] add solve count graph with day / week / month toggle --- src/Components/ChartPanel.tsx | 32 ++++++++++++++++++++++++++++++-- src/Helpers/Types.tsx | 1 + src/Workers/chartWorker.ts | 27 ++++++++++++++++++++++++++- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/Components/ChartPanel.tsx b/src/Components/ChartPanel.tsx index 8ea0f5e3..76228896 100644 --- a/src/Components/ChartPanel.tsx +++ b/src/Components/ChartPanel.tsx @@ -13,7 +13,7 @@ import { } from "../Helpers/Types"; import { Chart as ChartJS, ChartData, CategoryScale } from 'chart.js/auto'; import { createOptions, buildChartHtml } from "../Helpers/ChartHelpers"; -import { Row, Spinner, Tooltip } from "react-bootstrap"; +import { Row, Col, Card, ButtonGroup, Button, OverlayTrigger, Ratio, Spinner, Tooltip } from "react-bootstrap"; import { ThemeContext } from "../contexts/ThemeContext"; import { Line, Bar, Doughnut } from 'react-chartjs-2'; import { Const } from "../Helpers/Constants"; @@ -70,7 +70,7 @@ const BEST_SOLVES_COLS = [ export class ChartPanel extends React.Component { static contextType = ThemeContext; - state: ChartPanelState & { _propsKey?: string } = { chartData: null, isComputing: false }; + state: ChartPanelState & { _propsKey?: string } = { chartData: null, isComputing: false, solvesPerPeriod: 'day' }; static getDerivedStateFromProps( nextProps: ChartPanelProps, @@ -253,6 +253,34 @@ export class ChartPanel extends React.Component
, "Longest Daily Streaks", "How many days in a row you've achieved solves of each time")); charts.push(buildChartHtml(} options={createOptions(ChartType.Line, "Date", "Time (s)", p.useLogScale, true, true, isDark)} />, "Daily Fastest Solve", "This chart shows the fastest solve for each day, based on the selected filters")); + { + const period = this.state.solvesPerPeriod; + const periodLabels: Record = { day: 'Daily', week: 'Weekly', month: 'Monthly' }; + const periodData = { day: c.solvesPerDay, week: c.solvesPerWeek, month: c.solvesPerMonth }[period]; + const xAxisLabel = { day: 'Date', week: 'Week Starting', month: 'Month' }[period]; + charts.push( + + + Number of solves completed per day, week, or month}> + Solve Count ⓘ + +
+ + {(['day', 'week', 'month'] as const).map(p => ( + + ))} + +
+ + } options={createOptions(ChartType.Bar, xAxisLabel, "Solves", false, false, false, isDark)} /> + +
+ + ); + } charts.push(buildChartHtml(
, "Current Records", "This chart shows your current records for Single, Ao5, Ao12, Ao100, and Ao1000")); if (hasOll) { diff --git a/src/Helpers/Types.tsx b/src/Helpers/Types.tsx index e4ff1788..d5d8528d 100644 --- a/src/Helpers/Types.tsx +++ b/src/Helpers/Types.tsx @@ -212,6 +212,7 @@ export interface ChartPanelProps { export interface ChartPanelState { chartData: Record | null; isComputing: boolean; + solvesPerPeriod: 'day' | 'week' | 'month'; } export interface StreakRow { diff --git a/src/Workers/chartWorker.ts b/src/Workers/chartWorker.ts index f5c68114..edf184fe 100644 --- a/src/Workers/chartWorker.ts +++ b/src/Workers/chartWorker.ts @@ -214,6 +214,28 @@ function buildDailyRecordData(solves: Solve[]) { }; } +function buildSolvesPerPeriodData(solves: Solve[], period: 'day' | 'week' | 'month') { + const counts: { [key: string]: number } = {}; + for (const solve of solves) { + let key: string; + if (period === 'day') { + key = solve.date.toLocaleDateString('en-CA'); + } else if (period === 'week') { + const d = new Date(solve.date); + const day = d.getDay(); + const diff = d.getDate() - day + (day === 0 ? -6 : 1); + d.setDate(diff); + key = d.toLocaleDateString('en-CA'); + } else { + key = solve.date.toISOString().slice(0, 7); + } + counts[key] = (counts[key] ?? 0) + 1; + } + const labels = Object.keys(counts).sort(); + const label = period === 'day' ? 'Solves per Day' : period === 'week' ? 'Solves per Week' : 'Solves per Month'; + return { labels, datasets: [{ label, data: labels.map(k => counts[k]) }] }; +} + // ── Efficiency ─────────────────────────────────────────────────────────────── function buildRunningEfficiencyData( @@ -403,6 +425,9 @@ function computeAllChartData(input: WorkerInput): Record { ? buildInspectionData(inspectionSolves, windowSize) : null, dailyRecord: buildDailyRecordData(solves), + solvesPerDay: buildSolvesPerPeriodData(solves, 'day'), + solvesPerWeek: buildSolvesPerPeriodData(solves, 'week'), + solvesPerMonth: buildSolvesPerPeriodData(solves, 'month'), streakRows: buildAllStreakRows(solves), recordRows: buildRecordRows(solves), goodBad: buildGoodBadData(solves, windowSize, pointsPerGraph, goodTime, badTime), @@ -423,7 +448,7 @@ function computeAllChartData(input: WorkerInput): Record { const chartDataKeys = [ 'runningAverage', 'runningStdDev', 'runningTps', 'runningInspection', 'runningTurns', 'runningRecognitionExecution', 'runningEfficiency', 'histogram', 'stepAverages', - 'runningColorPercentages', 'inspection', 'dailyRecord', 'goodBad', 'recordHistory', + 'runningColorPercentages', 'inspection', 'dailyRecord', 'solvesPerDay', 'solvesPerWeek', 'solvesPerMonth', 'goodBad', 'recordHistory', 'stepPercentages', 'typicalCompare', 'ollCategory', 'pllCategory', 'caseData', ] as const; for (const key of chartDataKeys) {