Skip to content

Commit 46e1239

Browse files
feat: add ICS export, search column picker, dark-mode PDF, and offline payment queue (#243, #244, #245, #246) (#278)
- ICS calendar export with RFC 5545 VEVENT entries and VALARM reminders - Search result column customization with localStorage persistence - Dark-mode-aware PDF receipts with Print/Screen mode toggle - Offline payment queue using IndexedDB with auto-resubmit on reconnect Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d62db7d commit 46e1239

11 files changed

Lines changed: 1022 additions & 26 deletions

File tree

src/__tests__/icsExport.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { generateICS, type ICSEvent } from "@/lib/icsExport";
2+
3+
const FIXED_STAMP = new Date("2025-01-15T12:00:00Z");
4+
5+
function makeEvent(overrides: Partial<ICSEvent> = {}): ICSEvent {
6+
return {
7+
uid: "test-uid-1@stellarsplit",
8+
summary: "Payout to Alice",
9+
description: "Invoice #42 payout",
10+
date: new Date("2025-02-01T00:00:00Z"),
11+
...overrides,
12+
};
13+
}
14+
15+
describe("generateICS — single event", () => {
16+
it("produces valid iCalendar structure", () => {
17+
const ics = generateICS([makeEvent()], FIXED_STAMP);
18+
19+
expect(ics).toContain("BEGIN:VCALENDAR");
20+
expect(ics).toContain("END:VCALENDAR");
21+
expect(ics).toContain("VERSION:2.0");
22+
expect(ics).toContain("PRODID:");
23+
expect(ics).toContain("BEGIN:VEVENT");
24+
expect(ics).toContain("END:VEVENT");
25+
expect(ics).toContain("UID:test-uid-1@stellarsplit");
26+
expect(ics).toContain("SUMMARY:Payout to Alice");
27+
});
28+
});
29+
30+
describe("generateICS — multiple events", () => {
31+
it("includes correct number of VEVENT blocks", () => {
32+
const events: ICSEvent[] = [
33+
makeEvent({ uid: "uid-1" }),
34+
makeEvent({ uid: "uid-2", summary: "Payout to Bob" }),
35+
makeEvent({ uid: "uid-3", summary: "Payout to Carol" }),
36+
];
37+
const ics = generateICS(events, FIXED_STAMP);
38+
39+
const veventCount = (ics.match(/BEGIN:VEVENT/g) || []).length;
40+
expect(veventCount).toBe(3);
41+
42+
expect(ics).toContain("UID:uid-1");
43+
expect(ics).toContain("UID:uid-2");
44+
expect(ics).toContain("UID:uid-3");
45+
});
46+
});
47+
48+
describe("generateICS — VALARM reminder", () => {
49+
it("includes a 1-day-before reminder in each event", () => {
50+
const events = [makeEvent({ uid: "a" }), makeEvent({ uid: "b" })];
51+
const ics = generateICS(events, FIXED_STAMP);
52+
53+
const alarmCount = (ics.match(/BEGIN:VALARM/g) || []).length;
54+
expect(alarmCount).toBe(2);
55+
expect(ics).toContain("TRIGGER:-P1D");
56+
expect(ics).toContain("ACTION:DISPLAY");
57+
});
58+
});
59+
60+
describe("generateICS — zero events", () => {
61+
it("returns valid iCalendar with no VEVENT", () => {
62+
const ics = generateICS([], FIXED_STAMP);
63+
64+
expect(ics).toContain("BEGIN:VCALENDAR");
65+
expect(ics).toContain("END:VCALENDAR");
66+
expect(ics).toContain("VERSION:2.0");
67+
expect(ics).toContain("CALSCALE:GREGORIAN");
68+
expect(ics).not.toContain("BEGIN:VEVENT");
69+
});
70+
});
71+
72+
describe("generateICS — date formatting", () => {
73+
it("formats dates as YYYYMMDD VALUE=DATE", () => {
74+
const event = makeEvent({ date: new Date("2025-12-25T00:00:00Z") });
75+
const ics = generateICS([event], FIXED_STAMP);
76+
77+
expect(ics).toContain("DTSTART;VALUE=DATE:20251225");
78+
});
79+
80+
it("formats DTSTAMP correctly", () => {
81+
const ics = generateICS([makeEvent()], FIXED_STAMP);
82+
83+
expect(ics).toContain("DTSTAMP:20250115T120000Z");
84+
});
85+
86+
it("pads single-digit months and days", () => {
87+
const event = makeEvent({ date: new Date("2025-03-05T00:00:00Z") });
88+
const ics = generateICS([event], FIXED_STAMP);
89+
90+
expect(ics).toContain("DTSTART;VALUE=DATE:20250305");
91+
});
92+
});

src/__tests__/invoicePDF.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { getPDFThemeClasses } from "@/lib/pdfTheme";
2+
3+
describe("getPDFThemeClasses", () => {
4+
it("print mode returns light classes when resolvedTheme is light", () => {
5+
const classes = getPDFThemeClasses("print", "light");
6+
expect(classes.container).toBe("bg-white text-black");
7+
expect(classes.subtitle).toBe("text-gray-500");
8+
expect(classes.borderRow).toBe("border-gray-200");
9+
expect(classes.borderHeaderRow).toBe("border-gray-300");
10+
expect(classes.recipientRow).toBe("border-gray-100");
11+
expect(classes.watermark).toBe("text-gray-800");
12+
});
13+
14+
it("print mode returns light classes when resolvedTheme is dark", () => {
15+
const classes = getPDFThemeClasses("print", "dark");
16+
expect(classes.container).toBe("bg-white text-black");
17+
expect(classes.subtitle).toBe("text-gray-500");
18+
expect(classes.borderRow).toBe("border-gray-200");
19+
expect(classes.borderHeaderRow).toBe("border-gray-300");
20+
expect(classes.recipientRow).toBe("border-gray-100");
21+
expect(classes.watermark).toBe("text-gray-800");
22+
});
23+
24+
it("screen mode + dark theme returns dark classes", () => {
25+
const classes = getPDFThemeClasses("screen", "dark");
26+
expect(classes.container).toBe("bg-gray-900 text-gray-100");
27+
expect(classes.subtitle).toBe("text-gray-400");
28+
expect(classes.borderRow).toBe("border-gray-700");
29+
expect(classes.borderHeaderRow).toBe("border-gray-600");
30+
expect(classes.recipientRow).toBe("border-gray-800");
31+
expect(classes.watermark).toBe("text-gray-300");
32+
});
33+
34+
it("screen mode + light theme returns light classes", () => {
35+
const classes = getPDFThemeClasses("screen", "light");
36+
expect(classes.container).toBe("bg-white text-black");
37+
expect(classes.subtitle).toBe("text-gray-500");
38+
expect(classes.borderRow).toBe("border-gray-200");
39+
expect(classes.borderHeaderRow).toBe("border-gray-300");
40+
expect(classes.recipientRow).toBe("border-gray-100");
41+
expect(classes.watermark).toBe("text-gray-800");
42+
});
43+
});

src/__tests__/offlineQueue.test.ts

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import type { QueuedPayment } from "@/lib/offlineQueue";
2+
3+
let store: Record<string, QueuedPayment> = {};
4+
5+
vi.mock("@/lib/offlineQueue", async () => {
6+
const actual = await vi.importActual<typeof import("@/lib/offlineQueue")>(
7+
"@/lib/offlineQueue"
8+
);
9+
10+
const getQueuedPayments = vi.fn(async (): Promise<QueuedPayment[]> => {
11+
return Object.values(store).sort((a, b) => a.timestamp - b.timestamp);
12+
});
13+
14+
const updatePaymentStatus = vi.fn(
15+
async (id: string, status: QueuedPayment["status"], error?: string) => {
16+
if (store[id]) {
17+
store[id] = { ...store[id], status };
18+
if (error !== undefined) store[id].error = error;
19+
}
20+
}
21+
);
22+
23+
const removePayment = vi.fn(async (id: string) => {
24+
delete store[id];
25+
});
26+
27+
async function processQueue(
28+
submitFn: (payment: QueuedPayment) => Promise<void>
29+
): Promise<Array<{ id: string; success: boolean; error?: string }>> {
30+
const payments = await getQueuedPayments();
31+
const pending = payments.filter((p) => p.status === "pending");
32+
const results: Array<{ id: string; success: boolean; error?: string }> = [];
33+
34+
for (const payment of pending) {
35+
await updatePaymentStatus(payment.id, "submitting");
36+
try {
37+
await submitFn(payment);
38+
await removePayment(payment.id);
39+
results.push({ id: payment.id, success: true });
40+
} catch (err) {
41+
const errorMsg =
42+
err instanceof Error ? err.message : "Unknown error";
43+
await updatePaymentStatus(payment.id, "failed", errorMsg);
44+
results.push({ id: payment.id, success: false, error: errorMsg });
45+
}
46+
}
47+
48+
return results;
49+
}
50+
51+
return {
52+
...actual,
53+
openDB: vi.fn(),
54+
getQueuedPayments,
55+
updatePaymentStatus,
56+
removePayment,
57+
processQueue,
58+
};
59+
});
60+
61+
const { isNetworkError, processQueue } = await import("@/lib/offlineQueue");
62+
63+
function setStore(payments: QueuedPayment[]) {
64+
store = {};
65+
for (const p of payments) store[p.id] = { ...p };
66+
}
67+
68+
beforeEach(() => {
69+
store = {};
70+
});
71+
72+
describe("isNetworkError", () => {
73+
it("returns true for TypeError with 'Failed to fetch'", () => {
74+
expect(isNetworkError(new TypeError("Failed to fetch"))).toBe(true);
75+
});
76+
77+
it("returns true for TypeError with 'network' in message", () => {
78+
expect(isNetworkError(new TypeError("A network error occurred"))).toBe(
79+
true
80+
);
81+
});
82+
83+
it("returns true for error with name NetworkError", () => {
84+
const err = new Error("something");
85+
err.name = "NetworkError";
86+
expect(isNetworkError(err)).toBe(true);
87+
});
88+
89+
it("returns true for error with name AbortError", () => {
90+
const err = new Error("aborted");
91+
err.name = "AbortError";
92+
expect(isNetworkError(err)).toBe(true);
93+
});
94+
95+
it("returns false for contract rejection errors", () => {
96+
expect(isNetworkError(new Error("invoice already funded"))).toBe(false);
97+
});
98+
99+
it("returns false for generic errors", () => {
100+
expect(isNetworkError(new Error("something went wrong"))).toBe(false);
101+
});
102+
103+
it("returns false for non-error values", () => {
104+
expect(isNetworkError("string error")).toBe(false);
105+
expect(isNetworkError(null)).toBe(false);
106+
expect(isNetworkError(undefined)).toBe(false);
107+
});
108+
});
109+
110+
describe("processQueue", () => {
111+
it("processes payments in timestamp order", async () => {
112+
const order: string[] = [];
113+
setStore([
114+
{
115+
id: "p3",
116+
invoiceId: "inv-3",
117+
amount: 300n,
118+
sender: "GABC",
119+
timestamp: 3000,
120+
status: "pending",
121+
},
122+
{
123+
id: "p1",
124+
invoiceId: "inv-1",
125+
amount: 100n,
126+
sender: "GABC",
127+
timestamp: 1000,
128+
status: "pending",
129+
},
130+
{
131+
id: "p2",
132+
invoiceId: "inv-2",
133+
amount: 200n,
134+
sender: "GABC",
135+
timestamp: 2000,
136+
status: "pending",
137+
},
138+
]);
139+
140+
const submitFn = vi.fn(async (p: QueuedPayment) => {
141+
order.push(p.id);
142+
});
143+
144+
const results = await processQueue(submitFn);
145+
146+
expect(order).toEqual(["p1", "p2", "p3"]);
147+
expect(results).toHaveLength(3);
148+
expect(results.every((r) => r.success)).toBe(true);
149+
});
150+
151+
it("removes successful payments and marks failed ones", async () => {
152+
setStore([
153+
{
154+
id: "ok",
155+
invoiceId: "inv-ok",
156+
amount: 100n,
157+
sender: "GABC",
158+
timestamp: 1000,
159+
status: "pending",
160+
},
161+
{
162+
id: "fail",
163+
invoiceId: "inv-fail",
164+
amount: 200n,
165+
sender: "GABC",
166+
timestamp: 2000,
167+
status: "pending",
168+
},
169+
]);
170+
171+
const submitFn = vi.fn(async (p: QueuedPayment) => {
172+
if (p.id === "fail") throw new Error("tx rejected");
173+
});
174+
175+
const results = await processQueue(submitFn);
176+
177+
expect(results).toEqual([
178+
{ id: "ok", success: true },
179+
{ id: "fail", success: false, error: "tx rejected" },
180+
]);
181+
182+
expect(store["ok"]).toBeUndefined();
183+
expect(store["fail"]).toBeDefined();
184+
expect(store["fail"].status).toBe("failed");
185+
expect(store["fail"].error).toBe("tx rejected");
186+
});
187+
188+
it("skips non-pending payments", async () => {
189+
setStore([
190+
{
191+
id: "pending-one",
192+
invoiceId: "inv-1",
193+
amount: 100n,
194+
sender: "GABC",
195+
timestamp: 1000,
196+
status: "pending",
197+
},
198+
{
199+
id: "already-failed",
200+
invoiceId: "inv-2",
201+
amount: 200n,
202+
sender: "GABC",
203+
timestamp: 2000,
204+
status: "failed",
205+
},
206+
]);
207+
208+
const submitFn = vi.fn(async () => {});
209+
210+
const results = await processQueue(submitFn);
211+
212+
expect(submitFn).toHaveBeenCalledTimes(1);
213+
expect(results).toHaveLength(1);
214+
expect(results[0].id).toBe("pending-one");
215+
});
216+
});

0 commit comments

Comments
 (0)