feat: update video plus colour syncing

This commit is contained in:
2026-07-21 09:45:49 +09:30
parent e732011f1e
commit 87c320a1aa
8 changed files with 214 additions and 11 deletions
+2
View File
@@ -28,3 +28,5 @@ betterseqtaplus-safari/
.env.submit .env.submit
dependency-graph.svg dependency-graph.svg
update-videos/
Binary file not shown.
@@ -0,0 +1,22 @@
import { describe, expect, it } from "@jest/globals";
import { nearestGoogleEventColorId } from "./eventColor";
describe("nearestGoogleEventColorId", () => {
it("returns undefined for empty input", () => {
expect(nearestGoogleEventColorId(undefined)).toBeUndefined();
expect(nearestGoogleEventColorId("")).toBeUndefined();
});
it("maps a red subject colour to Tomato", () => {
expect(nearestGoogleEventColorId("#dc2127")).toBe("11");
expect(nearestGoogleEventColorId("#E76F51")).toBe("4");
});
it("maps a green subject colour to Basil or Sage", () => {
expect(["2", "10"]).toContain(nearestGoogleEventColorId("#51b749"));
});
it("accepts rgb() values", () => {
expect(nearestGoogleEventColorId("rgb(220, 33, 39)")).toBe("11");
});
});
@@ -0,0 +1,90 @@
/** Google Calendar event palette (`colorId` → background). */
export const GOOGLE_EVENT_COLORS: ReadonlyArray<{ id: string; background: string }> = [
{ id: "1", background: "#a4bdfc" },
{ id: "2", background: "#7ae7bf" },
{ id: "3", background: "#dbadff" },
{ id: "4", background: "#ff887c" },
{ id: "5", background: "#fbd75b" },
{ id: "6", background: "#ffb878" },
{ id: "7", background: "#46d6db" },
{ id: "8", background: "#e1e1e1" },
{ id: "9", background: "#5484ed" },
{ id: "10", background: "#51b749" },
{ id: "11", background: "#dc2127" },
];
type Rgb = { r: number; g: number; b: number };
function expandHex(hex: string): string | null {
const raw = hex.replace("#", "").trim();
if (/^[0-9a-fA-F]{3}$/.test(raw)) {
return raw
.split("")
.map((ch) => ch + ch)
.join("");
}
if (/^[0-9a-fA-F]{6}$/.test(raw)) return raw;
if (/^[0-9a-fA-F]{8}$/.test(raw)) return raw.slice(0, 6);
return null;
}
/** Parse common CSS colour strings to RGB (hex / rgb / rgba). */
export function parseRgbColour(value: string): Rgb | null {
const trimmed = value.trim();
if (!trimmed) return null;
const hexMatch = trimmed.match(/#([0-9a-fA-F]{3,8})\b/);
if (hexMatch) {
const expanded = expandHex(hexMatch[1]!);
if (!expanded) return null;
return {
r: Number.parseInt(expanded.slice(0, 2), 16),
g: Number.parseInt(expanded.slice(2, 4), 16),
b: Number.parseInt(expanded.slice(4, 6), 16),
};
}
const rgbMatch = trimmed.match(
/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i,
);
if (rgbMatch) {
return {
r: Math.round(Number(rgbMatch[1])),
g: Math.round(Number(rgbMatch[2])),
b: Math.round(Number(rgbMatch[3])),
};
}
return null;
}
function rgbDistance(a: Rgb, b: Rgb): number {
const dr = a.r - b.r;
const dg = a.g - b.g;
const db = a.b - b.b;
return dr * dr + dg * dg + db * db;
}
/**
* Maps a SEQTA subject colour to the nearest Google Calendar event `colorId`.
* Returns undefined when the colour cannot be parsed.
*/
export function nearestGoogleEventColorId(colour: string | undefined): string | undefined {
const target = colour ? parseRgbColour(colour) : null;
if (!target) return undefined;
let bestId: string | undefined;
let bestDistance = Number.POSITIVE_INFINITY;
for (const entry of GOOGLE_EVENT_COLORS) {
const candidate = parseRgbColour(entry.background);
if (!candidate) continue;
const distance = rgbDistance(target, candidate);
if (distance < bestDistance) {
bestDistance = distance;
bestId = entry.id;
}
}
return bestId;
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "@jest/globals"; import { describe, expect, it } from "@jest/globals";
import { import {
googleApiEventBody,
lessonToGoogleEvent, lessonToGoogleEvent,
mapLessonsToGoogleEvents, mapLessonsToGoogleEvents,
outlookGraphEventBody, outlookGraphEventBody,
@@ -51,6 +52,27 @@ describe("lessonToGoogleEvent", () => {
}); });
expect(event?.description).toContain("Mr Smith"); expect(event?.description).toContain("Mr Smith");
}); });
it("maps subject colour to a Google colorId", () => {
const event = lessonToGoogleEvent(
ORIGIN,
{ ...baseLesson, colour: "#dc2127" },
"Australia/Perth",
);
expect(event?.colorId).toBe("11");
});
});
describe("googleApiEventBody", () => {
it("includes colorId when present", () => {
const event = lessonToGoogleEvent(
ORIGIN,
{ ...baseLesson, colour: "#dc2127" },
"Australia/Perth",
);
expect(event).not.toBeNull();
expect(googleApiEventBody(event!)).toMatchObject({ colorId: "11" });
});
}); });
describe("mapLessonsToGoogleEvents", () => { describe("mapLessonsToGoogleEvents", () => {
@@ -1,6 +1,7 @@
import { BSPLUS_GOOGLE_CALENDAR_EVENT_PROP } from "@/config/googleCalendar"; import { BSPLUS_GOOGLE_CALENDAR_EVENT_PROP } from "@/config/googleCalendar";
import { BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY } from "@/config/outlookCalendar"; import { BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY } from "@/config/outlookCalendar";
import { outlookDescriptionWithKey } from "@/seqta/utils/calendarSync/eventFingerprint"; import { outlookDescriptionWithKey } from "@/seqta/utils/calendarSync/eventFingerprint";
import { nearestGoogleEventColorId } from "./eventColor";
import type { GoogleCalendarEventInput, SeqtaTimetableLesson } from "./types"; import type { GoogleCalendarEventInput, SeqtaTimetableLesson } from "./types";
const SKIP_TYPES = new Set(["note", "holiday", "assembly-note"]); const SKIP_TYPES = new Set(["note", "holiday", "assembly-note"]);
@@ -54,6 +55,8 @@ export function lessonToGoogleEvent(
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}`);
const colorId = nearestGoogleEventColorId(lesson.colour);
return { return {
seqtaKey: seqtaLessonKey(origin, lesson), seqtaKey: seqtaLessonKey(origin, lesson),
summary, summary,
@@ -62,6 +65,7 @@ export function lessonToGoogleEvent(
startDateTime: `${lesson.date}T${from}:00`, startDateTime: `${lesson.date}T${from}:00`,
endDateTime: `${lesson.date}T${until}:00`, endDateTime: `${lesson.date}T${until}:00`,
timeZone, timeZone,
...(colorId ? { colorId } : {}),
}; };
} }
@@ -82,7 +86,7 @@ export function mapLessonsToGoogleEvents(
} }
export function googleApiEventBody(event: GoogleCalendarEventInput): Record<string, unknown> { export function googleApiEventBody(event: GoogleCalendarEventInput): Record<string, unknown> {
return { const body: Record<string, unknown> = {
summary: event.summary, summary: event.summary,
location: event.location, location: event.location,
description: event.description, description: event.description,
@@ -94,6 +98,10 @@ export function googleApiEventBody(event: GoogleCalendarEventInput): Record<stri
}, },
}, },
}; };
if (event.colorId) {
body.colorId = event.colorId;
}
return body;
} }
export function outlookGraphEventBody(event: GoogleCalendarEventInput): Record<string, unknown> { export function outlookGraphEventBody(event: GoogleCalendarEventInput): Record<string, unknown> {
@@ -2,6 +2,8 @@ import type { SyncDateRange } from "./syncDateRange";
import { syncWindowRange } from "./syncDateRange"; import { syncWindowRange } from "./syncDateRange";
import type { SeqtaTimetableLesson } from "./types"; import type { SeqtaTimetableLesson } from "./types";
type PrefItem = { name?: string; value?: string };
async function postSeqtaJson<T>(path: string, body: Record<string, unknown>): Promise<T> { async function postSeqtaJson<T>(path: string, body: Record<string, unknown>): Promise<T> {
const res = await fetch(`${location.origin}${path}`, { const res = await fetch(`${location.origin}${path}`, {
method: "POST", method: "POST",
@@ -19,6 +21,54 @@ async function postSeqtaJson<T>(path: string, body: Record<string, unknown>): Pr
return (await res.json()) as T; return (await res.json()) as T;
} }
function prefsToSubjectColours(prefs: PrefItem[] | undefined): Record<string, string> {
const colours: Record<string, string> = {};
for (const pref of prefs ?? []) {
if (!pref.name?.startsWith("timetable.subject.colour.") || !pref.value) continue;
const code = pref.name.slice("timetable.subject.colour.".length);
if (code) colours[code] = pref.value;
}
return colours;
}
async function fetchSubjectColours(): Promise<Record<string, string>> {
try {
if (isEngageParentContext()) {
const data = await postSeqtaJson<{ payload?: PrefItem[] }>("/seqta/parent/load/prefs?", {
request: "userPrefs",
asArray: true,
});
return prefsToSubjectColours(data?.payload);
}
const studentId = await resolveStudentId();
const body: Record<string, unknown> = {
request: "userPrefs",
asArray: true,
};
if (studentId != null) body.user = studentId;
const data = await postSeqtaJson<{ payload?: PrefItem[] }>("/seqta/student/load/prefs?", body);
return prefsToSubjectColours(data?.payload);
} catch {
return {};
}
}
function withSubjectColours(
lessons: SeqtaTimetableLesson[],
colours: Record<string, string>,
): SeqtaTimetableLesson[] {
if (Object.keys(colours).length === 0) return lessons;
return lessons.map((lesson) => {
const code = lesson.code?.trim();
if (!code) return lesson;
const colour = colours[code];
if (!colour) return lesson;
return { ...lesson, colour };
});
}
export async function resolveStudentId(): Promise<number | undefined> { export async function resolveStudentId(): Promise<number | undefined> {
try { try {
const json = await postSeqtaJson<{ payload?: { id?: number; student?: number } }>( const json = await postSeqtaJson<{ payload?: { id?: number; student?: number } }>(
@@ -44,6 +94,9 @@ export async function fetchTimetableLessons(
range: SyncDateRange, range: SyncDateRange,
): Promise<SeqtaTimetableLesson[]> { ): Promise<SeqtaTimetableLesson[]> {
const { from, until } = range; const { from, until } = range;
const coloursPromise = fetchSubjectColours();
let lessons: SeqtaTimetableLesson[] = [];
if (isEngageParentContext()) { if (isEngageParentContext()) {
const listJson = await postSeqtaJson<{ payload?: { id?: string | number }[] }>( const listJson = await postSeqtaJson<{ payload?: { id?: string | number }[] }>(
@@ -59,9 +112,8 @@ export async function fetchTimetableLessons(
"/seqta/parent/load/timetable", "/seqta/parent/load/timetable",
{ from, until, student: studentId }, { from, until, student: studentId },
); );
return Array.isArray(data?.payload?.items) ? data.payload.items : []; lessons = Array.isArray(data?.payload?.items) ? data.payload.items : [];
} } else {
const studentId = await resolveStudentId(); const studentId = await resolveStudentId();
const body: Record<string, unknown> = { from, until }; const body: Record<string, unknown> = { from, until };
if (studentId != null) body.student = studentId; if (studentId != null) body.student = studentId;
@@ -70,7 +122,10 @@ export async function fetchTimetableLessons(
"/seqta/student/load/timetable?", "/seqta/student/load/timetable?",
body, body,
); );
return Array.isArray(data?.payload?.items) ? data.payload.items : []; lessons = Array.isArray(data?.payload?.items) ? data.payload.items : [];
}
return withSubjectColours(lessons, await coloursPromise);
} }
export async function fetchTimetableForSync(weeksAhead?: number): Promise<SeqtaTimetableLesson[]> { export async function fetchTimetableForSync(weeksAhead?: number): Promise<SeqtaTimetableLesson[]> {
+4
View File
@@ -10,6 +10,8 @@ export interface SeqtaTimetableLesson {
period?: string; period?: string;
calendarid?: string | number; calendarid?: string | number;
ci?: number; ci?: number;
/** SEQTA subject colour (hex/rgb) used for Google Calendar event colour. */
colour?: string;
} }
export interface GoogleCalendarEventInput { export interface GoogleCalendarEventInput {
@@ -20,6 +22,8 @@ export interface GoogleCalendarEventInput {
startDateTime: string; startDateTime: string;
endDateTime: string; endDateTime: string;
timeZone: string; timeZone: string;
/** Google Calendar event colorId ("1""11"). */
colorId?: string;
} }
export interface GoogleCalendarSyncRequest { export interface GoogleCalendarSyncRequest {