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 @@
-
-
-
-
-
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",
+ };
+}