diff --git a/src/Components/ChartPanel.tsx b/src/Components/ChartPanel.tsx index 99ab640e..e9470f37 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} options={createOptions(ChartType.Line, "Solve Number", "Time (s)", p.useLogScale, true, false, isDark)} />, "Average Inspection Time", "This chart shows how much inspection time you use on average")); } charts.push(buildChartHtml(
, "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 dr = c.dailyRecord as { datasets: unknown[]; xAxisMin?: Date; xAxisMax?: Date }; + const drOptions = createOptions(ChartType.Line, "Date", "Time (s)", p.useLogScale, true, true, isDark); + if (p.allDays) { + (drOptions as any).scales.x.type = 'time'; + const DAY_MS = 86_400_000; + if (dr.xAxisMin) (drOptions as any).scales.x.min = dr.xAxisMin.valueOf() - 3 * DAY_MS; + if (dr.xAxisMax) (drOptions as any).scales.x.max = dr.xAxisMax.valueOf() + 3 * DAY_MS; + } + charts.push(buildChartHtml(} options={drOptions} />, "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: 'Day', week: 'Week', 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 +314,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.allDays) { + // '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..ec39aa18 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, + "All Days", + "When on, date-based charts show a continuous timeline spanning all solve dates. Days/weeks/months without solves are shown as gaps or zero bars." + )} + {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..16f1ea06 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, + allDays: boolean } export interface FileInputProps { @@ -204,12 +205,14 @@ export interface ChartPanelProps { methodName: MethodName, steps: StepName[], useLogScale: boolean, - use4SegmentTiming: boolean + use4SegmentTiming: boolean, + allDays: 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..6de29367 100644 --- a/src/Workers/chartWorker.ts +++ b/src/Workers/chartWorker.ts @@ -51,6 +51,7 @@ interface WorkerInput { methodName: MethodName; use4SegmentTiming: boolean; isDark: boolean; + allDays: 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) }, ], }; } @@ -164,7 +199,7 @@ function buildStepAverages(solves: Solve[], steps: StepName[], windowSize: numbe return { labels, datasets }; } -function buildDailyRecordData(solves: Solve[]) { +function buildDailyRecordData(solves: Solve[], allDays: boolean) { const fastestSolveEachDay: { [key: string]: number } = {}; for (const solve of solves) { const day = solve.date.toLocaleDateString('en-CA'); @@ -172,13 +207,95 @@ function buildDailyRecordData(solves: Solve[]) { ? Math.min(fastestSolveEachDay[day], solve.time) : solve.time; } - const labels = Object.keys(fastestSolveEachDay).sort(); + const sortedKeys = Object.keys(fastestSolveEachDay).sort(); + const data = sortedKeys.map(d => ({ x: new Date(d + 'T12:00:00'), y: fastestSolveEachDay[d] })); + + let xAxisMin: Date | undefined; + let xAxisMax: Date | undefined; + if (allDays && solves.length > 0) { + let minMs = solves[0].date.valueOf(); + let maxMs = solves[0].date.valueOf(); + for (let i = 1; i < solves.length; i++) { + const ms = solves[i].date.valueOf(); + if (ms < minMs) minMs = ms; + if (ms > maxMs) maxMs = ms; + } + xAxisMin = new Date(minMs); + xAxisMax = new Date(maxMs); + } + return { - labels, - datasets: [{ label: 'Fastest Solve Each Day', data: labels.map(d => fastestSolveEachDay[d]) }], + xAxisMin, + xAxisMax, + datasets: [{ label: 'Fastest Solve Each Day', data }], }; } +function buildSolvesPerPeriodData(solves: Solve[], period: 'day' | 'week' | 'month', allDays: boolean) { + 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; + } + + if (allDays && solves.length > 0) { + const sorted = solves.map(s => s.date.valueOf()).sort((a, b) => a - b); + const minDate = new Date(sorted[0]); + const maxDate = new Date(sorted[sorted.length - 1]); + + if (period === 'day') { + const cur = new Date(minDate); + cur.setHours(0, 0, 0, 0); + const end = new Date(maxDate); + end.setHours(0, 0, 0, 0); + while (cur <= end) { + const key = cur.toLocaleDateString('en-CA'); + if (!(key in counts)) counts[key] = 0; + cur.setDate(cur.getDate() + 1); + } + } else if (period === 'week') { + // Find Monday of the week containing minDate + const cur = new Date(minDate); + const day = cur.getDay(); + cur.setDate(cur.getDate() - day + (day === 0 ? -6 : 1)); + cur.setHours(0, 0, 0, 0); + const end = new Date(maxDate); + while (cur <= end) { + const key = cur.toLocaleDateString('en-CA'); + if (!(key in counts)) counts[key] = 0; + cur.setDate(cur.getDate() + 7); + } + } else { + // month: iterate YYYY-MM from min to max + let year = minDate.getFullYear(); + let month = minDate.getMonth(); + const endYear = maxDate.getFullYear(); + const endMonth = maxDate.getMonth(); + while (year < endYear || (year === endYear && month <= endMonth)) { + const key = `${year}-${String(month + 1).padStart(2, '0')}`; + if (!(key in counts)) counts[key] = 0; + month++; + if (month > 11) { month = 0; year++; } + } + } + } + + 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 +459,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, allDays } = input; const hasOll = steps.includes(StepName.OLL); const hasPll = steps.includes(StepName.PLL); @@ -367,11 +484,14 @@ function computeAllChartData(input: WorkerInput): Record { inspection: showInspectionCharts ? buildInspectionData(inspectionSolves, windowSize) : null, - dailyRecord: buildDailyRecordData(solves), + dailyRecord: buildDailyRecordData(solves, allDays), + solvesPerDay: buildSolvesPerPeriodData(solves, 'day', allDays), + solvesPerWeek: buildSolvesPerPeriodData(solves, 'week', allDays), + solvesPerMonth: buildSolvesPerPeriodData(solves, 'month', allDays), streakRows: buildAllStreakRows(solves), recordRows: buildRecordRows(solves), goodBad: buildGoodBadData(solves, windowSize, pointsPerGraph, goodTime, badTime), - recordHistory: buildRecordHistory(solves), + recordHistory: buildRecordHistory(solves, allDays), stepPercentages: buildStepPercentages(solves, steps, windowSize), typicalCompare: buildTypicalCompare(solves, windowSize), bestSolvesData: computeBestSolvesData(solves), @@ -388,7 +508,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', () => {