From cb7769dabf791cb020148d08d4269cfb82230062 Mon Sep 17 00:00:00 2001 From: sandrawillow001-afk Date: Tue, 29 Sep 2026 00:43:05 +0000 Subject: [PATCH] fix(frontend): RFC 5545-correct calendar export with tests (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calendar export shipped but was not conformant and had drifted: the generator accepted an `allDay` flag and silently ignored it, titles containing commas were truncated because TEXT values were never escaped, long lines were not folded, invalid dates produced unparseable output, and the two project pages emitted different UIDs and descriptions for the same milestones. There were no tests. - generateICS: escape TEXT per RFC 5545 ยง3.3.11, fold lines at 75 octets without splitting UTF-8 (ยง3.1), render all-day events with an exclusive DTEND, drop events with unusable dates/UIDs/summaries instead of emitting NaN, and guard downloadICS outside the browser. - project-calendar: one shared builder used by both project pages, emitting a stable event per milestone due date plus a project deadline event that is skipped when a milestone already covers that day. - types/hook: expose the on-chain `deadline` on Project so the deadline event can actually be built. - tests: 43 cases covering escaping, folding, injection of invalid input, the deadline deduplication rule and the project-to-.ics path. - docs: document the event model, UIDs and conformance rules. ๐Ÿค– Generated with Codebuff Co-Authored-By: Codebuff --- docs/frontend/calendar-export.md | 55 +++++ .../[locale]/dashboard/projects/[id]/page.tsx | 31 +-- frontend/app/dashboard/projects/[id]/page.tsx | 24 +- frontend/lib/__tests__/generateICS.test.ts | 224 +++++++++++++++++ .../lib/__tests__/project-calendar.test.ts | 226 ++++++++++++++++++ frontend/lib/generateICS.ts | 176 ++++++++++++-- frontend/lib/hooks/useAgenticPay.ts | 1 + frontend/lib/project-calendar.ts | 130 ++++++++++ frontend/lib/types.ts | 2 + 9 files changed, 820 insertions(+), 49 deletions(-) create mode 100644 docs/frontend/calendar-export.md create mode 100644 frontend/lib/__tests__/generateICS.test.ts create mode 100644 frontend/lib/__tests__/project-calendar.test.ts create mode 100644 frontend/lib/project-calendar.ts diff --git a/docs/frontend/calendar-export.md b/docs/frontend/calendar-export.md new file mode 100644 index 00000000..d4db3395 --- /dev/null +++ b/docs/frontend/calendar-export.md @@ -0,0 +1,55 @@ +# Calendar export (#65) + +The project detail page exposes an **Add to Calendar** button that downloads an +`.ics` file containing the project's deadlines so they can be added to Google, +Apple or Outlook calendars. + +## Pieces + +| Module | Responsibility | +|--------|----------------| +| `frontend/lib/generateICS.ts` | RFC 5545 serialisation: escaping, line folding, all-day/timed events, `downloadICS` | +| `frontend/lib/project-calendar.ts` | Turns a project into events: milestone due dates plus the project deadline, with stable UIDs | + +Both the default and localized project pages call +`buildProjectCalendarEvents`, so the two routes no longer drift (they previously +emitted different `UID`s and descriptions for the same milestone, and only one of +them requested all-day events โ€” a flag the generator used to ignore). + +## What gets exported + +- **One event per milestone due date** โ€” `UID` + `agenticpay-project--milestone-@agenticpay`. +- **One event for the project deadline** โ€” `UID` + `agenticpay-project--deadline@agenticpay`. It is skipped when a + milestone already falls on that same UTC day, because the contract currently + maps the project deadline onto a single milestone and duplicating it would + clutter the calendar. + +UIDs are stable across exports, so re-importing updates existing entries instead +of creating duplicates. + +## RFC 5545 details + +- **TEXT escaping (ยง3.3.11).** `\`, `;`, `,` and newlines in + `SUMMARY`/`DESCRIPTION`/`LOCATION` are escaped. Without this a title such as + `Redesign, phase 2` is truncated by most clients. +- **Line folding (ยง3.1).** Content lines longer than 75 octets are folded with + CRLF + a single space, and multi-byte UTF-8 characters are never split. +- **All-day events.** Due dates are dates, not instants, so they are emitted as + `DTSTART;VALUE=DATE:YYYYMMDD` with an **exclusive** `DTEND` on the following + day. Pass `allDay: false` for timed events (default duration one hour). +- **Invalid input is skipped.** Events with missing/unparseable dates, UIDs or + summaries are dropped rather than emitting `NaN` fields, which would make the + entire calendar unparseable. +- **CRLF line endings**, `VERSION:2.0`, `CALSCALE:GREGORIAN`, `METHOD:PUBLISH` + and a `PRODID`. + +## Tests + +`frontend/lib/__tests__/generateICS.test.ts` covers escaping, folding, +all-day/timed rendering, invalid input and the envelope. + +`frontend/lib/__tests__/project-calendar.test.ts` covers event building, the +deadline milestone deduplication rule, filenames and the end-to-end +project-to-`.ics` path (including titles containing commas). diff --git a/frontend/app/[locale]/dashboard/projects/[id]/page.tsx b/frontend/app/[locale]/dashboard/projects/[id]/page.tsx index beec73eb..d6bdc0d2 100644 --- a/frontend/app/[locale]/dashboard/projects/[id]/page.tsx +++ b/frontend/app/[locale]/dashboard/projects/[id]/page.tsx @@ -23,6 +23,7 @@ import { MarkdownContent } from '@/components/markdown/MarkdownContent'; import { CopyButton } from '@/components/ui/copy-button'; import { parseEther } from 'viem'; import { generateICS, downloadICS } from '@/lib/generateICS'; +import { buildProjectCalendarEvents, buildProjectCalendarFilename } from '@/lib/project-calendar'; import { CommentThread } from '@/components/collaboration/CommentThread'; import { ActivityFeed } from '@/components/collaboration/ActivityFeed'; @@ -118,29 +119,23 @@ export default function ProjectDetailPage() { }; const handleAddToCalendar = () => { - const events = project.milestones - .filter((m) => m.dueDate) - .map((m) => ({ - uid: `agenticpay-project-${project.id}-milestone-${m.id}@agenticpay`, - summary: `${project.title} โ€” ${m.title}`, - description: [ - m.description, - `Project: ${project.title}`, - `Amount: ${m.amount} ${project.currency}`, - `Status: ${m.status}`, - project.githubRepo ? `Repository: ${project.githubRepo}` : undefined, - ].filter(Boolean).join('\n'), - start: new Date(m.dueDate!), - allDay: true, - })); + // Shared with the default page so both export identical, RFC 5545-correct + // calendars covering milestone dates and the project deadline (issue #65). + const events = buildProjectCalendarEvents({ + projectId: project.id, + projectTitle: project.title, + currency: project.currency, + githubRepo: project.githubRepo, + deadline: project.deadline, + milestones: project.milestones, + }); if (events.length === 0) { - toast.info('No milestone due dates to export.'); + toast.info('No project or milestone dates to export.'); return; } - const filename = `${project.title.trim().replace(/\s+/g, '-') || `project-${project.id}`}-milestones.ics`; - downloadICS(filename, generateICS(events)); + downloadICS(buildProjectCalendarFilename(project.title), generateICS(events)); toast.success('Calendar file downloaded.'); }; diff --git a/frontend/app/dashboard/projects/[id]/page.tsx b/frontend/app/dashboard/projects/[id]/page.tsx index c2fd7a7e..194d41c2 100644 --- a/frontend/app/dashboard/projects/[id]/page.tsx +++ b/frontend/app/dashboard/projects/[id]/page.tsx @@ -23,6 +23,7 @@ import { MarkdownContent } from '@/components/markdown/MarkdownContent'; import { CopyButton } from '@/components/ui/copy-button'; import { parseEther } from 'viem'; import { generateICS, downloadICS } from '@/lib/generateICS'; +import { buildProjectCalendarEvents, buildProjectCalendarFilename } from '@/lib/project-calendar'; type PendingTransaction = { functionName: string; @@ -115,21 +116,24 @@ export default function ProjectDetailPage() { }; const handleAddToCalendar = () => { - const events = project.milestones - .filter((m) => m.dueDate) - .map((m) => ({ - uid: `milestone-${m.id}@agenticpay`, - summary: `${project.title} โ€” ${m.title}`, - description: m.description ?? undefined, - start: new Date(m.dueDate!), - })); + // Shared with the localized page so both export identical, RFC 5545-correct + // calendars covering milestone dates and the project deadline (issue #65). + const events = buildProjectCalendarEvents({ + projectId: project.id, + projectTitle: project.title, + currency: project.currency, + githubRepo: project.githubRepo, + deadline: project.deadline, + milestones: project.milestones, + }); if (events.length === 0) { - toast.info('No milestone due dates to export.'); + toast.info('No project or milestone dates to export.'); return; } - downloadICS(`${project.title.replace(/\s+/g, '-')}.ics`, generateICS(events)); + downloadICS(buildProjectCalendarFilename(project.title), generateICS(events)); + toast.success('Calendar file downloaded.'); }; return ( diff --git a/frontend/lib/__tests__/generateICS.test.ts b/frontend/lib/__tests__/generateICS.test.ts new file mode 100644 index 00000000..37343644 --- /dev/null +++ b/frontend/lib/__tests__/generateICS.test.ts @@ -0,0 +1,224 @@ +/** + * generateICS tests โ€” Issue #65 + */ +import { describe, expect, it } from 'vitest'; + +import { + escapeICSText, + foldICSLine, + formatICSDate, + formatICSDateTime, + generateICS, + isValidDate, + type ICSEvent, +} from '@/lib/generateICS'; + +const NOW = new Date('2026-03-01T09:30:00Z'); + +function event(overrides: Partial = {}): ICSEvent { + return { + uid: 'agenticpay-project-1-milestone-1@agenticpay', + summary: 'Website redesign', + start: new Date('2026-04-15T00:00:00Z'), + ...overrides, + }; +} + +describe('escapeICSText', () => { + it('escapes commas so a summary is not truncated', () => { + expect(escapeICSText('Redesign, phase 2')).toBe('Redesign\\, phase 2'); + }); + + it('escapes semicolons and backslashes', () => { + expect(escapeICSText('a;b\\c')).toBe('a\\;b\\\\c'); + }); + + it('converts newlines to the \\n escape', () => { + expect(escapeICSText('line one\nline two\r\nline three')).toBe('line one\\nline two\\nline three'); + }); +}); + +describe('date formatting', () => { + it('formats a UTC date as YYYYMMDD with zero padding', () => { + expect(formatICSDate(new Date('2026-01-05T23:00:00Z'))).toBe('20260105'); + }); + + it('formats a UTC date-time as YYYYMMDDTHHMMSSZ', () => { + expect(formatICSDateTime(new Date('2026-01-05T04:07:09Z'))).toBe('20260105T040709Z'); + }); + + it('recognises invalid dates', () => { + expect(isValidDate(new Date('nope'))).toBe(false); + expect(isValidDate(new Date('2026-01-05T00:00:00Z'))).toBe(true); + expect(isValidDate(undefined)).toBe(false); + }); +}); + +describe('foldICSLine', () => { + it('leaves short lines untouched', () => { + expect(foldICSLine('SUMMARY:short')).toBe('SUMMARY:short'); + }); + + it('folds long lines with CRLF plus a leading space', () => { + const line = `DESCRIPTION:${'x'.repeat(200)}`; + const folded = foldICSLine(line); + + expect(folded).toContain('\r\n '); + for (const segment of folded.split('\r\n')) { + expect(new TextEncoder().encode(segment).length).toBeLessThanOrEqual(75); + } + }); + + it('round-trips when unfolded by a parser', () => { + const line = `DESCRIPTION:${'y'.repeat(300)}`; + const unfolded = foldICSLine(line).replace(/\r\n /g, ''); + + expect(unfolded).toBe(line); + }); + + it('never splits a multi-byte character', () => { + const line = `SUMMARY:${'รฉ'.repeat(120)}`; + const folded = foldICSLine(line); + + for (const segment of folded.split('\r\n')) { + expect(segment).not.toContain('\uFFFD'); + expect(new TextEncoder().encode(segment).length).toBeLessThanOrEqual(75); + } + expect(folded.replace(/\r\n /g, '')).toBe(line); + }); +}); + +describe('generateICS', () => { + it('emits a VCALENDAR envelope with the required properties', () => { + const ics = generateICS([event()], NOW); + const lines = ics.split('\r\n'); + + expect(lines[0]).toBe('BEGIN:VCALENDAR'); + expect(lines).toContain('VERSION:2.0'); + expect(lines).toContain('CALSCALE:GREGORIAN'); + expect(lines).toContain('PRODID:-//AgenticPay//Calendar Export//EN'); + expect(lines.at(-1)).toBe('END:VCALENDAR'); + }); + + it('uses CRLF line endings', () => { + const ics = generateICS([event()], NOW); + const lines = ics.split('\n'); + + expect(ics).toContain('\r\n'); + // Every line but the last is terminated by CRLF. + expect(lines.slice(0, -1).every((line) => line.endsWith('\r'))).toBe(true); + expect(lines.at(-1)).not.toContain('\r'); + }); + + it('renders an all-day event with an exclusive DTEND', () => { + const ics = generateICS([event({ start: new Date('2026-04-15T00:00:00Z') })], NOW); + + expect(ics).toContain('DTSTART;VALUE=DATE:20260415'); + expect(ics).toContain('DTEND;VALUE=DATE:20260416'); + expect(ics).not.toContain('DTSTART:2026'); + }); + + it('respects an explicit end date for all-day events', () => { + const ics = generateICS( + [event({ start: new Date('2026-04-15T00:00:00Z'), end: new Date('2026-04-18T00:00:00Z') })], + NOW + ); + + expect(ics).toContain('DTEND;VALUE=DATE:20260418'); + }); + + it('renders a timed event when allDay is false', () => { + const ics = generateICS( + [ + event({ + allDay: false, + start: new Date('2026-04-15T13:00:00Z'), + end: new Date('2026-04-15T14:30:00Z'), + }), + ], + NOW + ); + + expect(ics).toContain('DTSTART:20260415T130000Z'); + expect(ics).toContain('DTEND:20260415T143000Z'); + }); + + it('defaults a timed event to one hour when no end is given', () => { + const ics = generateICS([event({ allDay: false, start: new Date('2026-04-15T13:00:00Z') })], NOW); + + expect(ics).toContain('DTEND:20260415T140000Z'); + }); + + it('defaults to an all-day event when allDay is omitted', () => { + const ics = generateICS([event()], NOW); + expect(ics).toContain('DTSTART;VALUE=DATE'); + }); + + it('escapes the summary and description', () => { + const ics = generateICS( + [event({ summary: 'Redesign, phase 2', description: 'Line one\nLine two; done, maybe' })], + NOW + ); + + expect(ics).toContain('SUMMARY:Redesign\\, phase 2'); + expect(ics).toContain('Line one\\nLine two\\; done\\, maybe'); + }); + + it('stamps DTSTAMP from the provided clock', () => { + expect(generateICS([event()], NOW)).toContain('DTSTAMP:20260301T093000Z'); + }); + + it('keeps the UID stable so re-imports update instead of duplicating', () => { + const ics = generateICS([event()], NOW); + expect(ics).toContain('UID:agenticpay-project-1-milestone-1@agenticpay'); + }); + + it('includes the location when provided', () => { + expect(generateICS([event({ location: 'Remote' })], NOW)).toContain('LOCATION:Remote'); + }); + + it('skips events with an invalid start date', () => { + const ics = generateICS([event({ start: new Date('not-a-date') })], NOW); + + expect(ics).not.toContain('BEGIN:VEVENT'); + expect(ics).not.toContain('NaN'); + }); + + it('skips events without a uid or summary', () => { + const ics = generateICS([event({ uid: '' }), event({ summary: '' })], NOW); + expect(ics).not.toContain('BEGIN:VEVENT'); + }); + + it('keeps valid events when another event is invalid', () => { + const ics = generateICS( + [event({ start: new Date('bad') }), event({ uid: 'ok-1', summary: 'Good' })], + NOW + ); + + const blocks = ics.split('BEGIN:VEVENT').length - 1; + expect(blocks).toBe(1); + expect(ics).toContain('SUMMARY:Good'); + }); + + it('returns a valid empty calendar for no events', () => { + const ics = generateICS([], NOW); + expect(ics).toBe( + [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//AgenticPay//Calendar Export//EN', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'X-WR-CALNAME:AgenticPay', + 'END:VCALENDAR', + ].join('\r\n') + ); + }); + + it('folds a very long description across multiple lines', () => { + const ics = generateICS([event({ description: 'z'.repeat(400) })], NOW); + + expect(ics).toContain('\r\n '); + expect(ics.replace(/\r\n /g, '')).toContain('z'.repeat(400)); + }); +}); diff --git a/frontend/lib/__tests__/project-calendar.test.ts b/frontend/lib/__tests__/project-calendar.test.ts new file mode 100644 index 00000000..91a5db82 --- /dev/null +++ b/frontend/lib/__tests__/project-calendar.test.ts @@ -0,0 +1,226 @@ +/** + * buildProjectCalendarEvents tests โ€” Issue #65 + */ +import { describe, expect, it } from 'vitest'; + +import { generateICS } from '@/lib/generateICS'; +import { + buildProjectCalendarEvents, + buildProjectCalendarFilename, + type CalendarMilestone, +} from '@/lib/project-calendar'; + +const DUE = '2026-04-15T00:00:00.000Z'; + +function milestone(overrides: Partial = {}): CalendarMilestone { + return { + id: '1', + title: 'Project Deliverable', + description: 'Build the thing', + amount: '1.5', + status: 'pending', + dueDate: DUE, + ...overrides, + }; +} + +describe('buildProjectCalendarEvents โ€” milestone dates', () => { + it('creates one event per milestone with a due date', () => { + const events = buildProjectCalendarEvents({ + projectId: '7', + projectTitle: 'Redesign', + currency: 'ETH', + milestones: [milestone()], + }); + + expect(events).toHaveLength(1); + expect(events[0]!.uid).toBe('agenticpay-project-7-milestone-1@agenticpay'); + expect(events[0]!.summary).toBe('Redesign โ€” Project Deliverable'); + expect(events[0]!.start.toISOString()).toBe(DUE); + expect(events[0]!.allDay).toBe(true); + }); + + it('includes amount, status, description and repository in the details', () => { + const events = buildProjectCalendarEvents({ + projectId: '7', + projectTitle: 'Redesign', + currency: 'ETH', + githubRepo: 'https://github.com/a/b', + milestones: [milestone()], + }); + + const description = events[0]!.description ?? ''; + expect(description).toContain('Build the thing'); + expect(description).toContain('Project: Redesign'); + expect(description).toContain('Amount: 1.5 ETH'); + expect(description).toContain('Status: pending'); + expect(description).toContain('Repository: https://github.com/a/b'); + }); + + it('omits the currency when it is unknown', () => { + const events = buildProjectCalendarEvents({ + projectId: '7', + projectTitle: 'Redesign', + milestones: [milestone()], + }); + + expect(events[0]!.description).toContain('Amount: 1.5'); + expect(events[0]!.description).not.toContain('Amount: 1.5 '); + }); + + it('includes multiple milestone dates', () => { + const events = buildProjectCalendarEvents({ + projectId: '7', + projectTitle: 'Redesign', + milestones: [ + milestone({ id: '1', title: 'Design', dueDate: '2026-04-01T00:00:00.000Z' }), + milestone({ id: '2', title: 'Build', dueDate: '2026-05-01T00:00:00.000Z' }), + ], + }); + + expect(events).toHaveLength(2); + expect(events.map((e) => e.summary)).toEqual([ + 'Redesign โ€” Design', + 'Redesign โ€” Build', + ]); + }); + + it('skips milestones without a due date', () => { + const events = buildProjectCalendarEvents({ + projectId: '7', + projectTitle: 'Redesign', + milestones: [milestone({ dueDate: undefined })], + }); + + expect(events).toEqual([]); + }); + + it('skips milestones with an unparseable due date', () => { + const events = buildProjectCalendarEvents({ + projectId: '7', + projectTitle: 'Redesign', + milestones: [milestone({ dueDate: 'not-a-date' })], + }); + + expect(events).toEqual([]); + }); + + it('ignores duplicate milestone identifiers', () => { + const events = buildProjectCalendarEvents({ + projectId: '7', + projectTitle: 'Redesign', + milestones: [milestone(), milestone()], + }); + + expect(events).toHaveLength(1); + }); +}); + +describe('buildProjectCalendarEvents โ€” project deadline', () => { + it('adds a deadline event when no milestone falls on that day', () => { + const events = buildProjectCalendarEvents({ + projectId: '7', + projectTitle: 'Redesign', + deadline: '2026-06-30T00:00:00.000Z', + milestones: [milestone({ dueDate: '2026-04-15T00:00:00.000Z' })], + }); + + expect(events).toHaveLength(2); + const deadline = events.find((e) => e.uid === 'agenticpay-project-7-deadline@agenticpay')!; + expect(deadline.summary).toBe('Redesign โ€” project deadline'); + expect(deadline.start.toISOString()).toBe('2026-06-30T00:00:00.000Z'); + }); + + it('does not duplicate the deadline when a milestone covers the same day', () => { + const events = buildProjectCalendarEvents({ + projectId: '7', + projectTitle: 'Redesign', + deadline: DUE, + milestones: [milestone()], + }); + + expect(events).toHaveLength(1); + expect(events.some((e) => e.uid.includes('deadline'))).toBe(false); + }); + + it('still adds the deadline when a milestone is on a different time of the same day', () => { + const events = buildProjectCalendarEvents({ + projectId: '7', + projectTitle: 'Redesign', + deadline: '2026-04-15T18:00:00.000Z', + milestones: [milestone({ dueDate: DUE })], + }); + + // Same UTC day, so the milestone already represents it. + expect(events).toHaveLength(1); + }); + + it('can be told to skip the deadline event', () => { + const events = buildProjectCalendarEvents({ + projectId: '7', + projectTitle: 'Redesign', + deadline: '2026-06-30T00:00:00.000Z', + milestones: [milestone()], + includeDeadline: false, + }); + + expect(events).toHaveLength(1); + }); + + it('skips an invalid deadline', () => { + const events = buildProjectCalendarEvents({ + projectId: '7', + projectTitle: 'Redesign', + deadline: 'not-a-date', + milestones: [milestone({ dueDate: '2026-04-15T00:00:00.000Z' })], + }); + + expect(events).toHaveLength(1); + }); + + it('handles a project with only a deadline', () => { + const events = buildProjectCalendarEvents({ + projectId: '7', + projectTitle: 'Redesign', + deadline: '2026-06-30T00:00:00.000Z', + milestones: [], + }); + + expect(events).toHaveLength(1); + expect(events[0]!.uid).toBe('agenticpay-project-7-deadline@agenticpay'); + }); +}); + +describe('buildProjectCalendarEvents โ†’ generateICS', () => { + it('produces an importable calendar for milestone and deadline dates', () => { + const events = buildProjectCalendarEvents({ + projectId: '7', + projectTitle: 'Redesign, phase 2', + currency: 'ETH', + deadline: '2026-06-30T00:00:00.000Z', + milestones: [milestone({ title: 'Design, round 1' })], + }); + + const ics = generateICS(events, new Date('2026-03-01T00:00:00Z')); + + expect(ics.split('BEGIN:VEVENT').length - 1).toBe(2); + // Commas in user content are escaped, so the calendar stays parseable. + expect(ics).toContain('SUMMARY:Redesign\\, phase 2 โ€” Design\\, round 1'); + expect(ics).toContain('DTSTART;VALUE=DATE:20260415'); + expect(ics).toContain('DTSTART;VALUE=DATE:20260630'); + }); +}); + +describe('buildProjectCalendarFilename', () => { + it('slugifies the project title', () => { + expect(buildProjectCalendarFilename('Website Redesign')).toBe('Website-Redesign.ics'); + }); + + it('strips characters that are unsafe in filenames', () => { + expect(buildProjectCalendarFilename('Redesign: phase 2 / v3?')).toBe('Redesign-phase-2-v3.ics'); + }); + + it('falls back when the title slugifies to nothing', () => { + expect(buildProjectCalendarFilename('///')).toBe('project.ics'); + }); +}); diff --git a/frontend/lib/generateICS.ts b/frontend/lib/generateICS.ts index 67df975c..0dc9e309 100644 --- a/frontend/lib/generateICS.ts +++ b/frontend/lib/generateICS.ts @@ -1,48 +1,182 @@ -interface ICSEvent { +/** + * iCalendar (`.ics`) generation โ€” Issue #65 + * + * Produces RFC 5545 calendars for project deadlines and milestone due dates so + * they can be added to Google/Apple/Outlook calendars. + * + * Conformance details that matter in practice: + * + * - **TEXT escaping (ยง3.3.11)** โ€” backslashes, semicolons, commas and newlines + * must be escaped inside `SUMMARY`/`DESCRIPTION`/`LOCATION`. A title such as + * `Redesign, phase 2` otherwise truncates the field in most clients. + * - **Line folding (ยง3.1)** โ€” lines longer than 75 octets must be folded with + * CRLF + a single space, without splitting a UTF-8 sequence. + * - **All-day events** โ€” due dates are dates, not instants, so they are emitted + * as `DTSTART;VALUE=DATE:YYYYMMDD` with an **exclusive** `DTEND` on the + * following day. Previously an `allDay` flag was accepted by callers but + * silently ignored by the generator. + * - Invalid dates are skipped rather than emitted as `NaNundefinedZ`, which + * makes the whole calendar unparseable. + */ + +export interface ICSEvent { + /** Globally unique identifier; stable so re-imports update instead of duplicate. */ uid: string; summary: string; description?: string; + location?: string; start: Date; + /** Only used for timed events; ignored when `allDay` is true. */ end?: Date; + /** Emit a date-only event. Defaults to `true`, since these are due dates. */ + allDay?: boolean; +} + +/** Maximum octets per content line before folding (RFC 5545 ยง3.1). */ +const MAX_LINE_OCTETS = 75; + +const uidDomain = 'agenticpay'; + +/** Escape a value for use in an iCalendar TEXT property (RFC 5545 ยง3.3.11). */ +export function escapeICSText(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\r\n|\r|\n/g, '\\n'); +} + +function pad(value: number): string { + return String(value).padStart(2, '0'); +} + +/** `YYYYMMDD` in UTC โ€” used for `VALUE=DATE` properties. */ +export function formatICSDate(date: Date): string { + return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}`; +} + +/** `YYYYMMDDTHHMMSSZ` in UTC โ€” used for date-time properties. */ +export function formatICSDateTime(date: Date): string { + return ( + `${formatICSDate(date)}T` + + `${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z` + ); +} + +/** True when the value is a usable calendar date. */ +export function isValidDate(value: unknown): value is Date { + return value instanceof Date && !Number.isNaN(value.getTime()); +} + +/** + * Fold a content line at 75 octets, continuing with a leading space. Multi-byte + * UTF-8 characters are never split across a fold. + */ +export function foldICSLine(line: string): string { + const encoder = new TextEncoder(); + if (encoder.encode(line).length <= MAX_LINE_OCTETS) return line; + + const folded: string[] = []; + let current = ''; + let currentOctets = 0; + // Continuation lines start with a space, leaving 74 octets for content. + let limit = MAX_LINE_OCTETS; + + for (const character of line) { + const octets = encoder.encode(character).length; + if (currentOctets + octets > limit) { + folded.push(current); + current = ''; + currentOctets = 0; + limit = MAX_LINE_OCTETS - 1; + current = ' '; + currentOctets = 1; + } + current += character; + currentOctets += octets; + } + + if (current) folded.push(current); + return folded.join('\r\n'); } -function formatICSDate(date: Date): string { - return date.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; +/** Serialize a single event block. Returns `undefined` for unusable events. */ +function serializeEvent(event: ICSEvent, now: Date): string[] | undefined { + if (!event.uid || !event.summary || !isValidDate(event.start)) return undefined; + + const allDay = event.allDay ?? true; + const lines = ['BEGIN:VEVENT', `UID:${escapeICSText(event.uid)}`]; + + lines.push(`DTSTAMP:${formatICSDateTime(now)}`); + + if (allDay) { + // DTEND is exclusive: the day after the due date. + const endDate = isValidDate(event.end) + ? event.end + : new Date(event.start.getTime() + 24 * 60 * 60 * 1000); + lines.push(`DTSTART;VALUE=DATE:${formatICSDate(event.start)}`); + lines.push(`DTEND;VALUE=DATE:${formatICSDate(endDate)}`); + } else { + const end = isValidDate(event.end) + ? event.end + : new Date(event.start.getTime() + 60 * 60 * 1000); + lines.push(`DTSTART:${formatICSDateTime(event.start)}`); + lines.push(`DTEND:${formatICSDateTime(end)}`); + } + + lines.push(`SUMMARY:${escapeICSText(event.summary)}`); + if (event.description) lines.push(`DESCRIPTION:${escapeICSText(event.description)}`); + if (event.location) lines.push(`LOCATION:${escapeICSText(event.location)}`); + lines.push('END:VEVENT'); + + return lines; } -export function generateICS(events: ICSEvent[]): string { +/** + * Build an `.ics` document. Events with missing or invalid dates are dropped so + * one bad deadline cannot corrupt the export. + */ +export function generateICS(events: ICSEvent[], now: Date = new Date()): string { const lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', - 'PRODID:-//AgenticPay//Calendar Export//EN', + `PRODID:-//AgenticPay//Calendar Export//EN`, 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', + `X-WR-CALNAME:${escapeICSText('AgenticPay')}`, ]; for (const event of events) { - const end = event.end ?? new Date(event.start.getTime() + 86400000); - lines.push( - 'BEGIN:VEVENT', - `UID:${event.uid}`, - `DTSTAMP:${formatICSDate(new Date())}`, - `DTSTART:${formatICSDate(event.start)}`, - `DTEND:${formatICSDate(end)}`, - `SUMMARY:${event.summary}`, - ...(event.description ? [`DESCRIPTION:${event.description.replace(/\n/g, '\\n')}`] : []), - 'END:VEVENT', - ); + const serialized = serializeEvent(event, now); + if (serialized) lines.push(...serialized); } lines.push('END:VCALENDAR'); - return lines.join('\r\n'); + + return lines.map(foldICSLine).join('\r\n'); } +/** Trigger a browser download of generated calendar content. */ export function downloadICS(filename: string, content: string): void { + if (typeof document === 'undefined' || typeof URL.createObjectURL !== 'function') return; + const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename.endsWith('.ics') ? filename : `${filename}.ics`; + anchor.click(); URL.revokeObjectURL(url); } + +/** UID for a milestone due date, stable across exports. */ +export function milestoneEventUid(projectId: string, milestoneId: string): string { + return `agenticpay-project-${projectId}-milestone-${milestoneId}@${uidDomain}`; +} + +/** UID for a project deadline, stable across exports. */ +export function deadlineEventUid(projectId: string): string { + return `agenticpay-project-${projectId}-deadline@${uidDomain}`; +} + +export default generateICS; diff --git a/frontend/lib/hooks/useAgenticPay.ts b/frontend/lib/hooks/useAgenticPay.ts index da68d3a4..68706934 100644 --- a/frontend/lib/hooks/useAgenticPay.ts +++ b/frontend/lib/hooks/useAgenticPay.ts @@ -260,6 +260,7 @@ const formatProjectData = (data: RawProjectData): Project => { rawDepositedAmount: data.depositedAmount, rawStatus: Number(data.status), createdAt: new Date(Number(data.createdAt) * 1000).toISOString(), + deadline: new Date(Number(data.deadline) * 1000).toISOString(), githubRepo: data.githubRepo, invoiceUri: data.invoiceUri, // Pass invoiceUri milestones: milestones, diff --git a/frontend/lib/project-calendar.ts b/frontend/lib/project-calendar.ts new file mode 100644 index 00000000..6d046ac6 --- /dev/null +++ b/frontend/lib/project-calendar.ts @@ -0,0 +1,130 @@ +/** + * Project โ†’ calendar events โ€” Issue #65 + * + * Both project detail pages (default and localized) previously built their own + * event lists, which drifted: they used different `UID`s for the same milestone, + * different descriptions, and only one of them asked for all-day events. This + * module is the single source for the export, and it covers both acceptance + * criteria โ€” **project deadlines** and **milestone dates** โ€” without producing + * duplicate entries, because the contract currently maps the project deadline + * onto a single milestone. + */ + +import { + deadlineEventUid, + isValidDate, + milestoneEventUid, + type ICSEvent, +} from '@/lib/generateICS'; +import type { Milestone } from '@/lib/types'; + +/** The subset of a milestone the calendar needs. */ +export type CalendarMilestone = Pick< + Milestone, + 'id' | 'title' | 'description' | 'amount' | 'status' | 'dueDate' +>; + +export interface ProjectCalendarInput { + projectId: string; + projectTitle: string; + currency?: string; + githubRepo?: string; + /** Project-level deadline (ISO 8601). */ + deadline?: string | null; + milestones: CalendarMilestone[]; + /** Include the project deadline event. Defaults to `true`. */ + includeDeadline?: boolean; +} + +function toDate(value?: string | null): Date | undefined { + if (!value) return undefined; + const date = new Date(value); + return isValidDate(date) ? date : undefined; +} + +function sameUtcDay(a: Date, b: Date): boolean { + return ( + a.getUTCFullYear() === b.getUTCFullYear() && + a.getUTCMonth() === b.getUTCMonth() && + a.getUTCDate() === b.getUTCDate() + ); +} + +function detailLines(input: ProjectCalendarInput, extra: Array): string { + return [ + ...extra, + `Project: ${input.projectTitle}`, + input.githubRepo ? `Repository: ${input.githubRepo}` : undefined, + ] + .filter((line): line is string => Boolean(line)) + .join('\n'); +} + +/** + * Build calendar events for a project: one per milestone due date plus the + * project deadline itself. + * + * The deadline event is skipped when a milestone already falls on that same day + * (the current contract shape), so the calendar stays free of duplicates. Events + * with missing or unparseable dates are omitted. + */ +export function buildProjectCalendarEvents(input: ProjectCalendarInput): ICSEvent[] { + const deadline = toDate(input.deadline); + const events: ICSEvent[] = []; + const seenUids = new Set(); + const milestoneDays: Date[] = []; + + for (const milestone of input.milestones) { + const dueDate = toDate(milestone.dueDate); + if (!dueDate) continue; + + milestoneDays.push(dueDate); + + const uid = milestoneEventUid(input.projectId, milestone.id); + if (seenUids.has(uid)) continue; + seenUids.add(uid); + + events.push({ + uid, + summary: `${input.projectTitle} โ€” ${milestone.title}`, + description: detailLines(input, [ + milestone.description, + input.currency ? `Amount: ${milestone.amount} ${input.currency}` : `Amount: ${milestone.amount}`, + `Status: ${milestone.status}`, + ]), + start: dueDate, + allDay: true, + }); + } + + const deadlineCoveredByMilestone = + deadline !== undefined && milestoneDays.some((day) => sameUtcDay(day, deadline)); + + if (deadline && input.includeDeadline !== false && !deadlineCoveredByMilestone) { + const uid = deadlineEventUid(input.projectId); + if (!seenUids.has(uid)) { + events.push({ + uid, + summary: `${input.projectTitle} โ€” project deadline`, + description: detailLines(input, [`Deadline: ${deadline.toISOString().slice(0, 10)}`]), + start: deadline, + allDay: true, + }); + } + } + + return events; +} + +/** Filesystem-safe `.ics` filename for a project. */ +export function buildProjectCalendarFilename(projectTitle: string): string { + const slug = projectTitle + .trim() + .replace(/[^\w.-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); + + return `${slug || 'project'}.ics`; +} + +export default buildProjectCalendarEvents; diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 46de9c9a..5da9cd9b 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -30,6 +30,8 @@ export interface Project { rawStatus?: number; // Enum index milestones: Milestone[]; createdAt: string; + /** ISO 8601 project deadline as stored on-chain (issue #65). */ + deadline?: string; githubRepo?: string; invoiceUri?: string; // Added invoiceUri }