feat: add this year option, custom time range and adjust layout of analyitics for #468

This commit is contained in:
2026-08-20 12:00:42 +09:30
parent 0333f24a60
commit 574de5ccbf
6 changed files with 268 additions and 90 deletions
@@ -8,6 +8,7 @@
import { import {
buildGradeTrendChart, buildGradeTrendChart,
getTimeRangeLabel, getTimeRangeLabel,
type CustomTimeRange,
type TimeRange, type TimeRange,
type TrendPoint, type TrendPoint,
} from "./timeRange"; } from "./timeRange";
@@ -17,10 +18,11 @@
interface Props { interface Props {
data: Assessment[]; data: Assessment[];
timeRange: TimeRange; timeRange: TimeRange;
customTimeRange?: CustomTimeRange;
showSubjectTrends?: boolean; showSubjectTrends?: boolean;
} }
let { data, timeRange, showSubjectTrends = false }: Props = $props(); let { data, timeRange, customTimeRange, showSubjectTrends = false }: Props = $props();
let showPrediction = $state(false); let showPrediction = $state(false);
let predictionMonths = $state(3); let predictionMonths = $state(3);
@@ -30,9 +32,12 @@
const chartResult = $derived.by(() => const chartResult = $derived.by(() =>
buildGradeTrendChart(data, timeRange, { buildGradeTrendChart(data, timeRange, {
showPerSubject: showSubjectTrends, showPerSubject: showSubjectTrends,
custom: customTimeRange,
}), }),
); );
const timeRangeText = $derived(() => getTimeRangeLabel(timeRange, customTimeRange));
const historicalData = $derived(chartResult.points); const historicalData = $derived(chartResult.points);
const chartSeries = $derived(chartResult.series); const chartSeries = $derived(chartResult.series);
const accentColor = $derived(chartResult.accentColor); const accentColor = $derived(chartResult.accentColor);
@@ -205,9 +210,9 @@
</div> </div>
<p class="bsplus-analytics-card-desc"> <p class="bsplus-analytics-card-desc">
{#if showSubjectTrends} {#if showSubjectTrends}
Overall and per-subject averages · {getTimeRangeLabel(timeRange)} Overall and per-subject averages · {timeRangeText()}
{:else} {:else}
Average grades over time · {getTimeRangeLabel(timeRange)} Average grades over time · {timeRangeText()}
{/if} {/if}
</p> </p>
</div> </div>
@@ -357,7 +362,7 @@
{/if} {/if}
<br /> <br />
<span> <span>
{historicalData.length} data points · {getTimeRangeLabel(timeRange)} {historicalData.length} data points · {timeRangeText()}
{#if showSubjectTrends && chartSeries.length > 1} {#if showSubjectTrends && chartSeries.length > 1}
· {chartSeries.length - 1} subject{chartSeries.length - 1 === 1 ? "" : "s"} · {chartSeries.length - 1} subject{chartSeries.length - 1 === 1 ? "" : "s"}
{/if} {/if}
@@ -14,7 +14,7 @@
import type { Assessment } from "./types"; import type { Assessment } from "./types";
import { getTimeRangeLabel, type TimeRange } from "./timeRange"; import { getTimeRangeLabel, type CustomTimeRange, type TimeRange } from "./timeRange";
import { import {
@@ -36,11 +36,15 @@
timeRange: TimeRange; 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") { 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 @@
<strong>No graded assessments</strong> <strong>No graded assessments</strong>
<span>for {getTimeRangeLabel(timeRange).toLowerCase()}</span> <span>for {timeRangeText().toLowerCase()}</span>
</div> </div>
@@ -12,9 +12,11 @@
import AssessmentTable from "./AssessmentTable.svelte"; import AssessmentTable from "./AssessmentTable.svelte";
import GradeRangeSlider from "./GradeRangeSlider.svelte"; import GradeRangeSlider from "./GradeRangeSlider.svelte";
import { import {
defaultCustomTimeRange,
filterAssessmentsByTimeRange, filterAssessmentsByTimeRange,
getTimeRangeLabel, getTimeRangeLabel,
TIME_RANGE_OPTIONS, TIME_RANGE_OPTIONS,
type CustomTimeRange,
type TimeRange, type TimeRange,
} from "./timeRange"; } from "./timeRange";
import { openAnalyticsPrivacyPopup } from "./openAnalyticsPrivacyPopup"; import { openAnalyticsPrivacyPopup } from "./openAnalyticsPrivacyPopup";
@@ -32,6 +34,7 @@
let showSubjectsDropdown = $state(false); let showSubjectsDropdown = $state(false);
let showTimeRangeDropdown = $state(false); let showTimeRangeDropdown = $state(false);
let timeRange: TimeRange = $state("all"); let timeRange: TimeRange = $state("all");
let customTimeRange: CustomTimeRange = $state(defaultCustomTimeRange());
let showSubjectTrends = $state(false); let showSubjectTrends = $state(false);
let timestampInterval: ReturnType<typeof setInterval> | null = null; let timestampInterval: ReturnType<typeof setInterval> | null = null;
@@ -68,7 +71,7 @@
}); });
const timeScopedData = $derived(() => const timeScopedData = $derived(() =>
filterAssessmentsByTimeRange(filteredData(), timeRange), filterAssessmentsByTimeRange(filteredData(), timeRange, customTimeRange),
); );
const gradedFiltered = $derived(() => const gradedFiltered = $derived(() =>
@@ -138,7 +141,7 @@
} }
} }
const timeRangeLabel = $derived(() => getTimeRangeLabel(timeRange)); const timeRangeLabel = $derived(() => getTimeRangeLabel(timeRange, customTimeRange));
function closeToolbarDropdowns() { function closeToolbarDropdowns() {
showSubjectsDropdown = false; showSubjectsDropdown = false;
@@ -155,6 +158,7 @@
function selectTimeRange(value: TimeRange) { function selectTimeRange(value: TimeRange) {
timeRange = value; timeRange = value;
if (value === "custom") customTimeRange = defaultCustomTimeRange();
showTimeRangeDropdown = false; showTimeRangeDropdown = false;
} }
@@ -199,8 +203,14 @@
/> />
<div class="bsplus-analytics-root"> <div class="bsplus-analytics-root">
<header class="bsplus-analytics-header bsplus-analytics-animate"> {#if error}
<div class="bsplus-analytics-header-text"> <p class="bsplus-analytics-alert bsplus-analytics-animate" role="alert" transition:fade={{ duration: 200 }}>
{error}
</p>
{/if}
{#snippet sidebarTitle()}
<header class="bsplus-analytics-sidebar-head bsplus-analytics-animate">
<h1> <h1>
Analytics Analytics
{#if syncing} {#if syncing}
@@ -210,11 +220,14 @@
</span> </span>
{/if} {/if}
</h1> </h1>
{#if lastUpdated && analyticsData && analyticsData.length > 0} </header>
{/snippet}
{#snippet sidebarActions()}
<div class="bsplus-analytics-sidebar-actions">
{#if lastUpdated}
<p class="bsplus-analytics-meta">Last updated: {formattedTimestamp()}</p> <p class="bsplus-analytics-meta">Last updated: {formattedTimestamp()}</p>
{/if} {/if}
</div>
<div class="bsplus-analytics-header-actions">
<button <button
type="button" type="button"
class="bsplus-analytics-btn bsplus-analytics-btn-privacy" class="bsplus-analytics-btn bsplus-analytics-btn-privacy"
@@ -231,21 +244,25 @@
{syncing ? "Syncing…" : "Refresh data"} {syncing ? "Syncing…" : "Refresh data"}
</button> </button>
</div> </div>
</header> {/snippet}
{#if error}
<p class="bsplus-analytics-alert bsplus-analytics-animate" role="alert" transition:fade={{ duration: 200 }}>
{error}
</p>
{/if}
{#if loading || !contentReady} {#if loading || !contentReady}
<div class="bsplus-analytics-loading bsplus-analytics-animate"> <div class="bsplus-analytics-layout bsplus-analytics-animate">
<aside class="bsplus-analytics-filters" aria-label="Analytics">
{@render sidebarTitle()}
{@render sidebarActions()}
</aside>
<div class="bsplus-analytics-main">
<div class="bsplus-analytics-loading">
<div class="bsplus-analytics-spinner" aria-label="Loading analytics"></div> <div class="bsplus-analytics-spinner" aria-label="Loading analytics"></div>
</div> </div>
</div>
</div>
{:else if analyticsData && analyticsData.length > 0} {:else if analyticsData && analyticsData.length > 0}
<div class="bsplus-analytics-layout bsplus-analytics-animate bsplus-analytics-delay-1"> <div class="bsplus-analytics-layout bsplus-analytics-animate bsplus-analytics-delay-1">
<aside class="bsplus-analytics-filters" aria-label="Filters"> <aside class="bsplus-analytics-filters" aria-label="Filters">
{@render sidebarTitle()}
<div class="bsplus-analytics-filters-head"> <div class="bsplus-analytics-filters-head">
<h2 class="bsplus-analytics-filters-title">Filters</h2> <h2 class="bsplus-analytics-filters-title">Filters</h2>
{#if hasActiveFilters()} {#if hasActiveFilters()}
@@ -297,6 +314,22 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if timeRange === "custom"}
<input
type="date"
class="bsplus-analytics-input"
bind:value={customTimeRange.from}
max={customTimeRange.to}
aria-label="Custom range start date"
/>
<input
type="date"
class="bsplus-analytics-input"
bind:value={customTimeRange.to}
min={customTimeRange.from}
aria-label="Custom range end date"
/>
{/if}
</div> </div>
<div class="bsplus-analytics-filter-group" data-analytics-dropdown> <div class="bsplus-analytics-filter-group" data-analytics-dropdown>
@@ -378,6 +411,8 @@
<span>Per-subject trends</span> <span>Per-subject trends</span>
</label> </label>
</div> </div>
{@render sidebarActions()}
</aside> </aside>
<div class="bsplus-analytics-main"> <div class="bsplus-analytics-main">
@@ -404,11 +439,12 @@
<AnalyticsAreaChart <AnalyticsAreaChart
data={gradedFiltered()} data={gradedFiltered()}
{timeRange} {timeRange}
{customTimeRange}
showSubjectTrends={showSubjectTrends} showSubjectTrends={showSubjectTrends}
/> />
</div> </div>
<div class="bsplus-analytics-chart-cell"> <div class="bsplus-analytics-chart-cell">
<AnalyticsBarChart data={gradedFiltered()} {timeRange} /> <AnalyticsBarChart data={gradedFiltered()} {timeRange} {customTimeRange} />
</div> </div>
</div> </div>
@@ -426,20 +462,20 @@
</div> </div>
</div> </div>
{:else} {:else}
<div class="bsplus-analytics-empty bsplus-analytics-animate" transition:fade={{ duration: 300 }}> <div class="bsplus-analytics-layout bsplus-analytics-animate" transition:fade={{ duration: 300 }}>
<aside class="bsplus-analytics-filters" aria-label="Analytics">
{@render sidebarTitle()}
{@render sidebarActions()}
</aside>
<div class="bsplus-analytics-main">
<div class="bsplus-analytics-empty">
<h2>No analytics data yet</h2> <h2>No analytics data yet</h2>
<p> <p>
Data syncs when you visit this page. Assessments with released marks will Data syncs when you visit this page. Assessments with released marks will
appear here with trends and grade breakdowns. appear here with trends and grade breakdowns.
</p> </p>
<button </div>
type="button" </div>
class="bsplus-analytics-btn bsplus-analytics-btn-primary"
disabled={syncing}
onclick={() => runSync()}
>
Sync now
</button>
</div> </div>
{/if} {/if}
</div> </div>
+35 -30
View File
@@ -130,21 +130,24 @@
animation-delay: 400ms; animation-delay: 400ms;
} }
/* ─── Header ─── */ /* ─── Sidebar page title ─── */
.bsplus-analytics-header { .bsplus-analytics-sidebar-head {
display: flex; margin: 0 0 0.35rem;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
} }
.bsplus-analytics-header-actions { .bsplus-analytics-sidebar-head h1 {
display: flex; margin: 0;
flex-direction: row; font-size: 1.5rem;
align-items: center; font-weight: 700;
gap: 0.5rem; letter-spacing: -0.02em;
flex-shrink: 0; 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 { .bsplus-analytics-btn-privacy {
@@ -164,26 +167,11 @@
box-shadow: var(--bsplus-theme-btn-privacy-hover-shadow, none); 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 { .bsplus-analytics-meta {
margin-top: 0.15rem; margin: 0;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--bsplus-analytics-muted); color: var(--bsplus-analytics-muted);
line-height: 1.4;
} }
.bsplus-analytics-badge { .bsplus-analytics-badge {
@@ -371,6 +359,23 @@
position: relative; 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 { .bsplus-analytics-filter-group .bsplus-analytics-field-label {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
@@ -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/);
});
});
+101 -20
View File
@@ -1,39 +1,117 @@
import type { Assessment } from "./types"; 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 }[] = [ export const TIME_RANGE_OPTIONS: { value: TimeRange; label: string }[] = [
{ value: "all", label: "All time" }, { value: "all", label: "All time" },
{ value: "ytd", label: "This year" },
{ value: "365d", label: "Last 12 months" }, { value: "365d", label: "Last 12 months" },
{ value: "90d", label: "Last 3 months" }, { value: "90d", label: "Last 3 months" },
{ value: "30d", label: "Last 30 days" }, { value: "30d", label: "Last 30 days" },
{ value: "7d", label: "Last 7 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"; return TIME_RANGE_OPTIONS.find((o) => o.value === timeRange)?.label ?? "All time";
} }
export function getTimeRangeCutoff(timeRange: TimeRange): Date | null { function assessmentInRange(
if (timeRange === "all") return null; due: string,
const referenceDate = new Date(); bounds: { start: Date | null; end: Date | null },
let daysToSubtract = 90; ): boolean {
if (timeRange === "30d") daysToSubtract = 30; const date = new Date(due);
else if (timeRange === "7d") daysToSubtract = 7; if (bounds.start && date < bounds.start) return false;
else if (timeRange === "365d") daysToSubtract = 365; if (bounds.end && date > bounds.end) return false;
const cutoff = new Date(referenceDate); return true;
cutoff.setDate(cutoff.getDate() - daysToSubtract);
cutoff.setHours(0, 0, 0, 0);
return cutoff;
} }
export function filterAssessmentsByTimeRange( export function filterAssessmentsByTimeRange(
assessments: Assessment[], assessments: Assessment[],
timeRange: TimeRange, timeRange: TimeRange,
custom?: CustomTimeRange,
): Assessment[] { ): Assessment[] {
const cutoff = getTimeRangeCutoff(timeRange); const bounds = getTimeRangeBounds(timeRange, custom);
if (!cutoff) return assessments; if (!bounds.start && !bounds.end) return assessments;
return assessments.filter((a) => new Date(a.due) >= cutoff); 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 = { export type TrendPoint = {
@@ -115,7 +193,7 @@ function slugSubjectKey(name: string, keyBySubject: Map<string, string>): string
export function buildGradeTrendChart( export function buildGradeTrendChart(
data: Assessment[], data: Assessment[],
timeRange: TimeRange, timeRange: TimeRange,
options: { showPerSubject?: boolean } = {}, options: { showPerSubject?: boolean; custom?: CustomTimeRange } = {},
): { points: TrendPoint[]; series: TrendSeries[]; accentColor: string } { ): { points: TrendPoint[]; series: TrendSeries[]; accentColor: string } {
const accentColor = const accentColor =
"var(--bsplus-analytics-accent, var(--better-main, #007bff))"; "var(--bsplus-analytics-accent, var(--better-main, #007bff))";
@@ -127,8 +205,8 @@ export function buildGradeTrendChart(
return { points: [], series: [], accentColor }; return { points: [], series: [], accentColor };
} }
const useMonthlyGrouping = timeRange === "365d" || timeRange === "all"; const bounds = getTimeRangeBounds(timeRange, options.custom);
const cutoff = getTimeRangeCutoff(timeRange); const useMonthlyGrouping = usesMonthlyGrouping(timeRange, options.custom);
const overallBuckets = new Map<string, number[]>(); const overallBuckets = new Map<string, number[]>();
const subjectBuckets = new Map<string, Map<string, number[]>>(); const subjectBuckets = new Map<string, Map<string, number[]>>();
@@ -136,10 +214,13 @@ export function buildGradeTrendChart(
const keyBySubject = new Map<string, string>(); const keyBySubject = new Map<string, string>();
for (const assessment of graded) { for (const assessment of graded) {
if (!assessmentInRange(assessment.due, bounds)) continue;
const grade = assessment.finalGrade!; const grade = assessment.finalGrade!;
const periodKey = periodKeyForAssessment(assessment, useMonthlyGrouping); const periodKey = periodKeyForAssessment(assessment, useMonthlyGrouping);
const periodDateValue = periodDate(periodKey, 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, []); if (!overallBuckets.has(periodKey)) overallBuckets.set(periodKey, []);
overallBuckets.get(periodKey)!.push(grade); overallBuckets.get(periodKey)!.push(grade);