Skip to content

Commit 879606f

Browse files
committed
add partial calendar event support
1 parent 67d4e33 commit 879606f

File tree

16 files changed

+910
-29
lines changed

16 files changed

+910
-29
lines changed

.vscode/settings.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
{
22
"editor.codeActionsOnSave": {
3-
"source.fixAll.biome": "explicit"
4-
}
3+
"source.fixAll.biome": "explicit",
4+
},
5+
"editor.formatOnSave": true,
6+
"biome.lsp.bin": "./worker/node_modules/@biomejs/biome/bin/biome"
57
}

app/src/api.g.ts

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,23 @@ export interface paths {
5555
patch?: never;
5656
trace?: never;
5757
};
58+
"/api/event": {
59+
parameters: {
60+
query?: never;
61+
header?: never;
62+
path?: never;
63+
cookie?: never;
64+
};
65+
get?: never;
66+
put?: never;
67+
/** @description Create a new shared calendar event */
68+
post: operations["postApiEvent"];
69+
delete?: never;
70+
options?: never;
71+
head?: never;
72+
patch?: never;
73+
trace?: never;
74+
};
5875
"/api/info/{id}": {
5976
parameters: {
6077
query?: never;
@@ -119,10 +136,10 @@ export interface operations {
119136
} & {
120137
/**
121138
* @description Unix timestamp for when the item will expire in milliseconds. Must be between 0 and 1 year (31 days for files).
122-
* @default 1760987919326
139+
* @default 1761000448906
123140
* @example 1735689600000
124141
*/
125-
expires: number | null;
142+
expires?: number | null;
126143
/** @description An optional string that allows you to later delete the item before it expires. */
127144
deleteToken?: string | null;
128145
};
@@ -169,10 +186,10 @@ export interface operations {
169186
} & {
170187
/**
171188
* @description Unix timestamp for when the item will expire in milliseconds. Must be between 0 and 1 year (31 days for files).
172-
* @default 1760987919327
189+
* @default 1761000448907
173190
* @example 1735689600000
174191
*/
175-
expires: number | null;
192+
expires?: number | null;
176193
/** @description An optional string that allows you to later delete the item before it expires. */
177194
deleteToken?: string | null;
178195
};
@@ -214,19 +231,19 @@ export interface operations {
214231
} & {
215232
/**
216233
* @description Unix timestamp for when the item will expire in milliseconds. Must be between 0 and 1 year (31 days for files).
217-
* @default 1760987919327
234+
* @default 1761000448907
218235
* @example 1735689600000
219236
*/
220-
expires: number | null;
237+
expires?: number | null;
221238
/** @description An optional string that allows you to later delete the item before it expires. */
222239
deleteToken?: string | null;
223240
} & {
224241
/**
225242
* @description Unix timestamp for when the item will expire in milliseconds. Must be between 0 and 1 year (31 days for files).
226-
* @default 1760987919327
243+
* @default 1761000448907
227244
* @example 1735689600000
228245
*/
229-
expires: number | null;
246+
expires?: number | null;
230247
/** @description An optional string that allows you to later delete the item before it expires. */
231248
deleteToken?: string | null;
232249
};
@@ -254,6 +271,61 @@ export interface operations {
254271
};
255272
};
256273
};
274+
postApiEvent: {
275+
parameters: {
276+
query?: never;
277+
header?: never;
278+
path?: never;
279+
cookie?: never;
280+
};
281+
requestBody?: {
282+
content: {
283+
"application/json": {
284+
/** @example My event */
285+
title: string;
286+
description?: string | null;
287+
location?: string | null;
288+
startDate: string;
289+
endDate?: string | null;
290+
/** @default false */
291+
allDay?: boolean;
292+
} & {
293+
/**
294+
* @description Unix timestamp for when the item will expire in milliseconds. Must be between 0 and 1 year (31 days for files).
295+
* @default 1761000448908
296+
* @example 1735689600000
297+
*/
298+
expires?: number | null;
299+
/** @description An optional string that allows you to later delete the item before it expires. */
300+
deleteToken?: string | null;
301+
};
302+
};
303+
};
304+
responses: {
305+
/** @description Successful response */
306+
200: {
307+
headers: {
308+
[name: string]: unknown;
309+
};
310+
content: {
311+
"application/json": {
312+
id: string;
313+
title: string;
314+
description: string | null;
315+
location: string | null;
316+
startDate: string;
317+
endDate: string | null;
318+
allDay: boolean;
319+
createdAt: string;
320+
updatedAt: string;
321+
expiresAt: string | null;
322+
/** @constant */
323+
type: "event";
324+
};
325+
};
326+
};
327+
};
328+
};
257329
getApiInfoById: {
258330
parameters: {
259331
query?: never;
@@ -298,6 +370,19 @@ export interface operations {
298370
expiresAt: string | null;
299371
/** @constant */
300372
type: "upload";
373+
} | {
374+
id: string;
375+
title: string;
376+
description: string | null;
377+
location: string | null;
378+
startDate: string;
379+
endDate: string | null;
380+
allDay: boolean;
381+
createdAt: string;
382+
updatedAt: string;
383+
expiresAt: string | null;
384+
/** @constant */
385+
type: "event";
301386
};
302387
};
303388
};

app/src/calendarLinks.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { operations } from "./api.g";
2+
3+
type EventData = operations["postApiEvent"]["responses"][200]["content"]["application/json"];
4+
5+
function toICSDate(date: Date | string): string {
6+
const dt = new Date(date);
7+
return (
8+
dt.getUTCFullYear().toString() +
9+
(dt.getUTCMonth() + 1).toString().padStart(2, "0") +
10+
dt.getUTCDate().toString().padStart(2, "0") +
11+
"T" +
12+
dt.getUTCHours().toString().padStart(2, "0") +
13+
dt.getUTCMinutes().toString().padStart(2, "0") +
14+
dt.getUTCSeconds().toString().padStart(2, "0") +
15+
"Z"
16+
);
17+
}
18+
19+
export function addHours(d: Date, hours: number): Date {
20+
const result = new Date(d.getTime());
21+
result.setHours(result.getHours() + hours);
22+
return result;
23+
}
24+
25+
export function googleCalendarUrl(event: EventData): string {
26+
const baseUrl = "https://calendar.google.com/calendar/render?action=TEMPLATE";
27+
const params = new URLSearchParams({
28+
text: event.title,
29+
dates: `${toICSDate(event.startDate)}/${toICSDate(event.endDate ?? addHours(new Date(event.startDate), 1))}`,
30+
details: event.description || "",
31+
location: event.location || "",
32+
});
33+
return `${baseUrl}&${params.toString()}`;
34+
}
35+
36+
export function outlookCalendarUrl(event: EventData): string {
37+
const baseUrl = "https://outlook.live.com/calendar/0/deeplink/compose?path=/calendar/action/compose";
38+
39+
const paramsData: { [key: string]: string } = {
40+
subject: event.title,
41+
startdt: event.startDate,
42+
body: event.description || "",
43+
location: event.location || "",
44+
};
45+
46+
if (event.endDate !== null) {
47+
paramsData.enddt = event.endDate;
48+
}
49+
50+
const params = new URLSearchParams(paramsData);
51+
return `${baseUrl}&${params.toString()}`;
52+
}

app/src/views/View.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { CodeHighlight } from "@mantine/code-highlight";
2-
import { Alert, Box, Button, Container, Text, Title } from "@mantine/core";
2+
import { Alert, Box, Button, Container, Stack, Text, Title } from "@mantine/core";
3+
import { DateTime } from "luxon";
34
import { useEffect, useState } from "react";
4-
import { AlertOctagon, Download } from "react-feather";
5+
import { AlertOctagon, Calendar, Download } from "react-feather";
56
import { useParams } from "react-router";
7+
import { googleCalendarUrl, outlookCalendarUrl } from "../calendarLinks";
68
import Header from "../components/Header";
79
import TimedRedirect from "../components/TimedRedirect";
810
import { info as getInfo, idToUrl, shortUrl } from "../controller";
@@ -52,6 +54,7 @@ function View() {
5254
title = "Something has gone wrong!";
5355
content = <Text mt={6}>Error: {error.message}. Please try again.</Text>;
5456
} else {
57+
subtitle = `Created ${new Date(data.createdAt).toLocaleDateString()}`;
5558
switch (data.type) {
5659
case "url":
5760
title = shortUrl(data.url);
@@ -106,8 +109,45 @@ function View() {
106109
);
107110
break;
108111
}
112+
case "event": {
113+
title = data.title;
114+
const startDate = DateTime.fromISO(data.startDate);
115+
const endDate = data.endDate !== null ? DateTime.fromISO(data.endDate) : null;
116+
subtitle = startDate.toLocaleString({ dateStyle: "long", timeStyle: data.allDay ? undefined : "short" });
117+
const lessThan24Hours = endDate === null || startDate.diff(endDate).hours < 24;
118+
if (endDate !== null && !(lessThan24Hours && data.allDay)) {
119+
subtitle += ` to ${endDate.toLocaleString({ dateStyle: lessThan24Hours ? undefined : "long", timeStyle: data.allDay ? undefined : "short" })}`;
120+
}
121+
content = (
122+
<Stack gap="xs">
123+
{data.location && (
124+
<Text>
125+
<strong>Location:</strong> {data.location}
126+
</Text>
127+
)}
128+
{data.description && (
129+
<Text>
130+
<strong>Description:</strong> {data.description}
131+
</Text>
132+
)}
133+
<Text c="dimmed">Created {new Date(data.createdAt).toLocaleDateString()}</Text>
134+
135+
<Box>
136+
<Button leftSection={<Calendar size={16} />} component="a" target="_blank" href={googleCalendarUrl(data)} me={12}>
137+
Add to Google Calendar
138+
</Button>
139+
<Button leftSection={<Calendar size={16} />} component="a" target="_blank" href={outlookCalendarUrl(data)} me={12}>
140+
Add to Outlook
141+
</Button>
142+
<Button leftSection={<Download size={16} />} component="a" href={`${idToUrl(link!)}?direct=1`} me={12}>
143+
Add to iCloud & others (ICS)...
144+
</Button>
145+
</Box>
146+
</Stack>
147+
);
148+
break;
149+
}
109150
}
110-
subtitle = `Created ${new Date(data.createdAt).toLocaleDateString()}`;
111151
}
112152
}
113153

0 commit comments

Comments
 (0)