From 9e0b7367307d863eca649c3219a41160424e6cb4 Mon Sep 17 00:00:00 2001 From: jarik2014 Date: Fri, 25 Sep 2026 11:37:25 +0000 Subject: [PATCH] feat: add 'add to calendar' (.ics) action for stream end dates (#566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an accessible 'Calendar' button on /stream/[id] that downloads a standard RFC 5545 .ics invitation for a bounded stream's end date: - lib/calendar.ts — buildStreamEndIcs (UTC timestamps, TEXT escaping, 75-octet line folding, deterministic UID, VALARM reminder) and downloadIcs (Blob + anchor download with object-URL cleanup) - components/stream/AddToCalendarButton.tsx — icon button matching the PDF header button, aria-label, success/failure toasts - rendered only when info.endTime > 0 (open-ended streams have no date to schedule) - tests: lib/calendar.test.ts (27), AddToCalendarButton.test.tsx (7), StreamPage calendar-action presence/absence (2) - CHANGELOG entry, CalendarPlus lucide mock stub Closes #566 --- CHANGELOG.md | 4 + __mocks__/lucide-react.tsx | 1 + app/stream/[id]/__tests__/page.test.tsx | 50 ++++ app/stream/[id]/page.tsx | 12 + components/stream/AddToCalendarButton.tsx | 110 ++++++++ .../__tests__/AddToCalendarButton.test.tsx | 103 ++++++++ lib/calendar.test.ts | 250 ++++++++++++++++++ lib/calendar.ts | 180 +++++++++++++ 8 files changed, 710 insertions(+) create mode 100644 components/stream/AddToCalendarButton.tsx create mode 100644 components/stream/__tests__/AddToCalendarButton.test.tsx create mode 100644 lib/calendar.test.ts create mode 100644 lib/calendar.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b128c6e..0c8ad91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ All notable changes are documented here. Format based on [Keep a Changelog](http and export controls (#555) - `/stream/[id]` — **PDF** button triggers `window.print()` to produce a formatted single-stream summary document via the print stylesheet; actions and back-link hidden in print view (#571) +- `/stream/[id]` — **Calendar** button downloads a standard `.ics` invitation for the stream's + end date (one-hour event, one-hour reminder, deterministic UID so re-importing updates the + existing entry instead of duplicating it), built by the new `lib/calendar.ts`; rendered only + for bounded streams, since an open-ended stream has no end date to schedule (#566) - `app/globals.css` — `@media print` block scoped to `.print-receipt` class: resets body to white/black, collapses table borders, hides interactive chrome, preserves monospace addresses and amounts, renders status badges in monochrome, adds a print footer with the diff --git a/__mocks__/lucide-react.tsx b/__mocks__/lucide-react.tsx index 7821913..9779f72 100644 --- a/__mocks__/lucide-react.tsx +++ b/__mocks__/lucide-react.tsx @@ -28,6 +28,7 @@ export const AlertCircle = createIconStub('AlertCircle'); export const ArrowDownToLine = createIconStub('ArrowDownToLine'); export const ArrowLeft = createIconStub('ArrowLeft'); export const ArrowRight = createIconStub('ArrowRight'); +export const CalendarPlus = createIconStub('CalendarPlus'); export const Check = createIconStub('Check'); export const CheckCircle = createIconStub('CheckCircle'); export const Clock = createIconStub('Clock'); diff --git a/app/stream/[id]/__tests__/page.test.tsx b/app/stream/[id]/__tests__/page.test.tsx index 3078d67..69f3c99 100644 --- a/app/stream/[id]/__tests__/page.test.tsx +++ b/app/stream/[id]/__tests__/page.test.tsx @@ -45,6 +45,14 @@ vi.mock('@/components/stream/StreamActions', () => ({ StreamActions: ({ token }: { token: string }) => React.createElement('div', { 'data-testid': 'stream-actions' }, token), })); +vi.mock('@/components/stream/AddToCalendarButton', () => ({ + AddToCalendarButton: ({ streamId, endTime }: { streamId: string; endTime: number }) => + React.createElement('div', { + 'data-testid': 'add-to-calendar', + 'data-stream-id': streamId, + 'data-end-time': String(endTime), + }), +})); import { useWallet } from '@/contexts/WalletContext'; import { getStreamAddress, getStreamInfo, getWithdrawable } from '@/lib/stream'; @@ -269,4 +277,46 @@ describe('StreamPage (app/stream/[id]/page.tsx)', () => { // silently stale "active" state from the first request. expect(container.querySelector('[data-testid="badge"]')?.textContent).toBe('cancelled'); }); + + it('#566 — offers the calendar action with the stream end date when the stream is bounded', async () => { + mockUseWallet.mockReturnValue({ + publicKey: 'GRECIPIENT', + connected: true, + } as unknown as ReturnType); + mockAddr.mockResolvedValue('STREAM_ADDR'); + const endTime = Math.floor(Date.now() / 1000) + 3600; + mockInfo.mockResolvedValue(makeInfo({ endTime })); + mockWithdrawable.mockResolvedValue(0n); + + await act(async () => { + root.render(React.createElement(StreamPage)); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const action = container.querySelector('[data-testid="add-to-calendar"]'); + expect(action).not.toBeNull(); + expect(action?.getAttribute('data-stream-id')).toBe('42'); + expect(action?.getAttribute('data-end-time')).toBe(String(endTime)); + }); + + it('#566 — omits the calendar action for an open-ended stream (nothing to schedule)', async () => { + mockUseWallet.mockReturnValue({ + publicKey: 'GRECIPIENT', + connected: true, + } as unknown as ReturnType); + mockAddr.mockResolvedValue('STREAM_ADDR'); + mockInfo.mockResolvedValue(makeInfo({ endTime: 0 })); + mockWithdrawable.mockResolvedValue(0n); + + await act(async () => { + root.render(React.createElement(StreamPage)); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container.querySelector('[data-testid="add-to-calendar"]')).toBeNull(); + }); }); diff --git a/app/stream/[id]/page.tsx b/app/stream/[id]/page.tsx index 89cc865..2f0b48a 100644 --- a/app/stream/[id]/page.tsx +++ b/app/stream/[id]/page.tsx @@ -14,6 +14,7 @@ import { StreamFlowChart } from "@/components/stream/StreamFlowChart"; import { StreamActions } from "@/components/stream/StreamActions"; import { OperatorInfo } from "@/components/stream/OperatorInfo"; import { StreamNoteEditor } from "@/components/stream/StreamNoteEditor"; +import { AddToCalendarButton } from "@/components/stream/AddToCalendarButton"; import { useWallet } from "@/contexts/WalletContext"; import { useStreamNote } from "@/hooks/useStreamNote"; import { @@ -293,6 +294,17 @@ export default function StreamPage() {