Skip to content

Commit 4331146

Browse files
feat(names): pr changes, add unit tests, update reminders calculations, update texts
1 parent c18b160 commit 4331146

10 files changed

Lines changed: 288 additions & 42 deletions

File tree

apps/names/src/components/dialogs/AddToCalendarDialog.tsx

Lines changed: 3 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,44 +8,14 @@ import { Dialog, DialogBody, DialogContent, DialogPosition, Header } from '@iota
88
import { normalizeIotaName } from '@iota/iota-names-sdk';
99

1010
import { formatDate } from '@/lib/utils/format/formatDate';
11-
import { type CalendarEvent, google, ics } from '@/lib/utils/calendar';
11+
import { buildEvent, google, ics } from '@/lib/utils/calendar';
1212

1313
interface AddToCalendarDialogProps {
1414
name: string;
1515
expirationDate: Date;
1616
setOpen: (bool: boolean) => void;
1717
}
1818

19-
function at9am(date: Date): Date {
20-
const d = new Date(date);
21-
d.setHours(9, 0, 0, 0);
22-
return d;
23-
}
24-
25-
function buildEvent(name: string, expirationDate: Date): CalendarEvent {
26-
const displayName = normalizeIotaName(name);
27-
28-
const oneMonthBefore = new Date(expirationDate);
29-
oneMonthBefore.setMonth(oneMonthBefore.getMonth() - 1);
30-
31-
const oneDayBefore = new Date(expirationDate);
32-
oneDayBefore.setDate(oneDayBefore.getDate() - 1);
33-
34-
return {
35-
title: `${displayName} – Renewal Reminder`,
36-
description: `Your IOTA name ${displayName} expires on ${formatDate(expirationDate)}. Remember to renew it at iotanames.com.`,
37-
date: expirationDate,
38-
alerts: [
39-
{
40-
triggerAt: at9am(oneMonthBefore),
41-
description: `1 month until ${displayName} expires`,
42-
},
43-
{ triggerAt: at9am(oneDayBefore), description: `1 day until ${displayName} expires` },
44-
{ triggerAt: at9am(expirationDate), description: `${displayName} expires today` },
45-
],
46-
};
47-
}
48-
4919
function downloadIcsFile(name: string, content: string): void {
5020
const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' });
5121
const url = URL.createObjectURL(blob);
@@ -116,13 +86,13 @@ export function AddToCalendarDialog({ name, expirationDate, setOpen }: AddToCale
11686
<CalendarOption
11787
icon={<Globe />}
11888
title="Google Calendar"
119-
description="Opens Google Calendar to create an event. We recommend adding reminders for 1 month and 1 day before the expiry date."
89+
description="Opens Google Calendar to create an event. Note: automatic reminders are not supported, you will need to add them manually."
12090
onClick={handleGoogleCalendar}
12191
/>
12292
<CalendarOption
12393
icon={<Export />}
12494
title="Download .ics"
125-
description="Downloads a calendar file compatible with any app. Includes reminders at 09:00 on the expiry day, 1 day before, and 1 month before."
95+
description="Downloads a calendar file compatible with any app. Includes reminders at 09:00 on the expiry day, 1 day before, 1 week before, and 1 month before."
12696
onClick={handleDownloadIcs}
12797
/>
12898
</div>
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) 2026 IOTA Stiftung
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { describe, expect, it } from 'vitest';
5+
6+
import { at9am, buildEvent } from './buildEvent';
7+
8+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
9+
10+
describe('at9am', () => {
11+
it('sets hours to 9 and zeroes minutes, seconds, milliseconds', () => {
12+
const d = new Date(2026, 2, 15, 14, 30, 45, 500);
13+
const result = at9am(d);
14+
expect(result.getHours()).toBe(9);
15+
expect(result.getMinutes()).toBe(0);
16+
expect(result.getSeconds()).toBe(0);
17+
expect(result.getMilliseconds()).toBe(0);
18+
});
19+
20+
it('does not mutate the input date', () => {
21+
const d = new Date(2026, 2, 15, 14, 0, 0, 0);
22+
at9am(d);
23+
expect(d.getHours()).toBe(14);
24+
});
25+
});
26+
27+
describe('buildEvent', () => {
28+
const expiration = new Date(2026, 5, 15); // June 15, 2026
29+
30+
it('returns 4 alerts', () => {
31+
const event = buildEvent('test.iota', expiration);
32+
expect(event.alerts).toHaveLength(4);
33+
});
34+
35+
it('sets event date to the expiration date', () => {
36+
const event = buildEvent('test.iota', expiration);
37+
expect(event.date).toBe(expiration);
38+
});
39+
40+
it('title includes the name', () => {
41+
const event = buildEvent('test.iota', expiration);
42+
expect(event.title).toContain('test');
43+
});
44+
45+
it('description references iotanames.com', () => {
46+
const event = buildEvent('test.iota', expiration);
47+
expect(event.description).toContain('iotanames.com');
48+
});
49+
50+
it('alerts are ordered from earliest to latest', () => {
51+
const event = buildEvent('test.iota', expiration);
52+
const times = event.alerts!.map((a) => a.triggerAt.getTime());
53+
expect(times).toEqual([...times].sort((a, b) => a - b));
54+
});
55+
56+
it('all alert triggers are at 09:00', () => {
57+
const event = buildEvent('test.iota', expiration);
58+
for (const alert of event.alerts!) {
59+
expect(alert.triggerAt.getHours()).toBe(9);
60+
expect(alert.triggerAt.getMinutes()).toBe(0);
61+
expect(alert.triggerAt.getSeconds()).toBe(0);
62+
}
63+
});
64+
65+
it('one-month alert is roughly 30 days before expiration', () => {
66+
const event = buildEvent('test.iota', expiration);
67+
const diffDays = (expiration.getTime() - event.alerts![0].triggerAt.getTime()) / MS_PER_DAY;
68+
expect(diffDays).toBeGreaterThanOrEqual(28);
69+
expect(diffDays).toBeLessThanOrEqual(31);
70+
});
71+
72+
it('one-week alert is exactly 7 days before expiration (at 9am)', () => {
73+
const event = buildEvent('test.iota', expiration);
74+
const expected = new Date(expiration);
75+
expected.setDate(expected.getDate() - 7);
76+
expected.setHours(9, 0, 0, 0);
77+
expect(event.alerts![1].triggerAt.getTime()).toBe(expected.getTime());
78+
});
79+
80+
it('one-day alert is exactly 1 day before expiration (at 9am)', () => {
81+
const event = buildEvent('test.iota', expiration);
82+
const expected = new Date(expiration);
83+
expected.setDate(expected.getDate() - 1);
84+
expected.setHours(9, 0, 0, 0);
85+
expect(event.alerts![2].triggerAt.getTime()).toBe(expected.getTime());
86+
});
87+
88+
it('expiry-day alert is on the expiration date at 9am', () => {
89+
const event = buildEvent('test.iota', expiration);
90+
const expected = new Date(expiration);
91+
expected.setHours(9, 0, 0, 0);
92+
expect(event.alerts![3].triggerAt.getTime()).toBe(expected.getTime());
93+
});
94+
95+
it('one-month alert clamps correctly for names expiring on the 31st (Feb overflow)', () => {
96+
const march31 = new Date(2026, 2, 31); // March 31
97+
const event = buildEvent('test.iota', march31);
98+
const oneMonthAlert = event.alerts![0].triggerAt;
99+
// Should be Feb 28 at 9am (2026 is not a leap year), not March 3
100+
expect(oneMonthAlert.getMonth()).toBe(1); // February
101+
expect(oneMonthAlert.getDate()).toBe(28);
102+
});
103+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2026 IOTA Stiftung
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { normalizeIotaName } from '@iota/iota-names-sdk';
5+
6+
import { formatDate } from '@/lib/utils/format/formatDate';
7+
8+
import { subtractMonths, subtractWeeks } from './dateUtils';
9+
import type { CalendarEvent } from './types';
10+
11+
export function at9am(date: Date): Date {
12+
const d = new Date(date);
13+
d.setHours(9, 0, 0, 0);
14+
return d;
15+
}
16+
17+
export function buildEvent(name: string, expirationDate: Date): CalendarEvent {
18+
const displayName = normalizeIotaName(name);
19+
20+
const oneMonthBefore = subtractMonths(expirationDate, 1);
21+
const oneWeekBefore = subtractWeeks(expirationDate, 1);
22+
const oneDayBefore = new Date(expirationDate);
23+
oneDayBefore.setDate(oneDayBefore.getDate() - 1);
24+
25+
return {
26+
title: `${displayName} – Renewal Reminder`,
27+
description: `Your IOTA name ${displayName} expires on ${formatDate(expirationDate)}. Remember to renew it at iotanames.com.`,
28+
date: expirationDate,
29+
alerts: [
30+
{
31+
triggerAt: at9am(oneMonthBefore),
32+
description: `1 month until ${displayName} expires`,
33+
},
34+
{ triggerAt: at9am(oneWeekBefore), description: `1 week until ${displayName} expires` },
35+
{ triggerAt: at9am(oneDayBefore), description: `1 day until ${displayName} expires` },
36+
{ triggerAt: at9am(expirationDate), description: `${displayName} expires today` },
37+
],
38+
};
39+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Copyright (c) 2026 IOTA Stiftung
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
export const MS_PER_DAY = 24 * 60 * 60 * 1000;
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (c) 2026 IOTA Stiftung
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { describe, expect, it } from 'vitest';
5+
6+
import { subtractMonths, subtractWeeks } from './dateUtils';
7+
8+
describe('subtractWeeks', () => {
9+
it('subtracts exactly 7 days per week', () => {
10+
const date = new Date(2026, 0, 15); // Jan 15
11+
expect(subtractWeeks(date, 1).getDate()).toBe(8);
12+
expect(subtractWeeks(date, 1).getMonth()).toBe(0);
13+
});
14+
15+
it('rolls back across a month boundary', () => {
16+
const date = new Date(2026, 1, 3); // Feb 3
17+
const result = subtractWeeks(date, 1);
18+
expect(result.getFullYear()).toBe(2026);
19+
expect(result.getMonth()).toBe(0); // January
20+
expect(result.getDate()).toBe(27);
21+
});
22+
23+
it('rolls back across a year boundary', () => {
24+
const date = new Date(2026, 0, 5); // Jan 5
25+
const result = subtractWeeks(date, 1);
26+
expect(result.getFullYear()).toBe(2025);
27+
expect(result.getMonth()).toBe(11); // December
28+
expect(result.getDate()).toBe(29);
29+
});
30+
31+
it('does not mutate the input date', () => {
32+
const date = new Date(2026, 0, 15);
33+
subtractWeeks(date, 1);
34+
expect(date.getDate()).toBe(15);
35+
});
36+
37+
it('supports multiple weeks', () => {
38+
const date = new Date(2026, 0, 29); // Jan 29
39+
const result = subtractWeeks(date, 2);
40+
expect(result.getMonth()).toBe(0);
41+
expect(result.getDate()).toBe(15);
42+
});
43+
});
44+
45+
describe('subtractMonths', () => {
46+
it('subtracts one month from a mid-month date', () => {
47+
const date = new Date(2026, 2, 15); // March 15
48+
const result = subtractMonths(date, 1);
49+
expect(result.getFullYear()).toBe(2026);
50+
expect(result.getMonth()).toBe(1); // February
51+
expect(result.getDate()).toBe(15);
52+
});
53+
54+
it('clamps to last day of month when source day overflows (31st → Feb)', () => {
55+
const date = new Date(2026, 2, 31); // March 31
56+
const result = subtractMonths(date, 1);
57+
expect(result.getFullYear()).toBe(2026);
58+
expect(result.getMonth()).toBe(1); // February
59+
expect(result.getDate()).toBe(28); // 2026 is not a leap year
60+
});
61+
62+
it('clamps to Feb 29 on a leap year', () => {
63+
const date = new Date(2024, 2, 31); // March 31, 2024
64+
const result = subtractMonths(date, 1);
65+
expect(result.getFullYear()).toBe(2024);
66+
expect(result.getMonth()).toBe(1); // February
67+
expect(result.getDate()).toBe(29); // 2024 is a leap year
68+
});
69+
70+
it('clamps to last day when subtracting from Jan 31 → Dec 31 (no overflow)', () => {
71+
const date = new Date(2026, 0, 31); // Jan 31
72+
const result = subtractMonths(date, 1);
73+
expect(result.getFullYear()).toBe(2025);
74+
expect(result.getMonth()).toBe(11); // December
75+
expect(result.getDate()).toBe(31);
76+
});
77+
78+
it('rolls back across a year boundary', () => {
79+
const date = new Date(2026, 1, 15); // Feb 15
80+
const result = subtractMonths(date, 2);
81+
expect(result.getFullYear()).toBe(2025);
82+
expect(result.getMonth()).toBe(11); // December
83+
expect(result.getDate()).toBe(15);
84+
});
85+
86+
it('does not mutate the input date', () => {
87+
const date = new Date(2026, 2, 31);
88+
subtractMonths(date, 1);
89+
expect(date.getDate()).toBe(31);
90+
expect(date.getMonth()).toBe(2);
91+
});
92+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (c) 2026 IOTA Stiftung
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
export function dateStr(d: Date): string {
5+
const year = d.getFullYear();
6+
const month = String(d.getMonth() + 1).padStart(2, '0');
7+
const day = String(d.getDate()).padStart(2, '0');
8+
return `${year}${month}${day}`;
9+
}
10+
11+
export function subtractWeeks(date: Date, weeks: number): Date {
12+
const result = new Date(date);
13+
result.setDate(result.getDate() - weeks * 7);
14+
return result;
15+
}
16+
17+
// Clamps to the last day of the target month to avoid JS setMonth overflow
18+
// (e.g. March 31 - 1 month → Feb 31 → March 3 without the clamp).
19+
export function subtractMonths(date: Date, months: number): Date {
20+
const result = new Date(date);
21+
const targetDay = result.getDate();
22+
result.setDate(1);
23+
result.setMonth(result.getMonth() - months);
24+
const lastDay = new Date(result.getFullYear(), result.getMonth() + 1, 0).getDate();
25+
result.setDate(Math.min(targetDay, lastDay));
26+
return result;
27+
}

apps/names/src/lib/utils/calendar/google.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,15 @@
11
// Copyright (c) 2026 IOTA Stiftung
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import { MS_PER_DAY } from './constants';
45
import type { CalendarEvent } from './types';
5-
6-
function dateStr(d: Date): string {
7-
const year = d.getFullYear();
8-
const month = String(d.getMonth() + 1).padStart(2, '0');
9-
const day = String(d.getDate()).padStart(2, '0');
10-
return `${year}${month}${day}`;
11-
}
6+
import { dateStr } from './dateUtils';
127

138
/**
149
* Returns a Google Calendar "Add Event" URL for an all-day event.
1510
* Note: Google Calendar does not support reminders via URL — alerts are ignored.
1611
*/
1712
export function google(event: CalendarEvent): string {
18-
const MS_PER_DAY = 24 * 60 * 60 * 1000;
1913
const start = dateStr(event.date);
2014
const end = dateStr(new Date(event.date.getTime() + MS_PER_DAY));
2115

apps/names/src/lib/utils/calendar/ics.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ describe('ics', () => {
7373
it('includes DESCRIPTION', () => {
7474
expect(ics(BASE_EVENT)).toContain('DESCRIPTION:Be there!');
7575
});
76+
77+
it('includes a DTSTAMP in UTC datetime format', () => {
78+
expect(ics(BASE_EVENT)).toMatch(/DTSTAMP:\d{8}T\d{6}Z/);
79+
});
7680
});
7781

7882
describe('date boundaries', () => {
@@ -175,6 +179,14 @@ describe('ics', () => {
175179
);
176180
});
177181

182+
it('escapes bare carriage returns to prevent ICS line-break injection', () => {
183+
expect(ics({ ...BASE_EVENT, description: 'a\rb' })).toContain('DESCRIPTION:a\\nb');
184+
});
185+
186+
it('escapes CRLF sequences to prevent ICS line-break injection', () => {
187+
expect(ics({ ...BASE_EVENT, description: 'a\r\nb' })).toContain('DESCRIPTION:a\\nb');
188+
});
189+
178190
it('escapes special characters in alert descriptions', () => {
179191
const event: CalendarEvent = {
180192
...BASE_EVENT,

0 commit comments

Comments
 (0)