From c3d8d9feaa3c834f1300ca56151fba674ebc7803 Mon Sep 17 00:00:00 2001 From: Isaiah Gardner <99689836+Gardner-Programs@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:16:03 -0400 Subject: [PATCH] Mirror item metadata into a description tag any client can use Plan-IT stored work item and sprint metadata only in Google Calendar extendedProperties.private, which most calendar clients (including AI assistants restricted to basic Calendar APIs) cannot read or write. That made the board invisible to anything but this app. Events now also carry a machine-readable tag as the last line of the description, e.g. [planit kind=workitem; type=feature; status=active; priority=high] Readers prefer the tag and fall back to extendedProperties, so existing events keep working, and events created or updated by external clients (which can only edit descriptions) participate fully: classification in listProjectEvents also recognizes the tag. The tag is stripped from the user-visible description on read. This turns any calendar-capable client into a Plan-IT client. --- src/api/calendar.ts | 82 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 16 deletions(-) diff --git a/src/api/calendar.ts b/src/api/calendar.ts index ea5e67d..a3589f3 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -4,6 +4,35 @@ const BASE = 'https://www.googleapis.com/calendar/v3' const PLANIT_TAG = '[Plan-IT]' const POS_RE = /\[pos:(\d+)\]/ +// ─── Plan-IT metadata tag ──────────────────────────────────────────────────── +// A machine-readable tag stored at the end of the event description, e.g. +// [planit kind=workitem; type=feature; status=active; priority=high; points=5] +// extendedProperties.private remains the legacy store, but the description tag +// is preferred when present. This lets any calendar client that can edit a +// description (including AI assistants using basic Calendar APIs) read and +// update Plan-IT metadata. Values must not contain ';' or ']'. +const META_RE = /\n?\[planit ([^\]]*)\]\s*$/ + +function buildMeta(fields: Record): string { + const body = Object.entries(fields) + .filter(([, v]) => v !== undefined && v !== null && v !== '') + .map(([k, v]) => `${k}=${v}`) + .join('; ') + return `\n\n[planit ${body}]` +} + +function parseMeta(description: string | undefined): { fields: Record; clean: string } { + const raw = description ?? '' + const m = META_RE.exec(raw) + if (!m) return { fields: {}, clean: raw } + const fields: Record = {} + for (const pair of m[1].split(';')) { + const idx = pair.indexOf('=') + if (idx > 0) fields[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim() + } + return { fields, clean: raw.replace(META_RE, '').trimEnd() } +} + function headers(token: string) { return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } } @@ -66,8 +95,9 @@ export async function listProjectEvents(token: string, projectId: string): Promi const workItems: WorkItem[] = [] for (const ev of data.items ?? []) { const p = ev.extendedProperties?.private ?? {} - if (p.planitType === 'sprint') sprints.push(eventToSprint(ev, projectId)) - else if (p.planitType === 'workitem') workItems.push(eventToWorkItem(ev, projectId)) + const kind = parseMeta(ev.description).fields.kind ?? p.planitType + if (kind === 'sprint') sprints.push(eventToSprint(ev, projectId)) + else if (kind === 'workitem') workItems.push(eventToWorkItem(ev, projectId)) } return { sprints, workItems } } @@ -75,7 +105,7 @@ export async function listProjectEvents(token: string, projectId: string): Promi export async function createSprint(token: string, projectId: string, sprint: Omit): Promise { const ev = await req(token, 'POST', `/calendars/${encodeURIComponent(projectId)}/events`, { summary: sprint.name, - description: sprint.description, + description: sprint.description + buildMeta({ kind: 'sprint', status: sprint.status }), start: { date: sprint.startDate }, end: { date: sprint.endDate }, extendedProperties: { @@ -91,7 +121,7 @@ export async function updateSprint(token: string, projectId: string, sprint: Spr `/calendars/${encodeURIComponent(projectId)}/events/${encodeURIComponent(sprint.id)}`, { summary: sprint.name, - description: sprint.description, + description: sprint.description + buildMeta({ kind: 'sprint', status: sprint.status }), start: { date: sprint.startDate }, end: { date: sprint.endDate }, extendedProperties: { @@ -111,7 +141,16 @@ export async function deleteSprint(token: string, projectId: string, sprintId: s export async function createWorkItem(token: string, projectId: string, item: Omit): Promise { const ev = await req(token, 'POST', `/calendars/${encodeURIComponent(projectId)}/events`, { summary: item.title, - description: item.description, + description: item.description + buildMeta({ + kind: 'workitem', + type: item.type, + status: item.status, + priority: item.priority, + points: String(item.storyPoints), + sprint: item.sprintId, + deadline: item.deadline, + assignee: item.assignee, + }), ...(item.deadline ? { start: { date: item.deadline }, end: { date: item.deadline } } : { start: { date: today() }, end: { date: today() } }), extendedProperties: { private: { @@ -135,7 +174,16 @@ export async function updateWorkItem(token: string, projectId: string, item: Wor `/calendars/${encodeURIComponent(projectId)}/events/${encodeURIComponent(item.id)}`, { summary: item.title, - description: item.description, + description: item.description + buildMeta({ + kind: 'workitem', + type: item.type, + status: item.status, + priority: item.priority, + points: String(item.storyPoints), + sprint: item.sprintId, + deadline: item.deadline, + assignee: item.assignee, + }), ...(item.deadline ? { start: { date: item.deadline }, end: { date: item.deadline } } : {}), extendedProperties: { private: { @@ -200,31 +248,33 @@ function calToProject(c: CalendarListEntry): Project { function eventToSprint(ev: CalendarEvent, projectId: string): Sprint { const p = ev.extendedProperties?.private ?? {} + const { fields, clean } = parseMeta(ev.description) return { id: ev.id, projectId, name: ev.summary ?? '', - description: ev.description ?? '', + description: clean, startDate: ev.start?.date ?? '', endDate: ev.end?.date ?? '', - status: (p.status as SprintStatus) ?? 'planned', + status: ((fields.status ?? p.status) as SprintStatus) ?? 'planned', } } function eventToWorkItem(ev: CalendarEvent, projectId: string): WorkItem { const p = ev.extendedProperties?.private ?? {} + const { fields, clean } = parseMeta(ev.description) return { id: ev.id, projectId, - sprintId: p.sprintId ?? '', + sprintId: fields.sprint ?? p.sprintId ?? '', title: ev.summary ?? '', - description: ev.description ?? '', - type: (p.type as WorkItemType) ?? 'task', - status: (p.status as WorkItemStatus) ?? 'planned', - priority: (p.priority as Priority) ?? 'medium', - storyPoints: Number(p.storyPoints ?? 0), - assignee: p.assignee ?? '', - deadline: p.deadline ?? '', + description: clean, + type: ((fields.type ?? p.type) as WorkItemType) ?? 'task', + status: ((fields.status ?? p.status) as WorkItemStatus) ?? 'planned', + priority: ((fields.priority ?? p.priority) as Priority) ?? 'medium', + storyPoints: Number(fields.points ?? p.storyPoints ?? 0), + assignee: fields.assignee ?? p.assignee ?? '', + deadline: fields.deadline ?? p.deadline ?? '', } }