From 41918666883e1861b715ae34e29eccbbf8c7d8d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 09:55:31 +0000 Subject: [PATCH 1/6] Initial plan From 2ab2b09e140079a6d9410b9e7f4077e20034265b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 09:57:49 +0000 Subject: [PATCH 2/6] Initial exploration complete Co-authored-by: Justtolook <22642772+Justtolook@users.noreply.github.com> --- package-lock.json | 44 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index de086cb..7ab3f5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -798,6 +798,7 @@ "resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.5.tgz", "integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/Fuzzyma" @@ -821,6 +822,7 @@ "resolved": "https://registry.npmjs.org/@svgdotjs/svg.select.js/-/svg.select.js-4.0.3.tgz", "integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 14.18" }, @@ -974,7 +976,8 @@ "version": "18.7.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.15.tgz", "integrity": "sha512-XnjpaI8Bgc3eBag2Aw4t2Uj/49lLBSStHWfqKvIuXD7FIrZyMLWp8KuAFHAqxMZYTF9l08N1ctUn9YNybZJVmQ==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@types/node-forge": { "version": "1.3.14", @@ -1348,6 +1351,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -1464,6 +1468,7 @@ "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.3.6.tgz", "integrity": "sha512-sVEPw+J0Gp0IHQabKu8cfdsxlfME0e36Wid7RIaPclGM2OUt+O7O4+6mfAmTUYhy5bDk8cNHzEhPfVtLCIXEJA==", "license": "SEE LICENSE IN LICENSE", + "peer": true, "dependencies": { "@svgdotjs/svg.draggable.js": "^3.0.4", "@svgdotjs/svg.filter.js": "^3.0.8", @@ -1645,6 +1650,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4461,6 +4467,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4790,6 +4797,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -4816,6 +4824,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -4835,6 +4844,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", "integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==", "dev": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5806,6 +5816,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", "dev": true, + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -5907,6 +5918,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.2.tgz", "integrity": "sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6035,6 +6047,7 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -6083,6 +6096,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", "dev": true, + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", @@ -6164,6 +6178,7 @@ "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -6258,6 +6273,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6859,7 +6875,8 @@ "@svgdotjs/svg.js": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.5.tgz", - "integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==" + "integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==", + "peer": true }, "@svgdotjs/svg.resize.js": { "version": "2.0.5", @@ -6871,6 +6888,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/@svgdotjs/svg.select.js/-/svg.select.js-4.0.3.tgz", "integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==", + "peer": true, "requires": {} }, "@tsconfig/node10": { @@ -7015,7 +7033,8 @@ "version": "18.7.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.15.tgz", "integrity": "sha512-XnjpaI8Bgc3eBag2Aw4t2Uj/49lLBSStHWfqKvIuXD7FIrZyMLWp8KuAFHAqxMZYTF9l08N1ctUn9YNybZJVmQ==", - "dev": true + "dev": true, + "peer": true }, "@types/node-forge": { "version": "1.3.14", @@ -7343,6 +7362,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7413,6 +7433,7 @@ "version": "5.3.6", "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.3.6.tgz", "integrity": "sha512-sVEPw+J0Gp0IHQabKu8cfdsxlfME0e36Wid7RIaPclGM2OUt+O7O4+6mfAmTUYhy5bDk8cNHzEhPfVtLCIXEJA==", + "peer": true, "requires": { "@svgdotjs/svg.draggable.js": "^3.0.4", "@svgdotjs/svg.filter.js": "^3.0.8", @@ -7540,6 +7561,7 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, + "peer": true, "requires": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9543,6 +9565,7 @@ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, + "peer": true, "requires": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9747,6 +9770,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "peer": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -9764,6 +9788,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "peer": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -9779,7 +9804,8 @@ "version": "0.14.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", "integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==", - "dev": true + "dev": true, + "peer": true }, "read-cache": { "version": "1.0.0", @@ -10471,6 +10497,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", "dev": true, + "peer": true, "requires": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -10532,7 +10559,8 @@ "version": "4.8.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.2.tgz", "integrity": "sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw==", - "dev": true + "dev": true, + "peer": true }, "underscore": { "version": "1.13.4", @@ -10615,6 +10643,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, + "peer": true, "requires": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -10647,7 +10676,8 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true + "dev": true, + "peer": true }, "acorn-import-phases": { "version": "1.0.4", @@ -10669,6 +10699,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", "dev": true, + "peer": true, "requires": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", @@ -10710,6 +10741,7 @@ "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz", "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", "dev": true, + "peer": true, "requires": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", From 1d5d32a3ad539f758932c2fae4c3cbda5b06edac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 10:00:09 +0000 Subject: [PATCH 3/6] Add time spent reviewing statistics and chart Co-authored-by: Justtolook <22642772+Justtolook@users.noreply.github.com> --- src/lib/dataProcessing.ts | 165 ++++++++++++++++++++++++++++++++++ src/widgets/statistics.tsx | 176 ++++++++++++++++++++++++++++++++++++- 2 files changed, 339 insertions(+), 2 deletions(-) diff --git a/src/lib/dataProcessing.ts b/src/lib/dataProcessing.ts index 66a1100..e271fdb 100644 --- a/src/lib/dataProcessing.ts +++ b/src/lib/dataProcessing.ts @@ -521,3 +521,168 @@ export function getRetentionRateByTimeOfDay(allCards: Card[] | undefined): TimeO return blockStats; } +/** + * Represents time statistics for a specific day + */ +export interface DailyTimeStats { + date: number; // Unix timestamp + timeMs: number; // Total time in milliseconds + reviewCount: number; // Number of reviews +} + +/** + * Gets time spent per day from card repetition history + * @param allCards - Array of all cards to analyze + * @param startLimit - Start date timestamp (default: 0) + * @param endLimit - End date timestamp (default: Infinity) + * @returns Array of daily time statistics + */ +export function getTimeSpentPerDay( + allCards: Card[] | undefined, + startLimit: number = 0, + endLimit: number = Infinity +): DailyTimeStats[] { + if (!allCards || allCards.length === 0) return []; + + const dailyStats = new Map(); + + for (const card of allCards) { + const history = card.repetitionHistory; + if (!history) continue; + + for (const rep of history) { + if (rep.date < startLimit || rep.date > endLimit) continue; + if (rep.date <= LIMIT) continue; + if (!rep.responseTime || rep.responseTime <= 0) continue; + + const dayKey = new Date(rep.date).toDateString(); + const stats = dailyStats.get(dayKey) || { timeMs: 0, count: 0 }; + stats.timeMs += rep.responseTime; + stats.count += 1; + dailyStats.set(dayKey, stats); + } + } + + if (dailyStats.size === 0) return []; + + const result: DailyTimeStats[] = []; + for (const [dayKey, stats] of dailyStats) { + result.push({ + date: new Date(dayKey).getTime(), + timeMs: stats.timeMs, + reviewCount: stats.count + }); + } + + result.sort((a, b) => a.date - b.date); + return result; +} + +/** + * Overall time statistics summary + */ +export interface TimeStatsSummary { + totalTimeMs: number; + totalReviews: number; + daysWithReviews: number; + totalDaysInPeriod: number; + averageTimePerDay: number; // Average over all days in period (ms) + averageTimePerReviewDay: number; // Average over days with reviews (ms) + averageTimePerCard: number; // Average time per card review (ms) + cardsPerMinute: number; // Average cards per minute +} + +/** + * Calculates overall time statistics from daily data + * @param dailyData - Array of daily time statistics + * @param startDate - Start of the period (for calculating total days) + * @param endDate - End of the period + * @returns Summary of time statistics + */ +export function calculateTimeStatsSummary( + dailyData: DailyTimeStats[], + startDate?: number, + endDate?: number +): TimeStatsSummary { + if (dailyData.length === 0) { + return { + totalTimeMs: 0, + totalReviews: 0, + daysWithReviews: 0, + totalDaysInPeriod: 0, + averageTimePerDay: 0, + averageTimePerReviewDay: 0, + averageTimePerCard: 0, + cardsPerMinute: 0 + }; + } + + const totalTimeMs = dailyData.reduce((sum, day) => sum + day.timeMs, 0); + const totalReviews = dailyData.reduce((sum, day) => sum + day.reviewCount, 0); + const daysWithReviews = dailyData.filter(day => day.reviewCount > 0).length; + + // Calculate total days in period + let totalDaysInPeriod = dailyData.length; + if (startDate !== undefined && endDate !== undefined) { + const daysDiff = Math.ceil((endDate - startDate) / (24 * 60 * 60 * 1000)); + totalDaysInPeriod = Math.max(daysDiff, dailyData.length); + } + + const averageTimePerDay = totalDaysInPeriod > 0 ? totalTimeMs / totalDaysInPeriod : 0; + const averageTimePerReviewDay = daysWithReviews > 0 ? totalTimeMs / daysWithReviews : 0; + const averageTimePerCard = totalReviews > 0 ? totalTimeMs / totalReviews : 0; + + // Cards per minute: (totalReviews / (totalTimeMs / 60000)) + const cardsPerMinute = totalTimeMs > 0 ? (totalReviews / (totalTimeMs / 60000)) : 0; + + return { + totalTimeMs, + totalReviews, + daysWithReviews, + totalDaysInPeriod, + averageTimePerDay, + averageTimePerReviewDay, + averageTimePerCard, + cardsPerMinute + }; +} + +/** + * Format milliseconds to human-readable time string + * @param ms - Time in milliseconds + * @param format - Format type ('short' for "1h 23m", 'long' for "1 hour 23 minutes", 'hours' for "1.38h") + * @returns Formatted time string + */ +export function formatTime(ms: number, format: 'short' | 'long' | 'hours' | 'seconds' = 'short'): string { + if (ms === 0) return '0s'; + + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (format === 'hours') { + const hoursDecimal = ms / (1000 * 60 * 60); + return `${hoursDecimal.toFixed(2)}h`; + } + + if (format === 'seconds') { + const secondsDecimal = ms / 1000; + return `${secondsDecimal.toFixed(2)}s`; + } + + const parts: string[] = []; + + if (hours > 0) { + parts.push(format === 'long' ? `${hours} hour${hours > 1 ? 's' : ''}` : `${hours}h`); + } + if (minutes > 0) { + parts.push(format === 'long' ? `${minutes} minute${minutes > 1 ? 's' : ''}` : `${minutes}m`); + } + if (seconds > 0 && hours === 0) { // Only show seconds if less than an hour + parts.push(format === 'long' ? `${seconds} second${seconds > 1 ? 's' : ''}` : `${seconds}s`); + } + + return parts.join(' ') || '0s'; +} + diff --git a/src/widgets/statistics.tsx b/src/widgets/statistics.tsx index d627f2d..8e328af 100644 --- a/src/widgets/statistics.tsx +++ b/src/widgets/statistics.tsx @@ -29,7 +29,12 @@ import { getHardestCards, HardCardData, getRetentionRateByTimeOfDay, - TimeOfDayRetention + TimeOfDayRetention, + getTimeSpentPerDay, + calculateTimeStatsSummary, + formatTime, + DailyTimeStats, + TimeStatsSummary } from '../lib/dataProcessing'; type RangeMode = 'Today' | 'Yesterday' | 'Week' | 'This Week' | 'Last Week' | 'Month' | 'This Month' | 'Last Month' | 'Year' | 'This Year' | 'Last Year' | 'All'; @@ -325,6 +330,19 @@ export const Statistics = () => { return getRetentionRateByTimeOfDay(filteredCards); }, [filteredCards]); + // Time statistics data + const timeStatsData = React.useMemo(() => { + const startTs = dateStart ? new Date(dateStart).getTime() : 0; + const endTs = dateEnd ? new Date(dateEnd).getTime() + (24 * 60 * 60 * 1000) : Infinity; + return getTimeSpentPerDay(activeCardsSource, startTs, endTs); + }, [activeCardsSource, dateStart, dateEnd]); + + const timeStatsSummary = React.useMemo(() => { + const startTs = dateStart ? new Date(dateStart).getTime() : undefined; + const endTs = dateEnd ? new Date(dateEnd).getTime() + (24 * 60 * 60 * 1000) : undefined; + return calculateTimeStatsSummary(timeStatsData, startTs, endTs); + }, [timeStatsData, dateStart, dateEnd]); + // -- Styles -- const containerStyle = getContainerStyle(); const boxStyle = getBoxStyle(); @@ -720,7 +738,7 @@ export const Statistics = () => { {/* Metrics Grid */} -
+
Retention Rate @@ -811,6 +829,41 @@ export const Statistics = () => { )}
+ + {/* Time Spent Card */} +
+
+ Time Spent +
+ + + + + +
+
+
+ {formatTime(timeStatsSummary.totalTimeMs)} +
+ {timeStatsSummary.totalTimeMs > 0 && ( +
+ {formatTime(timeStatsSummary.averageTimePerReviewDay)}/day studied +
+ )} +
@@ -851,6 +904,14 @@ export const Statistics = () => { 'Retention rate by time of day' )}
+ +
+ {chart_time_spent( + timeStatsData, + timeStatsSummary, + 'Time spent reviewing' + )} +
@@ -1513,4 +1574,115 @@ function chart_retention_by_time_of_day( ; } +function chart_time_spent( + timeData: DailyTimeStats[], + summary: TimeStatsSummary, + title: string +) { + if (!timeData || timeData.length === 0) return
+
No time data available
+
Time tracking data will appear as you review flashcards
+
; + + // Prepare data for bar chart (time in minutes per day) + const chartData = timeData.map(day => ({ + x: day.date, + y: Math.round((day.timeMs / 1000 / 60) * 10) / 10 // Convert to minutes, round to 1 decimal + })); + + const options = { + ...getCommonChartOptions(title, 'datetime'), + dataLabels: { enabled: false }, + plotOptions: { + bar: { + borderRadius: 2, + columnWidth: '85%' + } + }, + colors: ['#f59e0b'], // amber color for time + xaxis: { + ...getCommonChartOptions(title, 'datetime').xaxis, + type: 'datetime' as const, + labels: { + format: 'MMM dd', + style: { colors: 'var(--rn-clr-content-primary)' } + } + }, + yaxis: { + decimalsInFloat: 1, + labels: { + style: { colors: 'var(--rn-clr-content-primary)' }, + formatter: function(val: number) { + return val.toFixed(1) + 'm'; + } + }, + title: { + text: 'Time (minutes)', + style: { color: 'var(--rn-clr-content-primary)' } + } + }, + tooltip: { + theme: 'light' as const, + x: { format: 'dd MMM yyyy' }, + y: { + formatter: function(val: number, opts: any) { + const dayIndex = opts.dataPointIndex; + const reviews = timeData[dayIndex]?.reviewCount || 0; + return `${val.toFixed(1)} minutes (${reviews} reviews)`; + } + } + } + }; + + return
+ {/* Summary Statistics */} +
+
+
+
Total Time
+
+ {formatTime(summary.totalTimeMs, 'hours')} +
+
+
+
Avg per Day
+
+ {formatTime(summary.averageTimePerDay)} +
+
+
+
Avg per Study Day
+
+ {formatTime(summary.averageTimePerReviewDay)} +
+
+
+
Avg per Card
+
+ {formatTime(summary.averageTimePerCard, 'seconds')} + + ({summary.cardsPerMinute.toFixed(1)}/min) + +
+
+
+
+ Days studied: {summary.daysWithReviews} of {summary.totalDaysInPeriod} ({summary.totalDaysInPeriod > 0 ? ((summary.daysWithReviews / summary.totalDaysInPeriod) * 100).toFixed(1) : 0}%) +
+
+ + {/* Chart */} + +
; +} + renderWidget(Statistics); From 2858259aff1d1095154cda4552bb2029820f87bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 10:02:45 +0000 Subject: [PATCH 4/6] Fix code review issues - improve formatTime and extract percentage calculation Co-authored-by: Justtolook <22642772+Justtolook@users.noreply.github.com> --- src/lib/dataProcessing.ts | 15 ++++++++++++--- src/widgets/statistics.tsx | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/lib/dataProcessing.ts b/src/lib/dataProcessing.ts index e271fdb..5af1b5c 100644 --- a/src/lib/dataProcessing.ts +++ b/src/lib/dataProcessing.ts @@ -590,6 +590,7 @@ export interface TimeStatsSummary { averageTimePerReviewDay: number; // Average over days with reviews (ms) averageTimePerCard: number; // Average time per card review (ms) cardsPerMinute: number; // Average cards per minute + percentageDaysStudied: number; // Percentage of days studied (0-100) } /** @@ -613,7 +614,8 @@ export function calculateTimeStatsSummary( averageTimePerDay: 0, averageTimePerReviewDay: 0, averageTimePerCard: 0, - cardsPerMinute: 0 + cardsPerMinute: 0, + percentageDaysStudied: 0 }; } @@ -634,6 +636,8 @@ export function calculateTimeStatsSummary( // Cards per minute: (totalReviews / (totalTimeMs / 60000)) const cardsPerMinute = totalTimeMs > 0 ? (totalReviews / (totalTimeMs / 60000)) : 0; + + const percentageDaysStudied = totalDaysInPeriod > 0 ? (daysWithReviews / totalDaysInPeriod) * 100 : 0; return { totalTimeMs, @@ -643,7 +647,8 @@ export function calculateTimeStatsSummary( averageTimePerDay, averageTimePerReviewDay, averageTimePerCard, - cardsPerMinute + cardsPerMinute, + percentageDaysStudied }; } @@ -654,7 +659,11 @@ export function calculateTimeStatsSummary( * @returns Formatted time string */ export function formatTime(ms: number, format: 'short' | 'long' | 'hours' | 'seconds' = 'short'): string { - if (ms === 0) return '0s'; + if (ms === 0) { + if (format === 'hours') return '0.00h'; + if (format === 'seconds') return '0.00s'; + return '0s'; + } const totalSeconds = Math.floor(ms / 1000); const hours = Math.floor(totalSeconds / 3600); diff --git a/src/widgets/statistics.tsx b/src/widgets/statistics.tsx index 8e328af..7b22a97 100644 --- a/src/widgets/statistics.tsx +++ b/src/widgets/statistics.tsx @@ -1670,7 +1670,7 @@ function chart_time_spent(
- Days studied: {summary.daysWithReviews} of {summary.totalDaysInPeriod} ({summary.totalDaysInPeriod > 0 ? ((summary.daysWithReviews / summary.totalDaysInPeriod) * 100).toFixed(1) : 0}%) + Days studied: {summary.daysWithReviews} of {summary.totalDaysInPeriod} ({summary.percentageDaysStudied.toFixed(1)}%)
From fbbc77a36aa33898a60e605562de3b9802b91856 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 10:04:30 +0000 Subject: [PATCH 5/6] Update README with time tracking feature documentation Co-authored-by: Justtolook <22642772+Justtolook@users.noreply.github.com> --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index 8e2f9f3..780d567 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,13 @@ The RemNote Statistics Plugin transforms your flashcard review data into actiona ## ✨ Key Features +### ⏱️ Time Tracking & Productivity +- **Time Spent Statistics**: Track total time spent reviewing flashcards +- **Time Spent Chart**: Bar chart showing daily review time in minutes (similar to Anki) +- **Productivity Metrics**: View average time per day, per study day, and per card +- **Study Efficiency**: Cards per minute rate and study day percentage +- **Time Spent Card**: New metric card in Review Statistics section showing total time and daily averages + ### 📈 Comprehensive Analytics Dashboard - **Retention Rate Tracking**: Monitor your recall success rate over time with daily, weekly (7-day moving average), monthly (30-day moving average), and cumulative average trend lines - **Review Heatmap**: GitHub-style heatmap showing your learning activity across days and weeks @@ -79,12 +86,14 @@ The dashboard is organized into four main sections: 3. **Review Statistics Section** - Retention rate card with success percentage - Total reviews count + - Time spent card with total time and daily average - Button distribution breakdown (Forgot vs. Remembered) - Interactive charts: - Buttons Pressed (with percentages) - Cards Grouped by Reviews - Cumulative Reviews Over Time - Retention Rate Over Time (with smoothing options) + - Time Spent Reviewing (daily time in minutes with summary statistics) 4. **Outlook Section** - Select forecast period (Week, Month, Year) @@ -108,10 +117,12 @@ The dashboard is organized into four main sections: ### For Students - **Track Study Consistency**: Use the heatmap to maintain daily learning streaks +- **Monitor Study Time**: See exactly how much time you spend reviewing with detailed time statistics - **Identify Weak Areas**: Use the Hardest Flashcards section to find and improve problematic cards - **Plan Study Sessions**: Forecast view helps you anticipate heavy review days and the time of day chart helps you schedule reviews when you're most productive - **Target Problem Cards**: Click directly on difficult cards to review and improve their content - **Optimize Study Schedule**: Review the time of day chart to discover when you have the best retention and schedule important reviews accordingly +- **Track Efficiency**: Monitor your cards per minute rate to improve review speed over time ### For Power Users - **Optimize Spaced Repetition**: Monitor retention rate trends to adjust your learning strategy @@ -121,6 +132,25 @@ The dashboard is organized into four main sections: ## 📋 Changelog +### Version 1.6.0 (Current) +**New Features:** +- ⏱️ **Time Spent Reviewing Statistics**: Comprehensive time tracking for flashcard reviews + - New "Time Spent" metric card in Review Statistics section showing total review time + - **Time Spent Reviewing Chart**: Anki-style bar chart displaying daily time spent in minutes + - **Summary Statistics Panel**: View total time, average per day, average per study day, and average per card + - **Productivity Metrics**: Cards per minute rate and percentage of days studied + - **Smart Time Formatting**: Displays time in hours, minutes, or seconds depending on magnitude + - Uses `responseTime` data from RemNote's repetition history + - Gracefully handles missing time data with appropriate empty state messages + +**Technical Improvements:** +- Added `getTimeSpentPerDay()` function for daily time aggregation +- Added `calculateTimeStatsSummary()` for comprehensive time statistics +- Added `formatTime()` utility with multiple format options +- Enhanced TypeScript interfaces with `DailyTimeStats` and `TimeStatsSummary` +- Updated grid layout to accommodate 5 metric cards in Review Statistics section + + ### Version 1.5.0 **New Features:** - ✨ Added Queue Toolbar Button for quick access during flashcard reviews From f65c53e5cffcee8e6807452e3a4285d5357336a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 11:46:19 +0000 Subject: [PATCH 6/6] Update time chart styling and add cumulative time series Co-authored-by: Justtolook <22642772+Justtolook@users.noreply.github.com> --- src/widgets/statistics.tsx | 128 +++++++++++++++++++++++++++++-------- 1 file changed, 100 insertions(+), 28 deletions(-) diff --git a/src/widgets/statistics.tsx b/src/widgets/statistics.tsx index 7b22a97..6cfefe7 100644 --- a/src/widgets/statistics.tsx +++ b/src/widgets/statistics.tsx @@ -1590,16 +1590,42 @@ function chart_time_spent( y: Math.round((day.timeMs / 1000 / 60) * 10) / 10 // Convert to minutes, round to 1 decimal })); + // Prepare cumulative time data (in hours) + let cumulativeTimeMs = 0; + const cumulativeData = timeData.map(day => { + cumulativeTimeMs += day.timeMs; + return { + x: day.date, + y: Math.round((cumulativeTimeMs / 1000 / 60 / 60) * 100) / 100 // Convert to hours, round to 2 decimals + }; + }); + const options = { ...getCommonChartOptions(title, 'datetime'), dataLabels: { enabled: false }, + stroke: { + width: [0, 2], // 0 for bars, 2 for area line + curve: 'smooth' as const + }, + fill: { + type: ['solid', 'gradient'], + gradient: { + shade: 'light', + type: 'vertical', + shadeIntensity: 0.25, + inverseColors: false, + opacityFrom: 0.5, + opacityTo: 0.1, + stops: [0, 100] + } + }, plotOptions: { bar: { borderRadius: 2, columnWidth: '85%' } }, - colors: ['#f59e0b'], // amber color for time + colors: ['#f59e0b', '#3362f0'], // amber for daily time, blue for cumulative xaxis: { ...getCommonChartOptions(title, 'datetime').xaxis, type: 'datetime' as const, @@ -1608,60 +1634,95 @@ function chart_time_spent( style: { colors: 'var(--rn-clr-content-primary)' } } }, - yaxis: { - decimalsInFloat: 1, - labels: { - style: { colors: 'var(--rn-clr-content-primary)' }, - formatter: function(val: number) { - return val.toFixed(1) + 'm'; + yaxis: [ + { + // Left axis for daily time (minutes) + seriesName: 'Daily Time', + decimalsInFloat: 1, + labels: { + style: { colors: 'var(--rn-clr-content-primary)' }, + formatter: function(val: number) { + return val.toFixed(1) + 'm'; + } + }, + title: { + text: 'Time (minutes)', + style: { color: 'var(--rn-clr-content-primary)' } } }, - title: { - text: 'Time (minutes)', - style: { color: 'var(--rn-clr-content-primary)' } + { + // Right axis for cumulative time (hours) + seriesName: 'Cumulative Time', + opposite: true, + decimalsInFloat: 1, + labels: { + style: { colors: 'var(--rn-clr-content-primary)' }, + formatter: function(val: number) { + return val.toFixed(1) + 'h'; + } + }, + title: { + text: 'Cumulative Time (hours)', + style: { color: 'var(--rn-clr-content-primary)' } + } } - }, + ], tooltip: { theme: 'light' as const, x: { format: 'dd MMM yyyy' }, y: { formatter: function(val: number, opts: any) { - const dayIndex = opts.dataPointIndex; - const reviews = timeData[dayIndex]?.reviewCount || 0; - return `${val.toFixed(1)} minutes (${reviews} reviews)`; + const seriesIndex = opts.seriesIndex; + if (seriesIndex === 0) { + // Daily time bar + const dayIndex = opts.dataPointIndex; + const reviews = timeData[dayIndex]?.reviewCount || 0; + return `${val.toFixed(1)} minutes (${reviews} reviews)`; + } else { + // Cumulative time area + return `${val.toFixed(2)} hours total`; + } } } + }, + legend: { + show: true, + position: 'top' as const, + horizontalAlign: 'center' as const, + labels: { + colors: 'var(--rn-clr-content-primary)' + } } }; return
{/* Summary Statistics */} -
-
+
-
Total Time
-
+
Total Time
+
{formatTime(summary.totalTimeMs, 'hours')}
-
Avg per Day
-
+
Avg per Day
+
{formatTime(summary.averageTimePerDay)}
-
Avg per Study Day
-
+
Avg per Study Day
+
{formatTime(summary.averageTimePerReviewDay)}
-
Avg per Card
-
+
Avg per Card
+
{formatTime(summary.averageTimePerCard, 'seconds')} ({summary.cardsPerMinute.toFixed(1)}/min) @@ -1669,7 +1730,7 @@ function chart_time_spent(
-
+
Days studied: {summary.daysWithReviews} of {summary.totalDaysInPeriod} ({summary.percentageDaysStudied.toFixed(1)}%)
@@ -1677,10 +1738,21 @@ function chart_time_spent( {/* Chart */}
; }