diff --git a/src/plugins/built-in/gradeAnalytics/AnalyticsAreaChart.svelte b/src/plugins/built-in/gradeAnalytics/AnalyticsAreaChart.svelte index a9f6b52d..fafe4be7 100644 --- a/src/plugins/built-in/gradeAnalytics/AnalyticsAreaChart.svelte +++ b/src/plugins/built-in/gradeAnalytics/AnalyticsAreaChart.svelte @@ -1,396 +1,335 @@ - -
-
-
-

Grade trends

-

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

-
-
- -
- - {#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} -
- - -
- 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 | 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 @@
-
- Time period -
- - {#if showTimeRangeDropdown} -
- {#each TIME_RANGE_OPTIONS as option (option.value)} - {@const selected = timeRange === option.value} - + {#if showTimeRangeDropdown} +
+ {#each TIME_RANGE_OPTIONS as option (option.value)} + {@const selected = timeRange === option.value} + - {/each} -
- {/if} -
-
- -
- Subjects -
- + {/each} +
{/if} - - {#if showSubjectsDropdown} -
-
+
+ +
+ Subjects +
+ - {#each uniqueSubjects() as subject} - {@const selected = filterSubjects.includes(subject)} + {:else if filterSubjects.length === 1} + {filterSubjects[0]} + {:else} + {filterSubjects.length} selected + {/if} + + {#if showSubjectsDropdown} +
- {/each} -
- {/if} + {#each uniqueSubjects() as subject} + {@const selected = filterSubjects.includes(subject)} + + {/each} +
+ {/if} +
-
-
- Grade range - -
+ - + {#if hasActiveFilters()} + + {/if} - +
+ 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()} - - {/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 @@ + + +
+
+ + +
+ + {value} month{value === 1 ? "" : "s"} + +
+ + 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(); + }, + }; + }
: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 */ `
-

3.7.0 – Grade Analytics, Enhanced Navigation, Global Search & SEQTA Engage Improvements

+

3.7.0 – Grade Analytics, Enhanced Navigation, fonts, Global Search & SEQTA Engage Improvements

  • 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.
  • 3.6.5 - Theme of the Month, custom message folders & assessment weighting overrides

  • Added Theme of the Month — a monthly featured theme popup with a link to view it in the theme store.