fix: fix analyitics page for @SethBurkart123

This commit is contained in:
2026-06-13 19:36:04 +09:30
parent 160407dde6
commit 14a322a128
5 changed files with 381 additions and 171 deletions
@@ -35,6 +35,7 @@
let showSubjectTrends = $state(false);
let timestampInterval: ReturnType<typeof setInterval> | 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 @@
</p>
{/if}
{#if loading}
{#if loading || !contentReady}
<div class="bsplus-analytics-loading bsplus-analytics-animate">
<div class="bsplus-analytics-spinner" aria-label="Loading analytics"></div>
</div>
@@ -261,6 +265,7 @@
</section>
<div class="bsplus-analytics-toolbar bsplus-analytics-animate bsplus-analytics-delay-2">
<div class="bsplus-analytics-toolbar-grid">
<div
class="bsplus-analytics-field bsplus-analytics-toolbar-dropdown-field"
data-analytics-dropdown
@@ -364,11 +369,6 @@
</div>
</div>
<div class="bsplus-analytics-field bsplus-analytics-grade-range">
<span class="bsplus-analytics-field-label">Grade range</span>
<GradeRangeSlider bind:value={gradeRange} />
</div>
<div class="bsplus-analytics-field bsplus-analytics-toolbar-search">
<span class="bsplus-analytics-field-label">Search</span>
<input
@@ -379,14 +379,34 @@
/>
</div>
<label class="bsplus-analytics-checkbox">
{#if hasActiveFilters()}
<button
type="button"
class="bsplus-analytics-btn bsplus-analytics-btn-ghost bsplus-analytics-toolbar-clear"
onclick={clearFilters}
>
Clear filters
</button>
{/if}
<div class="bsplus-analytics-field bsplus-analytics-grade-range">
<span class="bsplus-analytics-field-label">Grade range</span>
<GradeRangeSlider bind:value={gradeRange} />
</div>
<label
class="bsplus-analytics-checkbox bsplus-analytics-toolbar-trends"
class:bsplus-analytics-toolbar-trends-top={!hasActiveFilters()}
>
<input type="checkbox" bind:checked={showSubjectTrends} />
<span>Show per-subject trends on chart</span>
</label>
</div>
</div>
<div class="bsplus-analytics-charts">
{#key filteredData().length + "-" + gradeRange.join(",") + filterSearch + filterSubjects.join("|") + timeRange + String(showSubjectTrends)}
<div class="bsplus-analytics-chart-cell">
<div class="bsplus-analytics-animate bsplus-analytics-delay-3">
<AnalyticsAreaChart
data={gradedFiltered()}
@@ -394,13 +414,16 @@
showSubjectTrends={showSubjectTrends}
/>
</div>
</div>
<div class="bsplus-analytics-chart-cell">
<div class="bsplus-analytics-animate bsplus-analytics-delay-4">
<AnalyticsBarChart data={gradedFiltered()} {timeRange} />
</div>
</div>
{/key}
</div>
<div class="bsplus-analytics-animate bsplus-analytics-delay-4" style="animation-delay: 400ms;">
<div class="bsplus-analytics-animate bsplus-analytics-delay-5">
<AssessmentTable data={timeScopedData()} />
</div>
@@ -411,15 +434,6 @@
({gradedFiltered().length} with grades)
{/if}
</span>
{#if hasActiveFilters()}
<button
type="button"
class="bsplus-analytics-btn bsplus-analytics-btn-ghost"
onclick={clearFilters}
>
Clear filters
</button>
{/if}
</footer>
{:else}
<div class="bsplus-analytics-empty bsplus-analytics-animate" transition:fade={{ duration: 300 }}>
@@ -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();
},
};
}
</script>
<div
bind:this={ref}
use:observeChartResize
data-chart={chartId}
data-slot="chart"
class="bsplus-chart-host {className}"
+126 -42
View File
@@ -73,8 +73,8 @@
}
.bsplus-analytics-animate {
animation: bsplus-analytics-fade-in-up 0.55s var(--bsplus-analytics-ease)
forwards;
opacity: 0;
animation: bsplus-analytics-fade-in-up 0.55s var(--bsplus-analytics-ease) both;
will-change: opacity, transform;
}
@@ -98,6 +98,9 @@
.bsplus-analytics-delay-4 {
animation-delay: 320ms;
}
.bsplus-analytics-delay-5 {
animation-delay: 400ms;
}
/* ─── Header ─── */
.bsplus-analytics-header {
@@ -165,10 +168,9 @@
font-size: 0.7rem;
font-weight: 600;
vertical-align: middle;
background: color-mix(
in srgb,
var(--bsplus-analytics-accent) 18%,
transparent
background: var(
--bsplus-analytics-accent-subtle,
color-mix(in srgb, var(--bsplus-analytics-accent) 18%, transparent)
);
color: var(--bsplus-analytics-accent);
}
@@ -324,10 +326,6 @@
/* ─── Filter toolbar ─── */
.bsplus-analytics-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
padding: 1rem 1.15rem;
border-radius: var(
--bsplus-theme-card-radius,
@@ -346,6 +344,73 @@
isolation: isolate;
}
.bsplus-analytics-toolbar-grid {
display: grid;
grid-template-columns: minmax(9rem, 1fr) minmax(9rem, 1fr) minmax(12rem, 1.4fr) auto;
grid-template-rows: auto auto;
gap: 0.9rem 1rem;
align-items: end;
width: 100%;
}
.bsplus-analytics-toolbar-clear {
grid-column: 4;
grid-row: 1;
justify-self: end;
align-self: end;
padding: 0.65rem 0.9rem;
white-space: nowrap;
}
.bsplus-analytics-toolbar-trends {
grid-column: 4;
grid-row: 2;
justify-self: end;
align-self: center;
margin-left: 0;
max-width: 14rem;
text-align: right;
}
.bsplus-analytics-toolbar-trends-top {
grid-row: 1;
align-self: end;
}
@media (max-width: 900px) {
.bsplus-analytics-toolbar-grid {
grid-template-columns: 1fr 1fr;
}
.bsplus-analytics-toolbar-search {
grid-column: 1 / -1;
}
.bsplus-analytics-toolbar-clear {
grid-column: 1 / -1;
grid-row: auto;
justify-self: stretch;
}
.bsplus-analytics-grade-range {
grid-column: 1 / -1;
}
.bsplus-analytics-toolbar-trends {
grid-column: 1 / -1;
grid-row: auto;
justify-self: start;
max-width: none;
text-align: left;
}
}
@media (max-width: 520px) {
.bsplus-analytics-toolbar-grid {
grid-template-columns: 1fr;
}
}
.bsplus-analytics-toolbar-dropdown-field {
position: relative;
z-index: 2;
@@ -370,7 +435,6 @@
display: flex;
align-items: center;
gap: 0.55rem;
margin-left: auto;
padding: 0.35rem 0;
font-size: 0.8125rem;
font-weight: 500;
@@ -389,13 +453,6 @@
flex-shrink: 0;
}
@media (max-width: 900px) {
.bsplus-analytics-checkbox {
margin-left: 0;
width: 100%;
}
}
.bsplus-analytics-select,
.bsplus-analytics-input {
appearance: none;
@@ -456,9 +513,10 @@
}
.bsplus-analytics-grade-range {
flex: 1;
min-width: 12rem;
max-width: 20rem;
grid-column: 1 / 4;
grid-row: 2;
min-width: 0;
max-width: none;
}
.bsplus-analytics-range-value {
@@ -471,17 +529,7 @@
}
.bsplus-analytics-toolbar-search {
margin-left: auto;
flex: 1 1 14rem;
max-width: 18rem;
}
@media (max-width: 768px) {
.bsplus-analytics-toolbar-search {
margin-left: 0;
max-width: none;
width: 100%;
}
min-width: 0;
}
/* Toolbar custom dropdowns (time period, subjects) */
@@ -601,6 +649,29 @@
width: 100%;
position: relative;
z-index: 1;
align-items: stretch;
}
.bsplus-analytics-chart-cell {
display: flex;
flex-direction: column;
width: 100%;
min-height: 0;
min-width: 0;
}
.bsplus-analytics-chart-cell > :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 */
@@ -610,7 +681,7 @@
@media (min-width: 960px) {
.bsplus-analytics-charts {
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.5rem;
}
}
@@ -618,6 +689,7 @@
.bsplus-analytics-card {
display: flex;
flex-direction: column;
height: 100%;
border-radius: var(
--bsplus-theme-card-radius,
var(--bsplus-analytics-radius)
@@ -645,8 +717,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);
}
@@ -657,9 +730,10 @@
.bsplus-analytics-card-controls {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: flex-end;
gap: 0.75rem;
flex-shrink: 0;
}
.bsplus-analytics-card-control {
@@ -705,6 +779,8 @@
.bsplus-analytics-card-body {
padding: 1rem 1.15rem;
flex: 1;
width: 100%;
min-width: 0;
background: var(--bsplus-analytics-surface);
}
@@ -734,20 +810,24 @@
/* ─── 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 {
width: 100%;
min-width: 0;
height: 280px;
min-height: 280px;
max-height: 280px;
}
.bsplus-analytics-root .bsplus-chart-surface-bar {
width: 100%;
min-width: 0;
height: 320px;
min-height: 320px;
max-height: 320px;
@@ -787,6 +867,9 @@
}
.bsplus-analytics-root [data-slot="chart"] svg {
display: block;
width: 100% !important;
max-width: 100%;
background: transparent !important;
overflow: visible;
}
@@ -964,10 +1047,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);
}
@@ -998,9 +1080,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 ─── */
+12 -2
View File
@@ -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"
@@ -96,8 +97,17 @@ 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("--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",
@@ -0,0 +1,80 @@
import Color from "color";
export type ContrastAccentPalette = {
accent: string;
accentSubtle: string;
onAccent: string;
};
type ColorInstance = ReturnType<typeof Color>;
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",
};
}