diff --git a/src/Components/ChartPanel.tsx b/src/Components/ChartPanel.tsx index 99ab640e..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, @@ -100,6 +100,7 @@ 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) { @@ -269,7 +301,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 072f1681..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"; @@ -80,14 +80,15 @@ 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. 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/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/Helpers/Types.tsx b/src/Helpers/Types.tsx index e095b85c..d5d8528d 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,12 +205,14 @@ export interface ChartPanelProps { methodName: MethodName, steps: StepName[], useLogScale: boolean, - use4SegmentTiming: boolean + use4SegmentTiming: boolean, + recordHistoryAllDays: boolean } 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 ba997368..edf184fe 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 ─────────────────────────────────────────────────────────── @@ -111,40 +112,74 @@ 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 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[]) { + 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]); } } - return records; + + return records.map(r => ({ x: r.date, y: r.value })); } -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); 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: 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) }, + { 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) }, ], }; } @@ -179,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( @@ -342,7 +399,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); @@ -368,10 +425,13 @@ 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), - recordHistory: buildRecordHistory(solves), + recordHistory: buildRecordHistory(solves, recordHistoryAllDays), stepPercentages: buildStepPercentages(solves, steps, windowSize), typicalCompare: buildTypicalCompare(solves, windowSize), bestSolvesData: computeBestSolvesData(solves), @@ -388,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) { 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', () => {