Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions sync/src/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ const ITEMS_QUERY = `
name
field { ... on ProjectV2FieldCommon { name } }
}
... on ProjectV2ItemFieldIterationValue {
title
startDate
duration
field { ... on ProjectV2FieldCommon { name } }
}
}
}
}
Expand Down
38 changes: 29 additions & 9 deletions sync/src/plan.js
Original file line number Diff line number Diff line change
@@ -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}`;
Expand All @@ -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 =
Expand All @@ -44,29 +53,40 @@ 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 };

const description = [
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)
Expand Down
48 changes: 47 additions & 1 deletion test/sync.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading