mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-28 05:27:35 +00:00
feat(calendar): show event indicators in mini calendar
Co-authored-by: ayxwi <274959179+xwil1@users.noreply.github.com>
This commit is contained in:
parent
4b55e69640
commit
fbac9ba8fa
4 changed files with 252 additions and 9 deletions
|
|
@ -1,17 +1,24 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import type { CalendarModel } from '$lib/apis/calendar';
|
||||
import type { CalendarEventModel, CalendarModel } from '$lib/apis/calendar';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import {
|
||||
miniCalendarDayKey,
|
||||
miniCalendarEventIndicators,
|
||||
miniCalendarVisibleRange
|
||||
} from './calendarEventIndicators';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let calendars: CalendarModel[] = [];
|
||||
export let events: CalendarEventModel[] = [];
|
||||
export let visibleCalendarIds: Set<string> = new Set();
|
||||
export let currentDate: Date = new Date();
|
||||
export let onToggle: (id: string) => void = () => {};
|
||||
export let onCreateCalendar: () => void = () => {};
|
||||
export let onDeleteCalendar: (id: string) => void = () => {};
|
||||
export let onDateSelect: (date: Date) => void = () => {};
|
||||
export let onVisibleRangeChange: (start: string, end: string) => void | Promise<void> = () => {};
|
||||
|
||||
// Delete confirmation state
|
||||
let showDeleteConfirm = false;
|
||||
|
|
@ -38,12 +45,8 @@
|
|||
$: miniMonth = currentDate.getMonth();
|
||||
$: miniYear = currentDate.getFullYear();
|
||||
|
||||
$: miniMonthStart = new Date(miniYear, miniMonth, 1);
|
||||
$: miniCalStart = (() => {
|
||||
const d = new Date(miniMonthStart);
|
||||
d.setDate(d.getDate() - d.getDay());
|
||||
return d;
|
||||
})();
|
||||
$: miniVisibleRange = miniCalendarVisibleRange(new Date(miniYear, miniMonth, 1));
|
||||
$: miniCalStart = miniVisibleRange.start;
|
||||
|
||||
$: miniDays = (() => {
|
||||
const days: Date[] = [];
|
||||
|
|
@ -54,6 +57,17 @@
|
|||
}
|
||||
return days;
|
||||
})();
|
||||
$: calendarColorMap = new Map(calendars.map((calendar) => [calendar.id, calendar.color]));
|
||||
$: eventIndicators = miniCalendarEventIndicators(events, visibleCalendarIds, calendarColorMap);
|
||||
|
||||
let lastVisibleRangeKey = '';
|
||||
$: {
|
||||
const rangeKey = `${miniVisibleRange.start.toISOString()}|${miniVisibleRange.end.toISOString()}`;
|
||||
if (rangeKey !== lastVisibleRangeKey) {
|
||||
lastVisibleRangeKey = rangeKey;
|
||||
onVisibleRangeChange(miniVisibleRange.start.toISOString(), miniVisibleRange.end.toISOString());
|
||||
}
|
||||
}
|
||||
|
||||
$: miniMonthNames = [
|
||||
'January',
|
||||
|
|
@ -78,6 +92,14 @@
|
|||
return d.toDateString() === currentDate.toDateString();
|
||||
}
|
||||
|
||||
function miniDayAriaLabel(day: Date, eventCount: number): string {
|
||||
const dateLabel = `${miniMonthNames[day.getMonth()]} ${day.getDate()}, ${day.getFullYear()}`;
|
||||
if (eventCount <= 0) {
|
||||
return dateLabel;
|
||||
}
|
||||
return `${dateLabel}, ${eventCount} ${eventCount === 1 ? 'event' : 'events'}`;
|
||||
}
|
||||
|
||||
function navigateMini(delta: number) {
|
||||
if (miniMonth + delta > 11) {
|
||||
miniMonth = 0;
|
||||
|
|
@ -155,8 +177,11 @@
|
|||
|
||||
<div class="grid grid-cols-7 text-center text-[10px]">
|
||||
{#each miniDays as day}
|
||||
{@const dayIndicators = eventIndicators.get(miniCalendarDayKey(day))}
|
||||
{@const eventColors = dayIndicators?.colors ?? []}
|
||||
{@const eventCount = dayIndicators?.count ?? 0}
|
||||
<button
|
||||
class="w-6 h-6 flex items-center justify-center rounded-full transition
|
||||
class="relative w-6 h-6 flex items-center justify-center rounded-full transition
|
||||
{day.getMonth() !== miniMonth ? 'text-gray-300 dark:text-gray-600' : ''}
|
||||
{isToday(day) ? 'bg-blue-500 text-white' : ''}
|
||||
{day.toDateString() === currentDate.toDateString() && !isToday(day)
|
||||
|
|
@ -165,9 +190,23 @@
|
|||
{!isToday(day) && day.toDateString() !== currentDate.toDateString()
|
||||
? 'hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
: ''}"
|
||||
aria-label={miniDayAriaLabel(day, eventCount)}
|
||||
on:click={() => onDateSelect(day)}
|
||||
>
|
||||
{day.getDate()}
|
||||
{#if eventColors.length > 0}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="absolute bottom-px left-1/2 -translate-x-1/2 flex gap-px"
|
||||
>
|
||||
{#each eventColors.slice(0, 3) as color}
|
||||
<span
|
||||
class="size-1 rounded-full {isToday(day) ? 'ring-1 ring-white/80' : ''}"
|
||||
style="background-color: {color};"
|
||||
></span>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
69
src/lib/components/calendar/calendarEventIndicators.test.ts
Normal file
69
src/lib/components/calendar/calendarEventIndicators.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
miniCalendarEventIndicators,
|
||||
miniCalendarVisibleRange
|
||||
} from './calendarEventIndicators';
|
||||
|
||||
const NS = 1_000_000;
|
||||
|
||||
function atLocalNoon(year: number, monthIndex: number, day: number): number {
|
||||
return new Date(year, monthIndex, day, 12, 0, 0, 0).getTime() * NS;
|
||||
}
|
||||
|
||||
describe('miniCalendarEventIndicators', () => {
|
||||
it('returns one visible color dot per calendar color by local calendar day', () => {
|
||||
const indicators = miniCalendarEventIndicators(
|
||||
[
|
||||
{
|
||||
calendar_id: 'personal',
|
||||
start_at: atLocalNoon(2026, 5, 22),
|
||||
end_at: null,
|
||||
color: null
|
||||
},
|
||||
{
|
||||
calendar_id: 'personal',
|
||||
start_at: atLocalNoon(2026, 5, 22),
|
||||
end_at: null,
|
||||
color: null
|
||||
},
|
||||
{
|
||||
calendar_id: 'care',
|
||||
start_at: atLocalNoon(2026, 5, 22),
|
||||
end_at: null,
|
||||
color: '#ef4444'
|
||||
},
|
||||
{
|
||||
calendar_id: 'hidden',
|
||||
start_at: atLocalNoon(2026, 5, 23),
|
||||
end_at: null,
|
||||
color: null
|
||||
}
|
||||
],
|
||||
new Set(['personal', 'care']),
|
||||
new Map([
|
||||
['personal', '#3b82f6'],
|
||||
['care', '#22c55e'],
|
||||
['hidden', '#f59e0b']
|
||||
])
|
||||
);
|
||||
|
||||
expect(indicators.get(new Date(2026, 5, 22).getTime().toString())).toEqual({
|
||||
colors: ['#3b82f6', '#ef4444'],
|
||||
count: 3
|
||||
});
|
||||
expect(indicators.has(new Date(2026, 5, 23).getTime().toString())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('miniCalendarVisibleRange', () => {
|
||||
it('returns the six-week mini calendar grid range for the selected month', () => {
|
||||
const range = miniCalendarVisibleRange(new Date(2026, 5, 22));
|
||||
|
||||
expect(range.start.getFullYear()).toBe(2026);
|
||||
expect(range.start.getMonth()).toBe(4);
|
||||
expect(range.start.getDate()).toBe(31);
|
||||
expect(range.end.getFullYear()).toBe(2026);
|
||||
expect(range.end.getMonth()).toBe(6);
|
||||
expect(range.end.getDate()).toBe(12);
|
||||
});
|
||||
});
|
||||
68
src/lib/components/calendar/calendarEventIndicators.ts
Normal file
68
src/lib/components/calendar/calendarEventIndicators.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { CalendarEventModel } from '$lib/apis/calendar';
|
||||
|
||||
const NS = 1_000_000;
|
||||
const DEFAULT_CALENDAR_COLOR = '#3b82f6';
|
||||
|
||||
type MiniCalendarEvent = Pick<
|
||||
CalendarEventModel,
|
||||
'calendar_id' | 'start_at' | 'end_at' | 'color'
|
||||
>;
|
||||
|
||||
export type MiniCalendarDayIndicators = {
|
||||
colors: string[];
|
||||
count: number;
|
||||
};
|
||||
|
||||
function dayKey(date: Date): string {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime().toString();
|
||||
}
|
||||
|
||||
export function miniCalendarVisibleRange(date: Date): { start: Date; end: Date } {
|
||||
const monthStart = new Date(date.getFullYear(), date.getMonth(), 1);
|
||||
const start = new Date(monthStart);
|
||||
start.setDate(start.getDate() - start.getDay());
|
||||
start.setHours(0, 0, 0, 0);
|
||||
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 42);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export function miniCalendarEventIndicators(
|
||||
events: MiniCalendarEvent[],
|
||||
visibleCalendarIds: Set<string>,
|
||||
calendarColors: Map<string, string | null | undefined> = new Map()
|
||||
): Map<string, MiniCalendarDayIndicators> {
|
||||
const indicators = new Map<string, MiniCalendarDayIndicators>();
|
||||
|
||||
for (const event of events) {
|
||||
if (!visibleCalendarIds.has(event.calendar_id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const color = event.color || calendarColors.get(event.calendar_id) || DEFAULT_CALENDAR_COLOR;
|
||||
const startDate = new Date(event.start_at / NS);
|
||||
const endDate = new Date((event.end_at || event.start_at) / NS);
|
||||
const cursor = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
|
||||
const last = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()).getTime();
|
||||
|
||||
while (cursor.getTime() <= last) {
|
||||
const key = dayKey(cursor);
|
||||
const dayIndicators = indicators.get(key) ?? { colors: [], count: 0 };
|
||||
dayIndicators.count += 1;
|
||||
if (!dayIndicators.colors.includes(color)) {
|
||||
dayIndicators.colors.push(color);
|
||||
}
|
||||
indicators.set(key, dayIndicators);
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return indicators;
|
||||
}
|
||||
|
||||
export function miniCalendarDayKey(date: Date): string {
|
||||
return dayKey(date);
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
} from '$lib/apis/calendar';
|
||||
import CalendarView from '$lib/components/calendar/CalendarView.svelte';
|
||||
import CalendarSidebar from '$lib/components/calendar/CalendarSidebar.svelte';
|
||||
import { miniCalendarVisibleRange } from '$lib/components/calendar/calendarEventIndicators';
|
||||
import CalendarEventModal from '$lib/components/calendar/CalendarEventModal.svelte';
|
||||
import CreateCalendarModal from '$lib/components/calendar/CreateCalendarModal.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
|
|
@ -27,6 +28,7 @@
|
|||
let loaded = false;
|
||||
let calendars: CalendarModel[] = [];
|
||||
let events: CalendarEventModel[] = [];
|
||||
let miniCalendarEvents: CalendarEventModel[] = [];
|
||||
let visibleCalendarIds: Set<string> = new Set();
|
||||
|
||||
let view: 'month' | 'week' | 'day' = 'month';
|
||||
|
|
@ -36,6 +38,11 @@
|
|||
let editEvent: CalendarEventModel | null = null;
|
||||
let defaultStartAt: number | null = null;
|
||||
let showCreateCalendarModal = false;
|
||||
let miniRangeStart: string | null = null;
|
||||
let miniRangeEnd: string | null = null;
|
||||
let miniEventsLoadedRangeKey: string | null = null;
|
||||
let miniEventsLoadingRangeKey: string | null = null;
|
||||
let miniEventsRequestId = 0;
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January',
|
||||
|
|
@ -100,8 +107,58 @@
|
|||
}
|
||||
}
|
||||
|
||||
function getMiniVisibleRange(date: Date): { start: string; end: string } {
|
||||
const range = miniCalendarVisibleRange(date);
|
||||
return {
|
||||
start: range.start.toISOString(),
|
||||
end: range.end.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function miniRangeKey(start: string, end: string): string {
|
||||
return `${start}|${end}`;
|
||||
}
|
||||
|
||||
async function loadMiniEvents(start?: string, end?: string) {
|
||||
const requestId = ++miniEventsRequestId;
|
||||
const range =
|
||||
start && end
|
||||
? { start, end }
|
||||
: miniRangeStart && miniRangeEnd
|
||||
? { start: miniRangeStart, end: miniRangeEnd }
|
||||
: getMiniVisibleRange(currentDate);
|
||||
|
||||
miniRangeStart = range.start;
|
||||
miniRangeEnd = range.end;
|
||||
const rangeKey = miniRangeKey(range.start, range.end);
|
||||
const previousLoadedRangeKey = miniEventsLoadedRangeKey;
|
||||
miniEventsLoadedRangeKey = null;
|
||||
miniEventsLoadingRangeKey = rangeKey;
|
||||
if (previousLoadedRangeKey !== rangeKey) {
|
||||
miniCalendarEvents = [];
|
||||
}
|
||||
|
||||
try {
|
||||
const nextEvents = await getCalendarEvents(localStorage.token, range.start, range.end);
|
||||
if (requestId !== miniEventsRequestId) {
|
||||
return;
|
||||
}
|
||||
miniCalendarEvents = nextEvents;
|
||||
miniEventsLoadedRangeKey = rangeKey;
|
||||
} catch (err) {
|
||||
if (requestId !== miniEventsRequestId) {
|
||||
return;
|
||||
}
|
||||
toast.error(`${err}`);
|
||||
} finally {
|
||||
if (requestId === miniEventsRequestId) {
|
||||
miniEventsLoadingRangeKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await loadEvents();
|
||||
await Promise.all([loadEvents(), loadMiniEvents()]);
|
||||
}
|
||||
|
||||
function toggleCalendar(id: string) {
|
||||
|
|
@ -161,6 +218,14 @@
|
|||
refresh();
|
||||
}
|
||||
|
||||
async function handleMiniVisibleRangeChange(start: string, end: string) {
|
||||
const rangeKey = miniRangeKey(start, end);
|
||||
if (miniEventsLoadedRangeKey === rangeKey || miniEventsLoadingRangeKey === rangeKey) {
|
||||
return;
|
||||
}
|
||||
await loadMiniEvents(start, end);
|
||||
}
|
||||
|
||||
function handleCreateCalendar() {
|
||||
showCreateCalendarModal = true;
|
||||
}
|
||||
|
|
@ -360,12 +425,14 @@
|
|||
<div class="hidden md:flex flex-col w-56 shrink-0 pr-1.5 pl-3 overflow-y-auto">
|
||||
<CalendarSidebar
|
||||
{calendars}
|
||||
events={miniCalendarEvents}
|
||||
{visibleCalendarIds}
|
||||
{currentDate}
|
||||
onToggle={toggleCalendar}
|
||||
onCreateCalendar={handleCreateCalendar}
|
||||
onDeleteCalendar={handleDeleteCalendar}
|
||||
onDateSelect={handleDateSelect}
|
||||
onVisibleRangeChange={handleMiniVisibleRangeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue