diff --git a/src/plugins/built-in/gradeAnalytics/AnalyticsAreaChart.svelte b/src/plugins/built-in/gradeAnalytics/AnalyticsAreaChart.svelte index 441205d2..833f35af 100644 --- a/src/plugins/built-in/gradeAnalytics/AnalyticsAreaChart.svelte +++ b/src/plugins/built-in/gradeAnalytics/AnalyticsAreaChart.svelte @@ -8,6 +8,7 @@ import { buildGradeTrendChart, getTimeRangeLabel, + type CustomTimeRange, type TimeRange, type TrendPoint, } from "./timeRange"; @@ -17,10 +18,11 @@ interface Props { data: Assessment[]; timeRange: TimeRange; + customTimeRange?: CustomTimeRange; showSubjectTrends?: boolean; } - let { data, timeRange, showSubjectTrends = false }: Props = $props(); + let { data, timeRange, customTimeRange, showSubjectTrends = false }: Props = $props(); let showPrediction = $state(false); let predictionMonths = $state(3); @@ -30,9 +32,12 @@ const chartResult = $derived.by(() => buildGradeTrendChart(data, timeRange, { showPerSubject: showSubjectTrends, + custom: customTimeRange, }), ); + const timeRangeText = $derived(() => getTimeRangeLabel(timeRange, customTimeRange)); + const historicalData = $derived(chartResult.points); const chartSeries = $derived(chartResult.series); const accentColor = $derived(chartResult.accentColor); @@ -205,9 +210,9 @@

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

@@ -357,7 +362,7 @@ {/if}
- {historicalData.length} data points · {getTimeRangeLabel(timeRange)} + {historicalData.length} data points · {timeRangeText()} {#if showSubjectTrends && chartSeries.length > 1} · {chartSeries.length - 1} subject{chartSeries.length - 1 === 1 ? "" : "s"} {/if} diff --git a/src/plugins/built-in/gradeAnalytics/AnalyticsBarChart.svelte b/src/plugins/built-in/gradeAnalytics/AnalyticsBarChart.svelte index 07ecb505..2bdbed52 100644 --- a/src/plugins/built-in/gradeAnalytics/AnalyticsBarChart.svelte +++ b/src/plugins/built-in/gradeAnalytics/AnalyticsBarChart.svelte @@ -14,7 +14,7 @@ import type { Assessment } from "./types"; - import { getTimeRangeLabel, type TimeRange } from "./timeRange"; + import { getTimeRangeLabel, type CustomTimeRange, type TimeRange } from "./timeRange"; import { @@ -36,11 +36,15 @@ timeRange: TimeRange; + customTimeRange?: CustomTimeRange; + } - let { data, timeRange }: Props = $props(); + let { data, timeRange, customTimeRange }: Props = $props(); + + const timeRangeText = $derived(() => getTimeRangeLabel(timeRange, customTimeRange)); @@ -152,11 +156,11 @@ if (d.modeUsed === "letter") { - return `Assessments per letter grade · ${getTimeRangeLabel(timeRange)}`; + return `Assessments per letter grade · ${timeRangeText()}`; } - return `Assessments per grade band · ${getTimeRangeLabel(timeRange)}`; + return `Assessments per grade band · ${timeRangeText()}`; }); @@ -360,7 +364,7 @@ No graded assessments - for {getTimeRangeLabel(timeRange).toLowerCase()} + for {timeRangeText().toLowerCase()} diff --git a/src/plugins/built-in/gradeAnalytics/GradeAnalyticsPage.svelte b/src/plugins/built-in/gradeAnalytics/GradeAnalyticsPage.svelte index b15857b6..3d265080 100644 --- a/src/plugins/built-in/gradeAnalytics/GradeAnalyticsPage.svelte +++ b/src/plugins/built-in/gradeAnalytics/GradeAnalyticsPage.svelte @@ -12,9 +12,11 @@ import AssessmentTable from "./AssessmentTable.svelte"; import GradeRangeSlider from "./GradeRangeSlider.svelte"; import { + defaultCustomTimeRange, filterAssessmentsByTimeRange, getTimeRangeLabel, TIME_RANGE_OPTIONS, + type CustomTimeRange, type TimeRange, } from "./timeRange"; import { openAnalyticsPrivacyPopup } from "./openAnalyticsPrivacyPopup"; @@ -32,6 +34,7 @@ let showSubjectsDropdown = $state(false); let showTimeRangeDropdown = $state(false); let timeRange: TimeRange = $state("all"); + let customTimeRange: CustomTimeRange = $state(defaultCustomTimeRange()); let showSubjectTrends = $state(false); let timestampInterval: ReturnType | null = null; @@ -68,7 +71,7 @@ }); const timeScopedData = $derived(() => - filterAssessmentsByTimeRange(filteredData(), timeRange), + filterAssessmentsByTimeRange(filteredData(), timeRange, customTimeRange), ); const gradedFiltered = $derived(() => @@ -138,7 +141,7 @@ } } - const timeRangeLabel = $derived(() => getTimeRangeLabel(timeRange)); + const timeRangeLabel = $derived(() => getTimeRangeLabel(timeRange, customTimeRange)); function closeToolbarDropdowns() { showSubjectsDropdown = false; @@ -155,6 +158,7 @@ function selectTimeRange(value: TimeRange) { timeRange = value; + if (value === "custom") customTimeRange = defaultCustomTimeRange(); showTimeRangeDropdown = false; } @@ -199,8 +203,14 @@ />
-
-
+ {#if error} + + {/if} + + {#snippet sidebarTitle()} +

Analytics {#if syncing} @@ -210,11 +220,14 @@ {/if}

- {#if lastUpdated && analyticsData && analyticsData.length > 0} +
+ {/snippet} + + {#snippet sidebarActions()} +
+ {#if lastUpdated}

Last updated: {formattedTimestamp()}

{/if} -
-
-
- - {#if error} - - {/if} + {/snippet} {#if loading || !contentReady} -
-
+
+ +
+
+
+
+
{:else if analyticsData && analyticsData.length > 0}
+ {#if timeRange === "custom"} + + + {/if}
@@ -378,6 +411,8 @@ Per-subject trends
+ + {@render sidebarActions()}
@@ -404,11 +439,12 @@
- +
@@ -426,20 +462,20 @@ {:else} -
-

No analytics data yet

-

- Data syncs when you visit this page. Assessments with released marks will - appear here with trends and grade breakdowns. -

- +
+ +
+
+

No analytics data yet

+

+ Data syncs when you visit this page. Assessments with released marks will + appear here with trends and grade breakdowns. +

+
+
{/if}
diff --git a/src/plugins/built-in/gradeAnalytics/styles.css b/src/plugins/built-in/gradeAnalytics/styles.css index c065e53f..9a61f34f 100644 --- a/src/plugins/built-in/gradeAnalytics/styles.css +++ b/src/plugins/built-in/gradeAnalytics/styles.css @@ -130,21 +130,24 @@ animation-delay: 400ms; } -/* ─── Header ─── */ -.bsplus-analytics-header { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - align-items: flex-start; - gap: 0.75rem; +/* ─── Sidebar page title ─── */ +.bsplus-analytics-sidebar-head { + margin: 0 0 0.35rem; } -.bsplus-analytics-header-actions { - display: flex; - flex-direction: row; - align-items: center; - gap: 0.5rem; - flex-shrink: 0; +.bsplus-analytics-sidebar-head h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.2; + color: var(--bsplus-analytics-text); +} + +.bsplus-analytics-sidebar-head .bsplus-analytics-badge { + display: block; + width: fit-content; + margin: 0.35rem 0 0; } .bsplus-analytics-btn-privacy { @@ -164,26 +167,11 @@ box-shadow: var(--bsplus-theme-btn-privacy-hover-shadow, none); } -.bsplus-analytics-header-text h1 { - margin: 0 0 0.2rem; - font-size: 1.875rem; - font-weight: 700; - letter-spacing: -0.02em; - line-height: 1.2; - color: var(--bsplus-analytics-text); -} - -.bsplus-analytics-header-text p { - margin: 0; - color: var(--bsplus-analytics-muted); - font-size: 0.9375rem; - line-height: 1.5; -} - .bsplus-analytics-meta { - margin-top: 0.15rem; + margin: 0; font-size: 0.75rem; color: var(--bsplus-analytics-muted); + line-height: 1.4; } .bsplus-analytics-badge { @@ -371,6 +359,23 @@ position: relative; } +.bsplus-analytics-sidebar-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.5rem; + padding-top: 0.75rem; + border-top: 1px solid var(--bsplus-analytics-border); +} + +.bsplus-analytics-sidebar-actions .bsplus-analytics-btn { + width: 100%; +} + +.bsplus-analytics-sidebar-actions .bsplus-analytics-meta { + padding-bottom: 0.15rem; +} + .bsplus-analytics-filter-group .bsplus-analytics-field-label { font-size: 0.75rem; font-weight: 600; diff --git a/src/plugins/built-in/gradeAnalytics/timeRange.test.ts b/src/plugins/built-in/gradeAnalytics/timeRange.test.ts new file mode 100644 index 00000000..65a82318 --- /dev/null +++ b/src/plugins/built-in/gradeAnalytics/timeRange.test.ts @@ -0,0 +1,47 @@ +import { + defaultCustomTimeRange, + filterAssessmentsByTimeRange, + getTimeRangeBounds, + getTimeRangeLabel, +} from "./timeRange"; +import type { Assessment } from "./types"; + +const assessment = (due: string): Assessment => + ({ + due, + finalGrade: 80, + subject: "Math", + }) as Assessment; + +describe("timeRange", () => { + const ref = new Date("2026-08-20T12:00:00"); + + it("uses calendar year start for this year", () => { + const { start, end } = getTimeRangeBounds("ytd", undefined, ref); + expect(start?.getFullYear()).toBe(2026); + expect(start?.getMonth()).toBe(0); + expect(start?.getDate()).toBe(1); + expect(end).toBeNull(); + }); + + it("filters custom ranges inclusively", () => { + const custom = { from: "2026-03-01", to: "2026-03-31" }; + const items = [ + assessment("2026-02-28"), + assessment("2026-03-01"), + assessment("2026-03-31T23:59:00"), + assessment("2026-04-01"), + ]; + const filtered = filterAssessmentsByTimeRange(items, "custom", custom); + expect(filtered.map((a) => a.due)).toEqual([ + "2026-03-01", + "2026-03-31T23:59:00", + ]); + }); + + it("labels custom ranges", () => { + expect( + getTimeRangeLabel("custom", defaultCustomTimeRange(ref)), + ).toMatch(/2026/); + }); +}); diff --git a/src/plugins/built-in/gradeAnalytics/timeRange.ts b/src/plugins/built-in/gradeAnalytics/timeRange.ts index aebd75e5..a5e34b2e 100644 --- a/src/plugins/built-in/gradeAnalytics/timeRange.ts +++ b/src/plugins/built-in/gradeAnalytics/timeRange.ts @@ -1,39 +1,117 @@ import type { Assessment } from "./types"; -export type TimeRange = "all" | "365d" | "90d" | "30d" | "7d"; +export type TimeRange = "all" | "ytd" | "365d" | "90d" | "30d" | "7d" | "custom"; + +export type CustomTimeRange = { from: string; to: string }; export const TIME_RANGE_OPTIONS: { value: TimeRange; label: string }[] = [ { value: "all", label: "All time" }, + { value: "ytd", label: "This year" }, { value: "365d", label: "Last 12 months" }, { value: "90d", label: "Last 3 months" }, { value: "30d", label: "Last 30 days" }, { value: "7d", label: "Last 7 days" }, + { value: "custom", label: "Custom range" }, ]; -export function getTimeRangeLabel(timeRange: TimeRange): string { +export function defaultCustomTimeRange(referenceDate = new Date()): CustomTimeRange { + return { + from: `${referenceDate.getFullYear()}-01-01`, + to: referenceDate.toISOString().slice(0, 10), + }; +} + +function parseDateOnly(iso: string): Date { + const date = new Date(`${iso}T00:00:00`); + date.setHours(0, 0, 0, 0); + return date; +} + +export function getTimeRangeBounds( + timeRange: TimeRange, + custom?: CustomTimeRange, + referenceDate = new Date(), +): { start: Date | null; end: Date | null } { + if (timeRange === "all") return { start: null, end: null }; + + if (timeRange === "ytd") { + const start = new Date(referenceDate.getFullYear(), 0, 1); + start.setHours(0, 0, 0, 0); + return { start, end: null }; + } + + if (timeRange === "custom") { + if (!custom?.from || !custom?.to) return { start: null, end: null }; + let start = parseDateOnly(custom.from); + let end = parseDateOnly(custom.to); + end.setHours(23, 59, 59, 999); + if (start > end) [start, end] = [end, start]; + return { start, end }; + } + + let days = 90; + if (timeRange === "30d") days = 30; + else if (timeRange === "7d") days = 7; + else if (timeRange === "365d") days = 365; + + const start = new Date(referenceDate); + start.setDate(start.getDate() - days); + start.setHours(0, 0, 0, 0); + return { start, end: null }; +} + +export function getTimeRangeCutoff( + timeRange: TimeRange, + custom?: CustomTimeRange, +): Date | null { + return getTimeRangeBounds(timeRange, custom).start; +} + +export function getTimeRangeLabel( + timeRange: TimeRange, + custom?: CustomTimeRange, +): string { + if (timeRange === "custom" && custom?.from && custom?.to) { + const fmt = (iso: string) => + parseDateOnly(iso).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); + return `${fmt(custom.from)} – ${fmt(custom.to)}`; + } return TIME_RANGE_OPTIONS.find((o) => o.value === timeRange)?.label ?? "All time"; } -export function getTimeRangeCutoff(timeRange: TimeRange): Date | null { - if (timeRange === "all") return null; - const referenceDate = new Date(); - let daysToSubtract = 90; - if (timeRange === "30d") daysToSubtract = 30; - else if (timeRange === "7d") daysToSubtract = 7; - else if (timeRange === "365d") daysToSubtract = 365; - const cutoff = new Date(referenceDate); - cutoff.setDate(cutoff.getDate() - daysToSubtract); - cutoff.setHours(0, 0, 0, 0); - return cutoff; +function assessmentInRange( + due: string, + bounds: { start: Date | null; end: Date | null }, +): boolean { + const date = new Date(due); + if (bounds.start && date < bounds.start) return false; + if (bounds.end && date > bounds.end) return false; + return true; } export function filterAssessmentsByTimeRange( assessments: Assessment[], timeRange: TimeRange, + custom?: CustomTimeRange, ): Assessment[] { - const cutoff = getTimeRangeCutoff(timeRange); - if (!cutoff) return assessments; - return assessments.filter((a) => new Date(a.due) >= cutoff); + const bounds = getTimeRangeBounds(timeRange, custom); + if (!bounds.start && !bounds.end) return assessments; + return assessments.filter((a) => assessmentInRange(a.due, bounds)); +} + +function usesMonthlyGrouping( + timeRange: TimeRange, + custom?: CustomTimeRange, +): boolean { + if (timeRange === "365d" || timeRange === "all" || timeRange === "ytd") return true; + if (timeRange !== "custom" || !custom?.from || !custom?.to) return false; + const { start, end } = getTimeRangeBounds(timeRange, custom); + if (!start || !end) return true; + return end.getTime() - start.getTime() > 90 * 86_400_000; } export type TrendPoint = { @@ -115,7 +193,7 @@ function slugSubjectKey(name: string, keyBySubject: Map): string export function buildGradeTrendChart( data: Assessment[], timeRange: TimeRange, - options: { showPerSubject?: boolean } = {}, + options: { showPerSubject?: boolean; custom?: CustomTimeRange } = {}, ): { points: TrendPoint[]; series: TrendSeries[]; accentColor: string } { const accentColor = "var(--bsplus-analytics-accent, var(--better-main, #007bff))"; @@ -127,8 +205,8 @@ export function buildGradeTrendChart( return { points: [], series: [], accentColor }; } - const useMonthlyGrouping = timeRange === "365d" || timeRange === "all"; - const cutoff = getTimeRangeCutoff(timeRange); + const bounds = getTimeRangeBounds(timeRange, options.custom); + const useMonthlyGrouping = usesMonthlyGrouping(timeRange, options.custom); const overallBuckets = new Map(); const subjectBuckets = new Map>(); @@ -136,10 +214,13 @@ export function buildGradeTrendChart( const keyBySubject = new Map(); for (const assessment of graded) { + if (!assessmentInRange(assessment.due, bounds)) continue; + const grade = assessment.finalGrade!; const periodKey = periodKeyForAssessment(assessment, useMonthlyGrouping); const periodDateValue = periodDate(periodKey, useMonthlyGrouping); - if (cutoff && periodDateValue < cutoff) continue; + if (bounds.start && periodDateValue < bounds.start) continue; + if (bounds.end && periodDateValue > bounds.end) continue; if (!overallBuckets.has(periodKey)) overallBuckets.set(periodKey, []); overallBuckets.get(periodKey)!.push(grade);