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 }