Skip to content
Merged
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
82 changes: 66 additions & 16 deletions src/api/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, string>): 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<string, string>; clean: string } {
const raw = description ?? ''
const m = META_RE.exec(raw)
if (!m) return { fields: {}, clean: raw }
const fields: Record<string, string> = {}
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' }
}
Expand Down Expand Up @@ -66,16 +95,17 @@ 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 }
}

export async function createSprint(token: string, projectId: string, sprint: Omit<Sprint, 'id' | 'projectId'>): Promise<Sprint> {
const ev = await req<CalendarEvent>(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: {
Expand All @@ -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: {
Expand All @@ -111,7 +141,16 @@ export async function deleteSprint(token: string, projectId: string, sprintId: s
export async function createWorkItem(token: string, projectId: string, item: Omit<WorkItem, 'id' | 'projectId'>): Promise<WorkItem> {
const ev = await req<CalendarEvent>(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: {
Expand All @@ -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: {
Expand Down Expand Up @@ -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 ?? '',
}
}

Expand Down
Loading