Skip to content

Commit ff849d7

Browse files
feat(names): add to calendar (#173)
Closes #171 # Description of change Adds an "Add to Calendar" option to the "..." dropdown menu on renewable IOTA names ## How the change has been tested Clicking it opens a panel with two options: Google Calendar (opens the web URL) and Download .ics (downloads a calendar file with automatic reminders) - Open the ... menu on a renewable name — "Add to Calendar" appears - Clicking "Add to Calendar" opens the panel - "Google Calendar" opens calendar.google.com with the event pre-filled on the expiration date - "Download .ics" downloads a .ics file; importing it into Apple Calendar / Outlook shows three reminders at 09:00: 1 month before, 1 day before, and on the day ## Screens <img width="360" height="522" alt="image" src="https://github.com/user-attachments/assets/49400faf-2edb-4152-8f6c-9c5707bb9e1c" /> <img width="487" height="870" alt="image" src="https://github.com/user-attachments/assets/40714584-45ed-44a2-99c1-3d692119f2ca" /> <img width="1205" height="822" alt="image" src="https://github.com/user-attachments/assets/be01c4c2-327a-467c-8a50-387ba7b6a948" />
1 parent 056305b commit ff849d7

15 files changed

Lines changed: 821 additions & 0 deletions
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright (c) 2026 IOTA Stiftung
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
'use client';
5+
6+
import { Export, Globe } from '@iota/apps-ui-icons';
7+
import { Dialog, DialogBody, DialogContent, DialogPosition, Header } from '@iota/apps-ui-kit';
8+
import { GRACE_PERIOD_MS, normalizeIotaName } from '@iota/iota-names-sdk';
9+
10+
import { RegistrationNft } from '@/lib/interfaces';
11+
import { isNameRecordExpired } from '@/lib/utils/names';
12+
import { formatDate } from '@/lib/utils/format/formatDate';
13+
import { buildEvent, google, ics } from '@/lib/utils/calendar';
14+
15+
interface AddToCalendarDialogProps {
16+
nft: Pick<RegistrationNft, 'name' | 'expirationDate'>;
17+
setOpen: (bool: boolean) => void;
18+
}
19+
20+
function downloadIcsFile(name: string, content: string): void {
21+
const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' });
22+
const url = URL.createObjectURL(blob);
23+
const link = document.createElement('a');
24+
link.href = url;
25+
link.download = `${normalizeIotaName(name)}-renewal.ics`;
26+
document.body.appendChild(link);
27+
link.click();
28+
document.body.removeChild(link);
29+
URL.revokeObjectURL(url);
30+
}
31+
32+
interface CalendarOptionProps {
33+
icon: React.ReactNode;
34+
title: string;
35+
description: string;
36+
onClick: () => void;
37+
}
38+
39+
function CalendarOption({ icon, title, description, onClick }: CalendarOptionProps) {
40+
return (
41+
<button
42+
onClick={onClick}
43+
className="flex items-start gap-md w-full rounded-xl bg-names-neutral-12 px-md py-sm hover:bg-names-neutral-20 transition-colors cursor-pointer text-left"
44+
>
45+
<span className="[&_svg]:h-6 [&_svg]:w-6 text-names-neutral-60 mt-0.5 shrink-0">
46+
{icon}
47+
</span>
48+
<div className="flex flex-col gap-xxs">
49+
<span className="text-body-lg text-names-neutral-92">{title}</span>
50+
<span className="text-body-sm text-names-neutral-60">{description}</span>
51+
</div>
52+
</button>
53+
);
54+
}
55+
56+
export function AddToCalendarDialog({ nft, setOpen }: AddToCalendarDialogProps) {
57+
const isExpired = isNameRecordExpired(nft);
58+
const calendarDate = isExpired
59+
? new Date(nft.expirationDate.getTime() + GRACE_PERIOD_MS)
60+
: nft.expirationDate;
61+
const displayName = normalizeIotaName(nft.name);
62+
63+
function handleClose() {
64+
setOpen(false);
65+
}
66+
67+
function handleGoogleCalendar() {
68+
const event = buildEvent(nft.name, calendarDate);
69+
window.open(google(event), '_blank', 'noopener noreferrer');
70+
}
71+
72+
function handleDownloadIcs() {
73+
const event = buildEvent(nft.name, calendarDate);
74+
downloadIcsFile(nft.name, ics(event));
75+
}
76+
77+
return (
78+
<Dialog open onOpenChange={setOpen}>
79+
<DialogContent isFixedPosition position={DialogPosition.Right}>
80+
<Header title="Add to Calendar" onClose={handleClose} />
81+
<DialogBody>
82+
<div className="flex flex-col gap-md">
83+
{isExpired ? (
84+
<p className="text-body-md text-names-neutral-60">
85+
<span className="text-names-neutral-92">{displayName}</span> has
86+
already expired. Add the grace period deadline (
87+
<span className="text-names-neutral-92">
88+
{formatDate(calendarDate)}
89+
</span>
90+
) to your calendar — this is your last chance to renew.
91+
</p>
92+
) : (
93+
<p className="text-body-md text-names-neutral-60">
94+
Add the expiry date of{' '}
95+
<span className="text-names-neutral-92">{displayName}</span> (
96+
<span className="text-names-neutral-92">
97+
{formatDate(calendarDate)}
98+
</span>
99+
) to a calendar app.
100+
</p>
101+
)}
102+
<div className="flex flex-col gap-xs">
103+
<CalendarOption
104+
icon={<Globe />}
105+
title="Google Calendar"
106+
description="Opens Google Calendar to create an event. Note: automatic reminders are not supported, you will need to add them manually."
107+
onClick={handleGoogleCalendar}
108+
/>
109+
<CalendarOption
110+
icon={<Export />}
111+
title="Download .ics"
112+
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."
113+
onClick={handleDownloadIcs}
114+
/>
115+
</div>
116+
</div>
117+
</DialogBody>
118+
</DialogContent>
119+
</Dialog>
120+
);
121+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Fragment } from 'react';
77
import { RegistrationNft } from '@/lib/interfaces';
88

99
import { DeleteNameDialog } from '.';
10+
import { AddToCalendarDialog } from './AddToCalendarDialog';
1011
import { ConnectToAddressDialog } from './ConnectToAddressDialog';
1112
import { CreateSubnameDialog } from './CreateSubnameDialog';
1213
import { EditMetadataDialog } from './EditMetadata';
@@ -62,6 +63,10 @@ export function NameDialogsController({ nft, openDialogId, onClose }: NameDialog
6263
{openDialogId === NameDialogId.EditMetadata ? (
6364
<EditMetadataDialog name={nft.name} setOpen={onClose} />
6465
) : null}
66+
67+
{openDialogId === NameDialogId.AddToCalendar ? (
68+
<AddToCalendarDialog nft={nft} setOpen={onClose} />
69+
) : null}
6570
</Fragment>
6671
);
6772
}

apps/names/src/components/dialogs/enums.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ export enum NameDialogId {
1010
SetPermissions = 'set-permissions',
1111
ConnectToAddress = 'connect-to-address',
1212
EditMetadata = 'edit-metadata',
13+
AddToCalendar = 'add-to-calendar',
1314
}
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+
});

0 commit comments

Comments
 (0)