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
+1 -1
View File
@@ -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", () => {
+11 -3
View File
@@ -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[]> {
+1
View File
@@ -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. */