feat: minor fixes before update

This commit is contained in:
2026-07-24 09:32:00 +09:30
parent f926127687
commit b5347e2732
16 changed files with 454 additions and 81 deletions
+42 -6
View File
@@ -561,16 +561,32 @@ ul.magicDelete > li.deleting {
display: flex !important; display: flex !important;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
width: calc(100% - 12px) !important;
max-width: none !important;
box-sizing: border-box !important;
padding: 0 !important;
} }
#menu.bsplus-sidebar-edit-mode .item.draggable > label { #menu.bsplus-sidebar-edit-mode .item.draggable > label:not(.toggle) {
flex: 1; flex: 1 1 0 !important;
min-width: 0; width: 0 !important;
min-width: 0 !important;
max-width: none !important;
} }
#menu.bsplus-sidebar-edit-mode .item.draggable .label {
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
overflow-wrap: normal !important;
}
#menu.bsplus-sidebar-edit-mode .item.draggable > .toggle,
#menu.bsplus-sidebar-edit-mode .onoffswitch { #menu.bsplus-sidebar-edit-mode .onoffswitch {
pointer-events: auto !important; pointer-events: auto !important;
flex-shrink: 0; flex: 0 0 auto !important;
width: auto !important;
margin: 0 10px 0 0 !important;
position: relative; position: relative;
z-index: 2; z-index: 2;
} }
@@ -655,6 +671,17 @@ html.bsplus-custom-sidebar-pending #menu > .icon-cover,
height: 100%; height: 100%;
z-index: 2; z-index: 2;
position: relative; position: relative;
padding-top: 0 !important;
/*
* Extend the logo spacer for the same gap as between items.
* SEQTA's #menu>ul::before border-bottom is the hairline under the logo.
*/
&::before {
box-shadow: none !important;
border-bottom: none !important;
height: calc(69.5px + 8px) !important;
}
} }
li.hasChildren { li.hasChildren {
@@ -667,7 +694,7 @@ html.bsplus-custom-sidebar-pending #menu > .icon-cover,
pointer-events: auto !important; pointer-events: auto !important;
width: auto !important; width: auto !important;
max-width: none !important; max-width: none !important;
margin: 2px 6px !important; margin: 0 6px 8px !important;
box-sizing: border-box !important; box-sizing: border-box !important;
} }
@@ -1044,6 +1071,12 @@ ol:has([class*="MessageList__avatar___"] svg) {
-webkit-box-shadow: 0px 5px 16px 6px rgba(0, 0, 0, 0.2) !important; -webkit-box-shadow: 0px 5px 16px 6px rgba(0, 0, 0, 0.2) !important;
box-shadow: 0px 0px 4px 2px rgba(0, 0, 0, 0.2) !important; box-shadow: 0px 0px 4px 2px rgba(0, 0, 0, 0.2) !important;
} }
/* Custom sidebar: keep title depth to the right, not a hairline under the logo into the menu. */
body:has(#menu.bsplus-custom-sidebar) #container #content #title,
body:has(#menu.bsplus-custom-sidebar).menuShown #container #content #title {
-webkit-box-shadow: 6px 0 8px -4px rgba(0, 0, 0, 0.25) !important;
box-shadow: 6px 0 8px -4px rgba(0, 0, 0, 0.25) !important;
}
#main .timetablepage .quickbar { #main .timetablepage .quickbar {
border: none; border: none;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
@@ -1279,9 +1312,12 @@ html.transparencyEffects
.assessmentsWrapper .message { .assessmentsWrapper .message {
display: none; display: none;
} }
#menu li:hover { #menu li:hover:not(.active) {
background: rgba(0, 0, 0, 0.15) !important; background: rgba(0, 0, 0, 0.15) !important;
} }
#menu li.active:hover {
background: rgba(0, 0, 0, 0.35) !important;
}
#menu li:focus-visible, #menu li:focus-visible,
#menu section:focus-visible { #menu section:focus-visible {
@@ -63,9 +63,12 @@
{#if activeTab === index} {#if activeTab === index}
<div <div
role="tabpanel" role="tabpanel"
class="focus:outline-none w-full h-full min-h-0 pt-2 overflow-y-auto no-scrollbar pb-6 tab active" class="focus:outline-none w-full h-full min-h-0 pt-2 overflow-y-auto no-scrollbar pb-6 tab active relative"
> >
<div class="sticky top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div> <div
class="sticky top-0 w-full h-3 -mb-3 bg-gradient-to-b from-white/80 dark:from-zinc-800/80 to-transparent pointer-events-none z-[1]"
aria-hidden="true"
></div>
<Content {...props} /> <Content {...props} />
</div> </div>
{/if} {/if}
@@ -327,12 +327,6 @@
if (menuEl) syncCalendarSyncTheme(menuEl); if (menuEl) syncCalendarSyncTheme(menuEl);
} }
$effect(() => {
const host = rootEl?.closest(".timetable-calendar-controls");
host?.classList.toggle("bsplus-cal-menu-open", menuOpen);
return () => host?.classList.remove("bsplus-cal-menu-open");
});
$effect(() => { $effect(() => {
if (menuOpen && menuEl) syncHostTheme(); if (menuOpen && menuEl) syncHostTheme();
}); });
@@ -496,7 +490,7 @@
> >
<div class="bsplus-cal-menu-header"> <div class="bsplus-cal-menu-header">
<span class="bsplus-cal-menu-title">Calendar sync</span> <span class="bsplus-cal-menu-title">Calendar sync</span>
<span class="bsplus-cal-menu-sub">Copy your SEQTA timetable classes to Google or Outlook</span> <span class="bsplus-cal-menu-sub">Copy your SEQTA classes and appointments to Google or Outlook</span>
</div> </div>
{@render providerPanel( {@render providerPanel(
@@ -697,10 +691,14 @@
min-width: auto; min-width: auto;
height: auto; height: auto;
padding: 0 10px; padding: 0 10px;
margin-left: 4px; margin: 0;
border-radius: 16px !important; border-radius: 16px !important;
font-family: inherit; font-family: inherit;
transition: transform 0.2s ease, opacity 0.2s ease; background: var(--bsplus-cal-surface, var(--background-primary, #232323)) !important;
transition:
transform 0.2s ease,
opacity 0.2s ease,
background-color 0.2s ease;
overflow: hidden; overflow: hidden;
isolation: isolate; isolation: isolate;
} }
@@ -751,8 +749,13 @@
transform: scale(0.97); transform: scale(0.97);
} }
.bsplus-cal-trigger--open { .bsplus-cal-trigger--open,
background: color-mix(in srgb, var(--bsplus-cal-accent, var(--better-main, #3b82f6)) 14%, transparent) !important; .bsplus-cal-trigger--open:hover,
.bsplus-cal-trigger--open:active {
background: var(
--bsplus-cal-surface-muted,
color-mix(in srgb, var(--bsplus-cal-text, #fff) 12%, var(--bsplus-cal-surface, #232323))
) !important;
} }
.bsplus-cal-trigger--busy { .bsplus-cal-trigger--busy {
@@ -829,7 +832,7 @@
.bsplus-cal-menu { .bsplus-cal-menu {
position: fixed; position: fixed;
z-index: var(--bsplus-cal-z-menu, 2147483646); z-index: var(--bsplus-cal-z-menu, 1200);
width: min(320px, calc(100vw - 24px)); width: min(320px, calc(100vw - 24px));
padding: 10px; padding: 10px;
border-radius: 14px; border-radius: 14px;
@@ -951,7 +954,7 @@
.bsplus-cal-modal-backdrop { .bsplus-cal-modal-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: var(--bsplus-cal-z-modal, 2147483647); z-index: var(--bsplus-cal-z-modal, 1300);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1027,7 +1030,7 @@
position: fixed; position: fixed;
right: 16px; right: 16px;
bottom: 16px; bottom: 16px;
z-index: 100000; z-index: var(--bsplus-cal-z-modal, 1300);
max-width: min(360px, calc(100vw - 32px)); max-width: min(360px, calc(100vw - 32px));
padding: 12px 14px; padding: 12px 14px;
border-radius: 12px; border-radius: 12px;
@@ -1,28 +1,16 @@
.timetablepage #toolbar {
position: relative;
z-index: 100;
}
.timetable-calendar-controls { .timetable-calendar-controls {
position: relative; position: relative;
z-index: 100001;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} vertical-align: middle;
.timetable-calendar-controls.bsplus-cal-menu-open {
z-index: 2147483645;
}
.timetablepage #toolbar:has(.bsplus-cal-menu-open) {
z-index: 2147483645 !important;
} }
:root { :root {
--bsplus-cal-z-menu: 2147483646; --bsplus-cal-z-menu: 1200;
--bsplus-cal-z-modal: 2147483647; --bsplus-cal-z-modal: 1300;
} }
.bsplus-calendar-sync-mount { .bsplus-calendar-sync-mount {
display: inline-flex; display: inline-flex;
align-items: center;
} }
@@ -155,8 +155,18 @@ export async function mountGoogleCalendarButton(): Promise<void> {
registerCalendarContentHandlers(); registerCalendarContentHandlers();
const controls = document.createElement("div"); const controls = document.createElement("div");
controls.className = `${CONTROLS_CLASS} bsplus-timetable-control`; controls.className = CONTROLS_CLASS;
// Sit between the date selector and zoom controls (same spacing as neighbours).
const zoomControls = toolbar.querySelector(".timetable-zoom-controls");
const dateButton = toolbar.querySelector(".buttonCalendar, button.calendar");
if (zoomControls) {
toolbar.insertBefore(controls, zoomControls);
} else if (dateButton?.nextSibling) {
toolbar.insertBefore(controls, dateButton.nextSibling);
} else {
toolbar.appendChild(controls); toolbar.appendChild(controls);
}
const mountRoot = document.createElement("div"); const mountRoot = document.createElement("div");
mountRoot.className = "bsplus-calendar-sync-mount"; mountRoot.className = "bsplus-calendar-sync-mount";
+24 -1
View File
@@ -530,7 +530,30 @@ function setupSidebarAccessibility() {
if (!menu) return; if (!menu) return;
sidebarAccessibilityObserver?.disconnect(); sidebarAccessibilityObserver?.disconnect();
sidebarAccessibilityObserver = new MutationObserver(() => { sidebarAccessibilityObserver = new MutationObserver((mutations) => {
// Custom Svelte sidebar owns drill a11y — ignore its DOM (opening Goals/Folios
// mutates a lot; re-running here used to help freeze the tab).
if (menu.classList.contains("bsplus-custom-sidebar")) {
const root = document.getElementById("bsplus-sidebar-root");
if (
root &&
mutations.every(
(m) => root === m.target || root.contains(m.target as Node),
)
) {
return;
}
// Still ignore class/style-only native noise while custom sidebar is on.
if (
mutations.every(
(m) =>
m.type === "attributes" &&
(m.attributeName === "class" || m.attributeName === "style"),
)
) {
return;
}
}
scheduleSidebarAccessibilityUpdate(); scheduleSidebarAccessibilityUpdate();
}); });
sidebarAccessibilityObserver.observe(menu, { sidebarAccessibilityObserver.observe(menu, {
+30 -6
View File
@@ -97,6 +97,20 @@
void sidebarState.isDrilling; void sidebarState.isDrilling;
restoreCustomMenuActive(); restoreCustomMenuActive();
}); });
// Drill `.sub` is position:absolute inside this scrollport — if the list was
// scrolled down (e.g. Folios/Goals near the bottom), the panel sits under the
// logo until we reset. Also reset when going back up the stack.
$effect(() => {
void sidebarState.drillStack.length;
void sidebarState.enterFrameKey;
const root = document.getElementById("bsplus-sidebar-root");
if (!root) return;
root.scrollTop = 0;
requestAnimationFrame(() => {
root.scrollTop = 0;
});
});
</script> </script>
<!-- <!--
@@ -212,10 +226,15 @@
role="button" role="button"
tabindex="0" tabindex="0"
aria-label="Back" aria-label="Back"
onclick={() => sidebarState.goBack()} onclick={(e) => {
e.preventDefault();
e.stopPropagation();
sidebarState.goBack();
}}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
sidebarState.goBack(); sidebarState.goBack();
} }
}} }}
@@ -303,22 +322,27 @@
.bsplus-sidebar-edit-actions { .bsplus-sidebar-edit-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
width: 85%; width: calc(100% - 12px);
margin: 12px auto 16px; margin: 12px 6px 16px;
padding: 0; padding: 0;
list-style: none; list-style: none;
cursor: default; cursor: default;
box-sizing: border-box;
} }
.edit-btn { .edit-btn {
flex: 1; flex: 1 1 0;
padding: 10px 12px; min-width: 0;
padding: 10px 8px;
border: none; border: none;
border-radius: 10px; border-radius: 10px;
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.2);
color: inherit; color: inherit;
font: inherit; font-family: inherit;
font-size: 13px;
font-weight: 700; font-weight: 700;
line-height: 1.2;
white-space: nowrap;
cursor: pointer; cursor: pointer;
transition: transition:
background-color 0.2s ease, background-color 0.2s ease,
+36 -10
View File
@@ -56,13 +56,18 @@
ondragstart={() => onDragStart?.(item.key)} ondragstart={() => onDragStart?.(item.key)}
ondragover={(e) => e.preventDefault()} ondragover={(e) => e.preventDefault()}
ondrop={() => onDrop?.(item.key)} ondrop={() => onDrop?.(item.key)}
onclick={() => { onclick={(e) => {
// Keep SEQTA's #menu handlers from seeing custom-list clicks — that fights
// our drill UI and can freeze the tab (Goals / Folios / etc.).
e.preventDefault();
e.stopPropagation();
if (!editMode) onActivate(item); if (!editMode) onActivate(item);
}} }}
onkeydown={(e) => { onkeydown={(e) => {
if (editMode) return; if (editMode) return;
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
onActivate(item); onActivate(item);
} }
}} }}
@@ -118,10 +123,11 @@
user-select: none; user-select: none;
} }
.bsplus-sidebar-item > label { .bsplus-sidebar-item > label:not(.toggle) {
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%; flex: 1 1 auto;
width: auto;
min-width: 0; min-width: 0;
margin: 0; margin: 0;
padding: 12px; padding: 12px;
@@ -129,7 +135,7 @@
color: inherit; color: inherit;
} }
.bsplus-sidebar-item:hover { .bsplus-sidebar-item:hover:not(.active) {
background: rgba(0, 0, 0, 0.15); background: rgba(0, 0, 0, 0.15);
} }
@@ -138,7 +144,8 @@
box-shadow: 0 0 0 2px var(--theme-primary, #fff); box-shadow: 0 0 0 2px var(--theme-primary, #fff);
} }
.bsplus-sidebar-item.active:not(.hasChildren) { .bsplus-sidebar-item.active:not(.hasChildren),
.bsplus-sidebar-item.active:not(.hasChildren):hover {
background: rgba(0, 0, 0, 0.35); background: rgba(0, 0, 0, 0.35);
color: #fff; color: #fff;
} }
@@ -147,7 +154,7 @@
justify-content: center; justify-content: center;
} }
.bsplus-sidebar-item.compact > label { .bsplus-sidebar-item.compact > label:not(.toggle) {
padding: 8px; padding: 8px;
justify-content: center; justify-content: center;
} }
@@ -158,6 +165,18 @@
.bsplus-sidebar-item.edit-mode { .bsplus-sidebar-item.edit-mode {
cursor: grab; cursor: grab;
width: 100%;
max-width: 100%;
gap: 4px;
padding-right: 4px;
box-sizing: border-box;
}
.bsplus-sidebar-item.edit-mode > label:not(.toggle) {
flex: 1 1 0;
width: 0;
min-width: 0;
padding-right: 4px;
} }
.bsplus-sidebar-item :global(label > svg) { .bsplus-sidebar-item :global(label > svg) {
@@ -171,8 +190,9 @@
.label { .label {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
white-space: normal; white-space: nowrap;
overflow-wrap: anywhere; overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2; line-height: 1.2;
} }
@@ -194,17 +214,23 @@
} }
.toggle { .toggle {
margin-right: 12px; margin: 0 10px 0 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex: 0 0 auto;
width: auto;
padding: 0; padding: 0;
position: relative;
z-index: 2;
} }
.toggle input { .toggle input {
width: 18px; width: 18px;
height: 18px; height: 18px;
margin: 0;
flex-shrink: 0;
accent-color: var(--theme-primary, #fff); accent-color: var(--theme-primary, #fff);
} }
+56 -4
View File
@@ -21,6 +21,7 @@ let menuEl: HTMLElement | null = null;
let menuObserver: MutationObserver | null = null; let menuObserver: MutationObserver | null = null;
let syncTimer: ReturnType<typeof setTimeout> | null = null; let syncTimer: ReturnType<typeof setTimeout> | null = null;
let hashListenerAttached = false; let hashListenerAttached = false;
let sidebarCaptureAttached = false;
let earlyPrepareStarted = false; let earlyPrepareStarted = false;
let catchupTimer: ReturnType<typeof setInterval> | null = null; let catchupTimer: ReturnType<typeof setInterval> | null = null;
let nativeMenuListenerAttached = false; let nativeMenuListenerAttached = false;
@@ -56,6 +57,44 @@ function scheduleSync() {
}, 50); }, 50);
} }
/**
* Capture-phase: own all clicks inside the custom list so SEQTA's #menu handlers
* never see them. Opening Goals/Folios via SEQTA + our drill UI freezes the tab.
*/
function onCustomSidebarCaptureClick(event: MouseEvent) {
if (!menuEl || sidebarState.editMode) return;
const root = document.getElementById(ROOT_ID);
const target = event.target;
if (!(target instanceof Element) || !root?.contains(target)) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
const back = target.closest(".back");
if (back instanceof HTMLElement && root.contains(back)) {
sidebarState.goBack();
return;
}
const li = target.closest("li.item[data-key]");
if (!(li instanceof HTMLElement) || !root.contains(li)) return;
// Already-open folder chrome (renders its own `.sub`) — ignore; use Back.
if (
li.classList.contains("hasChildren") &&
li.querySelector(":scope > .sub")
) {
return;
}
const key = li.dataset.key;
if (!key) return;
const item = sidebarState.findByKey(key);
if (item) sidebarState.activateItem(item, menuEl);
}
function onHashChange() { function onHashChange() {
sidebarState.syncActiveFromLocation(); sidebarState.syncActiveFromLocation();
if (menuEl) clearNativeDrillActive(menuEl); if (menuEl) clearNativeDrillActive(menuEl);
@@ -189,11 +228,14 @@ export async function mountCustomSidebar(): Promise<boolean> {
menuObserver?.disconnect(); menuObserver?.disconnect();
menuObserver = new MutationObserver((mutations) => { menuObserver = new MutationObserver((mutations) => {
const ours = document.getElementById(ROOT_ID); const ours = document.getElementById(ROOT_ID);
// Ignore our list entirely. Also ignore native `class` toggles — SEQTA // Ignore our list entirely. Ignore native class/style churn — SEQTA and
// re-adds drill `.active` after we clear it; syncing on that freezes the tab. // theme transitions rewrite those constantly; syncing on them freezes the tab.
const relevant = mutations.some((m) => { const relevant = mutations.some((m) => {
if (ours?.contains(m.target as Node)) return false; if (ours?.contains(m.target as Node)) return false;
if (m.type === "attributes" && m.attributeName === "class") return false; if (m.type === "attributes") {
const attr = m.attributeName;
if (attr === "class" || attr === "style") return false;
}
return true; return true;
}); });
if (!relevant) return; if (!relevant) return;
@@ -203,9 +245,14 @@ export async function mountCustomSidebar(): Promise<boolean> {
subtree: true, subtree: true,
childList: true, childList: true,
attributes: true, attributes: true,
attributeFilter: ["style", "data-key", "data-path", "data-colour"], attributeFilter: ["data-key", "data-path", "data-colour"],
}); });
if (!sidebarCaptureAttached) {
document.addEventListener("click", onCustomSidebarCaptureClick, true);
sidebarCaptureAttached = true;
}
if (!hashListenerAttached) { if (!hashListenerAttached) {
window.addEventListener("hashchange", onHashChange); window.addEventListener("hashchange", onHashChange);
hashListenerAttached = true; hashListenerAttached = true;
@@ -242,6 +289,11 @@ export function unmountCustomSidebar() {
hashListenerAttached = false; hashListenerAttached = false;
} }
if (sidebarCaptureAttached) {
document.removeEventListener("click", onCustomSidebarCaptureClick, true);
sidebarCaptureAttached = false;
}
if (nativeMenuListenerAttached) { if (nativeMenuListenerAttached) {
window.removeEventListener("bsplus-native-menu-updated", syncFromMenu); window.removeEventListener("bsplus-native-menu-updated", syncFromMenu);
nativeMenuListenerAttached = false; nativeMenuListenerAttached = false;
+31 -8
View File
@@ -43,6 +43,15 @@ function ensureActive(el: Element | null | undefined) {
} }
} }
function resetSidebarScroll() {
const root = document.getElementById("bsplus-sidebar-root");
if (!(root instanceof HTMLElement)) return;
root.scrollTop = 0;
requestAnimationFrame(() => {
root.scrollTop = 0;
});
}
/** /**
* SEQTA (and some themes) strip `.active` from `#menu li` after navigation. * SEQTA (and some themes) strip `.active` from `#menu li` after navigation.
* Theme decorations and drill `.sub` chrome depend on that class on our list. * Theme decorations and drill `.sub` chrome depend on that class on our list.
@@ -247,17 +256,29 @@ class SidebarState {
} }
} }
openFolder(item: SidebarItem) { openFolder(item: SidebarItem, menu?: HTMLElement) {
if (!item.hasChildren) return; if (!item.hasChildren) return;
this.enterFrameKey = item.key; // Ignore duplicate opens (double-firing click / label + li).
this.drillStack = [ if (this.drillStack.at(-1)?.key === item.key) return;
...this.drillStack,
{ const frame: SidebarDrillFrame = {
key: item.key, key: item.key,
label: item.label, label: item.label,
items: filterVisible(item.children), items: filterVisible(item.children),
}, };
]; const isRoot = this.visibleRootItems.some((entry) => entry.key === item.key);
this.enterFrameKey = item.key;
// Root folders replace the stack; nested folders append.
this.drillStack = isRoot ? [frame] : [...this.drillStack, frame];
// Keep native drill closed so SEQTA CSS :has(> ul > li.hasChildren.active)
// does not lock pointer-events on the custom list.
if (menu) clearNativeDrillActive(menu);
// Absolute `.sub` panels live inside the scrollport — jump to top so the
// drilled page isn't left under the logo when the list was scrolled down.
resetSidebarScroll();
} }
clearEnterFrame(key?: string) { clearEnterFrame(key?: string) {
@@ -270,11 +291,13 @@ class SidebarState {
if (!this.drillStack.length) return; if (!this.drillStack.length) return;
this.enterFrameKey = null; this.enterFrameKey = null;
this.drillStack = this.drillStack.slice(0, -1); this.drillStack = this.drillStack.slice(0, -1);
resetSidebarScroll();
} }
resetDrill() { resetDrill() {
this.enterFrameKey = null; this.enterFrameKey = null;
this.drillStack = []; this.drillStack = [];
resetSidebarScroll();
} }
setEditMode(enabled: boolean) { setEditMode(enabled: boolean) {
@@ -320,7 +343,7 @@ class SidebarState {
if (this.editMode) return; if (this.editMode) return;
if (item.hasChildren) { if (item.hasChildren) {
this.openFolder(item); this.openFolder(item, menu);
return; return;
} }
+1 -1
View File
@@ -8,6 +8,7 @@ export const WHATS_NEW_CHANGELOG: WhatsNewRelease[] = [
"title": "3.7.3 Timetable sync to Calendar & Bugfix Bundle", "title": "3.7.3 Timetable sync to Calendar & Bugfix Bundle",
"items": [ "items": [
"Added an option in the Timetable to sync to Google Calendar and Outlook Calendar", "Added an option in the Timetable to sync to Google Calendar and Outlook Calendar",
"Improved the sidebar to be more stable and performant.",
"Fixed dropdown contrast and readability in settings and across SEQTA pages.", "Fixed dropdown contrast and readability in settings and across SEQTA pages.",
"Fixed Analytics sidebar item not hiding when toggled off in Edit Sidebar.", "Fixed Analytics sidebar item not hiding when toggled off in Edit Sidebar.",
"Fixed timetable subject colour picker not reopening after closing (#221).", "Fixed timetable subject colour picker not reopening after closing (#221).",
@@ -17,7 +18,6 @@ export const WHATS_NEW_CHANGELOG: WhatsNewRelease[] = [
"Fixed assessment overview showing <code>Undefined%</code> for letter grades (#430).", "Fixed assessment overview showing <code>Undefined%</code> for letter grades (#430).",
"Fixed notifications older than a year being removed; added local per-account archive (#443).", "Fixed notifications older than a year being removed; added local per-account archive (#443).",
"Fixed notices on the home screen sometimes failing to load (#388).", "Fixed notices on the home screen sometimes failing to load (#388).",
"Fixed nightly/CI release builds (Windows runners, zip packaging, layerchart compile).",
"Fixed multi-target builds exiting early and masking Vite errors.", "Fixed multi-target builds exiting early and masking Vite errors.",
"Improved background music autoplay with a tap-to-start hint when blocked.", "Improved background music autoplay with a tap-to-start hint when blocked.",
"Added verbose logging toggle under Developer settings.", "Added verbose logging toggle under Developer settings.",
@@ -61,6 +61,31 @@ describe("lessonToGoogleEvent", () => {
); );
expect(event?.colorId).toBe("11"); expect(event?.colorId).toBe("11");
}); });
it("maps appointments with notes", () => {
const event = lessonToGoogleEvent(
ORIGIN,
{
date: "2026-07-20",
from: "09:30",
until: "13:10",
description: "Advisor meeting",
type: "appointment",
calendarid: "event:5",
colour: "#ffc107",
notes: "Bring forms",
},
"Australia/Perth",
);
expect(event).toMatchObject({
summary: "Advisor meeting",
startDateTime: "2026-07-20T09:30:00",
endDateTime: "2026-07-20T13:10:00",
seqtaKey: `${ORIGIN}:cal:event:5`,
});
expect(event?.description).toContain("Type: Appointment");
expect(event?.description).toContain("Bring forms");
});
}); });
describe("googleApiEventBody", () => { describe("googleApiEventBody", () => {
@@ -50,10 +50,18 @@ export function lessonToGoogleEvent(
const staff = lesson.staff?.trim(); const staff = lesson.staff?.trim();
const room = lesson.room?.trim(); const room = lesson.room?.trim();
const isAppointment = (lesson.type ?? "").toLowerCase() === "appointment";
const notes = lesson.notes?.trim();
const descriptionLines = ["Synced by BetterSEQTA+"]; const descriptionLines = ["Synced by BetterSEQTA+"];
if (isAppointment) {
descriptionLines.push("Type: Appointment");
if (notes) descriptionLines.push(`Notes: ${notes}`);
} else {
if (staff) descriptionLines.push(`Teacher: ${staff}`); if (staff) descriptionLines.push(`Teacher: ${staff}`);
if (lesson.code) descriptionLines.push(`Code: ${lesson.code}`); if (lesson.code) descriptionLines.push(`Code: ${lesson.code}`);
if (lesson.period) descriptionLines.push(`Period: ${lesson.period}`); if (lesson.period) descriptionLines.push(`Period: ${lesson.period}`);
if (notes) descriptionLines.push(`Notes: ${notes}`);
}
const colorId = nearestGoogleEventColorId(lesson.colour); const colorId = nearestGoogleEventColorId(lesson.colour);
@@ -0,0 +1,54 @@
import { describe, expect, it } from "@jest/globals";
import { appointmentToLesson, parseSeqtaDateTime } from "./fetchTimetable";
describe("parseSeqtaDateTime", () => {
it("parses SEQTA event timestamps", () => {
expect(parseSeqtaDateTime("2026-07-20 09:30:00.0")).toEqual({
date: "2026-07-20",
time: "09:30",
});
});
it("rejects empty values", () => {
expect(parseSeqtaDateTime(undefined)).toBeNull();
expect(parseSeqtaDateTime("")).toBeNull();
});
});
describe("appointmentToLesson", () => {
it("maps appointment payload rows into timetable lessons", () => {
expect(
appointmentToLesson({
id: 5,
from: "2026-07-20 09:30:00.0",
until: "2026-07-20 13:10:00.0",
event: {
id: 5,
title: "fsdfsdsdfdsfdsf",
notes: "sdfsfddfsdsfdsfdfssdcfdsfsdfdsfds",
colour: "#ffc107",
event_type: "appointment",
},
}),
).toEqual({
date: "2026-07-20",
from: "09:30",
until: "13:10",
description: "fsdfsdsdfdsfdsf",
type: "appointment",
calendarid: "event:5",
colour: "#ffc107",
notes: "sdfsfddfsdsfdsfdfssdcfdsfsdfdsfds",
});
});
it("skips incomplete appointment rows", () => {
expect(
appointmentToLesson({
from: "2026-07-20 09:30:00.0",
until: "2026-07-20 13:10:00.0",
event: { title: "" },
}),
).toBeNull();
});
});
@@ -69,19 +69,42 @@ function withSubjectColours(
}); });
} }
export async function resolveStudentId(): Promise<number | undefined> { type SeqtaLoginPayload = {
id?: number;
student?: number;
type?: string;
};
let cachedLogin: SeqtaLoginPayload | null = null;
async function resolveLoginPayload(): Promise<SeqtaLoginPayload | undefined> {
if (cachedLogin?.id != null) return cachedLogin;
try { try {
const json = await postSeqtaJson<{ payload?: { id?: number; student?: number } }>( const json = await postSeqtaJson<{ payload?: SeqtaLoginPayload }>(
"/seqta/student/login", "/seqta/student/login",
{ mode: "normal", query: null, redirect_url: location.origin }, { mode: "normal", query: null, redirect_url: location.href },
); );
const id = json?.payload?.id ?? json?.payload?.student; const payload = json?.payload;
return typeof id === "number" && Number.isFinite(id) ? id : undefined; if (!payload) return undefined;
cachedLogin = payload;
return payload;
} catch { } catch {
return undefined; return undefined;
} }
} }
export async function resolveStudentId(): Promise<number | undefined> {
const payload = await resolveLoginPayload();
const id = payload?.id ?? payload?.student;
return typeof id === "number" && Number.isFinite(id) ? id : undefined;
}
async function resolvePersonType(): Promise<string> {
const payload = await resolveLoginPayload();
const type = payload?.type?.trim().toLowerCase();
return type || "student";
}
function isEngageParentContext(): boolean { function isEngageParentContext(): boolean {
return ( return (
location.pathname.includes("/parent/") || location.pathname.includes("/parent/") ||
@@ -90,11 +113,83 @@ function isEngageParentContext(): boolean {
); );
} }
type SeqtaAppointmentPayload = {
id?: number;
from?: string;
until?: string;
event?: {
id?: number;
title?: string;
notes?: string;
colour?: string;
event_type?: string;
};
};
/** Parse SEQTA datetimes like `2026-07-20 09:30:00.0` into lesson date/time fields. */
export function parseSeqtaDateTime(value: string | undefined): { date: string; time: string } | null {
if (!value) return null;
const match = value.trim().match(/^(\d{4}-\d{2}-\d{2})[ T](\d{1,2}:\d{2})/);
if (!match) return null;
return { date: match[1], time: match[2] };
}
export function appointmentToLesson(item: SeqtaAppointmentPayload): SeqtaTimetableLesson | null {
const start = parseSeqtaDateTime(item.from);
const end = parseSeqtaDateTime(item.until);
const title = item.event?.title?.trim();
const eventId = item.event?.id ?? item.id;
if (!start || !end || !title || eventId == null) return null;
const notes = item.event?.notes?.trim();
return {
date: start.date,
from: start.time,
until: end.time,
description: title,
type: item.event?.event_type?.trim() || "appointment",
calendarid: `event:${eventId}`,
colour: item.event?.colour?.trim() || undefined,
...(notes ? { notes } : {}),
};
}
export async function fetchAppointments(range: SyncDateRange): Promise<SeqtaTimetableLesson[]> {
if (isEngageParentContext()) return [];
try {
const person = await resolveStudentId();
if (person == null) return [];
const personType = await resolvePersonType();
const data = await postSeqtaJson<{ payload?: SeqtaAppointmentPayload[] }>(
"/seqta/student/events/load",
{
dateFrom: range.from,
dateTo: range.until,
person,
personType,
},
);
const payload = Array.isArray(data?.payload) ? data.payload : [];
const lessons: SeqtaTimetableLesson[] = [];
for (const item of payload) {
const lesson = appointmentToLesson(item);
if (lesson) lessons.push(lesson);
}
return lessons;
} catch {
return [];
}
}
export async function fetchTimetableLessons( export async function fetchTimetableLessons(
range: SyncDateRange, range: SyncDateRange,
): Promise<SeqtaTimetableLesson[]> { ): Promise<SeqtaTimetableLesson[]> {
const { from, until } = range; const { from, until } = range;
const coloursPromise = fetchSubjectColours(); const coloursPromise = fetchSubjectColours();
const appointmentsPromise = fetchAppointments(range);
let lessons: SeqtaTimetableLesson[] = []; let lessons: SeqtaTimetableLesson[] = [];
@@ -125,7 +220,9 @@ export async function fetchTimetableLessons(
lessons = Array.isArray(data?.payload?.items) ? data.payload.items : []; lessons = Array.isArray(data?.payload?.items) ? data.payload.items : [];
} }
return withSubjectColours(lessons, await coloursPromise); const coloured = withSubjectColours(lessons, await coloursPromise);
const appointments = await appointmentsPromise;
return [...coloured, ...appointments];
} }
export async function fetchTimetableForSync(weeksAhead?: number): Promise<SeqtaTimetableLesson[]> { export async function fetchTimetableForSync(weeksAhead?: number): Promise<SeqtaTimetableLesson[]> {
+1
View File
@@ -8,6 +8,7 @@ export interface SeqtaTimetableLesson {
code?: string; code?: string;
type?: string; type?: string;
period?: string; period?: string;
notes?: string;
calendarid?: string | number; calendarid?: string | number;
ci?: number; ci?: number;
/** SEQTA subject colour (hex/rgb) used for Google Calendar event colour. */ /** SEQTA subject colour (hex/rgb) used for Google Calendar event colour. */