From 62a3d56bed2ea7bb0a35bd4b1563dd5d66b53656 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sat, 13 Jun 2026 21:22:50 +0930 Subject: [PATCH] feat: grade forecast --- .../gradeAnalytics/AnalyticsAreaChart.svelte | 425 ++++++++---------- .../PredictionMonthsSlider.svelte | 136 ++++++ .../built-in/gradeAnalytics/styles.css | 30 +- .../built-in/gradeAnalytics/timeRange.ts | 5 +- src/plugins/built-in/gradeAnalytics/ui.ts | 4 + .../gradeAnalytics/utils/gradePrediction.ts | 179 ++++++++ 6 files changed, 536 insertions(+), 243 deletions(-) create mode 100644 src/plugins/built-in/gradeAnalytics/PredictionMonthsSlider.svelte create mode 100644 src/plugins/built-in/gradeAnalytics/utils/gradePrediction.ts diff --git a/src/plugins/built-in/gradeAnalytics/AnalyticsAreaChart.svelte b/src/plugins/built-in/gradeAnalytics/AnalyticsAreaChart.svelte index 020220bf..fafe4be7 100644 --- a/src/plugins/built-in/gradeAnalytics/AnalyticsAreaChart.svelte +++ b/src/plugins/built-in/gradeAnalytics/AnalyticsAreaChart.svelte @@ -1,388 +1,335 @@ - -
- -
- +
-

Grade trends

-

- {#if showSubjectTrends} - Overall and per-subject averages · {getTimeRangeLabel(timeRange)} - {:else} - Average grades over time · {getTimeRangeLabel(timeRange)} - {/if} -

-
+
+ + +
+ Months ahead + +
+
- -
- - {#if filteredData().length > 0} - - - + {#if historicalData.length > 0} + {#key `${showPrediction}-${predictionMonths}`} + - v.toLocaleDateString("en-US", { - month: "short", - day: timeRange === "7d" ? "numeric" : undefined, - }), - }, - yAxis: { - format: (v: number) => `${v.toFixed(0)}%`, - }, - }} - > - {#snippet marks({ series, getAreaProps })} - - - - - - - + + - - {#each series as s, i (s.key)} - - {@const meta = chartSeries().find((c) => c.key === s.key)} - + {@const meta = chartSeries.find((c) => c.key === s.key)} {@const isOverall = meta?.isOverall ?? s.key === "average"} + {@const isForecast = s.key === "forecast"} - - + {#if !isForecast} + + {/if} {/each} - + {/snippet} + {#snippet aboveMarks()} + {#if showPrediction && forecastLineData.length > 1} + + {/if} {/snippet} {#snippet tooltip()} - - v.toLocaleDateString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - })} - indicator="line" - /> - {/snippet} - - + {/key} + {#if showPrediction && !canForecast} +

+ At least 3 graded periods are needed to generate a forecast. +

+ {/if} {:else} -
- No grade data for this range - Complete assessments with released marks to see trends. -
- {/if} -
- -
- - {#if trend().direction === "up"} - - Trending up · {trend().percentage}% vs previous period - - {:else if trend().direction === "down"} - - Trending down · {trend().percentage}% vs previous period - - {:else} - - Grades remain stable across this period - + {#if showPrediction && forecast} + + Projected average in {predictionMonths} month{predictionMonths === 1 ? "" : "s"}: + {forecast.projectedGrade}% + + · {forecast.trendPerMonth >= 0 ? "+" : ""}{forecast.trendPerMonth}%/mo trend + · R² {forecast.rSquared.toFixed(2)} + + +
{/if} + {#if trend.direction === "up"} + Trending up · {trend.percentage}% vs previous period + {:else if trend.direction === "down"} + Trending down · {trend.percentage}% vs previous period + {:else} + Grades remain stable across this period + {/if}
- - - {filteredData().length} data points · {getTimeRangeLabel(timeRange)} - - {#if showSubjectTrends && chartSeries().length > 1} - - · {chartSeries().length - 1} subject{chartSeries().length - 1 === 1 ? "" : "s"} - + {historicalData.length} data points · {getTimeRangeLabel(timeRange)} + {#if showSubjectTrends && chartSeries.length > 1} + · {chartSeries.length - 1} subject{chartSeries.length - 1 === 1 ? "" : "s"} + {/if} + {#if showPrediction && forecast} + · {forecast.methodLabel} {/if} - -
-
- diff --git a/src/plugins/built-in/gradeAnalytics/PredictionMonthsSlider.svelte b/src/plugins/built-in/gradeAnalytics/PredictionMonthsSlider.svelte new file mode 100644 index 00000000..9c9d3f2f --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/PredictionMonthsSlider.svelte @@ -0,0 +1,136 @@ + + +
+
+ + +
+ + {value} month{value === 1 ? "" : "s"} + +
+ + diff --git a/src/plugins/built-in/gradeAnalytics/styles.css b/src/plugins/built-in/gradeAnalytics/styles.css index 025bd598..5de17668 100644 --- a/src/plugins/built-in/gradeAnalytics/styles.css +++ b/src/plugins/built-in/gradeAnalytics/styles.css @@ -730,12 +730,38 @@ .bsplus-analytics-card-controls { display: flex; - flex-wrap: nowrap; + flex-wrap: wrap; align-items: flex-end; - gap: 0.75rem; + gap: 0.75rem 1rem; flex-shrink: 0; } +.bsplus-analytics-forecast-controls { + min-width: min(100%, 18rem); + max-width: 22rem; +} + +.bsplus-analytics-forecast-toggle { + width: 100%; + justify-content: flex-end; +} + +.bsplus-analytics-forecast-horizon { + flex: 1; + min-width: 11rem; +} + +.bsplus-analytics-forecast-line { + opacity: 0.9; +} + +.bsplus-analytics-root [data-slot="chart"] .bsplus-analytics-forecast-line { + stroke: var(--bsplus-analytics-forecast, var(--bsplus-analytics-accent)) !important; + stroke-dasharray: 7 5 !important; + stroke-width: 2.5px; + fill: none !important; +} + .bsplus-analytics-card-control { display: flex; flex-direction: column; diff --git a/src/plugins/built-in/gradeAnalytics/timeRange.ts b/src/plugins/built-in/gradeAnalytics/timeRange.ts index 0a2817c2..aebd75e5 100644 --- a/src/plugins/built-in/gradeAnalytics/timeRange.ts +++ b/src/plugins/built-in/gradeAnalytics/timeRange.ts @@ -38,9 +38,10 @@ export function filterAssessmentsByTimeRange( export type TrendPoint = { date: Date; - average: number; + average: number | null; count: number; - [seriesKey: string]: number | Date; + forecast?: number; + [seriesKey: string]: number | Date | null | undefined; }; export type TrendSeries = { diff --git a/src/plugins/built-in/gradeAnalytics/ui.ts b/src/plugins/built-in/gradeAnalytics/ui.ts index d2d186fd..2599a34a 100644 --- a/src/plugins/built-in/gradeAnalytics/ui.ts +++ b/src/plugins/built-in/gradeAnalytics/ui.ts @@ -105,6 +105,10 @@ function syncThemeFromPage(target: HTMLElement) { target.style.setProperty("--bsplus-analytics-accent", palette.accent); target.style.setProperty("--bsplus-analytics-accent-subtle", palette.accentSubtle); + target.style.setProperty( + "--bsplus-analytics-forecast", + `color-mix(in srgb, ${palette.accent} 72%, ${target.classList.contains("dark") ? "#f8fafc" : "#64748b"})`, + ); target.style.setProperty("--better-main", palette.accent); target.style.setProperty("--bsplus-theme-btn-primary-bg", palette.accent); target.style.setProperty("--bsplus-theme-btn-primary-color", palette.onAccent); diff --git a/src/plugins/built-in/gradeAnalytics/utils/gradePrediction.ts b/src/plugins/built-in/gradeAnalytics/utils/gradePrediction.ts new file mode 100644 index 00000000..30dc43d2 --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/utils/gradePrediction.ts @@ -0,0 +1,179 @@ +export type HistoricalGradePoint = { + date: Date; + average: number; +}; + +export type ForecastPoint = { + date: Date; + value: number; +}; + +export type GradeForecastResult = { + points: ForecastPoint[]; + projectedGrade: number; + trendPerMonth: number; + rSquared: number; + methodLabel: string; +}; + +const MIN_POINTS = 3; +const MAX_MONTHS = 12; + +function clampGrade(value: number): number { + return Math.min(100, Math.max(0, value)); +} + +function addMonths(date: Date, months: number): Date { + const next = new Date(date); + next.setMonth(next.getMonth() + months); + next.setDate(1); + next.setHours(0, 0, 0, 0); + return next; +} + +/** Holt's linear trend method (double exponential smoothing). */ +function holtLinearForecast( + values: number[], + horizon: number, + alpha = 0.38, + beta = 0.14, +): number[] { + if (values.length < 2) return []; + + let level = values[0]; + let trend = values[1] - values[0]; + + for (let i = 1; i < values.length; i++) { + const prevLevel = level; + level = alpha * values[i] + (1 - alpha) * (level + trend); + trend = beta * (level - prevLevel) + (1 - beta) * trend; + } + + return Array.from({ length: horizon }, (_, i) => level + (i + 1) * trend); +} + +/** Weighted least squares with recency bias (exponential weights). */ +function weightedLinearRegression(values: number[]): { + forecasts: number[]; + slope: number; + rSquared: number; +} { + const n = values.length; + if (n < 2) { + return { forecasts: [], slope: 0, rSquared: 0 }; + } + + const decay = 0.72; + const weights = values.map((_, i) => decay ** (n - 1 - i)); + const xs = values.map((_, i) => i); + + let sumW = 0; + let sumWX = 0; + let sumWY = 0; + let sumWXX = 0; + let sumWXY = 0; + + for (let i = 0; i < n; i++) { + const w = weights[i]; + sumW += w; + sumWX += w * xs[i]; + sumWY += w * values[i]; + sumWXX += w * xs[i] * xs[i]; + sumWXY += w * xs[i] * values[i]; + } + + const denom = sumW * sumWXX - sumWX * sumWX; + const slope = denom === 0 ? 0 : (sumW * sumWXY - sumWX * sumWY) / denom; + const intercept = (sumWY - slope * sumWX) / sumW; + + let ssRes = 0; + let ssTot = 0; + const meanY = sumWY / sumW; + + for (let i = 0; i < n; i++) { + const predicted = intercept + slope * xs[i]; + ssRes += weights[i] * (values[i] - predicted) ** 2; + ssTot += weights[i] * (values[i] - meanY) ** 2; + } + + const rSquared = ssTot === 0 ? 1 : Math.max(0, 1 - ssRes / ssTot); + const forecasts = Array.from({ length: MAX_MONTHS }, (_, i) => + intercept + slope * (n - 1 + (i + 1)), + ); + + return { forecasts, slope, rSquared }; +} + +function monthKey(date: Date): string { + return date.toISOString().slice(0, 7); +} + +/** Collapse trend points to calendar months for stable forward projections. */ +export function aggregateToMonthlyPoints( + historical: HistoricalGradePoint[], +): HistoricalGradePoint[] { + const buckets = new Map(); + + for (const point of historical) { + const key = monthKey(point.date); + if (!buckets.has(key)) buckets.set(key, []); + buckets.get(key)!.push(point.average); + } + + return [...buckets.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, values]) => ({ + date: new Date(`${key}-01`), + average: values.reduce((sum, value) => sum + value, 0) / values.length, + })); +} + +/** + * Blend Holt-Winters-style smoothing with weighted regression, then damp + * toward the recent mean so extreme projections stay realistic for grades. + */ +export function computeGradeForecast( + historical: HistoricalGradePoint[], + monthsForward: number, +): GradeForecastResult | null { + const horizon = Math.min(MAX_MONTHS, Math.max(1, Math.round(monthsForward))); + const sorted = [...historical] + .filter((p) => Number.isFinite(p.average)) + .sort((a, b) => a.date.getTime() - b.date.getTime()); + + if (sorted.length < MIN_POINTS) return null; + + const values = sorted.map((p) => p.average); + const holt = holtLinearForecast(values, horizon); + const regression = weightedLinearRegression(values); + const recentMean = values.slice(-3).reduce((sum, v) => sum + v, 0) / Math.min(3, values.length); + + const lastDate = sorted[sorted.length - 1].date; + const points: ForecastPoint[] = []; + + for (let i = 0; i < horizon; i++) { + const holtValue = holt[i] ?? regression.forecasts[i] ?? recentMean; + const regValue = regression.forecasts[i] ?? holtValue; + const blended = holtValue * 0.58 + regValue * 0.42; + const damped = blended * 0.86 + recentMean * 0.14; + + points.push({ + date: addMonths(lastDate, i + 1), + value: Math.round(clampGrade(damped) * 10) / 10, + }); + } + + const projectedGrade = points[points.length - 1]?.value ?? recentMean; + const trendPerMonth = + points.length > 1 + ? (points[points.length - 1].value - values[values.length - 1]) / points.length + : regression.slope; + + return { + points, + projectedGrade, + trendPerMonth: Math.round(trendPerMonth * 10) / 10, + rSquared: Math.round(regression.rSquared * 100) / 100, + methodLabel: "Holt linear + weighted regression", + }; +}