feat(calendar): production hardening - cleanup, tests, a11y, error handling

- Remove unused components (DayView, YearView, MultiDayView, context menus, swipe nav, NLP parser)
- Narrow CalendarViewType to week/month/agenda (was 14 types)
- Add global HttpExceptionFilter with structured error responses
- Add LIKE pattern injection prevention in search queries
- Add GET request deduplication in API client
- Add 48 web app tests (events API, events store, view store)
- Improve accessibility (ARIA roles, labels, live regions, grid semantics)
- Add i18n keys for a11y labels across all 5 languages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-15 12:43:36 +01:00
parent 7bb4b1dd5b
commit 7f5c70c7cd
47 changed files with 2432 additions and 6691 deletions

View file

@ -1,5 +1,5 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
@ -18,6 +18,7 @@ import { NetworkModule } from './network/network.module';
import { EmailModule } from './email/email.module';
import { NotificationModule } from './notification/notification.module';
import { AdminModule } from './admin/admin.module';
import { HttpExceptionFilter } from './common/http-exception.filter';
@Module({
imports: [
@ -60,6 +61,10 @@ import { AdminModule } from './admin/admin.module';
AdminModule,
],
providers: [
{
provide: APP_FILTER,
useClass: HttpExceptionFilter,
},
{
provide: APP_GUARD,
useClass: ThrottlerGuard,

View file

@ -0,0 +1,60 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response, Request } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
let error = 'Internal Server Error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
error = exceptionResponse;
} else if (typeof exceptionResponse === 'object') {
const res = exceptionResponse as Record<string, any>;
message = res.message || message;
error = res.error || error;
}
} else if (exception instanceof Error) {
message = exception.message;
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
} else {
this.logger.error('Unknown exception', exception);
}
if (status >= 500) {
this.logger.error(
`[${request.method}] ${request.url} - ${status}: ${message}`,
exception instanceof Error ? exception.stack : undefined
);
} else {
this.logger.warn(`[${request.method}] ${request.url} - ${status}: ${message}`);
}
response.status(status).json({
statusCode: status,
message,
error,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}

View file

@ -16,6 +16,10 @@ export class EventService {
private eventTagService: EventTagService
) {}
private escapeLikePattern(input: string): string {
return input.replace(/[%_\\]/g, '\\$&');
}
async queryEvents(userId: string, query: QueryEventsDto): Promise<Event[]> {
const conditions = [eq(events.userId, userId)];
@ -37,13 +41,11 @@ export class EventService {
conditions.push(or(eq(events.status, 'confirmed'), eq(events.status, 'tentative')) as any);
}
// Search filter
// Search filter (escaped to prevent LIKE pattern injection)
if (query.search) {
const escaped = this.escapeLikePattern(query.search);
conditions.push(
or(
ilike(events.title, `%${query.search}%`),
ilike(events.description, `%${query.search}%`)
) as any
or(ilike(events.title, `%${escaped}%`), ilike(events.description, `%${escaped}%`)) as any
);
}
@ -193,13 +195,11 @@ export class EventService {
conditions.push(inArray(events.calendarId, query.calendarIds));
}
// Search filter - search in title and description
// Search filter (escaped to prevent LIKE pattern injection)
if (query.search) {
const escaped = this.escapeLikePattern(query.search);
conditions.push(
or(
ilike(events.title, `%${query.search}%`),
ilike(events.description, `%${query.search}%`)
) as any
or(ilike(events.title, `%${escaped}%`), ilike(events.description, `%${escaped}%`)) as any
);
}

View file

@ -11,7 +11,8 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "prettier --write .",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run"
},
"devDependencies": {
"@manacore/shared-pwa": "workspace:*",
@ -20,10 +21,12 @@
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",
"@testing-library/jest-dom": "^6.9.1",
"@types/d3-force": "^3.0.0",
"@types/node": "^20.0.0",
"@types/suncalc": "^1.9.2",
"@vite-pwa/sveltekit": "^1.1.0",
"jsdom": "^25.0.1",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^5.41.0",
@ -31,14 +34,14 @@
"tailwindcss": "^4.1.7",
"tslib": "^2.4.1",
"typescript": "^5.9.3",
"vite": "^6.0.0"
"vite": "^6.0.0",
"vitest": "^4.1.0"
},
"dependencies": {
"@calendar/shared": "workspace:*",
"@manacore/shared-api-client": "workspace:*",
"@manacore/shared-app-onboarding": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-splitscreen": "workspace:*",
"@manacore/shared-stores": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",
@ -46,6 +49,8 @@
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-splitscreen": "workspace:*",
"@manacore/shared-stores": "workspace:*",
"@manacore/shared-subscription-ui": "workspace:*",
"@manacore/shared-tags": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
@ -54,9 +59,8 @@
"@manacore/shared-types": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"@manacore/shared-app-onboarding": "workspace:*",
"@sqlite.org/sqlite-wasm": "^3.49.1-build1",
"@neodrag/svelte": "^2.3.3",
"@sqlite.org/sqlite-wasm": "^3.49.1-build1",
"d3-force": "^3.0.0",
"date-fns": "^4.1.0",
"suncalc": "^1.9.0",

View file

@ -41,6 +41,12 @@ function getApi(): ApiClient {
return _api;
}
/**
* Request deduplication for GET requests
* Prevents identical concurrent requests from being sent multiple times
*/
const pendingRequests = new Map<string, Promise<ApiResult<unknown>>>();
/**
* Legacy fetchApi interface for backwards compatibility
*/
@ -55,6 +61,7 @@ export interface FetchOptions {
/**
* Fetch API wrapper using shared client
* Maintains backward compatibility with existing code
* GET requests are deduplicated identical concurrent GETs share one in-flight request
*/
export async function fetchApi<T>(
endpoint: string,
@ -67,6 +74,19 @@ export async function fetchApi<T>(
return api.upload<T>(endpoint, body);
}
// Deduplicate GET requests
if (method === 'GET') {
const existing = pendingRequests.get(endpoint);
if (existing) {
return existing as Promise<ApiResult<T>>;
}
const promise = api.get<T>(endpoint).finally(() => {
pendingRequests.delete(endpoint);
});
pendingRequests.set(endpoint, promise as Promise<ApiResult<unknown>>);
return promise;
}
switch (method) {
case 'POST':
return api.post<T>(endpoint, body);

View file

@ -0,0 +1,278 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { CalendarEvent } from '@calendar/shared';
// Mock the client module
vi.mock('./client', () => ({
fetchApi: vi.fn(),
}));
import { fetchApi } from './client';
import { getEvents, getEvent, createEvent, updateEvent, deleteEvent } from './events';
const mockFetchApi = vi.mocked(fetchApi);
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'evt-1',
calendarId: 'cal-1',
userId: 'user-1',
title: 'Test Event',
description: null,
location: null,
startTime: '2026-03-15T10:00:00Z',
endTime: '2026-03-15T11:00:00Z',
isAllDay: false,
timezone: 'Europe/Berlin',
recurrenceRule: null,
recurrenceEndDate: null,
recurrenceExceptions: null,
parentEventId: null,
color: null,
status: 'confirmed',
externalId: null,
metadata: null,
createdAt: '2026-03-01T00:00:00Z',
updatedAt: '2026-03-01T00:00:00Z',
...overrides,
};
}
describe('events API client', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getEvents', () => {
it('should build query params with startDate and endDate', async () => {
mockFetchApi.mockResolvedValue({
data: { events: [], pagination: { offset: 0, count: 0 } },
error: null,
});
await getEvents({
startDate: '2026-03-01T00:00:00',
endDate: '2026-03-31T23:59:59',
});
expect(mockFetchApi).toHaveBeenCalledOnce();
const url = mockFetchApi.mock.calls[0][0];
expect(url).toContain('startDate=2026-03-01T00%3A00%3A00');
expect(url).toContain('endDate=2026-03-31T23%3A59%3A59');
});
it('should include calendarIds when provided', async () => {
mockFetchApi.mockResolvedValue({
data: { events: [], pagination: { offset: 0, count: 0 } },
error: null,
});
await getEvents({
startDate: '2026-03-01T00:00:00',
endDate: '2026-03-31T23:59:59',
calendarIds: ['cal-1', 'cal-2'],
});
const url = mockFetchApi.mock.calls[0][0];
expect(url).toContain('calendarIds=cal-1%2Ccal-2');
});
it('should include search param when provided', async () => {
mockFetchApi.mockResolvedValue({
data: { events: [], pagination: { offset: 0, count: 0 } },
error: null,
});
await getEvents({
startDate: '2026-03-01T00:00:00',
endDate: '2026-03-31T23:59:59',
search: 'meeting',
});
const url = mockFetchApi.mock.calls[0][0];
expect(url).toContain('search=meeting');
});
it('should include limit and offset when provided', async () => {
mockFetchApi.mockResolvedValue({
data: { events: [], pagination: { offset: 0, count: 0 } },
error: null,
});
await getEvents({
startDate: '2026-03-01T00:00:00',
endDate: '2026-03-31T23:59:59',
limit: 10,
offset: 20,
});
const url = mockFetchApi.mock.calls[0][0];
expect(url).toContain('limit=10');
expect(url).toContain('offset=20');
});
it('should extract events array from response', async () => {
const events = [makeEvent(), makeEvent({ id: 'evt-2', title: 'Second' })];
mockFetchApi.mockResolvedValue({
data: { events, pagination: { offset: 0, count: 2 } },
error: null,
});
const result = await getEvents({
startDate: '2026-03-01T00:00:00',
endDate: '2026-03-31T23:59:59',
});
expect(result.data).toHaveLength(2);
expect(result.error).toBeNull();
expect(result.pagination).toEqual({ offset: 0, count: 2 });
});
it('should return error when API fails', async () => {
mockFetchApi.mockResolvedValue({
data: null,
error: { message: 'Server error', code: 'SERVER_ERROR', status: 500 },
});
const result = await getEvents({
startDate: '2026-03-01T00:00:00',
endDate: '2026-03-31T23:59:59',
});
expect(result.data).toBeNull();
expect(result.error).toEqual({
message: 'Server error',
code: 'SERVER_ERROR',
status: 500,
});
});
});
describe('getEvent', () => {
it('should fetch a single event by ID', async () => {
const event = makeEvent();
mockFetchApi.mockResolvedValue({
data: { event },
error: null,
});
const result = await getEvent('evt-1');
expect(mockFetchApi).toHaveBeenCalledWith('/events/evt-1');
expect(result.data).toEqual(event);
expect(result.error).toBeNull();
});
it('should return error when event not found', async () => {
mockFetchApi.mockResolvedValue({
data: null,
error: { message: 'Not found', code: 'NOT_FOUND', status: 404 },
});
const result = await getEvent('nonexistent');
expect(result.data).toBeNull();
expect(result.error?.code).toBe('NOT_FOUND');
});
});
describe('createEvent', () => {
it('should send POST request with event data', async () => {
const event = makeEvent();
mockFetchApi.mockResolvedValue({
data: { event },
error: null,
});
const input = {
calendarId: 'cal-1',
title: 'Test Event',
startTime: '2026-03-15T10:00:00Z',
endTime: '2026-03-15T11:00:00Z',
};
const result = await createEvent(input);
expect(mockFetchApi).toHaveBeenCalledWith('/events', {
method: 'POST',
body: input,
});
expect(result.data).toEqual(event);
expect(result.error).toBeNull();
});
it('should return error on creation failure', async () => {
mockFetchApi.mockResolvedValue({
data: null,
error: { message: 'Validation failed', code: 'VALIDATION_ERROR', status: 400 },
});
const result = await createEvent({
title: '',
startTime: '2026-03-15T10:00:00Z',
endTime: '2026-03-15T11:00:00Z',
});
expect(result.data).toBeNull();
expect(result.error?.code).toBe('VALIDATION_ERROR');
});
});
describe('updateEvent', () => {
it('should send PUT request with update data', async () => {
const event = makeEvent({ title: 'Updated Title' });
mockFetchApi.mockResolvedValue({
data: { event },
error: null,
});
const updateData = { title: 'Updated Title' };
const result = await updateEvent('evt-1', updateData);
expect(mockFetchApi).toHaveBeenCalledWith('/events/evt-1', {
method: 'PUT',
body: updateData,
});
expect(result.data).toEqual(event);
expect(result.error).toBeNull();
});
it('should return error on update failure', async () => {
mockFetchApi.mockResolvedValue({
data: null,
error: { message: 'Forbidden', code: 'FORBIDDEN', status: 403 },
});
const result = await updateEvent('evt-1', { title: 'Updated' });
expect(result.data).toBeNull();
expect(result.error?.code).toBe('FORBIDDEN');
});
});
describe('deleteEvent', () => {
it('should send DELETE request', async () => {
mockFetchApi.mockResolvedValue({
data: null,
error: null,
});
const result = await deleteEvent('evt-1');
expect(mockFetchApi).toHaveBeenCalledWith('/events/evt-1', {
method: 'DELETE',
});
expect(result.error).toBeNull();
});
it('should return error on delete failure', async () => {
mockFetchApi.mockResolvedValue({
data: null,
error: { message: 'Not found', code: 'NOT_FOUND', status: 404 },
});
const result = await deleteEvent('nonexistent');
expect(result.error?.code).toBe('NOT_FOUND');
});
});
});

View file

@ -3,9 +3,7 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
import { filterByTags } from '$lib/utils/eventFiltering';
import EventContextMenu from '$lib/components/event/EventContextMenu.svelte';
import { format, parseISO, isToday, isTomorrow, startOfDay } from 'date-fns';
import { de } from 'date-fns/locale';
import { toDate } from '$lib/utils/eventDateHelpers';
@ -87,18 +85,6 @@
onEventClick(event);
}
}
function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
eventContextMenuStore.show(event, e.clientX, e.clientY);
}
function handleContextMenuEdit(event: CalendarEvent) {
if (onEventClick) {
onEventClick(event);
}
}
</script>
<div class="agenda-view">
@ -124,11 +110,7 @@
<div class="events-for-date">
{#each group.events as event}
<button
class="event-item"
onclick={() => handleEventClick(event)}
oncontextmenu={(e) => handleEventContextMenu(event, e)}
>
<button class="event-item" onclick={() => handleEventClick(event)}>
<div
class="color-bar"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
@ -187,8 +169,6 @@
{/if}
</div>
<EventContextMenu onEdit={handleContextMenuEdit} />
<style>
.agenda-view {
padding: 1rem;

View file

@ -3,10 +3,6 @@
import { settingsStore } from '$lib/stores/settings.svelte';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
import CalendarHeaderContextMenu from './CalendarHeaderContextMenu.svelte';
let contextMenu: CalendarHeaderContextMenu;
// Get weekday format string based on setting
function getWeekdayFormat(): string {
switch (settingsStore.headerWeekdayFormat) {
@ -70,41 +66,22 @@
};
switch (viewStore.viewType) {
case 'day':
return format(date, getDateFormat(true), { locale: de });
case '5day':
case 'week':
case '10day':
case '14day':
return formatRange();
case 'month':
return format(date, 'MMMM yyyy', { locale: de });
case 'year':
return format(date, 'yyyy', { locale: de });
case 'agenda':
return 'Agenda';
default:
return format(date, 'MMMM yyyy', { locale: de });
}
});
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
contextMenu.show(e.clientX, e.clientY);
}
</script>
<header
class="calendar-header"
class:compact={settingsStore.headerCompact}
oncontextmenu={handleContextMenu}
role="banner"
>
<h1 class="header-title">{title}</h1>
<header class="calendar-header" class:compact={settingsStore.headerCompact} role="banner">
<h1 class="header-title" aria-live="polite">{title}</h1>
</header>
<CalendarHeaderContextMenu bind:this={contextMenu} />
<style>
.calendar-header {
padding: 0.75rem 1rem;

View file

@ -1,102 +0,0 @@
<script lang="ts">
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { ArrowsIn, TextAa, Calendar, CalendarBlank } from '@manacore/shared-icons';
import { settingsStore, type WeekdayFormat } from '$lib/stores/settings.svelte';
// Context menu state
let visible = $state(false);
let x = $state(0);
let y = $state(0);
// Build menu items based on current settings
let menuItems = $derived.by((): ContextMenuItem[] => {
return [
{
id: 'compact',
label: 'Kompakte Ansicht',
icon: ArrowsIn,
toggle: true,
checked: settingsStore.headerCompact,
action: () => toggleSetting('headerCompact'),
},
{
id: 'divider-1',
label: '',
type: 'divider',
},
{
id: 'weekday-full',
label: 'Wochentag ausgeschrieben',
icon: TextAa,
toggle: true,
checked: settingsStore.headerWeekdayFormat === 'full',
action: () => setWeekdayFormat('full'),
},
{
id: 'weekday-short',
label: 'Wochentag gekürzt',
icon: TextAa,
toggle: true,
checked: settingsStore.headerWeekdayFormat === 'short',
action: () => setWeekdayFormat('short'),
},
{
id: 'weekday-hidden',
label: 'Wochentag ausblenden',
icon: TextAa,
toggle: true,
checked: settingsStore.headerWeekdayFormat === 'hidden',
action: () => setWeekdayFormat('hidden'),
},
{
id: 'divider-2',
label: '',
type: 'divider',
},
{
id: 'show-date',
label: 'Datum anzeigen',
icon: Calendar,
toggle: true,
checked: settingsStore.headerShowDate,
action: () => toggleSetting('headerShowDate'),
},
{
id: 'always-show-month',
label: 'Monat immer anzeigen',
icon: CalendarBlank,
toggle: true,
checked: settingsStore.headerAlwaysShowMonth,
action: () => toggleSetting('headerAlwaysShowMonth'),
},
];
});
function toggleSetting(key: keyof typeof settingsStore.settings) {
const currentValue = settingsStore.settings[key];
if (typeof currentValue === 'boolean') {
settingsStore.set(key, !currentValue);
}
}
function setWeekdayFormat(format: WeekdayFormat) {
settingsStore.set('headerWeekdayFormat', format);
}
function handleClose() {
visible = false;
}
// Export show function to be called from parent
export function show(clientX: number, clientY: number) {
x = clientX;
y = clientY;
visible = true;
}
export function hide() {
visible = false;
}
</script>
<ContextMenu {visible} {x} {y} items={menuItems} onClose={handleClose} />

View file

@ -21,7 +21,7 @@
</button>
</div>
<div class="calendar-list">
<div class="calendar-list" role="group" aria-label="Kalender Sichtbarkeit">
{#each calendarsStore.calendars as calendar}
<label class="calendar-item">
<input
@ -29,8 +29,10 @@
checked={calendar.isVisible}
onchange={() => handleToggle(calendar.id)}
style="accent-color: {calendar.color}"
aria-label="{calendar.name} {calendar.isVisible ? 'sichtbar' : 'ausgeblendet'}"
/>
<span class="color-dot" style="background-color: {calendar.color}"></span>
<span class="color-dot" style="background-color: {calendar.color}" aria-hidden="true"
></span>
<span class="calendar-name">{calendar.name}</span>
</label>
{/each}

View file

@ -2,7 +2,6 @@
import { viewStore } from '$lib/stores/view.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import DateStripContextMenu from './DateStripContextMenu.svelte';
import {
format,
isToday,
@ -18,14 +17,6 @@
import { onMount, tick } from 'svelte';
import SunCalc from 'suncalc';
// Context menu reference
let contextMenu: DateStripContextMenu;
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
contextMenu?.show(e.clientX, e.clientY);
}
interface Props {
isToolbarExpanded?: boolean;
}
@ -247,7 +238,7 @@
class:compact={settingsStore.dateStripCompact}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="date-strip-container" oncontextmenu={handleContextMenu}>
<div class="date-strip-container">
<!-- Month label -->
<div class="month-header">
<span class="month-label">
@ -315,8 +306,6 @@
</div>
</div>
<DateStripContextMenu bind:this={contextMenu} />
<style>
.date-strip-wrapper {
position: fixed;

View file

@ -1,117 +0,0 @@
<script lang="ts">
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { Moon, Calendar, Eye, Columns, ArrowsIn, ArrowsOut } from '@manacore/shared-icons';
import { settingsStore } from '$lib/stores/settings.svelte';
// Context menu state
let visible = $state(false);
let x = $state(0);
let y = $state(0);
// Build menu items based on current settings
let menuItems = $derived.by((): ContextMenuItem[] => {
return [
{
id: 'moon-phases',
label: 'Mondphasen',
icon: Moon,
toggle: true,
checked: settingsStore.dateStripShowMoonPhases,
action: () => toggleSetting('dateStripShowMoonPhases'),
},
{
id: 'event-indicators',
label: 'Termin-Punkte',
icon: Eye,
toggle: true,
checked: settingsStore.dateStripShowEventIndicators,
action: () => toggleSetting('dateStripShowEventIndicators'),
},
{
id: 'weekday',
label: 'Wochentag',
icon: Calendar,
toggle: true,
checked: settingsStore.dateStripShowWeekday,
action: () => toggleSetting('dateStripShowWeekday'),
},
{
id: 'week-numbers',
label: 'Kalenderwochen',
icon: Calendar,
toggle: true,
checked: settingsStore.dateStripShowWeekNumbers,
action: () => toggleSetting('dateStripShowWeekNumbers'),
},
{
id: 'divider-1',
label: '',
type: 'divider',
},
{
id: 'highlight-weekends',
label: 'Wochenenden hervorheben',
icon: Calendar,
toggle: true,
checked: settingsStore.dateStripHighlightWeekends,
action: () => toggleSetting('dateStripHighlightWeekends'),
},
{
id: 'month-dividers',
label: 'Monatstrennlinien',
icon: Columns,
toggle: true,
checked: settingsStore.dateStripShowMonthDividers,
action: () => toggleSetting('dateStripShowMonthDividers'),
},
{
id: 'divider-2',
label: '',
type: 'divider',
},
{
id: 'compact',
label: 'Kompakte Ansicht',
icon: ArrowsIn,
toggle: true,
checked: settingsStore.dateStripCompact,
action: () => toggleSetting('dateStripCompact'),
},
{
id: 'divider-3',
label: '',
type: 'divider',
},
{
id: 'minimize',
label: settingsStore.dateStripCollapsed ? 'Erweitern' : 'Minimieren',
icon: settingsStore.dateStripCollapsed ? ArrowsOut : ArrowsIn,
action: () => toggleSetting('dateStripCollapsed'),
},
];
});
function toggleSetting(key: keyof typeof settingsStore.settings) {
const currentValue = settingsStore.settings[key];
if (typeof currentValue === 'boolean') {
settingsStore.set(key, !currentValue);
}
}
function handleClose() {
visible = false;
}
// Export show function to be called from parent
export function show(clientX: number, clientY: number) {
x = clientX;
y = clientY;
visible = true;
}
export function hide() {
visible = false;
}
</script>
<ContextMenu {visible} {x} {y} items={menuItems} onClose={handleClose} />

View file

@ -2,8 +2,6 @@
import { settingsStore } from '$lib/stores/settings.svelte';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
import DateStripContextMenu from './DateStripContextMenu.svelte';
interface Props {
isToolbarExpanded?: boolean;
isMobile?: boolean;
@ -11,17 +9,10 @@
let { isToolbarExpanded = false, isMobile = false }: Props = $props();
let contextMenu: DateStripContextMenu;
function handleClick() {
settingsStore.set('dateStripCollapsed', false);
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
contextMenu?.show(e.clientX, e.clientY);
}
// Format current date for FAB display: "Dez 14"
let fabLabel = $derived(format(new Date(), 'MMM d', { locale: de }));
</script>
@ -32,18 +23,11 @@
class:mobile={isMobile}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<button
onclick={handleClick}
oncontextmenu={handleContextMenu}
class="datestrip-fab"
title="Datumsleiste erweitern (Rechtsklick für Optionen)"
>
<button onclick={handleClick} class="datestrip-fab" title="Datumsleiste erweitern">
<span class="fab-label">{fabLabel}</span>
</button>
</div>
<DateStripContextMenu bind:this={contextMenu} />
<style>
.datestrip-fab-container {
position: fixed;

View file

@ -6,7 +6,6 @@
import { searchStore } from '$lib/stores/search.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import { birthdaysStore, type BirthdayEvent } from '$lib/stores/birthdays.svelte';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
import TodoDayCell from './TodoDayCell.svelte';
import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte';
import { useBirthdayPopover } from '$lib/composables';
@ -237,9 +236,9 @@
onQuickCreate(startTime, { x: e.clientX, y: e.clientY });
} else {
// Fallback: navigate to day view
// Fallback: navigate to week view for the selected day
viewStore.setDate(day);
viewStore.setViewType('day');
viewStore.setViewType('week');
}
}
@ -261,15 +260,7 @@
function handleMoreClick(day: Date, e: MouseEvent) {
e.stopPropagation();
viewStore.setDate(day);
viewStore.setViewType('day');
}
function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
// Don't show context menu for draft events
if (eventsStore.isDraftEvent(event.id)) return;
eventContextMenuStore.show(event, e.clientX, e.clientY);
viewStore.setViewType('week');
}
// ============================================================================
@ -285,16 +276,16 @@
<div class="month-view" style="--column-count: {columnCount}">
<!-- Week day headers -->
<div class="weekday-headers">
<div class="weekday-headers" role="row">
{#each weekDays as day}
<div class="weekday-header">{day}</div>
<div class="weekday-header" role="columnheader">{day}</div>
{/each}
</div>
<!-- Calendar grid -->
<div class="calendar-grid">
<div class="calendar-grid" role="grid" aria-label={$_('views.monthView')}>
{#each weeks as week}
<div class="week-row">
<div class="week-row" role="row">
{#each week as day}
{@const isDropTarget = isDragging && dragTargetDay && isSameDay(day, dragTargetDay)}
<div
@ -305,8 +296,9 @@
use:bindDayCellRef={day}
onclick={(e) => handleDayClick(day, e)}
onkeydown={(e) => e.key === 'Enter' && handleDayClick(day, e as unknown as MouseEvent)}
role="button"
role="gridcell"
tabindex="0"
aria-selected={isToday(day)}
aria-label={$_('a11y.createEventOn', {
values: { date: format(day, 'EEEE, d. MMMM', { locale: de }) },
})}
@ -339,9 +331,10 @@
style="background-color: {calendarsStore.getColor(event.calendarId)}"
onpointerdown={(e) => startDrag(event, e)}
onclick={(e) => !isDraft && handleEventClick(event, e)}
oncontextmenu={(e) => handleEventContextMenu(event, e)}
role="button"
tabindex="0"
aria-label={event.title ||
(isDraft ? $_('calendar.draftEvent') : $_('calendar.untitled'))}
>
{#if !event.isAllDay}
<span class="event-time"
@ -370,6 +363,9 @@
onclick={(e) => birthdayPopover.handleBirthdayClick(birthday, e)}
role="button"
tabindex="0"
aria-label="{birthday.displayName} - {$_(
'views.birthday'
)}{settingsStore.showBirthdayAge && birthday.age > 0 ? ` (${birthday.age})` : ''}"
>
🎂
<span class="event-title">{birthday.displayName}</span>

View file

@ -6,10 +6,7 @@
import { getOffsetDate } from '$lib/utils/dateNavigation';
import { HOUR_HEIGHT_PX } from '$lib/utils/calendarConstants';
import WeekView from './WeekView.svelte';
import DayView from './DayView.svelte';
import MonthView from './MonthView.svelte';
import MultiDayView from './MultiDayView.svelte';
import YearView from './YearView.svelte';
import AgendaView from './AgendaView.svelte';
import type { CalendarEvent } from '@calendar/shared';
@ -297,20 +294,8 @@
setTimeout(() => {
if (!currentPageEl) return;
// Only scroll for time-grid views (not month, year, agenda)
const timeGridViews = [
'day',
'3day',
'5day',
'week',
'10day',
'14day',
'30day',
'60day',
'90day',
'365day',
'custom',
];
// Only scroll for time-grid views (not month, agenda)
const timeGridViews = ['week'];
if (!timeGridViews.includes(viewStore.viewType)) return;
// Calculate scroll position to center around 12:00 (noon)
@ -335,32 +320,10 @@
<div class="carousel-track" style={trackStyle}>
<!-- Previous View -->
<div class="carousel-page" class:inactive={!isSwiping && offsetX <= 0}>
{#if viewStore.viewType === 'day'}
<DayView date={prevDate} />
{:else if viewStore.viewType === '3day'}
<MultiDayView dayCount={3} date={prevDate} />
{:else if viewStore.viewType === '5day'}
<MultiDayView dayCount={5} date={prevDate} />
{:else if viewStore.viewType === 'week'}
{#if viewStore.viewType === 'week'}
<WeekView date={prevDate} />
{:else if viewStore.viewType === '10day'}
<MultiDayView dayCount={10} date={prevDate} />
{:else if viewStore.viewType === '14day'}
<MultiDayView dayCount={14} date={prevDate} />
{:else if viewStore.viewType === '30day'}
<MultiDayView dayCount={30} date={prevDate} />
{:else if viewStore.viewType === '60day'}
<MultiDayView dayCount={60} date={prevDate} />
{:else if viewStore.viewType === '90day'}
<MultiDayView dayCount={90} date={prevDate} />
{:else if viewStore.viewType === '365day'}
<MultiDayView dayCount={365} date={prevDate} />
{:else if viewStore.viewType === 'custom'}
<MultiDayView dayCount={settingsStore.customDayCount} date={prevDate} />
{:else if viewStore.viewType === 'month'}
<MonthView date={prevDate} />
{:else if viewStore.viewType === 'year'}
<YearView date={prevDate} />
{:else if viewStore.viewType === 'agenda'}
<AgendaView date={prevDate} />
{/if}
@ -368,32 +331,10 @@
<!-- Current View (main interactive view) -->
<div class="carousel-page current" bind:this={currentPageEl}>
{#if viewStore.viewType === 'day'}
<DayView {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === '3day'}
<MultiDayView dayCount={3} {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === '5day'}
<MultiDayView dayCount={5} {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === 'week'}
{#if viewStore.viewType === 'week'}
<WeekView {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === '10day'}
<MultiDayView dayCount={10} {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === '14day'}
<MultiDayView dayCount={14} {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === '30day'}
<MultiDayView dayCount={30} {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === '60day'}
<MultiDayView dayCount={60} {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === '90day'}
<MultiDayView dayCount={90} {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === '365day'}
<MultiDayView dayCount={365} {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === 'custom'}
<MultiDayView dayCount={settingsStore.customDayCount} {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === 'month'}
<MonthView {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === 'year'}
<YearView {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === 'agenda'}
<AgendaView {onEventClick} />
{:else}
@ -403,32 +344,10 @@
<!-- Next View -->
<div class="carousel-page" class:inactive={!isSwiping && offsetX >= 0}>
{#if viewStore.viewType === 'day'}
<DayView date={nextDate} />
{:else if viewStore.viewType === '3day'}
<MultiDayView dayCount={3} date={nextDate} />
{:else if viewStore.viewType === '5day'}
<MultiDayView dayCount={5} date={nextDate} />
{:else if viewStore.viewType === 'week'}
{#if viewStore.viewType === 'week'}
<WeekView date={nextDate} />
{:else if viewStore.viewType === '10day'}
<MultiDayView dayCount={10} date={nextDate} />
{:else if viewStore.viewType === '14day'}
<MultiDayView dayCount={14} date={nextDate} />
{:else if viewStore.viewType === '30day'}
<MultiDayView dayCount={30} date={nextDate} />
{:else if viewStore.viewType === '60day'}
<MultiDayView dayCount={60} date={nextDate} />
{:else if viewStore.viewType === '90day'}
<MultiDayView dayCount={90} date={nextDate} />
{:else if viewStore.viewType === '365day'}
<MultiDayView dayCount={365} date={nextDate} />
{:else if viewStore.viewType === 'custom'}
<MultiDayView dayCount={settingsStore.customDayCount} date={nextDate} />
{:else if viewStore.viewType === 'month'}
<MonthView date={nextDate} />
{:else if viewStore.viewType === 'year'}
<YearView date={nextDate} />
{:else if viewStore.viewType === 'agenda'}
<AgendaView date={nextDate} />
{/if}

View file

@ -2,8 +2,6 @@
import { viewStore } from '$lib/stores/view.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import type { CalendarViewType } from '@calendar/shared';
import ViewModePillContextMenu from './ViewModePillContextMenu.svelte';
interface Props {
isToolbarExpanded?: boolean;
isMobile?: boolean;
@ -11,51 +9,22 @@
let { isToolbarExpanded = false, isMobile = false }: Props = $props();
let contextMenu: ViewModePillContextMenu;
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
contextMenu?.show(e.clientX, e.clientY);
}
function handleViewClick(view: CalendarViewType) {
viewStore.setViewType(view);
}
// View labels (short versions for pill)
const viewLabels: Record<CalendarViewType, string> = {
day: '1',
'3day': '3',
'5day': '5',
week: '7',
'10day': '10',
'14day': '14',
'30day': '30',
'60day': '60',
'90day': '90',
'365day': '365',
month: 'M',
year: 'Y',
agenda: 'A',
custom: 'C',
};
// View titles for tooltip
const viewTitles: Record<CalendarViewType, string> = {
day: 'Tagesansicht',
'3day': '3-Tage-Ansicht',
'5day': '5-Tage-Ansicht',
week: 'Wochenansicht',
'10day': '10-Tage-Ansicht',
'14day': '14-Tage-Ansicht',
'30day': '30-Tage-Ansicht',
'60day': '60-Tage-Ansicht',
'90day': '90-Tage-Ansicht',
'365day': '365-Tage-Ansicht',
month: 'Monatsansicht',
year: 'Jahresansicht',
agenda: 'Agenda',
custom: 'Benutzerdefiniert',
};
// Get enabled views from settings
@ -63,12 +32,7 @@
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="view-mode-pill"
class:toolbar-expanded={isToolbarExpanded}
class:mobile={isMobile}
oncontextmenu={handleContextMenu}
>
<div class="view-mode-pill" class:toolbar-expanded={isToolbarExpanded} class:mobile={isMobile}>
{#each enabledViews as view}
<button
type="button"
@ -77,29 +41,14 @@
onclick={() => handleViewClick(view)}
title={viewTitles[view]}
>
{#if view === 'day'}
{#if view === 'week'}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="6" y="4" width="12" height="16" rx="2" stroke-width="2" />
<path stroke-linecap="round" stroke-width="2" d="M6 8h12" />
<path
stroke-linecap="round"
stroke-width="2"
d="M3 4v16M6.5 4v16M10 4v16M13.5 4v16M17 4v16M20.5 4v16M24 4v16"
/>
</svg>
{:else if view === '5day' || view === 'week'}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if view === '5day'}
<path
stroke-linecap="round"
stroke-width="2"
d="M5 4v16M9 4v16M13 4v16M17 4v16M21 4v16"
/>
{:else}
<path
stroke-linecap="round"
stroke-width="2"
d="M3 4v16M6.5 4v16M10 4v16M13.5 4v16M17 4v16M20.5 4v16M24 4v16"
/>
{/if}
</svg>
{:else if view === '10day' || view === '14day'}
<span class="view-text">{viewLabels[view]}</span>
{:else if view === 'month'}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="16" rx="2" stroke-width="2" />
@ -110,21 +59,6 @@
d="M7 13h2M11 13h2M15 13h2M7 17h2M11 17h2"
/>
</svg>
{:else if view === 'year'}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="2" y="2" width="4" height="4" rx="0.5" stroke-width="1.5" />
<rect x="7.5" y="2" width="4" height="4" rx="0.5" stroke-width="1.5" />
<rect x="13" y="2" width="4" height="4" rx="0.5" stroke-width="1.5" />
<rect x="18.5" y="2" width="4" height="4" rx="0.5" stroke-width="1.5" />
<rect x="2" y="8" width="4" height="4" rx="0.5" stroke-width="1.5" />
<rect x="7.5" y="8" width="4" height="4" rx="0.5" stroke-width="1.5" />
<rect x="13" y="8" width="4" height="4" rx="0.5" stroke-width="1.5" />
<rect x="18.5" y="8" width="4" height="4" rx="0.5" stroke-width="1.5" />
<rect x="2" y="14" width="4" height="4" rx="0.5" stroke-width="1.5" />
<rect x="7.5" y="14" width="4" height="4" rx="0.5" stroke-width="1.5" />
<rect x="13" y="14" width="4" height="4" rx="0.5" stroke-width="1.5" />
<rect x="18.5" y="14" width="4" height="4" rx="0.5" stroke-width="1.5" />
</svg>
{:else if view === 'agenda'}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h12" />
@ -134,8 +68,6 @@
{/each}
</div>
<ViewModePillContextMenu bind:this={contextMenu} />
<style>
.view-mode-pill {
position: fixed;

View file

@ -1,410 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fly } from 'svelte/transition';
import { settingsStore } from '$lib/stores/settings.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import type { CalendarViewType } from '@calendar/shared';
// Context menu state
let visible = $state(false);
let x = $state(0);
let y = $state(0);
let menuElement = $state<HTMLElement | null>(null);
let adjustedX = $state(0);
let adjustedY = $state(0);
// Custom day count input state
let customDayInput = $state(String(settingsStore.customDayCount));
// View labels
const viewLabels: Record<CalendarViewType, string> = {
day: 'Tag (1)',
'3day': '3 Tage',
'5day': '5 Tage',
week: 'Woche (7)',
'10day': '10 Tage',
'14day': '14 Tage',
'30day': '30 Tage',
'60day': '60 Tage',
'90day': '90 Tage',
'365day': '365 Tage',
month: 'Monat',
year: 'Jahr',
agenda: 'Agenda',
custom: 'Benutzerdefiniert',
};
// All available views (ordered)
const allViews: CalendarViewType[] = [
'day',
'3day',
'5day',
'week',
'10day',
'14day',
'30day',
'60day',
'90day',
'365day',
'month',
'year',
'agenda',
'custom',
];
// Adjust position to keep menu within viewport
$effect(() => {
if (visible && menuElement) {
const rect = menuElement.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Adjust X if menu would overflow right
if (x + rect.width > viewportWidth - 10) {
adjustedX = x - rect.width;
} else {
adjustedX = x;
}
// Adjust Y if menu would overflow bottom
if (y + rect.height > viewportHeight - 10) {
adjustedY = y - rect.height;
} else {
adjustedY = y;
}
}
});
// Sync custom day input when settings change
$effect(() => {
customDayInput = String(settingsStore.customDayCount);
});
function isViewEnabled(view: CalendarViewType): boolean {
return settingsStore.quickViewPillViews.includes(view);
}
function toggleView(view: CalendarViewType) {
const current = settingsStore.quickViewPillViews;
if (current.includes(view)) {
// Remove view (but keep at least one)
if (current.length > 1) {
settingsStore.set(
'quickViewPillViews',
current.filter((v) => v !== view)
);
}
} else {
// Add view
settingsStore.set('quickViewPillViews', [...current, view]);
}
}
function handleCustomDayInputChange(e: Event) {
const target = e.target as HTMLInputElement;
customDayInput = target.value;
}
function applyCustomDays() {
const value = parseInt(customDayInput, 10);
if (isNaN(value) || value < 1 || value > 365) {
// Reset to current value if invalid
customDayInput = String(settingsStore.customDayCount);
return;
}
// Set custom day count
settingsStore.set('customDayCount', value);
customDayInput = String(value);
// Auto-enable 'custom' view if not already
const current = settingsStore.quickViewPillViews;
if (!current.includes('custom')) {
settingsStore.set('quickViewPillViews', [...current, 'custom']);
}
// Switch to custom view
viewStore.setViewType('custom');
// Close the menu
visible = false;
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
visible = false;
}
}
function handleInputKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
applyCustomDays();
}
// Stop propagation to prevent menu from closing
e.stopPropagation();
}
onMount(() => {
// Close on click outside
const handleClickOutside = (e: MouseEvent) => {
if (menuElement && !menuElement.contains(e.target as Node)) {
visible = false;
}
};
// Close on scroll
const handleScroll = () => {
visible = false;
};
window.addEventListener('click', handleClickOutside);
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('click', handleClickOutside);
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('keydown', handleKeyDown);
};
});
// Export show function to be called from parent
export function show(clientX: number, clientY: number) {
x = clientX;
y = clientY;
visible = true;
}
export function hide() {
visible = false;
}
</script>
{#if visible}
<!-- Backdrop to block clicks on elements behind -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="context-menu-backdrop"
onpointerdown={(e) => {
e.preventDefault();
e.stopPropagation();
visible = false;
}}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
visible = false;
}}
oncontextmenu={(e) => {
e.preventDefault();
e.stopPropagation();
visible = false;
}}
></div>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={menuElement}
class="context-menu"
style="left: {adjustedX}px; top: {adjustedY}px;"
role="menu"
tabindex="-1"
transition:fly={{ duration: 150, y: -8 }}
onclick={(e) => e.stopPropagation()}
oncontextmenu={(e) => e.preventDefault()}
onkeydown={handleKeyDown}
>
<!-- Standard view toggles -->
{#each allViews as view}
<button class="menu-item has-toggle" onclick={() => toggleView(view)} role="menuitem">
<span class="item-toggle" class:checked={isViewEnabled(view)}>
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
</span>
<span class="item-label">{viewLabels[view]}</span>
</button>
{/each}
<!-- Divider -->
<div class="divider"></div>
<!-- Custom day count section -->
<div class="custom-section">
<span class="custom-label">Benutzerdefiniert (1-365)</span>
<div class="custom-input-row">
<input
type="number"
class="custom-input"
min="1"
max="365"
value={customDayInput}
oninput={handleCustomDayInputChange}
onkeydown={handleInputKeyDown}
onclick={(e) => e.stopPropagation()}
/>
<span class="custom-unit">Tage</span>
<button class="custom-apply-btn" onclick={applyCustomDays}> Setzen </button>
</div>
</div>
</div>
{/if}
<style>
.context-menu-backdrop {
position: fixed;
inset: 0;
z-index: 9998;
background: transparent;
pointer-events: auto;
}
.context-menu {
position: fixed;
z-index: 9999;
min-width: 200px;
max-width: 280px;
padding: 0.375rem;
background: var(--color-surface-elevated-3);
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-lg);
pointer-events: auto;
}
.menu-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.625rem;
border: none;
background: transparent;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
text-align: left;
transition: background-color 100ms ease;
}
.menu-item:hover {
background: hsl(var(--color-muted));
}
.item-label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.divider {
height: 1px;
margin: 0.375rem 0.5rem;
background: hsl(var(--color-border));
}
/* Toggle switch styles */
.item-toggle {
display: flex;
align-items: center;
flex-shrink: 0;
}
.toggle-track {
position: relative;
width: 28px;
height: 16px;
background: hsl(var(--color-muted));
border-radius: 8px;
transition: background-color 150ms ease;
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
background: hsl(var(--color-background));
border-radius: 50%;
transition: transform 150ms ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.item-toggle.checked .toggle-track {
background: hsl(var(--color-primary));
}
.item-toggle.checked .toggle-thumb {
transform: translateX(12px);
}
/* Custom section styles */
.custom-section {
padding: 0.5rem 0.625rem;
}
.custom-label {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
margin-bottom: 0.5rem;
}
.custom-input-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.custom-input {
width: 60px;
padding: 0.375rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-md);
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
text-align: center;
}
.custom-input:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
/* Hide number input spinners */
.custom-input::-webkit-outer-spin-button,
.custom-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.custom-input[type='number'] {
-moz-appearance: textfield;
}
.custom-unit {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.custom-apply-btn {
margin-left: auto;
padding: 0.375rem 0.75rem;
border: none;
border-radius: var(--radius-md);
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: opacity 150ms ease;
}
.custom-apply-btn:hover {
opacity: 0.9;
}
</style>

View file

@ -2,8 +2,6 @@
import { viewStore } from '$lib/stores/view.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import type { CalendarViewType } from '@calendar/shared';
import ViewModePillContextMenu from './ViewModePillContextMenu.svelte';
interface Props {
/** Bottom offset from viewport bottom (default: '70px') */
bottomOffset?: string;
@ -11,75 +9,40 @@
let { bottomOffset = '70px' }: Props = $props();
let contextMenu: ViewModePillContextMenu;
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
contextMenu?.show(e.clientX, e.clientY);
}
function handleViewClick(view: CalendarViewType) {
viewStore.setViewType(view);
}
// View labels (numbers for day views, letters for others)
// View labels
const viewLabels: Record<CalendarViewType, string> = {
day: '1',
'3day': '3',
'5day': '5',
week: '7',
'10day': '10',
'14day': '14',
'30day': '30',
'60day': '60',
'90day': '90',
'365day': '365',
month: 'M',
year: 'Y',
agenda: 'L',
custom: '', // Will be set dynamically
};
// View titles for tooltip
const viewTitles: Record<CalendarViewType, string> = {
day: 'Tagesansicht',
'3day': '3-Tage-Ansicht',
'5day': '5-Tage-Ansicht',
week: 'Wochenansicht',
'10day': '10-Tage-Ansicht',
'14day': '14-Tage-Ansicht',
'30day': '30-Tage-Ansicht',
'60day': '60-Tage-Ansicht',
'90day': '90-Tage-Ansicht',
'365day': '365-Tage-Ansicht',
month: 'Monatsansicht',
year: 'Jahresansicht',
agenda: 'Agenda',
custom: 'Benutzerdefiniert',
};
// Get enabled views from settings
let enabledViews = $derived(settingsStore.quickViewPillViews);
// Get label for a view (dynamic for custom)
// Get label for a view
function getViewLabel(view: CalendarViewType): string {
if (view === 'custom') {
return String(settingsStore.customDayCount);
}
return viewLabels[view];
return viewLabels[view] || '';
}
// Get title for a view (dynamic for custom)
// Get title for a view
function getViewTitle(view: CalendarViewType): string {
if (view === 'custom') {
return `${settingsStore.customDayCount}-Tage-Ansicht`;
}
return viewTitles[view];
return viewTitles[view] || '';
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="views-bar" style="--bottom-offset: {bottomOffset}" oncontextmenu={handleContextMenu}>
<div class="views-bar" style="--bottom-offset: {bottomOffset}">
<div class="views-container">
<div class="views-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -108,8 +71,6 @@
</div>
</div>
<ViewModePillContextMenu bind:this={contextMenu} />
<style>
.views-bar {
position: fixed;

View file

@ -7,7 +7,6 @@
import { searchStore } from '$lib/stores/search.svelte';
import { todosStore, type Task } from '$lib/stores/todos.svelte';
import { birthdaysStore, type BirthdayEvent } from '$lib/stores/birthdays.svelte';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte';
import { useVisibleHours, useCurrentTimeIndicator, useBirthdayPopover } from '$lib/composables';
import { toDate } from '$lib/utils/eventDateHelpers';
@ -20,8 +19,6 @@
} from '$lib/utils/eventFiltering';
import EventCard from './EventCard.svelte';
import TaskBlock from './TaskBlock.svelte';
import EventContextMenu from '$lib/components/event/EventContextMenu.svelte';
import CalendarHeaderContextMenu from './CalendarHeaderContextMenu.svelte';
import { goto } from '$app/navigation';
import {
format,
@ -461,19 +458,6 @@
return `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')} - ${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
}
function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (eventsStore.isDraftEvent(event.id)) return;
eventContextMenuStore.show(event, e.clientX, e.clientY);
}
function handleContextMenuEdit(event: CalendarEvent) {
if (onEventClick) {
onEventClick(event);
}
}
// ========== Drag & Drop Functions ==========
function getDayFromX(clientX: number): Date | null {
@ -1055,10 +1039,15 @@
<!-- Sticky header container -->
<div class="sticky-header">
<!-- Day headers -->
<div class="day-headers">
<div class="day-headers" role="row">
<div class="time-gutter"></div>
{#each days as day}
<div class="day-header" class:today={isToday(day)}>
<div
class="day-header"
class:today={isToday(day)}
role="columnheader"
aria-label={format(day, 'EEEE, d. MMMM', { locale: currentDateLocale })}
>
<span class="day-name">{format(day, 'EEE', { locale: currentDateLocale })}</span>
<span class="day-number" class:today={isToday(day)}>{format(day, 'd')}</span>
</div>
@ -1082,6 +1071,7 @@
class:search-dimmed={searchStore.isEventDimmed(event.id)}
style="background-color: {calendarsStore.getColor(event.calendarId)}"
onclick={() => goto(`/?event=${event.id}`)}
aria-label="{event.title} - {$_('views.allDay')}"
>
{event.title}
</button>
@ -1091,6 +1081,9 @@
<button
class="all-day-event birthday-event"
onclick={(e) => birthdayPopover.handleBirthdayClick(birthday, e)}
aria-label="{birthday.displayName} - {$_(
'views.birthday'
)}{settingsStore.showBirthdayAge && birthday.age > 0 ? ` (${birthday.age})` : ''}"
>
🎂 {birthday.displayName}
{#if settingsStore.showBirthdayAge && birthday.age > 0}
@ -1105,7 +1098,12 @@
</div>
<!-- Time grid -->
<div class="time-grid scrollbar-thin" bind:this={timeGridEl}>
<div
class="time-grid scrollbar-thin"
bind:this={timeGridEl}
role="grid"
aria-label={$_('views.weekView')}
>
<!-- Time column -->
<div class="time-column">
{#each hours as hour}
@ -1146,6 +1144,7 @@
class:search-dimmed={searchStore.isEventDimmed(event.id)}
style="background-color: {calendarsStore.getColor(event.calendarId)}"
onclick={() => goto(`/?event=${event.id}`)}
aria-label="{event.title} - {$_('views.allDay')}"
>
<span class="event-title">{event.title}</span>
</button>
@ -1173,7 +1172,6 @@
formattedTime={isBeingResized ? getResizePreviewTime() : formatEventTimeRange(event)}
onClick={handleEventClick}
onPointerDown={startDrag}
onContextMenu={handleEventContextMenu}
onResizeStart={startResize}
/>
{/each}
@ -1277,8 +1275,6 @@
</div>
</div>
<EventContextMenu onEdit={handleContextMenuEdit} />
<!-- Birthday Popover -->
{#if birthdayPopover.selectedBirthday}
<BirthdayPopover

View file

@ -1,420 +0,0 @@
<script lang="ts">
import { viewStore } from '$lib/stores/view.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isSameMonth,
isToday,
setHours,
setMinutes,
} from 'date-fns';
import { de } from 'date-fns/locale';
import { toDate } from '$lib/utils/eventDateHelpers';
import { filterByTags } from '$lib/utils/eventFiltering';
import type { CalendarViewType, CalendarEvent } from '@calendar/shared';
interface Props {
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
date?: Date;
onQuickCreate?: (date: Date, position: { x: number; y: number }, endDate?: Date) => void;
onEventClick?: (event: CalendarEvent) => void;
}
let { date, onQuickCreate, onEventClick }: Props = $props();
// Use provided date or fall back to viewStore
let effectiveDate = $derived(date ?? viewStore.currentDate);
// Derived values
let year = $derived(effectiveDate.getFullYear());
let months = $derived(Array.from({ length: 12 }, (_, i) => new Date(year, i, 1)));
// Week day headers
const weekDaysFromMonday = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const weekDaysFromSunday = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
let weekDays = $derived(
settingsStore.weekStartsOn === 1 ? weekDaysFromMonday : weekDaysFromSunday
);
// Context menu state
let contextMenu = $state<{ visible: boolean; x: number; y: number; date: Date | null }>({
visible: false,
x: 0,
y: 0,
date: null,
});
// Context menu options
const viewOptions: { type: CalendarViewType; label: string }[] = [
{ type: 'day', label: 'Tagesansicht' },
{ type: 'week', label: 'Wochenansicht' },
{ type: 'month', label: 'Monatsansicht' },
];
// Precompute event counts for performance
let eventCountsByDay = $derived.by(() => {
const counts = new Map<string, number>();
let events = eventsStore.events ?? [];
// Apply tag filter if tags are selected
events = filterByTags(events, settingsStore.selectedTagIds);
for (const event of events) {
const start = toDate(event.startTime);
const key = format(start, 'yyyy-MM-dd');
counts.set(key, (counts.get(key) || 0) + 1);
}
return counts;
});
// Helper functions
function getMonthDays(month: Date): Date[] {
const monthStart = startOfMonth(month);
const monthEnd = endOfMonth(month);
const calendarStart = startOfWeek(monthStart, {
weekStartsOn: settingsStore.weekStartsOn,
});
const calendarEnd = endOfWeek(monthEnd, {
weekStartsOn: settingsStore.weekStartsOn,
});
return eachDayOfInterval({ start: calendarStart, end: calendarEnd });
}
function getEventCount(day: Date): number {
const key = format(day, 'yyyy-MM-dd');
return eventCountsByDay.get(key) || 0;
}
// Event handlers
function handleDayClick(day: Date, e: MouseEvent) {
if (onQuickCreate) {
const startTime = setMinutes(setHours(day, 9), 0);
onQuickCreate(startTime, { x: e.clientX, y: e.clientY });
} else {
viewStore.setDate(day);
viewStore.setViewType('day');
}
}
function handleDayContextMenu(day: Date, e: MouseEvent) {
e.preventDefault();
contextMenu = {
visible: true,
x: e.clientX,
y: e.clientY,
date: day,
};
}
function handleContextMenuSelect(viewType: CalendarViewType) {
if (contextMenu.date) {
viewStore.setDate(contextMenu.date);
viewStore.setViewType(viewType);
}
closeContextMenu();
}
function closeContextMenu() {
contextMenu = { visible: false, x: 0, y: 0, date: null };
}
function handleMonthClick(month: Date) {
viewStore.setDate(month);
viewStore.setViewType('month');
}
function handleKeyDown(e: KeyboardEvent, day: Date) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleDayClick(day, e as unknown as MouseEvent);
}
}
// Close context menu on click outside
function handleWindowClick() {
if (contextMenu.visible) {
closeContextMenu();
}
}
// Close context menu on Escape
function handleWindowKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && contextMenu.visible) {
closeContextMenu();
}
}
</script>
<svelte:window onclick={handleWindowClick} onkeydown={handleWindowKeyDown} />
<div class="year-view" role="grid" aria-label="Jahresansicht {year}">
{#each months as month}
<div class="mini-month" role="gridcell">
<button
class="month-header"
onclick={() => handleMonthClick(month)}
aria-label="Gehe zu {format(month, 'MMMM yyyy', { locale: de })}"
>
{format(month, 'MMMM', { locale: de })}
</button>
<div class="weekday-row">
{#each weekDays as day}
<span class="weekday">{day}</span>
{/each}
</div>
<div class="days-grid" role="grid" aria-label={format(month, 'MMMM', { locale: de })}>
{#each getMonthDays(month) as day}
{@const eventCount = getEventCount(day)}
<button
class="day"
class:other-month={!isSameMonth(day, month)}
class:today={isToday(day)}
class:has-events={eventCount > 0}
class:has-many-events={eventCount > 3}
role="gridcell"
tabindex="0"
aria-label="{format(day, 'd. MMMM', { locale: de })}{eventCount > 0
? `, ${eventCount} Termine`
: ''}"
onclick={(e) => handleDayClick(day, e)}
oncontextmenu={(e) => handleDayContextMenu(day, e)}
onkeydown={(e) => handleKeyDown(e, day)}
>
{format(day, 'd')}
</button>
{/each}
</div>
</div>
{/each}
</div>
<!-- Context Menu -->
{#if contextMenu.visible && contextMenu.date}
<div
class="context-menu"
style="left: {contextMenu.x}px; top: {contextMenu.y}px;"
role="menu"
aria-label="Ansicht wählen"
>
<div class="context-menu-header">
{format(contextMenu.date, 'd. MMMM yyyy', { locale: de })}
</div>
{#each viewOptions as option}
<button
class="context-menu-item"
role="menuitem"
onclick={() => handleContextMenuSelect(option.type)}
>
{option.label}
</button>
{/each}
</div>
{/if}
<style>
.year-view {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
padding: 1rem;
padding-bottom: 8rem;
height: 100%;
overflow: auto;
}
.mini-month {
background: hsl(var(--color-muted) / 0.3);
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-md);
padding: 0.75rem;
}
.month-header {
width: 100%;
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
text-align: left;
cursor: pointer;
border-radius: var(--radius-sm);
transition: background-color 150ms ease;
}
.month-header:hover {
background: hsl(var(--color-muted));
}
.weekday-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin: 0.5rem 0 0.25rem 0;
}
.weekday {
text-align: center;
font-size: 0.6875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.days-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.day {
position: relative;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8125rem;
border: none;
background: transparent;
border-radius: var(--radius-full);
cursor: pointer;
color: hsl(var(--color-foreground));
transition: all 150ms ease;
}
.day:hover {
background: hsl(var(--color-muted));
}
.day.other-month {
color: hsl(var(--color-muted-foreground));
opacity: 0.4;
}
.day.today {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-weight: 600;
}
.day.today:hover {
background: hsl(var(--color-primary) / 0.8);
}
.day.has-events::after {
content: '';
position: absolute;
bottom: 3px;
width: 4px;
height: 4px;
border-radius: var(--radius-full);
background: hsl(var(--color-primary));
}
.day.today.has-events::after {
background: hsl(var(--color-primary-foreground));
}
.day.has-many-events::after {
width: 8px;
border-radius: 2px;
}
/* Context Menu */
.context-menu {
position: fixed;
z-index: 100;
min-width: 160px;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-md);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 0.25rem;
animation: context-menu-in 150ms ease;
}
@keyframes context-menu-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.context-menu-header {
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
border-bottom: 1px solid hsl(var(--color-border));
margin-bottom: 0.25rem;
}
.context-menu-item {
width: 100%;
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
text-align: left;
font-size: 0.875rem;
color: hsl(var(--color-foreground));
cursor: pointer;
border-radius: var(--radius-sm);
transition: background-color 150ms ease;
}
.context-menu-item:hover {
background: hsl(var(--color-muted));
}
/* Responsive breakpoints */
@media (max-width: 1200px) {
.year-view {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.year-view {
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
padding: 0.75rem;
}
.mini-month {
padding: 0.5rem;
}
.month-header {
font-size: 0.75rem;
padding: 0.25rem;
}
.weekday {
font-size: 0.5625rem;
}
.day {
font-size: 0.6875rem;
}
}
@media (max-width: 480px) {
.year-view {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -1,197 +0,0 @@
<script lang="ts">
import { ContextMenu, type ContextMenuItem, toastStore } from '@manacore/shared-ui';
import { Pencil, Copy, Trash, Palette, CalendarBlank, Export } from '@manacore/shared-icons';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import type { CalendarEvent } from '@calendar/shared';
interface Props {
onEdit?: (event: CalendarEvent) => void;
}
let { onEdit }: Props = $props();
// Build menu items based on target event
let menuItems = $derived.by((): ContextMenuItem[] => {
const event = eventContextMenuStore.targetEvent;
if (!event) return [];
return [
{
id: 'edit',
label: 'Bearbeiten',
icon: Pencil,
shortcut: 'E',
action: () => handleEdit(),
},
{
id: 'duplicate',
label: 'Duplizieren',
icon: Copy,
shortcut: 'D',
action: () => handleDuplicate(),
},
{
id: 'divider-1',
label: '',
type: 'divider',
},
{
id: 'change-calendar',
label: 'Kalender wechseln',
icon: CalendarBlank,
action: () => handleChangeCalendar(),
disabled: calendarsStore.calendars.length <= 1,
},
{
id: 'change-color',
label: 'Farbe ändern',
icon: Palette,
action: () => handleChangeColor(),
},
{
id: 'export',
label: 'Exportieren (.ics)',
icon: Export,
action: () => handleExport(),
},
{
id: 'divider-2',
label: '',
type: 'divider',
},
{
id: 'delete',
label: 'Löschen',
icon: Trash,
variant: 'danger',
action: () => handleDelete(),
},
];
});
function handleEdit() {
const event = eventContextMenuStore.targetEvent;
if (event && onEdit) {
onEdit(event);
}
}
async function handleDuplicate() {
const event = eventContextMenuStore.targetEvent;
if (!event) return;
try {
await eventsStore.createEvent({
calendarId: event.calendarId,
title: `${event.title} (Kopie)`,
description: event.description ?? undefined,
location: event.location ?? undefined,
startTime: event.startTime,
endTime: event.endTime,
isAllDay: event.isAllDay,
color: event.color ?? undefined,
});
toastStore.success('Termin dupliziert');
} catch (error) {
console.error('Error duplicating event:', error);
toastStore.error('Fehler beim Duplizieren');
}
}
function handleChangeCalendar() {
// Workaround: cycles through calendars until modal is implemented
const event = eventContextMenuStore.targetEvent;
if (!event) return;
// For now, cycle through calendars
const calendars = calendarsStore.calendars;
const currentIndex = calendars.findIndex((c) => c.id === event.calendarId);
const nextIndex = (currentIndex + 1) % calendars.length;
const nextCalendar = calendars[nextIndex];
if (nextCalendar) {
eventsStore.updateEvent(event.id, { calendarId: nextCalendar.id });
toastStore.success(`Verschoben nach "${nextCalendar.name}"`);
}
}
function handleChangeColor() {
// Workaround: cycles through colors until modal is implemented
const event = eventContextMenuStore.targetEvent;
if (!event) return;
// For now, cycle through some predefined colors
const colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899'];
const currentIndex = colors.indexOf(event.color || '');
const nextIndex = (currentIndex + 1) % colors.length;
eventsStore.updateEvent(event.id, { color: colors[nextIndex] });
toastStore.success('Farbe geändert');
}
function handleExport() {
const event = eventContextMenuStore.targetEvent;
if (!event) return;
// Generate simple ICS content
const startDate =
typeof event.startTime === 'string' ? new Date(event.startTime) : event.startTime;
const endDate = typeof event.endTime === 'string' ? new Date(event.endTime) : event.endTime;
const formatDate = (date: Date) => {
return date.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
};
const icsContent = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Manacore//Calendar//DE
BEGIN:VEVENT
UID:${event.id}
DTSTART:${formatDate(startDate)}
DTEND:${formatDate(endDate)}
SUMMARY:${event.title}
${event.description ? `DESCRIPTION:${event.description}` : ''}
${event.location ? `LOCATION:${event.location}` : ''}
END:VEVENT
END:VCALENDAR`;
const blob = new Blob([icsContent], { type: 'text/calendar' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${event.title.replace(/[^a-zA-Z0-9]/g, '_')}.ics`;
link.click();
URL.revokeObjectURL(url);
toastStore.success('Termin exportiert');
}
async function handleDelete() {
const event = eventContextMenuStore.targetEvent;
if (!event) return;
if (confirm(`Möchten Sie "${event.title}" wirklich löschen?`)) {
try {
await eventsStore.deleteEvent(event.id);
toastStore.success('Termin gelöscht');
} catch (error) {
console.error('Error deleting event:', error);
toastStore.error('Fehler beim Löschen');
}
}
}
function handleClose() {
eventContextMenuStore.hide();
}
</script>
<ContextMenu
visible={eventContextMenuStore.visible}
x={eventContextMenuStore.x}
y={eventContextMenuStore.y}
items={menuItems}
onClose={handleClose}
/>

View file

@ -145,11 +145,13 @@
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_to_interactive_role -->
<div class="modal-backdrop" onclick={handleBackdropClick} role="button" tabindex="-1">
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={handleBackdropClick} role="presentation">
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="modal-title">
{#if loading}
<EventDetailSkeleton />
<div aria-live="polite" aria-label="Laden...">
<EventDetailSkeleton />
</div>
{:else if event}
<div class="modal-header">
<h2 id="modal-title" class="modal-title">

View file

@ -231,7 +231,11 @@
}
</script>
<form onsubmit={handleSubmit} class="flex flex-col gap-4">
<form
onsubmit={handleSubmit}
class="flex flex-col gap-4"
aria-label={mode === 'create' ? 'Termin erstellen' : 'Termin bearbeiten'}
>
<div class="flex flex-col gap-2">
<label for="title" class="text-sm font-medium text-foreground">Titel *</label>
<input
@ -342,6 +346,8 @@
type="button"
class="flex items-center gap-1 text-sm text-primary hover:text-primary/80 transition-colors self-start"
onclick={() => (showLocationDetails = !showLocationDetails)}
aria-expanded={showLocationDetails}
aria-controls="location-details"
>
<svg
class="w-4 h-4 transition-transform"
@ -349,6 +355,7 @@
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
@ -357,7 +364,10 @@
<!-- Address detail fields -->
{#if showLocationDetails}
<div class="flex flex-col gap-3 p-3 bg-muted/50 rounded-lg border border-border mt-1">
<div
id="location-details"
class="flex flex-col gap-3 p-3 bg-muted/50 rounded-lg border border-border mt-1"
>
<div class="flex flex-col gap-1">
<label for="street" class="text-xs font-medium text-muted-foreground">Straße</label>
<input

View file

@ -692,6 +692,8 @@
oninput={handleTitleChange}
bind:this={titleInputRef}
placeholder="Titel hinzufügen"
aria-label="Terminname"
required
/>
</div>
@ -724,6 +726,8 @@
type="button"
class="calendar-pill"
class:active={calendarId === cal.id}
aria-pressed={calendarId === cal.id}
aria-label="Kalender: {cal.name}"
onclick={() => {
calendarId = cal.id;
if (!isEditMode) {
@ -762,7 +766,8 @@
<button
type="button"
class="remove-person"
onclick={() => (responsiblePerson = null)}>×</button
onclick={() => (responsiblePerson = null)}
aria-label="Verantwortliche Person entfernen">×</button
>
</div>
{:else}
@ -799,6 +804,7 @@
class="remove-person"
onclick={() =>
(attendees = attendees.filter((a) => a.email !== attendee.email))}
aria-label="Teilnehmer {attendee.name || attendee.email} entfernen"
>×</button
>
</div>
@ -832,8 +838,16 @@
</div>
<!-- All day toggle -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_to_interactive_role -->
<div class="form-row clickable" onclick={handleAllDayToggle} role="button" tabindex="0">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="form-row clickable"
onclick={handleAllDayToggle}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && handleAllDayToggle()}
role="switch"
tabindex="0"
aria-checked={isAllDay}
aria-label="Ganztägig"
>
<div class="row-icon">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -887,22 +901,24 @@
</div>
<div class="row-content datetime-row">
<div class="datetime-field">
<span class="field-label">Beginn</span>
<span class="field-label" id="start-date-label">Beginn</span>
<input
type="date"
class="field-input"
value={startDateStr}
onchange={handleStartDateChange}
aria-labelledby="start-date-label"
/>
</div>
{#if !isAllDay}
<div class="datetime-field time-field">
<span class="field-label">Uhrzeit</span>
<span class="field-label" id="start-time-label">Uhrzeit</span>
<input
type="time"
class="field-input"
value={startTimeStr}
onchange={handleStartTimeChange}
aria-label="Beginn Uhrzeit"
/>
</div>
{/if}
@ -912,7 +928,13 @@
<!-- End date/time -->
<div class="form-row">
<div class="row-icon">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
class="icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -923,22 +945,24 @@
</div>
<div class="row-content datetime-row">
<div class="datetime-field">
<span class="field-label">Ende</span>
<span class="field-label" id="end-date-label">Ende</span>
<input
type="date"
class="field-input"
value={endDateStr}
onchange={handleEndDateChange}
aria-labelledby="end-date-label"
/>
</div>
{#if !isAllDay}
<div class="datetime-field time-field">
<span class="field-label">Uhrzeit</span>
<span class="field-label" id="end-time-label">Uhrzeit</span>
<input
type="time"
class="field-input"
value={endTimeStr}
onchange={handleEndTimeChange}
aria-label="Ende Uhrzeit"
/>
</div>
{/if}
@ -969,12 +993,14 @@
class="field-input full"
bind:value={location}
placeholder="Ort hinzufügen"
aria-label="Ort"
/>
<!-- Toggle for address details -->
<button
type="button"
class="address-toggle"
onclick={() => (showLocationDetails = !showLocationDetails)}
aria-expanded={showLocationDetails}
>
<svg
class="toggle-chevron"
@ -1060,6 +1086,7 @@
bind:value={description}
placeholder="Beschreibung hinzufügen"
rows="3"
aria-label="Beschreibung"
></textarea>
</div>
</div>

View file

@ -126,20 +126,9 @@
// View labels
const viewLabels: Record<CalendarViewType, string> = {
day: 'Tag',
'3day': '3 Tage',
'5day': '5 Tage',
week: 'Woche',
'10day': '10 Tage',
'14day': '14 Tage',
'30day': '30 Tage',
'60day': '60 Tage',
'90day': '90 Tage',
'365day': '365 Tage',
month: 'Monat',
year: 'Jahr',
agenda: 'Agenda',
custom: 'Benutzerdefiniert',
};
// Duration options in minutes

View file

@ -26,9 +26,6 @@ export { useCalendarKeyboard, type CancellableOperation } from './useCalendarKey
// Birthday popover management
export { useBirthdayPopover } from './useBirthdayPopover.svelte';
// Swipe/scroll navigation for view switching
export { useSwipeNavigation, type SwipeNavigationOptions } from './useSwipeNavigation.svelte';
// Legacy exports (kept for backwards compatibility, may be removed later)
export { useDragDrop, type DragDropConfig, type DragState } from './useDragDrop.svelte';
export { useResize, type ResizeConfig, type ResizeState } from './useResize.svelte';

View file

@ -1,182 +0,0 @@
/**
* Swipe Navigation Composable
* Enables horizontal swipe/scroll navigation for calendar views
*
* Supports:
* - Trackpad horizontal scroll (Mac/Windows)
* - Touch swipe (Mobile/Tablet)
* - Mouse horizontal scroll wheel
*/
import { browser } from '$app/environment';
export interface SwipeNavigationOptions {
/** Minimum pixels to trigger navigation (default: 80) */
threshold?: number;
/** Debounce time in ms for wheel events (default: 150) */
debounceMs?: number;
/** Disable swipe navigation temporarily */
disabled?: boolean;
}
const DEFAULT_THRESHOLD = 80;
const DEFAULT_DEBOUNCE_MS = 150;
/**
* Creates swipe/scroll navigation for a container element
*
* @param getElement - Function returning the target element
* @param onNext - Callback when swiping left (go to next period)
* @param onPrevious - Callback when swiping right (go to previous period)
* @param options - Configuration options
*
* @example
* ```svelte
* <script>
* import { useSwipeNavigation } from '$lib/composables';
* import { viewStore } from '$lib/stores/view.svelte';
*
* let containerRef: HTMLElement;
*
* useSwipeNavigation(
* () => containerRef,
* () => viewStore.goToNext(),
* () => viewStore.goToPrevious()
* );
* </script>
*
* <div bind:this={containerRef}>...</div>
* ```
*/
export function useSwipeNavigation(
getElement: () => HTMLElement | null,
onNext: () => void,
onPrevious: () => void,
options: SwipeNavigationOptions = {}
) {
if (!browser) return;
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
// Track accumulated wheel delta for trackpad detection
let accumulatedDelta = 0;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Touch tracking
let touchStartX = 0;
let touchStartY = 0;
let isTouching = false;
/**
* Handle wheel events (trackpad horizontal scroll)
*/
function handleWheel(e: WheelEvent) {
// Skip if disabled
if (options.disabled) return;
// Only handle horizontal scrolling (deltaX dominant)
// This distinguishes trackpad gestures from vertical scrolling
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
// Don't interfere with event dragging
const target = e.target as HTMLElement;
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
// Prevent default scroll behavior for horizontal gestures
e.preventDefault();
// Accumulate horizontal delta
accumulatedDelta += e.deltaX;
// Reset accumulator after debounce period
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
accumulatedDelta = 0;
}, debounceMs);
// Check if threshold reached
if (accumulatedDelta > threshold) {
onNext();
accumulatedDelta = 0;
if (debounceTimer) clearTimeout(debounceTimer);
} else if (accumulatedDelta < -threshold) {
onPrevious();
accumulatedDelta = 0;
if (debounceTimer) clearTimeout(debounceTimer);
}
}
/**
* Handle touch start
*/
function handleTouchStart(e: TouchEvent) {
// Skip if disabled
if (options.disabled) return;
// Don't interfere with event dragging
const target = e.target as HTMLElement;
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
isTouching = true;
}
/**
* Handle touch end
*/
function handleTouchEnd(e: TouchEvent) {
// Skip if disabled or wasn't tracking
if (options.disabled || !isTouching) return;
isTouching = false;
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const deltaX = touchEndX - touchStartX;
const deltaY = touchEndY - touchStartY;
// Only trigger if horizontal movement is dominant and exceeds threshold
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > threshold) {
if (deltaX > 0) {
// Swiped right → go to previous
onPrevious();
} else {
// Swiped left → go to next
onNext();
}
}
}
/**
* Handle touch cancel
*/
function handleTouchCancel() {
isTouching = false;
}
// Setup and cleanup with $effect
$effect(() => {
const el = getElement();
if (!el) return;
// Add event listeners
el.addEventListener('wheel', handleWheel, { passive: false });
el.addEventListener('touchstart', handleTouchStart, { passive: true });
el.addEventListener('touchend', handleTouchEnd, { passive: true });
el.addEventListener('touchcancel', handleTouchCancel, { passive: true });
// Cleanup
return () => {
el.removeEventListener('wheel', handleWheel);
el.removeEventListener('touchstart', handleTouchStart);
el.removeEventListener('touchend', handleTouchEnd);
el.removeEventListener('touchcancel', handleTouchCancel);
if (debounceTimer) {
clearTimeout(debounceTimer);
}
};
});
}

View file

@ -21,7 +21,11 @@
"agenda": "Agenda",
"weekdaysOnly": "Nur Wochentage",
"weekNumber": "KW",
"moreEvents": "+{count} mehr"
"moreEvents": "+{count} mehr",
"allDay": "Ganztägig",
"birthday": "Geburtstag",
"weekView": "Wochenansicht",
"monthView": "Monatsansicht"
},
"calendar": {
"today": "Heute",
@ -31,6 +35,7 @@
"myCalendars": "Meine Kalender",
"sharedCalendars": "Geteilte Kalender",
"draftEvent": "(Neuer Termin)",
"untitled": "Ohne Titel",
"hideSidebar": "Sidebar ausblenden",
"showSidebar": "Sidebar einblenden"
},

View file

@ -21,7 +21,11 @@
"agenda": "Agenda",
"weekdaysOnly": "Weekdays only",
"weekNumber": "W",
"moreEvents": "+{count} more"
"moreEvents": "+{count} more",
"allDay": "All day",
"birthday": "Birthday",
"weekView": "Week view",
"monthView": "Month view"
},
"calendar": {
"today": "Today",
@ -31,6 +35,7 @@
"myCalendars": "My Calendars",
"sharedCalendars": "Shared Calendars",
"draftEvent": "(New Event)",
"untitled": "Untitled",
"hideSidebar": "Hide sidebar",
"showSidebar": "Show sidebar"
},

View file

@ -19,7 +19,11 @@
"month": "Mes",
"year": "Año",
"agenda": "Agenda",
"weekdaysOnly": "Solo días laborables"
"weekdaysOnly": "Solo días laborables",
"allDay": "Todo el día",
"birthday": "Cumpleaños",
"weekView": "Vista semanal",
"monthView": "Vista mensual"
},
"calendar": {
"today": "Hoy",
@ -27,7 +31,8 @@
"noEvents": "Sin eventos",
"allDay": "Todo el día",
"myCalendars": "Mis calendarios",
"sharedCalendars": "Calendarios compartidos"
"sharedCalendars": "Calendarios compartidos",
"untitled": "Sin título"
},
"event": {
"title": "Título",

View file

@ -19,7 +19,11 @@
"month": "Mois",
"year": "Année",
"agenda": "Agenda",
"weekdaysOnly": "Jours ouvrables"
"weekdaysOnly": "Jours ouvrables",
"allDay": "Toute la journée",
"birthday": "Anniversaire",
"weekView": "Vue semaine",
"monthView": "Vue mois"
},
"calendar": {
"today": "Aujourd'hui",
@ -27,7 +31,8 @@
"noEvents": "Aucun événement",
"allDay": "Toute la journée",
"myCalendars": "Mes calendriers",
"sharedCalendars": "Calendriers partagés"
"sharedCalendars": "Calendriers partagés",
"untitled": "Sans titre"
},
"event": {
"title": "Titre",

View file

@ -19,7 +19,11 @@
"month": "Mese",
"year": "Anno",
"agenda": "Agenda",
"weekdaysOnly": "Solo giorni feriali"
"weekdaysOnly": "Solo giorni feriali",
"allDay": "Tutto il giorno",
"birthday": "Compleanno",
"weekView": "Vista settimanale",
"monthView": "Vista mensile"
},
"calendar": {
"today": "Oggi",
@ -27,7 +31,8 @@
"noEvents": "Nessun evento",
"allDay": "Tutto il giorno",
"myCalendars": "I miei calendari",
"sharedCalendars": "Calendari condivisi"
"sharedCalendars": "Calendari condivisi",
"untitled": "Senza titolo"
},
"event": {
"title": "Titolo",

View file

@ -1,45 +0,0 @@
/**
* Event Context Menu Store - Manages context menu state for calendar events
*/
import type { CalendarEvent } from '@calendar/shared';
// State
let visible = $state(false);
let x = $state(0);
let y = $state(0);
let targetEvent = $state<CalendarEvent | null>(null);
export const eventContextMenuStore = {
// Getters
get visible() {
return visible;
},
get x() {
return x;
},
get y() {
return y;
},
get targetEvent() {
return targetEvent;
},
/**
* Show the context menu for an event
*/
show(event: CalendarEvent, clientX: number, clientY: number) {
targetEvent = event;
x = clientX;
y = clientY;
visible = true;
},
/**
* Hide the context menu
*/
hide() {
visible = false;
targetEvent = null;
},
};

View file

@ -0,0 +1,386 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { CalendarEvent } from '@calendar/shared';
import type { PaginationMeta } from '$lib/api/events';
const defaultPagination: PaginationMeta = { offset: 0, count: 0 };
// Mock dependencies before importing the store
vi.mock('$lib/api/events', () => ({
getEvents: vi.fn(),
createEvent: vi.fn(),
updateEvent: vi.fn(),
deleteEvent: vi.fn(),
}));
vi.mock('@manacore/shared-ui', () => ({
toastStore: {
error: vi.fn(),
success: vi.fn(),
},
}));
import * as api from '$lib/api/events';
import { eventsStore } from './events.svelte';
const mockGetEvents = vi.mocked(api.getEvents);
const mockCreateEvent = vi.mocked(api.createEvent);
const mockUpdateEvent = vi.mocked(api.updateEvent);
const mockDeleteEvent = vi.mocked(api.deleteEvent);
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'evt-1',
calendarId: 'cal-1',
userId: 'user-1',
title: 'Test Event',
description: null,
location: null,
startTime: '2026-03-15T10:00:00',
endTime: '2026-03-15T11:00:00',
isAllDay: false,
timezone: 'Europe/Berlin',
recurrenceRule: null,
recurrenceEndDate: null,
recurrenceExceptions: null,
parentEventId: null,
color: null,
status: 'confirmed',
externalId: null,
metadata: null,
createdAt: '2026-03-01T00:00:00',
updatedAt: '2026-03-01T00:00:00',
...overrides,
};
}
describe('eventsStore', () => {
beforeEach(() => {
vi.clearAllMocks();
eventsStore.clear();
eventsStore.clearDraftEvent();
});
describe('getEventsForDay', () => {
it('should return events that start on the given day', async () => {
const event = makeEvent({
id: 'evt-1',
startTime: '2026-03-15T10:00:00',
endTime: '2026-03-15T11:00:00',
});
mockGetEvents.mockResolvedValue({
data: [event],
pagination: defaultPagination,
error: null,
});
await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-31'));
const result = eventsStore.getEventsForDay(new Date('2026-03-15'), false);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('evt-1');
});
it('should not return events from a different day', async () => {
const event = makeEvent({
startTime: '2026-03-15T10:00:00',
endTime: '2026-03-15T11:00:00',
});
mockGetEvents.mockResolvedValue({
data: [event],
pagination: defaultPagination,
error: null,
});
await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-31'));
const result = eventsStore.getEventsForDay(new Date('2026-03-16'), false);
expect(result).toHaveLength(0);
});
it('should include all-day events that span the given day', async () => {
const event = makeEvent({
id: 'allday-1',
startTime: '2026-03-14T00:00:00',
endTime: '2026-03-16T23:59:59',
isAllDay: true,
});
mockGetEvents.mockResolvedValue({
data: [event],
pagination: defaultPagination,
error: null,
});
await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-31'));
const result = eventsStore.getEventsForDay(new Date('2026-03-15'), false);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('allday-1');
});
it('should include draft event when includeDraft is true', async () => {
mockGetEvents.mockResolvedValue({ data: [], pagination: defaultPagination, error: null });
await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-31'));
eventsStore.createDraftEvent({
startTime: '2026-03-15T09:00:00',
endTime: '2026-03-15T10:00:00',
});
const result = eventsStore.getEventsForDay(new Date('2026-03-15'), true);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('__draft__');
});
it('should exclude draft event when includeDraft is false', async () => {
mockGetEvents.mockResolvedValue({ data: [], pagination: defaultPagination, error: null });
await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-31'));
eventsStore.createDraftEvent({
startTime: '2026-03-15T09:00:00',
endTime: '2026-03-15T10:00:00',
});
const result = eventsStore.getEventsForDay(new Date('2026-03-15'), false);
expect(result).toHaveLength(0);
});
});
describe('getEventsInRange', () => {
it('should return events that overlap with the given range', async () => {
const events = [
makeEvent({
id: 'evt-1',
startTime: '2026-03-15T10:00:00',
endTime: '2026-03-15T11:00:00',
}),
makeEvent({
id: 'evt-2',
startTime: '2026-03-20T14:00:00',
endTime: '2026-03-20T15:00:00',
}),
];
mockGetEvents.mockResolvedValue({ data: events, pagination: defaultPagination, error: null });
await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-31'));
const result = eventsStore.getEventsInRange(new Date('2026-03-14'), new Date('2026-03-16'));
expect(result).toHaveLength(1);
expect(result[0].id).toBe('evt-1');
});
it('should return events that partially overlap the range', async () => {
const event = makeEvent({
startTime: '2026-03-14T22:00:00',
endTime: '2026-03-15T02:00:00',
});
mockGetEvents.mockResolvedValue({
data: [event],
pagination: defaultPagination,
error: null,
});
await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-31'));
const result = eventsStore.getEventsInRange(
new Date('2026-03-15T00:00:00'),
new Date('2026-03-15T23:59:59')
);
expect(result).toHaveLength(1);
});
it('should return empty array when no events in range', async () => {
const event = makeEvent({
startTime: '2026-03-20T10:00:00',
endTime: '2026-03-20T11:00:00',
});
mockGetEvents.mockResolvedValue({
data: [event],
pagination: defaultPagination,
error: null,
});
await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-31'));
const result = eventsStore.getEventsInRange(new Date('2026-03-14'), new Date('2026-03-16'));
expect(result).toHaveLength(0);
});
});
describe('createDraftEvent / clearDraftEvent', () => {
it('should create a draft event with __draft__ id', () => {
const draft = eventsStore.createDraftEvent({
title: 'Draft Meeting',
startTime: '2026-03-15T10:00:00',
endTime: '2026-03-15T11:00:00',
});
expect(draft.id).toBe('__draft__');
expect(draft.title).toBe('Draft Meeting');
expect(eventsStore.draftEvent).not.toBeNull();
expect(eventsStore.draftEvent?.id).toBe('__draft__');
});
it('should clear the draft event', () => {
eventsStore.createDraftEvent({
title: 'Draft',
startTime: '2026-03-15T10:00:00',
endTime: '2026-03-15T11:00:00',
});
expect(eventsStore.draftEvent).not.toBeNull();
eventsStore.clearDraftEvent();
expect(eventsStore.draftEvent).toBeNull();
});
it('should set default values for missing fields', () => {
const draft = eventsStore.createDraftEvent({});
expect(draft.calendarId).toBe('');
expect(draft.title).toBe('');
expect(draft.isAllDay).toBe(false);
expect(draft.status).toBe('confirmed');
expect(draft.description).toBeNull();
expect(draft.location).toBeNull();
});
});
describe('isDraftEvent', () => {
it('should return true for __draft__ id', () => {
expect(eventsStore.isDraftEvent('__draft__')).toBe(true);
});
it('should return false for regular event ids', () => {
expect(eventsStore.isDraftEvent('evt-1')).toBe(false);
expect(eventsStore.isDraftEvent('')).toBe(false);
});
});
describe('deleteEvent (optimistic update)', () => {
it('should remove event optimistically and restore on error', async () => {
const events = [makeEvent({ id: 'evt-1' }), makeEvent({ id: 'evt-2', title: 'Second' })];
mockGetEvents.mockResolvedValue({ data: events, pagination: defaultPagination, error: null });
await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-31'));
// Verify both events exist
expect(eventsStore.events).toHaveLength(2);
// Simulate API error on delete
mockDeleteEvent.mockResolvedValue({
data: null,
error: { message: 'Server error', code: 'SERVER_ERROR', status: 500 },
});
await eventsStore.deleteEvent('evt-1');
// Event should be restored after error
expect(eventsStore.events).toHaveLength(2);
const ids = eventsStore.events.map((e) => e.id);
expect(ids).toContain('evt-1');
});
it('should permanently remove event on successful delete', async () => {
const events = [makeEvent({ id: 'evt-1' })];
mockGetEvents.mockResolvedValue({ data: events, pagination: defaultPagination, error: null });
await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-31'));
mockDeleteEvent.mockResolvedValue({ data: null, error: null });
await eventsStore.deleteEvent('evt-1');
expect(eventsStore.events).toHaveLength(0);
});
});
describe('createEvent', () => {
it('should add the created event to the store', async () => {
mockGetEvents.mockResolvedValue({ data: [], pagination: defaultPagination, error: null });
await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-31'));
const newEvent = makeEvent({ id: 'new-1', title: 'New Event' });
mockCreateEvent.mockResolvedValue({ data: newEvent, error: null });
await eventsStore.createEvent({
calendarId: 'cal-1',
title: 'New Event',
startTime: '2026-03-15T10:00:00',
endTime: '2026-03-15T11:00:00',
});
expect(eventsStore.events).toHaveLength(1);
expect(eventsStore.events[0].id).toBe('new-1');
});
});
describe('updateEvent', () => {
it('should replace the updated event in the store', async () => {
const event = makeEvent({ id: 'evt-1', title: 'Original' });
mockGetEvents.mockResolvedValue({
data: [event],
pagination: defaultPagination,
error: null,
});
await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-31'));
const updated = makeEvent({ id: 'evt-1', title: 'Updated' });
mockUpdateEvent.mockResolvedValue({ data: updated, error: null });
await eventsStore.updateEvent('evt-1', { title: 'Updated' });
expect(eventsStore.events).toHaveLength(1);
expect(eventsStore.events[0].title).toBe('Updated');
});
});
describe('fetchEvents', () => {
it('should set loading state during fetch', async () => {
let resolvePromise: (value: unknown) => void;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
});
mockGetEvents.mockReturnValue(promise as ReturnType<typeof api.getEvents>);
const fetchPromise = eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-31'));
expect(eventsStore.loading).toBe(true);
resolvePromise!({ data: [], pagination: defaultPagination, error: null });
await fetchPromise;
expect(eventsStore.loading).toBe(false);
});
it('should set error on API failure', async () => {
mockGetEvents.mockResolvedValue({
data: null,
pagination: null,
error: { message: 'Network error', code: 'NETWORK_ERROR' },
});
await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-31'));
expect(eventsStore.error).toBe('Network error');
});
});
describe('getById', () => {
it('should return event by ID', async () => {
const event = makeEvent({ id: 'evt-1', title: 'Found' });
mockGetEvents.mockResolvedValue({
data: [event],
pagination: defaultPagination,
error: null,
});
await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-31'));
expect(eventsStore.getById('evt-1')?.title).toBe('Found');
});
it('should return undefined for unknown ID', async () => {
mockGetEvents.mockResolvedValue({ data: [], pagination: defaultPagination, error: null });
await eventsStore.fetchEvents(new Date('2026-03-01'), new Date('2026-03-31'));
expect(eventsStore.getById('nonexistent')).toBeUndefined();
});
});
});

View file

@ -14,16 +14,16 @@ import {
addDays,
addWeeks,
addMonths,
addYears,
subDays,
subWeeks,
subMonths,
subYears,
} from 'date-fns';
// Import settings store for weekStartsOn
import { settingsStore } from './settings.svelte';
// Supported view types after cleanup
const SUPPORTED_VIEWS: CalendarViewType[] = ['week', 'month', 'agenda'];
// State
let currentDate = $state(new Date());
let viewType = $state<CalendarViewType>('week');
@ -33,73 +33,16 @@ const viewRange = $derived.by(() => {
const weekStartsOn = settingsStore.weekStartsOn;
switch (viewType) {
case 'day':
return {
start: startOfDay(currentDate),
end: endOfDay(currentDate),
};
case '3day':
return {
start: startOfDay(currentDate),
end: endOfDay(addDays(currentDate, 2)),
};
case '5day':
return {
start: startOfDay(currentDate),
end: endOfDay(addDays(currentDate, 4)),
};
case 'week':
return {
start: startOfWeek(currentDate, { weekStartsOn }),
end: endOfWeek(currentDate, { weekStartsOn }),
};
case '10day':
return {
start: startOfDay(currentDate),
end: endOfDay(addDays(currentDate, 9)),
};
case '14day':
return {
start: startOfDay(currentDate),
end: endOfDay(addDays(currentDate, 13)),
};
case '30day':
return {
start: startOfDay(currentDate),
end: endOfDay(addDays(currentDate, 29)),
};
case '60day':
return {
start: startOfDay(currentDate),
end: endOfDay(addDays(currentDate, 59)),
};
case '90day':
return {
start: startOfDay(currentDate),
end: endOfDay(addDays(currentDate, 89)),
};
case '365day':
return {
start: startOfDay(currentDate),
end: endOfDay(addDays(currentDate, 364)),
};
case 'custom': {
const customDays = settingsStore.customDayCount;
return {
start: startOfDay(currentDate),
end: endOfDay(addDays(currentDate, customDays - 1)),
};
}
case 'month':
return {
start: startOfMonth(currentDate),
end: endOfMonth(currentDate),
};
case 'year':
return {
start: new Date(currentDate.getFullYear(), 0, 1),
end: new Date(currentDate.getFullYear(), 11, 31),
};
case 'agenda':
// Agenda shows 30 days from current date
return {
@ -138,29 +81,12 @@ export const viewStore = {
// Load view type from settings or localStorage (for backwards compatibility)
const savedView = localStorage.getItem('calendar-view-type');
if (
savedView &&
[
'day',
'3day',
'5day',
'week',
'10day',
'14day',
'30day',
'60day',
'90day',
'365day',
'month',
'year',
'agenda',
'custom',
].includes(savedView)
) {
if (savedView && SUPPORTED_VIEWS.includes(savedView as CalendarViewType)) {
viewType = savedView as CalendarViewType;
} else {
// Use default view from settings
viewType = settingsStore.defaultView;
// Use default view from settings, fallback to 'week' if unsupported
const defaultView = settingsStore.defaultView;
viewType = SUPPORTED_VIEWS.includes(defaultView) ? defaultView : 'week';
}
},
@ -175,6 +101,10 @@ export const viewStore = {
* Set the view type
*/
setViewType(type: CalendarViewType) {
// Only allow supported view types
if (!SUPPORTED_VIEWS.includes(type)) {
type = 'week';
}
viewType = type;
if (browser) {
localStorage.setItem('calendar-view-type', type);
@ -193,47 +123,14 @@ export const viewStore = {
*/
goToPrevious() {
switch (viewType) {
case 'day':
currentDate = subDays(currentDate, 1);
break;
case '3day':
currentDate = subDays(currentDate, 3);
break;
case '5day':
currentDate = subDays(currentDate, 5);
break;
case 'week':
currentDate = subWeeks(currentDate, 1);
break;
case '10day':
currentDate = subDays(currentDate, 10);
break;
case '14day':
currentDate = subDays(currentDate, 14);
break;
case '30day':
currentDate = subDays(currentDate, 30);
break;
case '60day':
currentDate = subDays(currentDate, 60);
break;
case '90day':
currentDate = subDays(currentDate, 90);
break;
case '365day':
currentDate = subDays(currentDate, 365);
break;
case 'custom':
currentDate = subDays(currentDate, settingsStore.customDayCount);
break;
case 'month':
currentDate = subMonths(currentDate, 1);
break;
case 'year':
currentDate = subYears(currentDate, 1);
break;
case 'agenda':
currentDate = subDays(currentDate, 7);
currentDate = subWeeks(currentDate, 1);
break;
}
},
@ -243,47 +140,14 @@ export const viewStore = {
*/
goToNext() {
switch (viewType) {
case 'day':
currentDate = addDays(currentDate, 1);
break;
case '3day':
currentDate = addDays(currentDate, 3);
break;
case '5day':
currentDate = addDays(currentDate, 5);
break;
case 'week':
currentDate = addWeeks(currentDate, 1);
break;
case '10day':
currentDate = addDays(currentDate, 10);
break;
case '14day':
currentDate = addDays(currentDate, 14);
break;
case '30day':
currentDate = addDays(currentDate, 30);
break;
case '60day':
currentDate = addDays(currentDate, 60);
break;
case '90day':
currentDate = addDays(currentDate, 90);
break;
case '365day':
currentDate = addDays(currentDate, 365);
break;
case 'custom':
currentDate = addDays(currentDate, settingsStore.customDayCount);
break;
case 'month':
currentDate = addMonths(currentDate, 1);
break;
case 'year':
currentDate = addYears(currentDate, 1);
break;
case 'agenda':
currentDate = addDays(currentDate, 7);
currentDate = addWeeks(currentDate, 1);
break;
}
},

View file

@ -0,0 +1,163 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { startOfWeek, startOfMonth, endOfMonth, addDays, isSameDay } from 'date-fns';
// Mock $app/environment
vi.mock('$app/environment', () => ({
browser: false,
}));
// Mock the settings store
vi.mock('./settings.svelte', () => ({
settingsStore: {
weekStartsOn: 1 as 0 | 1,
customDayCount: 30,
defaultView: 'week',
initialize: vi.fn(),
},
}));
// Mock @manacore/shared-stores
vi.mock('@manacore/shared-stores', () => ({
createAppSettingsStore: vi.fn(() => ({
settings: { weekStartsOn: 1, customDayCount: 30, defaultView: 'week' },
initialize: vi.fn(),
set: vi.fn(),
update: vi.fn(),
reset: vi.fn(),
getDefaults: vi.fn(),
toggleImmersiveMode: vi.fn(),
})),
}));
// Mock user-settings store
vi.mock('./user-settings.svelte', () => ({
userSettings: {
loaded: false,
currentDeviceAppSettings: {},
updateDeviceAppSettings: vi.fn(),
},
}));
import { viewStore } from './view.svelte';
describe('viewStore', () => {
beforeEach(() => {
viewStore.setDate(new Date('2026-03-15T12:00:00'));
viewStore.setViewType('week');
});
describe('setDate / currentDate', () => {
it('should update the current date', () => {
const newDate = new Date('2026-06-01T00:00:00');
viewStore.setDate(newDate);
expect(isSameDay(viewStore.currentDate, newDate)).toBe(true);
});
});
describe('setViewType / viewType', () => {
it('should update the view type', () => {
viewStore.setViewType('month');
expect(viewStore.viewType).toBe('month');
});
it('should accept all valid view types', () => {
const types = ['week', 'month', 'agenda'] as const;
for (const type of types) {
viewStore.setViewType(type);
expect(viewStore.viewType).toBe(type);
}
});
});
describe('viewRange', () => {
it('should return correct range for week view', () => {
viewStore.setDate(new Date('2026-03-15T12:00:00')); // Sunday
viewStore.setViewType('week');
const range = viewStore.viewRange;
const expected = startOfWeek(new Date('2026-03-15'), { weekStartsOn: 1 });
expect(isSameDay(range.start, expected)).toBe(true);
});
it('should return correct range for month view', () => {
viewStore.setDate(new Date('2026-03-15T12:00:00'));
viewStore.setViewType('month');
const range = viewStore.viewRange;
expect(isSameDay(range.start, startOfMonth(new Date('2026-03-15')))).toBe(true);
expect(isSameDay(range.end, endOfMonth(new Date('2026-03-15')))).toBe(true);
});
it('should return correct range for agenda view (30 days)', () => {
viewStore.setDate(new Date('2026-03-15T12:00:00'));
viewStore.setViewType('agenda');
const range = viewStore.viewRange;
expect(isSameDay(range.start, new Date('2026-03-15'))).toBe(true);
expect(isSameDay(range.end, addDays(new Date('2026-03-15'), 30))).toBe(true);
});
});
describe('goToNext / goToPrevious', () => {
it('should navigate forward by 1 week in week view', () => {
viewStore.setDate(new Date('2026-03-15T12:00:00'));
viewStore.setViewType('week');
viewStore.goToNext();
expect(isSameDay(viewStore.currentDate, new Date('2026-03-22'))).toBe(true);
});
it('should navigate backward by 1 week in week view', () => {
viewStore.setDate(new Date('2026-03-15T12:00:00'));
viewStore.setViewType('week');
viewStore.goToPrevious();
expect(isSameDay(viewStore.currentDate, new Date('2026-03-08'))).toBe(true);
});
it('should navigate forward by 1 month in month view', () => {
viewStore.setDate(new Date('2026-03-15T12:00:00'));
viewStore.setViewType('month');
viewStore.goToNext();
expect(viewStore.currentDate.getMonth()).toBe(3); // April
expect(viewStore.currentDate.getDate()).toBe(15);
});
it('should navigate backward by 1 month in month view', () => {
viewStore.setDate(new Date('2026-03-15T12:00:00'));
viewStore.setViewType('month');
viewStore.goToPrevious();
expect(viewStore.currentDate.getMonth()).toBe(1); // February
expect(viewStore.currentDate.getDate()).toBe(15);
});
it('should navigate forward by 7 days in agenda view', () => {
viewStore.setDate(new Date('2026-03-15T12:00:00'));
viewStore.setViewType('agenda');
viewStore.goToNext();
expect(isSameDay(viewStore.currentDate, new Date('2026-03-22'))).toBe(true);
});
it('should navigate backward by 7 days in agenda view', () => {
viewStore.setDate(new Date('2026-03-15T12:00:00'));
viewStore.setViewType('agenda');
viewStore.goToPrevious();
expect(isSameDay(viewStore.currentDate, new Date('2026-03-08'))).toBe(true);
});
});
describe('goToToday', () => {
it('should set date to today', () => {
viewStore.setDate(new Date('2020-01-01'));
viewStore.goToToday();
const today = new Date();
expect(isSameDay(viewStore.currentDate, today)).toBe(true);
});
});
});

View file

@ -4,17 +4,7 @@
*/
import type { CalendarViewType } from '@calendar/shared';
import {
addDays,
addWeeks,
addMonths,
addYears,
subDays,
subWeeks,
subMonths,
subYears,
} from 'date-fns';
import { settingsStore } from '$lib/stores/settings.svelte';
import { addDays, addWeeks, addMonths, subDays, subWeeks, subMonths } from 'date-fns';
/**
* Calculate a date offset based on the current view type
@ -33,47 +23,12 @@ import { settingsStore } from '$lib/stores/settings.svelte';
*/
export function getOffsetDate(date: Date, viewType: CalendarViewType, offset: number): Date {
switch (viewType) {
case 'day':
return offset > 0 ? addDays(date, offset) : subDays(date, Math.abs(offset));
case '3day':
return offset > 0 ? addDays(date, offset * 3) : subDays(date, Math.abs(offset) * 3);
case '5day':
return offset > 0 ? addDays(date, offset * 5) : subDays(date, Math.abs(offset) * 5);
case 'week':
return offset > 0 ? addWeeks(date, offset) : subWeeks(date, Math.abs(offset));
case '10day':
return offset > 0 ? addDays(date, offset * 10) : subDays(date, Math.abs(offset) * 10);
case '14day':
return offset > 0 ? addDays(date, offset * 14) : subDays(date, Math.abs(offset) * 14);
case '30day':
return offset > 0 ? addDays(date, offset * 30) : subDays(date, Math.abs(offset) * 30);
case '60day':
return offset > 0 ? addDays(date, offset * 60) : subDays(date, Math.abs(offset) * 60);
case '90day':
return offset > 0 ? addDays(date, offset * 90) : subDays(date, Math.abs(offset) * 90);
case '365day':
return offset > 0 ? addDays(date, offset * 365) : subDays(date, Math.abs(offset) * 365);
case 'custom': {
const days = settingsStore.customDayCount;
return offset > 0 ? addDays(date, offset * days) : subDays(date, Math.abs(offset) * days);
}
case 'month':
return offset > 0 ? addMonths(date, offset) : subMonths(date, Math.abs(offset));
case 'year':
return offset > 0 ? addYears(date, offset) : subYears(date, Math.abs(offset));
case 'agenda':
// Agenda moves by 7 days
return offset > 0 ? addDays(date, offset * 7) : subDays(date, Math.abs(offset) * 7);

View file

@ -1,261 +0,0 @@
/**
* Event Parser for Calendar App
*
* Extends the base parser with event-specific patterns:
* - Calendar: @CalendarName
* - Duration: für 2 Stunden, 30 min
* - Location: in Berlin, bei Firma XY
*/
import {
parseBaseInput,
extractAtReference,
combineDateAndTime,
formatDatePreview,
formatTimePreview,
} from '@manacore/shared-utils';
export interface ParsedEvent {
title: string;
startTime?: Date;
endTime?: Date;
calendarName?: string;
location?: string;
tagNames: string[];
isAllDay: boolean;
}
interface Calendar {
id: string;
name: string;
}
interface Tag {
id: string;
name: string;
}
export interface ParsedEventWithIds {
title: string;
startTime?: string;
endTime?: string;
calendarId?: string;
tagIds: string[];
location?: string;
isAllDay: boolean;
}
// Duration patterns (event-specific)
const DURATION_PATTERNS: { pattern: RegExp; getMinutes: (match: RegExpMatchArray) => number }[] = [
// "für X Stunden" or "X Stunden"
{
pattern: /(?:für\s+)?(\d+(?:[.,]\d+)?)\s*(?:stunde?n?|h)\b/i,
getMinutes: (match) => Math.round(parseFloat(match[1].replace(',', '.')) * 60),
},
// "für X Minuten" or "X min"
{
pattern: /(?:für\s+)?(\d+)\s*(?:minuten?|min)\b/i,
getMinutes: (match) => parseInt(match[1], 10),
},
// "1,5h" or "1.5h"
{
pattern: /(\d+[.,]\d+)\s*h\b/i,
getMinutes: (match) => Math.round(parseFloat(match[1].replace(',', '.')) * 60),
},
];
// Location patterns (event-specific)
const LOCATION_PATTERNS: RegExp[] = [
/\bin\s+([^@#!]+?)(?=\s+(?:@|#|!|\d{1,2}[:.]\d{2}|um\s+\d|\d{1,2}\s*uhr)|$)/i,
/\bbei\s+([^@#!]+?)(?=\s+(?:@|#|!|\d{1,2}[:.]\d{2}|um\s+\d|\d{1,2}\s*uhr)|$)/i,
];
/**
* Extract duration from text
*/
function extractDuration(text: string): { minutes?: number; remaining: string } {
for (const { pattern, getMinutes } of DURATION_PATTERNS) {
const match = text.match(pattern);
if (match) {
return {
minutes: getMinutes(match),
remaining: text.replace(pattern, '').trim(),
};
}
}
return { minutes: undefined, remaining: text };
}
/**
* Extract location from text
*/
function extractLocation(text: string): { location?: string; remaining: string } {
for (const pattern of LOCATION_PATTERNS) {
const match = text.match(pattern);
if (match) {
return {
location: match[1].trim(),
remaining: text.replace(pattern, '').trim(),
};
}
}
return { location: undefined, remaining: text };
}
/**
* Parse natural language event input
*
* Examples:
* - "Meeting morgen 14 Uhr für 1 Stunde @Arbeit in Büro #wichtig"
* - "Arzttermin Montag 10:30 30 min bei Dr. Müller"
* - "Geburtstag 15.12. ganztägig #privat"
*/
export function parseEventInput(input: string): ParsedEvent {
let text = input.trim();
// Check for all-day indicator first
const allDayPattern = /\bganztägig\b|\ball[- ]?day\b/i;
const isAllDay = allDayPattern.test(text);
text = text.replace(allDayPattern, '').trim();
// Extract calendar (@CalendarName) - event-specific
const calendarResult = extractAtReference(text);
text = calendarResult.remaining;
const calendarName = calendarResult.value;
// Extract duration first (before base parser)
const durationResult = extractDuration(text);
text = durationResult.remaining;
const durationMinutes = durationResult.minutes;
// Extract location (before base parser to avoid conflicts)
const locationResult = extractLocation(text);
text = locationResult.remaining;
const location = locationResult.location;
// Use base parser for common patterns (date, time, tags)
const base = parseBaseInput(text);
// Combine date and time for start
const startTime = combineDateAndTime(base.date, base.time);
// Calculate end time based on duration (default 1 hour)
let endTime: Date | undefined;
if (startTime && !isAllDay) {
const duration = durationMinutes || 60; // Default 1 hour
endTime = new Date(startTime.getTime() + duration * 60 * 1000);
} else if (startTime && isAllDay) {
// All-day events: end time is end of day
endTime = new Date(startTime);
endTime.setHours(23, 59, 59, 999);
}
return {
title: base.title,
startTime,
endTime,
calendarName,
location,
tagNames: base.tagNames,
isAllDay,
};
}
/**
* Resolve calendar and tag names to IDs
*/
export function resolveEventIds(
parsed: ParsedEvent,
calendars: Calendar[],
tags: Tag[]
): ParsedEventWithIds {
let calendarId: string | undefined;
const tagIds: string[] = [];
// Find calendar by name (case-insensitive)
if (parsed.calendarName) {
const calendar = calendars.find(
(c) => c.name.toLowerCase() === parsed.calendarName!.toLowerCase()
);
if (calendar) {
calendarId = calendar.id;
}
}
// Use default calendar if none specified
if (!calendarId && calendars.length > 0) {
const defaultCalendar = calendars.find((c: any) => c.isDefault) || calendars[0];
calendarId = defaultCalendar.id;
}
// Find tags by name (case-insensitive)
for (const tagName of parsed.tagNames) {
const tag = tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase());
if (tag) {
tagIds.push(tag.id);
}
}
return {
title: parsed.title,
startTime: parsed.startTime?.toISOString(),
endTime: parsed.endTime?.toISOString(),
calendarId,
tagIds,
location: parsed.location,
isAllDay: parsed.isAllDay,
};
}
/**
* Format parsed event for preview display
*/
export function formatParsedEventPreview(parsed: ParsedEvent): string {
const parts: string[] = [];
if (parsed.startTime) {
let dateStr = `📅 ${formatDatePreview(parsed.startTime)}`;
if (!parsed.isAllDay && parsed.startTime.getHours() !== 0) {
dateStr += ` ${formatTimePreview({
hours: parsed.startTime.getHours(),
minutes: parsed.startTime.getMinutes(),
})}`;
// Add duration if end time differs
if (parsed.endTime) {
const durationMs = parsed.endTime.getTime() - parsed.startTime.getTime();
const durationMins = Math.round(durationMs / 60000);
if (durationMins > 0 && durationMins !== 60) {
if (durationMins >= 60) {
const hours = Math.floor(durationMins / 60);
const mins = durationMins % 60;
dateStr += mins > 0 ? ` (${hours}h ${mins}min)` : ` (${hours}h)`;
} else {
dateStr += ` (${durationMins}min)`;
}
}
}
}
if (parsed.isAllDay) {
dateStr += ' (Ganztägig)';
}
parts.push(dateStr);
}
if (parsed.location) {
parts.push(`📍 ${parsed.location}`);
}
if (parsed.calendarName) {
parts.push(`📆 ${parsed.calendarName}`);
}
if (parsed.tagNames.length > 0) {
parts.push(`🏷️ ${parsed.tagNames.join(', ')}`);
}
return parts.join(' · ');
}

View file

@ -18,7 +18,6 @@
PillNavItem,
PillDropdownItem,
QuickInputItem,
CreatePreview,
PillTabGroupConfig,
PillTagSelectorConfig,
PillNavElement,
@ -48,22 +47,15 @@
import { searchStore } from '$lib/stores/search.svelte';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
import {
parseEventInput,
resolveEventIds,
formatParsedEventPreview,
} from '$lib/utils/event-parser';
import CalendarToolbar from '$lib/components/calendar/CalendarToolbar.svelte';
import CalendarToolbarContent from '$lib/components/calendar/CalendarToolbarContent.svelte';
import DateStrip from '$lib/components/calendar/DateStrip.svelte';
import DateStripFab from '$lib/components/calendar/DateStripFab.svelte';
import ViewsBar from '$lib/components/calendar/ViewsBar.svelte';
import EventContextMenu from '$lib/components/event/EventContextMenu.svelte';
import SettingsModal from '$lib/components/settings/SettingsModal.svelte';
import VoiceRecordButton from '$lib/components/voice/VoiceRecordButton.svelte';
import VoiceRecordingModal from '$lib/components/voice/VoiceRecordingModal.svelte';
import { voiceRecordingStore } from '$lib/stores/voice-recording.svelte';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
import { calendarOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
@ -108,55 +100,6 @@
}
}
// QuickInputBar Quick-Create handlers
function handleParseCreate(query: string): CreatePreview | null {
if (!query.trim()) return null;
const parsed = parseEventInput(query);
if (!parsed.title) return null;
return {
title: `"${parsed.title}" erstellen`,
subtitle: formatParsedEventPreview(parsed),
};
}
async function handleCreate(query: string): Promise<void> {
const parsed = parseEventInput(query);
if (!parsed.title) return;
// Resolve calendar and tag names to IDs
const calendars = calendarsStore.calendars.map((c) => ({ id: c.id, name: c.name }));
const tags = eventTagsStore.tags.map((t) => ({ id: t.id, name: t.name }));
const resolved = resolveEventIds(parsed, calendars, tags);
// Ensure we have start and end times
if (!resolved.startTime) {
// Default to now + 1 hour
const now = new Date();
resolved.startTime = now.toISOString();
const end = new Date(now.getTime() + 60 * 60 * 1000);
resolved.endTime = end.toISOString();
}
// Create event - calendarId is now optional, backend will use/create default if not provided
await eventsStore.createEvent({
// Only include calendarId if resolved (from command or default calendar)
...(resolved.calendarId ? { calendarId: resolved.calendarId } : {}),
title: resolved.title,
startTime: resolved.startTime,
endTime: resolved.endTime || resolved.startTime,
isAllDay: resolved.isAllDay,
location: resolved.location,
tagIds: resolved.tagIds,
});
// Refresh calendars if none existed (in case default was created)
if (calendarsStore.calendars.length === 0) {
await calendarsStore.fetchCalendars();
}
}
let isToolbarCollapsed = $state(true); // Default to collapsed - FAB next to InputBar
// Mobile detection for responsive layout
@ -424,11 +367,6 @@
goto('/login');
}
// Context menu edit handler - navigate to event
function handleContextMenuEdit(event: { id: string }) {
goto(`/?event=${event.id}`);
}
// Reactive effect: load birthdays when setting is enabled
$effect(() => {
if (browser && settingsStore.showBirthdays && authStore.isAuthenticated) {
@ -440,22 +378,12 @@
function handleVoiceResult(transcription: string) {
if (!browser) return;
// Parse the transcribed text to extract event data
const parsed = parseEventInput(transcription);
// Dispatch custom event for +page.svelte to handle
// The event data includes parsed info plus original transcription as description
window.dispatchEvent(
new CustomEvent('voice-event-create', {
detail: {
title: parsed.title || transcription,
startTime: parsed.startTime,
endTime: parsed.endTime,
location: parsed.location,
isAllDay: parsed.isAllDay,
tagNames: parsed.tagNames,
calendarName: parsed.calendarName,
description: transcription, // Original transcription as description
title: transcription,
description: transcription,
},
})
);
@ -584,8 +512,6 @@
placeholder="Neuer Termin oder suchen..."
emptyText="Keine Termine gefunden"
searchingText="Suche..."
onCreate={handleCreate}
onParseCreate={handleParseCreate}
createText="Erstellen"
appIcon="calendar"
bottomOffset={isMobile
@ -627,6 +553,7 @@
class="main-content bg-background"
class:has-toolbar={showCalendarToolbar}
class:immersive={settingsStore.immersiveModeEnabled}
aria-label="Kalender"
>
<div
class="content-wrapper"
@ -639,9 +566,6 @@
</div>
</SplitPaneContainer>
<!-- Global Event Context Menu - rendered at top level for proper z-index -->
<EventContextMenu onEdit={handleContextMenuEdit} />
<!-- InputBar Help Modal -->
<InputBarHelpModal open={helpModalOpen} onClose={handleCloseHelpModal} mode={helpModalMode} />

View file

@ -127,20 +127,9 @@
// View labels
const viewLabels: Record<CalendarViewType, string> = {
day: 'Tag',
'3day': '3 Tage',
'5day': '5 Tage',
week: 'Woche',
'10day': '10 Tage',
'14day': '14 Tage',
'30day': '30 Tage',
'60day': '60 Tage',
'90day': '90 Tage',
'365day': '365 Tage',
month: 'Monat',
year: 'Jahr',
agenda: 'Agenda',
custom: 'Benutzerdefiniert',
};
// Duration options in minutes

View file

@ -1,328 +1,323 @@
<script lang="ts">
import UnifiedBar from '$lib/components/calendar/UnifiedBar.svelte';
import UnifiedBarControls from '$lib/components/calendar/UnifiedBarControls.svelte';
import { unifiedBarStore } from '$lib/stores/unified-bar.svelte';
import { onMount } from 'svelte';
import UnifiedBar from '$lib/components/calendar/UnifiedBar.svelte';
import UnifiedBarControls from '$lib/components/calendar/UnifiedBarControls.svelte';
import { unifiedBarStore } from '$lib/stores/unified-bar.svelte';
import { onMount } from 'svelte';
// Demo handlers
function handleSearch(query: string) {
console.log('Search:', query);
}
// Demo handlers
function handleSearch(query: string) {
console.log('Search:', query);
}
function handleSelect(result: any) {
console.log('Select:', result);
}
function handleSelect(result: any) {
console.log('Select:', result);
}
function handleSearchChange(query: string) {
console.log('Search change:', query);
}
function handleSearchChange(query: string) {
console.log('Search change:', query);
}
function handleCreate(data: any) {
console.log('Create:', data);
}
function handleCreate(data: any) {
console.log('Create:', data);
}
function handleParseCreate(data: any) {
console.log('Parse create:', data);
}
function handleDateSelect(date: Date) {
console.log('Date select:', date);
}
function handleDateSelect(date: Date) {
console.log('Date select:', date);
}
function handleOverlayToggle(event: CustomEvent) {
console.log('Overlay toggle:', event.detail);
}
function handleOverlayToggle(event: CustomEvent) {
console.log('Overlay toggle:', event.detail);
}
function handleOverlayAction(event: CustomEvent) {
console.log('Overlay action:', event.detail);
}
function handleOverlayAction(event: CustomEvent) {
console.log('Overlay action:', event.detail);
}
function handleModeChange(mode: string) {
console.log('Mode change:', mode);
}
function handleModeChange(mode: string) {
console.log('Mode change:', mode);
}
function handleLayerChange(layer: string) {
console.log('Layer change:', layer);
}
function handleLayerChange(layer: string) {
console.log('Layer change:', layer);
}
function handleQuickAction(event: CustomEvent) {
console.log('Quick action:', event.detail);
}
function handleQuickAction(event: CustomEvent) {
console.log('Quick action:', event.detail);
}
function handleToolbarCollapsedChange(collapsed: boolean) {
console.log('Toolbar collapsed:', collapsed);
}
function handleToolbarCollapsedChange(collapsed: boolean) {
console.log('Toolbar collapsed:', collapsed);
}
// Initialize store on mount
onMount(() => {
unifiedBarStore.enableCloudSync();
});
// Initialize store on mount
onMount(() => {
unifiedBarStore.enableCloudSync();
});
</script>
<svelte:head>
<title>UnifiedBar Demo - Calendar</title>
<title>UnifiedBar Demo - Calendar</title>
</svelte:head>
<main class="demo-container">
<header class="demo-header">
<h1>UnifiedBar Demo</h1>
<p>Demonstration der neuen unified bottom bar Architektur</p>
</header>
<header class="demo-header">
<h1>UnifiedBar Demo</h1>
<p>Demonstration der neuen unified bottom bar Architektur</p>
</header>
<!-- Controls for testing -->
<section class="controls-section">
<h2>UnifiedBar Controls</h2>
<UnifiedBarControls onModeChange={handleModeChange} onLayerChange={handleLayerChange} />
</section>
<!-- Controls for testing -->
<section class="controls-section">
<h2>UnifiedBar Controls</h2>
<UnifiedBarControls onModeChange={handleModeChange} onLayerChange={handleLayerChange} />
</section>
<!-- Main content area -->
<section class="content-section">
<div class="content-placeholder">
<h2>Kalender Inhalt</h2>
<p>Dies ist der Hauptinhaltbereich, in dem die Kalender-Ansichten angezeigt werden.</p>
<!-- Main content area -->
<section class="content-section">
<div class="content-placeholder">
<h2>Kalender Inhalt</h2>
<p>Dies ist der Hauptinhaltbereich, in dem die Kalender-Ansichten angezeigt werden.</p>
<div class="info-cards">
<div class="info-card">
<h3>Aktueller Modus</h3>
<p><strong>{unifiedBarStore.mode}</strong></p>
</div>
<div class="info-cards">
<div class="info-card">
<h3>Aktueller Modus</h3>
<p><strong>{unifiedBarStore.mode}</strong></p>
</div>
<div class="info-card">
<h3>Aktiver Layer</h3>
<p><strong>{unifiedBarStore.activeLayer}</strong></p>
</div>
<div class="info-card">
<h3>Aktiver Layer</h3>
<p><strong>{unifiedBarStore.activeLayer}</strong></p>
</div>
<div class="info-card">
<h3>Sichtbare Layers</h3>
<ul>
{#if unifiedBarStore.showQuickInput}<li>✓ QuickInput</li>{/if}
{#if unifiedBarStore.showDateStrip}<li>✓ DateStrip</li>{/if}
{#if unifiedBarStore.showTagStrip}<li>✓ TagStrip</li>{/if}
{#if unifiedBarStore.showCalendarToolbar}<li>✓ CalendarToolbar</li>{/if}
</ul>
</div>
<div class="info-card">
<h3>Sichtbare Layers</h3>
<ul>
{#if unifiedBarStore.showQuickInput}<li>✓ QuickInput</li>{/if}
{#if unifiedBarStore.showDateStrip}<li>✓ DateStrip</li>{/if}
{#if unifiedBarStore.showTagStrip}<li>✓ TagStrip</li>{/if}
{#if unifiedBarStore.showCalendarToolbar}<li>✓ CalendarToolbar</li>{/if}
</ul>
</div>
<div class="info-card">
<h3>Overlay Status</h3>
<p><strong>{unifiedBarStore.isOverlayOpen ? 'Offen' : 'Geschlossen'}</strong></p>
</div>
</div>
<div class="info-card">
<h3>Overlay Status</h3>
<p><strong>{unifiedBarStore.isOverlayOpen ? 'Offen' : 'Geschlossen'}</strong></p>
</div>
</div>
<div class="demo-actions">
<button onmousedown={() => unifiedBarStore.setMode('collapsed')}> Zusammengklappt </button>
<button onmousedown={() => unifiedBarStore.setMode('expanded')}> Erweitert </button>
<button onmousedown={() => unifiedBarStore.toggleOverlay()}> Overlay Toggle </button>
<button onmousedown={() => unifiedBarStore.expandToLayer('date')}> zum Datum-Layer </button>
<button onmousedown={() => unifiedBarStore.collapseAll()}> Alle einklappen </button>
</div>
</div>
</section>
<div class="demo-actions">
<button onmousedown={() => unifiedBarStore.setMode('collapsed')}> Zusammengklappt </button>
<button onmousedown={() => unifiedBarStore.setMode('expanded')}> Erweitert </button>
<button onmousedown={() => unifiedBarStore.toggleOverlay()}> Overlay Toggle </button>
<button onmousedown={() => unifiedBarStore.expandToLayer('date')}> zum Datum-Layer </button>
<button onmousedown={() => unifiedBarStore.collapseAll()}> Alle einklappen </button>
</div>
</div>
</section>
<!-- UnifiedBar at the bottom -->
<UnifiedBar
onSearch={handleSearch}
onSelect={handleSelect}
onSearchChange={handleSearchChange}
onCreate={handleCreate}
onParseCreate={handleParseCreate}
onDateSelect={handleDateSelect}
onToolbarCollapsedChange={handleToolbarCollapsedChange}
placeholder="Neuer Termin oder suchen..."
emptyText="Keine Termine gefunden"
searchingText="Suche..."
createText="Erstellen"
appIcon="calendar"
isMobile={false}
showCalendarToolbar={true}
/>
<!-- UnifiedBar at the bottom -->
<UnifiedBar
onSearch={handleSearch}
onSelect={handleSelect}
onSearchChange={handleSearchChange}
onCreate={handleCreate}
onDateSelect={handleDateSelect}
onToolbarCollapsedChange={handleToolbarCollapsedChange}
placeholder="Neuer Termin oder suchen..."
emptyText="Keine Termine gefunden"
searchingText="Suche..."
createText="Erstellen"
appIcon="calendar"
isMobile={false}
showCalendarToolbar={true}
/>
<!-- Footer with info -->
<footer class="demo-footer">
<p>UnifiedBar Demo - Calendar App</p>
<p>Scrollen Sie, um zu sehen wie die Bars fixiert bleiben</p>
</footer>
<!-- Footer with info -->
<footer class="demo-footer">
<p>UnifiedBar Demo - Calendar App</p>
<p>Scrollen Sie, um zu sehen wie die Bars fixiert bleiben</p>
</footer>
</main>
<style>
.demo-container {
min-height: 100vh;
padding-bottom: 400px; /* Space for UnifiedBar */
background: var(--color-background);
color: var(--color-foreground);
}
.demo-container {
min-height: 100vh;
padding-bottom: 400px; /* Space for UnifiedBar */
background: var(--color-background);
color: var(--color-foreground);
}
.demo-header {
padding: var(--spacing-xl) var(--spacing-lg);
text-align: center;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.demo-header {
padding: var(--spacing-xl) var(--spacing-lg);
text-align: center;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.demo-header h1 {
margin: 0 0 var(--spacing-sm) 0;
font-size: 2rem;
font-weight: 700;
color: var(--color-primary);
}
.demo-header h1 {
margin: 0 0 var(--spacing-sm) 0;
font-size: 2rem;
font-weight: 700;
color: var(--color-primary);
}
.demo-header p {
margin: 0;
color: var(--color-muted-foreground);
font-size: 1rem;
}
.demo-header p {
margin: 0;
color: var(--color-muted-foreground);
font-size: 1rem;
}
.controls-section {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-surface) 50%, transparent);
}
.controls-section {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-surface) 50%, transparent);
}
.controls-section h2 {
margin: 0 0 var(--spacing-md) 0;
font-size: 1.25rem;
font-weight: 600;
}
.controls-section h2 {
margin: 0 0 var(--spacing-md) 0;
font-size: 1.25rem;
font-weight: 600;
}
.content-section {
padding: var(--spacing-lg);
}
.content-section {
padding: var(--spacing-lg);
}
.content-placeholder {
max-width: 1200px;
margin: 0 auto;
}
.content-placeholder {
max-width: 1200px;
margin: 0 auto;
}
.content-placeholder h2 {
margin: 0 0 var(--spacing-lg) 0;
font-size: 1.5rem;
font-weight: 600;
}
.content-placeholder h2 {
margin: 0 0 var(--spacing-lg) 0;
font-size: 1.5rem;
font-weight: 600;
}
.content-placeholder p {
margin: 0 0 var(--spacing-xl) 0;
color: var(--color-muted-foreground);
line-height: 1.6;
}
.content-placeholder p {
margin: 0 0 var(--spacing-xl) 0;
color: var(--color-muted-foreground);
line-height: 1.6;
}
.info-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.info-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.info-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
transition: all var(--transition-base);
}
.info-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
transition: all var(--transition-base);
}
.info-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.info-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.info-card h3 {
margin: 0 0 var(--spacing-sm) 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-muted-foreground);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.info-card h3 {
margin: 0 0 var(--spacing-sm) 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-muted-foreground);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.info-card p {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-foreground);
}
.info-card p {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-foreground);
}
.info-card ul {
margin: 0;
padding: 0;
list-style: none;
}
.info-card ul {
margin: 0;
padding: 0;
list-style: none;
}
.info-card li {
padding: var(--spacing-xs) 0;
color: var(--color-success);
font-weight: 500;
}
.info-card li {
padding: var(--spacing-xs) 0;
color: var(--color-success);
font-weight: 500;
}
.demo-actions {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-md);
margin-top: var(--spacing-xl);
}
.demo-actions {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-md);
margin-top: var(--spacing-xl);
}
.demo-actions button {
padding: var(--spacing-md) var(--spacing-lg);
background: var(--color-primary);
color: var(--color-primary-foreground);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
font-weight: 500;
transition: all var(--transition-base);
}
.demo-actions button {
padding: var(--spacing-md) var(--spacing-lg);
background: var(--color-primary);
color: var(--color-primary-foreground);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
font-weight: 500;
transition: all var(--transition-base);
}
.demo-actions button:hover {
filter: brightness(0.9);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.demo-actions button:hover {
filter: brightness(0.9);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.demo-footer {
position: fixed;
bottom: 350px;
left: 0;
right: 0;
text-align: center;
padding: var(--spacing-md);
background: color-mix(in srgb, var(--color-surface) 80%, transparent);
border-top: 1px solid var(--color-border);
color: var(--color-muted-foreground);
font-size: 0.875rem;
}
.demo-footer {
position: fixed;
bottom: 350px;
left: 0;
right: 0;
text-align: center;
padding: var(--spacing-md);
background: color-mix(in srgb, var(--color-surface) 80%, transparent);
border-top: 1px solid var(--color-border);
color: var(--color-muted-foreground);
font-size: 0.875rem;
}
.demo-footer p {
margin: 0;
}
.demo-footer p {
margin: 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.demo-container {
padding-bottom: 500px;
}
/* Responsive Design */
@media (max-width: 768px) {
.demo-container {
padding-bottom: 500px;
}
.demo-header {
padding: var(--spacing-lg) var(--spacing-md);
}
.demo-header {
padding: var(--spacing-lg) var(--spacing-md);
}
.demo-header h1 {
font-size: 1.5rem;
}
.demo-header h1 {
font-size: 1.5rem;
}
.controls-section,
.content-section {
padding: var(--spacing-md);
}
.controls-section,
.content-section {
padding: var(--spacing-md);
}
.info-cards {
grid-template-columns: 1fr;
gap: var(--spacing-md);
}
.info-cards {
grid-template-columns: 1fr;
gap: var(--spacing-md);
}
.demo-actions {
flex-direction: column;
}
.demo-actions {
flex-direction: column;
}
.demo-footer {
bottom: 450px;
}
}
.demo-footer {
bottom: 450px;
}
}
</style>

View file

@ -1,3 +1,4 @@
/// <reference types="vitest/config" />
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
@ -28,4 +29,9 @@ export default defineConfig({
optimizeDeps: {
exclude: [...MANACORE_SHARED_PACKAGES, '@calendar/shared'],
},
test: {
environment: 'jsdom',
include: ['src/**/*.test.ts'],
globals: true,
},
});

View file

@ -1,21 +1,7 @@
/**
* Calendar view types
*/
export type CalendarViewType =
| 'day'
| '3day'
| '5day'
| 'week'
| '10day'
| '14day'
| '30day'
| '60day'
| '90day'
| '365day'
| 'month'
| 'year'
| 'agenda'
| 'custom';
export type CalendarViewType = 'week' | 'month' | 'agenda';
/**
* Calendar settings stored in JSONB

783
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff