feat(calendar): import user calendars - #418
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces “linked/imported calendars” for the schedule calendar, exposing UI to import an external calendar URL, list linked calendars, and control their visibility/removal, plus backend-assisted URL checking for iCal fetching.
Changes:
- Added UI for importing calendars (URL + metadata + color) and showing a preview before submission.
- Added “linked calendar” cards with hide/remove actions, and surfaced calendar config/export entrypoint in the user menu.
- Updated calendar/iCal fetching & viewer logic to support linked calendar sources and success callbacks.
Reviewed changes
Copilot reviewed 18 out of 19 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| src/components/schedule/personal-card/PersonalCard.tsx | New reusable “personal calendar” card UI with export/hide/link actions. |
| src/components/schedule/personal-card/HideButtonPersonal.tsx | Refactors to shared HideButtonUI and removes debug logging. |
| src/components/schedule/linked-card/RemoveButtonLinked.tsx | Adds linked-calendar removal UI with confirmation modal and optimistic cache update. |
| src/components/schedule/linked-card/LinkedCard.tsx | Adds linked-calendar card UI and action buttons. |
| src/components/schedule/linked-card/HideButtonLinked.tsx | Adds hide/unhide behavior for linked calendars with optimistic cache patching. |
| src/components/schedule/linked-card/EditButtonLinked.tsx | Adds (currently hidden) linked-calendar edit trigger wired to ImportModal. |
| src/components/schedule/HideButtonUI.tsx | Extracts generic hide button UI to reuse across card types. |
| src/components/schedule/group-card/HideButtonGroup.tsx | Adds group hide button wrapper using shared HideButtonUI. |
| src/components/schedule/group-card/GroupCard.tsx | Switches group cards to the new HideButtonGroup wrapper. |
| src/components/layout/UserMenu.tsx | Adds “Config & Export” entrypoint to open calendar configuration dialog. |
| src/components/calendar/ScheduleLinkInput.tsx | Adds reusable controlled input for calendar URL entry. |
| src/components/calendar/import/index.ts | Exposes ImportModal via barrel export. |
| src/components/calendar/import/ImportModal.tsx | Implements calendar import modal with preview, validation gating, and submission. |
| src/components/calendar/import/ColorsPalette.tsx | Adds a color picker palette UI for imported calendar styling. |
| src/components/calendar/iCalendarPlugin/event-source-def.ts | Routes non-internal iCal URLs through an API “check calendar URL” endpoint before fetching. |
| src/components/calendar/ConfigCalendarDialog.tsx | Shows linked calendars and adds import flow alongside existing export/config UI. |
| src/components/calendar/CalendarViewer.tsx | Adds eventSourceSuccess hook and extends event-source reconciliation logic (incl. color). |
| src/components/calendar/CalendarPage.tsx | Includes linked calendars in the set of calendar sources to display. |
| src/api/schedule/links.ts | Adds getImportedLink() to build the ICS URL for linked calendars. |
Suppressed comments (2)
src/components/calendar/CalendarViewer.tsx:378
- Same issue here: checking meta.color against the string "undefined" will fail when the meta color is actually undefined. Use a nullish comparison so sources with no color are treated consistently.
source.url === eventSource.url &&
// @ts-expect-error internalEventSource is presented in eventSource
(source.internalEventSource.meta.color === eventSource.color ||
// @ts-expect-error internalEventSource is presented in eventSource
source.internalEventSource.meta.color === "undefined"),
src/components/schedule/HideButtonUI.tsx:16
- This button is missing type="button". Without it, it defaults to "submit", which can cause unintended submissions if HideButtonUI is used inside a form.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| }).then((response) => { | ||
| if (response.status !== 200) return; | ||
| return response.text().then((icsText) => { |
| const removeLinkedCalendar = () => { | ||
| remove.mutate( | ||
| { | ||
| params: { query: { alias } }, | ||
| }, | ||
| { | ||
| onError: (error) => { | ||
| showError("Import failed", formatApiErrorMessage(error)); | ||
| }, | ||
| }, | ||
| ); | ||
| }; |
| : null; | ||
| return ( | ||
| <LinkedCard | ||
| key={linkedCalendar?.id} |
| <button | ||
| onClick={() => { | ||
| setImportModalOpen(true); | ||
| }} | ||
| className="underline underline-offset-4" | ||
| > |
| export function getImportedLink( | ||
| userId: number | undefined, | ||
| linkedAlias: string, | ||
| ) { | ||
| return `${SCHEDULE_API_URL}/users/${userId}/linked/${linkedAlias}.ics`; | ||
| } |
9589412 to
6a995e3
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 19 changed files in this pull request and generated 1 comment.
Suppressed comments (9)
src/components/calendar/import/ImportModal.tsx:105
calendarColoris always initialized to#9747ff. In edit mode (prevAliasprovided) this risks overwriting the previously saved color on PATCH even if the user didn’t intend to change it. Initialize the state from the existing linked calendar color when available.
const [calendarColor, setCalendarColor] = useState("#9747ff");
src/components/calendar/import/ImportModal.tsx:184
- The submit
disabledcondition only checks URL/description changes and ignores color changes, so changing only the color keeps the button disabled. Include color (for edit mode) in the “no changes” check.
disabled={
calendarName.length === 0 ||
!isCalendarChecked ||
(calendarURL === prevUrl &&
prevDescription === calendarDescription)
src/components/schedule/linked-card/RemoveButtonLinked.tsx:52
- The error toast says “Import failed”, but this action is removing a linked calendar. Use an error message that matches the operation so users aren’t confused.
onError: (error) => {
showError("Import failed", formatApiErrorMessage(error));
},
src/components/schedule/HideButtonUI.tsx:15
- This button is missing
type="button". Without it, the button can accidentally submit an enclosing<form>if this component is ever rendered inside one.
src/components/schedule/linked-card/EditButtonLinked.tsx:29 - This button is missing
type="button", which can cause accidental form submission if the card is ever placed inside a<form>.
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setImportModalOpen(true);
}}
className="hover:bg-base-200 rounded-box flex h-10 w-10 items-center justify-center text-3xl"
src/components/layout/UserMenu.tsx:134
- This menu button is missing
type="button", which can cause accidental form submission if the menu is ever rendered inside a<form>.
<button
onClick={() => setConfigModalOpen(true)}
className="bg-base-200 text-base-content/75 hover:bg-base-300 rounded-box flex w-full flex-row items-center justify-center gap-2 px-6 py-2 text-center whitespace-nowrap"
>
<span className="icon-[material-symbols--settings-outline] text-2xl" />
Config & Export
</button>
src/components/calendar/iCalendarPlugin/event-source-def.ts:74
- When the calendar fetch returns a non-200 status, the promise resolves to
undefinedandexpandICalEvents(iCalExpander, ...)will throw. Reject the promise on non-OK responses soerrorCallbackruns, or otherwise guard againstundefined.
}).then((response) => {
if (response.status !== 200) return;
return response.text().then((icsText) => {
src/components/calendar/ConfigCalendarDialog.tsx:124
- This inline button is missing
type="button", which can cause accidental form submission if the dialog content is ever wrapped in a<form>.
Add favorite calendars using star button or{" "}
<button
onClick={() => setImportModalOpen(true)}
className="underline underline-offset-4"
>
src/components/calendar/CalendarPage.tsx:138
- The “unique items” filter uses
array.indexOf(value), which does not deduplicate objects (it compares by reference). With imported calendars added, duplicates (same URL/color) can slip through and create duplicate event sources.
// Add imported calendars
imported.forEach((v) => {
if (v.is_active)
toShow.push({
url: getImportedLink(userId, v.alias),
| @@ -0,0 +1,208 @@ | |||
| import { $schedule } from "@/api/schedule"; | |||
| import { Calendar } from "@/components/calendar/Calendar.tsx"; | |||
| import { useState } from "react"; | |||
6a995e3 to
fc950cc
Compare
fc950cc to
dc58b2c
Compare
Description of changes