diff --git a/.gitignore b/.gitignore index 311312a3..25e71042 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ betterseqtaplus-safari/ .env.submit dependency-graph.svg +update-videos/ + diff --git a/src/resources/calendar-sync-update.mp4 b/src/resources/calendar-sync-update.mp4 new file mode 100644 index 00000000..03241908 Binary files /dev/null and b/src/resources/calendar-sync-update.mp4 differ diff --git a/src/seqta/utils/googleCalendar/eventColor.test.ts b/src/seqta/utils/googleCalendar/eventColor.test.ts new file mode 100644 index 00000000..1e664b78 --- /dev/null +++ b/src/seqta/utils/googleCalendar/eventColor.test.ts @@ -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"); + }); +}); diff --git a/src/seqta/utils/googleCalendar/eventColor.ts b/src/seqta/utils/googleCalendar/eventColor.ts new file mode 100644 index 00000000..497ac9c8 --- /dev/null +++ b/src/seqta/utils/googleCalendar/eventColor.ts @@ -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; +} diff --git a/src/seqta/utils/googleCalendar/eventMapper.test.ts b/src/seqta/utils/googleCalendar/eventMapper.test.ts index 7db17c67..ef344fcc 100644 --- a/src/seqta/utils/googleCalendar/eventMapper.test.ts +++ b/src/seqta/utils/googleCalendar/eventMapper.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@jest/globals"; import { + googleApiEventBody, lessonToGoogleEvent, mapLessonsToGoogleEvents, outlookGraphEventBody, @@ -51,6 +52,27 @@ describe("lessonToGoogleEvent", () => { }); 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", () => { diff --git a/src/seqta/utils/googleCalendar/eventMapper.ts b/src/seqta/utils/googleCalendar/eventMapper.ts index 755110b8..45dfe248 100644 --- a/src/seqta/utils/googleCalendar/eventMapper.ts +++ b/src/seqta/utils/googleCalendar/eventMapper.ts @@ -1,6 +1,7 @@ import { BSPLUS_GOOGLE_CALENDAR_EVENT_PROP } from "@/config/googleCalendar"; import { BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY } from "@/config/outlookCalendar"; import { outlookDescriptionWithKey } from "@/seqta/utils/calendarSync/eventFingerprint"; +import { nearestGoogleEventColorId } from "./eventColor"; import type { GoogleCalendarEventInput, SeqtaTimetableLesson } from "./types"; 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.period) descriptionLines.push(`Period: ${lesson.period}`); + const colorId = nearestGoogleEventColorId(lesson.colour); + return { seqtaKey: seqtaLessonKey(origin, lesson), summary, @@ -62,6 +65,7 @@ export function lessonToGoogleEvent( startDateTime: `${lesson.date}T${from}:00`, endDateTime: `${lesson.date}T${until}:00`, timeZone, + ...(colorId ? { colorId } : {}), }; } @@ -82,7 +86,7 @@ export function mapLessonsToGoogleEvents( } export function googleApiEventBody(event: GoogleCalendarEventInput): Record { - return { + const body: Record = { summary: event.summary, location: event.location, description: event.description, @@ -94,6 +98,10 @@ export function googleApiEventBody(event: GoogleCalendarEventInput): Record { diff --git a/src/seqta/utils/googleCalendar/fetchTimetable.ts b/src/seqta/utils/googleCalendar/fetchTimetable.ts index f5c7444b..7d2a6427 100644 --- a/src/seqta/utils/googleCalendar/fetchTimetable.ts +++ b/src/seqta/utils/googleCalendar/fetchTimetable.ts @@ -2,6 +2,8 @@ import type { SyncDateRange } from "./syncDateRange"; import { syncWindowRange } from "./syncDateRange"; import type { SeqtaTimetableLesson } from "./types"; +type PrefItem = { name?: string; value?: string }; + async function postSeqtaJson(path: string, body: Record): Promise { const res = await fetch(`${location.origin}${path}`, { method: "POST", @@ -19,6 +21,54 @@ async function postSeqtaJson(path: string, body: Record): Pr return (await res.json()) as T; } +function prefsToSubjectColours(prefs: PrefItem[] | undefined): Record { + const colours: Record = {}; + 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> { + 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 = { + 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, +): 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 { try { const json = await postSeqtaJson<{ payload?: { id?: number; student?: number } }>( @@ -44,6 +94,9 @@ export async function fetchTimetableLessons( range: SyncDateRange, ): Promise { const { from, until } = range; + const coloursPromise = fetchSubjectColours(); + + let lessons: SeqtaTimetableLesson[] = []; if (isEngageParentContext()) { const listJson = await postSeqtaJson<{ payload?: { id?: string | number }[] }>( @@ -59,18 +112,20 @@ export async function fetchTimetableLessons( "/seqta/parent/load/timetable", { 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 body: Record = { from, until }; + if (studentId != null) body.student = studentId; + + const data = await postSeqtaJson<{ payload?: { items?: SeqtaTimetableLesson[] } }>( + "/seqta/student/load/timetable?", + body, + ); + lessons = Array.isArray(data?.payload?.items) ? data.payload.items : []; } - const studentId = await resolveStudentId(); - const body: Record = { from, until }; - if (studentId != null) body.student = studentId; - - const data = await postSeqtaJson<{ payload?: { items?: SeqtaTimetableLesson[] } }>( - "/seqta/student/load/timetable?", - body, - ); - return Array.isArray(data?.payload?.items) ? data.payload.items : []; + return withSubjectColours(lessons, await coloursPromise); } export async function fetchTimetableForSync(weeksAhead?: number): Promise { diff --git a/src/seqta/utils/googleCalendar/types.ts b/src/seqta/utils/googleCalendar/types.ts index c7c14465..210d4876 100644 --- a/src/seqta/utils/googleCalendar/types.ts +++ b/src/seqta/utils/googleCalendar/types.ts @@ -10,6 +10,8 @@ export interface SeqtaTimetableLesson { period?: string; calendarid?: string | number; ci?: number; + /** SEQTA subject colour (hex/rgb) used for Google Calendar event colour. */ + colour?: string; } export interface GoogleCalendarEventInput { @@ -20,6 +22,8 @@ export interface GoogleCalendarEventInput { startDateTime: string; endDateTime: string; timeZone: string; + /** Google Calendar event colorId ("1"–"11"). */ + colorId?: string; } export interface GoogleCalendarSyncRequest {