feat(core): events list in journal calendar (#11873)

close AF-2505

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced calendar event integration in the journal detail page, allowing users to view and interact with calendar events for a selected date.
  - Added the ability to create new linked documents from calendar events directly within the journal interface.
- **Improvements**
  - Enhanced calendar integration with support for all-day and timed events, improved event grouping by date, and real-time updates.
  - Added new English localization entries for calendar event labels.
- **Bug Fixes**
  - Improved URL handling for calendar subscriptions, ensuring better compatibility and error feedback.
- **Style**
  - Added new styles for calendar event components to ensure a consistent and user-friendly appearance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
CatsJuice
2025-04-25 02:37:51 +00:00
parent 4b7fddc32c
commit f5ac0aee97
11 changed files with 361 additions and 18 deletions

View File

@@ -0,0 +1,71 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, style } from '@vanilla-extract/css';
export const primaryColor = createVar('calendar-event-primary');
export const list = style({
display: 'flex',
flexDirection: 'column',
gap: 2,
padding: '0px 16px 10px 16px',
});
export const event = style({
display: 'flex',
gap: 8,
alignItems: 'center',
padding: '5px 4px',
borderRadius: 4,
cursor: 'pointer',
selectors: {
'&:hover': {
backgroundColor: cssVarV2.layer.background.hoverOverlay,
},
},
});
export const eventIcon = style({
width: 20,
height: 20,
color: primaryColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
});
export const eventTitle = style({
width: 0,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: 14,
fontWeight: 400,
lineHeight: '22px',
color: cssVarV2.text.primary,
});
export const eventCaption = style({
fontSize: 12,
lineHeight: '20px',
color: cssVarV2.text.secondary,
});
export const eventTime = style({
display: 'flex',
selectors: {
[`${event}:hover &`]: {
display: 'none',
},
},
});
export const eventNewDoc = style({
display: 'none',
gap: 4,
selectors: {
[`${event}:hover &`]: {
display: 'flex',
},
},
});

View File

@@ -0,0 +1,140 @@
import { Loading, toast } from '@affine/component';
import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { DocsService } from '@affine/core/modules/doc';
import {
type CalendarEvent,
IntegrationService,
} from '@affine/core/modules/integration';
import { JournalService } from '@affine/core/modules/journal';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { FullDayIcon, PeriodIcon, PlusIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import type { Dayjs } from 'dayjs';
import type ICAL from 'ical.js';
import { useEffect, useMemo, useState } from 'react';
import * as styles from './calendar-events.css';
const pad = (val?: number) => (val ?? 0).toString().padStart(2, '0');
function formatTime(start?: ICAL.Time, end?: ICAL.Time) {
const from = `${pad(start?.hour)}:${pad(start?.minute)}`;
const to = `${pad(end?.hour)}:${pad(end?.minute)}`;
return from === to ? from : `${from} - ${to}`;
}
export const CalendarEvents = ({ date }: { date: Dayjs }) => {
const calendar = useService(IntegrationService).calendar;
const events = useLiveData(
useMemo(() => calendar.eventsByDate$(date), [calendar, date])
);
useEffect(() => {
const update = () => {
calendar.subscriptions$.value.forEach(sub => sub.update());
};
update();
const interval = setInterval(update, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [calendar]);
return (
<ul className={styles.list}>
{events.map(event => (
<CalendarEventRenderer key={event.id} event={event} />
))}
</ul>
);
};
const CalendarEventRenderer = ({ event }: { event: CalendarEvent }) => {
const t = useI18n();
const { url, title, startAt, endAt, allDay, date } = event;
const [loading, setLoading] = useState(false);
const calendar = useService(IntegrationService).calendar;
const docsService = useService(DocsService);
const guardService = useService(GuardService);
const journalService = useService(JournalService);
const workspaceService = useService(WorkspaceService);
const { createPage } = usePageHelper(
workspaceService.workspace.docCollection
);
const subscription = useLiveData(
useMemo(() => calendar.subscription$(url), [calendar, url])
);
const config = useLiveData(
useMemo(() => subscription?.config$, [subscription?.config$])
);
const color = config?.color || cssVarV2.button.primary;
const handleClick = useAsyncCallback(async () => {
if (!date || loading) return;
const docs = journalService.journalsByDate$(
date.format('YYYY-MM-DD')
).value;
if (docs.length === 0) return;
setLoading(true);
try {
for (const doc of docs) {
const canEdit = await guardService.can('Doc_Update', doc.id);
if (!canEdit) {
toast(t['com.affine.no-permission']());
continue;
}
const newDoc = createPage();
await docsService.changeDocTitle(newDoc.id, title);
await docsService.addLinkedDoc(doc.id, newDoc.id);
}
} finally {
setLoading(false);
}
}, [
createPage,
date,
docsService,
guardService,
journalService,
loading,
t,
title,
]);
return (
<li
style={assignInlineVars({
[styles.primaryColor]: color,
})}
className={styles.event}
data-all-day={allDay}
onClick={handleClick}
>
<div className={styles.eventIcon}>
{allDay ? <FullDayIcon /> : <PeriodIcon />}
</div>
<div className={styles.eventTitle}>{title}</div>
{loading ? (
<Loading />
) : (
<div className={styles.eventCaption}>
<span className={styles.eventTime}>
{allDay
? t['com.affine.integration.calendar.all-day']()
: formatTime(startAt, endAt)}
</span>
<span className={styles.eventNewDoc}>
<PlusIcon style={{ fontSize: 18 }} />
{t['com.affine.integration.calendar.new-doc']()}
</span>
</div>
)}
</li>
);
};

View File

@@ -32,6 +32,7 @@ import dayjs from 'dayjs';
import type { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { CalendarEvents } from './calendar-events';
import * as styles from './journal.css';
import { JournalTemplateOnboarding } from './template-onboarding';
import { JournalTemplateSetting } from './template-setting';
@@ -166,6 +167,7 @@ export const EditorJournalPanel = () => {
{journalDate ? (
<>
<JournalConflictBlock date={journalDate} />
<CalendarEvents date={journalDate} />
<JournalDailyCountBlock date={journalDate} />
</>
) : (

View File

@@ -8,12 +8,13 @@ import {
onStart,
} from '@toeverything/infra';
import ICAL from 'ical.js';
import { switchMap } from 'rxjs';
import { EMPTY, mergeMap, switchMap, throttleTime } from 'rxjs';
import type {
CalendarStore,
CalendarSubscriptionConfig,
} from '../store/calendar';
import { parseCalendarUrl } from '../utils/calendar-url-parser';
export class CalendarSubscription extends Entity<{ url: string }> {
constructor(private readonly store: CalendarStore) {
@@ -44,16 +45,26 @@ export class CalendarSubscription extends Entity<{ url: string }> {
error$ = new LiveData<any>(null);
update = effect(
throttleTime(30 * 1000),
switchMap(() =>
fromPromise(async () => {
const response = await fetch(this.url);
const cache = await response.text();
this.store.setSubscriptionCache(this.url, cache).catch(console.error);
const url = parseCalendarUrl(this.url);
const response = await fetch(url);
return await response.text();
}).pipe(
mergeMap(value => {
this.store.setSubscriptionCache(this.url, value).catch(console.error);
return EMPTY;
}),
catchErrorInto(this.error$),
onStart(() => this.loading$.setValue(true)),
onComplete(() => this.loading$.setValue(false))
)
)
);
override dispose() {
super.dispose();
this.update.reset();
}
}

View File

@@ -1,4 +1,5 @@
import { Entity, LiveData, ObjectPool } from '@toeverything/infra';
import dayjs, { type Dayjs } from 'dayjs';
import ICAL from 'ical.js';
import { Observable, switchMap } from 'rxjs';
@@ -6,8 +7,33 @@ import type {
CalendarStore,
CalendarSubscriptionConfig,
} from '../store/calendar';
import { parseCalendarUrl } from '../utils/calendar-url-parser';
import { CalendarSubscription } from './calendar-subscription';
export type CalendarEvent = {
id: string;
url: string;
title: string;
startAt?: ICAL.Time;
endAt?: ICAL.Time;
allDay?: boolean;
date?: Dayjs;
};
type EventsByDateMap = Map<string, CalendarEvent[]>;
const isAllDay = (current: Dayjs, start: Dayjs, end: Dayjs): boolean => {
if (current.isSame(start, 'day')) {
return (
start.hour() === 0 && start.minute() === 0 && !current.isSame(end, 'day')
);
} else if (current.isSame(end, 'day')) {
return false;
} else {
return true;
}
};
export class CalendarIntegration extends Entity {
constructor(private readonly store: CalendarStore) {
super();
@@ -45,19 +71,87 @@ export class CalendarIntegration extends Entity {
),
[]
);
subscription$(url: string) {
return this.subscriptions$.map(subscriptions =>
subscriptions.find(sub => sub.url === url)
);
}
contents$ = LiveData.computed(get => {
const subscriptions = get(this.subscriptions$);
return subscriptions.map(sub => ({
url: sub.url,
content: get(sub.content$),
}));
});
eventsByDateMap$ = LiveData.computed(get => {
const contents = get(this.contents$);
const eventsByDate: EventsByDateMap = new Map();
for (const { content, url } of contents) {
if (!content) continue;
const jCal = ICAL.parse(content);
const vCalendar = new ICAL.Component(jCal);
const vEvents = vCalendar.getAllSubcomponents('vevent');
for (const vEvent of vEvents) {
const event = new ICAL.Event(vEvent);
const calendarEvent: CalendarEvent = {
id: event.uid,
url,
title: event.summary,
startAt: event.startDate,
endAt: event.endDate,
};
// create index for each day of the event
if (event.startDate && event.endDate) {
const start = dayjs(event.startDate.toJSDate());
const end = dayjs(event.endDate.toJSDate());
let current = start;
while (current.isBefore(end) || current.isSame(end, 'day')) {
if (
current.isSame(end, 'day') &&
end.hour() === 0 &&
end.minute() === 0
) {
break;
}
const todayEvent: CalendarEvent = { ...calendarEvent };
const dateKey = current.format('YYYY-MM-DD');
if (!eventsByDate.has(dateKey)) {
eventsByDate.set(dateKey, []);
}
todayEvent.allDay = isAllDay(current, start, end);
todayEvent.date = current;
todayEvent.id = `${event.uid}-${dateKey}`;
eventsByDate.get(dateKey)?.push(todayEvent);
current = current.add(1, 'day');
}
} else {
console.warn("event's start or end date is missing", event);
}
}
}
return eventsByDate;
});
eventsByDate$(date: Dayjs) {
return this.eventsByDateMap$.map(eventsByDateMap => {
const dateKey = date.format('YYYY-MM-DD');
const events = [...(eventsByDateMap.get(dateKey) || [])];
// sort events by start time
return events.sort((a, b) => {
return (
(a.startAt?.toJSDate().getTime() ?? 0) -
(b.startAt?.toJSDate().getTime() ?? 0)
);
});
});
}
async verifyUrl(_url: string) {
let url = _url;
try {
const urlObj = new URL(url);
if (urlObj.protocol === 'webcal:') {
urlObj.protocol = 'https';
}
url = urlObj.toString();
} catch (err) {
console.error(err);
throw new Error('Invalid URL');
}
const url = parseCalendarUrl(_url);
try {
const response = await fetch(url);
const content = await response.text();

View File

@@ -18,6 +18,7 @@ import { IntegrationRefStore } from './store/integration-ref';
import { ReadwiseStore } from './store/readwise';
export { IntegrationService };
export type { CalendarEvent } from './entities/calendar';
export { CalendarIntegration } from './entities/calendar';
export { CalendarSubscription } from './entities/calendar-subscription';
export { IntegrationTypeIcon } from './views/icon';

View File

@@ -0,0 +1,14 @@
export const parseCalendarUrl = (_url: string) => {
let url = _url;
try {
const urlObj = new URL(url);
if (urlObj.protocol === 'webcal:') {
urlObj.protocol = 'https';
}
url = urlObj.toString();
return url;
} catch (err) {
console.error(err);
throw new Error(`Invalid URL: "${url}"`);
}
};