From 79732b1961183305e87061a73921f9476664f7bc Mon Sep 17 00:00:00 2001 From: Yuna-Celisse <29951517@qq.com> Date: Mon, 28 Sep 2026 22:13:00 +0800 Subject: [PATCH] feat: use an iteration when a project item has no dates Items without a start or end date take their span from the Iteration field, on both the website feed and the Google Calendar sync. --- README.md | 15 +++++++++------ sync/src/github.js | 6 ++++++ sync/src/plan.js | 38 +++++++++++++++++++++++++++--------- test/sync.test.mjs | 48 +++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 91 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8c805ac..bb58845 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,10 @@ Calendar feeds for NBTCA, served from one Cloudflare Worker at Subscribe with `webcal://ical.nbtca.space/events.ics`. - `/school.ics` is generated from the reviewed files in `data/school/`. Subscribe with `webcal://ical.nbtca.space/school.ics`. -- `/project.ics` is generated from NBTCA Project 5 items that have a Start - date or End date. Subscribe with `webcal://ical.nbtca.space/project.ics`. +- `/project.ics` is generated from NBTCA Project 5. An item uses its Start + date and End date when either is set. Otherwise it uses its Iteration, + from the iteration start through its duration. Subscribe with + `webcal://ical.nbtca.space/project.ics`. The Worker needs a `GITHUB_TOKEN` secret with `read:project`. Each proxied or generated feed keeps its own last-good copy, so one source failing cannot replace another. @@ -47,14 +49,15 @@ version `1487e0c0-a969-454f-8bcb-55f7f01876fb`. ## Project schedule sync `sync/` is a second Worker, `nbtca-project-calendar`. Every 15 minutes it reads -organization project 5 and mirrors items with a Start date or End date onto a -separate Google Calendar. `pnpm deploy` does not publish this Worker, and it +organization project 5 and mirrors the same dated items as `/project.ics` onto +a separate Google Calendar. `pnpm deploy` does not publish this Worker, and it does not add a route to `ical.nbtca.space`. A project end date is inclusive. Google Calendar's all-day end date is exclusive, matching `endDateExclusive` in the school calendar. An item with -only one of the two dates becomes a one-day event. Items that lose both dates, -and items that leave the project, have the event created by this sync removed. +only one of the two dates becomes a one-day event. An iteration span ends +after its duration. Items with neither dates nor an iteration, and items that +leave the project, have the event created by this sync removed. Events on that calendar without this sync's private marker stay in place. Credentials stay in Worker secrets: diff --git a/sync/src/github.js b/sync/src/github.js index aa99f24..9ddc18c 100644 --- a/sync/src/github.js +++ b/sync/src/github.js @@ -36,6 +36,12 @@ const ITEMS_QUERY = ` name field { ... on ProjectV2FieldCommon { name } } } + ... on ProjectV2ItemFieldIterationValue { + title + startDate + duration + field { ... on ProjectV2FieldCommon { name } } + } } } } diff --git a/sync/src/plan.js b/sync/src/plan.js index 391a9a0..b00ef90 100644 --- a/sync/src/plan.js +++ b/sync/src/plan.js @@ -1,6 +1,7 @@ const START_FIELD = "Start date"; const END_FIELD = "End date"; const STATUS_FIELD = "Status"; +const ITERATION_FIELD = "Iteration"; export function projectMarker(owner, number) { return `${owner}/projects/${number}`; @@ -26,7 +27,15 @@ export function readProjectItem(node) { )?.date || null; const start = dated(START_FIELD); const end = dated(END_FIELD); - if (!start && !end) return null; + const iteration = values.find( + (value) => + value?.__typename === "ProjectV2ItemFieldIterationValue" && + value.field?.name === ITERATION_FIELD && + value.startDate && + Number.isInteger(value.duration) && + value.duration > 0, + ); + if (!start && !end && !iteration) return null; const content = node.content ?? {}; const status = @@ -44,22 +53,32 @@ export function readProjectItem(node) { status, start, end, + iterationTitle: iteration?.title || null, + iterationStart: iteration?.startDate || null, + iterationDuration: iteration?.duration || null, updatedAt: node.updatedAt || null, }; } -function inclusiveRange(start, end) { - const startDate = start || end; - const inclusiveEnd = end || start; - if (!startDate || !inclusiveEnd) return null; - if (inclusiveEnd < startDate) { - return { error: `end ${inclusiveEnd} is before start ${startDate}` }; +function inclusiveRange(item) { + if (item.start || item.end) { + const startDate = item.start || item.end; + const inclusiveEnd = item.end || item.start; + if (inclusiveEnd < startDate) { + return { error: `end ${inclusiveEnd} is before start ${startDate}` }; + } + return { startDate, endDateExclusive: addDays(inclusiveEnd, 1), fromIteration: false }; } - return { startDate, endDateExclusive: addDays(inclusiveEnd, 1) }; + if (!item.iterationStart || !item.iterationDuration) return null; + return { + startDate: item.iterationStart, + endDateExclusive: addDays(item.iterationStart, item.iterationDuration), + fromIteration: true, + }; } export function toDesiredEvent(item, owner, number) { - const range = inclusiveRange(item.start, item.end); + const range = inclusiveRange(item); if (!range) return null; if (range.error) return { itemId: item.itemId, error: range.error }; @@ -67,6 +86,7 @@ export function toDesiredEvent(item, owner, number) { item.url, item.repository && item.number != null ? `${item.repository}#${item.number}` : null, item.status ? `Status: ${item.status}` : null, + range.fromIteration && item.iterationTitle ? `Iteration: ${item.iterationTitle}` : null, `Project: ${projectUrl(owner, number)}`, ] .filter(Boolean) diff --git a/test/sync.test.mjs b/test/sync.test.mjs index bdb3b5a..2d69088 100644 --- a/test/sync.test.mjs +++ b/test/sync.test.mjs @@ -129,7 +129,53 @@ test("renders dated project items as a website calendar", () => { assert.match(calendar, /DTSTAMP:20260924T010203Z/); }); -test("reads only project items that have a start or end date", () => { +test("uses the iteration span when an item has no dates", () => { + const item = readProjectItem({ + id: "PVTI_iter", + updatedAt: "2026-09-01T00:00:00Z", + content: { title: "没有具体日期" }, + fieldValues: { + nodes: [ + { + __typename: "ProjectV2ItemFieldIterationValue", + title: "2026 FW", + startDate: "2026-09-01", + duration: 181, + field: { name: "Iteration" }, + }, + ], + }, + }); + const desired = toDesiredEvent(item, "nbtca", 5); + assert.equal(desired.startDate, "2026-09-01"); + assert.equal(desired.endDateExclusive, "2027-03-01"); + assert.match(desired.description, /Iteration: 2026 FW/); + + const dated = readProjectItem({ + id: "PVTI_both", + updatedAt: "2026-09-01T00:00:00Z", + content: { title: "有具体日期" }, + fieldValues: { + nodes: [ + { __typename: "ProjectV2ItemFieldDateValue", date: "2026-09-25", field: { name: "Start date" } }, + { __typename: "ProjectV2ItemFieldDateValue", date: "2026-09-30", field: { name: "End date" } }, + { + __typename: "ProjectV2ItemFieldIterationValue", + title: "2026 FW", + startDate: "2026-09-01", + duration: 181, + field: { name: "Iteration" }, + }, + ], + }, + }); + const explicit = toDesiredEvent(dated, "nbtca", 5); + assert.equal(explicit.startDate, "2026-09-25"); + assert.equal(explicit.endDateExclusive, "2026-10-01"); + assert.doesNotMatch(explicit.description, /Iteration:/); +}); + +test("skips items with neither dates nor an iteration", () => { assert.equal(readProjectItem(issueNode("PVTI_empty", "没有日期", null, null)), null); const item = readProjectItem(issueNode("PVTI_dated", "有日期", "2026-09-22", null, "In Progress")); assert.equal(item.start, "2026-09-22");