mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
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:
parent
7bb4b1dd5b
commit
7f5c70c7cd
47 changed files with 2432 additions and 6691 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
278
apps/calendar/apps/web/src/lib/api/events.test.ts
Normal file
278
apps/calendar/apps/web/src/lib/api/events.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
386
apps/calendar/apps/web/src/lib/stores/events.test.ts
Normal file
386
apps/calendar/apps/web/src/lib/stores/events.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
|
|
|
|||
163
apps/calendar/apps/web/src/lib/stores/view.test.ts
Normal file
163
apps/calendar/apps/web/src/lib/stores/view.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(' · ');
|
||||
}
|
||||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
783
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue