-
- Grade trends
-- {#if showSubjectTrends} - Overall and per-subject averages · {getTimeRangeLabel(timeRange)} - {:else} - Average grades over time · {getTimeRangeLabel(timeRange)} - {/if} -
-
-
-
+ | null = null;
+ let contentReady = $state(false);
const formattedTimestamp = $derived(() => {
if (!lastUpdated) return "";
@@ -170,6 +171,9 @@
analyticsData = [];
} finally {
loading = false;
+ requestAnimationFrame(() => {
+ contentReady = true;
+ });
}
const ttl = getCacheTtlMs(24);
@@ -235,7 +239,7 @@
{/if}
- {#if loading}
+ {#if loading || !contentReady}
@@ -261,146 +265,165 @@
+
+
-
-
-
+ 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}
+
-
-
-
-
diff --git a/src/plugins/built-in/gradeAnalytics/GradeAnalyticsPage.svelte b/src/plugins/built-in/gradeAnalytics/GradeAnalyticsPage.svelte
index daabf943..9d5590ee 100644
--- a/src/plugins/built-in/gradeAnalytics/GradeAnalyticsPage.svelte
+++ b/src/plugins/built-in/gradeAnalytics/GradeAnalyticsPage.svelte
@@ -35,6 +35,7 @@
let showSubjectTrends = $state(false);
let timestampInterval: ReturnType+ 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}
-
-
- Time period
-
+ Clear filters
+
+ {/if}
-
+
-
- {#if showTimeRangeDropdown}
-
-
- {#each TIME_RANGE_OPTIONS as option (option.value)}
- {@const selected = timeRange === option.value}
-
-
- Grade range
-
-
+
+ Search
+
+
-
- Search
-
-
+ {#if hasActiveFilters()}
+
+ Grade range
+
+
+
+
+
{#key filteredData().length + "-" + gradeRange.join(",") + filterSearch + filterSubjects.join("|") + timeRange + String(showSubjectTrends)}
-
-
+
+
-
+
+
-
+
-
+
{/key}
+
+
+
@@ -411,15 +434,6 @@
({gradedFiltered().length} with grades)
{/if}
- {#if hasActiveFilters()}
-
- Clear filters
-
- {/if}
{:else}
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 @@
+
+
+
+
+
+
diff --git a/src/plugins/built-in/gradeAnalytics/chart/chart-container.svelte b/src/plugins/built-in/gradeAnalytics/chart/chart-container.svelte
index 25bff61c..f2e79efa 100644
--- a/src/plugins/built-in/gradeAnalytics/chart/chart-container.svelte
+++ b/src/plugins/built-in/gradeAnalytics/chart/chart-container.svelte
@@ -25,10 +25,32 @@
return config;
},
});
+
+ function observeChartResize(node: HTMLElement) {
+ let frame = 0;
+ const notify = () => {
+ cancelAnimationFrame(frame);
+ frame = requestAnimationFrame(() => {
+ window.dispatchEvent(new Event("resize"));
+ });
+ };
+
+ const observer = new ResizeObserver(notify);
+ observer.observe(node);
+ notify();
+
+ return {
+ destroy() {
+ cancelAnimationFrame(frame);
+ observer.disconnect();
+ },
+ };
+ }
+
+
+ {value} month{value === 1 ? "" : "s"}
+
+
+
+
+
+
+ :global(.bsplus-analytics-animate) {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ width: 100%;
+ min-width: 0;
+}
+
+.bsplus-analytics-chart-cell :global(.bsplus-analytics-card) {
+ flex: 1;
+ width: 100%;
+ min-width: 0;
+}
+
/* Fade-in animation must not paint above the filter toolbar / dropdown */
.bsplus-analytics-charts > .bsplus-analytics-animate {
z-index: 1;
@@ -618,7 +688,7 @@
@media (min-width: 960px) {
.bsplus-analytics-charts {
- grid-template-columns: 1fr 1fr;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.5rem;
}
@@ -660,8 +730,9 @@
.bsplus-analytics-card-header {
display: flex;
justify-content: space-between;
- align-items: flex-start;
+ align-items: flex-end;
gap: 1rem;
+ min-height: 4.75rem;
padding: 1.15rem 1.25rem;
border-bottom: 1px solid var(--bsplus-analytics-border);
}
@@ -674,7 +745,34 @@
display: flex;
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 {
@@ -728,6 +826,8 @@
flex: 1;
display: flex;
flex-direction: column;
+ width: 100%;
+ min-width: 0;
background: var(--bsplus-analytics-surface);
}
@@ -777,18 +877,28 @@
/* ─── Layerchart / SVG (fix default black rects in dark UI) ─── */
.bsplus-chart-host {
- display: flex;
- justify-content: center;
+ display: block;
width: 100%;
+ min-width: 0;
overflow: visible;
color: var(--bsplus-analytics-muted);
}
-.bsplus-analytics-root .bsplus-chart-surface,
+.bsplus-analytics-root .bsplus-chart-surface {
+ width: 100%;
+ min-width: 0;
+ height: var(--bsplus-analytics-chart-height, 280px);
+ min-height: var(--bsplus-analytics-chart-height, 280px);
+ max-height: var(--bsplus-analytics-chart-height, 280px);
+ flex-shrink: 0;
+}
+
.bsplus-analytics-root .bsplus-chart-surface-bar {
- height: var(--bsplus-analytics-chart-height);
- min-height: var(--bsplus-analytics-chart-height);
- max-height: var(--bsplus-analytics-chart-height);
+ width: 100%;
+ min-width: 0;
+ height: 320px;
+ min-height: 320px;
+ max-height: 320px;
flex-shrink: 0;
}
@@ -826,6 +936,9 @@
}
.bsplus-analytics-root [data-slot="chart"] svg {
+ display: block;
+ width: 100% !important;
+ max-width: 100%;
background: transparent !important;
overflow: visible;
}
@@ -1003,10 +1116,9 @@
border-radius: 999px;
font-weight: 700;
font-size: 0.75rem;
- background: color-mix(
- in srgb,
- var(--bsplus-analytics-accent) 14%,
- transparent
+ background: var(
+ --bsplus-analytics-accent-subtle,
+ color-mix(in srgb, var(--bsplus-analytics-accent) 14%, transparent)
);
color: var(--bsplus-analytics-accent);
}
@@ -1037,9 +1149,11 @@
display: flex;
flex-wrap: wrap;
align-items: center;
- justify-content: flex-end;
+ justify-content: flex-start;
gap: 0.75rem;
padding-bottom: 0.5rem;
+ color: var(--bsplus-analytics-muted);
+ font-size: 0.8125rem;
}
/* ─── States ─── */
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 de0245ea..58c20d4b 100644
--- a/src/plugins/built-in/gradeAnalytics/ui.ts
+++ b/src/plugins/built-in/gradeAnalytics/ui.ts
@@ -3,6 +3,7 @@ import pluginStyles from "./styles.css?inline";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { mount, unmount } from "svelte";
import GradeAnalyticsPage from "./GradeAnalyticsPage.svelte";
+import { buildContrastAccentPalette } from "./utils/accentColor";
type ThemeSettingKey =
| "selectedColor"
@@ -115,8 +116,21 @@ function syncThemeFromPage(target: HTMLElement) {
}
const accent = resolvePageAccentColor();
- target.style.setProperty("--bsplus-analytics-accent", accent);
- target.style.setProperty("--better-main", accent);
+ const surface =
+ target.style.getPropertyValue("--background-primary").trim() ||
+ computed.getPropertyValue("--background-primary").trim() ||
+ (target.classList.contains("dark") ? "#1e293b" : "#ffffff");
+ const palette = buildContrastAccentPalette(accent, surface);
+
+ 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);
target.classList.toggle(
"dark",
diff --git a/src/plugins/built-in/gradeAnalytics/utils/accentColor.ts b/src/plugins/built-in/gradeAnalytics/utils/accentColor.ts
new file mode 100644
index 00000000..606b2042
--- /dev/null
+++ b/src/plugins/built-in/gradeAnalytics/utils/accentColor.ts
@@ -0,0 +1,80 @@
+import Color from "color";
+
+export type ContrastAccentPalette = {
+ accent: string;
+ accentSubtle: string;
+ onAccent: string;
+};
+
+type ColorInstance = ReturnType;
+
+const MIN_CONTRAST_LIGHT = 4.5;
+const MIN_CONTRAST_DARK = 3;
+
+function contrastRatio(foreground: ColorInstance, background: ColorInstance): number {
+ const fg = foreground.luminosity();
+ const bg = background.luminosity();
+ const lighter = Math.max(fg, bg);
+ const darker = Math.min(fg, bg);
+ return (lighter + 0.05) / (darker + 0.05);
+}
+
+function adjustLightnessForContrast(
+ hue: number,
+ saturation: number,
+ lightness: number,
+ background: ColorInstance,
+ isDark: boolean,
+): ColorInstance {
+ const minContrast = isDark ? MIN_CONTRAST_DARK : MIN_CONTRAST_LIGHT;
+ let candidate = Color.hsl(hue, saturation, lightness);
+
+ for (let i = 0; i < 16; i++) {
+ if (contrastRatio(candidate, background) >= minContrast) {
+ return candidate;
+ }
+ const { l } = candidate.hsl().object();
+ candidate = Color.hsl(
+ hue,
+ saturation,
+ isDark ? Math.min(l + 5, 82) : Math.max(l - 5, 18),
+ );
+ }
+
+ return candidate;
+}
+
+/**
+ * Keep the user's hue/saturation but pick lightness so accent text and fills
+ * stay readable against the analytics surface background.
+ */
+export function buildContrastAccentPalette(
+ accentRaw: string,
+ backgroundRaw: string,
+): ContrastAccentPalette {
+ const accent = Color(accentRaw);
+ const background = Color(backgroundRaw);
+ const isDark = background.isDark();
+
+ const { h, s } = accent.hsl().object();
+ const saturation = Math.min(Math.max(s, 42), 88);
+ const baseLightness = isDark ? 64 : 40;
+
+ const foreground = adjustLightnessForContrast(
+ h,
+ saturation,
+ baseLightness,
+ background,
+ isDark,
+ );
+
+ const accentHex = foreground.hex();
+ const subtleLightness = isDark ? 28 : 94;
+ const subtle = Color.hsl(h, saturation * 0.75, subtleLightness);
+
+ return {
+ accent: accentHex,
+ accentSubtle: subtle.alpha(isDark ? 0.22 : 0.14).rgb().string(),
+ onAccent: foreground.isLight() ? "#141414" : "#ffffff",
+ };
+}
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",
+ };
+}
diff --git a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts
index 780236e8..41d2f54f 100644
--- a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts
+++ b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts
@@ -42,7 +42,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
const text = stringToHTML(/* html */ `
- Added Enhanced Navigation for courses: the navigator now auto-scrolls to the selected lesson (e.g. inside the "Go to…" popup) and prev/next arrows for jumping between lessons.
Added Grade Analytics, new sidebar page with grade trend charts synced from SEQTA.
Added Grade distribution auto-detects your school’s letter scale from released marks for analytics page.
@@ -50,11 +50,16 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
Added shortcuts to SEQTA Engage home page.
Added assessments overview and assessment weighting overrides for SEQTA Engage.
Added BetterSEQTA sidebar icons to SEQTA Engage.
- Added runtime handlers for upcoming interactive theme.
+ Added more capabilities for an upcoming interactive theme.
+ Added font picker in settings.
+ Added rubric copy on assessment detail pages.
+ Added manual weight entry when an assessment weight is N/A.
Added an automatic reindex of assessments if any of a series of tracked values change (title, release state, etc). Helps keep weightings up to date.
Fixed BetterSEQTA sidebar injection issues on some pages.
Tweak Theme of the Month popup making it more clear about dismissals and respecting “Don’t show again”.
Fixed duplicate-result fixes.
+ Improved assessments overview with a better look.
+ Fixed assement overview showing old subjects.
Added Theme of the Month — a monthly featured theme popup with a link to view it in the theme store.