From 574dfa95a7e7143946746ba3e240ec4884ba51e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 05:02:44 +0000 Subject: [PATCH 1/6] feat: ETag, If-None-Match and per-route Cache-Control Every 200 JSON response from /timetable, /lunch, /schedule, /school and /classes now carries a strong ETag (SHA-256 of the serialised body). A matching If-None-Match (weak comparison, lists and * supported) gets a 304 with an empty body. Cache-Control keeps max-age=0 and adds per-route s-maxage so the Vercel CDN can absorb repeated polls; 404 NEIS_DATA_NOT_FOUND is cacheable for an hour and every other error is no-store. Status codes and bodies are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DvQgaoFCY6wDChdDYQbV8D --- apps/docs/openapi/openapi.json | 2 +- package.json | 2 +- src/app.ts | 14 +++++ src/http/cache.ts | 109 +++++++++++++++++++++++++++++++++ src/openapi-config.ts | 3 + src/test/http-cache.ts | 93 ++++++++++++++++++++++++++++ 6 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 src/http/cache.ts create mode 100644 src/test/http-cache.ts diff --git a/apps/docs/openapi/openapi.json b/apps/docs/openapi/openapi.json index 8de4b1d..744eb2e 100644 --- a/apps/docs/openapi/openapi.json +++ b/apps/docs/openapi/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "TimeforSchool API", - "description": "TimeforSchool wraps **Comcigan** and the Korean **NEIS Open API** (school info, classes, meals, calendar, timetables).\n\n### Timetable source\n`GET /timetable` defaults to `source=auto`: both upstreams in parallel, then merge per period (whichever side has a subject; shorter name when both do). Missing weekdays/periods fill from the other. NEIS Saturday is ignored. Pin `source=comcigan` or `source=neis` to call only one.\n\n### School identifier\nUse **either** `schoolname` **or** `schoolcode` (NEIS 7-digit code)—never both. No endpoint defaults to a particular school; `GET /school` requires `schoolname`.\n\n### Dates\n`startdate` / `enddate` use **YYYYMMDD** (e.g. `20250526`).\n\n### Errors\nFailed requests return `{ ok: false, error: { code, message, details? } }` with an appropriate HTTP status.", + "description": "TimeforSchool wraps **Comcigan** and the Korean **NEIS Open API** (school info, classes, meals, calendar, timetables).\n\n### Timetable source\n`GET /timetable` defaults to `source=auto`: both upstreams in parallel, then merge per period (whichever side has a subject; shorter name when both do). Missing weekdays/periods fill from the other. NEIS Saturday is ignored. Pin `source=comcigan` or `source=neis` to call only one.\n\n### School identifier\nUse **either** `schoolname` **or** `schoolcode` (NEIS 7-digit code)—never both. No endpoint defaults to a particular school; `GET /school` requires `schoolname`.\n\n### Dates\n`startdate` / `enddate` use **YYYYMMDD** (e.g. `20250526`).\n\n### HTTP caching\nEvery `200` from `/timetable`, `/lunch`, `/schedule`, `/school` and `/classes` carries a strong `ETag` (SHA-256 of the body). Send it back as `If-None-Match` to get `304 Not Modified` with an empty body when nothing changed. `Cache-Control` keeps `max-age=0` (clients decide when to ask) and sets `s-maxage` for the CDN: 5 minutes for `/timetable`, 6 hours for `/lunch` and `/schedule`, 1 day for `/school` and `/classes`, 1 hour for `404 NEIS_DATA_NOT_FOUND`. Other errors are `no-store`.\n\n### Errors\nFailed requests return `{ ok: false, error: { code, message, details? } }` with an appropriate HTTP status.", "version": "0.0.1" }, "tags": [ diff --git a/package.json b/package.json index 39211a7..b4c8469 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "openapi:sync": "npm run openapi:export && npm run openapi:generate -w @timeforschool/docs", "dev": "tsx watch src/server.ts", "start": "node dist/server.js", - "test": "tsx src/test/timetable-merge.ts", + "test": "tsx src/test/timetable-merge.ts && tsx src/test/http-cache.ts", "test:live": "tsx src/test/smoke.ts && tsx src/test/yangjeong-1-3.ts && tsx src/test/neis-timetable.ts", "vercel:dev": "vercel dev", "vercel:deploy": "vercel deploy --prod" diff --git a/src/app.ts b/src/app.ts index 206cfdb..e4efbb9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,6 +5,7 @@ import { fetchTimeTable } from "@timeforschool/client"; import { Elysia, type Static, t } from "elysia"; import { API_VERSION, APP_NAME, CORS_ORIGINS, NEIS_API_KEY } from "./config.js"; import { ApiError, ErrorCode } from "./errors/api-error.js"; +import { CacheControl, errorCacheControl, httpCache } from "./http/cache.js"; import { ApiErrorSchema, ClassNo, @@ -68,6 +69,10 @@ export const app = new Elysia({ name: "timeforschool-api" }) }, ); set.status = apiError.status; + set.headers["cache-control"] = errorCacheControl( + apiError.status, + apiError.code, + ); return apiError.toJSON(); } @@ -75,6 +80,10 @@ export const app = new Elysia({ name: "timeforschool-api" }) error instanceof ApiError ? error : ApiError.fromUnknown(error); set.status = apiError.status; + set.headers["cache-control"] = errorCacheControl( + apiError.status, + apiError.code, + ); return apiError.toJSON(); }) .get( @@ -113,6 +122,7 @@ export const app = new Elysia({ name: "timeforschool-api" }) description: "Returns all NEIS school records matching schoolname (required).", }, + mapResponse: httpCache(CacheControl.directory), response: { 200: "SchoolInfoList", 400: "ApiError", @@ -160,6 +170,7 @@ export const app = new Elysia({ name: "timeforschool-api" }) description: "Returns sorted class names (반) for the given school and grade using NEIS classInfo.", }, + mapResponse: httpCache(CacheControl.directory), response: { 200: "ClassList", 400: "ApiError", @@ -232,6 +243,7 @@ export const app = new Elysia({ name: "timeforschool-api" }) description: "Fetches the class schedule. Default `source=auto` calls Comcigan and NEIS in parallel and merges them per period: whichever side has a subject is used, the shorter name wins when both do, and missing weekdays/periods fill from the other source. Empty or cancelled Comcigan periods still fill from NEIS. NEIS Saturday is ignored (stale 토요휴업일). Pin `source=comcigan` or `source=neis` to hit a single upstream. Provide schoolname, or schoolcode alone. week: 0 = this week, 1 = next week. Comcigan substitutions use `replaced` + `original`. `day_time` always comes from Comcigan and is blank when Comcigan has none. `teacher` also stays blank when Comcigan has no data.", }, + mapResponse: httpCache(CacheControl.timetable), response: { 200: "TimetableResponse", 400: "ApiError", @@ -286,6 +298,7 @@ export const app = new Elysia({ name: "timeforschool-api" }) description: "Returns NEIS mealServiceDietInfo rows. Parentheses are stripped from dish names; HTML line breaks become newlines.", }, + mapResponse: httpCache(CacheControl.calendar), response: { 200: "MealList", 400: "ApiError", @@ -330,6 +343,7 @@ export const app = new Elysia({ name: "timeforschool-api" }) summary: "School calendar events", description: "Returns NEIS SchoolSchedule rows between startdate and enddate (inclusive).", }, + mapResponse: httpCache(CacheControl.calendar), response: { 200: "ScheduleList", 400: "ApiError", diff --git a/src/http/cache.ts b/src/http/cache.ts new file mode 100644 index 0000000..e0040a1 --- /dev/null +++ b/src/http/cache.ts @@ -0,0 +1,109 @@ +import { createHash } from "node:crypto"; + +/** + * `Cache-Control` per route. `max-age` stays 0 so clients decide when to ask; + * `s-maxage` lets the Vercel CDN absorb repeated polls, and the ETag makes + * client revalidation cheap (304, empty body). + */ +export const CacheControl = { + timetable: "public, max-age=0, s-maxage=300, stale-while-revalidate=3600", + calendar: "public, max-age=0, s-maxage=21600, stale-while-revalidate=86400", + directory: "public, max-age=0, s-maxage=86400", + neisDataNotFound: "public, max-age=0, s-maxage=3600", + noStore: "no-store", +} as const; + +export type CacheControlValue = (typeof CacheControl)[keyof typeof CacheControl]; + +/** `Cache-Control` for an error response: only "no data" may be cached. */ +export function errorCacheControl( + status: number, + code: string, +): CacheControlValue { + return status === 404 && code === "NEIS_DATA_NOT_FOUND" + ? CacheControl.neisDataNotFound + : CacheControl.noStore; +} + +/** Strong ETag: quoted base64url SHA-256 of the serialised body. */ +export function computeEtag(body: string): string { + return `"${createHash("sha256").update(body).digest("base64url")}"`; +} + +function opaqueTag(tag: string): string { + const trimmed = tag.trim(); + return trimmed.startsWith("W/") ? trimmed.slice(2) : trimmed; +} + +/** + * RFC 9110 §13.1.2: `If-None-Match` uses weak comparison, so `W/"x"` matches + * `"x"` (a CDN may weaken the tag when it compresses the body). `*` matches + * any current representation. + */ +export function ifNoneMatchMatches( + header: string | null | undefined, + etag: string, +): boolean { + if (!header) return false; + if (header.trim() === "*") return true; + const target = opaqueTag(etag); + return header.split(",").some((tag) => opaqueTag(tag) === target); +} + +export interface CachedJsonResult { + status: 200 | 304; + body: string | null; + headers: Record; +} + +/** + * Serialise a 200 JSON body, tag it, and decide between `200` and `304`. + * Pure so it can be tested without the app. + */ +export function cachedJson( + value: unknown, + cacheControl: CacheControlValue, + ifNoneMatch: string | null | undefined, +): CachedJsonResult { + const body = JSON.stringify(value); + const etag = computeEtag(body); + const headers = { etag, "cache-control": cacheControl }; + if (ifNoneMatchMatches(ifNoneMatch, etag)) { + return { status: 304, body: null, headers }; + } + return { + status: 200, + body, + headers: { ...headers, "content-type": "application/json" }, + }; +} + +interface MapResponseContext { + responseValue: unknown; + request: Request; + set: { status?: number | string; headers: Record }; +} + +/** + * Elysia `mapResponse` hook for a JSON GET route: adds `ETag` and + * `Cache-Control` to `200` bodies and answers a matching `If-None-Match` + * with `304`. Errors are left to `onError`. + */ +export function httpCache(cacheControl: CacheControlValue) { + return ({ responseValue, request, set }: MapResponseContext) => { + if (responseValue instanceof Response) return; + if (set.status !== undefined && set.status !== 200) return; + + const result = cachedJson( + responseValue, + cacheControl, + request.headers.get("if-none-match"), + ); + Object.assign(set.headers, result.headers); + set.status = result.status; + return new Response(result.body, { + status: result.status, + headers: set.headers as Record, + }); + }; +} diff --git a/src/openapi-config.ts b/src/openapi-config.ts index 6ae641f..cf0e404 100644 --- a/src/openapi-config.ts +++ b/src/openapi-config.ts @@ -12,6 +12,9 @@ Use **either** \`schoolname\` **or** \`schoolcode\` (NEIS 7-digit code)—never ### Dates \`startdate\` / \`enddate\` use **YYYYMMDD** (e.g. \`20250526\`). +### HTTP caching +Every \`200\` from \`/timetable\`, \`/lunch\`, \`/schedule\`, \`/school\` and \`/classes\` carries a strong \`ETag\` (SHA-256 of the body). Send it back as \`If-None-Match\` to get \`304 Not Modified\` with an empty body when nothing changed. \`Cache-Control\` keeps \`max-age=0\` (clients decide when to ask) and sets \`s-maxage\` for the CDN: 5 minutes for \`/timetable\`, 6 hours for \`/lunch\` and \`/schedule\`, 1 day for \`/school\` and \`/classes\`, 1 hour for \`404 NEIS_DATA_NOT_FOUND\`. Other errors are \`no-store\`. + ### Errors Failed requests return \`{ ok: false, error: { code, message, details? } }\` with an appropriate HTTP status. `.trim(); diff --git a/src/test/http-cache.ts b/src/test/http-cache.ts new file mode 100644 index 0000000..f757c59 --- /dev/null +++ b/src/test/http-cache.ts @@ -0,0 +1,93 @@ +/** + * Unit tests for ETag / If-None-Match / Cache-Control (no network). + */ +import { Elysia } from "elysia"; +import { app } from "../app.js"; +import { + CacheControl, + cachedJson, + computeEtag, + errorCacheControl, + httpCache, + ifNoneMatchMatches, +} from "../http/cache.js"; +import { assert } from "./helpers.js"; + +async function run(): Promise { + const etag = computeEtag('{"a":1}'); + assert(/^"[A-Za-z0-9_-]{43}"$/.test(etag), "ETag is a quoted strong SHA-256 tag"); + assert(etag === computeEtag('{"a":1}'), "ETag is deterministic"); + assert(etag !== computeEtag('{"a":2}'), "ETag changes with the body"); + + assert(!ifNoneMatchMatches(null, etag), "no header never matches"); + assert(!ifNoneMatchMatches("", etag), "empty header never matches"); + assert(ifNoneMatchMatches(etag, etag), "exact tag matches"); + assert(ifNoneMatchMatches(`W/${etag}`, etag), "weak form matches (weak comparison)"); + assert(ifNoneMatchMatches(`"other", ${etag}`, etag), "tag in a list matches"); + assert(ifNoneMatchMatches("*", etag), "* matches"); + assert(!ifNoneMatchMatches('"other"', etag), "different tag does not match"); + assert( + !ifNoneMatchMatches(etag.slice(1, -1), etag), + "unquoted tag does not match", + ); + + const fresh = cachedJson({ a: 1 }, CacheControl.timetable, null); + assert(fresh.status === 200 && fresh.body === '{"a":1}', "200 carries the body"); + assert(fresh.headers.etag === etag, "200 carries the ETag"); + assert(fresh.headers["cache-control"] === CacheControl.timetable, "200 carries Cache-Control"); + const revalidated = cachedJson({ a: 1 }, CacheControl.timetable, etag); + assert(revalidated.status === 304 && revalidated.body === null, "304 has an empty body"); + assert(revalidated.headers.etag === etag, "304 repeats the ETag"); + + assert( + errorCacheControl(404, "NEIS_DATA_NOT_FOUND") === CacheControl.neisDataNotFound, + "NEIS_DATA_NOT_FOUND is CDN-cacheable", + ); + assert(errorCacheControl(404, "SCHOOL_NOT_FOUND") === "no-store", "other 404s are no-store"); + assert(errorCacheControl(400, "VALIDATION_ERROR") === "no-store", "400 is no-store"); + assert(errorCacheControl(502, "NEIS_UPSTREAM_ERROR") === "no-store", "502 is no-store"); + + // The hook inside a real Elysia app. + const mini = new Elysia() + .onError(({ set }) => { + set.status = 500; + return { ok: false }; + }) + .get("/ok", () => ({ a: 1 }), { mapResponse: httpCache(CacheControl.directory) }) + .get( + "/fail", + () => { + throw new Error("boom"); + }, + { mapResponse: httpCache(CacheControl.directory) }, + ); + const first = await mini.handle(new Request("http://127.0.0.1/ok")); + assert(first.status === 200, "mini: 200"); + assert(first.headers.get("etag") === etag, "mini: ETag header"); + assert(first.headers.get("cache-control") === CacheControl.directory, "mini: Cache-Control"); + assert(first.headers.get("content-type") === "application/json", "mini: JSON content type"); + assert((await first.text()) === '{"a":1}', "mini: body unchanged"); + const second = await mini.handle( + new Request("http://127.0.0.1/ok", { headers: { "if-none-match": etag } }), + ); + assert(second.status === 304, "mini: 304 on match"); + assert((await second.text()) === "", "mini: 304 body empty"); + assert(second.headers.get("etag") === etag, "mini: 304 ETag"); + const failed = await mini.handle(new Request("http://127.0.0.1/fail")); + assert(failed.status === 500 && !failed.headers.has("etag"), "mini: errors get no ETag"); + + // Real app: validation errors fail before any upstream call. + const invalid = await app.handle( + new Request("http://127.0.0.1/timetable?schoolcode=7010208&grade=9&classno=3"), + ); + assert(invalid.status === 400, "invalid grade is still 400"); + assert(invalid.headers.get("cache-control") === "no-store", "400 is no-store"); + assert(!invalid.headers.has("etag"), "400 has no ETag"); + + console.log("✓ http cache: ETag, If-None-Match, Cache-Control"); +} + +run().catch((error) => { + console.error(error); + process.exit(1); +}); From f684b3405f330b1f0bd722bbcd9f0cafef752bcc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 05:04:15 +0000 Subject: [PATCH 2/6] feat(timetable): add week_start (KST Monday of the described week) /timetable now returns week_start, the YYYYMMDD Monday of the week the response describes, computed in Asia/Seoul so the UTC server agrees with Korean clients. Clients that cache week=1 can tell when that cached "next week" has become this week. Reuses the client's weekYmdRange, now exported from @timeforschool/client/timetable. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DvQgaoFCY6wDChdDYQbV8D --- apps/docs/content/docs/client/timetable.mdx | 14 +++++++ apps/docs/openapi/openapi.json | 11 ++++- package.json | 2 +- packages/client/src/timetable/index.ts | 8 +++- src/app.ts | 3 ++ src/schemas/responses.ts | 6 +++ src/services/timetable-extras.ts | 10 +++++ src/test/timetable-extras.ts | 46 +++++++++++++++++++++ 8 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 src/services/timetable-extras.ts create mode 100644 src/test/timetable-extras.ts diff --git a/apps/docs/content/docs/client/timetable.mdx b/apps/docs/content/docs/client/timetable.mdx index 6bb9369..7234f56 100644 --- a/apps/docs/content/docs/client/timetable.mdx +++ b/apps/docs/content/docs/client/timetable.mdx @@ -51,6 +51,20 @@ const grade1Class3 = table.timetable[1][3].slice(1); HTTP `GET /timetable` already returns that weekday array as `timetable`. +## KST week helpers + +`@timeforschool/client/timetable` also exports the date helpers `fetchNeisTimeTable` uses. All dates are `YYYYMMDD` in Asia/Seoul, whatever the host time zone. + +```ts +import { addDaysYmd, kstYmd, weekYmdRange } from "@timeforschool/client/timetable"; + +kstYmd(); // today in KST, e.g. "20261002" +weekYmdRange(1); // { mondayYmd: "20261005", fromYmd: "20261005", toYmd: "20261011" } +addDaysYmd("20261231", 1); // "20270101" +``` + +HTTP `GET /timetable` returns the same Monday as `week_start`. + ## What NEIS does not publish Left blank on purpose when Comcigan has no data (same keys, empty values): diff --git a/apps/docs/openapi/openapi.json b/apps/docs/openapi/openapi.json index 744eb2e..805c7e4 100644 --- a/apps/docs/openapi/openapi.json +++ b/apps/docs/openapi/openapi.json @@ -986,7 +986,8 @@ "required": [ "day_time", "timetable", - "update_date" + "update_date", + "week_start" ], "properties": { "day_time": { @@ -1137,6 +1138,14 @@ "2025-05-26" ], "type": "string" + }, + "week_start": { + "pattern": "^\\d{8}$", + "description": "Monday of the week this response describes, as YYYYMMDD in KST. `week=0` is the current Mon–Sun week in Korea, `week=1` the one after. Keep it with a cached response: a cached `week=1` whose `week_start` is now the current Monday has become this week.", + "examples": [ + "20261005" + ], + "type": "string" } }, "$id": "#/components/schemas/TimetableResponse" diff --git a/package.json b/package.json index b4c8469..b6ebecb 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "openapi:sync": "npm run openapi:export && npm run openapi:generate -w @timeforschool/docs", "dev": "tsx watch src/server.ts", "start": "node dist/server.js", - "test": "tsx src/test/timetable-merge.ts && tsx src/test/http-cache.ts", + "test": "tsx src/test/timetable-merge.ts && tsx src/test/http-cache.ts && tsx src/test/timetable-extras.ts", "test:live": "tsx src/test/smoke.ts && tsx src/test/yangjeong-1-3.ts && tsx src/test/neis-timetable.ts", "vercel:dev": "vercel dev", "vercel:deploy": "vercel deploy --prod" diff --git a/packages/client/src/timetable/index.ts b/packages/client/src/timetable/index.ts index 81f6539..83c04fe 100644 --- a/packages/client/src/timetable/index.ts +++ b/packages/client/src/timetable/index.ts @@ -16,7 +16,13 @@ export type { TimeTableResult, TimetableSource, } from "./types.js"; -export { fetchNeisTimeTable, mapNeisTimetableRows } from "./neis.js"; +export { + addDaysYmd, + fetchNeisTimeTable, + kstYmd, + mapNeisTimetableRows, + weekYmdRange, +} from "./neis.js"; export { mergeTimeTableResults, mergePeriod } from "./merge.js"; export type { FetchNeisTimeTableOptions } from "./neis.js"; export { diff --git a/src/app.ts b/src/app.ts index e4efbb9..74d4c9b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -28,6 +28,7 @@ import { createNeisClient, resolveSchool, } from "./services/school.js"; +import { weekStartYmd } from "./services/timetable-extras.js"; import { omitNullsFromRows } from "./utils/json.js"; const REMOVE_PAREN_PATTERN = /\([^)]*\)/g; @@ -189,6 +190,7 @@ export const app = new Elysia({ name: "timeforschool-api" }) const classno = query.classno; const week = query.week ?? 0; const source = query.source ?? "auto"; + const weekStart = weekStartYmd(week); const client = createNeisClient(); let schoolName = schoolname; @@ -225,6 +227,7 @@ export const app = new Elysia({ name: "timeforschool-api" }) day_time: timetable.dayTime, timetable: weekDays, update_date: timetable.updateDate, + week_start: weekStart, } as Static; }, { diff --git a/src/schemas/responses.ts b/src/schemas/responses.ts index 858afac..155143f 100644 --- a/src/schemas/responses.ts +++ b/src/schemas/responses.ts @@ -248,4 +248,10 @@ export const TimetableResponseSchema = t.Object({ "Comcigan last-updated stamp, or NEIS LOAD_DTM as YYYY-MM-DD. Empty if NEIS omitted it.", examples: ["2025-05-26"], }), + week_start: t.String({ + pattern: "^\\d{8}$", + description: + "Monday of the week this response describes, as YYYYMMDD in KST. `week=0` is the current Mon–Sun week in Korea, `week=1` the one after. Keep it with a cached response: a cached `week=1` whose `week_start` is now the current Monday has become this week.", + examples: ["20261005"], + }), }); diff --git a/src/services/timetable-extras.ts b/src/services/timetable-extras.ts new file mode 100644 index 0000000..38f204d --- /dev/null +++ b/src/services/timetable-extras.ts @@ -0,0 +1,10 @@ +import { weekYmdRange } from "@timeforschool/client/timetable"; + +/** + * `YYYYMMDD` Monday of the week a `/timetable` response describes, in KST + * (the server runs in UTC). `week=0` is the current Mon–Sun week, `week=1` + * the one after. + */ +export function weekStartYmd(week: number, now = new Date()): string { + return weekYmdRange(week, now).mondayYmd; +} diff --git a/src/test/timetable-extras.ts b/src/test/timetable-extras.ts new file mode 100644 index 0000000..aa1ab99 --- /dev/null +++ b/src/test/timetable-extras.ts @@ -0,0 +1,46 @@ +/** + * Unit tests for /timetable and /schedule extras (no network). + */ +import { weekStartYmd } from "../services/timetable-extras.js"; +import { assert } from "./helpers.js"; + +function eq(actual: T, expected: T, message: string): void { + assert( + JSON.stringify(actual) === JSON.stringify(expected), + `${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); +} + +function testWeekStart(): void { + // Friday 2026-10-02 13:57 KST. + const friday = new Date("2026-10-02T04:57:00Z"); + eq(weekStartYmd(0, friday), "20260928", "week=0 on a Friday"); + eq(weekStartYmd(1, friday), "20261005", "week=1 on a Friday"); + + // Sunday 2026-10-04 23:50 KST is still the current week. + const sundayLate = new Date("2026-10-04T14:50:00Z"); + eq(weekStartYmd(0, sundayLate), "20260928", "Sunday 23:50 KST, week=0"); + eq(weekStartYmd(1, sundayLate), "20261005", "Sunday 23:50 KST, week=1"); + + // Monday 2026-10-05 00:30 KST is Sunday 15:30 UTC: already the new week. + const mondayEarly = new Date("2026-10-04T15:30:00Z"); + eq(weekStartYmd(0, mondayEarly), "20261005", "Monday 00:30 KST, week=0"); + eq(weekStartYmd(1, mondayEarly), "20261012", "Monday 00:30 KST, week=1"); + + // Month boundary: Wednesday 2026-07-01 KST belongs to the week of 06-29. + const julyFirst = new Date("2026-07-01T03:00:00Z"); + eq(weekStartYmd(0, julyFirst), "20260629", "month boundary, week=0"); + eq(weekStartYmd(1, julyFirst), "20260706", "month boundary, week=1"); + + // Year boundary: Friday 2027-01-01 00:10 KST (still 2026 in UTC). + const newYear = new Date("2026-12-31T15:10:00Z"); + eq(weekStartYmd(0, newYear), "20261228", "year boundary, week=0"); + eq(weekStartYmd(1, newYear), "20270104", "year boundary, week=1"); + + // Sunday 2026-12-27 22:00 KST: next week starts in the same year. + const sundayBeforeNewYear = new Date("2026-12-27T13:00:00Z"); + eq(weekStartYmd(1, sundayBeforeNewYear), "20261228", "Sunday before New Year, week=1"); +} + +testWeekStart(); +console.log("✓ timetable extras: week_start"); From 30095b3c0d8fe1294933ba708faf287c399e68cd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 05:05:32 +0000 Subject: [PATCH 3/6] feat(timetable): add cancelled and makeup flags to each period MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each /timetable slot gains two booleans, appended after `original`: - cancelled: replaced is true and subject is empty (exam-day lessons that were dropped; `original` keeps the lesson) - makeup: subject starts with "[보강]" subject, teacher, replaced and original are unchanged, so existing clients that ignore unknown keys see the same data. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DvQgaoFCY6wDChdDYQbV8D --- apps/docs/openapi/openapi.json | 30 ++++++++++++-- src/app.ts | 4 +- src/schemas/responses.ts | 16 ++++++++ src/services/timetable-extras.ts | 27 ++++++++++++ src/test/timetable-extras.ts | 70 +++++++++++++++++++++++++++++++- 5 files changed, 139 insertions(+), 8 deletions(-) diff --git a/apps/docs/openapi/openapi.json b/apps/docs/openapi/openapi.json index 805c7e4..9af6833 100644 --- a/apps/docs/openapi/openapi.json +++ b/apps/docs/openapi/openapi.json @@ -1016,14 +1016,18 @@ "subject": "수학", "teacher": "김교사", "replaced": false, - "original": null + "original": null, + "cancelled": false, + "makeup": false }, { "period": 2, "subject": "체육", "teacher": "이교사", "replaced": false, - "original": null + "original": null, + "cancelled": false, + "makeup": false }, { "period": 3, @@ -1034,7 +1038,9 @@ "period": 3, "subject": "영어", "teacher": "박교사" - } + }, + "cancelled": false, + "makeup": false } ] ] @@ -1049,7 +1055,9 @@ "subject", "teacher", "replaced", - "original" + "original", + "cancelled", + "makeup" ], "properties": { "period": { @@ -1127,6 +1135,20 @@ } } ] + }, + "cancelled": { + "description": "true when the lesson was cancelled: `replaced` is true and `subject` is empty (e.g. exam days). `original` holds the cancelled lesson.", + "examples": [ + false + ], + "type": "boolean" + }, + "makeup": { + "description": "true for a make-up lesson: `subject` starts with `[보강]`. `subject` is left as is, prefix included.", + "examples": [ + false + ], + "type": "boolean" } } } diff --git a/src/app.ts b/src/app.ts index 74d4c9b..2550da0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -28,7 +28,7 @@ import { createNeisClient, resolveSchool, } from "./services/school.js"; -import { weekStartYmd } from "./services/timetable-extras.js"; +import { weekStartYmd, withSlotFlags } from "./services/timetable-extras.js"; import { omitNullsFromRows } from "./utils/json.js"; const REMOVE_PAREN_PATTERN = /\([^)]*\)/g; @@ -225,7 +225,7 @@ export const app = new Elysia({ name: "timeforschool-api" }) return { day_time: timetable.dayTime, - timetable: weekDays, + timetable: weekDays.map((day) => day.map(withSlotFlags)), update_date: timetable.updateDate, week_start: weekStart, } as Static; diff --git a/src/schemas/responses.ts b/src/schemas/responses.ts index 155143f..91c7705 100644 --- a/src/schemas/responses.ts +++ b/src/schemas/responses.ts @@ -202,6 +202,16 @@ export const TimetablePeriodSchema = t.Object({ examples: [false], }), original: TimetableOriginalSchema, + cancelled: t.Boolean({ + description: + "true when the lesson was cancelled: `replaced` is true and `subject` is empty (e.g. exam days). `original` holds the cancelled lesson.", + examples: [false], + }), + makeup: t.Boolean({ + description: + "true for a make-up lesson: `subject` starts with `[보강]`. `subject` is left as is, prefix included.", + examples: [false], + }), }); export const TimetableResponseSchema = t.Object({ @@ -220,6 +230,8 @@ export const TimetableResponseSchema = t.Object({ teacher: "김교사", replaced: false, original: null, + cancelled: false, + makeup: false, }, { period: 2, @@ -227,6 +239,8 @@ export const TimetableResponseSchema = t.Object({ teacher: "이교사", replaced: false, original: null, + cancelled: false, + makeup: false, }, { period: 3, @@ -238,6 +252,8 @@ export const TimetableResponseSchema = t.Object({ subject: "영어", teacher: "박교사", }, + cancelled: false, + makeup: false, }, ], ], diff --git a/src/services/timetable-extras.ts b/src/services/timetable-extras.ts index 38f204d..54a0256 100644 --- a/src/services/timetable-extras.ts +++ b/src/services/timetable-extras.ts @@ -8,3 +8,30 @@ import { weekYmdRange } from "@timeforschool/client/timetable"; export function weekStartYmd(week: number, now = new Date()): string { return weekYmdRange(week, now).mondayYmd; } + +export interface TimetableSlot { + period: number; + subject: string; + teacher: string; + replaced: boolean; + original: unknown; +} + +const MAKEUP_PREFIX = "[보강]"; + +/** + * Add `cancelled` and `makeup` to a slot, leaving every existing field + * (including `subject`) untouched. + * - `cancelled`: substituted with nothing (`replaced` and empty `subject`), + * as on exam days. + * - `makeup`: a make-up lesson, `subject` starts with `[보강]`. + */ +export function withSlotFlags( + slot: T, +): T & { cancelled: boolean; makeup: boolean } { + return { + ...slot, + cancelled: slot.replaced && slot.subject === "", + makeup: slot.subject.startsWith(MAKEUP_PREFIX), + }; +} diff --git a/src/test/timetable-extras.ts b/src/test/timetable-extras.ts index aa1ab99..545678e 100644 --- a/src/test/timetable-extras.ts +++ b/src/test/timetable-extras.ts @@ -1,7 +1,7 @@ /** * Unit tests for /timetable and /schedule extras (no network). */ -import { weekStartYmd } from "../services/timetable-extras.js"; +import { weekStartYmd, withSlotFlags } from "../services/timetable-extras.js"; import { assert } from "./helpers.js"; function eq(actual: T, expected: T, message: string): void { @@ -42,5 +42,71 @@ function testWeekStart(): void { eq(weekStartYmd(1, sundayBeforeNewYear), "20261228", "Sunday before New Year, week=1"); } +function testSlotFlags(): void { + const cancelled = withSlotFlags({ + period: 1, + subject: "", + teacher: "", + replaced: true, + original: { period: 1, subject: "국사", teacher: "김기*" }, + }); + eq(cancelled, { + period: 1, + subject: "", + teacher: "", + replaced: true, + original: { period: 1, subject: "국사", teacher: "김기*" }, + cancelled: true, + makeup: false, + }, "exam-day slot is cancelled, other fields and key order untouched"); + + const makeup = withSlotFlags({ + period: 6, + subject: "[보강]음악 연주와 창작", + teacher: "", + replaced: false, + original: null, + }); + eq(makeup.subject, "[보강]음악 연주와 창작", "makeup subject unchanged"); + eq([makeup.cancelled, makeup.makeup], [false, true], "[보강] is a makeup"); + + const substituted = withSlotFlags({ + period: 5, + subject: "음악", + teacher: "최현*", + replaced: true, + original: { period: 5, subject: "음악", teacher: "이용*" }, + }); + eq([substituted.cancelled, substituted.makeup], [false, false], "substitution with a subject is not cancelled"); + + const exam = withSlotFlags({ + period: 4, + subject: "중간고사공통국어2", + teacher: "", + replaced: false, + original: null, + }); + eq([exam.cancelled, exam.makeup], [false, false], "exam period is neither"); + + const empty = withSlotFlags({ + period: 8, + subject: "", + teacher: "", + replaced: false, + original: null, + }); + eq(empty.cancelled, false, "empty but not replaced is not cancelled"); + + const midMakeup = withSlotFlags({ + period: 2, + subject: "음악[보강]", + teacher: "", + replaced: false, + original: null, + }); + eq(midMakeup.makeup, false, "[보강] only counts as a prefix"); +} + testWeekStart(); -console.log("✓ timetable extras: week_start"); +testSlotFlags(); +console.log("✓ timetable extras: week_start, slot flags"); From 31e9e52caf222c44a5b33002f93038aa5873eafc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 05:07:47 +0000 Subject: [PATCH 4/6] feat(timetable): add days, per-day status from NEIS SchoolSchedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /timetable now returns `days`, index-aligned with `timetable`: { date, closed, events }. It comes from SchoolSchedule for Mon–Sat of week_start, started before the timetable fetch so both upstream calls overlap, and reuses the route's NeisClient. - Only rows whose *_GRADE_EVENT_YN for the requested grade is "Y" count. - closed: a kept row is 공휴일 or 휴업일. events: kept EVENT_NMs, without 토요휴업일. - An empty week returns closed:false / events:[] for every day. - A failed or slow (>1s after the timetable is ready) schedule lookup omits `days`; the timetable still answers 200. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DvQgaoFCY6wDChdDYQbV8D --- apps/docs/content/docs/api/timetable.mdx | 6 +- apps/docs/openapi/openapi.json | 80 +++++++++- src/app.ts | 36 ++++- src/schemas/responses.ts | 33 ++++ src/services/timetable-extras.ts | 118 +++++++++++++- src/test/timetable-extras.ts | 195 ++++++++++++++++++++++- 6 files changed, 459 insertions(+), 9 deletions(-) diff --git a/apps/docs/content/docs/api/timetable.mdx b/apps/docs/content/docs/api/timetable.mdx index edd3232..0ba1167 100644 --- a/apps/docs/content/docs/api/timetable.mdx +++ b/apps/docs/content/docs/api/timetable.mdx @@ -22,7 +22,11 @@ _openapi: week, 1 = next week. Comcigan substitutions use `replaced` + `original`. `day_time` always comes from Comcigan and is blank when Comcigan has none. `teacher` also stays blank when Comcigan has no - data. + data. `week_start` is the KST Monday the response describes. Each + period carries `cancelled` and `makeup` flags. `days` (index-aligned + with `timetable`) marks holidays and lists the grade's NEIS calendar + events; it is fetched in parallel and omitted if the calendar lookup + fails. heading: weekly-class-timetable --- diff --git a/apps/docs/openapi/openapi.json b/apps/docs/openapi/openapi.json index 9af6833..b492122 100644 --- a/apps/docs/openapi/openapi.json +++ b/apps/docs/openapi/openapi.json @@ -246,7 +246,7 @@ "Timetable" ], "summary": "Weekly class timetable", - "description": "Fetches the class schedule. Default `source=auto` calls Comcigan and NEIS in parallel and merges them per period: whichever side has a subject is used, the shorter name wins when both do, and missing weekdays/periods fill from the other source. Empty or cancelled Comcigan periods still fill from NEIS. NEIS Saturday is ignored (stale 토요휴업일). Pin `source=comcigan` or `source=neis` to hit a single upstream. Provide schoolname, or schoolcode alone. week: 0 = this week, 1 = next week. Comcigan substitutions use `replaced` + `original`. `day_time` always comes from Comcigan and is blank when Comcigan has none. `teacher` also stays blank when Comcigan has no data.", + "description": "Fetches the class schedule. Default `source=auto` calls Comcigan and NEIS in parallel and merges them per period: whichever side has a subject is used, the shorter name wins when both do, and missing weekdays/periods fill from the other source. Empty or cancelled Comcigan periods still fill from NEIS. NEIS Saturday is ignored (stale 토요휴업일). Pin `source=comcigan` or `source=neis` to hit a single upstream. Provide schoolname, or schoolcode alone. week: 0 = this week, 1 = next week. Comcigan substitutions use `replaced` + `original`. `day_time` always comes from Comcigan and is blank when Comcigan has none. `teacher` also stays blank when Comcigan has no data. `week_start` is the KST Monday the response describes. Each period carries `cancelled` and `makeup` flags. `days` (index-aligned with `timetable`) marks holidays and lists the grade's NEIS calendar events; it is fetched in parallel and omitted if the calendar lookup fails.", "parameters": [ { "name": "schoolname", @@ -1168,6 +1168,84 @@ "20261005" ], "type": "string" + }, + "days": { + "description": "Per-day status from NEIS SchoolSchedule, index-aligned with `timetable` (`days[i]` describes `timetable[i]`, starting at `week_start`). Only rows that apply to the requested grade count. A week with no schedule rows returns every day with `closed: false` and `events: []`. **Omitted** when the schedule lookup fails or is slow; the timetable is still returned.", + "examples": [ + [ + { + "date": "20261005", + "closed": true, + "events": [ + "대체공휴일" + ] + }, + { + "date": "20261006", + "closed": false, + "events": [] + }, + { + "date": "20261007", + "closed": false, + "events": [ + "중간고사" + ] + }, + { + "date": "20261008", + "closed": false, + "events": [ + "중간고사" + ] + }, + { + "date": "20261009", + "closed": true, + "events": [ + "한글날" + ] + } + ] + ], + "type": "array", + "items": { + "type": "object", + "required": [ + "date", + "closed", + "events" + ], + "properties": { + "date": { + "pattern": "^\\d{8}$", + "description": "Calendar date (YYYYMMDD) of this timetable day.", + "examples": [ + "20261009" + ], + "type": "string" + }, + "closed": { + "description": "true when NEIS SchoolSchedule marks the day as a holiday (`공휴일`) or school closure (`휴업일`) for this grade. The timetable for that day may still repeat the holiday name in every period.", + "examples": [ + true + ], + "type": "boolean" + }, + "events": { + "description": "SchoolSchedule `EVENT_NM`s for this day that apply to the requested grade, without `토요휴업일`. Empty when there are none.", + "examples": [ + [ + "한글날" + ] + ], + "type": "array", + "items": { + "type": "string" + } + } + } + } } }, "$id": "#/components/schemas/TimetableResponse" diff --git a/src/app.ts b/src/app.ts index 2550da0..317cc6d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -28,12 +28,25 @@ import { createNeisClient, resolveSchool, } from "./services/school.js"; -import { weekStartYmd, withSlotFlags } from "./services/timetable-extras.js"; +import { + fetchWeekSchedule, + mapScheduleToDays, + weekStartYmd, + withDeadline, + withSlotFlags, +} from "./services/timetable-extras.js"; import { omitNullsFromRows } from "./utils/json.js"; const REMOVE_PAREN_PATTERN = /\([^)]*\)/g; const BR_TAG_PATTERN = /\s*/g; +/** + * How long `/timetable` waits for the SchoolSchedule join once the timetable + * itself is ready. A slow schedule call drops `days` instead of slowing the + * response. + */ +const SCHEDULE_GRACE_MS = 1000; + const ClassListSchema = t.Array(t.String({ examples: ["1", "2", "3"] })); /** Korean academic year starts in March; Jan/Feb belong to the previous year. */ @@ -203,6 +216,13 @@ export const app = new Elysia({ name: "timeforschool-api" }) schoolName = school.SCHUL_NM; } + // Started before the timetable so the two upstream calls overlap. + const schedule = fetchWeekSchedule( + client, + school ?? { schoolname: schoolName }, + weekStart, + ); + const timetable = await fetchTimeTable({ schoolName, schoolCode: schoolcode ? Number(schoolcode) : undefined, @@ -223,11 +243,23 @@ export const app = new Elysia({ name: "timeforschool-api" }) ); } + const scheduleRows = await withDeadline(schedule, SCHEDULE_GRACE_MS); + return { day_time: timetable.dayTime, timetable: weekDays.map((day) => day.map(withSlotFlags)), update_date: timetable.updateDate, week_start: weekStart, + ...(scheduleRows + ? { + days: mapScheduleToDays( + scheduleRows, + weekStart, + weekDays.length, + grade, + ), + } + : {}), } as Static; }, { @@ -244,7 +276,7 @@ export const app = new Elysia({ name: "timeforschool-api" }) tags: ["Timetable"], summary: "Weekly class timetable", description: - "Fetches the class schedule. Default `source=auto` calls Comcigan and NEIS in parallel and merges them per period: whichever side has a subject is used, the shorter name wins when both do, and missing weekdays/periods fill from the other source. Empty or cancelled Comcigan periods still fill from NEIS. NEIS Saturday is ignored (stale 토요휴업일). Pin `source=comcigan` or `source=neis` to hit a single upstream. Provide schoolname, or schoolcode alone. week: 0 = this week, 1 = next week. Comcigan substitutions use `replaced` + `original`. `day_time` always comes from Comcigan and is blank when Comcigan has none. `teacher` also stays blank when Comcigan has no data.", + "Fetches the class schedule. Default `source=auto` calls Comcigan and NEIS in parallel and merges them per period: whichever side has a subject is used, the shorter name wins when both do, and missing weekdays/periods fill from the other source. Empty or cancelled Comcigan periods still fill from NEIS. NEIS Saturday is ignored (stale 토요휴업일). Pin `source=comcigan` or `source=neis` to hit a single upstream. Provide schoolname, or schoolcode alone. week: 0 = this week, 1 = next week. Comcigan substitutions use `replaced` + `original`. `day_time` always comes from Comcigan and is blank when Comcigan has none. `teacher` also stays blank when Comcigan has no data. `week_start` is the KST Monday the response describes. Each period carries `cancelled` and `makeup` flags. `days` (index-aligned with `timetable`) marks holidays and lists the grade's NEIS calendar events; it is fetched in parallel and omitted if the calendar lookup fails.", }, mapResponse: httpCache(CacheControl.timetable), response: { diff --git a/src/schemas/responses.ts b/src/schemas/responses.ts index 91c7705..4b4e2cc 100644 --- a/src/schemas/responses.ts +++ b/src/schemas/responses.ts @@ -214,6 +214,24 @@ export const TimetablePeriodSchema = t.Object({ }), }); +export const TimetableDaySchema = t.Object({ + date: t.String({ + pattern: "^\\d{8}$", + description: "Calendar date (YYYYMMDD) of this timetable day.", + examples: ["20261009"], + }), + closed: t.Boolean({ + description: + "true when NEIS SchoolSchedule marks the day as a holiday (`공휴일`) or school closure (`휴업일`) for this grade. The timetable for that day may still repeat the holiday name in every period.", + examples: [true], + }), + events: t.Array(t.String(), { + description: + "SchoolSchedule `EVENT_NM`s for this day that apply to the requested grade, without `토요휴업일`. Empty when there are none.", + examples: [["한글날"]], + }), +}); + export const TimetableResponseSchema = t.Object({ day_time: t.Array(t.String({ examples: ["09:00"] }), { description: @@ -270,4 +288,19 @@ export const TimetableResponseSchema = t.Object({ "Monday of the week this response describes, as YYYYMMDD in KST. `week=0` is the current Mon–Sun week in Korea, `week=1` the one after. Keep it with a cached response: a cached `week=1` whose `week_start` is now the current Monday has become this week.", examples: ["20261005"], }), + days: t.Optional( + t.Array(TimetableDaySchema, { + description: + "Per-day status from NEIS SchoolSchedule, index-aligned with `timetable` (`days[i]` describes `timetable[i]`, starting at `week_start`). Only rows that apply to the requested grade count. A week with no schedule rows returns every day with `closed: false` and `events: []`. **Omitted** when the schedule lookup fails or is slow; the timetable is still returned.", + examples: [ + [ + { date: "20261005", closed: true, events: ["대체공휴일"] }, + { date: "20261006", closed: false, events: [] }, + { date: "20261007", closed: false, events: ["중간고사"] }, + { date: "20261008", closed: false, events: ["중간고사"] }, + { date: "20261009", closed: true, events: ["한글날"] }, + ], + ], + }), + ), }); diff --git a/src/services/timetable-extras.ts b/src/services/timetable-extras.ts index 54a0256..887316f 100644 --- a/src/services/timetable-extras.ts +++ b/src/services/timetable-extras.ts @@ -1,4 +1,11 @@ -import { weekYmdRange } from "@timeforschool/client/timetable"; +import { + NeisDataNotFoundError, + type NeisClient, + type SchoolInfoRow, + type SchoolScheduleRow, +} from "@timeforschool/client"; +import { addDaysYmd, weekYmdRange } from "@timeforschool/client/timetable"; +import { resolveSchool } from "./school.js"; /** * `YYYYMMDD` Monday of the week a `/timetable` response describes, in KST @@ -35,3 +42,112 @@ export function withSlotFlags( makeup: slot.subject.startsWith(MAKEUP_PREFIX), }; } + +/** NEIS SchoolSchedule column saying whether a row applies to grade N (1–6). */ +const GRADE_EVENT_COLUMNS = [ + "ONE_GRADE_EVENT_YN", + "TW_GRADE_EVENT_YN", + "THREE_GRADE_EVENT_YN", + "FR_GRADE_EVENT_YN", + "FIV_GRADE_EVENT_YN", + "SIX_GRADE_EVENT_YN", +] as const satisfies readonly (keyof SchoolScheduleRow)[]; + +type GradeEventColumns = Pick< + SchoolScheduleRow, + (typeof GRADE_EVENT_COLUMNS)[number] +>; + +/** true when the row's `*_GRADE_EVENT_YN` for `grade` is `"Y"` (NEIS uses `"N"` or `"*"` otherwise). */ +export function appliesToGrade(row: GradeEventColumns, grade: number): boolean { + const column = GRADE_EVENT_COLUMNS[grade - 1]; + return column !== undefined && row[column] === "Y"; +} + +export function filterScheduleByGrade( + rows: T[], + grade: number, +): T[] { + return rows.filter((row) => appliesToGrade(row, grade)); +} + +export interface TimetableDay { + date: string; + closed: boolean; + events: string[]; +} + +const CLOSED_DAY_KINDS = new Set(["공휴일", "휴업일"]); +/** Every Saturday carries one; it says nothing about the week. */ +const IGNORED_EVENTS = new Set(["토요휴업일"]); + +/** + * One entry per timetable day (index-aligned, starting at `weekStart`), from + * SchoolSchedule rows. Rows for other grades and `토요휴업일` are dropped; + * `closed` is true when a kept row is a `공휴일` or `휴업일`. + */ +export function mapScheduleToDays( + rows: SchoolScheduleRow[], + weekStart: string, + dayCount: number, + grade: number, +): TimetableDay[] { + const kept = filterScheduleByGrade(rows, grade).filter( + (row) => !IGNORED_EVENTS.has(row.EVENT_NM), + ); + return Array.from({ length: dayCount }, (_, index) => { + const date = addDaysYmd(weekStart, index); + const dayRows = kept.filter((row) => row.AA_YMD === date); + const events = [ + ...new Set(dayRows.map((row) => row.EVENT_NM).filter(Boolean)), + ]; + return { + date, + closed: dayRows.some((row) => CLOSED_DAY_KINDS.has(row.SBTR_DD_SC_NM)), + events, + }; + }); +} + +/** + * SchoolSchedule rows for Mon–Sat of `weekStart`. Never rejects: an empty + * range resolves to `[]`, any failure to `undefined`. + */ +export async function fetchWeekSchedule( + client: NeisClient, + school: SchoolInfoRow | { schoolname: string }, + weekStart: string, +): Promise { + try { + const resolved = + "SD_SCHUL_CODE" in school + ? school + : await resolveSchool({ schoolname: school.schoolname }, client); + return await client.schoolSchedule({ + ATPT_OFCDC_SC_CODE: resolved.ATPT_OFCDC_SC_CODE, + SD_SCHUL_CODE: resolved.SD_SCHUL_CODE, + AA_FROM_YMD: weekStart, + AA_TO_YMD: addDaysYmd(weekStart, 5), + }); + } catch (error) { + if (error instanceof NeisDataNotFoundError) return []; + console.warn("Timetable schedule join failed:", error); + return undefined; + } +} + +/** Resolve `promise`, or `undefined` if it takes longer than `ms`. */ +export async function withDeadline( + promise: Promise, + ms: number, +): Promise { + let timer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), ms); + }); + try { + return await Promise.race([promise, deadline]); + } finally { + clearTimeout(timer); + } +} diff --git a/src/test/timetable-extras.ts b/src/test/timetable-extras.ts index 545678e..c5729cd 100644 --- a/src/test/timetable-extras.ts +++ b/src/test/timetable-extras.ts @@ -1,7 +1,21 @@ /** * Unit tests for /timetable and /schedule extras (no network). */ -import { weekStartYmd, withSlotFlags } from "../services/timetable-extras.js"; +import { + NeisDataNotFoundError, + type NeisClient, + type SchoolInfoRow, + type SchoolScheduleRow, +} from "@timeforschool/client"; +import { + appliesToGrade, + fetchWeekSchedule, + filterScheduleByGrade, + mapScheduleToDays, + weekStartYmd, + withDeadline, + withSlotFlags, +} from "../services/timetable-extras.js"; import { assert } from "./helpers.js"; function eq(actual: T, expected: T, message: string): void { @@ -107,6 +121,179 @@ function testSlotFlags(): void { eq(midMakeup.makeup, false, "[보강] only counts as a prefix"); } -testWeekStart(); -testSlotFlags(); -console.log("✓ timetable extras: week_start, slot flags"); +function scheduleRow( + date: string, + name: string, + kind: string, + grades: string = "YYY***", +): SchoolScheduleRow { + const [one, two, three, four, five, six] = grades.split(""); + return { + ATPT_OFCDC_SC_CODE: "B10", + ATPT_OFCDC_SC_NM: "서울특별시교육청", + SD_SCHUL_CODE: "7010208", + SCHUL_NM: "양정고등학교", + AY: "2026", + DGHT_CRSE_SC_NM: "주간", + SCHUL_CRSE_SC_NM: "고등학교", + SBTR_DD_SC_NM: kind, + AA_YMD: date, + EVENT_NM: name, + EVENT_CNTNT: "", + ONE_GRADE_EVENT_YN: one, + TW_GRADE_EVENT_YN: two, + THREE_GRADE_EVENT_YN: three, + FR_GRADE_EVENT_YN: four, + FIV_GRADE_EVENT_YN: five, + SIX_GRADE_EVENT_YN: six, + LOAD_DTM: "20261002000000", + } as SchoolScheduleRow; +} + +// NEIS SchoolSchedule for 양정고 2026-10-05..10, as fetched on 2026-10-02. +const OCTOBER_WEEK = [ + scheduleRow("20261005", "대체공휴일", "공휴일"), + scheduleRow("20261007", "중간고사", "해당없음"), + scheduleRow("20261008", "중간고사", "해당없음"), + scheduleRow("20261009", "한글날", "공휴일"), + scheduleRow("20261010", "토요휴업일", "휴업일"), +]; + +function testGradeFilter(): void { + const third = scheduleRow("20261006", "3학년 모의고사", "해당없음", "NNY***"); + eq(appliesToGrade(third, 3), true, "Y applies"); + eq(appliesToGrade(third, 1), false, "N does not apply"); + eq(appliesToGrade(third, 4), false, "* does not apply"); + eq(appliesToGrade(third, 7), false, "out-of-range grade never applies"); + eq( + filterScheduleByGrade([...OCTOBER_WEEK, third], 1).map((row) => row.EVENT_NM), + ["대체공휴일", "중간고사", "중간고사", "한글날", "토요휴업일"], + "grade 1 keeps school-wide rows, drops grade-3 rows, keeps raw rows", + ); + eq(filterScheduleByGrade(OCTOBER_WEEK, 4), [], "grade 4 at a high school matches nothing"); +} + +function testScheduleDays(): void { + eq( + mapScheduleToDays(OCTOBER_WEEK, "20261005", 5, 1), + [ + { date: "20261005", closed: true, events: ["대체공휴일"] }, + { date: "20261006", closed: false, events: [] }, + { date: "20261007", closed: false, events: ["중간고사"] }, + { date: "20261008", closed: false, events: ["중간고사"] }, + { date: "20261009", closed: true, events: ["한글날"] }, + ], + "October week: Monday and Friday closed, exam days open", + ); + + eq( + mapScheduleToDays(OCTOBER_WEEK, "20261005", 6, 1)[5], + { date: "20261010", closed: false, events: [] }, + "토요휴업일 is dropped", + ); + + eq( + mapScheduleToDays(OCTOBER_WEEK, "20261005", 5, 4).map((day) => day.closed), + [false, false, false, false, false], + "rows for other grades do not close the day", + ); + + eq( + mapScheduleToDays( + [ + scheduleRow("20261229", "방학식", "해당없음"), + scheduleRow("20261229", "방학식", "해당없음"), + scheduleRow("20261231", "겨울방학", "휴업일"), + scheduleRow("20270101", "신정", "공휴일"), + ], + "20261228", + 5, + 2, + ), + [ + { date: "20261228", closed: false, events: [] }, + { date: "20261229", closed: false, events: ["방학식"] }, + { date: "20261230", closed: false, events: [] }, + { date: "20261231", closed: true, events: ["겨울방학"] }, + { date: "20270101", closed: true, events: ["신정"] }, + ], + "year boundary, 휴업일 closes, duplicate names collapse", + ); + + eq(mapScheduleToDays([], "20261005", 5, 1).length, 5, "empty rows still give one entry per day"); + eq(mapScheduleToDays(OCTOBER_WEEK, "20261005", 0, 1), [], "no timetable days, no entries"); +} + +async function testFetchWeekSchedule(): Promise { + const school = { ATPT_OFCDC_SC_CODE: "B10", SD_SCHUL_CODE: "7010208" } as SchoolInfoRow; + const requests: unknown[] = []; + const client = (behaviour: () => Promise) => + ({ + schoolSchedule: (params: unknown) => { + requests.push(params); + return behaviour(); + }, + }) as unknown as NeisClient; + + const rows = await fetchWeekSchedule( + client(async () => OCTOBER_WEEK), + school, + "20261005", + ); + eq(rows?.length, 5, "rows pass through"); + eq( + requests[0], + { + ATPT_OFCDC_SC_CODE: "B10", + SD_SCHUL_CODE: "7010208", + AA_FROM_YMD: "20261005", + AA_TO_YMD: "20261010", + }, + "requests Monday to Saturday", + ); + + const empty = await fetchWeekSchedule( + client(async () => { + throw new NeisDataNotFoundError("INFO-200", "해당하는 데이터가 없습니다."); + }), + school, + "20261005", + ); + eq(empty, [], "an empty range is [] (days with no events)"); + + const originalWarn = console.warn; + console.warn = () => {}; + try { + const failed = await fetchWeekSchedule( + client(async () => { + throw new Error("ECONNRESET"); + }), + school, + "20261005", + ); + eq(failed, undefined, "an outage is undefined (days omitted), never a throw"); + } finally { + console.warn = originalWarn; + } +} + +async function testDeadline(): Promise { + eq(await withDeadline(Promise.resolve(1), 50), 1, "fast promise wins"); + const slow = new Promise((resolve) => setTimeout(() => resolve(2), 200)); + eq(await withDeadline(slow, 10), undefined, "slow promise is cut off"); +} + +async function run(): Promise { + testWeekStart(); + testSlotFlags(); + testGradeFilter(); + testScheduleDays(); + await testFetchWeekSchedule(); + await testDeadline(); + console.log("✓ timetable extras: week_start, slot flags, grade filter, days"); +} + +run().catch((error) => { + console.error(error); + process.exit(1); +}); From 24a11d5abc15de04246b3dcd0a4f18203e592092 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 05:08:41 +0000 Subject: [PATCH 5/6] feat(schedule): optional grade filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /schedule accepts an optional grade (1–6). When set, only rows whose matching *_GRADE_EVENT_YN is "Y" are returned, in their raw NEIS shape. An empty result after filtering is still 404 NEIS_DATA_NOT_FOUND (with `grade` added to its details). Without grade, behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DvQgaoFCY6wDChdDYQbV8D --- apps/docs/content/docs/api/schedule.mdx | 5 +++- apps/docs/openapi/openapi.json | 31 ++++++++++++++++++++++++- src/app.ts | 20 ++++++++++++++-- src/test/http-cache.ts | 8 +++++++ 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/apps/docs/content/docs/api/schedule.mdx b/apps/docs/content/docs/api/schedule.mdx index 629e008..53ead3c 100644 --- a/apps/docs/content/docs/api/schedule.mdx +++ b/apps/docs/content/docs/api/schedule.mdx @@ -13,7 +13,10 @@ _openapi: contents: - content: >- Returns NEIS SchoolSchedule rows between startdate and enddate - (inclusive). + (inclusive), in their raw NEIS shape. Optional `grade` keeps only rows + whose matching `*_GRADE_EVENT_YN` column (`ONE_GRADE_EVENT_YN` for + grade 1 … `SIX_GRADE_EVENT_YN` for grade 6) is `Y`. An empty result, + with or without `grade`, is `404 NEIS_DATA_NOT_FOUND`. heading: school-calendar-events --- diff --git a/apps/docs/openapi/openapi.json b/apps/docs/openapi/openapi.json index b492122..de0948a 100644 --- a/apps/docs/openapi/openapi.json +++ b/apps/docs/openapi/openapi.json @@ -538,7 +538,7 @@ "Schedule" ], "summary": "School calendar events", - "description": "Returns NEIS SchoolSchedule rows between startdate and enddate (inclusive).", + "description": "Returns NEIS SchoolSchedule rows between startdate and enddate (inclusive), in their raw NEIS shape. Optional `grade` keeps only rows whose matching `*_GRADE_EVENT_YN` column (`ONE_GRADE_EVENT_YN` for grade 1 … `SIX_GRADE_EVENT_YN` for grade 6) is `Y`. An empty result, with or without `grade`, is `404 NEIS_DATA_NOT_FOUND`.", "parameters": [ { "name": "schoolname", @@ -591,6 +591,35 @@ ], "type": "string" } + }, + { + "name": "grade", + "in": "query", + "required": false, + "schema": { + "minimum": 1, + "maximum": 6, + "description": "Optional grade level (학년). When set, only rows whose matching `*_GRADE_EVENT_YN` is `Y` are returned.", + "examples": [ + 1 + ], + "anyOf": [ + { + "format": "numeric", + "default": 0, + "type": "string" + }, + { + "minimum": 1, + "maximum": 6, + "description": "Optional grade level (학년). When set, only rows whose matching `*_GRADE_EVENT_YN` is `Y` are returned.", + "examples": [ + 1 + ], + "type": "number" + } + ] + } } ], "responses": { diff --git a/src/app.ts b/src/app.ts index 317cc6d..616ec4b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -30,6 +30,7 @@ import { } from "./services/school.js"; import { fetchWeekSchedule, + filterScheduleByGrade, mapScheduleToDays, weekStartYmd, withDeadline, @@ -347,12 +348,16 @@ export const app = new Elysia({ name: "timeforschool-api" }) async ({ query }) => { const client = createNeisClient(); const school = await resolveSchool(query, client); - const rows = await client.schoolSchedule({ + const allRows = await client.schoolSchedule({ ATPT_OFCDC_SC_CODE: school.ATPT_OFCDC_SC_CODE, SD_SCHUL_CODE: school.SD_SCHUL_CODE, AA_FROM_YMD: query.startdate, AA_TO_YMD: query.enddate, }); + const rows = + query.grade === undefined + ? allRows + : filterScheduleByGrade(allRows, query.grade); if (rows.length === 0) { throw ApiError.neisDataNotFound({ @@ -360,6 +365,7 @@ export const app = new Elysia({ name: "timeforschool-api" }) startdate: query.startdate, enddate: query.enddate, schoolname: school.SCHUL_NM, + ...(query.grade === undefined ? {} : { grade: String(query.grade) }), }); } @@ -371,12 +377,22 @@ export const app = new Elysia({ name: "timeforschool-api" }) t.Object({ startdate: DateYmd, enddate: DateYmd, + grade: t.Optional( + t.Numeric({ + minimum: 1, + maximum: 6, + description: + "Optional grade level (학년). When set, only rows whose matching `*_GRADE_EVENT_YN` is `Y` are returned.", + examples: [1], + }), + ), }), ]), detail: { tags: ["Schedule"], summary: "School calendar events", - description: "Returns NEIS SchoolSchedule rows between startdate and enddate (inclusive).", + description: + "Returns NEIS SchoolSchedule rows between startdate and enddate (inclusive), in their raw NEIS shape. Optional `grade` keeps only rows whose matching `*_GRADE_EVENT_YN` column (`ONE_GRADE_EVENT_YN` for grade 1 … `SIX_GRADE_EVENT_YN` for grade 6) is `Y`. An empty result, with or without `grade`, is `404 NEIS_DATA_NOT_FOUND`.", }, mapResponse: httpCache(CacheControl.calendar), response: { diff --git a/src/test/http-cache.ts b/src/test/http-cache.ts index f757c59..abd945b 100644 --- a/src/test/http-cache.ts +++ b/src/test/http-cache.ts @@ -84,6 +84,14 @@ async function run(): Promise { assert(invalid.headers.get("cache-control") === "no-store", "400 is no-store"); assert(!invalid.headers.has("etag"), "400 has no ETag"); + const invalidGrade = await app.handle( + new Request( + "http://127.0.0.1/schedule?schoolcode=7010208&startdate=20261005&enddate=20261010&grade=7", + ), + ); + assert(invalidGrade.status === 400, "/schedule grade outside 1–6 is 400"); + assert(invalidGrade.headers.get("cache-control") === "no-store", "/schedule 400 is no-store"); + console.log("✓ http cache: ETag, If-None-Match, Cache-Control"); } From f13bdb15667fd0e5713e905a5202394803762010 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 05:09:36 +0000 Subject: [PATCH 6/6] docs(timetable): correct day_time and document original - day_time: the example was "09:00"; the real format is "(HH:MM)" (e.g. "1(08:10)"), start times only, and the array is empty when Comcigan has none. - original: documented as {period, subject, teacher} or null, with the exam-day cancelled lesson as an example. Validation is unchanged. - Timetable example now shows a cancelled and a make-up period. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DvQgaoFCY6wDChdDYQbV8D --- apps/docs/openapi/openapi.json | 45 +++++++++++++++++++++--- src/schemas/responses.ts | 62 ++++++++++++++++++++++++++-------- 2 files changed, 87 insertions(+), 20 deletions(-) diff --git a/apps/docs/openapi/openapi.json b/apps/docs/openapi/openapi.json index de0948a..6e6448f 100644 --- a/apps/docs/openapi/openapi.json +++ b/apps/docs/openapi/openapi.json @@ -1020,18 +1020,22 @@ ], "properties": { "day_time": { - "description": "Period start times from Comcigan. Empty when only NEIS data is available (NEIS does not publish bell times).", + "description": "Bell times from Comcigan, one string per period in the form `\"(HH:MM)\"`, e.g. `\"1(08:10)\"`. Start times only, no end times. Always taken from Comcigan, so it is an empty array when Comcigan has none (`source=neis`, schools not on Comcigan, or Comcigan unreachable). NEIS does not publish bell times.", "examples": [ [ - "09:00", - "09:50", - "10:00" + "1(08:10)", + "2(09:10)", + "3(10:10)", + "4(11:10)", + "5(13:10)", + "6(14:10)", + "7(15:10)" ] ], "type": "array", "items": { "examples": [ - "09:00" + "1(08:10)" ], "type": "string" } @@ -1070,6 +1074,28 @@ }, "cancelled": false, "makeup": false + }, + { + "period": 4, + "subject": "", + "teacher": "", + "replaced": true, + "original": { + "period": 4, + "subject": "국사", + "teacher": "김기*" + }, + "cancelled": true, + "makeup": false + }, + { + "period": 5, + "subject": "[보강]음악 연주와 창작", + "teacher": "", + "replaced": false, + "original": null, + "cancelled": false, + "makeup": true } ] ] @@ -1116,6 +1142,15 @@ "type": "boolean" }, "original": { + "description": "The lesson Comcigan originally scheduled for this slot: an object `{period, subject, teacher}` when `replaced` is true, otherwise `null`. Always `null` for `source=neis`. When the lesson was cancelled (e.g. on exam days), `subject` is empty, `cancelled` is true and `original` names the dropped lesson.", + "examples": [ + null, + { + "period": 1, + "subject": "국사", + "teacher": "김기*" + } + ], "anyOf": [ { "type": "object", diff --git a/src/schemas/responses.ts b/src/schemas/responses.ts index 4b4e2cc..6de4530 100644 --- a/src/schemas/responses.ts +++ b/src/schemas/responses.ts @@ -176,18 +176,28 @@ export const TimetableLectureSchema = t.Object({ }); /** Pre-substitution class; null when unchanged, object when replaced (may be partial). */ -export const TimetableOriginalSchema = t.Union([ - TimetableLectureSchema, - t.Null(), - t.Object( - { - period: t.Optional(t.Number()), - subject: t.Optional(t.String()), - teacher: t.Optional(t.String()), - }, - { additionalProperties: true }, - ), -]); +export const TimetableOriginalSchema = t.Union( + [ + TimetableLectureSchema, + t.Null(), + t.Object( + { + period: t.Optional(t.Number()), + subject: t.Optional(t.String()), + teacher: t.Optional(t.String()), + }, + { additionalProperties: true }, + ), + ], + { + description: + "The lesson Comcigan originally scheduled for this slot: an object `{period, subject, teacher}` when `replaced` is true, otherwise `null`. Always `null` for `source=neis`. When the lesson was cancelled (e.g. on exam days), `subject` is empty, `cancelled` is true and `original` names the dropped lesson.", + examples: [ + null, + { period: 1, subject: "국사", teacher: "김기*" }, + ], + }, +); export const TimetablePeriodSchema = t.Object({ period: t.Number({ examples: [1] }), @@ -233,10 +243,10 @@ export const TimetableDaySchema = t.Object({ }); export const TimetableResponseSchema = t.Object({ - day_time: t.Array(t.String({ examples: ["09:00"] }), { + day_time: t.Array(t.String({ examples: ["1(08:10)"] }), { description: - "Period start times from Comcigan. Empty when only NEIS data is available (NEIS does not publish bell times).", - examples: [["09:00", "09:50", "10:00"]], + "Bell times from Comcigan, one string per period in the form `\"(HH:MM)\"`, e.g. `\"1(08:10)\"`. Start times only, no end times. Always taken from Comcigan, so it is an empty array when Comcigan has none (`source=neis`, schools not on Comcigan, or Comcigan unreachable). NEIS does not publish bell times.", + examples: [["1(08:10)", "2(09:10)", "3(10:10)", "4(11:10)", "5(13:10)", "6(14:10)", "7(15:10)"]], }), timetable: t.Array(t.Array(TimetablePeriodSchema), { examples: [ @@ -273,6 +283,28 @@ export const TimetableResponseSchema = t.Object({ cancelled: false, makeup: false, }, + { + period: 4, + subject: "", + teacher: "", + replaced: true, + original: { + period: 4, + subject: "국사", + teacher: "김기*", + }, + cancelled: true, + makeup: false, + }, + { + period: 5, + subject: "[보강]음악 연주와 창작", + teacher: "", + replaced: false, + original: null, + cancelled: false, + makeup: true, + }, ], ], ],