feat(calendar): show event indicators in mini calendar

Co-authored-by: ayxwi <274959179+xwil1@users.noreply.github.com>
This commit is contained in:
Xvvln 2026-07-02 14:43:15 +08:00
parent 4b55e69640
commit fbac9ba8fa
4 changed files with 252 additions and 9 deletions

View file

@ -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>

View 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);
});
});

View 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);
}

View file

@ -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>