mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 17:21:05 +00:00
feat: minor fixes before update
This commit is contained in:
@@ -530,7 +530,30 @@ function setupSidebarAccessibility() {
|
||||
if (!menu) return;
|
||||
|
||||
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();
|
||||
});
|
||||
sidebarAccessibilityObserver.observe(menu, {
|
||||
|
||||
@@ -97,6 +97,20 @@
|
||||
void sidebarState.isDrilling;
|
||||
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>
|
||||
|
||||
<!--
|
||||
@@ -212,10 +226,15 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Back"
|
||||
onclick={() => sidebarState.goBack()}
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
sidebarState.goBack();
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
sidebarState.goBack();
|
||||
}
|
||||
}}
|
||||
@@ -303,22 +322,27 @@
|
||||
.bsplus-sidebar-edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 85%;
|
||||
margin: 12px auto 16px;
|
||||
width: calc(100% - 12px);
|
||||
margin: 12px 6px 16px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
cursor: default;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 10px 8px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
|
||||
@@ -56,13 +56,18 @@
|
||||
ondragstart={() => onDragStart?.(item.key)}
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
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);
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (editMode) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onActivate(item);
|
||||
}
|
||||
}}
|
||||
@@ -118,10 +123,11 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.bsplus-sidebar-item > label {
|
||||
.bsplus-sidebar-item > label:not(.toggle) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
@@ -129,7 +135,7 @@
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.bsplus-sidebar-item:hover {
|
||||
.bsplus-sidebar-item:hover:not(.active) {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
@@ -138,7 +144,8 @@
|
||||
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);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -147,7 +154,7 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bsplus-sidebar-item.compact > label {
|
||||
.bsplus-sidebar-item.compact > label:not(.toggle) {
|
||||
padding: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -158,6 +165,18 @@
|
||||
|
||||
.bsplus-sidebar-item.edit-mode {
|
||||
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) {
|
||||
@@ -171,8 +190,9 @@
|
||||
.label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@@ -194,17 +214,23 @@
|
||||
}
|
||||
|
||||
.toggle {
|
||||
margin-right: 12px;
|
||||
margin: 0 10px 0 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--theme-primary, #fff);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ let menuEl: HTMLElement | null = null;
|
||||
let menuObserver: MutationObserver | null = null;
|
||||
let syncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let hashListenerAttached = false;
|
||||
let sidebarCaptureAttached = false;
|
||||
let earlyPrepareStarted = false;
|
||||
let catchupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let nativeMenuListenerAttached = false;
|
||||
@@ -56,6 +57,44 @@ function scheduleSync() {
|
||||
}, 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() {
|
||||
sidebarState.syncActiveFromLocation();
|
||||
if (menuEl) clearNativeDrillActive(menuEl);
|
||||
@@ -189,11 +228,14 @@ export async function mountCustomSidebar(): Promise<boolean> {
|
||||
menuObserver?.disconnect();
|
||||
menuObserver = new MutationObserver((mutations) => {
|
||||
const ours = document.getElementById(ROOT_ID);
|
||||
// Ignore our list entirely. Also ignore native `class` toggles — SEQTA
|
||||
// re-adds drill `.active` after we clear it; syncing on that freezes the tab.
|
||||
// Ignore our list entirely. Ignore native class/style churn — SEQTA and
|
||||
// theme transitions rewrite those constantly; syncing on them freezes the tab.
|
||||
const relevant = mutations.some((m) => {
|
||||
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;
|
||||
});
|
||||
if (!relevant) return;
|
||||
@@ -203,9 +245,14 @@ export async function mountCustomSidebar(): Promise<boolean> {
|
||||
subtree: true,
|
||||
childList: 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) {
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
hashListenerAttached = true;
|
||||
@@ -242,6 +289,11 @@ export function unmountCustomSidebar() {
|
||||
hashListenerAttached = false;
|
||||
}
|
||||
|
||||
if (sidebarCaptureAttached) {
|
||||
document.removeEventListener("click", onCustomSidebarCaptureClick, true);
|
||||
sidebarCaptureAttached = false;
|
||||
}
|
||||
|
||||
if (nativeMenuListenerAttached) {
|
||||
window.removeEventListener("bsplus-native-menu-updated", syncFromMenu);
|
||||
nativeMenuListenerAttached = false;
|
||||
|
||||
@@ -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.
|
||||
* 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;
|
||||
// Ignore duplicate opens (double-firing click / label + li).
|
||||
if (this.drillStack.at(-1)?.key === item.key) return;
|
||||
|
||||
const frame: SidebarDrillFrame = {
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
items: filterVisible(item.children),
|
||||
};
|
||||
const isRoot = this.visibleRootItems.some((entry) => entry.key === item.key);
|
||||
|
||||
this.enterFrameKey = item.key;
|
||||
this.drillStack = [
|
||||
...this.drillStack,
|
||||
{
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
items: filterVisible(item.children),
|
||||
},
|
||||
];
|
||||
// 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) {
|
||||
@@ -270,11 +291,13 @@ class SidebarState {
|
||||
if (!this.drillStack.length) return;
|
||||
this.enterFrameKey = null;
|
||||
this.drillStack = this.drillStack.slice(0, -1);
|
||||
resetSidebarScroll();
|
||||
}
|
||||
|
||||
resetDrill() {
|
||||
this.enterFrameKey = null;
|
||||
this.drillStack = [];
|
||||
resetSidebarScroll();
|
||||
}
|
||||
|
||||
setEditMode(enabled: boolean) {
|
||||
@@ -320,7 +343,7 @@ class SidebarState {
|
||||
if (this.editMode) return;
|
||||
|
||||
if (item.hasChildren) {
|
||||
this.openFolder(item);
|
||||
this.openFolder(item, menu);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export const WHATS_NEW_CHANGELOG: WhatsNewRelease[] = [
|
||||
"title": "3.7.3 – Timetable sync to Calendar & Bugfix Bundle",
|
||||
"items": [
|
||||
"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 Analytics sidebar item not hiding when toggled off in Edit Sidebar.",
|
||||
"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 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 nightly/CI release builds (Windows runners, zip packaging, layerchart compile).",
|
||||
"Fixed multi-target builds exiting early and masking Vite errors.",
|
||||
"Improved background music autoplay with a tap-to-start hint when blocked.",
|
||||
"Added verbose logging toggle under Developer settings.",
|
||||
|
||||
@@ -61,6 +61,31 @@ describe("lessonToGoogleEvent", () => {
|
||||
);
|
||||
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", () => {
|
||||
|
||||
@@ -50,10 +50,18 @@ export function lessonToGoogleEvent(
|
||||
const staff = lesson.staff?.trim();
|
||||
const room = lesson.room?.trim();
|
||||
|
||||
const isAppointment = (lesson.type ?? "").toLowerCase() === "appointment";
|
||||
const notes = lesson.notes?.trim();
|
||||
const descriptionLines = ["Synced by BetterSEQTA+"];
|
||||
if (staff) descriptionLines.push(`Teacher: ${staff}`);
|
||||
if (lesson.code) descriptionLines.push(`Code: ${lesson.code}`);
|
||||
if (lesson.period) descriptionLines.push(`Period: ${lesson.period}`);
|
||||
if (isAppointment) {
|
||||
descriptionLines.push("Type: Appointment");
|
||||
if (notes) descriptionLines.push(`Notes: ${notes}`);
|
||||
} else {
|
||||
if (staff) descriptionLines.push(`Teacher: ${staff}`);
|
||||
if (lesson.code) descriptionLines.push(`Code: ${lesson.code}`);
|
||||
if (lesson.period) descriptionLines.push(`Period: ${lesson.period}`);
|
||||
if (notes) descriptionLines.push(`Notes: ${notes}`);
|
||||
}
|
||||
|
||||
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 {
|
||||
const json = await postSeqtaJson<{ payload?: { id?: number; student?: number } }>(
|
||||
const json = await postSeqtaJson<{ payload?: SeqtaLoginPayload }>(
|
||||
"/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;
|
||||
return typeof id === "number" && Number.isFinite(id) ? id : undefined;
|
||||
const payload = json?.payload;
|
||||
if (!payload) return undefined;
|
||||
cachedLogin = payload;
|
||||
return payload;
|
||||
} catch {
|
||||
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 {
|
||||
return (
|
||||
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(
|
||||
range: SyncDateRange,
|
||||
): Promise<SeqtaTimetableLesson[]> {
|
||||
const { from, until } = range;
|
||||
const coloursPromise = fetchSubjectColours();
|
||||
const appointmentsPromise = fetchAppointments(range);
|
||||
|
||||
let lessons: SeqtaTimetableLesson[] = [];
|
||||
|
||||
@@ -125,7 +220,9 @@ export async function fetchTimetableLessons(
|
||||
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[]> {
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface SeqtaTimetableLesson {
|
||||
code?: string;
|
||||
type?: string;
|
||||
period?: string;
|
||||
notes?: string;
|
||||
calendarid?: string | number;
|
||||
ci?: number;
|
||||
/** SEQTA subject colour (hex/rgb) used for Google Calendar event colour. */
|
||||
|
||||
Reference in New Issue
Block a user