Merge pull request #18 from Memo-2023/till-dev

feat: calendar improvements, contacts refactoring & auth token refresh
This commit is contained in:
Nils Weiser 2025-12-15 14:27:32 +01:00 committed by GitHub
commit 9238ff72a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
186 changed files with 20251 additions and 7376 deletions

View file

@ -799,18 +799,20 @@ Docker containers can reach each other by service name (`mana-core-auth`), but b
### Apps Using This Pattern Correctly
- ✅ `chat/apps/web` - Has `hooks.server.ts` with runtime injection
- ✅ `todo/apps/web` - Fixed
- ✅ `calendar/apps/web` - Fixed
- ✅ `clock/apps/web` - Fixed
All web apps with backends now use the runtime injection pattern:
### Apps That Still Need Fixing
- ✅ `chat/apps/web`
- ✅ `picture/apps/web`
- ✅ `zitare/apps/web`
- ✅ `contacts/apps/web`
- ✅ `calendar/apps/web`
- ✅ `clock/apps/web`
- ✅ `todo/apps/web`
- ❌ `contacts/apps/web`
- ❌ `manadeck/apps/web`
- ❌ `manacore/apps/web`
- ❌ `zitare/apps/web`
- ❌ `picture/apps/web`
### Apps That May Need Fixing
- ❓ `manadeck/apps/web` - Check if using dynamic URLs
- ❓ `manacore/apps/web` - Check if using dynamic URLs
### Quick Checklist for New SvelteKit Apps

View file

@ -6,6 +6,7 @@ import { HealthModule } from './health/health.module';
import { CalendarModule } from './calendar/calendar.module';
import { EventModule } from './event/event.module';
import { EventTagModule } from './event-tag/event-tag.module';
import { EventTagGroupModule } from './event-tag-group/event-tag-group.module';
import { ReminderModule } from './reminder/reminder.module';
import { ShareModule } from './share/share.module';
import { NetworkModule } from './network/network.module';
@ -22,6 +23,7 @@ import { NetworkModule } from './network/network.module';
CalendarModule,
EventModule,
EventTagModule,
EventTagGroupModule,
ReminderModule,
ShareModule,
NetworkModule,

View file

@ -0,0 +1,23 @@
import { pgTable, uuid, text, timestamp, varchar, integer, index } from 'drizzle-orm/pg-core';
/**
* Event tag groups table - stores user-defined tag groups (e.g., Persons, Locations)
*/
export const eventTagGroups = pgTable(
'event_tag_groups',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
name: varchar('name', { length: 100 }).notNull(),
color: varchar('color', { length: 7 }).default('#3B82F6'),
sortOrder: integer('sort_order').default(0),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdx: index('event_tag_groups_user_idx').on(table.userId),
})
);
export type EventTagGroup = typeof eventTagGroups.$inferSelect;
export type NewEventTagGroup = typeof eventTagGroups.$inferInsert;

View file

@ -1,5 +1,15 @@
import { pgTable, uuid, text, timestamp, varchar, primaryKey, index } from 'drizzle-orm/pg-core';
import {
pgTable,
uuid,
text,
timestamp,
varchar,
primaryKey,
index,
integer,
} from 'drizzle-orm/pg-core';
import { events } from './events.schema';
import { eventTagGroups } from './event-tag-groups.schema';
/**
* Event tags table - stores user-defined tags with colors
@ -11,11 +21,14 @@ export const eventTags = pgTable(
userId: text('user_id').notNull(),
name: varchar('name', { length: 100 }).notNull(),
color: varchar('color', { length: 7 }).default('#3B82F6'),
groupId: uuid('group_id').references(() => eventTagGroups.id, { onDelete: 'set null' }),
sortOrder: integer('sort_order').default(0),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdx: index('event_tags_user_idx').on(table.userId),
groupIdx: index('event_tags_group_idx').on(table.groupId),
})
);

View file

@ -2,6 +2,7 @@
export * from './calendars.schema';
export * from './events.schema';
export * from './event-tags.schema';
export * from './event-tag-groups.schema';
export * from './calendar-shares.schema';
export * from './reminders.schema';
export * from './external-calendars.schema';

View file

@ -0,0 +1,12 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
export class CreateEventTagGroupDto {
@IsString()
@MaxLength(100)
name!: string;
@IsString()
@IsOptional()
@MaxLength(7)
color?: string;
}

View file

@ -0,0 +1,3 @@
export * from './create-event-tag-group.dto';
export * from './update-event-tag-group.dto';
export * from './reorder-event-tag-groups.dto';

View file

@ -0,0 +1,7 @@
import { IsArray, IsUUID } from 'class-validator';
export class ReorderEventTagGroupsDto {
@IsArray()
@IsUUID('4', { each: true })
groupIds!: string[];
}

View file

@ -0,0 +1,13 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
export class UpdateEventTagGroupDto {
@IsString()
@IsOptional()
@MaxLength(100)
name?: string;
@IsString()
@IsOptional()
@MaxLength(7)
color?: string;
}

View file

@ -0,0 +1,91 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
ParseUUIDPipe,
NotFoundException,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { EventTagGroupService } from './event-tag-group.service';
import { CreateEventTagGroupDto, UpdateEventTagGroupDto, ReorderEventTagGroupsDto } from './dto';
@Controller('event-tag-groups')
@UseGuards(JwtAuthGuard)
export class EventTagGroupController {
constructor(private readonly eventTagGroupService: EventTagGroupService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
const groups = await this.eventTagGroupService.findByUserId(user.userId);
const tagCounts = await this.eventTagGroupService.getTagCountsForUser(user.userId);
// Add tag count to each group
const groupsWithCounts = groups.map((group) => ({
...group,
tagCount: tagCounts.get(group.id) ?? 0,
}));
return {
groups: groupsWithCounts,
ungroupedTagCount: tagCounts.get(null) ?? 0,
};
}
@Put('reorder')
async reorder(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderEventTagGroupsDto) {
const groups = await this.eventTagGroupService.reorder(user.userId, dto.groupIds);
const tagCounts = await this.eventTagGroupService.getTagCountsForUser(user.userId);
const groupsWithCounts = groups.map((group) => ({
...group,
tagCount: tagCounts.get(group.id) ?? 0,
}));
return {
groups: groupsWithCounts,
ungroupedTagCount: tagCounts.get(null) ?? 0,
};
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const group = await this.eventTagGroupService.findById(id, user.userId);
if (!group) {
throw new NotFoundException('Tag group not found');
}
const tagCount = await this.eventTagGroupService.getTagCountByGroup(id);
return { group: { ...group, tagCount } };
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateEventTagGroupDto) {
const group = await this.eventTagGroupService.create({
...dto,
userId: user.userId,
});
return { group: { ...group, tagCount: 0 } };
}
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateEventTagGroupDto
) {
const group = await this.eventTagGroupService.update(id, user.userId, dto);
const tagCount = await this.eventTagGroupService.getTagCountByGroup(id);
return { group: { ...group, tagCount } };
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
await this.eventTagGroupService.delete(id, user.userId);
return { success: true };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { EventTagGroupController } from './event-tag-group.controller';
import { EventTagGroupService } from './event-tag-group.service';
@Module({
controllers: [EventTagGroupController],
providers: [EventTagGroupService],
exports: [EventTagGroupService],
})
export class EventTagGroupModule {}

View file

@ -0,0 +1,125 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, asc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { eventTagGroups, eventTags } from '../db/schema';
import type { EventTagGroup, NewEventTagGroup } from '../db/schema';
const DEFAULT_TAG_GROUPS = [
{ name: 'Personen', color: '#ec4899' }, // pink
{ name: 'Orte', color: '#14b8a6' }, // teal
{ name: 'Allgemein', color: '#3b82f6' }, // blue
] as const;
@Injectable()
export class EventTagGroupService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findByUserId(userId: string): Promise<EventTagGroup[]> {
const groups = await this.db
.select()
.from(eventTagGroups)
.where(eq(eventTagGroups.userId, userId))
.orderBy(asc(eventTagGroups.sortOrder), asc(eventTagGroups.name));
// Create default groups on first access (when user has no groups yet)
if (groups.length === 0) {
return this.createDefaultGroups(userId);
}
return groups;
}
async createDefaultGroups(userId: string): Promise<EventTagGroup[]> {
const groupsToCreate = DEFAULT_TAG_GROUPS.map((group, index) => ({
userId,
name: group.name,
color: group.color,
sortOrder: index,
}));
return this.db.insert(eventTagGroups).values(groupsToCreate).returning();
}
async findById(id: string, userId: string): Promise<EventTagGroup | null> {
const [group] = await this.db
.select()
.from(eventTagGroups)
.where(and(eq(eventTagGroups.id, id), eq(eventTagGroups.userId, userId)));
return group || null;
}
async create(data: NewEventTagGroup): Promise<EventTagGroup> {
// Get highest sortOrder for user
const existing = await this.db
.select()
.from(eventTagGroups)
.where(eq(eventTagGroups.userId, data.userId));
const maxSortOrder = existing.reduce((max, g) => Math.max(max, g.sortOrder ?? 0), -1);
const [group] = await this.db
.insert(eventTagGroups)
.values({ ...data, sortOrder: maxSortOrder + 1 })
.returning();
return group;
}
async update(
id: string,
userId: string,
data: Partial<Omit<NewEventTagGroup, 'userId'>>
): Promise<EventTagGroup> {
const [group] = await this.db
.update(eventTagGroups)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(eventTagGroups.id, id), eq(eventTagGroups.userId, userId)))
.returning();
if (!group) {
throw new NotFoundException('Tag group not found');
}
return group;
}
async delete(id: string, userId: string): Promise<void> {
// First, unassign all tags from this group (set groupId to null)
await this.db.update(eventTags).set({ groupId: null }).where(eq(eventTags.groupId, id));
// Then delete the group
await this.db
.delete(eventTagGroups)
.where(and(eq(eventTagGroups.id, id), eq(eventTagGroups.userId, userId)));
}
async getTagCountByGroup(groupId: string): Promise<number> {
const tags = await this.db.select().from(eventTags).where(eq(eventTags.groupId, groupId));
return tags.length;
}
async getTagCountsForUser(userId: string): Promise<Map<string | null, number>> {
const tags = await this.db.select().from(eventTags).where(eq(eventTags.userId, userId));
const counts = new Map<string | null, number>();
for (const tag of tags) {
const groupId = tag.groupId;
counts.set(groupId, (counts.get(groupId) ?? 0) + 1);
}
return counts;
}
async reorder(userId: string, groupIds: string[]): Promise<EventTagGroup[]> {
// Update sortOrder for each group based on array position
await Promise.all(
groupIds.map((id, index) =>
this.db
.update(eventTagGroups)
.set({ sortOrder: index, updatedAt: new Date() })
.where(and(eq(eventTagGroups.id, id), eq(eventTagGroups.userId, userId)))
)
);
return this.findByUserId(userId);
}
}

View file

@ -0,0 +1,4 @@
export * from './event-tag-group.module';
export * from './event-tag-group.service';
export * from './event-tag-group.controller';
export * from './dto';

View file

@ -1,4 +1,4 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { IsString, IsOptional, MaxLength, IsUUID } from 'class-validator';
export class CreateEventTagDto {
@IsString()
@ -9,4 +9,8 @@ export class CreateEventTagDto {
@IsOptional()
@MaxLength(7)
color?: string;
@IsUUID()
@IsOptional()
groupId?: string;
}

View file

@ -1,4 +1,4 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
import { IsString, IsOptional, MaxLength, IsUUID } from 'class-validator';
export class UpdateEventTagDto {
@IsString()
@ -10,4 +10,8 @@ export class UpdateEventTagDto {
@IsOptional()
@MaxLength(7)
color?: string;
@IsUUID()
@IsOptional()
groupId?: string | null;
}

View file

@ -1,5 +1,5 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, inArray } from 'drizzle-orm';
import { eq, and, inArray, isNull, asc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { eventTags, eventToTags } from '../db/schema';
@ -116,4 +116,31 @@ export class EventTagService {
.from(eventTags)
.where(and(inArray(eventTags.id, ids), eq(eventTags.userId, userId)));
}
async findByGroupId(groupId: string | null, userId: string): Promise<EventTag[]> {
const condition =
groupId === null
? and(isNull(eventTags.groupId), eq(eventTags.userId, userId))
: and(eq(eventTags.groupId, groupId), eq(eventTags.userId, userId));
return this.db
.select()
.from(eventTags)
.where(condition)
.orderBy(asc(eventTags.sortOrder), asc(eventTags.name));
}
async updateTagGroup(tagId: string, userId: string, groupId: string | null): Promise<EventTag> {
const [tag] = await this.db
.update(eventTags)
.set({ groupId, updatedAt: new Date() })
.where(and(eq(eventTags.id, tagId), eq(eventTags.userId, userId)))
.returning();
if (!tag) {
throw new NotFoundException('Tag not found');
}
return tag;
}
}

View file

@ -58,7 +58,9 @@ export function createApiClient(config: ApiClientConfig) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${baseUrl}${apiPrefix}${endpoint}`, {
const url = `${baseUrl}${apiPrefix}${endpoint}`;
const response = await fetch(url, {
method,
headers,
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,

View file

@ -0,0 +1,100 @@
/**
* Cross-App API Client for Contacts Backend - Birthday Data
* Allows Calendar app to fetch contact birthdays for display
*/
import { env } from '$env/dynamic/public';
import { createApiClient } from './base-client';
const CONTACTS_API_BASE = env.PUBLIC_CONTACTS_API_URL || 'http://localhost:3015';
const contactsClient = createApiClient({
baseUrl: CONTACTS_API_BASE,
apiPrefix: '/api/v1',
});
// ============================================
// Types for Birthday Integration
// ============================================
/**
* Lightweight contact data for birthday display
* Only essential fields from Contacts API
*/
export interface ContactBirthdaySummary {
id: string;
displayName: string | null;
firstName: string | null;
lastName: string | null;
birthday: string; // YYYY-MM-DD format
photoUrl: string | null;
}
/**
* Birthday event for calendar display
* Generated from ContactBirthdaySummary with display date
*/
export interface BirthdayEvent {
id: string; // Format: birthday-{contactId}-{date}
contactId: string;
title: string; // "{Name}'s Geburtstag"
displayName: string;
photoUrl: string | null;
birthday: string; // Original birthday date
age: number; // Age on this birthday (0 if birth year unknown)
startTime: string; // ISO date of the birthday occurrence
endTime: string; // Same as startTime (all-day event)
isAllDay: true;
isBirthday: true; // Type discriminator
calendarId: string; // Virtual calendar ID
}
// ============================================
// API Response Types
// ============================================
interface BirthdaysResponse {
contacts: ContactBirthdaySummary[];
}
// ============================================
// API Functions
// ============================================
const fetchContactsApi = contactsClient.fetchApi;
/**
* Fetch all contacts with birthdays from Contacts service
*/
export async function getBirthdays(): Promise<{
data: ContactBirthdaySummary[] | null;
error: Error | null;
}> {
const result = await fetchContactsApi<BirthdaysResponse>('/contacts/birthdays');
return {
data: result.data?.contacts || null,
error: result.error,
};
}
// ============================================
// Helper Functions
// ============================================
/**
* Get display name from contact, with fallback
*/
export function getContactDisplayName(contact: ContactBirthdaySummary): string {
if (contact.displayName) return contact.displayName;
const fullName = [contact.firstName, contact.lastName].filter(Boolean).join(' ');
return fullName || 'Unbekannt';
}
/**
* Birthday calendar constants
*/
export const BIRTHDAY_CALENDAR = {
id: '__birthdays__',
name: 'Geburtstage',
color: '#EC4899', // Pink
} as const;

View file

@ -0,0 +1,84 @@
/**
* Event Tag Groups API Client
*/
import { fetchApi } from './client';
import type { EventTagGroup } from '@calendar/shared';
export interface CreateEventTagGroupInput {
name: string;
color?: string;
}
export interface UpdateEventTagGroupInput {
name?: string;
color?: string;
}
interface GetEventTagGroupsResponse {
groups: EventTagGroup[];
ungroupedTagCount: number;
}
export async function getEventTagGroups() {
const result = await fetchApi<GetEventTagGroupsResponse>('/event-tag-groups');
if (result.error || !result.data) {
return { data: null, ungroupedTagCount: 0, error: result.error };
}
return {
data: result.data.groups,
ungroupedTagCount: result.data.ungroupedTagCount,
error: null,
};
}
export async function getEventTagGroup(id: string) {
const result = await fetchApi<{ group: EventTagGroup }>(`/event-tag-groups/${id}`);
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.group, error: null };
}
export async function createEventTagGroup(data: CreateEventTagGroupInput) {
const result = await fetchApi<{ group: EventTagGroup }>('/event-tag-groups', {
method: 'POST',
body: data,
});
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.group, error: null };
}
export async function updateEventTagGroup(id: string, data: UpdateEventTagGroupInput) {
const result = await fetchApi<{ group: EventTagGroup }>(`/event-tag-groups/${id}`, {
method: 'PUT',
body: data,
});
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.group, error: null };
}
export async function deleteEventTagGroup(id: string) {
return fetchApi<{ success: boolean }>(`/event-tag-groups/${id}`, {
method: 'DELETE',
});
}
export async function reorderEventTagGroups(groupIds: string[]) {
const result = await fetchApi<GetEventTagGroupsResponse>('/event-tag-groups/reorder', {
method: 'PUT',
body: { groupIds },
});
if (result.error || !result.data) {
return { data: null, ungroupedTagCount: 0, error: result.error };
}
return {
data: result.data.groups,
ungroupedTagCount: result.data.ungroupedTagCount,
error: null,
};
}

View file

@ -1,132 +1,70 @@
/**
* Event Tags API Client - Uses central Tags API from mana-core-auth
* Event Tags API Client - Uses Calendar Backend API
*
* This module wraps the central Tags API to provide backward-compatible
* "event tags" interface for the Calendar app. Tags are now unified
* across all Manacore apps (Todo, Calendar, Contacts).
* This module provides the event tags interface for the Calendar app,
* using the calendar backend's /event-tags endpoint which supports
* tag groups (groupId).
*/
import { browser } from '$app/environment';
import {
createTagsClient,
type Tag,
type CreateTagInput,
type UpdateTagInput,
} from '@manacore/shared-tags';
import { authStore } from '$lib/stores/auth.svelte';
import { fetchApi } from './client';
import type { EventTag } from '@calendar/shared';
// Re-export Tag as EventTag for backward compatibility
export type EventTag = Tag;
export type CreateEventTagInput = CreateTagInput;
export type UpdateEventTagInput = UpdateTagInput;
// Re-export EventTag from shared
export type { EventTag };
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return 'http://localhost:3001';
export interface CreateEventTagInput {
name: string;
color?: string;
groupId?: string | null;
}
// Lazy-initialized client
let _tagsClient: ReturnType<typeof createTagsClient> | null = null;
function getTagsClient() {
if (!browser) return null;
if (!_tagsClient) {
_tagsClient = createTagsClient({
authUrl: getAuthUrl(),
getToken: async () => {
const token = await authStore.getAccessToken();
return token || '';
},
});
}
return _tagsClient;
export interface UpdateEventTagInput {
name?: string;
color?: string;
groupId?: string | null;
}
export async function getEventTags() {
const client = getTagsClient();
if (!client) return { data: null, error: null };
try {
const tags = await client.getAll();
return { data: tags, error: null };
} catch (e) {
return {
data: null,
error: { message: e instanceof Error ? e.message : 'Failed to fetch tags' },
};
const result = await fetchApi<{ tags: EventTag[] }>('/event-tags');
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.tags, error: null };
}
export async function getEventTag(id: string) {
const client = getTagsClient();
if (!client) return { data: null, error: null };
try {
const tag = await client.getById(id);
return { data: tag, error: null };
} catch (e) {
return {
data: null,
error: { message: e instanceof Error ? e.message : 'Failed to fetch tag' },
};
const result = await fetchApi<{ tag: EventTag }>(`/event-tags/${id}`);
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.tag, error: null };
}
export async function createEventTag(data: CreateEventTagInput) {
const client = getTagsClient();
if (!client) return { data: null, error: { message: 'Tags client not available' } };
try {
const tag = await client.create(data);
return { data: tag, error: null };
} catch (e) {
return {
data: null,
error: { message: e instanceof Error ? e.message : 'Failed to create tag' },
};
const result = await fetchApi<{ tag: EventTag }>('/event-tags', {
method: 'POST',
body: data,
});
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.tag, error: null };
}
export async function updateEventTag(id: string, data: UpdateEventTagInput) {
const client = getTagsClient();
if (!client) return { data: null, error: { message: 'Tags client not available' } };
try {
const tag = await client.update(id, data);
return { data: tag, error: null };
} catch (e) {
return {
data: null,
error: { message: e instanceof Error ? e.message : 'Failed to update tag' },
};
const result = await fetchApi<{ tag: EventTag }>(`/event-tags/${id}`, {
method: 'PUT',
body: data,
});
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.tag, error: null };
}
export async function deleteEventTag(id: string) {
const client = getTagsClient();
if (!client) return { data: null, error: { message: 'Tags client not available' } };
try {
await client.delete(id);
return { data: { success: true }, error: null };
} catch (e) {
return {
data: null,
error: { message: e instanceof Error ? e.message : 'Failed to delete tag' },
};
}
}
export async function createDefaultEventTags() {
const client = getTagsClient();
if (!client) return { data: null, error: null };
try {
const tags = await client.createDefaults();
return { data: tags, error: null };
} catch (e) {
return {
data: null,
error: { message: e instanceof Error ? e.message : 'Failed to create default tags' },
};
}
const result = await fetchApi<{ success: boolean }>(`/event-tags/${id}`, {
method: 'DELETE',
});
return result;
}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { Calendar, CheckSquare, Filter } from 'lucide-svelte';
import { FilterDropdown, type FilterDropdownOption } from '@manacore/shared-ui';
interface Props {
showEvents: boolean;
@ -19,10 +20,10 @@
onRangeChange,
}: Props = $props();
const rangeOptions = [
{ value: '7' as const, label: '7 Tage' },
{ value: '30' as const, label: '30 Tage' },
{ value: 'all' as const, label: 'Alle' },
const rangeOptions: FilterDropdownOption[] = [
{ value: '7', label: '7 Tage' },
{ value: '30', label: '30 Tage' },
{ value: 'all', label: 'Alle' },
];
</script>
@ -53,15 +54,13 @@
<div class="filter-group">
<div class="range-selector">
<Filter size={14} />
<select
<FilterDropdown
options={rangeOptions}
value={timeRange}
onchange={(e) =>
onRangeChange?.((e.target as HTMLSelectElement).value as '7' | '30' | 'all')}
>
{#each rangeOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
onChange={(v) => onRangeChange?.(v as '7' | '30' | 'all')}
placeholder="Zeitraum"
embedded={true}
/>
</div>
</div>
</div>
@ -122,21 +121,6 @@
color: hsl(var(--color-muted-foreground));
}
.range-selector select {
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
}
.range-selector select:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
@media (max-width: 480px) {
.agenda-filters {
flex-direction: column;

View file

@ -7,8 +7,9 @@
import TodoCheckbox from '$lib/components/todo/TodoCheckbox.svelte';
import PriorityBadge from '$lib/components/todo/PriorityBadge.svelte';
import { Calendar, MapPin, Clock } from 'lucide-svelte';
import { format, parseISO } from 'date-fns';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
import { toDate } from '$lib/utils/eventDateHelpers';
type ItemType = 'event' | 'todo';
@ -29,8 +30,8 @@
if (!event) return '';
if (event.isAllDay) return 'Ganztägig';
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const start = toDate(event.startTime);
const end = toDate(event.endTime);
return `${format(start, 'HH:mm')} - ${format(end, 'HH:mm')}`;
});

View file

@ -0,0 +1,269 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
import type { BirthdayEvent } from '$lib/api/birthdays';
import { settingsStore } from '$lib/stores/settings.svelte';
import { X, User, ExternalLink, Cake } from 'lucide-svelte';
import { format, parseISO } from 'date-fns';
import { de } from 'date-fns/locale';
interface Props {
birthday: BirthdayEvent;
position: { x: number; y: number };
onClose: () => void;
}
let { birthday, position, onClose }: Props = $props();
const CONTACTS_WEB_URL = env.PUBLIC_CONTACTS_WEB_URL || 'http://localhost:5184';
const contactUrl = `${CONTACTS_WEB_URL}/contacts/${birthday.contactId}`;
// Format the original birthday date
let birthdayDateFormatted = $derived(() => {
try {
const date = parseISO(birthday.birthday);
return format(date, 'd. MMMM', { locale: de });
} catch {
return birthday.birthday;
}
});
// Calculate popover position to stay within viewport
let adjustedPosition = $derived(() => {
const popoverWidth = 280;
const popoverHeight = 200;
const padding = 16;
let x = position.x;
let y = position.y;
// Check right boundary
if (x + popoverWidth + padding > window.innerWidth) {
x = window.innerWidth - popoverWidth - padding;
}
// Check bottom boundary
if (y + popoverHeight + padding > window.innerHeight) {
y = position.y - popoverHeight - 8; // Show above
}
// Check left boundary
if (x < padding) {
x = padding;
}
// Check top boundary
if (y < padding) {
y = padding;
}
return { x, y };
});
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- Backdrop -->
<div
class="fixed inset-0 z-50"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="button"
tabindex="-1"
>
<!-- Popover -->
<div
class="birthday-popover"
style="left: {adjustedPosition().x}px; top: {adjustedPosition().y}px;"
role="dialog"
aria-label="Geburtstag Details"
>
<!-- Header -->
<div class="popover-header">
<div class="header-content">
{#if birthday.photoUrl}
<img src={birthday.photoUrl} alt={birthday.displayName} class="contact-avatar" />
{:else}
<div class="contact-avatar-placeholder">
<Cake size={24} />
</div>
{/if}
<div class="header-info">
<h3 class="contact-name">{birthday.displayName}</h3>
{#if settingsStore.showBirthdayAge && birthday.age > 0}
<p class="contact-age">wird {birthday.age} Jahre alt</p>
{/if}
</div>
</div>
<button type="button" class="close-btn" onclick={onClose} aria-label="Schließen">
<X size={18} />
</button>
</div>
<!-- Content -->
<div class="popover-content">
<div class="info-row">
<Cake size={16} class="info-icon" />
<span>Geburtstag: {birthdayDateFormatted()}</span>
</div>
</div>
<!-- Actions -->
<div class="popover-actions">
<a href={contactUrl} target="_blank" rel="noopener noreferrer" class="action-btn primary">
<User size={16} />
<span>Kontakt öffnen</span>
<ExternalLink size={14} class="external-icon" />
</a>
</div>
</div>
</div>
<style>
.birthday-popover {
position: fixed;
width: 280px;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 12px;
box-shadow: 0 8px 24px hsl(var(--color-foreground) / 0.15);
overflow: hidden;
z-index: 51;
}
.popover-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 1rem;
background: linear-gradient(135deg, #ec4899 0%, #f472b6 100%);
color: white;
}
.header-content {
display: flex;
align-items: center;
gap: 0.75rem;
}
.contact-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
border: 2px solid white;
}
.contact-avatar-placeholder {
width: 48px;
height: 48px;
border-radius: 50%;
background: hsl(var(--color-surface) / 0.2);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid white;
color: white;
}
.header-info {
flex: 1;
min-width: 0;
}
.contact-name {
font-size: 1rem;
font-weight: 600;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contact-age {
font-size: 0.875rem;
margin: 0.125rem 0 0;
opacity: 0.9;
}
.close-btn {
background: hsl(var(--color-surface) / 0.2);
border: none;
border-radius: 50%;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: white;
transition: background 0.2s ease;
}
.close-btn:hover {
background: hsl(var(--color-surface) / 0.3);
}
.popover-content {
padding: 1rem;
}
.info-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
.info-row :global(.info-icon) {
color: #ec4899;
flex-shrink: 0;
}
.popover-actions {
padding: 0.75rem 1rem 1rem;
border-top: 1px solid hsl(var(--color-border));
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 1rem;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: all 0.2s ease;
}
.action-btn.primary {
background: #ec4899;
color: white;
}
.action-btn.primary:hover {
background: #db2777;
}
.action-btn :global(.external-icon) {
opacity: 0.7;
margin-left: auto;
}
</style>

View file

@ -0,0 +1,329 @@
<script lang="ts">
import { viewStore } from '$lib/stores/view.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
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';
import type { CalendarEvent } from '@calendar/shared';
interface Props {
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
date?: Date;
onEventClick?: (event: CalendarEvent) => void;
}
let { date, onEventClick }: Props = $props();
// Use provided date or fall back to viewStore
let effectiveDate = $derived(date ?? viewStore.currentDate);
// Group events by date
let groupedEvents = $derived.by(() => {
const currentEvents = eventsStore.events ?? [];
if (!Array.isArray(currentEvents)) return [];
// Filter by visible calendars
const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id));
// Filter events that start from current date onwards
const startDate = startOfDay(effectiveDate);
const groups: Map<string, CalendarEvent[]> = new Map();
for (const event of currentEvents) {
// Skip events from hidden calendars
if (!visibleCalendarIds.has(event.calendarId)) continue;
const start = toDate(event.startTime);
// Skip events before the start date
if (start < startDate) continue;
const dateKey = format(start, 'yyyy-MM-dd');
if (!groups.has(dateKey)) {
groups.set(dateKey, []);
}
groups.get(dateKey)!.push(event);
}
// Sort groups by date
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([dateKey, events]) => ({
date: parseISO(dateKey),
events: events.sort((a, b) => {
const aStart = toDate(a.startTime);
const bStart = toDate(b.startTime);
return aStart.getTime() - bStart.getTime();
}),
}));
});
function formatDateHeader(date: Date) {
if (isToday(date)) {
return 'Heute';
}
if (isTomorrow(date)) {
return 'Morgen';
}
return format(date, 'EEEE, d. MMMM', { locale: de });
}
function handleEventClick(event: CalendarEvent) {
if (onEventClick) {
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">
{#if groupedEvents.length === 0}
<div class="empty-state">
<svg class="empty-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p>Keine Termine in diesem Zeitraum</p>
</div>
{:else}
<div class="event-list">
{#each groupedEvents as group}
<div class="date-group">
<h2 class="date-header" class:today={isToday(group.date)}>
{formatDateHeader(group.date)}
</h2>
<div class="events-for-date">
{#each group.events as event}
<button
class="event-item"
onclick={() => handleEventClick(event)}
oncontextmenu={(e) => handleEventContextMenu(event, e)}
>
<div
class="color-bar"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
></div>
<div class="event-content">
<div class="event-time">
{#if event.isAllDay}
Ganztägig
{:else}
{format(toDate(event.startTime), 'HH:mm')} - {format(
toDate(event.endTime),
'HH:mm'
)}
{/if}
</div>
<div class="event-title">{event.title}</div>
{#if event.location}
<div class="event-location">
<svg
class="location-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{event.location}
</div>
{/if}
</div>
<svg class="chevron-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
<EventContextMenu onEdit={handleContextMenuEdit} />
<style>
.agenda-view {
padding: 1rem;
max-width: 700px;
margin: 0 auto;
height: 100%;
overflow-y: auto;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
}
.empty-icon {
width: 4rem;
height: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state p {
font-size: 1rem;
margin: 0;
}
.event-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.date-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.date-header {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
padding-left: 0.5rem;
padding-bottom: 0.25rem;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
}
.date-header.today {
color: hsl(var(--color-primary));
border-color: hsl(var(--color-primary) / 0.3);
}
.events-for-date {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.event-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
cursor: pointer;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-md);
text-align: left;
width: 100%;
background: hsl(var(--color-surface));
transition:
transform 150ms ease,
box-shadow 150ms ease,
border-color 150ms ease;
}
.event-item:hover {
transform: translateX(4px);
border-color: hsl(var(--color-border-hover, var(--color-border)));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.color-bar {
width: 4px;
align-self: stretch;
border-radius: 2px;
flex-shrink: 0;
min-height: 2.5rem;
}
.event-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.event-time {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
font-weight: 500;
}
.event-title {
font-weight: 500;
font-size: 0.9375rem;
color: hsl(var(--color-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-location {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
}
.location-icon {
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
}
.chevron-icon {
width: 1rem;
height: 1rem;
color: hsl(var(--color-muted-foreground));
opacity: 0.5;
flex-shrink: 0;
}
.event-item:hover .chevron-icon {
opacity: 1;
}
</style>

View file

@ -1,9 +1,50 @@
<script lang="ts">
import { viewStore } from '$lib/stores/view.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
import CalendarHeaderContextMenu from './CalendarHeaderContextMenu.svelte';
// Format title based on view type
let contextMenu: CalendarHeaderContextMenu;
// Get weekday format string based on setting
function getWeekdayFormat(): string {
switch (settingsStore.headerWeekdayFormat) {
case 'full':
return 'EEEE';
case 'short':
return 'EEE';
case 'hidden':
return '';
}
}
// Get date format string based on settings
function getDateFormat(includeYear: boolean = true): string {
const parts: string[] = [];
// Weekday
const weekdayFormat = getWeekdayFormat();
if (weekdayFormat) {
parts.push(weekdayFormat);
}
// Date with optional month
if (settingsStore.headerShowDate) {
if (settingsStore.headerAlwaysShowMonth) {
parts.push(includeYear ? 'd.M. MMMM yyyy' : 'd.M.');
} else {
parts.push(includeYear ? 'd. MMMM yyyy' : 'd.');
}
} else if (includeYear) {
// Only month and year if date is hidden
parts.push('MMMM yyyy');
}
return parts.join(', ');
}
// Format title based on view type and settings
let title = $derived.by(() => {
const date = viewStore.currentDate;
const rangeStart = viewStore.viewRange.start;
@ -11,23 +52,26 @@
// Helper to format date range
const formatRange = () => {
const showMonth = settingsStore.headerAlwaysShowMonth;
const startFormat = showMonth ? 'd.M.' : 'd.';
if (rangeStart.getMonth() === rangeEnd.getMonth()) {
return (
format(rangeStart, 'd.', { locale: de }) +
format(rangeStart, startFormat, { locale: de }) +
' - ' +
format(rangeEnd, 'd. MMMM yyyy', { locale: de })
format(rangeEnd, showMonth ? 'd.M. MMMM yyyy' : 'd. MMMM yyyy', { locale: de })
);
}
return (
format(rangeStart, 'd. MMM', { locale: de }) +
format(rangeStart, showMonth ? 'd.M. MMM' : 'd. MMM', { locale: de }) +
' - ' +
format(rangeEnd, 'd. MMM yyyy', { locale: de })
format(rangeEnd, showMonth ? 'd.M. MMM yyyy' : 'd. MMM yyyy', { locale: de })
);
};
switch (viewStore.viewType) {
case 'day':
return format(date, 'EEEE, d. MMMM yyyy', { locale: de });
return format(date, getDateFormat(true), { locale: de });
case '5day':
case 'week':
case '10day':
@ -43,16 +87,29 @@
return format(date, 'MMMM yyyy', { locale: de });
}
});
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
contextMenu.show(e.clientX, e.clientY);
}
</script>
<header class="calendar-header">
<header
class="calendar-header"
class:compact={settingsStore.headerCompact}
oncontextmenu={handleContextMenu}
role="banner"
>
<h1 class="header-title">{title}</h1>
</header>
<CalendarHeaderContextMenu bind:this={contextMenu} />
<style>
.calendar-header {
padding: 0.75rem 1rem;
background: transparent;
cursor: context-menu;
}
.header-title {
@ -67,4 +124,19 @@
font-size: 1rem;
}
}
/* Compact variant */
.calendar-header.compact {
padding: 0.5rem 1rem;
}
.calendar-header.compact .header-title {
font-size: 1rem;
}
@media (max-width: 640px) {
.calendar-header.compact .header-title {
font-size: 0.875rem;
}
}
</style>

View file

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

View file

@ -1,17 +1,21 @@
<script lang="ts">
import { PillToolbar, PillToolbarDivider } from '@manacore/shared-ui';
import { ExpandableToolbar } from '@manacore/shared-ui';
import CalendarToolbarContent from './CalendarToolbarContent.svelte';
interface Props {
isSidebarMode?: boolean;
isCollapsed?: boolean;
isMobile?: boolean;
bottomOffset?: string;
onModeChange?: (isSidebar: boolean) => void;
onCollapsedChange?: (isCollapsed: boolean) => void;
}
let {
isSidebarMode = false,
isCollapsed = false,
isCollapsed = true,
isMobile = false,
bottomOffset = '70px',
onModeChange,
onCollapsedChange,
}: Props = $props();
@ -19,105 +23,54 @@
function toggleSidebarMode() {
onModeChange?.(!isSidebarMode);
}
function collapseToolbar() {
onCollapsedChange?.(true);
}
function expandToolbar() {
onCollapsedChange?.(false);
}
</script>
{#if !isCollapsed}
<PillToolbar position="bottom" bottomOffset={isSidebarMode ? '0px' : '70px'}>
<CalendarToolbarContent />
<ExpandableToolbar
{isCollapsed}
{onCollapsedChange}
{isSidebarMode}
{bottomOffset}
collapsedTitle="Kalender-Optionen"
expandedTitle="Schließen"
>
<CalendarToolbarContent />
<PillToolbarDivider />
<!-- Layout Control -->
<div class="segmented-control glass-pill">
<button onclick={collapseToolbar} class="segment-btn" title="Toolbar minimieren">
<svg class="segment-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<div class="segment-divider"></div>
<button
onclick={toggleSidebarMode}
class="segment-btn"
title={isSidebarMode ? 'Zur Bottom-Navigation' : 'Zur Sidebar-Navigation'}
>
<svg class="segment-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if isSidebarMode}
<!-- Bottom bar layout icon -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 3h18v9H3V3zm0 12h18v6H3v-6z"
/>
{:else}
<!-- Sidebar layout icon -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 3h7v18H3V3zm9 0h9v18h-9V3z"
/>
{/if}
</svg>
</button>
</div>
</PillToolbar>
{/if}
<!-- FAB for collapsed state -->
{#if isCollapsed}
<button
onclick={expandToolbar}
class="toolbar-fab glass-pill"
class:sidebar-mode={isSidebarMode}
title="Toolbar anzeigen"
>
<svg class="fab-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
/>
</svg>
</button>
{/if}
{#snippet rightActions()}
<button
onclick={toggleSidebarMode}
class="layout-btn"
title={isSidebarMode ? 'Zur Bottom-Navigation' : 'Zur Sidebar-Navigation'}
>
<svg class="layout-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if isSidebarMode}
<!-- Bottom bar layout icon -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 3h18v9H3V3zm0 12h18v6H3v-6z"
/>
{:else}
<!-- Sidebar layout icon -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 3h7v18H3V3zm9 0h9v18h-9V3z"
/>
{/if}
</svg>
</button>
{/snippet}
</ExpandableToolbar>
<style>
.segmented-control {
display: flex;
align-items: center;
padding: 0.125rem;
gap: 0;
}
.glass-pill {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
border-radius: 9999px;
}
:global(.dark) .glass-pill {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.segment-btn {
/* Layout toggle button - app-specific style */
.layout-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem;
padding: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
@ -126,64 +79,17 @@
transition: all 0.15s ease;
}
.segment-btn:hover {
.layout-btn:hover {
background: rgba(0, 0, 0, 0.05);
color: hsl(var(--color-foreground));
}
:global(.dark) .segment-btn:hover {
:global(.dark) .layout-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.segment-divider {
width: 1px;
height: 1rem;
background: rgba(0, 0, 0, 0.1);
margin: 0 0.125rem;
}
:global(.dark) .segment-divider {
background: rgba(255, 255, 255, 0.15);
}
.segment-icon {
.layout-icon {
width: 1rem;
height: 1rem;
}
/* FAB for collapsed state - positioned right, above PillNav FAB */
.toolbar-fab {
position: fixed;
bottom: calc(56px + env(safe-area-inset-bottom, 0px)); /* Above PillNav FAB */
right: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
cursor: pointer;
border: none;
border-radius: 9999px 0 0 9999px;
transition: all 0.3s ease;
}
.toolbar-fab.sidebar-mode {
bottom: calc(56px + env(safe-area-inset-bottom, 0px));
}
.toolbar-fab:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.fab-icon {
width: 1.25rem;
height: 1.25rem;
color: hsl(var(--color-muted-foreground));
}
.toolbar-fab:hover .fab-icon {
color: hsl(var(--color-foreground));
}
</style>

View file

@ -37,6 +37,7 @@
'14day',
'month',
'year',
'agenda',
];
// Convert to ViewOptions for PillViewSwitcher
@ -100,7 +101,6 @@
options={viewOptions}
value={viewStore.viewType}
onChange={handleViewChange}
primaryColor="#3b82f6"
embedded={true}
/>
</div>
@ -154,21 +154,12 @@
}
.toolbar-content.vertical :global(.pill-view-switcher .switcher-btn:hover) {
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .toolbar-content.vertical :global(.pill-view-switcher .switcher-btn:hover) {
background: rgba(255, 255, 255, 0.1);
background: hsl(var(--color-foreground) / 0.05);
}
.toolbar-content.vertical :global(.pill-view-switcher .switcher-btn.active) {
background: color-mix(in srgb, #3b82f6 15%, transparent 85%);
border-color: color-mix(in srgb, #3b82f6 25%, transparent 75%);
}
:global(.dark) .toolbar-content.vertical :global(.pill-view-switcher .switcher-btn.active) {
background: color-mix(in srgb, #3b82f6 25%, transparent 75%);
border-color: color-mix(in srgb, #3b82f6 35%, transparent 65%);
background: hsl(var(--color-primary) / 0.15);
border-color: hsl(var(--color-primary) / 0.25);
}
/* PillTimeRangeSelector in vertical mode */

View file

@ -1,6 +1,8 @@
<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 DateStripContextMenu from './DateStripContextMenu.svelte';
import {
format,
isToday,
@ -9,16 +11,28 @@
subDays,
startOfDay,
isWithinInterval,
getWeek,
startOfWeek,
} from 'date-fns';
import { de } from 'date-fns/locale';
import { onMount, tick } from 'svelte';
import SunCalc from 'suncalc';
interface Props {
isSidebarMode?: boolean;
// Context menu reference
let contextMenu: DateStripContextMenu;
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
contextMenu?.show(e.clientX, e.clientY);
}
let { isSidebarMode = false }: Props = $props();
interface Props {
isSidebarMode?: boolean;
isToolbarExpanded?: boolean;
hasTagStrip?: boolean; // Whether TagStrip is visible below
}
let { isSidebarMode = false, isToolbarExpanded = false, hasTagStrip = false }: Props = $props();
// Get event count for a day (max 5 dots displayed)
function getEventCount(date: Date): number {
@ -61,6 +75,17 @@
return { significant: false, emoji: '' };
}
// Check if a date is the first day of the week (respects weekStartsOn setting)
function isFirstDayOfWeek(date: Date): boolean {
const weekStart = startOfWeek(date, { weekStartsOn: settingsStore.weekStartsOn });
return isSameDay(date, weekStart);
}
// Get week number for a date
function getWeekNumber(date: Date): number {
return getWeek(date, { weekStartsOn: settingsStore.weekStartsOn });
}
// Reactive view range - needed to trigger re-renders
let viewRange = $derived(viewStore.viewRange);
let currentDate = $derived(viewStore.currentDate);
@ -98,7 +123,7 @@
}
});
async function scrollToDate(date: Date) {
async function scrollToDate(date: Date, instant = false) {
await tick();
const targetDate = startOfDay(date);
@ -115,7 +140,7 @@
);
if (dayElement) {
dayElement.scrollIntoView({
behavior: 'smooth',
behavior: instant ? 'instant' : 'smooth',
inline: 'center',
block: 'nearest',
});
@ -185,7 +210,7 @@
}
}
// Get the month of the center visible day
// Get the month of the center visible day (initial: today)
let visibleMonth = $state(format(new Date(), 'MMMM yyyy', { locale: de }));
function updateVisibleMonth() {
@ -209,20 +234,35 @@
}
}
onMount(() => {
scrollToDate(viewStore.currentDate);
onMount(async () => {
// Always scroll to today on mount, then update the visible month
const today = new Date();
await scrollToDate(today, true);
updateVisibleMonth();
checkTodayVisibility();
});
</script>
<div class="date-strip-wrapper" class:sidebar-mode={isSidebarMode}>
{#if !isTodayVisible}
<button onclick={goToToday} title="Zum heutigen Tag" class="today-button"> Heute </button>
{/if}
<div class="date-strip-container">
<div
class="date-strip-wrapper"
class:sidebar-mode={isSidebarMode}
class:toolbar-expanded={isToolbarExpanded}
class:compact={settingsStore.dateStripCompact}
class:has-tag-strip={hasTagStrip}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="date-strip-container" oncontextmenu={handleContextMenu}>
<!-- Month label -->
<div class="month-header">
<span class="month-label">{visibleMonth}</span>
<span class="month-label">
{#if !isTodayVisible}
<button onclick={goToToday} title="Zum heutigen Tag" class="today-button">
<span class="today-label">Heute</span>
<span class="today-date">{format(new Date(), 'd. MMM', { locale: de })}</span>
</button>
{/if}
{visibleMonth}
</span>
</div>
<!-- Days row -->
@ -237,12 +277,16 @@
{@const isFirstOfMonth = day.getDate() === 1}
{@const moonPhase = isSignificantMoonPhase(day)}
{@const eventCount = getEventCount(day)}
{@const showWeekNumber = settingsStore.dateStripShowWeekNumbers && isFirstDayOfWeek(day)}
{#if isFirstOfMonth}
<div class="month-divider"></div>
<div
class="month-divider"
class:show-line={settingsStore.dateStripShowMonthDividers}
></div>
{/if}
<button
class="day-item"
class:weekend={dayIsWeekend}
class:weekend={dayIsWeekend && settingsStore.dateStripHighlightWeekends}
class:selected={dayIsSelected && !dayIsToday}
class:in-range={dayInRange && !dayIsToday}
class:range-start={dayIsRangeStart && !dayIsToday}
@ -250,23 +294,22 @@
data-date={format(day, 'yyyy-MM-dd')}
data-is-today={dayIsToday}
onclick={() => handleDayClick(day)}
style={dayIsToday
? 'background: #3b82f6; color: white; border-radius: 10px; font-weight: 700; box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);'
: ''}
class:is-today={dayIsToday}
>
{#if moonPhase.significant}
{#if showWeekNumber}
<span class="week-number-label">KW {getWeekNumber(day)}</span>
{/if}
{#if moonPhase.significant && settingsStore.dateStripShowMoonPhases}
<span class="moon-indicator">{moonPhase.emoji}</span>
{/if}
<span class="day-weekday" style={dayIsToday ? 'opacity: 1; color: white;' : ''}
>{format(day, 'EE', { locale: de })}</span
>
<span class="day-number" style={dayIsToday ? 'color: white;' : ''}
>{format(day, 'd')}</span
>
{#if eventCount > 0}
<div class="event-dots" style={dayIsToday ? 'opacity: 0.9;' : ''}>
{#if settingsStore.dateStripShowWeekday}
<span class="day-weekday">{format(day, 'EE', { locale: de })}</span>
{/if}
<span class="day-number">{format(day, 'd')}</span>
{#if eventCount > 0 && settingsStore.dateStripShowEventIndicators}
<div class="event-dots">
{#each Array(eventCount) as _, i}
<span class="event-dot" style={dayIsToday ? 'background: white;' : ''}></span>
<span class="event-dot"></span>
{/each}
</div>
{/if}
@ -276,45 +319,93 @@
</div>
</div>
<DateStripContextMenu bind:this={contextMenu} />
<style>
.date-strip-wrapper {
position: fixed;
bottom: calc(200px + env(safe-area-inset-bottom, 0px)); /* Above InputBar */
bottom: calc(140px + env(safe-area-inset-bottom, 0px)); /* Above InputBar + PillNav */
left: 0;
right: 0;
z-index: 48;
display: flex;
flex-direction: column;
align-items: center;
align-items: stretch;
pointer-events: none;
transition: bottom 0.3s ease;
transition: bottom 0.2s ease;
}
/* When PillNav is in sidebar mode, no PillNav/Toolbar at bottom - just InputBar */
/* When toolbar is expanded, push DateStrip up */
.date-strip-wrapper.toolbar-expanded {
bottom: calc(210px + env(safe-area-inset-bottom, 0px)); /* Extra space for toolbar */
}
/* When PillNav is in sidebar mode, no PillNav at bottom - just InputBar */
.date-strip-wrapper.sidebar-mode {
bottom: calc(70px + env(safe-area-inset-bottom, 0px));
}
.date-strip-wrapper.sidebar-mode.toolbar-expanded {
bottom: calc(140px + env(safe-area-inset-bottom, 0px));
}
/* When TagStrip is visible below, add extra offset */
.date-strip-wrapper.has-tag-strip {
bottom: calc(210px + env(safe-area-inset-bottom, 0px)); /* +70px for TagStrip */
}
.date-strip-wrapper.has-tag-strip.toolbar-expanded {
bottom: calc(280px + env(safe-area-inset-bottom, 0px));
}
.date-strip-wrapper.has-tag-strip.sidebar-mode {
bottom: calc(140px + env(safe-area-inset-bottom, 0px));
}
.date-strip-wrapper.has-tag-strip.sidebar-mode.toolbar-expanded {
bottom: calc(210px + env(safe-area-inset-bottom, 0px));
}
.today-button {
padding: 0.25rem 0.75rem;
background: transparent;
border: 1px solid #d1d5db;
position: absolute;
right: 100%;
top: 50%;
transform: translateY(-50%);
margin-right: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.375rem 0.875rem;
background: hsl(var(--color-surface) / 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--color-border));
border-radius: 9999px;
cursor: pointer;
color: #9ca3af;
font-size: 0.6875rem;
font-weight: 600;
margin-bottom: 0.375rem;
color: hsl(var(--color-primary));
pointer-events: auto;
transition: all 0.2s ease;
box-shadow: 0 2px 8px hsl(var(--color-foreground) / 0.08);
}
.today-button:hover {
background: rgba(59, 130, 246, 0.1);
border-color: #3b82f6;
color: #3b82f6;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
background: hsl(var(--color-surface) / 0.95);
border-color: hsl(var(--color-primary) / 0.3);
transform: translateY(-50%) scale(1.02);
box-shadow: 0 4px 12px hsl(var(--color-foreground) / 0.12);
}
.today-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.today-date {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.date-strip-container {
@ -322,12 +413,12 @@
flex-direction: column;
background: var(--color-surface, #ffffff);
border-radius: 16px;
margin: 0 1rem;
padding: 0.5rem;
margin: 0;
padding: 0.5rem 0;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
border: 1px solid var(--color-border, #e5e7eb);
pointer-events: auto;
max-width: calc(100vw - 2rem);
width: 100%;
overflow: hidden;
}
@ -340,20 +431,27 @@
}
.month-label {
position: relative;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-foreground, #1f2937);
white-space: nowrap;
min-width: 150px;
text-align: center;
}
.month-divider {
width: 1px;
height: 40px;
background: var(--color-border, #e5e7eb);
background: transparent;
margin: 0 0.5rem;
flex-shrink: 0;
}
.month-divider.show-line {
background: hsl(var(--color-border));
}
.days-scroll {
display: flex;
align-items: center;
@ -363,8 +461,16 @@
scrollbar-width: none;
-ms-overflow-style: none;
scroll-behavior: auto;
padding: 1.25rem 0.25rem 0.25rem;
padding: 1.25rem 1rem 0.25rem;
margin-top: -1rem;
mask-image: linear-gradient(to right, transparent 0%, black 8%, black 92%, transparent 100%);
-webkit-mask-image: linear-gradient(
to right,
transparent 0%,
black 8%,
black 92%,
transparent 100%
);
}
.days-scroll::-webkit-scrollbar {
@ -398,6 +504,20 @@
line-height: 1;
}
.week-number-label {
position: absolute;
top: -14px;
left: 50%;
transform: translateX(-50%);
font-size: 0.5625rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
white-space: nowrap;
pointer-events: none;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.event-dots {
display: flex;
gap: 2px;
@ -409,7 +529,7 @@
width: 4px;
height: 4px;
border-radius: 50%;
background: #3b82f6;
background: hsl(var(--color-primary));
opacity: 0.7;
}
@ -418,18 +538,34 @@
}
.day-item.weekend {
color: var(--color-muted-foreground, #6b7280);
background: transparent;
border: 1px solid hsl(var(--color-border));
}
.day-item.weekend:hover {
background: hsl(var(--color-muted) / 0.3);
border-color: hsl(var(--color-muted-foreground) / 0.5);
}
/* Weekend + in-range combination */
.day-item.weekend.in-range {
background: hsl(var(--color-primary) / 0.15);
border: 1px solid hsl(var(--color-primary) / 0.4);
}
.day-item.weekend.in-range:hover {
background: hsl(var(--color-primary) / 0.25);
}
.day-item.selected {
background: var(--color-muted, #f3f4f6);
color: #3b82f6;
color: hsl(var(--color-primary));
font-weight: 600;
}
/* View range highlighting */
.day-item.in-range {
background: rgba(59, 130, 246, 0.15);
background: hsl(var(--color-primary) / 0.15);
border-radius: 0;
}
@ -446,7 +582,33 @@
}
.day-item.in-range:hover {
background: rgba(59, 130, 246, 0.25);
background: hsl(var(--color-primary) / 0.25);
}
/* Today styling */
.day-item.is-today {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground, 0 0% 100%));
border-radius: 10px;
font-weight: 700;
box-shadow: 0 2px 8px hsl(var(--color-primary) / 0.4);
}
.day-item.is-today .day-weekday {
opacity: 1;
color: hsl(var(--color-primary-foreground, 0 0% 100%));
}
.day-item.is-today .day-number {
color: hsl(var(--color-primary-foreground, 0 0% 100%));
}
.day-item.is-today .event-dots {
opacity: 0.9;
}
.day-item.is-today .event-dot {
background: hsl(var(--color-primary-foreground, 0 0% 100%));
}
.day-weekday {
@ -465,9 +627,15 @@
/* Responsive */
@media (max-width: 640px) {
.date-strip-wrapper {
left: 0;
right: 0;
}
.date-strip-container {
margin: 0 0.5rem;
padding: 0.375rem;
margin: 0;
padding: 0.375rem 0;
width: 100%;
}
.month-label {
@ -484,6 +652,11 @@
top: -14px;
}
.week-number-label {
top: -12px;
font-size: 0.5rem;
}
.day-number {
font-size: 1rem;
}
@ -497,4 +670,67 @@
margin: 0 0.375rem;
}
}
/* Compact mode */
.date-strip-wrapper.compact .date-strip-container {
padding: 0.25rem 0;
}
.date-strip-wrapper.compact .month-header {
padding: 0.125rem 0.5rem 0.25rem;
}
.date-strip-wrapper.compact .month-label {
font-size: 0.875rem;
}
.date-strip-wrapper.compact .day-item {
min-width: 40px;
height: 44px;
padding: 0.25rem;
}
.date-strip-wrapper.compact .day-weekday {
font-size: 0.625rem;
}
.date-strip-wrapper.compact .day-number {
font-size: 0.875rem;
}
.date-strip-wrapper.compact .moon-indicator {
font-size: 0.875rem;
top: -12px;
}
.date-strip-wrapper.compact .week-number-label {
top: -10px;
font-size: 0.5rem;
}
.date-strip-wrapper.compact .month-divider {
height: 28px;
}
.date-strip-wrapper.compact .event-dot {
width: 3px;
height: 3px;
}
.date-strip-wrapper.compact .month-header {
padding-top: 0.375rem;
}
.date-strip-wrapper.compact .today-button {
padding: 0.25rem 0.625rem;
margin-right: 1rem;
}
.date-strip-wrapper.compact .today-label {
font-size: 0.5625rem;
}
.date-strip-wrapper.compact .today-date {
font-size: 0.625rem;
}
</style>

View file

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

View file

@ -0,0 +1,168 @@
<script lang="ts">
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 {
isSidebarMode?: boolean;
isToolbarExpanded?: boolean;
isMobile?: boolean;
hasTagStrip?: boolean;
}
let {
isSidebarMode = false,
isToolbarExpanded = false,
isMobile = false,
hasTagStrip = 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>
<div
class="datestrip-fab-container"
class:sidebar-mode={isSidebarMode}
class:toolbar-expanded={isToolbarExpanded}
class:mobile={isMobile}
class:has-tag-strip={hasTagStrip}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<button
onclick={handleClick}
oncontextmenu={handleContextMenu}
class="datestrip-fab"
title="Datumsleiste erweitern (Rechtsklick für Optionen)"
>
<span class="fab-label">{fabLabel}</span>
</button>
</div>
<DateStripContextMenu bind:this={contextMenu} />
<style>
.datestrip-fab-container {
position: fixed;
bottom: calc(70px + 9px + env(safe-area-inset-bottom, 0px));
/* Position left of InputBar: center (50%) minus half of InputBar (225px) minus gap (8px) minus fab width (54px) */
left: calc(50% - 225px - 8px - 54px);
z-index: 49;
pointer-events: none;
transition:
bottom 0.2s ease,
left 0.2s ease;
}
.datestrip-fab-container.sidebar-mode {
bottom: calc(9px + env(safe-area-inset-bottom, 0px));
/* In sidebar mode, InputBar is 700px wide, so position accordingly */
left: calc(50% - 350px - 8px - 54px);
}
.datestrip-fab-container.toolbar-expanded {
bottom: calc(140px + 9px + env(safe-area-inset-bottom, 0px));
}
.datestrip-fab-container.sidebar-mode.toolbar-expanded {
bottom: calc(70px + 9px + env(safe-area-inset-bottom, 0px));
/* In sidebar mode, InputBar is 700px wide */
left: calc(50% - 350px - 8px - 54px);
}
@media (max-width: 900px) {
.datestrip-fab-container {
left: 1rem;
}
}
/* Mobile: Position in row above InputBar, left of ViewModePill */
/* InputBar is at bottom: 70px (above PillNav), so controls go above that */
.datestrip-fab-container.mobile {
/* Above PillNav (70px) + InputBar (72px) + gap (8px), to the left of center */
bottom: calc(70px + 72px + 8px + env(safe-area-inset-bottom, 0px));
left: calc(50% - 100px - 54px - 8px);
}
.datestrip-fab-container.mobile.toolbar-expanded {
bottom: calc(70px + 72px + 70px + 8px + env(safe-area-inset-bottom, 0px));
}
/* When TagStrip is visible, add 70px offset */
.datestrip-fab-container.has-tag-strip {
bottom: calc(140px + 9px + env(safe-area-inset-bottom, 0px));
}
.datestrip-fab-container.has-tag-strip.toolbar-expanded {
bottom: calc(210px + 9px + env(safe-area-inset-bottom, 0px));
}
.datestrip-fab-container.has-tag-strip.sidebar-mode {
bottom: calc(70px + 9px + env(safe-area-inset-bottom, 0px));
}
.datestrip-fab-container.has-tag-strip.mobile {
bottom: calc(140px + 72px + 8px + env(safe-area-inset-bottom, 0px));
}
.datestrip-fab-container.has-tag-strip.mobile.toolbar-expanded {
bottom: calc(140px + 72px + 70px + 8px + env(safe-area-inset-bottom, 0px));
}
/* Fallback for CSS-only mobile detection */
@media (max-width: 640px) {
.datestrip-fab-container:not(.mobile) {
/* Above PillNav (70px) + InputBar (72px) + gap (8px), to the left of center */
bottom: calc(70px + 72px + 8px + env(safe-area-inset-bottom, 0px));
left: calc(50% - 100px - 54px - 8px);
}
.datestrip-fab-container:not(.mobile).toolbar-expanded {
bottom: calc(70px + 72px + 70px + 8px + env(safe-area-inset-bottom, 0px));
}
}
.datestrip-fab {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 54px;
height: 54px;
padding: 0 0.75rem;
cursor: pointer;
border: none;
transition: all 0.2s ease;
pointer-events: auto;
background: hsl(var(--color-surface) / 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--color-border));
box-shadow: 0 2px 8px hsl(var(--color-foreground) / 0.08);
border-radius: 9999px;
}
.datestrip-fab:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px hsl(var(--color-foreground) / 0.15);
}
.fab-label {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
white-space: nowrap;
}
</style>

View file

@ -5,129 +5,89 @@
import { settingsStore } from '$lib/stores/settings.svelte';
import { searchStore } from '$lib/stores/search.svelte';
import { todosStore, type Task } from '$lib/stores/todos.svelte';
import TaskBlock from './TaskBlock.svelte';
import { goto } from '$app/navigation';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
import { birthdaysStore } from '$lib/stores/birthdays.svelte';
import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte';
import { useVisibleHours, useCurrentTimeIndicator, useBirthdayPopover } from '$lib/composables';
import { toDate } from '$lib/utils/eventDateHelpers';
import { HOUR_HEIGHT_PX, SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants';
import {
format,
isToday,
parseISO,
differenceInMinutes,
addMinutes,
setHours,
setMinutes,
} from 'date-fns';
getVisibleTimedEvents,
getVisibleAllDayEvents,
getVisibleOverflowEvents,
type OverflowEvents,
} from '$lib/utils/eventFiltering';
import EventCard from './EventCard.svelte';
import TaskBlock from './TaskBlock.svelte';
import EventContextMenu from '$lib/components/event/EventContextMenu.svelte';
import { goto } from '$app/navigation';
import { format, isToday, differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns';
import { de } from 'date-fns/locale';
import type { 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 }) => void;
onEventClick?: (event: CalendarEvent) => void;
onTaskClick?: (task: Task) => void;
}
let { onQuickCreate, onEventClick, onTaskClick }: Props = $props();
let { date, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
// Constants
const HOUR_HEIGHT = 60; // pixels per hour
const SNAP_MINUTES = 15; // snap to 15-minute intervals
// Use provided date or fall back to viewStore
let effectiveDate = $derived(date ?? viewStore.currentDate);
// Generate hours (filtered based on settings)
let allHours = Array.from({ length: 24 }, (_, i) => i);
let hours = $derived(
settingsStore.filterHoursEnabled
? allHours.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour)
: allHours
);
// Use shared constants
const HOUR_HEIGHT = HOUR_HEIGHT_PX;
const SNAP_MINUTES = SNAP_INTERVAL_MINUTES;
// Calculate visible hours range for positioning
let firstVisibleHour = $derived(
settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0
);
let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24);
let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour);
// Use composables for hour filtering and time indicator
const visibleHours = useVisibleHours();
const timeIndicator = useCurrentTimeIndicator();
// Helper to convert minutes to percentage position (accounting for hidden hours)
function minutesToPercent(minutes: number): number {
const adjustedMinutes = minutes - firstVisibleHour * 60;
return (adjustedMinutes / (totalVisibleHours * 60)) * 100;
}
// Destructure for convenience (these are reactive getters)
let hours = $derived(visibleHours.hours);
let firstVisibleHour = $derived(visibleHours.firstVisibleHour);
let lastVisibleHour = $derived(visibleHours.lastVisibleHour);
let totalVisibleHours = $derived(visibleHours.totalVisibleHours);
const minutesToPercent = visibleHours.minutesToPercent;
// Current time indicator position
let now = $state(new Date());
let currentTimePosition = $derived.by(() => {
const minutes = now.getHours() * 60 + now.getMinutes();
return minutesToPercent(minutes);
});
// Update current time every minute
$effect(() => {
const interval = setInterval(() => {
now = new Date();
}, 60000);
return () => clearInterval(interval);
});
let currentTimePosition = $derived(minutesToPercent(timeIndicator.currentMinutes));
// Get timed events, filtering out those outside visible range when hour filter is enabled
let timedEvents = $derived.by(() => {
const allEvents = eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => !e.isAllDay);
if (settingsStore.filterHoursEnabled) {
const visibleStartMinutes = settingsStore.dayStartHour * 60;
const visibleEndMinutes = settingsStore.dayEndHour * 60;
return allEvents.filter((event) => {
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const eventStartMinutes = start.getHours() * 60 + start.getMinutes();
const eventEndMinutes = end.getHours() * 60 + end.getMinutes();
// Event overlaps with visible range
return eventStartMinutes < visibleEndMinutes && eventEndMinutes > visibleStartMinutes;
});
}
return allEvents;
});
let timedEvents = $derived(
getVisibleTimedEvents(
eventsStore.getEventsForDay(effectiveDate),
calendarsStore.visibleCalendars,
{
filterHoursEnabled: settingsStore.filterHoursEnabled,
dayStartHour: settingsStore.dayStartHour,
dayEndHour: settingsStore.dayEndHour,
}
)
);
// Get events that are completely outside the visible time range
let overflowEvents = $derived.by(() => {
let overflowEvents = $derived.by((): OverflowEvents => {
if (!settingsStore.filterHoursEnabled) {
return { before: [] as CalendarEvent[], after: [] as CalendarEvent[] };
return { before: [], after: [] };
}
const allEvents = eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => !e.isAllDay);
const before: CalendarEvent[] = [];
const after: CalendarEvent[] = [];
const visibleStartMinutes = settingsStore.dayStartHour * 60;
const visibleEndMinutes = settingsStore.dayEndHour * 60;
for (const event of allEvents) {
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const eventStartMinutes = start.getHours() * 60 + start.getMinutes();
const eventEndMinutes = end.getHours() * 60 + end.getMinutes();
// Event ends before visible range starts
if (eventEndMinutes <= visibleStartMinutes) {
before.push(event);
}
// Event starts after visible range ends
else if (eventStartMinutes >= visibleEndMinutes) {
after.push(event);
}
}
return { before, after };
return getVisibleOverflowEvents(
eventsStore.getEventsForDay(effectiveDate),
calendarsStore.visibleCalendars,
settingsStore.dayStartHour,
settingsStore.dayEndHour
);
});
let allDayEvents = $derived(
eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => e.isAllDay)
getVisibleAllDayEvents(
eventsStore.getEventsForDay(effectiveDate),
calendarsStore.visibleCalendars
)
);
// Get display mode for an event (per-event override takes precedence over global setting)
@ -145,6 +105,15 @@
let blockAllDayEvents = $derived(allDayEvents.filter((e) => getEventDisplayMode(e) === 'block'));
// Birthday Popover (using composable)
const birthdayPopover = useBirthdayPopover();
// Get birthdays for current day (if enabled in settings)
let birthdays = $derived.by(() => {
if (!settingsStore.showBirthdays) return [];
return birthdaysStore.getBirthdaysForDay(effectiveDate);
});
// ============================================================================
// Drag & Drop State
// ============================================================================
@ -165,6 +134,7 @@
let resizeOriginalEnd = $state<Date | null>(null);
let resizePreviewTop = $state(0);
let resizePreviewHeight = $state(0);
let resizeOffsetMinutes = $state(0);
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
let hasMoved = $state(false);
@ -210,8 +180,8 @@
e.preventDefault();
e.stopPropagation();
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const start = toDate(event.startTime);
const end = toDate(event.endTime);
const startMinutes = start.getHours() * 60 + start.getMinutes();
const duration = differenceInMinutes(end, start);
@ -255,18 +225,12 @@
Math.min(newStartMinutes, lastVisibleHour * 60 - 30)
);
const start =
typeof draggedEvent.startTime === 'string'
? parseISO(draggedEvent.startTime)
: draggedEvent.startTime;
const end =
typeof draggedEvent.endTime === 'string'
? parseISO(draggedEvent.endTime)
: draggedEvent.endTime;
const start = toDate(draggedEvent.startTime);
const end = toDate(draggedEvent.endTime);
const duration = differenceInMinutes(end, start);
// Create new start time on same day
let newStart = new Date(viewStore.currentDate);
let newStart = new Date(effectiveDate);
newStart = setHours(newStart, Math.floor(clampedMinutes / 60));
newStart = setMinutes(newStart, clampedMinutes % 60);
newStart.setSeconds(0, 0);
@ -301,16 +265,25 @@
resizeEdge = edge;
hasMoved = false;
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const start = toDate(event.startTime);
const end = toDate(event.endTime);
resizeOriginalStart = start;
resizeOriginalEnd = end;
const startMinutes = start.getHours() * 60 + start.getMinutes();
const endMinutes = end.getHours() * 60 + end.getMinutes();
const duration = differenceInMinutes(end, start);
resizePreviewTop = minutesToPercent(startMinutes);
resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
// Calculate offset between snapped click position and actual event boundary
const clickMinutes = getMinutesFromY(e.clientY);
if (edge === 'top') {
resizeOffsetMinutes = clickMinutes - startMinutes;
} else {
resizeOffsetMinutes = clickMinutes - endMinutes;
}
document.addEventListener('pointermove', handleResizeMove);
document.addEventListener('pointerup', handleResizeEnd);
}
@ -320,18 +293,19 @@
hasMoved = true;
const mouseMinutes = getMinutesFromY(e.clientY);
const snappedMinutes = snapToGrid(mouseMinutes);
// Apply offset to prevent jumping when drag starts
const adjustedMinutes = snapToGrid(mouseMinutes - resizeOffsetMinutes);
const origStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const origEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
if (resizeEdge === 'top') {
const newStartMinutes = Math.min(snappedMinutes, origEndMinutes - SNAP_MINUTES);
const newStartMinutes = Math.min(adjustedMinutes, origEndMinutes - SNAP_MINUTES);
const clampedStart = Math.max(firstVisibleHour * 60, newStartMinutes);
resizePreviewTop = minutesToPercent(clampedStart);
resizePreviewHeight = ((origEndMinutes - clampedStart) / (totalVisibleHours * 60)) * 100;
} else {
const newEndMinutes = Math.max(snappedMinutes, origStartMinutes + SNAP_MINUTES);
const newEndMinutes = Math.max(adjustedMinutes, origStartMinutes + SNAP_MINUTES);
const clampedEnd = Math.min(lastVisibleHour * 60, newEndMinutes);
resizePreviewHeight = ((clampedEnd - origStartMinutes) / (totalVisibleHours * 60)) * 100;
}
@ -344,7 +318,8 @@
}
const mouseMinutes = getMinutesFromY(e.clientY);
const snappedMinutes = snapToGrid(mouseMinutes);
// Apply offset to prevent jumping
const adjustedMinutes = snapToGrid(mouseMinutes - resizeOffsetMinutes);
const origStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const origEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
@ -355,17 +330,17 @@
if (resizeEdge === 'top') {
const newStartMinutes = Math.max(
firstVisibleHour * 60,
Math.min(snappedMinutes, origEndMinutes - SNAP_MINUTES)
Math.min(adjustedMinutes, origEndMinutes - SNAP_MINUTES)
);
newStart = setHours(new Date(viewStore.currentDate), Math.floor(newStartMinutes / 60));
newStart = setHours(new Date(effectiveDate), Math.floor(newStartMinutes / 60));
newStart = setMinutes(newStart, newStartMinutes % 60);
newStart.setSeconds(0, 0);
} else {
const newEndMinutes = Math.min(
lastVisibleHour * 60,
Math.max(snappedMinutes, origStartMinutes + SNAP_MINUTES)
Math.max(adjustedMinutes, origStartMinutes + SNAP_MINUTES)
);
newEnd = setHours(new Date(viewStore.currentDate), Math.floor(newEndMinutes / 60));
newEnd = setHours(new Date(effectiveDate), Math.floor(newEndMinutes / 60));
newEnd = setMinutes(newEnd, newEndMinutes % 60);
newEnd.setSeconds(0, 0);
}
@ -393,6 +368,7 @@
resizeEvent = null;
resizeOriginalStart = null;
resizeOriginalEnd = null;
resizeOffsetMinutes = 0;
hasMoved = false;
document.removeEventListener('pointermove', handleDragMove);
document.removeEventListener('pointerup', handleDragEnd);
@ -615,7 +591,7 @@
const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
await todosStore.updateTodo(data.taskId, {
scheduledDate: format(viewStore.currentDate, 'yyyy-MM-dd'),
scheduledDate: format(effectiveDate, 'yyyy-MM-dd'),
scheduledStartTime: startTime,
scheduledEndTime: endTime,
estimatedDuration: duration,
@ -645,8 +621,8 @@
// Event Styling
// ============================================================================
function getEventStyle(event: CalendarEvent) {
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const start = toDate(event.startTime);
const end = toDate(event.endTime);
const startMinutes = start.getHours() * 60 + start.getMinutes();
const duration = differenceInMinutes(end, start);
@ -655,9 +631,11 @@
const top = minutesToPercent(startMinutes);
const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 1.5); // minimum ~20px at 60px/hour
const color = calendarsStore.getColor(event.calendarId);
return `top: ${top}%; height: ${height}%;`;
}
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
function formatEventTime(event: CalendarEvent): string {
return `${format(toDate(event.startTime), 'HH:mm')} - ${format(toDate(event.endTime), 'HH:mm')}`;
}
/**
@ -686,7 +664,7 @@
* Get scheduled tasks for current day
*/
function getScheduledTasks(): Task[] {
return todosStore.getScheduledTasksForDay(viewStore.currentDate);
return todosStore.getScheduledTasksForDay(effectiveDate);
}
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
@ -710,7 +688,7 @@
// Don't create event if dragging or resizing
if (isDragging || isResizing) return;
const startTime = new Date(viewStore.currentDate);
const startTime = new Date(effectiveDate);
startTime.setHours(hour, 0, 0, 0);
if (onQuickCreate) {
@ -719,11 +697,25 @@
goto(`/event/new?start=${startTime.toISOString()}`);
}
}
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);
}
function handleContextMenuEdit(event: CalendarEvent) {
if (onEventClick) {
onEventClick(event);
}
}
</script>
<div class="day-view">
<!-- Header-style all-day events -->
{#if headerAllDayEvents.length > 0}
<!-- Header-style all-day events and birthdays -->
{#if headerAllDayEvents.length > 0 || birthdays.length > 0}
<div class="all-day-section">
<div class="time-gutter">
<span class="all-day-label">Ganztägig</span>
@ -740,6 +732,18 @@
{event.title}
</button>
{/each}
<!-- Birthdays -->
{#each birthdays as birthday}
<button
class="all-day-event birthday-event"
onclick={(e) => birthdayPopover.handleBirthdayClick(birthday, e)}
>
🎂 {birthday.displayName}
{#if settingsStore.showBirthdayAge && birthday.age > 0}
<span class="birthday-age">({birthday.age})</span>
{/if}
</button>
{/each}
</div>
</div>
{/if}
@ -757,7 +761,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="day-column"
class:today={isToday(viewStore.currentDate)}
class:today={isToday(effectiveDate)}
class:drop-target={isSidebarDropTarget}
bind:this={dayColumnRef}
ondragover={handleSidebarDragOver}
@ -786,68 +790,27 @@
{/each}
<!-- Timed events -->
{#each timedEvents as event}
{#each timedEvents as event (event.id)}
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
{@const isDraft = eventsStore.isDraftEvent(event.id)}
{@const isSearchHighlighted = searchStore.isEventHighlighted(event.id)}
{@const isSearchDimmed = searchStore.isEventDimmed(event.id)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="event-card"
class:dragging={isBeingDragged}
class:resizing={isBeingResized}
class:draft={isDraft}
class:search-highlighted={isSearchHighlighted}
class:search-dimmed={isSearchDimmed}
data-event-id={event.id}
<EventCard
{event}
style={isBeingDragged
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%;`
: isBeingResized
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%;`
: getEventStyle(event)}
onpointerdown={(e) => startDrag(event, e)}
onclick={(e) => !isDraft && handleEventClick(event, e)}
role="button"
tabindex="0"
>
<!-- Top resize handle -->
<div
class="resize-handle top"
onpointerdown={(e) => startResize(event, 'top', e)}
role="separator"
aria-orientation="horizontal"
aria-label="Startzeit ändern"
aria-valuenow={0}
tabindex="-1"
></div>
<span class="event-time">
{format(
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime,
'HH:mm'
)} -
{format(
typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime,
'HH:mm'
)}
</span>
<span class="event-title">{event.title || (isDraft ? '(Neuer Termin)' : '')}</span>
{#if event.location}
<span class="event-location">{event.location}</span>
{/if}
<!-- Bottom resize handle -->
<div
class="resize-handle bottom"
onpointerdown={(e) => startResize(event, 'bottom', e)}
role="separator"
aria-orientation="horizontal"
aria-label="Endzeit ändern"
aria-valuenow={0}
tabindex="-1"
></div>
</div>
color={calendarsStore.getColor(event.calendarId)}
isDragging={isBeingDragged}
isResizing={isBeingResized}
isSearchHighlighted={searchStore.isEventHighlighted(event.id)}
isSearchDimmed={searchStore.isEventDimmed(event.id)}
formattedTime={formatEventTime(event)}
onClick={handleEventClick}
onPointerDown={startDrag}
onContextMenu={handleEventContextMenu}
onResizeStart={startResize}
/>
{/each}
<!-- Scheduled Tasks (Time-Blocking) -->
@ -876,10 +839,7 @@
<div
class="overflow-line"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
title="{format(
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime,
'HH:mm'
)} {event.title}"
title="{format(toDate(event.startTime), 'HH:mm')} {event.title}"
></div>
{/each}
</div>
@ -893,33 +853,45 @@
<div
class="overflow-line"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
title="{format(
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime,
'HH:mm'
)} {event.title}"
title="{format(toDate(event.startTime), 'HH:mm')} {event.title}"
></div>
{/each}
</div>
{/if}
<!-- Current time indicator -->
{#if isToday(viewStore.currentDate)}
{#if isToday(effectiveDate)}
<div class="time-indicator" style="top: {currentTimePosition}%"></div>
{/if}
</div>
</div>
</div>
<!-- Event Context Menu -->
<EventContextMenu onEdit={handleContextMenuEdit} />
<!-- Birthday Popover -->
{#if birthdayPopover.selectedBirthday}
<BirthdayPopover
birthday={birthdayPopover.selectedBirthday}
position={birthdayPopover.popoverPosition}
onClose={birthdayPopover.closePopover}
/>
{/if}
<style>
.day-view {
display: flex;
flex-direction: column;
align-items: center;
}
.all-day-section {
display: flex;
border-bottom: 1px solid hsl(var(--color-border));
padding: 0.5rem 0;
width: 100%;
max-width: 800px;
}
.all-day-label {
@ -960,13 +932,14 @@
.all-day-block-event {
position: absolute;
top: 0;
left: 4px;
right: 4px;
left: 8px;
width: calc(100% - 16px);
max-width: 400px;
bottom: 0;
padding: 8px 12px;
color: white;
border: none;
border-radius: var(--radius-sm);
border-radius: var(--radius-md);
text-align: left;
cursor: pointer;
z-index: 0;
@ -1002,27 +975,29 @@
.time-grid {
flex: 1;
display: flex;
width: 100%;
max-width: 800px;
}
.time-column {
width: var(--time-column-width);
width: 50px;
flex-shrink: 0;
}
.time-label {
height: var(--hour-height);
padding-right: 0.5rem;
padding-right: 0.75rem;
text-align: right;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
position: relative;
top: -0.5em;
top: 0.25em;
}
.time-gutter {
width: var(--time-column-width);
width: 50px;
flex-shrink: 0;
padding-right: 0.5rem;
padding-right: 0.75rem;
text-align: right;
}
@ -1030,6 +1005,7 @@
flex: 1;
position: relative;
border-left: 1px solid hsl(var(--color-border));
max-width: 600px;
}
.day-column.today {
@ -1042,125 +1018,6 @@
outline-offset: -2px;
}
.event-card {
position: absolute;
left: 4px;
right: 4px;
color: white;
border: none;
text-align: left;
cursor: grab;
z-index: 1;
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 8px;
border-radius: var(--radius-sm);
overflow: hidden;
touch-action: none;
user-select: none;
transition:
box-shadow 150ms ease,
opacity 150ms ease;
}
.event-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.event-card.dragging {
cursor: grabbing;
opacity: 0.9;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
z-index: 100;
}
.event-card.resizing {
cursor: ns-resize;
opacity: 0.85;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
z-index: 100;
outline: 2px dashed rgba(255, 255, 255, 0.6);
outline-offset: -2px;
}
.event-card.draft {
outline: 2px solid hsl(var(--color-primary));
outline-offset: -1px;
animation: pulse-outline 1.5s ease-in-out infinite;
}
/* Search highlighting */
.event-card.search-highlighted {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
box-shadow:
0 0 0 4px hsl(var(--color-primary) / 0.3),
0 4px 12px rgba(0, 0, 0, 0.25);
z-index: 10;
}
.event-card.search-dimmed {
opacity: 0.35;
filter: grayscale(0.3);
}
@keyframes pulse-outline {
0%,
100% {
outline-color: hsl(var(--color-primary));
}
50% {
outline-color: hsl(var(--color-primary) / 0.5);
}
}
/* Resize Handles */
.resize-handle {
position: absolute;
left: 0;
right: 0;
height: 8px;
cursor: ns-resize;
opacity: 0;
transition: opacity 150ms ease;
z-index: 10;
}
.resize-handle.top {
top: 0;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.resize-handle.bottom {
bottom: 0;
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
.event-card:hover .resize-handle {
opacity: 1;
background: rgba(255, 255, 255, 0.3);
}
.resize-handle:hover {
background: rgba(255, 255, 255, 0.5) !important;
}
.event-time {
font-size: 0.75rem;
opacity: 0.9;
}
.event-title {
font-size: 0.875rem;
font-weight: 500;
}
.event-location {
font-size: 0.75rem;
opacity: 0.8;
}
/* Time indicator */
.time-indicator {
position: absolute;
@ -1230,4 +1087,18 @@
opacity: 1;
height: 5px;
}
/* Birthday events in all-day row */
.all-day-event.birthday-event {
background: linear-gradient(135deg, #ec4899 0%, #f472b6 100%);
}
.all-day-event.birthday-event:hover {
opacity: 0.9;
}
.birthday-age {
opacity: 0.85;
font-size: 0.7rem;
}
</style>

View file

@ -0,0 +1,251 @@
<script lang="ts">
import type { CalendarEvent } from '@calendar/shared';
import { eventsStore } from '$lib/stores/events.svelte';
import { _ } from 'svelte-i18n';
interface Props {
event: CalendarEvent;
style: string;
color: string;
isDragging?: boolean;
isResizing?: boolean;
isDraggingSource?: boolean;
isSearchHighlighted?: boolean;
isSearchDimmed?: boolean;
formattedTime: string;
onClick?: (event: CalendarEvent, e: MouseEvent) => void;
onPointerDown?: (event: CalendarEvent, e: PointerEvent) => void;
onContextMenu?: (event: CalendarEvent, e: MouseEvent) => void;
onResizeStart?: (event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) => void;
}
let {
event,
style,
color,
isDragging = false,
isResizing = false,
isDraggingSource = false,
isSearchHighlighted = false,
isSearchDimmed = false,
formattedTime,
onClick,
onPointerDown,
onContextMenu,
onResizeStart,
}: Props = $props();
let isDraft = $derived(eventsStore.isDraftEvent(event.id));
function handleClick(e: MouseEvent) {
if (isDragging || isResizing || isDraft) {
e.preventDefault();
e.stopPropagation();
return;
}
onClick?.(event, e);
}
function handlePointerDown(e: PointerEvent) {
onPointerDown?.(event, e);
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (isDraft) return;
onContextMenu?.(event, e);
}
function handleResizeTop(e: PointerEvent) {
e.stopPropagation();
onResizeStart?.(event, 'top', e);
}
function handleResizeBottom(e: PointerEvent) {
e.stopPropagation();
onResizeStart?.(event, 'bottom', e);
}
function handleKeydown(e: KeyboardEvent) {
if ((e.key === 'Enter' || e.key === ' ') && !isDraft) {
e.preventDefault();
onClick?.(event, e as unknown as MouseEvent);
}
}
</script>
<div
class="event-card"
class:dragging={isDragging && !isDraggingSource}
class:dragging-source={isDraggingSource}
class:resizing={isResizing}
class:draft={isDraft}
class:search-highlighted={isSearchHighlighted}
class:search-dimmed={isSearchDimmed}
data-event-id={event.id}
{style}
style:background-color={color}
role="button"
tabindex="0"
aria-label={event.title || $_('calendar.draftEvent')}
onpointerdown={handlePointerDown}
onclick={handleClick}
onkeydown={handleKeydown}
oncontextmenu={handleContextMenu}
>
<!-- Top resize handle -->
{#if onResizeStart}
<div
class="resize-handle top"
onpointerdown={handleResizeTop}
role="slider"
aria-label={$_('event.changeStartTime')}
aria-valuenow={0}
tabindex="-1"
></div>
{/if}
<span class="event-time">{formattedTime}</span>
<span class="event-title">{event.title || (isDraft ? $_('calendar.draftEvent') : '')}</span>
{#if event.location}
<span class="event-location">{event.location}</span>
{/if}
<!-- Bottom resize handle -->
{#if onResizeStart}
<div
class="resize-handle bottom"
onpointerdown={handleResizeBottom}
role="slider"
aria-label={$_('event.changeEndTime')}
aria-valuenow={0}
tabindex="-1"
></div>
{/if}
</div>
<style>
.event-card {
position: absolute;
left: 2px;
right: 2px;
padding: 2px 4px;
border-radius: var(--radius-sm);
text-align: left;
cursor: grab;
z-index: 1;
overflow: hidden;
transition:
box-shadow 0.15s ease,
opacity 0.15s ease;
touch-action: none;
user-select: none;
color: white;
}
.event-card:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.event-card:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
}
.event-card.dragging {
cursor: grabbing;
opacity: 0.9;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
z-index: 100;
}
.event-card.resizing {
opacity: 0.85;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
outline: 2px dashed hsl(var(--color-primary) / 0.6);
outline-offset: -2px;
}
/* Ghost style for source position during cross-day drag */
.event-card.dragging-source {
opacity: 0.4;
background: transparent !important;
border: 2px dashed hsl(var(--color-border));
pointer-events: none;
}
.event-card.dragging-source .event-title,
.event-card.dragging-source .event-time,
.event-card.dragging-source .event-location {
opacity: 0.5;
}
.event-card.draft {
border: 2px dashed hsl(var(--color-primary) / 0.6);
background-color: hsl(var(--color-primary) / 0.3) !important;
}
.event-card.search-highlighted {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.3);
}
.event-card.search-dimmed {
opacity: 0.35;
filter: grayscale(0.3);
}
.event-time {
display: block;
font-size: 0.6rem;
opacity: 0.9;
}
.event-title {
display: block;
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-location {
display: block;
font-size: 0.6rem;
opacity: 0.85;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Resize handles */
.resize-handle {
position: absolute;
left: 0;
right: 0;
height: 8px;
cursor: ns-resize;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 2;
}
.resize-handle.top {
top: 0;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.resize-handle.bottom {
bottom: 0;
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
.event-card:hover .resize-handle {
opacity: 1;
background: rgba(255, 255, 255, 0.3);
}
</style>

View file

@ -5,7 +5,11 @@
import { settingsStore } from '$lib/stores/settings.svelte';
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';
import { goto } from '$app/navigation';
import {
format,
@ -27,20 +31,26 @@
} from 'date-fns';
import { de } from 'date-fns/locale';
import { _ } from 'svelte-i18n';
import { filterByVisibleCalendars } from '$lib/utils/eventFiltering';
import type { 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 }) => void;
onEventClick?: (event: CalendarEvent) => void;
}
let { onQuickCreate, onEventClick }: Props = $props();
let { date, onQuickCreate, onEventClick }: Props = $props();
// Use provided date or fall back to viewStore
let effectiveDate = $derived(date ?? viewStore.currentDate);
// Get all days to display in the month grid (including days from prev/next months)
let allCalendarDays = $derived.by(() => {
const monthStart = startOfMonth(viewStore.currentDate);
const monthEnd = endOfMonth(viewStore.currentDate);
const monthStart = startOfMonth(effectiveDate);
const monthEnd = endOfMonth(effectiveDate);
const calendarStart = startOfWeek(monthStart, { weekStartsOn: settingsStore.weekStartsOn });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: settingsStore.weekStartsOn });
@ -80,7 +90,6 @@
let isDragging = $state(false);
let draggedEvent = $state<CalendarEvent | null>(null);
let dragTargetDay = $state<Date | null>(null);
let monthViewRef = $state<HTMLElement | null>(null);
// Store for day cell refs
let dayCellRefs = $state<Map<string, HTMLElement>>(new Map());
@ -191,8 +200,18 @@
// ============================================================================
// Event Handlers
// ============================================================================
function getEventsForDay(day: Date) {
return eventsStore.getEventsForDay(day).slice(0, 3); // Max 3 events shown
function getEventsForDay(day: Date): CalendarEvent[] {
return filterByVisibleCalendars(
eventsStore.getEventsForDay(day),
calendarsStore.visibleCalendars
).slice(0, 3); // Max 3 events shown
}
function getAllEventsForDay(day: Date): CalendarEvent[] {
return filterByVisibleCalendars(
eventsStore.getEventsForDay(day),
calendarsStore.visibleCalendars
);
}
function handleDayClick(day: Date, e: MouseEvent) {
@ -241,9 +260,27 @@
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);
}
// ============================================================================
// Birthday Functions
// ============================================================================
const birthdayPopover = useBirthdayPopover();
function getBirthdaysForDay(day: Date): BirthdayEvent[] {
if (!settingsStore.showBirthdays) return [];
return birthdaysStore.getBirthdaysForDay(day);
}
</script>
<div class="month-view" style="--column-count: {columnCount}" bind:this={monthViewRef}>
<div class="month-view" style="--column-count: {columnCount}">
<!-- Week day headers -->
<div class="weekday-headers">
{#each weekDays as day}
@ -259,7 +296,7 @@
{@const isDropTarget = isDragging && dragTargetDay && isSameDay(day, dragTargetDay)}
<div
class="day-cell"
class:other-month={!isSameMonth(day, viewStore.currentDate)}
class:other-month={!isSameMonth(day, effectiveDate)}
class:today={isToday(day)}
class:drop-target={isDropTarget}
use:bindDayCellRef={day}
@ -297,6 +334,7 @@
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"
>
@ -319,10 +357,27 @@
</div>
{/each}
{#if eventsStore.getEventsForDay(day).length > 3}
<!-- Birthdays -->
{#each getBirthdaysForDay(day) as birthday}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="event-pill birthday-pill"
onclick={(e) => birthdayPopover.handleBirthdayClick(birthday, e)}
role="button"
tabindex="0"
>
🎂
<span class="event-title">{birthday.displayName}</span>
{#if settingsStore.showBirthdayAge && birthday.age > 0}
<span class="birthday-age">({birthday.age})</span>
{/if}
</div>
{/each}
{#if getAllEventsForDay(day).length > 3}
<button class="more-events" onclick={(e) => handleMoreClick(day, e)}>
{$_('views.moreEvents', {
values: { count: eventsStore.getEventsForDay(day).length - 3 },
values: { count: getAllEventsForDay(day).length - 3 },
})}
</button>
{/if}
@ -334,6 +389,15 @@
</div>
</div>
<!-- Birthday Popover -->
{#if birthdayPopover.selectedBirthday}
<BirthdayPopover
birthday={birthdayPopover.selectedBirthday}
position={birthdayPopover.popoverPosition}
onClose={birthdayPopover.closePopover}
/>
{/if}
<style>
.month-view {
display: flex;
@ -344,6 +408,10 @@
display: grid;
grid-template-columns: repeat(var(--column-count, 7), 1fr);
border-bottom: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
position: sticky;
top: 0;
z-index: 10;
}
.weekday-header {
@ -514,4 +582,19 @@
outline: 2px dashed hsl(var(--color-primary));
outline-offset: -2px;
}
/* Birthday pills */
.event-pill.birthday-pill {
background: linear-gradient(135deg, #ec4899 0%, #f472b6 100%);
cursor: pointer;
}
.event-pill.birthday-pill:hover {
opacity: 0.9;
}
.birthday-age {
opacity: 0.85;
font-size: 0.65rem;
}
</style>

View file

@ -5,6 +5,19 @@
import { settingsStore } from '$lib/stores/settings.svelte';
import { searchStore } from '$lib/stores/search.svelte';
import { todosStore, type Task } from '$lib/stores/todos.svelte';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
import {
useVisibleHours,
useCurrentTimeIndicator,
} from '$lib/composables/useVisibleHours.svelte';
import { toDate } from '$lib/utils/eventDateHelpers';
import { HOUR_HEIGHT_PX, SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants';
import {
getVisibleTimedEvents,
getVisibleAllDayEvents,
getVisibleOverflowEvents,
type OverflowEvents,
} from '$lib/utils/eventFiltering';
import TaskBlock from './TaskBlock.svelte';
import { goto } from '$app/navigation';
import {
@ -12,7 +25,6 @@
eachDayOfInterval,
isToday,
isSameDay,
parseISO,
differenceInMinutes,
isWeekend,
addMinutes,
@ -22,20 +34,40 @@
import { de, enUS, fr, es, it } from 'date-fns/locale';
import { locale } from 'svelte-i18n';
// Constants
const HOUR_HEIGHT = 60; // px - should match CSS --hour-height
const MINUTES_PER_SLOT = 15; // Snap to 15-minute intervals
// Use shared constants
const HOUR_HEIGHT = HOUR_HEIGHT_PX;
const MINUTES_PER_SLOT = SNAP_INTERVAL_MINUTES;
import type { CalendarEvent } from '@calendar/shared';
// Props
interface Props {
dayCount: 5 | 10 | 14;
dayCount: number;
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
date?: Date;
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onEventClick?: (event: CalendarEvent) => void;
onTaskClick?: (task: Task) => void;
}
let { dayCount, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
let { dayCount, date, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
// Use provided date or fall back to viewStore
let effectiveDate = $derived(date ?? viewStore.currentDate);
// Calculate view range based on effective date
let effectiveViewRange = $derived.by(() => {
if (date) {
// Calculate range for the provided date based on day count
const end = new Date(date);
end.setDate(end.getDate() + dayCount - 1);
return {
start: date,
end: end,
};
}
// Use viewStore range when no date override
return viewStore.viewRange;
});
// Get date-fns locale based on current app locale
const dateLocales = { de, en: enUS, fr, es, it };
@ -46,8 +78,8 @@
// Generate days based on view range, optionally filtering weekends
let allDays = $derived(
eachDayOfInterval({
start: viewStore.viewRange.start,
end: viewStore.viewRange.end,
start: effectiveViewRange.start,
end: effectiveViewRange.end,
})
);
@ -55,47 +87,26 @@
settingsStore.showOnlyWeekdays ? allDays.filter((day) => !isWeekend(day)) : allDays
);
// Generate hours (filtered based on settings)
let allHours = Array.from({ length: 24 }, (_, i) => i);
let hours = $derived(
settingsStore.filterHoursEnabled
? allHours.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour)
: allHours
);
// Use composables for hour filtering and time indicator
const visibleHours = useVisibleHours();
const timeIndicator = useCurrentTimeIndicator();
// Calculate visible hours range for positioning
let firstVisibleHour = $derived(
settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0
);
let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24);
let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour);
// Helper to convert minutes to percentage position (accounting for hidden hours)
function minutesToPercent(minutes: number): number {
const adjustedMinutes = minutes - firstVisibleHour * 60;
return (adjustedMinutes / (totalVisibleHours * 60)) * 100;
}
// Destructure for convenience (these are reactive getters)
let hours = $derived(visibleHours.hours);
let firstVisibleHour = $derived(visibleHours.firstVisibleHour);
let lastVisibleHour = $derived(visibleHours.lastVisibleHour);
let totalVisibleHours = $derived(visibleHours.totalVisibleHours);
const minutesToPercent = visibleHours.minutesToPercent;
// Current time indicator position
let now = $state(new Date());
let currentTimePosition = $derived.by(() => {
const minutes = now.getHours() * 60 + now.getMinutes();
return minutesToPercent(minutes);
});
// Update current time every minute
$effect(() => {
const interval = setInterval(() => {
now = new Date();
}, 60000);
return () => clearInterval(interval);
});
let currentTimePosition = $derived(minutesToPercent(timeIndicator.currentMinutes));
// Determine column width based on day count
let columnClass = $derived.by(() => {
if (days.length <= 5) return 'normal';
if (days.length <= 10) return 'compact';
return 'very-compact';
if (days.length <= 14) return 'very-compact';
return 'ultra-compact';
});
// ========== Drag & Drop State ==========
@ -114,6 +125,7 @@
let resizeOriginalEnd = $state<Date | null>(null);
let resizePreviewTop = $state(0);
let resizePreviewHeight = $state(0);
let resizeOffsetMinutes = $state(0);
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
let hasMoved = $state(false);
@ -135,66 +147,35 @@
// Reference to the days container for position calculations
let daysContainerEl: HTMLDivElement;
function getEventsForDay(day: Date) {
const allEvents = eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay);
// If hour filtering is enabled, only show events that overlap with visible range
if (settingsStore.filterHoursEnabled) {
const visibleStartMinutes = settingsStore.dayStartHour * 60;
const visibleEndMinutes = settingsStore.dayEndHour * 60;
return allEvents.filter((event) => {
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const eventStartMinutes = start.getHours() * 60 + start.getMinutes();
const eventEndMinutes = end.getHours() * 60 + end.getMinutes();
// Event overlaps with visible range
return eventStartMinutes < visibleEndMinutes && eventEndMinutes > visibleStartMinutes;
});
}
return allEvents;
function getEventsForDay(day: Date): CalendarEvent[] {
return getVisibleTimedEvents(
eventsStore.getEventsForDay(day),
calendarsStore.visibleCalendars,
{
filterHoursEnabled: settingsStore.filterHoursEnabled,
dayStartHour: settingsStore.dayStartHour,
dayEndHour: settingsStore.dayEndHour,
}
);
}
// Get events that are completely outside the visible time range
function getOverflowEventsForDay(day: Date): { before: CalendarEvent[]; after: CalendarEvent[] } {
function getOverflowEventsForDay(day: Date): OverflowEvents {
if (!settingsStore.filterHoursEnabled) {
return { before: [], after: [] };
}
const allEvents = eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay);
const before: CalendarEvent[] = [];
const after: CalendarEvent[] = [];
const visibleStartMinutes = settingsStore.dayStartHour * 60;
const visibleEndMinutes = settingsStore.dayEndHour * 60;
for (const event of allEvents) {
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const eventStartMinutes = start.getHours() * 60 + start.getMinutes();
const eventEndMinutes = end.getHours() * 60 + end.getMinutes();
// Event ends before visible range starts
if (eventEndMinutes <= visibleStartMinutes) {
before.push(event);
}
// Event starts after visible range ends
else if (eventStartMinutes >= visibleEndMinutes) {
after.push(event);
}
}
return { before, after };
return getVisibleOverflowEvents(
eventsStore.getEventsForDay(day),
calendarsStore.visibleCalendars,
settingsStore.dayStartHour,
settingsStore.dayEndHour
);
}
function getAllDayEventsForDay(day: Date) {
return eventsStore.getEventsForDay(day).filter((e) => e.isAllDay);
function getAllDayEventsForDay(day: Date): CalendarEvent[] {
return getVisibleAllDayEvents(
eventsStore.getEventsForDay(day),
calendarsStore.visibleCalendars
);
}
// Get display mode for an event (per-event override takes precedence over global setting)
@ -220,8 +201,8 @@
);
function getEventStyle(event: CalendarEvent) {
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const start = toDate(event.startTime);
const end = toDate(event.endTime);
const startMinutes = start.getHours() * 60 + start.getMinutes();
const duration = differenceInMinutes(end, start);
@ -264,8 +245,7 @@
}
function formatEventTime(date: Date | string): string {
const d = typeof date === 'string' ? parseISO(date) : date;
return settingsStore.formatTime(d);
return settingsStore.formatTime(toDate(date));
}
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
@ -299,6 +279,14 @@
}
}
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);
}
// ========== Drag & Drop Functions ==========
function getDayFromX(clientX: number): Date | null {
@ -337,8 +325,8 @@
draggedEvent = event;
hasMoved = false;
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const start = toDate(event.startTime);
const end = toDate(event.endTime);
const duration = differenceInMinutes(end, start);
// Calculate initial preview position
@ -388,14 +376,8 @@
return;
}
const start =
typeof draggedEvent.startTime === 'string'
? parseISO(draggedEvent.startTime)
: draggedEvent.startTime;
const end =
typeof draggedEvent.endTime === 'string'
? parseISO(draggedEvent.endTime)
: draggedEvent.endTime;
const start = toDate(draggedEvent.startTime);
const end = toDate(draggedEvent.endTime);
const duration = differenceInMinutes(end, start);
// Calculate new start time
@ -441,18 +423,27 @@
resizeEdge = edge;
hasMoved = false;
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const start = toDate(event.startTime);
const end = toDate(event.endTime);
resizeOriginalStart = start;
resizeOriginalEnd = end;
// Set initial preview
const startMinutes = start.getHours() * 60 + start.getMinutes();
const endMinutes = end.getHours() * 60 + end.getMinutes();
const duration = differenceInMinutes(end, start);
resizePreviewTop = minutesToPercent(startMinutes);
resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
// Calculate offset between snapped click position and actual event boundary
const clickMinutes = getMinutesFromY(e.clientY);
if (edge === 'top') {
resizeOffsetMinutes = clickMinutes - startMinutes;
} else {
resizeOffsetMinutes = clickMinutes - endMinutes;
}
document.addEventListener('pointermove', handleResizeMove);
document.addEventListener('pointerup', handleResizeEnd);
}
@ -462,6 +453,8 @@
hasMoved = true;
const currentMinutes = getMinutesFromY(e.clientY);
// Apply offset to prevent jumping when drag starts
const adjustedMinutes = currentMinutes - resizeOffsetMinutes;
const originalStartMinutes =
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
@ -470,7 +463,7 @@
// Resize from bottom - change end time
const newEndMinutes = Math.max(
originalStartMinutes + 15,
Math.min(lastVisibleHour * 60, currentMinutes)
Math.min(lastVisibleHour * 60, adjustedMinutes)
);
const newDuration = newEndMinutes - originalStartMinutes;
resizePreviewHeight = (newDuration / (totalVisibleHours * 60)) * 100;
@ -478,7 +471,7 @@
// Resize from top - change start time
const newStartMinutes = Math.max(
firstVisibleHour * 60,
Math.min(originalEndMinutes - 15, currentMinutes)
Math.min(originalEndMinutes - 15, adjustedMinutes)
);
const newDuration = originalEndMinutes - newStartMinutes;
resizePreviewTop = minutesToPercent(newStartMinutes);
@ -495,11 +488,14 @@
resizeEvent = null;
resizeOriginalStart = null;
resizeOriginalEnd = null;
resizeOffsetMinutes = 0;
hasMoved = false;
return;
}
const currentMinutes = getMinutesFromY(e.clientY);
// Apply offset to prevent jumping
const adjustedMinutes = currentMinutes - resizeOffsetMinutes;
const originalStartMinutes =
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
@ -510,7 +506,7 @@
if (resizeEdge === 'bottom') {
const newEndMinutes = Math.max(
originalStartMinutes + 15,
Math.min(lastVisibleHour * 60, currentMinutes)
Math.min(lastVisibleHour * 60, adjustedMinutes)
);
const newHours = Math.floor(newEndMinutes / 60);
const newMins = newEndMinutes % 60;
@ -519,7 +515,7 @@
} else {
const newStartMinutes = Math.max(
firstVisibleHour * 60,
Math.min(originalEndMinutes - 15, currentMinutes)
Math.min(originalEndMinutes - 15, adjustedMinutes)
);
const newHours = Math.floor(newStartMinutes / 60);
const newMins = newStartMinutes % 60;
@ -545,6 +541,7 @@
resizeEvent = null;
resizeOriginalStart = null;
resizeOriginalEnd = null;
resizeOffsetMinutes = 0;
hasMoved = false;
}
@ -807,6 +804,7 @@
resizeEvent = null;
resizeOriginalStart = null;
resizeOriginalEnd = null;
resizeOffsetMinutes = 0;
isTaskDragging = false;
draggedTask = null;
taskDragTargetDay = null;
@ -826,41 +824,45 @@
class="multi-day-view"
class:compact={columnClass === 'compact'}
class:very-compact={columnClass === 'very-compact'}
class:ultra-compact={columnClass === 'ultra-compact'}
>
<!-- All-day events row (only shown when there are header-mode all-day events) -->
{#if hasAnyHeaderAllDayEvents}
<div class="all-day-row">
<!-- Sticky header container -->
<div class="sticky-header">
<!-- Day headers -->
<div class="day-headers">
<div class="time-gutter"></div>
{#each days as day}
<div class="all-day-cell">
{#each getHeaderAllDayEventsForDay(day) as event}
<button
class="all-day-event"
class:search-highlighted={searchStore.isEventHighlighted(event.id)}
class:search-dimmed={searchStore.isEventDimmed(event.id)}
style="background-color: {calendarsStore.getColor(event.calendarId)}"
onclick={(e) => handleEventClick(event, e)}
title={event.title}
>
{event.title}
</button>
{/each}
<div class="day-header" class:today={isToday(day)}>
<span class="day-name"
>{format(day, columnClass === 'very-compact' ? 'EEEEE' : 'EEE', { locale: de })}</span
>
<span class="day-number" class:today={isToday(day)}>{format(day, 'd')}</span>
</div>
{/each}
</div>
{/if}
<!-- Day headers -->
<div class="day-headers">
<div class="time-gutter"></div>
{#each days as day}
<div class="day-header" class:today={isToday(day)}>
<span class="day-name"
>{format(day, columnClass === 'very-compact' ? 'EEEEE' : 'EEE', { locale: de })}</span
>
<span class="day-number" class:today={isToday(day)}>{format(day, 'd')}</span>
<!-- All-day events row (only shown when there are header-mode all-day events) -->
{#if hasAnyHeaderAllDayEvents}
<div class="all-day-row">
<div class="time-gutter"></div>
{#each days as day}
<div class="all-day-cell">
{#each getHeaderAllDayEventsForDay(day) as event}
<button
class="all-day-event"
class:search-highlighted={searchStore.isEventHighlighted(event.id)}
class:search-dimmed={searchStore.isEventDimmed(event.id)}
style="background-color: {calendarsStore.getColor(event.calendarId)}"
onclick={(e) => handleEventClick(event, e)}
title={event.title}
>
{event.title}
</button>
{/each}
</div>
{/each}
</div>
{/each}
{/if}
</div>
<!-- Time grid -->
@ -935,6 +937,7 @@
tabindex="0"
onpointerdown={(e) => startDrag(event, e)}
onclick={(e) => !isDraft && handleEventClick(event, e)}
oncontextmenu={(e) => handleEventContextMenu(event, e)}
onkeydown={(e) => !isDraft && e.key === 'Enter' && goto(`/?event=${event.id}`)}
title={`${formatEventTime(event.startTime)} - ${formatEventTime(event.endTime)}: ${event.title || (isDraft ? '(Neuer Termin)' : '')}`}
>
@ -1063,6 +1066,13 @@
flex-direction: column;
}
.sticky-header {
position: sticky;
top: 0;
z-index: 10;
background: hsl(var(--color-background));
}
.all-day-row {
display: flex;
border-bottom: 1px solid hsl(var(--color-border));
@ -1080,6 +1090,7 @@
}
.all-day-event {
width: 100%;
padding: 2px 6px;
font-size: 0.75rem;
color: white;
@ -1089,8 +1100,8 @@
text-overflow: ellipsis;
border: none;
cursor: pointer;
max-width: 100%;
transition: opacity 0.15s ease;
text-align: left;
}
.all-day-event.search-highlighted {
@ -1249,7 +1260,7 @@
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
position: relative;
top: -0.5em;
top: 0.25em;
}
.compact .time-label,
@ -1518,4 +1529,61 @@
.very-compact .overflow-line:hover {
height: 4px;
}
/* Ultra-compact mode for 14+ days */
.ultra-compact .day-header {
padding: 0.0625rem;
}
.ultra-compact .day-name {
font-size: 0.55rem;
}
.ultra-compact .day-number {
font-size: 0.75rem;
width: 20px;
height: 20px;
}
.ultra-compact .time-label {
font-size: 0.55rem;
padding-right: 0.125rem;
}
.ultra-compact .event-card {
left: 0;
right: 0;
padding: 0 1px;
}
.ultra-compact .event-title {
font-size: 0.5rem;
}
.ultra-compact .all-day-event {
padding: 1px 2px;
font-size: 0.55rem;
}
.ultra-compact .all-day-block-event {
left: 0;
right: 0;
padding: 1px 2px;
}
.ultra-compact .all-day-block-event .event-title {
font-size: 0.5rem;
}
.ultra-compact .resize-handle {
height: 4px;
}
.ultra-compact .overflow-line {
height: 1px;
}
.ultra-compact .overflow-line:hover {
height: 3px;
}
</style>

View file

@ -0,0 +1,57 @@
<script lang="ts">
/**
* OverflowIndicator Component
* Shows colored lines indicating events outside the visible time range
*/
import type { CalendarEvent } from '@calendar/shared';
interface OverflowEvent {
event: CalendarEvent;
color: string;
tooltip: string;
}
interface Props {
events: OverflowEvent[];
position: 'top' | 'bottom';
label?: string;
}
let { events, position, label }: Props = $props();
</script>
{#if events.length > 0}
<div class="overflow-indicator {position}" title={label}>
{#each events as { color, tooltip }}
<div class="overflow-line" style="background-color: {color}" title={tooltip}></div>
{/each}
</div>
{/if}
<style>
.overflow-indicator {
position: absolute;
left: 0;
right: 0;
display: flex;
gap: 2px;
padding: 2px 4px;
z-index: 5;
}
.overflow-indicator.top {
top: 0;
}
.overflow-indicator.bottom {
bottom: 0;
}
.overflow-line {
flex: 1;
height: 3px;
border-radius: 1px;
opacity: 0.8;
}
</style>

View file

@ -0,0 +1,273 @@
<script lang="ts">
import { settingsStore } from '$lib/stores/settings.svelte';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import { eventTagGroupsStore } from '$lib/stores/event-tag-groups.svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { DotsThree, Plus } from '@manacore/shared-icons';
import TagStripModal from './TagStripModal.svelte';
interface Props {
isSidebarMode?: boolean;
}
let { isSidebarMode = false }: Props = $props();
let showModal = $state(false);
function handleTagClick(tagId: string) {
// Navigate to tags page with the tag selected for editing
goto(`/tags?edit=${tagId}`);
}
function handleOpenModal() {
showModal = true;
}
function handleCloseModal() {
showModal = false;
}
// Sort tags by group, then by name
const sortedTags = $derived.by(() => {
const tags = [...eventTagsStore.tags];
const groupOrder = new Map(eventTagGroupsStore.groups.map((g, i) => [g.id, i]));
return tags.sort((a, b) => {
// Ungrouped tags go last
const aOrder = a.groupId ? (groupOrder.get(a.groupId) ?? 999) : 1000;
const bOrder = b.groupId ? (groupOrder.get(b.groupId) ?? 999) : 1000;
if (aOrder !== bOrder) return aOrder - bOrder;
return a.name.localeCompare(b.name, 'de');
});
});
const hasTags = $derived(eventTagsStore.tags.length > 0);
onMount(async () => {
// Fetch tags and groups if not already loaded
if (eventTagsStore.tags.length === 0) {
await eventTagsStore.fetchTags();
}
if (eventTagGroupsStore.groups.length === 0) {
await eventTagGroupsStore.fetchGroups();
}
});
</script>
<div class="tag-strip-wrapper" class:sidebar-mode={isSidebarMode}>
<div class="tag-strip-container">
<!-- More Pill (opens modal) -->
<button class="more-pill glass-tag" onclick={handleOpenModal} title="Alle Tags anzeigen">
<DotsThree size={18} weight="bold" />
<span class="tag-name">Mehr</span>
</button>
{#if eventTagsStore.loading}
<div class="loading-state">Lädt...</div>
{:else if !hasTags}
<button class="empty-state glass-tag" onclick={() => goto('/tags')}>
<span>Keine Tags vorhanden</span>
<span class="add-hint">+ Erstellen</span>
</button>
{:else}
{#each sortedTags as tag (tag.id)}
<button
class="tag-pill glass-tag"
onclick={() => handleTagClick(tag.id)}
title={tag.name}
style="--tag-color: {tag.color || '#3b82f6'}"
>
<span class="tag-dot"></span>
<span class="tag-name">{tag.name}</span>
</button>
{/each}
<!-- Create Tag Button -->
<button
class="create-pill glass-tag"
onclick={() => goto('/tags?new=true')}
title="Neuer Tag"
>
<Plus size={16} weight="bold" />
</button>
{/if}
</div>
</div>
<!-- Tags Modal -->
<TagStripModal visible={showModal} onClose={handleCloseModal} {isSidebarMode} />
<style>
.tag-strip-wrapper {
position: fixed;
bottom: calc(70px + env(safe-area-inset-bottom, 0px)); /* Directly above PillNav */
left: 0;
right: 0;
z-index: 49; /* Above other strips */
display: flex;
flex-direction: column;
align-items: stretch;
pointer-events: none;
transition: bottom 0.2s ease;
}
/* When PillNav is in sidebar mode, TagStrip at very bottom */
.tag-strip-wrapper.sidebar-mode {
bottom: calc(0px + env(safe-area-inset-bottom, 0px));
}
.tag-strip-container {
display: flex;
align-items: center;
gap: 0.75rem;
background: transparent;
pointer-events: auto;
/* Center when content fits, left-align and scroll when it overflows */
width: fit-content;
max-width: 100%;
margin-left: auto;
margin-right: auto;
padding: 0.5rem 2rem;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.tag-strip-container::-webkit-scrollbar {
display: none;
}
.tag-pill,
.more-pill,
.create-pill {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s ease;
}
/* More pill with muted style */
.more-pill {
color: #6b7280;
}
.more-pill .tag-name {
color: #6b7280;
font-weight: 600;
}
:global(.dark) .more-pill {
color: #9ca3af;
}
:global(.dark) .more-pill .tag-name {
color: #9ca3af;
}
/* Create pill with primary accent */
.create-pill {
color: #3b82f6;
padding: 0.5rem !important;
}
:global(.dark) .create-pill {
color: #60a5fa;
}
/* Glass tag styling - same as PillNavigation pills */
.glass-tag {
padding: 0.5rem 1rem;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
:global(.dark) .glass-tag {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.glass-tag:hover {
transform: scale(1.05);
background: rgba(255, 255, 255, 0.95);
border-color: rgba(0, 0, 0, 0.15);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
:global(.dark) .glass-tag:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
}
.glass-tag:active {
transform: scale(0.98);
}
.tag-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--tag-color);
flex-shrink: 0;
}
.tag-name {
font-size: 0.9375rem;
font-weight: 500;
color: #374151;
white-space: nowrap;
}
:global(.dark) .tag-name {
color: #f3f4f6;
}
.loading-state {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
padding: 0.5rem;
}
.empty-state {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
color: #6b7280;
font-size: 0.875rem;
flex-shrink: 0;
}
:global(.dark) .empty-state {
color: #9ca3af;
}
.add-hint {
font-size: 0.875rem;
color: #3b82f6;
font-weight: 500;
}
/* Responsive */
@media (max-width: 640px) {
.tag-strip-wrapper {
left: 0;
right: 0;
}
.tag-strip-container {
padding: 0.5rem 1rem;
}
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,42 @@
<script lang="ts">
/**
* TimeColumn Component
* Renders the time labels column for calendar views (WeekView, DayView, MultiDayView)
*/
interface Props {
hours: number[];
formatHour: (hour: number) => string;
}
let { hours, formatHour }: Props = $props();
</script>
<div class="time-column">
{#each hours as hour}
<div class="time-label">
{formatHour(hour)}
</div>
{/each}
</div>
<style>
.time-column {
width: 48px;
flex-shrink: 0;
border-right: 1px solid hsl(var(--color-border));
position: relative;
}
.time-label {
height: var(--hour-height, 60px);
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding-right: 8px;
padding-top: 0;
font-size: 0.7rem;
color: hsl(var(--color-muted-foreground));
transform: translateY(-0.4em);
}
</style>

View file

@ -158,6 +158,18 @@
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--color-border));
overflow: hidden;
display: flex;
flex-direction: column;
height: 100%;
}
/* Mobile: Full-bleed ohne Rundungen */
@media (max-width: 768px) {
.todo-sidebar-section {
border-radius: 0;
border: none;
border-top: 1px solid hsl(var(--color-border));
}
}
.section-header {
@ -240,12 +252,18 @@
.section-content {
padding: 0 0.5rem 0.5rem;
flex: 1;
overflow-y: auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.todo-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
}
.service-unavailable,
@ -258,6 +276,7 @@
padding: 1.5rem 1rem;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
flex: 1;
}
.service-unavailable {

View file

@ -0,0 +1,447 @@
<script lang="ts">
import { browser } from '$app/environment';
import { viewStore } from '$lib/stores/view.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { getOffsetDate } from '$lib/utils/dateNavigation';
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';
interface Props {
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onEventClick?: (event: CalendarEvent) => void;
disableSwipe?: boolean;
}
let { onQuickCreate, onEventClick, disableSwipe = false }: Props = $props();
// Swipe tracking state
let offsetX = $state(0);
let startX = $state(0);
let isSwiping = $state(false);
let isAnimating = $state(false);
let animatingDirection: 'prev' | 'next' | null = null;
// Velocity tracking for momentum
let lastX = 0;
let lastTime = 0;
let velocity = 0;
// Animation frame tracking
let animationFrameId: number | null = null;
let pendingCallback: (() => void) | null = null;
// Container refs
let viewportEl: HTMLDivElement;
let viewportWidth = $state(0);
// Threshold: 15% of viewport width or high velocity triggers navigation
const SNAP_THRESHOLD = 0.15;
const VELOCITY_THRESHOLD = 0.5; // px/ms - increased for faster swipes
// Animation speed (px/ms) - constant speed for linear feel
const ANIMATION_SPEED = 3.0; // increased for snappier feel
// Debounce for wheel events
const WHEEL_DEBOUNCE_MS = 50; // reduced for faster response
let wheelDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// Calculate dates for previous/current/next views
let prevDate = $derived(getOffsetDate(viewStore.currentDate, viewStore.viewType, -1));
let currentDate = $derived(viewStore.currentDate);
let nextDate = $derived(getOffsetDate(viewStore.currentDate, viewStore.viewType, 1));
// Update viewport width on mount and resize
$effect(() => {
if (!browser || !viewportEl) return;
const updateWidth = () => {
viewportWidth = viewportEl.offsetWidth;
};
updateWidth();
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(viewportEl);
return () => resizeObserver.disconnect();
});
// Wheel handler (trackpad horizontal scroll)
function handleWheel(e: WheelEvent) {
if (disableSwipe) return;
// Only handle horizontal scrolling (deltaX dominant)
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;
e.preventDefault();
// If animating, check if we should chain navigation
if (isAnimating) {
const scrollDirection = e.deltaX < 0 ? 'next' : 'prev';
if (scrollDirection === animatingDirection && Math.abs(e.deltaX) > 10) {
// Chain navigation - immediately go to next page in same direction
chainNavigation(scrollDirection);
}
return;
}
// Simple direct offset update
offsetX += e.deltaX * -1;
offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX));
// Debounced snap
if (wheelDebounceTimer) clearTimeout(wheelDebounceTimer);
wheelDebounceTimer = setTimeout(snapToPage, WHEEL_DEBOUNCE_MS);
}
// Touch handlers
function handleTouchStart(e: TouchEvent) {
if (disableSwipe || isAnimating) return;
// Don't interfere with event dragging
const target = e.target as HTMLElement;
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
startX = e.touches[0].clientX;
lastX = startX;
lastTime = performance.now();
velocity = 0;
isSwiping = true;
if (wheelDebounceTimer) {
clearTimeout(wheelDebounceTimer);
wheelDebounceTimer = null;
}
}
function handleTouchMove(e: TouchEvent) {
if (!isSwiping || disableSwipe) return;
const currentX = e.touches[0].clientX;
const currentTime = performance.now();
// Calculate velocity (px/ms)
const dt = currentTime - lastTime;
if (dt > 0) {
velocity = (currentX - lastX) / dt;
}
lastX = currentX;
lastTime = currentTime;
offsetX = currentX - startX;
offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX));
}
function handleTouchEnd() {
if (!isSwiping) return;
isSwiping = false;
snapToPage();
}
function handleTouchCancel() {
if (!isSwiping) return;
isSwiping = false;
isAnimating = true;
animateToOffset(0, () => {
isAnimating = false;
});
}
// Chain navigation - immediately complete current and start next
function chainNavigation(direction: 'prev' | 'next') {
// Cancel current animation
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
// Complete current navigation immediately (without resetting state flags)
if (animatingDirection === 'prev') {
viewStore.goToPrevious();
} else if (animatingDirection === 'next') {
viewStore.goToNext();
}
// Reset and start new animation for another page in same direction
offsetX = direction === 'prev' ? viewportWidth * 0.4 : -viewportWidth * 0.4;
animatingDirection = direction;
const targetOffset = direction === 'prev' ? viewportWidth : -viewportWidth;
pendingCallback = () => {
if (direction === 'prev') {
viewStore.goToPrevious();
} else {
viewStore.goToNext();
}
offsetX = 0;
isAnimating = false;
animatingDirection = null;
pendingCallback = null;
};
animateToOffset(targetOffset, pendingCallback);
}
// Snap to page based on current offset and velocity
function snapToPage() {
if (isAnimating || viewportWidth === 0) return;
const threshold = viewportWidth * SNAP_THRESHOLD;
const hasHighVelocity = Math.abs(velocity) > VELOCITY_THRESHOLD;
// Determine direction based on position and velocity
let targetPage: 'prev' | 'next' | 'current' = 'current';
if (offsetX > threshold || (hasHighVelocity && velocity > 0 && offsetX > 0)) {
targetPage = 'prev';
} else if (offsetX < -threshold || (hasHighVelocity && velocity < 0 && offsetX < 0)) {
targetPage = 'next';
}
isAnimating = true;
animatingDirection = targetPage === 'current' ? null : targetPage;
if (targetPage === 'prev') {
pendingCallback = () => {
viewStore.goToPrevious();
offsetX = 0;
isAnimating = false;
animatingDirection = null;
pendingCallback = null;
};
animateToOffset(viewportWidth, pendingCallback);
} else if (targetPage === 'next') {
pendingCallback = () => {
viewStore.goToNext();
offsetX = 0;
isAnimating = false;
animatingDirection = null;
pendingCallback = null;
};
animateToOffset(-viewportWidth, pendingCallback);
} else {
pendingCallback = () => {
isAnimating = false;
animatingDirection = null;
pendingCallback = null;
};
animateToOffset(0, pendingCallback);
}
}
function animateToOffset(targetX: number, onComplete: () => void) {
// Cancel any existing animation
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
const startX = offsetX;
const distance = targetX - startX;
const direction = Math.sign(distance);
const absDistance = Math.abs(distance);
// If already at target, complete immediately
if (absDistance < 1) {
offsetX = targetX;
onComplete();
return;
}
let lastFrameTime = performance.now();
function tick() {
const now = performance.now();
const dt = now - lastFrameTime;
lastFrameTime = now;
// Move at constant speed
const step = ANIMATION_SPEED * dt * direction;
offsetX += step;
// Check if we've reached or passed the target
const reachedTarget =
(direction > 0 && offsetX >= targetX) || (direction < 0 && offsetX <= targetX);
if (reachedTarget) {
offsetX = targetX;
animationFrameId = null;
onComplete();
} else {
animationFrameId = requestAnimationFrame(tick);
}
}
animationFrameId = requestAnimationFrame(tick);
}
// Computed styles
let trackStyle = $derived(`transform: translateX(calc(-33.333% + ${offsetX}px))`);
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="carousel-viewport"
bind:this={viewportEl}
onwheel={handleWheel}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
ontouchcancel={handleTouchCancel}
>
<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'}
<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}
</div>
<!-- Current View (main interactive view) -->
<div class="carousel-page current">
{#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'}
<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}
<WeekView {onQuickCreate} {onEventClick} />
{/if}
</div>
<!-- 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'}
<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}
</div>
</div>
</div>
<style>
.carousel-viewport {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
touch-action: pan-y;
}
.carousel-track {
display: flex;
width: 300%;
height: 100%;
will-change: transform;
}
.carousel-page {
width: 33.333%;
height: 100%;
flex-shrink: 0;
overflow: hidden;
}
/* Mobile: Allow vertical scrolling within calendar page */
@media (max-width: 768px) {
.carousel-page {
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
}
/* Inactive pages have reduced interactivity for performance */
.carousel-page.inactive {
pointer-events: none;
}
.carousel-page.current {
/* Always interactive */
pointer-events: auto;
/* Enable vertical scrolling for keyboard navigation */
overflow-y: auto;
overflow-x: hidden;
}
</style>

View file

@ -0,0 +1,240 @@
<script lang="ts">
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 {
isSidebarMode?: boolean;
isToolbarExpanded?: boolean;
isMobile?: boolean;
}
let { isSidebarMode = false, 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',
'5day': '5',
week: '7',
'10day': '10',
'14day': '14',
month: 'M',
year: 'Y',
agenda: 'A',
};
// View titles for tooltip
const viewTitles: Record<CalendarViewType, string> = {
day: 'Tagesansicht',
'5day': '5-Tage-Ansicht',
week: 'Wochenansicht',
'10day': '10-Tage-Ansicht',
'14day': '14-Tage-Ansicht',
month: 'Monatsansicht',
year: 'Jahresansicht',
agenda: 'Agenda',
};
// Get enabled views from settings
let enabledViews = $derived(settingsStore.quickViewPillViews);
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="view-mode-pill"
class:sidebar-mode={isSidebarMode}
class:toolbar-expanded={isToolbarExpanded}
class:mobile={isMobile}
oncontextmenu={handleContextMenu}
>
{#each enabledViews as view}
<button
type="button"
class="view-btn"
class:active={viewStore.viewType === view}
onclick={() => handleViewClick(view)}
title={viewTitles[view]}
>
{#if view === 'day'}
<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" />
</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" />
<path stroke-linecap="round" stroke-width="2" d="M3 9h18M8 4v4M16 4v4" />
<path
stroke-linecap="round"
stroke-width="1.5"
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" />
</svg>
{/if}
</button>
{/each}
</div>
<ViewModePillContextMenu bind:this={contextMenu} />
<style>
.view-mode-pill {
position: fixed;
/* Same vertical alignment as FAB */
bottom: calc(70px + 9px + env(safe-area-inset-bottom, 0px));
/* Position left of the Toolbar FAB (which is at right: calc(50% - 350px - 70px)) */
/* FAB position + FAB width (54px) + gap (8px) = left of FAB */
right: calc(50% - 350px - 70px + 54px + 8px);
z-index: 91;
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.375rem;
background: hsl(var(--color-surface) / 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--color-border));
border-radius: 9999px;
box-shadow: 0 2px 8px hsl(var(--color-foreground) / 0.08);
transition: bottom 0.2s ease;
}
/* When toolbar is expanded, move pill up */
.view-mode-pill.toolbar-expanded {
bottom: calc(140px + 9px + env(safe-area-inset-bottom, 0px));
}
/* Sidebar mode positioning */
.view-mode-pill.sidebar-mode {
bottom: calc(9px + env(safe-area-inset-bottom, 0px));
right: calc(50% - 350px - 70px + 54px + 8px);
}
.view-mode-pill.sidebar-mode.toolbar-expanded {
bottom: calc(70px + 9px + env(safe-area-inset-bottom, 0px));
}
/* Responsive - on smaller screens, FAB is at right: 1rem */
@media (max-width: 900px) {
.view-mode-pill {
right: calc(1rem + 54px + 8px);
}
}
/* Mobile: ViewModePill moves above InputBar as its own row */
/* InputBar is at bottom: 70px (above PillNav), so controls go above that */
.view-mode-pill.mobile {
/* Position centered above InputBar */
right: auto;
left: 50%;
transform: translateX(-50%);
/* Above PillNav (70px) + InputBar (72px) + gap (8px) */
bottom: calc(70px + 72px + 8px + env(safe-area-inset-bottom, 0px));
}
.view-mode-pill.mobile.toolbar-expanded {
/* Move up when toolbar is expanded (add toolbar height 70px) */
bottom: calc(70px + 72px + 70px + 8px + env(safe-area-inset-bottom, 0px));
}
/* Fallback for CSS-only mobile detection */
@media (max-width: 640px) {
.view-mode-pill:not(.mobile) {
/* Position centered above InputBar */
right: auto;
left: 50%;
transform: translateX(-50%);
/* Above PillNav (70px) + InputBar (72px) + gap (8px) */
bottom: calc(70px + 72px + 8px + env(safe-area-inset-bottom, 0px));
}
.view-mode-pill:not(.mobile).toolbar-expanded {
/* Move up when toolbar is expanded (add toolbar height 70px) */
bottom: calc(70px + 72px + 70px + 8px + env(safe-area-inset-bottom, 0px));
}
}
.view-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0.375rem;
background: transparent;
border: none;
border-radius: 9999px;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
transition: all 0.15s ease;
}
.view-btn:hover {
background: hsl(var(--color-muted) / 0.5);
color: hsl(var(--color-foreground));
}
.view-btn.active {
background: color-mix(in srgb, #3b82f6 15%, transparent 85%);
color: #3b82f6;
}
.view-btn :global(svg) {
width: 1.125rem;
height: 1.125rem;
}
.view-text {
font-size: 0.75rem;
font-weight: 600;
}
</style>

View file

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

View file

@ -5,21 +5,36 @@
import { settingsStore } from '$lib/stores/settings.svelte';
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';
import { HOUR_HEIGHT_PX, SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants';
import {
getVisibleTimedEvents,
getVisibleAllDayEvents,
getVisibleOverflowEvents,
type OverflowEvents,
} 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,
eachDayOfInterval,
startOfDay,
isToday,
isWeekend,
isSameDay,
parseISO,
differenceInMinutes,
addMinutes,
setHours,
setMinutes,
getWeek,
startOfWeek,
endOfWeek,
} from 'date-fns';
import { de, enUS, fr, es, it } from 'date-fns/locale';
import { locale, _ } from 'svelte-i18n';
@ -27,16 +42,35 @@
import type { 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 }) => void;
onEventClick?: (event: CalendarEvent) => void;
onTaskClick?: (task: Task) => void;
}
let { onQuickCreate, onEventClick, onTaskClick }: Props = $props();
let { date, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
// Constants
const HOUR_HEIGHT = 60; // px - should match CSS --hour-height
const MINUTES_PER_SLOT = 15; // Snap to 15-minute intervals
// Use provided date or fall back to viewStore
let effectiveDate = $derived(date ?? viewStore.currentDate);
// Calculate view range based on effective date
let effectiveViewRange = $derived.by(() => {
if (date) {
// Calculate range for the provided date
const weekStartsOn = settingsStore.weekStartsOn;
return {
start: startOfWeek(date, { weekStartsOn }),
end: endOfWeek(date, { weekStartsOn }),
};
}
// Use viewStore range when no date override
return viewStore.viewRange;
});
// Use shared constants
const HOUR_HEIGHT = HOUR_HEIGHT_PX;
const MINUTES_PER_SLOT = SNAP_INTERVAL_MINUTES;
// Get date-fns locale based on current app locale
const dateLocales = { de, en: enUS, fr, es, it };
@ -47,8 +81,8 @@
// Generate days of the week, optionally filtering weekends
let allDays = $derived(
eachDayOfInterval({
start: viewStore.viewRange.start,
end: viewStore.viewRange.end,
start: effectiveViewRange.start,
end: effectiveViewRange.end,
})
);
@ -58,44 +92,22 @@
// Get week number for display
let weekNumber = $derived(
getWeek(viewStore.viewRange.start, { weekStartsOn: settingsStore.weekStartsOn })
getWeek(effectiveViewRange.start, { weekStartsOn: settingsStore.weekStartsOn })
);
// Generate hours (filtered based on settings)
let allHours = Array.from({ length: 24 }, (_, i) => i);
let hours = $derived(
settingsStore.filterHoursEnabled
? allHours.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour)
: allHours
);
// Use composables for hour filtering and time indicator
const visibleHours = useVisibleHours();
const timeIndicator = useCurrentTimeIndicator();
// Calculate visible hours range for positioning
let firstVisibleHour = $derived(
settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0
);
let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24);
let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour);
// Helper to convert minutes to percentage position (accounting for hidden hours)
function minutesToPercent(minutes: number): number {
const adjustedMinutes = minutes - firstVisibleHour * 60;
return (adjustedMinutes / (totalVisibleHours * 60)) * 100;
}
// Destructure for convenience (these are reactive getters)
let hours = $derived(visibleHours.hours);
let firstVisibleHour = $derived(visibleHours.firstVisibleHour);
let lastVisibleHour = $derived(visibleHours.lastVisibleHour);
let totalVisibleHours = $derived(visibleHours.totalVisibleHours);
const minutesToPercent = visibleHours.minutesToPercent;
// Current time indicator position
let now = $state(new Date());
let currentTimePosition = $derived.by(() => {
const minutes = now.getHours() * 60 + now.getMinutes();
return minutesToPercent(minutes);
});
// Update current time every minute
$effect(() => {
const interval = setInterval(() => {
now = new Date();
}, 60000);
return () => clearInterval(interval);
});
let currentTimePosition = $derived(minutesToPercent(timeIndicator.currentMinutes));
// Drag & Drop State
let isDragging = $state(false);
@ -113,6 +125,7 @@
let resizeOriginalEnd = $state<Date | null>(null);
let resizePreviewTop = $state(0);
let resizePreviewHeight = $state(0);
let resizeOffsetMinutes = $state(0);
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
let hasMoved = $state(false);
@ -134,66 +147,49 @@
// Reference to the days container for position calculations
let daysContainerEl: HTMLDivElement;
function getEventsForDay(day: Date) {
const allEvents = eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay);
// Birthday Popover (using composable)
const birthdayPopover = useBirthdayPopover();
// If hour filtering is enabled, only show events that overlap with visible range
if (settingsStore.filterHoursEnabled) {
const visibleStartMinutes = settingsStore.dayStartHour * 60;
const visibleEndMinutes = settingsStore.dayEndHour * 60;
return allEvents.filter((event) => {
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const eventStartMinutes = start.getHours() * 60 + start.getMinutes();
const eventEndMinutes = end.getHours() * 60 + end.getMinutes();
// Event overlaps with visible range
return eventStartMinutes < visibleEndMinutes && eventEndMinutes > visibleStartMinutes;
});
}
return allEvents;
// Get birthdays for a day (if enabled in settings)
function getBirthdaysForDay(day: Date): BirthdayEvent[] {
if (!settingsStore.showBirthdays) return [];
return birthdaysStore.getBirthdaysForDay(day);
}
// Get events that are completely outside the visible time range
function getOverflowEventsForDay(day: Date): { before: CalendarEvent[]; after: CalendarEvent[] } {
// Check if there are any birthdays to show in the all-day row
let hasAnyBirthdays = $derived(
settingsStore.showBirthdays && days.some((day) => getBirthdaysForDay(day).length > 0)
);
function getEventsForDay(day: Date): CalendarEvent[] {
return getVisibleTimedEvents(
eventsStore.getEventsForDay(day),
calendarsStore.visibleCalendars,
{
filterHoursEnabled: settingsStore.filterHoursEnabled,
dayStartHour: settingsStore.dayStartHour,
dayEndHour: settingsStore.dayEndHour,
}
);
}
function getOverflowEventsForDay(day: Date): OverflowEvents {
if (!settingsStore.filterHoursEnabled) {
return { before: [], after: [] };
}
const allEvents = eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay);
const before: CalendarEvent[] = [];
const after: CalendarEvent[] = [];
const visibleStartMinutes = settingsStore.dayStartHour * 60;
const visibleEndMinutes = settingsStore.dayEndHour * 60;
for (const event of allEvents) {
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const eventStartMinutes = start.getHours() * 60 + start.getMinutes();
const eventEndMinutes = end.getHours() * 60 + end.getMinutes();
// Event ends before visible range starts
if (eventEndMinutes <= visibleStartMinutes) {
before.push(event);
}
// Event starts after visible range ends
else if (eventStartMinutes >= visibleEndMinutes) {
after.push(event);
}
}
return { before, after };
return getVisibleOverflowEvents(
eventsStore.getEventsForDay(day),
calendarsStore.visibleCalendars,
settingsStore.dayStartHour,
settingsStore.dayEndHour
);
}
function getAllDayEventsForDay(day: Date) {
return eventsStore.getEventsForDay(day).filter((e) => e.isAllDay);
function getAllDayEventsForDay(day: Date): CalendarEvent[] {
return getVisibleAllDayEvents(
eventsStore.getEventsForDay(day),
calendarsStore.visibleCalendars
);
}
// Get display mode for an event (per-event override takes precedence over global setting)
@ -219,8 +215,8 @@
);
function getEventStyle(event: CalendarEvent) {
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const start = toDate(event.startTime);
const end = toDate(event.endTime);
const startMinutes = start.getHours() * 60 + start.getMinutes();
const duration = differenceInMinutes(end, start);
@ -228,9 +224,11 @@
const top = minutesToPercent(startMinutes);
const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 2); // Min 2% height
const color = calendarsStore.getColor(event.calendarId);
return `top: ${top}%; height: ${height}%;`;
}
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
function formatEventTimeRange(event: CalendarEvent): string {
return `${formatEventTime(event.startTime)} - ${formatEventTime(event.endTime)}`;
}
/**
@ -265,8 +263,7 @@
}
function formatEventTime(date: Date | string): string {
const d = typeof date === 'string' ? parseISO(date) : date;
return settingsStore.formatTime(d);
return settingsStore.formatTime(toDate(date));
}
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
@ -301,6 +298,19 @@
}
}
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 {
@ -339,8 +349,8 @@
draggedEvent = event;
hasMoved = false;
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const start = toDate(event.startTime);
const end = toDate(event.endTime);
const duration = differenceInMinutes(end, start);
// Calculate initial preview position
@ -390,14 +400,8 @@
return;
}
const start =
typeof draggedEvent.startTime === 'string'
? parseISO(draggedEvent.startTime)
: draggedEvent.startTime;
const end =
typeof draggedEvent.endTime === 'string'
? parseISO(draggedEvent.endTime)
: draggedEvent.endTime;
const start = toDate(draggedEvent.startTime);
const end = toDate(draggedEvent.endTime);
const duration = differenceInMinutes(end, start);
// Calculate new start time
@ -443,18 +447,27 @@
resizeEdge = edge;
hasMoved = false;
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const start = toDate(event.startTime);
const end = toDate(event.endTime);
resizeOriginalStart = start;
resizeOriginalEnd = end;
// Set initial preview
const startMinutes = start.getHours() * 60 + start.getMinutes();
const endMinutes = end.getHours() * 60 + end.getMinutes();
const duration = differenceInMinutes(end, start);
resizePreviewTop = minutesToPercent(startMinutes);
resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
// Calculate offset between snapped click position and actual event boundary
const clickMinutes = getMinutesFromY(e.clientY);
if (edge === 'top') {
resizeOffsetMinutes = clickMinutes - startMinutes;
} else {
resizeOffsetMinutes = clickMinutes - endMinutes;
}
document.addEventListener('pointermove', handleResizeMove);
document.addEventListener('pointerup', handleResizeEnd);
}
@ -464,6 +477,8 @@
hasMoved = true;
const currentMinutes = getMinutesFromY(e.clientY);
// Apply offset to prevent jumping when drag starts
const adjustedMinutes = currentMinutes - resizeOffsetMinutes;
const originalStartMinutes =
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
@ -472,7 +487,7 @@
// Resize from bottom - change end time
const newEndMinutes = Math.max(
originalStartMinutes + 15,
Math.min(lastVisibleHour * 60, currentMinutes)
Math.min(lastVisibleHour * 60, adjustedMinutes)
);
const newDuration = newEndMinutes - originalStartMinutes;
resizePreviewHeight = (newDuration / (totalVisibleHours * 60)) * 100;
@ -480,7 +495,7 @@
// Resize from top - change start time
const newStartMinutes = Math.max(
firstVisibleHour * 60,
Math.min(originalEndMinutes - 15, currentMinutes)
Math.min(originalEndMinutes - 15, adjustedMinutes)
);
const newDuration = originalEndMinutes - newStartMinutes;
resizePreviewTop = minutesToPercent(newStartMinutes);
@ -497,11 +512,14 @@
resizeEvent = null;
resizeOriginalStart = null;
resizeOriginalEnd = null;
resizeOffsetMinutes = 0;
hasMoved = false;
return;
}
const currentMinutes = getMinutesFromY(e.clientY);
// Apply offset to prevent jumping
const adjustedMinutes = currentMinutes - resizeOffsetMinutes;
const originalStartMinutes =
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
@ -512,7 +530,7 @@
if (resizeEdge === 'bottom') {
const newEndMinutes = Math.max(
originalStartMinutes + 15,
Math.min(lastVisibleHour * 60, currentMinutes)
Math.min(lastVisibleHour * 60, adjustedMinutes)
);
const newHours = Math.floor(newEndMinutes / 60);
const newMins = newEndMinutes % 60;
@ -521,7 +539,7 @@
} else {
const newStartMinutes = Math.max(
firstVisibleHour * 60,
Math.min(originalEndMinutes - 15, currentMinutes)
Math.min(originalEndMinutes - 15, adjustedMinutes)
);
const newHours = Math.floor(newStartMinutes / 60);
const newMins = newStartMinutes % 60;
@ -547,6 +565,7 @@
resizeEvent = null;
resizeOriginalStart = null;
resizeOriginalEnd = null;
resizeOffsetMinutes = 0;
hasMoved = false;
}
@ -825,6 +844,7 @@
resizeEvent = null;
resizeOriginalStart = null;
resizeOriginalEnd = null;
resizeOffsetMinutes = 0;
hasMoved = false;
}
// Cancel task drag/resize
@ -860,41 +880,56 @@
</div>
{/if}
<!-- All-day events row (only shown when there are header-mode all-day events) -->
{#if hasAnyHeaderAllDayEvents}
<div class="all-day-row">
<div class="time-gutter">
{#if settingsStore.showWeekNumbers}
<span class="week-label">{$_('views.weekNumber')} {weekNumber}</span>
{/if}
</div>
<!-- Sticky header container -->
<div class="sticky-header">
<!-- Day headers -->
<div class="day-headers">
<div class="time-gutter"></div>
{#each days as day}
<div class="all-day-cell">
{#each getHeaderAllDayEventsForDay(day) as event}
<button
class="all-day-event"
class:search-highlighted={searchStore.isEventHighlighted(event.id)}
class:search-dimmed={searchStore.isEventDimmed(event.id)}
style="background-color: {calendarsStore.getColor(event.calendarId)}"
onclick={() => goto(`/?event=${event.id}`)}
>
{event.title}
</button>
{/each}
<div class="day-header" class:today={isToday(day)}>
<span class="day-name">{format(day, 'EEE', { locale: currentDateLocale })}</span>
<span class="day-number" class:today={isToday(day)}>{format(day, 'd')}</span>
</div>
{/each}
</div>
{/if}
<!-- Day headers -->
<div class="day-headers">
<div class="time-gutter"></div>
{#each days as day}
<div class="day-header" class:today={isToday(day)}>
<span class="day-name">{format(day, 'EEE', { locale: currentDateLocale })}</span>
<span class="day-number" class:today={isToday(day)}>{format(day, 'd')}</span>
<!-- All-day events row (shown when there are header-mode all-day events or birthdays) -->
{#if hasAnyHeaderAllDayEvents || hasAnyBirthdays}
<div class="all-day-row">
<div class="time-gutter">
{#if settingsStore.showWeekNumbers}
<span class="week-label">{$_('views.weekNumber')} {weekNumber}</span>
{/if}
</div>
{#each days as day}
<div class="all-day-cell">
{#each getHeaderAllDayEventsForDay(day) as event}
<button
class="all-day-event"
class:search-highlighted={searchStore.isEventHighlighted(event.id)}
class:search-dimmed={searchStore.isEventDimmed(event.id)}
style="background-color: {calendarsStore.getColor(event.calendarId)}"
onclick={() => goto(`/?event=${event.id}`)}
>
{event.title}
</button>
{/each}
<!-- Birthdays -->
{#each getBirthdaysForDay(day) as birthday}
<button
class="all-day-event birthday-event"
onclick={(e) => birthdayPopover.handleBirthdayClick(birthday, e)}
>
🎂 {birthday.displayName}
{#if settingsStore.showBirthdayAge && birthday.age > 0}
<span class="birthday-age">({birthday.age})</span>
{/if}
</button>
{/each}
</div>
{/each}
</div>
{/each}
{/if}
</div>
<!-- Time grid -->
@ -945,59 +980,27 @@
{#each getEventsForDay(day) as event (event.id)}
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
{@const isDraft = eventsStore.isDraftEvent(event.id)}
{@const isCrossDayDrag =
isBeingDragged && dragTargetDay && !isSameDay(day, dragTargetDay)}
{@const isSearchHighlighted = searchStore.isEventHighlighted(event.id)}
{@const isSearchDimmed = searchStore.isEventDimmed(event.id)}
<div
class="event-card"
class:dragging={isBeingDragged && !isCrossDayDrag}
class:dragging-source={isCrossDayDrag}
class:resizing={isBeingResized}
class:draft={isDraft}
class:search-highlighted={isSearchHighlighted}
class:search-dimmed={isSearchDimmed}
data-event-id={event.id}
isBeingDragged && dragTargetDay !== null && !isSameDay(day, dragTargetDay)}
<EventCard
{event}
style={isBeingDragged && !isCrossDayDrag
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%;`
: isBeingResized
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%;`
: getEventStyle(event)}
role="button"
tabindex="0"
aria-label={event.title || $_('calendar.draftEvent')}
onpointerdown={(e) => startDrag(event, e)}
onclick={(e) => !isDraft && handleEventClick(event, e)}
onkeydown={(e) => !isDraft && e.key === 'Enter' && goto(`/?event=${event.id}`)}
>
<!-- Top resize handle -->
<div
class="resize-handle top"
onpointerdown={(e) => startResize(event, 'top', e)}
role="slider"
aria-label={$_('event.changeStartTime')}
aria-valuenow={0}
tabindex="-1"
></div>
<span class="event-time">
{formatEventTime(event.startTime)} - {formatEventTime(event.endTime)}
</span>
<span class="event-title"
>{event.title || (isDraft ? $_('calendar.draftEvent') : '')}</span
>
<!-- Bottom resize handle -->
<div
class="resize-handle bottom"
onpointerdown={(e) => startResize(event, 'bottom', e)}
role="slider"
aria-label={$_('event.changeEndTime')}
aria-valuenow={0}
tabindex="-1"
></div>
</div>
color={calendarsStore.getColor(event.calendarId)}
isDragging={isBeingDragged && !isCrossDayDrag}
isDraggingSource={isCrossDayDrag}
isResizing={isBeingResized}
isSearchHighlighted={searchStore.isEventHighlighted(event.id)}
isSearchDimmed={searchStore.isEventDimmed(event.id)}
formattedTime={formatEventTimeRange(event)}
onClick={handleEventClick}
onPointerDown={startDrag}
onContextMenu={handleEventContextMenu}
onResizeStart={startResize}
/>
{/each}
<!-- Scheduled Tasks (Time-Blocking) -->
@ -1035,15 +1038,13 @@
<!-- Drag preview (solid) for cross-day dragging - shows where event will be -->
{#if isDragging && draggedEvent && dragTargetDay && isSameDay(day, dragTargetDay) && !getEventsForDay(day).some((e) => e.id === draggedEvent!.id)}
<div
class="event-card drag-preview"
style="top: {dragPreviewTop}%; height: {dragPreviewHeight}%; background-color: {calendarsStore.getColor(
draggedEvent.calendarId
)};"
>
<span class="event-time">{formatEventTime(draggedEvent.startTime)}</span>
<span class="event-title">{draggedEvent.title}</span>
</div>
<EventCard
event={draggedEvent}
style="top: {dragPreviewTop}%; height: {dragPreviewHeight}%;"
color={calendarsStore.getColor(draggedEvent.calendarId)}
isDragging={true}
formattedTime={formatEventTimeRange(draggedEvent)}
/>
{/if}
<!-- Overflow indicators for events outside visible time range -->
@ -1086,6 +1087,17 @@
</div>
</div>
<EventContextMenu onEdit={handleContextMenuEdit} />
<!-- Birthday Popover -->
{#if birthdayPopover.selectedBirthday}
<BirthdayPopover
birthday={birthdayPopover.selectedBirthday}
position={birthdayPopover.popoverPosition}
onClose={birthdayPopover.closePopover}
/>
{/if}
<style>
.week-view {
display: flex;
@ -1096,6 +1108,13 @@
display: none; /* Hidden by default, shown in gutter instead */
}
.sticky-header {
position: sticky;
top: 0;
z-index: 10;
background: hsl(var(--color-background));
}
.all-day-row {
display: flex;
border-bottom: 1px solid hsl(var(--color-border));
@ -1112,6 +1131,7 @@
}
.all-day-event {
width: 100%;
padding: 2px 6px;
font-size: 0.75rem;
color: white;
@ -1122,6 +1142,7 @@
border: none;
cursor: pointer;
transition: opacity 0.15s ease;
text-align: left;
}
.all-day-event.search-highlighted {
@ -1135,6 +1156,20 @@
filter: grayscale(0.3);
}
/* Birthday events in all-day row */
.all-day-event.birthday-event {
background: linear-gradient(135deg, #ec4899 0%, #f472b6 100%);
}
.all-day-event.birthday-event:hover {
opacity: 0.9;
}
.birthday-age {
opacity: 0.85;
font-size: 0.7rem;
}
/* Block-style all-day events (displayed as full-day blocks in the grid) */
.all-day-block-event {
position: absolute;
@ -1246,7 +1281,7 @@
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
position: relative;
top: -0.5em;
top: 0.25em;
}
.days-container {
@ -1284,140 +1319,6 @@
background: hsl(var(--color-muted) / 0.3);
}
.event-card {
position: absolute;
left: 2px;
right: 2px;
padding: 2px 4px;
color: white;
border: none;
border-radius: var(--radius-sm);
text-align: left;
cursor: grab;
z-index: 1;
overflow: hidden;
transition:
box-shadow 0.15s ease,
opacity 0.15s ease;
touch-action: none;
user-select: none;
}
.event-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.event-card.dragging {
cursor: grabbing;
opacity: 0.9;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
z-index: 100;
}
.event-card.resizing {
opacity: 0.85;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
outline: 2px dashed rgba(255, 255, 255, 0.6);
outline-offset: -2px;
}
/* Ghost style for source position during cross-day drag */
.event-card.dragging-source {
opacity: 0.4;
background: transparent !important;
border: 2px dashed hsl(var(--color-border));
pointer-events: none;
}
.event-card.dragging-source .event-title,
.event-card.dragging-source .event-time {
opacity: 0.5;
}
/* Solid preview at target position during cross-day drag */
.event-card.drag-preview {
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Search highlighting */
.event-card.search-highlighted {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
box-shadow:
0 0 0 4px hsl(var(--color-primary) / 0.3),
0 4px 12px rgba(0, 0, 0, 0.25);
z-index: 10;
}
.event-card.search-dimmed {
opacity: 0.35;
filter: grayscale(0.3);
}
.event-card.draft {
outline: 2px solid hsl(var(--color-primary));
outline-offset: -1px;
animation: pulse-outline 1.5s ease-in-out infinite;
}
@keyframes pulse-outline {
0%,
100% {
outline-color: hsl(var(--color-primary));
}
50% {
outline-color: hsl(var(--color-primary) / 0.5);
}
}
.event-time {
font-size: 0.65rem;
opacity: 0.9;
display: block;
}
.event-title {
display: block;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Resize handles */
.resize-handle {
position: absolute;
left: 0;
right: 0;
height: 8px;
cursor: ns-resize;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 2;
}
.resize-handle.top {
top: 0;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.resize-handle.bottom {
bottom: 0;
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
.event-card:hover .resize-handle {
opacity: 1;
background: rgba(255, 255, 255, 0.3);
}
.resize-handle:hover {
background: rgba(255, 255, 255, 0.5) !important;
}
.time-indicator {
position: absolute;
left: 0;

View file

@ -11,22 +11,27 @@
eachDayOfInterval,
isSameMonth,
isToday,
parseISO,
setHours,
setMinutes,
} from 'date-fns';
import { de } from 'date-fns/locale';
import { toDate } from '$lib/utils/eventDateHelpers';
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 }) => void;
onEventClick?: (event: CalendarEvent) => void;
}
let { onQuickCreate, onEventClick }: Props = $props();
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(viewStore.currentDate.getFullYear());
let year = $derived(effectiveDate.getFullYear());
let months = $derived(Array.from({ length: 12 }, (_, i) => new Date(year, i, 1)));
@ -58,8 +63,7 @@
const events = eventsStore.events ?? [];
for (const event of events) {
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const start = toDate(event.startTime);
const key = format(start, 'yyyy-MM-dd');
counts.set(key, (counts.get(key) || 0) + 1);
}

View file

@ -0,0 +1,198 @@
<script lang="ts">
import { ContextMenu, type ContextMenuItem } 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 { toastStore } from '$lib/stores/toast.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() {
// TODO: Implement calendar picker modal
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() {
// TODO: Implement color picker modal
const event = eventContextMenuStore.targetEvent;
if (!event) return;
// For now, cycle through some predefined colors
const colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899'];
const currentIndex = colors.indexOf(event.color || '');
const nextIndex = (currentIndex + 1) % colors.length;
eventsStore.updateEvent(event.id, { color: colors[nextIndex] });
toastStore.success('Farbe geändert');
}
function handleExport() {
const event = eventContextMenuStore.targetEvent;
if (!event) return;
// Generate simple ICS content
const startDate =
typeof event.startTime === 'string' ? new Date(event.startTime) : event.startTime;
const endDate = typeof event.endTime === 'string' ? new Date(event.endTime) : event.endTime;
const formatDate = (date: Date) => {
return date.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
};
const icsContent = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Manacore//Calendar//DE
BEGIN:VEVENT
UID:${event.id}
DTSTART:${formatDate(startDate)}
DTEND:${formatDate(endDate)}
SUMMARY:${event.title}
${event.description ? `DESCRIPTION:${event.description}` : ''}
${event.location ? `LOCATION:${event.location}` : ''}
END:VEVENT
END:VCALENDAR`;
const blob = new Blob([icsContent], { type: 'text/calendar' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${event.title.replace(/[^a-zA-Z0-9]/g, '_')}.ics`;
link.click();
URL.revokeObjectURL(url);
toastStore.success('Termin exportiert');
}
async function handleDelete() {
const event = eventContextMenuStore.targetEvent;
if (!event) return;
if (confirm(`Möchten Sie "${event.title}" wirklich löschen?`)) {
try {
await eventsStore.deleteEvent(event.id);
toastStore.success('Termin gelöscht');
} catch (error) {
console.error('Error deleting event:', error);
toastStore.error('Fehler beim Löschen');
}
}
}
function handleClose() {
eventContextMenuStore.hide();
}
</script>
<ContextMenu
visible={eventContextMenuStore.visible}
x={eventContextMenuStore.x}
y={eventContextMenuStore.y}
items={menuItems}
onClose={handleClose}
/>

View file

@ -2,13 +2,14 @@
import { goto } from '$app/navigation';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { toast } from '$lib/stores/toast';
import { toast } from '$lib/stores/toast.svelte';
import EventForm from './EventForm.svelte';
import { TagBadge } from '@manacore/shared-ui';
import type { CalendarEvent, UpdateEventInput } from '@calendar/shared';
import * as api from '$lib/api/events';
import { format, parseISO } from 'date-fns';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
import { toDate } from '$lib/utils/eventDateHelpers';
import { EventDetailSkeleton } from '$lib/components/skeletons';
interface Props {
@ -99,8 +100,8 @@
if (event.isAllDay) {
return 'Ganztägig';
}
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const start = toDate(event.startTime);
const end = toDate(event.endTime);
return `${format(start, 'PPPp', { locale: de })} - ${format(end, 'p', { locale: de })}`;
}

View file

@ -3,8 +3,14 @@
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import { TagSelector, type Tag } from '@manacore/shared-ui';
import {
TagSelector,
FilterDropdown,
type Tag,
type FilterDropdownOption,
} from '@manacore/shared-ui';
import AttendeeSelector from './AttendeeSelector.svelte';
import ResponsiblePersonSelector from './ResponsiblePersonSelector.svelte';
import type {
CalendarEvent,
CreateEventInput,
@ -12,8 +18,10 @@
LocationDetails,
EventTag,
EventAttendee,
ResponsiblePerson,
} from '@calendar/shared';
import { format, addMinutes, parseISO } from 'date-fns';
import { format, addMinutes } from 'date-fns';
import { toDate } from '$lib/utils/eventDateHelpers';
interface Props {
mode: 'create' | 'edit';
@ -51,6 +59,11 @@
})) || []
);
// Responsible person state
let responsiblePerson = $state<ResponsiblePerson | null>(
event?.metadata?.responsiblePerson || null
);
// Attendees state
let attendees = $state<EventAttendee[]>(event?.metadata?.attendees || []);
@ -71,6 +84,18 @@
// Derived available tags for TagSelector
let availableTags = $derived(eventTagsStore.tags.map(eventTagToTag));
// Calendar options for FilterDropdown
let calendarOptions = $derived<FilterDropdownOption[]>(
calendarsStore.calendars.map((cal) => ({ value: cal.id, label: cal.name }))
);
// All-day display mode options
const displayModeOptions: FilterDropdownOption[] = [
{ value: 'default', label: 'Standard (aus Einstellungen)' },
{ value: 'header', label: 'In Kopfzeile' },
{ value: 'block', label: 'Als Tagesblock' },
];
// Auto-expand location details if any field is filled
$effect(() => {
if (event?.metadata?.locationDetails) {
@ -97,9 +122,8 @@
// Initialize date/time fields using settings for default duration
$effect(() => {
if (event) {
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const start = toDate(event.startTime);
const end = toDate(event.endTime);
startDate = format(start, 'yyyy-MM-dd');
startTime = format(start, 'HH:mm');
endDate = format(end, 'yyyy-MM-dd');
@ -172,6 +196,13 @@
delete metadata.locationDetails;
}
// Add responsible person
if (responsiblePerson) {
metadata.responsiblePerson = responsiblePerson;
} else {
delete metadata.responsiblePerson;
}
// Add attendees
if (attendees.length > 0) {
metadata.attendees = attendees;
@ -214,17 +245,14 @@
</div>
<div class="flex flex-col gap-2">
<label for="calendar" class="text-sm font-medium text-foreground">Kalender</label>
<span class="text-sm font-medium text-foreground">Kalender</span>
{#if calendarsStore.calendars.length > 0}
<select
id="calendar"
class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors"
bind:value={calendarId}
>
{#each calendarsStore.calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
<FilterDropdown
options={calendarOptions}
value={calendarId}
onChange={(v) => (calendarId = typeof v === 'string' ? v : '')}
placeholder="Kalender wählen"
/>
{:else}
<p class="text-sm text-muted-foreground italic">Standardkalender wird automatisch erstellt</p>
{/if}
@ -239,16 +267,13 @@
{#if isAllDay}
<div class="flex flex-col gap-2">
<label for="displayMode" class="text-sm font-medium text-foreground">Anzeigeart</label>
<select
id="displayMode"
class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors"
bind:value={allDayDisplayMode}
>
<option value="default">Standard (aus Einstellungen)</option>
<option value="header">In Kopfzeile</option>
<option value="block">Als Tagesblock</option>
</select>
<span class="text-sm font-medium text-foreground">Anzeigeart</span>
<FilterDropdown
options={displayModeOptions}
value={allDayDisplayMode}
onChange={(v) => (allDayDisplayMode = (v as 'default' | 'header' | 'block') || 'default')}
placeholder="Anzeigeart wählen"
/>
</div>
{/if}
@ -406,6 +431,15 @@
</div>
{/if}
<!-- Verantwortliche Person -->
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-foreground">Verantwortliche Person</span>
<ResponsiblePersonSelector
{responsiblePerson}
onResponsiblePersonChange={(person) => (responsiblePerson = person)}
/>
</div>
<!-- Teilnehmer -->
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-foreground">Teilnehmer</span>

View file

@ -2,10 +2,26 @@
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toast } from '$lib/stores/toast';
import type { LocationDetails, CalendarEvent } from '@calendar/shared';
import { format, addMinutes, parseISO } from 'date-fns';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { toast } from '$lib/stores/toast.svelte';
import type {
LocationDetails,
CalendarEvent,
ResponsiblePerson,
EventAttendee,
} from '@calendar/shared';
import type { ContactSummary, ContactOrManual, ManualContactEntry } from '@manacore/shared-types';
import {
ContactSelector,
ContactAvatar,
ConfirmationPopover,
FilterDropdown,
type FilterDropdownOption,
} from '@manacore/shared-ui';
import { Users } from 'lucide-svelte';
import { format, addMinutes } from 'date-fns';
import { de } from 'date-fns/locale';
import { toDate } from '$lib/utils/eventDateHelpers';
import { tick, onMount, onDestroy } from 'svelte';
// Portal action - moves element to body to escape stacking contexts
@ -109,6 +125,13 @@
}
const target = e.target as HTMLElement;
// If target was removed from DOM by state change (e.g., button that toggles its own visibility),
// ignore the click to prevent false "outside" detection
if (!target.isConnected) {
return;
}
const overlay = document.querySelector('.quick-event-overlay');
const eventSelector = isEditMode
? `[data-event-id="${event!.id}"]`
@ -185,6 +208,26 @@
let locationCountry = $state('');
let submitting = $state(false);
// People state
let responsiblePerson = $state<ResponsiblePerson | null>(null);
let attendees = $state<EventAttendee[]>([]);
let showPeopleSelector = $state(false);
let contactsAvailable = $state<boolean | null>(null);
// All-day display mode options
const displayModeOptions: FilterDropdownOption[] = [
{ value: 'default', label: 'Standard (aus Einstellungen)' },
{ value: 'header', label: 'In Kopfzeile' },
{ value: 'block', label: 'Als Tagesblock' },
];
// Check contacts availability
$effect(() => {
contactsStore.checkAvailability().then((available) => {
contactsAvailable = available;
});
});
// Editable date/time strings (for form inputs)
let startDateStr = $state('');
let startTimeStr = $state('');
@ -212,10 +255,13 @@
locationCountry = loc.country || '';
}
// Initialize people
responsiblePerson = event.metadata?.responsiblePerson || null;
attendees = event.metadata?.attendees || [];
// Initialize time fields
const eventStart =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const eventStart = toDate(event.startTime);
const eventEnd = toDate(event.endTime);
startDateStr = format(eventStart, 'yyyy-MM-dd');
startTimeStr = format(eventStart, 'HH:mm');
endDateStr = format(eventEnd, 'yyyy-MM-dd');
@ -226,22 +272,22 @@
// Date/time fields - derive from draft event (create mode) or event (edit mode)
let draftStart = $derived(() => {
if (isEditMode && event) {
return typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
return toDate(event.startTime);
}
const draft = eventsStore.draftEvent;
if (draft) {
return typeof draft.startTime === 'string' ? parseISO(draft.startTime) : draft.startTime;
return toDate(draft.startTime);
}
return startTime || new Date();
});
let draftEnd = $derived(() => {
if (isEditMode && event) {
return typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
return toDate(event.endTime);
}
const draft = eventsStore.draftEvent;
if (draft) {
return typeof draft.endTime === 'string' ? parseISO(draft.endTime) : draft.endTime;
return toDate(draft.endTime);
}
return addMinutes(startTime || new Date(), settingsStore.defaultEventDuration);
});
@ -328,15 +374,6 @@
});
}
// Update draft when calendar changes
function handleCalendarChange(e: Event) {
const target = e.target as HTMLSelectElement;
calendarId = target.value;
if (!isEditMode) {
eventsStore.updateDraftEvent({ calendarId: target.value });
}
}
// Update draft when all-day changes
function handleAllDayToggle() {
isAllDay = !isAllDay;
@ -348,6 +385,112 @@
// Overlay style
let overlayStyle = $derived(`left: ${overlayPosition.left}px; top: ${overlayPosition.top}px;`);
// People helpers
function handleContactSearch(query: string): Promise<ContactSummary[]> {
return contactsStore.searchContacts(query);
}
function handleResponsiblePersonChange(contacts: ContactOrManual[]) {
if (contacts.length === 0) {
responsiblePerson = null;
return;
}
const contact = contacts[0];
if ('isManual' in contact && contact.isManual) {
const manual = contact as ManualContactEntry;
responsiblePerson = { email: manual.email, name: manual.name };
} else {
const ref = contact as {
contactId: string;
displayName: string;
email?: string;
photoUrl?: string;
company?: string;
};
responsiblePerson = {
email: ref.email || '',
name: ref.displayName,
contactId: ref.contactId,
photoUrl: ref.photoUrl,
company: ref.company,
};
}
}
function handleAttendeesChange(contacts: ContactOrManual[]) {
attendees = contacts.map((contact) => {
if ('isManual' in contact && contact.isManual) {
const manual = contact as ManualContactEntry;
const existing = attendees.find((a) => a.email === manual.email);
return {
email: manual.email,
name: manual.name,
status: existing?.status || ('pending' as const),
};
}
const ref = contact as {
contactId: string;
displayName: string;
email?: string;
photoUrl?: string;
company?: string;
};
const existing = attendees.find(
(a) => a.contactId === ref.contactId || a.email === ref.email
);
return {
email: ref.email || '',
name: ref.displayName,
status: existing?.status || ('pending' as const),
contactId: ref.contactId,
photoUrl: ref.photoUrl,
company: ref.company,
};
});
}
// Convert to ContactOrManual for selectors
const responsibleAsContact = $derived<ContactOrManual[]>(
responsiblePerson
? responsiblePerson.contactId
? [
{
contactId: responsiblePerson.contactId,
displayName: responsiblePerson.name || responsiblePerson.email,
email: responsiblePerson.email,
photoUrl: responsiblePerson.photoUrl,
company: responsiblePerson.company,
fetchedAt: new Date().toISOString(),
},
]
: [
{
email: responsiblePerson.email,
name: responsiblePerson.name,
isManual: true as const,
},
]
: []
);
const attendeesAsContacts = $derived<ContactOrManual[]>(
attendees.map((a) =>
a.contactId
? {
contactId: a.contactId,
displayName: a.name || a.email,
email: a.email,
photoUrl: a.photoUrl,
company: a.company,
fetchedAt: new Date().toISOString(),
}
: { email: a.email, name: a.name, isManual: true as const }
)
);
// Count of people assigned
const peopleCount = $derived((responsiblePerson ? 1 : 0) + attendees.length);
async function handleSubmit(e: Event) {
e.preventDefault();
if (!title.trim() || !calendarId) return;
@ -396,6 +539,20 @@
delete metadata.locationDetails;
}
// Add responsible person
if (responsiblePerson) {
metadata = { ...(metadata || {}), responsiblePerson };
} else if (metadata) {
delete metadata.responsiblePerson;
}
// Add attendees
if (attendees.length > 0) {
metadata = { ...(metadata || {}), attendees };
} else if (metadata) {
delete metadata.attendees;
}
// Clean up empty metadata
if (metadata && Object.keys(metadata).length === 0) {
metadata = undefined;
@ -423,11 +580,16 @@
onUpdated?.();
} else {
// Create new event
await eventsStore.createEvent(eventData);
const result = await eventsStore.createEvent(eventData);
if (result.error) {
toast.error(`Fehler beim Erstellen: ${result.error.message}`);
return;
}
// Refresh calendars if none existed (in case default was created)
if (calendarsStore.calendars.length === 0) {
await calendarsStore.fetchCalendars();
}
toast.success('Termin erstellt');
onCreated?.();
}
@ -443,10 +605,6 @@
async function handleDelete() {
if (!event) return;
if (!confirm('Möchten Sie diesen Termin wirklich löschen?')) {
return;
}
submitting = true;
try {
const result = await eventsStore.deleteEvent(event.id);
@ -490,22 +648,25 @@
<span class="header-title">{isEditMode ? 'Termin bearbeiten' : 'Neuer Termin'}</span>
<div class="header-actions">
{#if isEditMode}
<button
type="button"
class="delete-btn"
onclick={handleDelete}
disabled={submitting}
aria-label="Löschen"
<ConfirmationPopover
onConfirm={handleDelete}
variant="danger"
title="Termin löschen?"
confirmLabel="Löschen"
loading={submitting}
placement="bottom"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
<button type="button" class="delete-btn" disabled={submitting} aria-label="Löschen">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</ConfirmationPopover>
{/if}
<button type="button" class="close-btn" onclick={onClose} aria-label="Schließen">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -554,24 +715,118 @@
</span>
</div>
<!-- Calendar select -->
<!-- Calendar pills -->
<div class="calendar-pills-container">
{#if calendarsStore.calendars.length > 0}
<div class="calendar-pills-scroll">
{#each calendarsStore.calendars as cal}
<button
type="button"
class="calendar-pill"
class:active={calendarId === cal.id}
onclick={() => {
calendarId = cal.id;
if (!isEditMode) {
eventsStore.updateDraftEvent({ calendarId: cal.id });
}
}}
>
<span class="calendar-pill-dot" style="background-color: {cal.color || '#3b82f6'}"
></span>
<span class="calendar-pill-name">{cal.name}</span>
</button>
{/each}
</div>
{:else}
<span class="field-placeholder">Standardkalender wird erstellt</span>
{/if}
</div>
<!-- People (compact) -->
<div class="form-row">
<div class="row-icon">
<div
class="calendar-dot"
style="background-color: {calendarsStore.getColor(calendarId)}"
></div>
<Users class="icon" size={18} />
</div>
<div class="row-content">
<span class="field-label">Kalender</span>
{#if calendarsStore.calendars.length > 0}
<select class="field-select" value={calendarId} onchange={handleCalendarChange}>
{#each calendarsStore.calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
<!-- Responsible person - always show directly -->
<div class="people-subsection">
<span class="field-label">Verantwortlich</span>
{#if responsiblePerson}
<div class="person-chip">
<ContactAvatar
photoUrl={responsiblePerson.photoUrl}
name={responsiblePerson.name || responsiblePerson.email}
size="xs"
/>
<span class="person-name">{responsiblePerson.name || responsiblePerson.email}</span>
<button
type="button"
class="remove-person"
onclick={() => (responsiblePerson = null)}>×</button
>
</div>
{:else}
<ContactSelector
selectedContacts={[]}
onContactsChange={handleResponsiblePersonChange}
onSearch={handleContactSearch}
allowManualEntry={true}
placeholder="Person auswählen..."
addLabel="Auswählen"
searchPlaceholder="Name oder E-Mail..."
isAvailable={contactsAvailable ?? false}
singleSelect={true}
/>
{/if}
</div>
<!-- Attendees - show when expanded or when there are attendees -->
{#if showPeopleSelector || attendees.length > 0}
<div class="people-subsection">
<span class="field-label">Teilnehmer</span>
{#if attendees.length > 0}
<div class="people-chips">
{#each attendees as attendee (attendee.email)}
<div class="person-chip">
<ContactAvatar
photoUrl={attendee.photoUrl}
name={attendee.name || attendee.email}
size="xs"
/>
<span class="person-name">{attendee.name || attendee.email}</span>
<button
type="button"
class="remove-person"
onclick={() =>
(attendees = attendees.filter((a) => a.email !== attendee.email))}
>×</button
>
</div>
{/each}
</div>
{/if}
<ContactSelector
selectedContacts={attendeesAsContacts}
onContactsChange={handleAttendeesChange}
onSearch={handleContactSearch}
allowManualEntry={true}
placeholder={attendees.length > 0
? 'Weitere hinzufügen...'
: 'Teilnehmer hinzufügen...'}
addLabel="Hinzufügen"
searchPlaceholder="Name oder E-Mail..."
isAvailable={contactsAvailable ?? false}
/>
</div>
{:else}
<span class="field-placeholder">Standardkalender wird erstellt</span>
<!-- Show expand button for attendees -->
<button
type="button"
class="add-attendees-btn"
onclick={() => (showPeopleSelector = true)}
>
+ Teilnehmer hinzufügen
</button>
{/if}
</div>
</div>
@ -607,11 +862,13 @@
<div class="row-icon"></div>
<div class="row-content">
<span class="field-label">Anzeigeart</span>
<select class="field-select" bind:value={allDayDisplayMode}>
<option value="default">Standard (aus Einstellungen)</option>
<option value="header">In Kopfzeile</option>
<option value="block">Als Tagesblock</option>
</select>
<FilterDropdown
options={displayModeOptions}
value={allDayDisplayMode}
onChange={(v) =>
(allDayDisplayMode = (v as 'default' | 'header' | 'block') || 'default')}
placeholder="Anzeigeart wählen"
/>
</div>
</div>
{/if}
@ -823,12 +1080,9 @@
position: fixed;
width: 380px;
max-height: 450px;
background: hsl(var(--color-surface));
background: var(--color-surface-elevated-2);
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-lg);
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.2),
0 4px 16px rgba(0, 0, 0, 0.1);
z-index: 99999 !important;
display: flex;
flex-direction: column;
@ -982,6 +1236,63 @@
border-radius: 50%;
}
/* Calendar pills */
.calendar-pills-container {
padding: 0.5rem 0;
border-bottom: 1px solid hsl(var(--color-border));
}
.calendar-pills-scroll {
display: flex;
gap: 0.5rem;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
padding: 0 1.25rem 2px;
}
.calendar-pills-scroll::-webkit-scrollbar {
display: none;
}
.calendar-pill {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 9999px;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transition: all 150ms;
flex-shrink: 0;
}
.calendar-pill:hover {
background: hsl(var(--color-muted) / 0.3);
color: hsl(var(--color-foreground));
}
.calendar-pill.active {
background: hsl(var(--color-primary) / 0.1);
border-color: hsl(var(--color-primary) / 0.3);
color: hsl(var(--color-primary));
}
.calendar-pill-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.calendar-pill-name {
}
.row-content {
flex: 1;
min-width: 0;
@ -1010,7 +1321,6 @@
margin-bottom: 0.25rem;
}
.field-select,
.field-input {
width: 100%;
padding: 0.5rem 0.625rem;
@ -1021,7 +1331,6 @@
font-size: 0.875rem;
}
.field-select:focus,
.field-input:focus {
outline: none;
border-color: hsl(var(--color-primary));
@ -1073,14 +1382,17 @@
.overlay-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
background: var(--color-surface-elevated-2);
flex-shrink: 0;
}
.overlay-actions .btn-primary {
flex: 1;
}
.btn-ghost {
padding: 0.5rem 1rem;
border: none;
@ -1198,4 +1510,78 @@
.address-field.city {
flex: 1;
}
/* People section */
.add-attendees-btn {
margin-top: 0.5rem;
padding: 0.25rem 0;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
cursor: pointer;
transition: color 150ms;
text-align: left;
}
.add-attendees-btn:hover {
color: hsl(var(--color-primary));
}
.people-subsection {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.people-chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 0.25rem;
}
.person-chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem 0.25rem 0.25rem;
background: hsl(var(--color-muted) / 0.5);
border-radius: var(--radius-full);
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.person-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove-person {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 1rem;
line-height: 1;
cursor: pointer;
border-radius: var(--radius-full);
transition: all 150ms;
}
.remove-person:hover {
background: hsl(var(--color-error) / 0.1);
color: hsl(var(--color-error));
}
.people-subsection + .people-subsection {
margin-top: 0.75rem;
}
</style>

View file

@ -0,0 +1,216 @@
<script lang="ts">
import type { ResponsiblePerson } from '@calendar/shared';
import type { ContactSummary, ContactOrManual, ManualContactEntry } from '@manacore/shared-types';
import { ContactSelector, ContactAvatar } from '@manacore/shared-ui';
import { X, ExternalLink } from 'lucide-svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
interface Props {
responsiblePerson: ResponsiblePerson | null;
onResponsiblePersonChange: (person: ResponsiblePerson | null) => void;
disabled?: boolean;
}
let { responsiblePerson, onResponsiblePersonChange, disabled = false }: Props = $props();
let contactsAvailable = $state<boolean | null>(null);
let showSelector = $state(false);
// Check contacts availability on mount
$effect(() => {
contactsStore.checkAvailability().then((available) => {
contactsAvailable = available;
});
});
// Convert responsible person to ContactOrManual format for the selector
const selectedContacts = $derived<ContactOrManual[]>(
responsiblePerson
? responsiblePerson.contactId
? [
{
contactId: responsiblePerson.contactId,
displayName: responsiblePerson.name || responsiblePerson.email,
email: responsiblePerson.email,
photoUrl: responsiblePerson.photoUrl,
company: responsiblePerson.company,
fetchedAt: new Date().toISOString(),
},
]
: [
{
email: responsiblePerson.email,
name: responsiblePerson.name,
isManual: true as const,
},
]
: []
);
function handleContactsChange(contacts: ContactOrManual[]) {
if (contacts.length === 0) {
onResponsiblePersonChange(null);
showSelector = false;
return;
}
const contact = contacts[0];
if ('isManual' in contact && contact.isManual) {
// Manual entry
const manual = contact as ManualContactEntry;
onResponsiblePersonChange({
email: manual.email,
name: manual.name,
});
} else {
// Contact reference
const contactRef = contact as {
contactId: string;
displayName: string;
email?: string;
photoUrl?: string;
company?: string;
};
onResponsiblePersonChange({
email: contactRef.email || '',
name: contactRef.displayName,
contactId: contactRef.contactId,
photoUrl: contactRef.photoUrl,
company: contactRef.company,
});
}
showSelector = false;
}
function handleSearch(query: string): Promise<ContactSummary[]> {
return contactsStore.searchContacts(query);
}
function handleRemove() {
onResponsiblePersonChange(null);
}
function handleOpenContact() {
if (responsiblePerson?.contactId) {
// Open contacts app with this contact
window.open(`/contacts/${responsiblePerson.contactId}`, '_blank');
}
}
</script>
<div class="responsible-person-selector">
{#if responsiblePerson}
<!-- Selected Person Display -->
<div class="flex items-center gap-3 p-2 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<ContactAvatar
photoUrl={responsiblePerson.photoUrl}
name={responsiblePerson.name || responsiblePerson.email}
size="sm"
/>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">
{responsiblePerson.name || responsiblePerson.email}
</div>
{#if responsiblePerson.name && responsiblePerson.email}
<div class="text-xs text-muted-foreground truncate">
{responsiblePerson.email}
</div>
{/if}
{#if responsiblePerson.company}
<div class="text-xs text-muted-foreground truncate">
{responsiblePerson.company}
</div>
{/if}
</div>
<!-- Open Contact Button (only if linked to contact) -->
{#if responsiblePerson.contactId}
<button
type="button"
onclick={handleOpenContact}
class="
p-1.5 rounded-md
text-gray-400 hover:text-blue-500
hover:bg-blue-50 dark:hover:bg-blue-900/20
transition-colors
"
title="Kontakt öffnen"
>
<ExternalLink size={16} />
</button>
{/if}
<!-- Remove Button -->
<button
type="button"
onclick={handleRemove}
class="
p-1.5 rounded-md
text-gray-400 hover:text-red-500
hover:bg-red-50 dark:hover:bg-red-900/20
transition-colors
"
title="Entfernen"
{disabled}
>
<X size={16} />
</button>
</div>
{:else if showSelector}
<!-- Contact Selector -->
<ContactSelector
selectedContacts={[]}
onContactsChange={handleContactsChange}
onSearch={handleSearch}
allowManualEntry={true}
placeholder="Person suchen oder E-Mail eingeben..."
addLabel="Verantwortlich"
searchPlaceholder="Name oder E-Mail..."
isAvailable={contactsAvailable ?? false}
{disabled}
singleSelect={true}
/>
<button
type="button"
onclick={() => (showSelector = false)}
class="mt-2 text-sm text-muted-foreground hover:text-foreground"
>
Abbrechen
</button>
{:else}
<!-- Add Button -->
<button
type="button"
onclick={() => (showSelector = true)}
class="
w-full flex items-center justify-center gap-2
px-4 py-2.5 rounded-lg
border-2 border-dashed border-gray-200 dark:border-gray-700
text-sm text-muted-foreground
hover:border-gray-300 dark:hover:border-gray-600
hover:text-foreground
transition-colors
"
{disabled}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
Verantwortliche Person hinzufügen
</button>
{/if}
</div>
<style>
.responsible-person-selector {
position: relative;
}
</style>

View file

@ -0,0 +1,245 @@
<script lang="ts">
import { TagBadge, type Tag } from '@manacore/shared-ui';
import { CaretDown, CaretRight, Pencil, FolderSimple } from '@manacore/shared-icons';
import type { EventTag, EventTagGroup } from '@calendar/shared';
interface Props {
groups: EventTagGroup[];
tags: EventTag[];
ungroupedLabel?: string;
onEditTag: (tag: EventTag) => void;
onEditGroup?: (group: EventTagGroup) => void;
loading?: boolean;
emptyMessage?: string;
}
let {
groups,
tags,
ungroupedLabel = 'Ohne Gruppe',
onEditTag,
onEditGroup,
loading = false,
emptyMessage = 'Keine Tags vorhanden',
}: Props = $props();
// Track collapsed state (inverted - we track what's collapsed, not expanded)
// This way new groups are automatically expanded
let collapsedGroups = $state<Set<string | null>>(new Set());
function toggleGroup(groupId: string | null) {
const newSet = new Set(collapsedGroups);
if (newSet.has(groupId)) {
newSet.delete(groupId);
} else {
newSet.add(groupId);
}
collapsedGroups = newSet;
}
function isExpanded(groupId: string | null): boolean {
return !collapsedGroups.has(groupId);
}
// Get tags for a specific group
function getTagsForGroup(groupId: string | null): EventTag[] {
return tags.filter((t) => (t.groupId ?? null) === groupId);
}
// Convert EventTag to Tag for TagBadge
function toTag(eventTag: EventTag): Tag {
return {
id: eventTag.id,
name: eventTag.name,
color: eventTag.color,
};
}
// Get ungrouped tags
const ungroupedTags = $derived(getTagsForGroup(null));
const hasUngroupedTags = $derived(ungroupedTags.length > 0);
const totalTags = $derived(tags.length);
</script>
{#if loading}
<div class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
{:else if totalTags === 0}
<div class="text-center py-12">
<div class="text-muted-foreground mb-2">{emptyMessage}</div>
</div>
{:else}
<div class="space-y-2">
<!-- Groups with their tags -->
{#each groups as group (group.id)}
{@const groupTags = getTagsForGroup(group.id)}
{#if groupTags.length > 0}
<div class="group-section">
<!-- Group Header -->
<button type="button" onclick={() => toggleGroup(group.id)} class="group-header">
<div class="flex items-center gap-2">
{#if isExpanded(group.id)}
<CaretDown size={16} weight="bold" class="text-muted-foreground" />
{:else}
<CaretRight size={16} weight="bold" class="text-muted-foreground" />
{/if}
<div
class="w-3 h-3 rounded-full flex-shrink-0"
style="background-color: {group.color}"
></div>
<span class="font-medium">{group.name}</span>
<span class="text-xs text-muted-foreground">({groupTags.length})</span>
</div>
{#if onEditGroup}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
onEditGroup(group);
}}
class="edit-group-btn"
aria-label="Gruppe bearbeiten"
>
<Pencil size={14} />
</button>
{/if}
</button>
<!-- Tags in this group -->
{#if isExpanded(group.id)}
<div class="tags-container">
{#each groupTags as tag (tag.id)}
<button type="button" class="tag-pill" onclick={() => onEditTag(tag)}>
<TagBadge tag={toTag(tag)} />
<span class="edit-icon">
<Pencil size={12} />
</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
{/each}
<!-- Ungrouped tags -->
{#if hasUngroupedTags}
<div class="group-section">
<!-- Ungrouped Header -->
<button type="button" onclick={() => toggleGroup(null)} class="group-header">
<div class="flex items-center gap-2">
{#if isExpanded(null)}
<CaretDown size={16} weight="bold" class="text-muted-foreground" />
{:else}
<CaretRight size={16} weight="bold" class="text-muted-foreground" />
{/if}
<FolderSimple size={14} class="text-muted-foreground" />
<span class="font-medium text-muted-foreground">{ungroupedLabel}</span>
<span class="text-xs text-muted-foreground">({ungroupedTags.length})</span>
</div>
</button>
<!-- Ungrouped Tags -->
{#if isExpanded(null)}
<div class="tags-container">
{#each ungroupedTags as tag (tag.id)}
<button type="button" class="tag-pill" onclick={() => onEditTag(tag)}>
<TagBadge tag={toTag(tag)} />
<span class="edit-icon">
<Pencil size={12} />
</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<style>
.group-section {
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
overflow: hidden;
background: hsl(var(--card));
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
background: hsl(var(--muted) / 0.3);
border: none;
cursor: pointer;
transition: background 0.2s ease;
text-align: left;
}
.group-header:hover {
background: hsl(var(--muted) / 0.5);
}
.edit-group-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem;
border-radius: 0.375rem;
color: hsl(var(--muted-foreground));
background: transparent;
border: none;
cursor: pointer;
opacity: 0;
transition: all 0.2s ease;
}
.group-header:hover .edit-group-btn {
opacity: 1;
}
.edit-group-btn:hover {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
}
.tags-container {
padding: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0;
padding-right: 0.5rem;
border: none;
background: transparent;
cursor: pointer;
border-radius: 9999px;
transition: all 0.15s ease;
}
.tag-pill:hover {
background: hsl(var(--muted) / 0.5);
}
.edit-icon {
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--muted-foreground));
opacity: 0;
transition: opacity 0.15s ease;
}
.tag-pill:hover .edit-icon {
opacity: 1;
}
</style>

View file

@ -0,0 +1,123 @@
<script lang="ts">
import { Modal, Input, TagColorPicker, TagBadge } from '@manacore/shared-ui';
import type { EventTagGroup } from '@calendar/shared';
interface Props {
group?: EventTagGroup | null;
isOpen: boolean;
onClose: () => void;
onSave: (name: string, color: string) => void;
onDelete?: () => void;
}
let { group = null, isOpen, onClose, onSave, onDelete }: Props = $props();
const DEFAULT_COLOR = '#3B82F6';
let name = $state(group?.name ?? '');
let color = $state(group?.color ?? DEFAULT_COLOR);
// Reset form when group changes
$effect(() => {
if (isOpen) {
name = group?.name ?? '';
color = group?.color ?? DEFAULT_COLOR;
}
});
function handleSave() {
if (name.trim()) {
onSave(name.trim(), color);
}
}
function handleDelete() {
if (
onDelete &&
confirm(
`Gruppe "${group?.name}" wirklich löschen? Tags in dieser Gruppe werden nicht gelöscht.`
)
) {
onDelete();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' && name.trim()) {
e.preventDefault();
handleSave();
}
}
const previewTag = $derived({ name: name || 'Gruppenname', color });
const isEditing = $derived(!!group);
</script>
<Modal
visible={isOpen}
{onClose}
title={isEditing ? 'Gruppe bearbeiten' : 'Neue Gruppe'}
maxWidth="sm"
>
<div class="space-y-6">
<!-- Name Input -->
<div>
<Input bind:value={name} placeholder="Gruppenname" onkeydown={handleKeyDown} />
</div>
<!-- Color Picker -->
<div>
<span class="block text-sm font-medium text-muted-foreground mb-3"> Farbe </span>
<TagColorPicker selectedColor={color} onColorChange={(c) => (color = c)} />
</div>
<!-- Preview -->
<div>
<span class="block text-sm font-medium text-muted-foreground mb-3"> Vorschau </span>
<div class="flex items-center gap-2">
<TagBadge tag={previewTag} />
</div>
</div>
<!-- Tag Count Info (only when editing) -->
{#if isEditing && group?.tagCount !== undefined && group.tagCount > 0}
<div class="text-sm text-muted-foreground">
{group.tagCount}
{group.tagCount === 1 ? 'Tag' : 'Tags'} in dieser Gruppe
</div>
{/if}
</div>
{#snippet footer()}
<div class="flex items-center justify-between">
<div>
{#if onDelete && isEditing}
<button
type="button"
onclick={handleDelete}
class="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
Löschen
</button>
{/if}
</div>
<div class="flex items-center gap-3">
<button
type="button"
onclick={onClose}
class="px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/10 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="button"
onclick={handleSave}
disabled={!name.trim()}
class="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
{isEditing ? 'Speichern' : 'Erstellen'}
</button>
</div>
</div>
{/snippet}
</Modal>

View file

@ -0,0 +1,2 @@
export { default as TagGroupEditModal } from './TagGroupEditModal.svelte';
export { default as GroupedTagList } from './GroupedTagList.svelte';

View file

@ -2,7 +2,7 @@
import { todosStore } from '$lib/stores/todos.svelte';
import type { Task, UpdateTaskInput, TaskPriority } from '$lib/api/todos';
import { PRIORITY_LABELS, PRIORITY_COLORS } from '$lib/api/todos';
import { toast } from '$lib/stores/toast';
import { toast } from '$lib/stores/toast.svelte';
import TodoCheckbox from './TodoCheckbox.svelte';
import PriorityBadge from './PriorityBadge.svelte';
import {

View file

@ -3,6 +3,32 @@
* Reusable logic extracted from components
*/
// Visible hours and time indicator
export { useVisibleHours, useCurrentTimeIndicator } from './useVisibleHours.svelte';
// Event drag/drop and resize (comprehensive composable)
export {
useEventDragDrop,
type EventDragDropConfig,
type EventDragState,
type EventResizeState,
} from './useEventDragDrop.svelte';
// Task drag/drop and resize
export { useTaskDragDrop, type TaskDragDropConfig } from './useTaskDragDrop.svelte';
// Sidebar task drop handling
export { useSidebarDrop, type SidebarDropConfig } from './useSidebarDrop.svelte';
// Keyboard handling
export { useCalendarKeyboard, type CancellableOperation } from './useCalendarKeyboard.svelte';
// 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';
export { useTaskDragDrop } from './useTaskDragDrop.svelte';

View file

@ -0,0 +1,49 @@
/**
* Birthday Popover Composable
* Manages birthday popover state and handlers for calendar views
*/
import type { BirthdayEvent } from '$lib/api/birthdays';
export function useBirthdayPopover() {
let selectedBirthday = $state<BirthdayEvent | null>(null);
let popoverPosition = $state<{ x: number; y: number }>({ x: 0, y: 0 });
/**
* Handle click on a birthday indicator to show the popover
*/
function handleBirthdayClick(birthday: BirthdayEvent, e: MouseEvent) {
e.stopPropagation();
selectedBirthday = birthday;
popoverPosition = { x: e.clientX, y: e.clientY };
}
/**
* Close the birthday popover
*/
function closePopover() {
selectedBirthday = null;
}
/**
* Check if popover is currently open
*/
function isOpen(): boolean {
return selectedBirthday !== null;
}
return {
// State (reactive getters)
get selectedBirthday() {
return selectedBirthday;
},
get popoverPosition() {
return popoverPosition;
},
// Methods
handleBirthdayClick,
closePopover,
isOpen,
};
}

View file

@ -0,0 +1,41 @@
/**
* Calendar Keyboard Handling Composable
* Handles keyboard shortcuts for calendar views (e.g., Escape to cancel drag/resize)
*/
export interface CancellableOperation {
/** Check if operation is active */
isActive: () => boolean;
/** Cancel the operation */
cancel: () => void;
}
/**
* Creates a keyboard handler that cancels operations on Escape key
* Automatically sets up and cleans up the event listener via $effect
*
* @param operations - Array of operations that can be cancelled (e.g., drag/drop, resize)
*/
export function useCalendarKeyboard(operations: CancellableOperation[]) {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
// Check if any operation is active
const activeOperation = operations.find((op) => op.isActive());
if (activeOperation) {
e.preventDefault();
activeOperation.cancel();
}
}
}
// Setup listener - call this in $effect
function setup() {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
return {
setup,
handleKeyDown,
};
}

View file

@ -4,7 +4,8 @@
*/
import type { CalendarEvent } from '@calendar/shared';
import { parseISO, differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns';
import { differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns';
import { toDate } from '$lib/utils/eventDateHelpers';
import { eventsStore } from '$lib/stores/events.svelte';
export interface DragDropConfig {
@ -107,8 +108,8 @@ export function useDragDrop(getConfig: () => DragDropConfig) {
draggedEvent = event;
hasMoved = false;
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const start = toDate(event.startTime);
const end = toDate(event.endTime);
const duration = differenceInMinutes(end, start);
// Calculate initial preview position
@ -158,14 +159,8 @@ export function useDragDrop(getConfig: () => DragDropConfig) {
}
const config = getConfig();
const start =
typeof draggedEvent.startTime === 'string'
? parseISO(draggedEvent.startTime)
: draggedEvent.startTime;
const end =
typeof draggedEvent.endTime === 'string'
? parseISO(draggedEvent.endTime)
: draggedEvent.endTime;
const start = toDate(draggedEvent.startTime);
const end = toDate(draggedEvent.endTime);
const duration = differenceInMinutes(end, start);
// Calculate new start time

View file

@ -0,0 +1,427 @@
/**
* Event Drag & Drop + Resize Composable
* Extracts duplicated drag/resize logic from WeekView, DayView, MultiDayView
*/
import type { CalendarEvent } from '@calendar/shared';
import { differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns';
import { toDate } from '$lib/utils/eventDateHelpers';
import { eventsStore } from '$lib/stores/events.svelte';
import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants';
export interface EventDragDropConfig {
/** Reference to the container element for position calculations */
containerEl: HTMLElement | null;
/** Array of visible days (for multi-day views) or single day (for day view) */
days: Date[];
/** First visible hour (for filtered hours mode) */
firstVisibleHour: number;
/** Last visible hour (for filtered hours mode) */
lastVisibleHour: number;
/** Total visible hours */
totalVisibleHours: number;
/** Height of one hour in pixels */
hourHeight: number;
/** Minutes per snap interval (default: 15) */
snapMinutes?: number;
/** Function to convert minutes to percentage position */
minutesToPercent: (minutes: number) => number;
}
export interface EventDragState {
isDragging: boolean;
draggedEvent: CalendarEvent | null;
dragTargetDay: Date | null;
dragPreviewTop: number;
dragPreviewHeight: number;
hasMoved: boolean;
}
export interface EventResizeState {
isResizing: boolean;
resizeEvent: CalendarEvent | null;
resizeEdge: 'top' | 'bottom';
resizePreviewTop: number;
resizePreviewHeight: number;
}
export function useEventDragDrop(getConfig: () => EventDragDropConfig) {
// ========== Drag State ==========
let isDragging = $state(false);
let draggedEvent = $state<CalendarEvent | null>(null);
let dragOffsetMinutes = $state(0);
let dragTargetDay = $state<Date | null>(null);
let dragPreviewTop = $state(0);
let dragPreviewHeight = $state(0);
// ========== Resize State ==========
let isResizing = $state(false);
let resizeEvent = $state<CalendarEvent | null>(null);
let resizeEdge = $state<'top' | 'bottom'>('bottom');
let resizeOriginalStart = $state<Date | null>(null);
let resizeOriginalEnd = $state<Date | null>(null);
let resizePreviewTop = $state(0);
let resizePreviewHeight = $state(0);
let resizeOffsetMinutes = $state(0);
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
let hasMoved = $state(false);
// ========== Helper Functions ==========
function getSnapMinutes(): number {
return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES;
}
function snapToGrid(minutes: number): number {
const snap = getSnapMinutes();
return Math.round(minutes / snap) * snap;
}
/**
* Get day from X coordinate (for multi-day views)
*/
function getDayFromX(clientX: number): Date | null {
const config = getConfig();
if (!config.containerEl) return null;
const rect = config.containerEl.getBoundingClientRect();
const relativeX = clientX - rect.left;
const dayWidth = rect.width / config.days.length;
const dayIndex = Math.floor(relativeX / dayWidth);
if (dayIndex >= 0 && dayIndex < config.days.length) {
return config.days[dayIndex];
}
return null;
}
/**
* Get minutes from Y coordinate
*/
function getMinutesFromY(clientY: number): number {
const config = getConfig();
if (!config.containerEl) return 0;
const rect = config.containerEl.getBoundingClientRect();
const scrollTop = config.containerEl.parentElement?.scrollTop || 0;
const relativeY = clientY - rect.top + scrollTop;
// Account for hidden early hours
const visibleMinutes =
(relativeY / (config.totalVisibleHours * config.hourHeight)) * config.totalVisibleHours * 60;
const totalMinutes = visibleMinutes + config.firstVisibleHour * 60;
// Snap to interval
return snapToGrid(totalMinutes);
}
// ========== Drag Functions ==========
function startDrag(event: CalendarEvent, e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
const config = getConfig();
isDragging = true;
draggedEvent = event;
hasMoved = false;
const start = toDate(event.startTime);
const end = toDate(event.endTime);
const duration = differenceInMinutes(end, start);
// Calculate initial preview position
const startMinutes = start.getHours() * 60 + start.getMinutes();
dragPreviewTop = config.minutesToPercent(startMinutes);
dragPreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
dragTargetDay = start;
// Calculate offset from event start to click position
const clickMinutes = getMinutesFromY(e.clientY);
dragOffsetMinutes = clickMinutes - startMinutes;
document.addEventListener('pointermove', handleDragMove);
document.addEventListener('pointerup', handleDragEnd);
}
function handleDragMove(e: PointerEvent) {
if (!isDragging || !draggedEvent) return;
const config = getConfig();
hasMoved = true;
// Calculate new position
const newDay = getDayFromX(e.clientX);
const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes;
// Clamp to valid range
const clampedMinutes = Math.max(
config.firstVisibleHour * 60,
Math.min(config.lastVisibleHour * 60 - 15, newMinutes)
);
// Update preview
dragPreviewTop = config.minutesToPercent(clampedMinutes);
if (newDay) {
dragTargetDay = newDay;
}
}
async function handleDragEnd(e: PointerEvent) {
document.removeEventListener('pointermove', handleDragMove);
document.removeEventListener('pointerup', handleDragEnd);
if (!isDragging || !draggedEvent || !dragTargetDay || !hasMoved) {
cleanupDrag();
return;
}
const start = toDate(draggedEvent.startTime);
const end = toDate(draggedEvent.endTime);
const duration = differenceInMinutes(end, start);
// Calculate new start time
const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes;
const clampedMinutes = Math.max(0, Math.min(24 * 60 - 15, newMinutes));
const newHours = Math.floor(clampedMinutes / 60);
const newMins = clampedMinutes % 60;
let newStart = new Date(dragTargetDay);
newStart = setHours(newStart, newHours);
newStart = setMinutes(newStart, newMins);
const newEnd = addMinutes(newStart, duration);
// Update event via store
if (eventsStore.isDraftEvent(draggedEvent.id)) {
eventsStore.updateDraftEvent({
startTime: newStart.toISOString(),
endTime: newEnd.toISOString(),
});
} else {
await eventsStore.updateEvent(draggedEvent.id, {
startTime: newStart.toISOString(),
endTime: newEnd.toISOString(),
});
}
cleanupDrag();
}
function cleanupDrag() {
isDragging = false;
draggedEvent = null;
dragTargetDay = null;
hasMoved = false;
}
// ========== Resize Functions ==========
function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
const config = getConfig();
isResizing = true;
resizeEvent = event;
resizeEdge = edge;
hasMoved = false;
const start = toDate(event.startTime);
const end = toDate(event.endTime);
resizeOriginalStart = start;
resizeOriginalEnd = end;
// Set initial preview
const startMinutes = start.getHours() * 60 + start.getMinutes();
const endMinutes = end.getHours() * 60 + end.getMinutes();
const duration = differenceInMinutes(end, start);
resizePreviewTop = config.minutesToPercent(startMinutes);
resizePreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
// Calculate offset between snapped click position and actual event boundary
const clickMinutes = getMinutesFromY(e.clientY);
if (edge === 'top') {
resizeOffsetMinutes = clickMinutes - startMinutes;
} else {
resizeOffsetMinutes = clickMinutes - endMinutes;
}
document.addEventListener('pointermove', handleResizeMove);
document.addEventListener('pointerup', handleResizeEnd);
}
function handleResizeMove(e: PointerEvent) {
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return;
const config = getConfig();
hasMoved = true;
const currentMinutes = getMinutesFromY(e.clientY);
// Apply offset to prevent jumping when drag starts
const adjustedMinutes = currentMinutes - resizeOffsetMinutes;
const originalStartMinutes =
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
if (resizeEdge === 'bottom') {
// Resize from bottom - change end time
const newEndMinutes = Math.max(
originalStartMinutes + 15,
Math.min(config.lastVisibleHour * 60, adjustedMinutes)
);
const newDuration = newEndMinutes - originalStartMinutes;
resizePreviewHeight = (newDuration / (config.totalVisibleHours * 60)) * 100;
} else {
// Resize from top - change start time
const newStartMinutes = Math.max(
config.firstVisibleHour * 60,
Math.min(originalEndMinutes - 15, adjustedMinutes)
);
const newDuration = originalEndMinutes - newStartMinutes;
resizePreviewTop = config.minutesToPercent(newStartMinutes);
resizePreviewHeight = (newDuration / (config.totalVisibleHours * 60)) * 100;
}
}
async function handleResizeEnd(e: PointerEvent) {
document.removeEventListener('pointermove', handleResizeMove);
document.removeEventListener('pointerup', handleResizeEnd);
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd || !hasMoved) {
cleanupResize();
return;
}
const config = getConfig();
const currentMinutes = getMinutesFromY(e.clientY);
// Apply offset to prevent jumping
const adjustedMinutes = currentMinutes - resizeOffsetMinutes;
const originalStartMinutes =
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
let newStart = resizeOriginalStart;
let newEnd = resizeOriginalEnd;
if (resizeEdge === 'bottom') {
const newEndMinutes = Math.max(
originalStartMinutes + 15,
Math.min(config.lastVisibleHour * 60, adjustedMinutes)
);
const newHours = Math.floor(newEndMinutes / 60);
const newMins = newEndMinutes % 60;
newEnd = setHours(new Date(resizeOriginalEnd), newHours);
newEnd = setMinutes(newEnd, newMins);
} else {
const newStartMinutes = Math.max(
config.firstVisibleHour * 60,
Math.min(originalEndMinutes - 15, adjustedMinutes)
);
const newHours = Math.floor(newStartMinutes / 60);
const newMins = newStartMinutes % 60;
newStart = setHours(new Date(resizeOriginalStart), newHours);
newStart = setMinutes(newStart, newMins);
}
// Update event via store
if (eventsStore.isDraftEvent(resizeEvent.id)) {
eventsStore.updateDraftEvent({
startTime: newStart.toISOString(),
endTime: newEnd.toISOString(),
});
} else {
await eventsStore.updateEvent(resizeEvent.id, {
startTime: newStart.toISOString(),
endTime: newEnd.toISOString(),
});
}
cleanupResize();
}
function cleanupResize() {
isResizing = false;
resizeEvent = null;
resizeOriginalStart = null;
resizeOriginalEnd = null;
resizeOffsetMinutes = 0;
hasMoved = false;
}
// ========== Combined Cleanup ==========
function cleanup() {
document.removeEventListener('pointermove', handleDragMove);
document.removeEventListener('pointerup', handleDragEnd);
document.removeEventListener('pointermove', handleResizeMove);
document.removeEventListener('pointerup', handleResizeEnd);
cleanupDrag();
cleanupResize();
}
/**
* Cancel any active drag/resize operation (e.g., on Escape key)
*/
function cancel() {
if (isDragging || isResizing) {
cleanup();
}
}
return {
// Drag state (reactive getters)
get isDragging() {
return isDragging;
},
get draggedEvent() {
return draggedEvent;
},
get dragTargetDay() {
return dragTargetDay;
},
get dragPreviewTop() {
return dragPreviewTop;
},
get dragPreviewHeight() {
return dragPreviewHeight;
},
// Resize state (reactive getters)
get isResizing() {
return isResizing;
},
get resizeEvent() {
return resizeEvent;
},
get resizeEdge() {
return resizeEdge;
},
get resizePreviewTop() {
return resizePreviewTop;
},
get resizePreviewHeight() {
return resizePreviewHeight;
},
// Shared state
get hasMoved() {
return hasMoved;
},
// Reset hasMoved after click handling
resetHasMoved() {
hasMoved = false;
},
// Methods
startDrag,
startResize,
cancel,
cleanup,
};
}

View file

@ -4,7 +4,8 @@
*/
import type { CalendarEvent } from '@calendar/shared';
import { parseISO, differenceInMinutes, setHours, setMinutes } from 'date-fns';
import { differenceInMinutes, setHours, setMinutes } from 'date-fns';
import { toDate } from '$lib/utils/eventDateHelpers';
import { eventsStore } from '$lib/stores/events.svelte';
export interface ResizeConfig {
@ -86,8 +87,8 @@ export function useResize(getConfig: () => ResizeConfig) {
resizeEdge = edge;
hasMoved = false;
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const start = toDate(event.startTime);
const end = toDate(event.endTime);
resizeOriginalStart = start;
resizeOriginalEnd = end;

View file

@ -0,0 +1,131 @@
/**
* Sidebar Task Drop Composable
* Handles dropping tasks from sidebar into calendar day columns
*/
import { todosStore } from '$lib/stores/todos.svelte';
import { format } from 'date-fns';
import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants';
export interface SidebarDropConfig {
/** First visible hour (for filtered hours mode) */
firstVisibleHour: number;
/** Total visible hours */
totalVisibleHours: number;
/** Minutes per snap interval (default: 15) */
snapMinutes?: number;
}
export function useSidebarDrop(getConfig: () => SidebarDropConfig) {
// Track active drop target (for visual feedback)
let dropTarget = $state<{ day: Date; y: number } | null>(null);
function getSnapMinutes(): number {
return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES;
}
function formatTime(hours: number, minutes: number): string {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}
/**
* Handle dragover event on a day column
*/
function handleDragOver(e: DragEvent, day: Date) {
e.preventDefault();
if (!e.dataTransfer) return;
// Check if this is a sidebar task drag
const types = e.dataTransfer.types;
if (!types.includes('application/json')) return;
e.dataTransfer.dropEffect = 'move';
dropTarget = { day, y: e.clientY };
}
/**
* Handle dragleave event
*/
function handleDragLeave(e: DragEvent) {
// Only clear if leaving the column entirely
const relatedTarget = e.relatedTarget as HTMLElement;
if (!relatedTarget?.closest('.day-column')) {
dropTarget = null;
}
}
/**
* Handle drop event on a day column
*/
async function handleDrop(e: DragEvent, day: Date) {
e.preventDefault();
dropTarget = null;
if (!e.dataTransfer) return;
const jsonData = e.dataTransfer.getData('application/json');
if (!jsonData) return;
try {
const data = JSON.parse(jsonData);
if (data.type !== 'sidebar-task') return;
const config = getConfig();
// Calculate drop time from Y position
const dayColumn = (e.target as HTMLElement).closest('.day-column');
if (!dayColumn) return;
const rect = dayColumn.getBoundingClientRect();
const relativeY = e.clientY - rect.top;
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
const minutesPerPercent = (config.totalVisibleHours * 60) / 100;
const rawMinutes = percentY * minutesPerPercent;
const snapMinutes = getSnapMinutes();
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
const totalMinutes = config.firstVisibleHour * 60 + snappedMinutes;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const startTime = formatTime(hours, minutes);
// Calculate end time
const duration = data.estimatedDuration || 30;
const endMinutes = totalMinutes + duration;
const endHours = Math.floor(endMinutes / 60);
const endMins = endMinutes % 60;
const endTime = formatTime(endHours, endMins);
// Update the task with scheduled time
await todosStore.updateTodo(data.taskId, {
scheduledDate: format(day, 'yyyy-MM-dd'),
scheduledStartTime: startTime,
scheduledEndTime: endTime,
estimatedDuration: duration,
});
} catch (err) {
console.error('Failed to parse drop data:', err);
}
}
/**
* Clear drop target (use when component unmounts or for manual cleanup)
*/
function clearDropTarget() {
dropTarget = null;
}
return {
// State (reactive getter)
get dropTarget() {
return dropTarget;
},
// Methods
handleDragOver,
handleDragLeave,
handleDrop,
clearDropTarget,
};
}

View file

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

View file

@ -1,306 +1,321 @@
/**
* Composable for Task Drag & Drop in Calendar Views
* Handles dragging tasks to reschedule and resizing to change duration
* Task Drag & Drop + Resize Composable
* Extracts duplicated task drag/resize logic from WeekView, DayView, MultiDayView
*
* Uses document-level event listeners for smooth drag operations across the entire screen.
*/
import type { Task, UpdateTaskInput } from '$lib/api/todos';
import type { Task } from '$lib/stores/todos.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import { format, parseISO, addMinutes, differenceInMinutes, setHours, setMinutes } from 'date-fns';
import { format } from 'date-fns';
import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants';
const SNAP_MINUTES = 15;
interface UseTaskDragDropOptions {
/** Minimum snap interval in minutes */
export interface TaskDragDropConfig {
/** Reference to the container element for position calculations */
containerEl: HTMLElement | null;
/** Array of visible days (for multi-day views) or single day (for day view) */
days: Date[];
/** First visible hour (for filtered hours mode) */
firstVisibleHour: number;
/** Total visible hours */
totalVisibleHours: number;
/** Minutes per snap interval (default: 15) */
snapMinutes?: number;
/** Callback when task is updated */
onTaskUpdate?: (task: Task) => void;
}
export function useTaskDragDrop(options: UseTaskDragDropOptions = {}) {
const snapMinutes = options.snapMinutes ?? SNAP_MINUTES;
// Drag state
let isDragging = $state(false);
export function useTaskDragDrop(getConfig: () => TaskDragDropConfig) {
// ========== Drag State ==========
let isTaskDragging = $state(false);
let draggedTask = $state<Task | null>(null);
let dragStartY = $state(0);
let dragTargetDay = $state<Date | null>(null);
let dragPreviewTop = $state(0);
let dragPreviewHeight = $state(0);
let taskDragTargetDay = $state<Date | null>(null);
let taskDragPreviewTop = $state(0);
let taskDragPreviewHeight = $state(0);
// Resize state
let isResizing = $state(false);
// ========== Resize State ==========
let isTaskResizing = $state(false);
let resizeTask = $state<Task | null>(null);
let resizeEdge = $state<'top' | 'bottom'>('bottom');
let resizeStartY = $state(0);
let resizePreviewTop = $state(0);
let resizePreviewHeight = $state(0);
let taskResizeEdge = $state<'top' | 'bottom'>('bottom');
let taskResizePreviewTop = $state(0);
let taskResizePreviewHeight = $state(0);
// Track if we actually moved
// Track if we actually moved during drag/resize
let hasMoved = $state(false);
/**
* Start dragging a task
*/
function startDrag(
task: Task,
e: PointerEvent,
gridElement: HTMLElement,
firstVisibleHour: number,
totalVisibleHours: number
) {
// ========== Helper Functions ==========
function getSnapMinutes(): number {
return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES;
}
function formatTime(hours: number, minutes: number): string {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}
// ========== Drag Functions ==========
function startDrag(task: Task, e: PointerEvent) {
e.preventDefault();
isDragging = true;
const config = getConfig();
isTaskDragging = true;
draggedTask = task;
dragStartY = e.clientY;
hasMoved = false;
// Calculate initial position
// Initialize preview position from task's current time
if (task.scheduledStartTime) {
const [h, m] = task.scheduledStartTime.split(':').map(Number);
const startMinutes = h * 60 + m - firstVisibleHour * 60;
dragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
const startMinutes = h * 60 + m - config.firstVisibleHour * 60;
taskDragPreviewTop = (startMinutes / (config.totalVisibleHours * 60)) * 100;
}
// Calculate height from duration
const duration = task.estimatedDuration || 30;
dragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
taskDragPreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
// Capture pointer
(e.target as HTMLElement).setPointerCapture(e.pointerId);
document.addEventListener('pointermove', handleDragMove);
document.addEventListener('pointerup', handleDragEnd);
}
/**
* Handle drag move
*/
function onDragMove(
e: PointerEvent,
gridElement: HTMLElement,
day: Date,
firstVisibleHour: number,
totalVisibleHours: number
) {
if (!isDragging || !draggedTask) return;
function handleDragMove(e: PointerEvent) {
if (!isTaskDragging || !draggedTask) return;
const config = getConfig();
hasMoved = true;
dragTargetDay = day;
const rect = gridElement.getBoundingClientRect();
// Find which day column we're over
if (config.containerEl) {
const dayColumns = config.containerEl.querySelectorAll('.day-column');
for (let i = 0; i < dayColumns.length; i++) {
const col = dayColumns[i];
const rect = col.getBoundingClientRect();
if (e.clientX >= rect.left && e.clientX <= rect.right) {
taskDragTargetDay = config.days[i];
break;
}
}
}
// Calculate vertical position
const targetColumn = config.containerEl?.querySelector('.day-column');
if (!targetColumn) return;
const rect = targetColumn.getBoundingClientRect();
const relativeY = e.clientY - rect.top;
const percentY = (relativeY / rect.height) * 100;
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
// Snap to intervals
const minutesPerPercent = (totalVisibleHours * 60) / 100;
const rawMinutes = percentY * minutesPerPercent + firstVisibleHour * 60;
const minutesPerPercent = (config.totalVisibleHours * 60) / 100;
const rawMinutes = percentY * minutesPerPercent;
const snapMinutes = getSnapMinutes();
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
dragPreviewTop = ((snappedMinutes - firstVisibleHour * 60) / (totalVisibleHours * 60)) * 100;
taskDragPreviewTop = (snappedMinutes / (config.totalVisibleHours * 60)) * 100;
}
/**
* End drag and update task
*/
async function endDrag(firstVisibleHour: number, totalVisibleHours: number) {
if (!isDragging || !draggedTask || !hasMoved) {
isDragging = false;
draggedTask = null;
dragTargetDay = null;
async function handleDragEnd() {
document.removeEventListener('pointermove', handleDragMove);
document.removeEventListener('pointerup', handleDragEnd);
if (!isTaskDragging || !draggedTask || !hasMoved) {
cleanupDrag();
return;
}
// Calculate new time from position
const minutesFromMidnight =
(dragPreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
const hours = Math.floor(minutesFromMidnight / 60);
const minutes = Math.round(minutesFromMidnight % 60);
const config = getConfig();
const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
// Calculate new time from position
const minutesFromStart = (taskDragPreviewTop / 100) * (config.totalVisibleHours * 60);
const totalMinutes = config.firstVisibleHour * 60 + minutesFromStart;
const hours = Math.floor(totalMinutes / 60);
const minutes = Math.round(totalMinutes % 60);
const newStartTime = formatTime(hours, minutes);
// Calculate end time based on duration
const duration = draggedTask.estimatedDuration || 30;
const endMinutes = minutesFromMidnight + duration;
const endHours = Math.floor(endMinutes / 60);
const endMins = Math.round(endMinutes % 60);
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
const endTotalMinutes = totalMinutes + duration;
const endHours = Math.floor(endTotalMinutes / 60);
const endMins = Math.round(endTotalMinutes % 60);
const newEndTime = formatTime(endHours, endMins);
const updateData: UpdateTaskInput = {
scheduledDate: dragTargetDay
? format(dragTargetDay, 'yyyy-MM-dd')
: draggedTask.scheduledDate,
await todosStore.updateTodo(draggedTask.id, {
scheduledDate: taskDragTargetDay ? format(taskDragTargetDay, 'yyyy-MM-dd') : undefined,
scheduledStartTime: newStartTime,
scheduledEndTime: newEndTime,
};
});
const result = await todosStore.updateTodo(draggedTask.id, updateData);
if (result.data) {
options.onTaskUpdate?.(result.data);
}
cleanupDrag();
}
isDragging = false;
function cleanupDrag() {
isTaskDragging = false;
draggedTask = null;
dragTargetDay = null;
taskDragTargetDay = null;
hasMoved = false;
}
/**
* Start resizing a task
*/
function startResize(
task: Task,
edge: 'top' | 'bottom',
e: PointerEvent,
firstVisibleHour: number,
totalVisibleHours: number
) {
// ========== Resize Functions ==========
function startResize(task: Task, edge: 'top' | 'bottom', e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
isResizing = true;
const config = getConfig();
isTaskResizing = true;
resizeTask = task;
resizeEdge = edge;
resizeStartY = e.clientY;
taskResizeEdge = edge;
hasMoved = false;
// Initialize preview position
if (task.scheduledStartTime) {
const [h, m] = task.scheduledStartTime.split(':').map(Number);
const startMinutes = h * 60 + m - firstVisibleHour * 60;
resizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
const startMinutes = h * 60 + m - config.firstVisibleHour * 60;
taskResizePreviewTop = (startMinutes / (config.totalVisibleHours * 60)) * 100;
}
const duration = task.estimatedDuration || 30;
resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
taskResizePreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
document.addEventListener('pointermove', handleResizeMove);
document.addEventListener('pointerup', handleResizeEnd);
}
/**
* Handle resize move
*/
function onResizeMove(
e: PointerEvent,
gridElement: HTMLElement,
firstVisibleHour: number,
totalVisibleHours: number
) {
if (!isResizing || !resizeTask) return;
function handleResizeMove(e: PointerEvent) {
if (!isTaskResizing || !resizeTask) return;
const config = getConfig();
hasMoved = true;
const rect = gridElement.getBoundingClientRect();
const targetColumn = config.containerEl?.querySelector('.day-column');
if (!targetColumn) return;
const rect = targetColumn.getBoundingClientRect();
const relativeY = e.clientY - rect.top;
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
const minutesPerPercent = (totalVisibleHours * 60) / 100;
const minutesPerPercent = (config.totalVisibleHours * 60) / 100;
const snapMinutes = getSnapMinutes();
if (resizeEdge === 'top') {
if (taskResizeEdge === 'top') {
// Adjust start time, keep end fixed
const originalEndPercent = resizePreviewTop + resizePreviewHeight;
const originalEndPercent = taskResizePreviewTop + taskResizePreviewHeight;
const rawMinutes = percentY * minutesPerPercent;
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
resizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
resizePreviewHeight = Math.max(2, originalEndPercent - resizePreviewTop);
taskResizePreviewTop = (snappedMinutes / (config.totalVisibleHours * 60)) * 100;
taskResizePreviewHeight = Math.max(2, originalEndPercent - taskResizePreviewTop);
} else {
// Adjust end time, keep start fixed
const rawMinutes = percentY * minutesPerPercent;
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100;
resizePreviewHeight = Math.max(2, newBottom - resizePreviewTop);
const newBottom = (snappedMinutes / (config.totalVisibleHours * 60)) * 100;
taskResizePreviewHeight = Math.max(2, newBottom - taskResizePreviewTop);
}
}
/**
* End resize and update task
*/
async function endResize(firstVisibleHour: number, totalVisibleHours: number) {
if (!isResizing || !resizeTask || !hasMoved) {
isResizing = false;
resizeTask = null;
async function handleResizeEnd() {
document.removeEventListener('pointermove', handleResizeMove);
document.removeEventListener('pointerup', handleResizeEnd);
if (!isTaskResizing || !resizeTask || !hasMoved) {
cleanupResize();
return;
}
const config = getConfig();
// Calculate new times from position
const startMinutes =
(resizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
(taskResizePreviewTop / 100) * (config.totalVisibleHours * 60) + config.firstVisibleHour * 60;
const endMinutes =
((resizePreviewTop + resizePreviewHeight) / 100) * (totalVisibleHours * 60) +
firstVisibleHour * 60;
((taskResizePreviewTop + taskResizePreviewHeight) / 100) * (config.totalVisibleHours * 60) +
config.firstVisibleHour * 60;
const startHours = Math.floor(startMinutes / 60);
const startMins = Math.round(startMinutes % 60);
const endHours = Math.floor(endMinutes / 60);
const endMins = Math.round(endMinutes % 60);
const newStartTime = `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')}`;
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
const newStartTime = formatTime(startHours, startMins);
const newEndTime = formatTime(endHours, endMins);
const newDuration = Math.round(endMinutes - startMinutes);
const updateData: UpdateTaskInput = {
await todosStore.updateTodo(resizeTask.id, {
scheduledStartTime: newStartTime,
scheduledEndTime: newEndTime,
estimatedDuration: newDuration,
};
});
const result = await todosStore.updateTodo(resizeTask.id, updateData);
if (result.data) {
options.onTaskUpdate?.(result.data);
}
cleanupResize();
}
isResizing = false;
function cleanupResize() {
isTaskResizing = false;
resizeTask = null;
hasMoved = false;
}
// ========== Combined Cleanup ==========
function cleanup() {
document.removeEventListener('pointermove', handleDragMove);
document.removeEventListener('pointerup', handleDragEnd);
document.removeEventListener('pointermove', handleResizeMove);
document.removeEventListener('pointerup', handleResizeEnd);
cleanupDrag();
cleanupResize();
}
/**
* Cancel any ongoing drag/resize
* Cancel any active drag/resize operation
*/
function cancel() {
isDragging = false;
isResizing = false;
draggedTask = null;
resizeTask = null;
dragTargetDay = null;
hasMoved = false;
if (isTaskDragging || isTaskResizing) {
cleanup();
}
}
return {
// State getters
get isDragging() {
return isDragging;
// Drag state (reactive getters)
get isTaskDragging() {
return isTaskDragging;
},
get draggedTask() {
return draggedTask;
},
get dragTargetDay() {
return dragTargetDay;
get taskDragTargetDay() {
return taskDragTargetDay;
},
get dragPreviewTop() {
return dragPreviewTop;
get taskDragPreviewTop() {
return taskDragPreviewTop;
},
get dragPreviewHeight() {
return dragPreviewHeight;
get taskDragPreviewHeight() {
return taskDragPreviewHeight;
},
get isResizing() {
return isResizing;
// Resize state (reactive getters)
get isTaskResizing() {
return isTaskResizing;
},
get resizeTask() {
return resizeTask;
},
get resizePreviewTop() {
return resizePreviewTop;
get taskResizeEdge() {
return taskResizeEdge;
},
get resizePreviewHeight() {
return resizePreviewHeight;
get taskResizePreviewTop() {
return taskResizePreviewTop;
},
get taskResizePreviewHeight() {
return taskResizePreviewHeight;
},
// Shared state
get hasMoved() {
return hasMoved;
},
// Methods
startDrag,
onDragMove,
endDrag,
startResize,
onResizeMove,
endResize,
cancel,
cleanup,
};
}

View file

@ -0,0 +1,102 @@
/**
* useVisibleHours Composable
*
* Provides hour filtering and time-to-position calculations for calendar views.
* Extracts common logic from WeekView, MultiDayView, and DayView.
*/
import { settingsStore } from '$lib/stores/settings.svelte';
const ALL_HOURS = Array.from({ length: 24 }, (_, i) => i);
/**
* Creates reactive hour visibility state and helper functions
*/
export function useVisibleHours() {
// Filtered hours based on settings
let hours = $derived(
settingsStore.filterHoursEnabled
? ALL_HOURS.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour)
: ALL_HOURS
);
// Calculate visible hours range for positioning
let firstVisibleHour = $derived(
settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0
);
let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24);
let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour);
/**
* Convert minutes (from midnight) to percentage position
* accounting for hidden hours when filtering is enabled
*/
function minutesToPercent(minutes: number): number {
const adjustedMinutes = minutes - firstVisibleHour * 60;
return (adjustedMinutes / (totalVisibleHours * 60)) * 100;
}
/**
* Convert percentage position back to minutes (from midnight)
*/
function percentToMinutes(percent: number): number {
return (percent / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
}
/**
* Check if a time range overlaps with the visible hours range
*/
function isTimeRangeVisible(startMinutes: number, endMinutes: number): boolean {
const visibleStartMinutes = firstVisibleHour * 60;
const visibleEndMinutes = lastVisibleHour * 60;
return startMinutes < visibleEndMinutes && endMinutes > visibleStartMinutes;
}
return {
get hours() {
return hours;
},
get firstVisibleHour() {
return firstVisibleHour;
},
get lastVisibleHour() {
return lastVisibleHour;
},
get totalVisibleHours() {
return totalVisibleHours;
},
minutesToPercent,
percentToMinutes,
isTimeRangeVisible,
};
}
/**
* Creates a reactive current time indicator
* Updates every minute and provides position calculation
*/
export function useCurrentTimeIndicator() {
let now = $state(new Date());
// Update current time every minute
$effect(() => {
const interval = setInterval(() => {
now = new Date();
}, 60000);
return () => clearInterval(interval);
});
return {
get now() {
return now;
},
/**
* Get current time as minutes from midnight
*/
get currentMinutes() {
return now.getHours() * 60 + now.getMinutes();
},
};
}

View file

@ -0,0 +1,108 @@
import { NavigationArrow, CalendarBlank, ListChecks } from '@manacore/shared-icons';
import {
COMMON_SHORTCUTS,
COMMON_SYNTAX,
DEFAULT_LIVE_EXAMPLE,
type HelpModalConfig,
type ShortcutCategory,
type SyntaxGroup,
} from '@manacore/shared-ui';
/**
* Calendar-specific keyboard shortcuts
*/
const CALENDAR_SHORTCUTS: ShortcutCategory[] = [
{
id: 'navigation',
title: 'Navigation',
icon: NavigationArrow,
shortcuts: [
{
keys: ['Cmd', '1'],
altKeys: ['Ctrl', '1'],
description: 'Kalender öffnen',
category: 'navigation',
},
{
keys: ['Cmd', '2'],
altKeys: ['Ctrl', '2'],
description: 'Aufgaben öffnen',
category: 'navigation',
},
{
keys: ['Cmd', '3'],
altKeys: ['Ctrl', '3'],
description: 'Statistiken öffnen',
category: 'navigation',
},
{
keys: ['Cmd', '4'],
altKeys: ['Ctrl', '4'],
description: 'Einstellungen öffnen',
category: 'navigation',
},
],
},
{
id: 'calendar',
title: 'Kalender',
icon: CalendarBlank,
shortcuts: [
{
keys: ['Enter'],
description: 'Event/Task öffnen',
category: 'calendar',
},
{
keys: ['Space'],
description: 'Event/Task öffnen',
category: 'calendar',
},
{
keys: ['Esc'],
description: 'Drag/Resize abbrechen',
category: 'calendar',
},
],
},
{
id: 'tasks',
title: 'Aufgaben',
icon: ListChecks,
shortcuts: [
{
keys: ['Enter'],
description: 'Aufgabe öffnen',
category: 'tasks',
},
{
keys: ['Space'],
description: 'Aufgabe abhaken',
category: 'tasks',
},
],
},
];
/**
* Calendar-specific syntax patterns (extends common syntax)
*/
const CALENDAR_SYNTAX: SyntaxGroup[] = [
// Calendar uses all common syntax patterns
];
/**
* Complete help configuration for the Calendar app
* Combines common shortcuts/syntax with Calendar-specific ones
*/
export const CALENDAR_HELP_CONFIG: HelpModalConfig = {
shortcuts: [...COMMON_SHORTCUTS, ...CALENDAR_SHORTCUTS],
syntax: [...COMMON_SYNTAX, ...CALENDAR_SYNTAX],
defaultTab: 'shortcuts',
liveExample: DEFAULT_LIVE_EXAMPLE,
};
/**
* Export individual parts for customization
*/
export { CALENDAR_SHORTCUTS, CALENDAR_SYNTAX };

View file

@ -0,0 +1,219 @@
/**
* Birthdays Store - Manages contact birthdays for calendar display
* Cross-app integration with Contacts Backend (similar to todosStore)
*/
import { browser } from '$app/environment';
import * as api from '$lib/api/birthdays';
import type { ContactBirthdaySummary, BirthdayEvent } from '$lib/api/birthdays';
import { getContactDisplayName, BIRTHDAY_CALENDAR } from '$lib/api/birthdays';
import { differenceInYears, isSameDay, isWithinInterval, parseISO, format } from 'date-fns';
// Re-export types for convenience
export type { ContactBirthdaySummary, BirthdayEvent };
// ============================================
// State
// ============================================
let birthdays = $state<ContactBirthdaySummary[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let serviceAvailable = $state(true);
let lastFetchTime = $state<number>(0);
// Cache settings
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// ============================================
// Store Export
// ============================================
export const birthdaysStore = {
// ========== Getters ==========
get birthdays() {
return birthdays ?? [];
},
get loading() {
return loading;
},
get error() {
return error;
},
get serviceAvailable() {
return serviceAvailable;
},
get calendarId() {
return BIRTHDAY_CALENDAR.id;
},
get calendarColor() {
return BIRTHDAY_CALENDAR.color;
},
get calendarName() {
return BIRTHDAY_CALENDAR.name;
},
// ========== Birthday Getters ==========
/**
* Get birthday events for a specific day
* Matches by month and day (ignores year)
*/
getBirthdaysForDay(date: Date): BirthdayEvent[] {
const currentBirthdays = birthdays ?? [];
if (!Array.isArray(currentBirthdays) || currentBirthdays.length === 0) return [];
return currentBirthdays
.filter((contact) => {
if (!contact.birthday) return false;
const birthdayDate = parseISO(contact.birthday);
// Compare month and day only
return (
birthdayDate.getMonth() === date.getMonth() && birthdayDate.getDate() === date.getDate()
);
})
.map((contact) => this.toBirthdayEvent(contact, date));
},
/**
* Get birthday events within a date range
*/
getBirthdaysInRange(start: Date, end: Date): BirthdayEvent[] {
const currentBirthdays = birthdays ?? [];
if (!Array.isArray(currentBirthdays) || currentBirthdays.length === 0) return [];
const events: BirthdayEvent[] = [];
const current = new Date(start);
// Iterate through each day in range
while (current <= end) {
const dayBirthdays = this.getBirthdaysForDay(current);
events.push(...dayBirthdays);
current.setDate(current.getDate() + 1);
}
return events;
},
/**
* Check if a specific day has any birthdays
*/
hasBirthdaysOnDay(date: Date): boolean {
const currentBirthdays = birthdays ?? [];
if (!Array.isArray(currentBirthdays)) return false;
return currentBirthdays.some((contact) => {
if (!contact.birthday) return false;
const birthdayDate = parseISO(contact.birthday);
return (
birthdayDate.getMonth() === date.getMonth() && birthdayDate.getDate() === date.getDate()
);
});
},
/**
* Get upcoming birthdays (next N days)
*/
getUpcomingBirthdays(days: number = 30): BirthdayEvent[] {
const start = new Date();
const end = new Date();
end.setDate(end.getDate() + days);
return this.getBirthdaysInRange(start, end);
},
/**
* Convert contact to birthday event
*/
toBirthdayEvent(contact: ContactBirthdaySummary, displayDate: Date): BirthdayEvent {
const displayName = getContactDisplayName(contact);
const birthdayDate = parseISO(contact.birthday);
const birthYear = birthdayDate.getFullYear();
// Calculate age (0 if year seems invalid, e.g., 1900 default)
let age = differenceInYears(displayDate, birthdayDate);
if (birthYear < 1900 || birthYear > new Date().getFullYear()) {
age = 0; // Unknown birth year
}
const dateStr = format(displayDate, 'yyyy-MM-dd');
return {
id: `birthday-${contact.id}-${dateStr}`,
contactId: contact.id,
title: `${displayName}`,
displayName,
photoUrl: contact.photoUrl,
birthday: contact.birthday,
age,
startTime: displayDate.toISOString(),
endTime: displayDate.toISOString(),
isAllDay: true,
isBirthday: true,
calendarId: BIRTHDAY_CALENDAR.id,
};
},
// ========== API Methods ==========
/**
* Fetch birthdays from Contacts service
* Uses cache to avoid frequent refetches
*/
async fetchBirthdays(force = false) {
if (!browser) return;
// Use cache if still valid
if (!force && Date.now() - lastFetchTime < CACHE_TTL && birthdays.length > 0) {
return;
}
loading = true;
error = null;
const result = await api.getBirthdays();
if (result.error) {
error = result.error.message;
serviceAvailable = false;
} else {
birthdays = result.data || [];
serviceAvailable = true;
lastFetchTime = Date.now();
}
loading = false;
},
/**
* Check if Contacts service is available
*/
async checkServiceHealth(): Promise<boolean> {
const result = await api.getBirthdays();
serviceAvailable = !result.error;
return serviceAvailable;
},
/**
* Clear birthdays cache
*/
clear() {
birthdays = [];
lastFetchTime = 0;
},
/**
* Get contact by ID from cached birthdays
*/
getContactById(id: string): ContactBirthdaySummary | undefined {
const currentBirthdays = birthdays ?? [];
if (!Array.isArray(currentBirthdays)) return undefined;
return currentBirthdays.find((c) => c.id === id);
},
/**
* Count of contacts with birthdays
*/
get count(): number {
return birthdays?.length ?? 0;
},
};

View file

@ -4,18 +4,42 @@
import type { Calendar, CreateCalendarInput, UpdateCalendarInput } from '@calendar/shared';
import * as api from '$lib/api/calendars';
import { BIRTHDAY_CALENDAR } from '$lib/api/birthdays';
import { settingsStore } from './settings.svelte';
// State
let calendars = $state<Calendar[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
// Virtual birthday calendar (created dynamically based on settings)
const birthdayCalendar: Calendar = {
id: BIRTHDAY_CALENDAR.id,
userId: '',
name: BIRTHDAY_CALENDAR.name,
color: BIRTHDAY_CALENDAR.color,
isDefault: false,
isVisible: true, // Visibility controlled by settingsStore.showBirthdays
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Helper to safely get calendars array (Svelte 5 runes safety)
function getCalendarsArray(): Calendar[] {
const arr = calendars ?? [];
return Array.isArray(arr) ? arr : [];
}
// Derived: all calendars including virtual birthday calendar
const allCalendars = $derived.by(() => {
const userCalendars = getCalendarsArray();
// Add virtual birthday calendar if birthdays are enabled in settings
if (settingsStore.showBirthdays) {
return [...userCalendars, { ...birthdayCalendar, isVisible: true }];
}
return userCalendars;
});
// Derived: visible calendars
const visibleCalendars = $derived(getCalendarsArray().filter((c) => c.isVisible));
@ -30,6 +54,9 @@ export const calendarsStore = {
get calendars() {
return calendars;
},
get allCalendars() {
return allCalendars;
},
get visibleCalendars() {
return visibleCalendars;
},
@ -42,6 +69,9 @@ export const calendarsStore = {
get error() {
return error;
},
get birthdayCalendarId() {
return BIRTHDAY_CALENDAR.id;
},
/**
* Fetch all calendars
@ -115,6 +145,23 @@ export const calendarsStore = {
return this.updateCalendar(id, { isVisible: !calendar.isVisible });
},
/**
* Set a calendar as the default
*/
async setAsDefault(id: string) {
const result = await api.updateCalendar(id, { isDefault: true });
if (result.data) {
// Update local state: set this one as default, remove default from others
calendars = getCalendarsArray().map((c) => ({
...c,
isDefault: c.id === id,
}));
}
return result;
},
/**
* Get calendar by ID
*/
@ -126,7 +173,26 @@ export const calendarsStore = {
* Get calendar color by ID (with fallback)
*/
getColor(id: string) {
// Handle virtual birthday calendar
if (id === BIRTHDAY_CALENDAR.id) {
return BIRTHDAY_CALENDAR.color;
}
const calendar = getCalendarsArray().find((c) => c.id === id);
return calendar?.color || '#3b82f6';
},
/**
* Toggle birthday calendar visibility
* (This updates the settings store, not the calendar itself)
*/
toggleBirthdaysVisibility() {
settingsStore.set('showBirthdays', !settingsStore.showBirthdays);
},
/**
* Check if a calendar ID is the virtual birthday calendar
*/
isBirthdayCalendar(id: string) {
return id === BIRTHDAY_CALENDAR.id;
},
};

View file

@ -0,0 +1,151 @@
/**
* Event Tag Groups Store - Manages tag groups using Svelte 5 runes
*/
import type { EventTagGroup } from '@calendar/shared';
import * as api from '$lib/api/event-tag-groups';
// State
let groups = $state<EventTagGroup[]>([]);
let ungroupedTagCount = $state(0);
let loading = $state(false);
let error = $state<string | null>(null);
// Helper to safely get groups array (Svelte 5 runes safety)
function getGroupsArray(): EventTagGroup[] {
const arr = groups ?? [];
return Array.isArray(arr) ? arr : [];
}
export const eventTagGroupsStore = {
// Getters
get groups() {
return groups;
},
get ungroupedTagCount() {
return ungroupedTagCount;
},
get loading() {
return loading;
},
get error() {
return error;
},
/**
* Fetch all groups
*/
async fetchGroups() {
loading = true;
error = null;
const result = await api.getEventTagGroups();
if (result.error) {
error = result.error.message;
groups = [];
ungroupedTagCount = 0;
} else {
groups = result.data || [];
ungroupedTagCount = result.ungroupedTagCount;
}
loading = false;
return result;
},
/**
* Create a new group
*/
async createGroup(data: api.CreateEventTagGroupInput) {
const result = await api.createEventTagGroup(data);
if (result.data) {
groups = [...groups, result.data];
}
return result;
},
/**
* Update a group
*/
async updateGroup(id: string, data: api.UpdateEventTagGroupInput) {
const result = await api.updateEventTagGroup(id, data);
if (result.data) {
groups = getGroupsArray().map((g) => (g.id === id ? result.data! : g));
}
return result;
},
/**
* Delete a group
*/
async deleteGroup(id: string) {
const result = await api.deleteEventTagGroup(id);
if (!result.error) {
groups = getGroupsArray().filter((g) => g.id !== id);
}
return result;
},
/**
* Get group by ID
*/
getById(id: string) {
return getGroupsArray().find((g) => g.id === id);
},
/**
* Clear store
*/
clear() {
groups = [];
ungroupedTagCount = 0;
error = null;
},
/**
* Update tag count for a group (after tag assignment changes)
*/
updateTagCount(groupId: string | null, delta: number) {
if (groupId === null) {
ungroupedTagCount = Math.max(0, ungroupedTagCount + delta);
} else {
groups = getGroupsArray().map((g) => {
if (g.id === groupId) {
return { ...g, tagCount: Math.max(0, (g.tagCount ?? 0) + delta) };
}
return g;
});
}
},
/**
* Reorder groups by providing new array order
*/
async reorderGroups(groupIds: string[]) {
// Optimistic update
const oldGroups = [...groups];
groups = groupIds.map((id, i) => {
const g = getGroupsArray().find((g) => g.id === id)!;
return { ...g, sortOrder: i };
});
const result = await api.reorderEventTagGroups(groupIds);
if (result.error) {
// Rollback on error
groups = oldGroups;
} else if (result.data) {
groups = result.data;
ungroupedTagCount = result.ungroupedTagCount;
}
return result;
},
};

View file

@ -1,11 +1,10 @@
/**
* Event Tags Store - Manages event tags using Svelte 5 runes
*
* Uses the central Tags API from mana-core-auth. Tags are now unified
* across all Manacore apps (Todo, Calendar, Contacts).
* Uses the Calendar Backend API which supports tag groups (groupId).
*/
import type { EventTag } from '$lib/api/event-tags';
import type { EventTag } from '@calendar/shared';
import * as api from '$lib/api/event-tags';
// State
@ -111,4 +110,27 @@ export const eventTagsStore = {
tags = [];
error = null;
},
/**
* Get tags grouped by groupId
* Returns a Map where keys are groupId (or null for ungrouped)
*/
getGroupedTags(): Map<string | null, EventTag[]> {
const grouped = new Map<string | null, EventTag[]>();
for (const tag of getTagsArray()) {
const groupId = tag.groupId ?? null;
const existing = grouped.get(groupId) ?? [];
grouped.set(groupId, [...existing, tag]);
}
return grouped;
},
/**
* Get tags by group ID (null for ungrouped)
*/
getTagsByGroup(groupId: string | null): EventTag[] {
return getTagsArray().filter((t) => (t.groupId ?? null) === groupId);
},
};

View file

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

View file

@ -4,7 +4,8 @@
import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared';
import * as api from '$lib/api/events';
import { format, isWithinInterval, parseISO, isSameDay } from 'date-fns';
import { format, isWithinInterval, isSameDay } from 'date-fns';
import { toDate } from '$lib/utils/eventDateHelpers';
import { toastStore } from './toast.svelte';
// State
@ -48,9 +49,10 @@ export const eventsStore = {
error = result.error.message;
toastStore.error(`Termine konnten nicht geladen werden: ${result.error.message}`);
} else {
// API returns { events: [...] }
const data = result.data as { events: CalendarEvent[] } | null;
events = data?.events || [];
// API returns events array directly (already extracted in api/events.ts)
const eventsData = result.data as CalendarEvent[] | null;
console.log('[Events Store] Loaded events:', eventsData?.length, eventsData);
events = eventsData || [];
loadedRange = { start: startDate, end: endDate };
}
@ -67,9 +69,8 @@ export const eventsStore = {
if (!Array.isArray(currentEvents)) return [];
const result = currentEvents.filter((event) => {
const eventStart =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const eventStart = toDate(event.startTime);
const eventEnd = toDate(event.endTime);
// For all-day events, check if day falls within event range
if (event.isAllDay) {
@ -85,10 +86,7 @@ export const eventsStore = {
// Include draft event if it exists and is on this day
if (includeDraft && draftEvent) {
const draftStart =
typeof draftEvent.startTime === 'string'
? parseISO(draftEvent.startTime)
: draftEvent.startTime;
const draftStart = toDate(draftEvent.startTime);
if (isSameDay(date, draftStart)) {
result.push(draftEvent);
}
@ -106,9 +104,8 @@ export const eventsStore = {
if (!Array.isArray(currentEvents)) return [];
return currentEvents.filter((event) => {
const eventStart =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const eventStart = toDate(event.startTime);
const eventEnd = toDate(event.endTime);
// Check if event overlaps with the range
return eventStart <= end && eventEnd >= start;
@ -121,11 +118,8 @@ export const eventsStore = {
async createEvent(data: CreateEventInput) {
const result = await api.createEvent(data);
if (result.error) {
toastStore.error(`Termin konnte nicht erstellt werden: ${result.error.message}`);
} else if (result.data) {
if (result.data) {
events = [...events, result.data];
toastStore.success('Termin erstellt');
}
return result;

View file

@ -7,39 +7,55 @@ interface SearchItem {
[key: string]: unknown;
}
class SearchStore {
// Current search query
query = $state('');
// State
let query = $state('');
let matchingEventIds = $state<Set<string>>(new Set());
let isSearching = $state(false);
// Event IDs that match the search
matchingEventIds = $state<Set<string>>(new Set());
// Whether search is active (user is typing in InputBar)
isSearching = $state(false);
// Set search query and matching items (events or any items with an id)
setSearch(query: string, matchingItems: SearchItem[]) {
this.query = query;
this.matchingEventIds = new Set(matchingItems.map((item) => item.id));
this.isSearching = query.trim().length > 0;
}
// Clear search
clear() {
this.query = '';
this.matchingEventIds = new Set();
this.isSearching = false;
}
// Check if an event matches the search
isEventHighlighted(eventId: string): boolean {
return this.isSearching && this.matchingEventIds.has(eventId);
}
// Check if an event should be dimmed (search active but event doesn't match)
isEventDimmed(eventId: string): boolean {
return this.isSearching && !this.matchingEventIds.has(eventId);
}
/**
* Set search query and matching items (events or any items with an id)
*/
function setSearch(newQuery: string, matchingItems: SearchItem[]) {
query = newQuery;
matchingEventIds = new Set(matchingItems.map((item) => item.id));
isSearching = newQuery.trim().length > 0;
}
export const searchStore = new SearchStore();
/**
* Clear search
*/
function clear() {
query = '';
matchingEventIds = new Set();
isSearching = false;
}
/**
* Check if an event matches the search
*/
function isEventHighlighted(eventId: string): boolean {
return isSearching && matchingEventIds.has(eventId);
}
/**
* Check if an event should be dimmed (search active but event doesn't match)
*/
function isEventDimmed(eventId: string): boolean {
return isSearching && !matchingEventIds.has(eventId);
}
export const searchStore = {
get query() {
return query;
},
get matchingEventIds() {
return matchingEventIds;
},
get isSearching() {
return isSearching;
},
setSearch,
clear,
isEventHighlighted,
isEventDimmed,
};

View file

@ -13,6 +13,7 @@ import { userSettings } from './user-settings.svelte';
export type WeekStartDay = 0 | 1; // 0 = Sunday, 1 = Monday
export type TimeFormat = '24h' | '12h';
export type AllDayDisplayMode = 'header' | 'block'; // header = separate row, block = full day block in grid
export type WeekdayFormat = 'full' | 'short' | 'hidden';
export interface CalendarAppSettings {
// View settings
@ -26,9 +27,39 @@ export interface CalendarAppSettings {
dayEndHour: number; // Last visible hour (0-23)
allDayDisplayMode: AllDayDisplayMode; // How to display all-day events
// Header settings
headerCompact: boolean; // Compact header display
headerWeekdayFormat: WeekdayFormat; // Weekday display format
headerShowDate: boolean; // Show date in header
headerAlwaysShowMonth: boolean; // Always show month (e.g., "13.12.")
// DateStrip settings
dateStripShowMoonPhases: boolean; // Show moon phase indicators
dateStripShowEventIndicators: boolean; // Show event dot indicators
dateStripShowWeekday: boolean; // Show weekday names (Mo, Di, Mi...)
dateStripHighlightWeekends: boolean; // Visually highlight weekend days
dateStripShowMonthDividers: boolean; // Show vertical dividers between months
dateStripCompact: boolean; // Use compact/smaller DateStrip
dateStripShowWeekNumbers: boolean; // Show week numbers at start of week
dateStripCollapsed: boolean; // Whether DateStrip is minimized to FAB
// TagStrip settings
tagStripCollapsed: boolean; // Whether TagStrip is hidden
// Immersive Mode settings
immersiveModeEnabled: boolean; // Fullscreen mode - hides all UI elements
// Birthday settings (cross-app integration with Contacts)
showBirthdays: boolean; // Show contact birthdays in calendar
showBirthdayAge: boolean; // Show age in birthday events
// UI settings
sidebarCollapsed: boolean;
// Quick View Pill settings
quickViewPillViews: CalendarViewType[]; // Views shown in quick switcher
customDayCount: number; // Custom day count for 'custom' view type (1-365)
// Event defaults
defaultEventDuration: number; // in minutes
defaultReminder: number; // in minutes before event
@ -44,7 +75,33 @@ const DEFAULT_SETTINGS: CalendarAppSettings = {
dayStartHour: 6,
dayEndHour: 20,
allDayDisplayMode: 'header',
// Header defaults
headerCompact: false,
headerWeekdayFormat: 'full',
headerShowDate: true,
headerAlwaysShowMonth: false,
// DateStrip defaults
dateStripShowMoonPhases: true,
dateStripShowEventIndicators: true,
dateStripShowWeekday: true,
dateStripHighlightWeekends: true,
dateStripShowMonthDividers: true,
dateStripCompact: false,
dateStripShowWeekNumbers: false,
dateStripCollapsed: false,
// TagStrip defaults
tagStripCollapsed: true, // Hidden by default
// Immersive Mode defaults
immersiveModeEnabled: false,
// Birthday defaults
showBirthdays: true,
showBirthdayAge: true,
// UI defaults
sidebarCollapsed: false,
// Quick View Pill defaults
quickViewPillViews: ['week', 'month', 'agenda'],
customDayCount: 30, // Default: 30 days (1 month)
// Event defaults
defaultEventDuration: 60,
defaultReminder: 15,
};
@ -142,6 +199,59 @@ export const settingsStore = {
get allDayDisplayMode() {
return settings.allDayDisplayMode;
},
// Header settings
get headerCompact() {
return settings.headerCompact;
},
get headerWeekdayFormat() {
return settings.headerWeekdayFormat;
},
get headerShowDate() {
return settings.headerShowDate;
},
get headerAlwaysShowMonth() {
return settings.headerAlwaysShowMonth;
},
// DateStrip settings
get dateStripShowMoonPhases() {
return settings.dateStripShowMoonPhases;
},
get dateStripShowEventIndicators() {
return settings.dateStripShowEventIndicators;
},
get dateStripShowWeekday() {
return settings.dateStripShowWeekday;
},
get dateStripHighlightWeekends() {
return settings.dateStripHighlightWeekends;
},
get dateStripShowMonthDividers() {
return settings.dateStripShowMonthDividers;
},
get dateStripCompact() {
return settings.dateStripCompact;
},
get dateStripShowWeekNumbers() {
return settings.dateStripShowWeekNumbers;
},
get dateStripCollapsed() {
return settings.dateStripCollapsed;
},
// TagStrip settings
get tagStripCollapsed() {
return settings.tagStripCollapsed;
},
// Immersive Mode settings
get immersiveModeEnabled() {
return settings.immersiveModeEnabled;
},
// Birthday settings
get showBirthdays() {
return settings.showBirthdays;
},
get showBirthdayAge() {
return settings.showBirthdayAge;
},
get defaultEventDuration() {
return settings.defaultEventDuration;
},
@ -151,6 +261,12 @@ export const settingsStore = {
get sidebarCollapsed() {
return settings.sidebarCollapsed;
},
get quickViewPillViews() {
return settings.quickViewPillViews;
},
get customDayCount() {
return settings.customDayCount;
},
get cloudSyncEnabled() {
return cloudSyncEnabled;
},
@ -191,6 +307,24 @@ export const settingsStore = {
syncToCloud();
},
/**
* Toggle TagStrip visibility
*/
toggleTagStrip() {
settings = { ...settings, tagStripCollapsed: !settings.tagStripCollapsed };
saveSettings(settings);
syncToCloud();
},
/**
* Toggle Immersive Mode (fullscreen, hide all UI)
*/
toggleImmersiveMode() {
settings = { ...settings, immersiveModeEnabled: !settings.immersiveModeEnabled };
saveSettings(settings);
syncToCloud();
},
/**
* Initialize settings from localStorage
*/

View file

@ -1,50 +0,0 @@
import { writable } from 'svelte/store';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
type: ToastType;
message: string;
duration?: number;
}
function createToastStore() {
const { subscribe, update } = writable<Toast[]>([]);
function add(message: string, type: ToastType = 'info', duration: number = 4000) {
const id = crypto.randomUUID();
const toast: Toast = { id, type, message, duration };
update((toasts) => [...toasts, toast]);
if (duration > 0) {
setTimeout(() => {
remove(id);
}, duration);
}
return id;
}
function remove(id: string) {
update((toasts) => toasts.filter((t) => t.id !== id));
}
function clear() {
update(() => []);
}
return {
subscribe,
add,
remove,
clear,
success: (message: string, duration?: number) => add(message, 'success', duration),
error: (message: string, duration?: number) => add(message, 'error', duration),
warning: (message: string, duration?: number) => add(message, 'warning', duration),
info: (message: string, duration?: number) => add(message, 'info', duration),
};
}
export const toast = createToastStore();

View file

@ -38,6 +38,11 @@ const viewRange = $derived.by(() => {
start: startOfDay(currentDate),
end: endOfDay(currentDate),
};
case '3day':
return {
start: startOfDay(currentDate),
end: endOfDay(addDays(currentDate, 2)),
};
case '5day':
return {
start: startOfDay(currentDate),
@ -58,6 +63,33 @@ const viewRange = $derived.by(() => {
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),
@ -108,7 +140,22 @@ export const viewStore = {
const savedView = localStorage.getItem('calendar-view-type');
if (
savedView &&
['day', '5day', 'week', '10day', '14day', 'month', 'year', 'agenda'].includes(savedView)
[
'day',
'3day',
'5day',
'week',
'10day',
'14day',
'30day',
'60day',
'90day',
'365day',
'month',
'year',
'agenda',
'custom',
].includes(savedView)
) {
viewType = savedView as CalendarViewType;
} else {
@ -149,6 +196,9 @@ export const viewStore = {
case 'day':
currentDate = subDays(currentDate, 1);
break;
case '3day':
currentDate = subDays(currentDate, 3);
break;
case '5day':
currentDate = subDays(currentDate, 5);
break;
@ -161,6 +211,21 @@ export const viewStore = {
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;
@ -181,6 +246,9 @@ export const viewStore = {
case 'day':
currentDate = addDays(currentDate, 1);
break;
case '3day':
currentDate = addDays(currentDate, 3);
break;
case '5day':
currentDate = addDays(currentDate, 5);
break;
@ -193,6 +261,21 @@ export const viewStore = {
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;

View file

@ -0,0 +1,60 @@
/**
* Shared calendar constants
* Single source of truth for magic numbers used across calendar views
*/
/**
* Height of one hour in pixels (should match CSS --hour-height variable)
*/
export const HOUR_HEIGHT_PX = 60;
/**
* Snap interval for drag/drop and resize operations in minutes
*/
export const SNAP_INTERVAL_MINUTES = 15;
/**
* Default event duration in minutes when creating quick events
*/
export const DEFAULT_EVENT_DURATION_MINUTES = 60;
/**
* Minimum event height as percentage of visible hours
*/
export const MIN_EVENT_HEIGHT_PERCENT = 1.5;
/**
* Maximum number of event dots to show in month view cells
*/
export const MAX_EVENT_DOTS = 5;
/**
* Days buffer for infinite scroll in date strip
*/
export const DATE_STRIP_BUFFER_DAYS = 60;
/**
* Default visible hours range
*/
export const DEFAULT_DAY_START_HOUR = 0;
export const DEFAULT_DAY_END_HOUR = 24;
/**
* Week starts on (0 = Sunday, 1 = Monday)
*/
export const DEFAULT_WEEK_STARTS_ON = 1;
/**
* All constants as a single object for convenient destructuring
*/
export const CALENDAR_CONSTANTS = {
HOUR_HEIGHT_PX,
SNAP_INTERVAL_MINUTES,
DEFAULT_EVENT_DURATION_MINUTES,
MIN_EVENT_HEIGHT_PERCENT,
MAX_EVENT_DOTS,
DATE_STRIP_BUFFER_DAYS,
DEFAULT_DAY_START_HOUR,
DEFAULT_DAY_END_HOUR,
DEFAULT_WEEK_STARTS_ON,
} as const;

View file

@ -0,0 +1,84 @@
/**
* Date Navigation Utilities
* Helper functions for calculating date offsets based on view type
*/
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';
/**
* Calculate a date offset based on the current view type
*
* @param date - The base date
* @param viewType - The current calendar view type
* @param offset - Number of periods to offset (-1 = previous, 1 = next)
* @returns The calculated date
*
* @example
* // Get previous week's date
* getOffsetDate(new Date(), 'week', -1)
*
* // Get next month's date
* getOffsetDate(new Date(), 'month', 1)
*/
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);
default:
return offset > 0 ? addWeeks(date, offset) : subWeeks(date, Math.abs(offset));
}
}

View file

@ -0,0 +1,165 @@
/**
* Event Filtering Utilities
* Reusable functions for filtering calendar events by visibility, time range, etc.
*/
import type { CalendarEvent } from '@calendar/shared';
import type { Calendar } from '@calendar/shared';
import { toDate } from './eventDateHelpers';
/**
* Create a Set of visible calendar IDs for efficient lookup
*/
export function getVisibleCalendarIds(visibleCalendars: Calendar[]): Set<string> {
return new Set(visibleCalendars.map((c) => c.id));
}
/**
* Filter events to only include those from visible calendars
*/
export function filterByVisibleCalendars(
events: CalendarEvent[],
visibleCalendars: Calendar[]
): CalendarEvent[] {
const visibleIds = getVisibleCalendarIds(visibleCalendars);
return events.filter((e) => visibleIds.has(e.calendarId));
}
/**
* Filter events to only include timed (non-all-day) events
*/
export function filterTimedEvents(events: CalendarEvent[]): CalendarEvent[] {
return events.filter((e) => !e.isAllDay);
}
/**
* Filter events to only include all-day events
*/
export function filterAllDayEvents(events: CalendarEvent[]): CalendarEvent[] {
return events.filter((e) => e.isAllDay);
}
/**
* Get event time in minutes from midnight
*/
export function getEventMinutes(event: CalendarEvent): { start: number; end: number } {
const start = toDate(event.startTime);
const end = toDate(event.endTime);
return {
start: start.getHours() * 60 + start.getMinutes(),
end: end.getHours() * 60 + end.getMinutes(),
};
}
/**
* Check if an event overlaps with a given time range (in minutes from midnight)
*/
export function eventOverlapsTimeRange(
event: CalendarEvent,
startMinutes: number,
endMinutes: number
): boolean {
const { start: eventStart, end: eventEnd } = getEventMinutes(event);
return eventStart < endMinutes && eventEnd > startMinutes;
}
/**
* Filter timed events that overlap with a visible hour range
*/
export function filterByHourRange(
events: CalendarEvent[],
dayStartHour: number,
dayEndHour: number
): CalendarEvent[] {
const startMinutes = dayStartHour * 60;
const endMinutes = dayEndHour * 60;
return events.filter((event) => eventOverlapsTimeRange(event, startMinutes, endMinutes));
}
/**
* Result type for overflow events
*/
export interface OverflowEvents {
before: CalendarEvent[];
after: CalendarEvent[];
}
/**
* Get events that are outside the visible hour range
* Returns events that end before the visible range starts (before)
* and events that start after the visible range ends (after)
*/
export function getOverflowEvents(
events: CalendarEvent[],
dayStartHour: number,
dayEndHour: number
): OverflowEvents {
const startMinutes = dayStartHour * 60;
const endMinutes = dayEndHour * 60;
const before: CalendarEvent[] = [];
const after: CalendarEvent[] = [];
for (const event of events) {
const { start: eventStart, end: eventEnd } = getEventMinutes(event);
if (eventEnd <= startMinutes) {
before.push(event);
} else if (eventStart >= endMinutes) {
after.push(event);
}
}
return { before, after };
}
/**
* Combined filter: Get visible timed events for a day with optional hour filtering
*/
export function getVisibleTimedEvents(
events: CalendarEvent[],
visibleCalendars: Calendar[],
options?: {
filterHoursEnabled?: boolean;
dayStartHour?: number;
dayEndHour?: number;
}
): CalendarEvent[] {
let filtered = filterByVisibleCalendars(events, visibleCalendars);
filtered = filterTimedEvents(filtered);
if (
options?.filterHoursEnabled &&
options.dayStartHour !== undefined &&
options.dayEndHour !== undefined
) {
filtered = filterByHourRange(filtered, options.dayStartHour, options.dayEndHour);
}
return filtered;
}
/**
* Combined filter: Get visible all-day events for a day
*/
export function getVisibleAllDayEvents(
events: CalendarEvent[],
visibleCalendars: Calendar[]
): CalendarEvent[] {
let filtered = filterByVisibleCalendars(events, visibleCalendars);
return filterAllDayEvents(filtered);
}
/**
* Combined filter: Get overflow events for visible calendars
*/
export function getVisibleOverflowEvents(
events: CalendarEvent[],
visibleCalendars: Calendar[],
dayStartHour: number,
dayEndHour: number
): OverflowEvents {
let filtered = filterByVisibleCalendars(events, visibleCalendars);
filtered = filterTimedEvents(filtered);
return getOverflowEvents(filtered, dayStartHour, dayEndHour);
}

View file

@ -3,7 +3,12 @@
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation, QuickInputBar } from '@manacore/shared-ui';
import {
PillNavigation,
QuickInputBar,
InputBarHelpModal,
ImmersiveModeToggle,
} from '@manacore/shared-ui';
import {
SplitPaneContainer,
setSplitPanelContext,
@ -14,6 +19,8 @@
PillDropdownItem,
QuickInputItem,
CreatePreview,
PillTabGroupConfig,
PillNavElement,
} from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
@ -23,6 +30,8 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { birthdaysStore } from '$lib/stores/birthdays.svelte';
import { browser } from '$app/environment';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
@ -50,6 +59,12 @@
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 TagStrip from '$lib/components/calendar/TagStrip.svelte';
import EventContextMenu from '$lib/components/event/EventContextMenu.svelte';
import ViewModePillContextMenu from '$lib/components/calendar/ViewModePillContextMenu.svelte';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
import type { CalendarViewType } from '@calendar/shared';
// App switcher items
const appItems = getPillAppItems('calendar');
@ -143,7 +158,52 @@
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
let isToolbarCollapsed = $state(false);
let isToolbarCollapsed = $state(true); // Default to collapsed - FAB next to InputBar
// Mobile detection for responsive layout
let isMobile = $state(false);
function updateMobileState() {
if (browser) {
isMobile = window.innerWidth <= 640;
}
}
// InputBar help modal state
let helpModalOpen = $state(false);
let helpModalMode = $state<'shortcuts' | 'syntax'>('shortcuts');
function handleShowShortcuts() {
helpModalMode = 'shortcuts';
helpModalOpen = true;
}
function handleShowSyntaxHelp() {
helpModalMode = 'syntax';
helpModalOpen = true;
}
function handleCloseHelpModal() {
helpModalOpen = false;
}
// Default calendar for InputBar quick create
let selectedDefaultCalendarId = $derived(
calendarsStore.calendars.find((c) => c.isDefault)?.id || calendarsStore.calendars[0]?.id
);
function handleDefaultCalendarChange(id: string) {
// Update the default calendar via API
calendarsStore.setAsDefault(id);
}
// Calendar options for InputBar context menu
let calendarOptions = $derived(
calendarsStore.calendars.map((c) => ({
id: c.id,
label: c.name,
}))
);
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
@ -195,26 +255,143 @@
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// Base navigation items for Calendar
const baseNavItems: PillNavItem[] = [
{ href: '/', label: 'Kalender', icon: 'calendar' },
{ href: '/agenda', label: 'Agenda', icon: 'list' },
{ href: '/tasks', label: 'Aufgaben', icon: 'check-square' },
{ href: '/tags', label: 'Tags', icon: 'tag' },
// Toggle TagStrip visibility
function handleTagsToggle() {
settingsStore.toggleTagStrip();
}
// Tags button active state (show as active when TagStrip is visible)
let isTagStripVisible = $derived(!settingsStore.tagStripCollapsed);
// Offset for elements above TagStrip (70px when visible)
let tagStripOffset = $derived(showCalendarToolbar && !settingsStore.tagStripCollapsed ? 70 : 0);
// Base navigation items for Calendar (without Kalender/Aufgaben - handled by tab group)
// Note: Tags uses onClick to toggle TagStrip visibility instead of navigating
let baseNavItems = $derived<PillNavItem[]>([
{
href: '/tags',
label: 'Tags',
icon: 'tag',
onClick: handleTagsToggle,
active: isTagStripVisible,
},
{ href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' },
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
]);
// Navigation items filtered by visibility settings
const navItems = $derived(
filterHiddenNavItems('calendar', baseNavItems, userSettings.nav.hiddenNavItems)
);
// Navigation shortcuts (Ctrl+1-4) - use base items for consistent shortcuts
const navRoutes = baseNavItems.map((item) => item.href);
// Active tab based on sidebar state: 'tasks' when sidebar is open, 'calendar' when closed
let activeTab = $derived(settingsStore.sidebarCollapsed ? 'calendar' : 'tasks');
// Tab group for Kalender/Aufgaben
let calendarTasksTabGroup = $derived<PillTabGroupConfig>({
type: 'tabs',
options: [
{ id: 'calendar', icon: 'calendar', label: 'Kalender', title: 'Kalender anzeigen' },
{ id: 'tasks', icon: 'check-square', label: 'Aufgaben', title: 'Aufgaben-Sidebar öffnen' },
],
value: activeTab,
onChange: handleTabChange,
});
// View switcher context menu
let viewContextMenu: ViewModePillContextMenu;
function handleViewContextMenu(x: number, y: number) {
viewContextMenu?.show(x, y);
}
// View labels for tabs (numbers for day views, letters for others)
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 tooltips
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)
function getViewLabel(view: CalendarViewType): string {
if (view === 'custom') {
return String(settingsStore.customDayCount);
}
return viewLabels[view];
}
// View switcher tab group (only shown on calendar main page)
let viewSwitcherTabGroup = $derived<PillTabGroupConfig>({
type: 'tabs',
options: enabledViews.map((view) => ({
id: view,
label: getViewLabel(view),
title: view === 'custom' ? `${settingsStore.customDayCount}-Tage-Ansicht` : viewTitles[view],
})),
value: viewStore.viewType,
onChange: (id) => viewStore.setViewType(id as CalendarViewType),
onContextMenu: handleViewContextMenu,
});
// Prepended elements (tab groups at the start of navigation)
let prependElements = $derived<PillNavElement[]>(
showCalendarToolbar ? [calendarTasksTabGroup, viewSwitcherTabGroup] : [calendarTasksTabGroup]
);
// Handle tab change: toggle sidebar for tasks, close for calendar
function handleTabChange(tabId: string) {
// Always navigate to main calendar page if not there
if ($page.url.pathname !== '/') {
goto('/');
}
if (tabId === 'tasks') {
// Toggle behavior: if sidebar is already open, close it
settingsStore.toggleSidebar();
} else if (tabId === 'calendar') {
// Kalender-Tab: close sidebar if open
if (!settingsStore.sidebarCollapsed) {
settingsStore.toggleSidebar();
}
}
}
// Navigation shortcuts (Ctrl+1 = Kalender, Ctrl+2 = Aufgaben toggle, Ctrl+3+ = other nav items)
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
@ -224,14 +401,71 @@
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const num = parseInt(event.key);
if (num >= 1 && num <= navRoutes.length) {
if (num === 1) {
// Ctrl+1: Kalender (close sidebar)
event.preventDefault();
const route = navRoutes[num - 1];
handleTabChange('calendar');
} else if (num === 2) {
// Ctrl+2: Aufgaben (toggle sidebar)
event.preventDefault();
handleTabChange('tasks');
} else if (num >= 3 && num <= baseNavItems.length + 2) {
// Ctrl+3+: other nav items (offset by 2 for the tab group)
event.preventDefault();
const route = baseNavItems[num - 3]?.href;
if (route) {
goto(route);
}
}
}
// F = Toggle Immersive Mode (no modifier keys)
if (
(event.key === 'f' || event.key === 'F') &&
!event.ctrlKey &&
!event.metaKey &&
!event.shiftKey &&
!event.altKey
) {
event.preventDefault();
settingsStore.toggleImmersiveMode();
}
// Arrow keys for calendar navigation (only on main calendar page, no modifiers)
if (
showCalendarToolbar &&
!event.ctrlKey &&
!event.metaKey &&
!event.shiftKey &&
!event.altKey
) {
if (event.key === 'ArrowLeft') {
event.preventDefault();
viewStore.goToPrevious();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
viewStore.goToNext();
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
// Scroll calendar view up/down - scroll to top/bottom
const scrollContainer = document.querySelector('.carousel-page.current');
if (scrollContainer) {
event.preventDefault();
if (event.key === 'ArrowDown') {
// Scroll to bottom
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: 'smooth',
});
} else {
// Scroll to top
scrollContainer.scrollTo({
top: 0,
behavior: 'smooth',
});
}
}
}
}
}
function handleModeChange(isSidebar: boolean) {
@ -276,6 +510,18 @@
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) {
birthdaysStore.fetchBirthdays();
}
});
onMount(async () => {
// Redirect to login if not authenticated
if (!authStore.isAuthenticated) {
@ -294,6 +540,8 @@
await eventTagsStore.fetchTags();
await userSettings.load();
// Note: Birthdays are loaded via reactive $effect when showBirthdays is enabled
// Redirect to start page if on root and a custom start page is set
const currentPath = window.location.pathname;
if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') {
@ -314,119 +562,185 @@
collapsedStore.set(true);
}
// Initialize toolbar collapsed state from localStorage
// Initialize toolbar collapsed state from localStorage (default is now collapsed)
const savedToolbarCollapsed = localStorage.getItem('calendar-toolbar-collapsed');
if (savedToolbarCollapsed === 'true') {
isToolbarCollapsed = true;
toolbarCollapsedStore.set(true);
if (savedToolbarCollapsed === 'false') {
isToolbarCollapsed = false;
toolbarCollapsedStore.set(false);
}
// Initialize mobile state
updateMobileState();
});
</script>
<svelte:window onkeydown={handleKeydown} />
<svelte:window onkeydown={handleKeydown} onresize={updateMobileState} />
<SplitPaneContainer>
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Kalender"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
desktopPosition="bottom"
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#3b82f6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
onOpenInPanel={handleOpenInPanel}
>
{#snippet toolbarContent()}
{#if showCalendarToolbar}
<CalendarToolbarContent vertical={true} />
{/if}
{/snippet}
</PillNavigation>
<!-- Date strip (only on main calendar page) -->
{#if showCalendarToolbar}
<DateStrip {isSidebarMode} />
{/if}
<!-- Calendar toolbar (only on main calendar page, not in sidebar mode) -->
{#if showCalendarToolbar && !isSidebarMode}
<CalendarToolbar
<!-- UI Elements (hidden in immersive mode) -->
{#if !settingsStore.immersiveModeEnabled}
<PillNavigation
items={navItems}
{prependElements}
currentPath={$page.url.pathname}
appName="Kalender"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isSidebarMode}
isCollapsed={isToolbarCollapsed}
onModeChange={handleToolbarModeChange}
onCollapsedChange={handleToolbarCollapsedChange}
/>
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
desktopPosition="bottom"
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#3b82f6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
onOpenInPanel={handleOpenInPanel}
>
{#snippet toolbarContent()}
{#if showCalendarToolbar}
<CalendarToolbarContent vertical={true} />
{/if}
{/snippet}
</PillNavigation>
<!-- Date strip (only on main calendar page) -->
{#if showCalendarToolbar}
{#if settingsStore.dateStripCollapsed}
<DateStripFab
{isSidebarMode}
isToolbarExpanded={!isToolbarCollapsed}
{isMobile}
hasTagStrip={!settingsStore.tagStripCollapsed}
/>
{:else}
<DateStrip
{isSidebarMode}
isToolbarExpanded={!isToolbarCollapsed}
hasTagStrip={!settingsStore.tagStripCollapsed}
/>
{/if}
{/if}
<!-- Tag strip (only on main calendar page, when not collapsed) - directly above PillNav -->
{#if showCalendarToolbar && !settingsStore.tagStripCollapsed}
<TagStrip {isSidebarMode} />
{/if}
<!-- Calendar toolbar (only on main calendar page, not in sidebar mode) -->
{#if showCalendarToolbar && !isSidebarMode}
<CalendarToolbar
{isSidebarMode}
isCollapsed={isToolbarCollapsed}
{isMobile}
bottomOffset={settingsStore.tagStripCollapsed ? '70px' : '140px'}
onModeChange={handleToolbarModeChange}
onCollapsedChange={handleToolbarCollapsedChange}
/>
{/if}
{/if}
<!-- Global Input Bar (hidden via CSS in immersive mode to prevent re-mount focus) -->
<div class="input-bar-wrapper" class:hidden={settingsStore.immersiveModeEnabled}>
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
onSearchChange={handleSearchChange}
placeholder="Neuer Termin oder suchen..."
emptyText="Keine Termine gefunden"
searchingText="Suche..."
onCreate={handleCreate}
onParseCreate={handleParseCreate}
createText="Erstellen"
appIcon="calendar"
bottomOffset={isMobile
? `${70 + tagStripOffset}px`
: isSidebarMode
? `${tagStripOffset}px`
: showCalendarToolbar && !isToolbarCollapsed
? `${140 + tagStripOffset}px`
: `${70 + tagStripOffset}px`}
hasFabRight={showCalendarToolbar && !isSidebarMode}
hasFabLeft={!isMobile &&
showCalendarToolbar &&
!isSidebarMode &&
settingsStore.dateStripCollapsed}
defaultOptions={calendarOptions}
selectedDefaultId={selectedDefaultCalendarId}
defaultOptionLabel="Standard-Kalender"
onDefaultChange={handleDefaultCalendarChange}
onShowShortcuts={handleShowShortcuts}
onShowSyntaxHelp={handleShowSyntaxHelp}
/>
</div>
<!-- Immersive Mode Toggle (always visible on main calendar page) -->
<ImmersiveModeToggle
isImmersive={settingsStore.immersiveModeEnabled}
onToggle={() => settingsStore.toggleImmersiveMode()}
visible={showCalendarToolbar}
/>
<main
class="main-content bg-background"
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:floating-mode={!isSidebarMode && !isCollapsed}
class:has-toolbar={showCalendarToolbar}
class:immersive={settingsStore.immersiveModeEnabled}
>
<div
class="content-wrapper"
class:calendar-expanded={settingsStore.sidebarCollapsed && $page.url.pathname === '/'}
class:immersive={settingsStore.immersiveModeEnabled}
>
{@render children()}
</div>
</main>
<!-- Global Input Bar -->
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
onSearchChange={handleSearchChange}
placeholder="Neuer Termin oder suchen..."
emptyText="Keine Termine gefunden"
searchingText="Suche..."
onCreate={handleCreate}
onParseCreate={handleParseCreate}
createText="Erstellen"
appIcon="calendar"
primaryColor="#3b82f6"
autoFocus={true}
bottomOffset={showCalendarToolbar
? isSidebarMode
? '0px'
: '130px'
: isSidebarMode
? '0px'
: '70px'}
/>
</div>
</SplitPaneContainer>
<!-- Global Event Context Menu - rendered at top level for proper z-index -->
<EventContextMenu onEdit={handleContextMenuEdit} />
<!-- View Mode Context Menu -->
<ViewModePillContextMenu bind:this={viewContextMenu} />
<!-- InputBar Help Modal -->
<InputBarHelpModal open={helpModalOpen} onClose={handleCloseHelpModal} mode={helpModalMode} />
<style>
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100vh;
overflow: hidden;
}
/* Mobile: Fixed viewport, no scroll */
@media (max-width: 768px) {
.layout-container {
height: 100vh;
max-height: 100vh;
overflow: hidden;
}
}
.main-content {
@ -434,32 +748,53 @@
position: relative;
/* Space for QuickInputBar at bottom */
padding-bottom: calc(80px + env(safe-area-inset-bottom));
/* Flex container for children */
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.main-content.floating-mode {
padding-top: 70px;
}
/* Extra padding when DateStrip + Toolbar are at bottom */
/* Extra padding when DateStrip is at bottom (toolbar is now a FAB) */
.main-content.floating-mode.has-toolbar {
padding-top: 0;
padding-bottom: calc(
280px + env(safe-area-inset-bottom)
); /* DateStrip + Toolbar + PillNav + QuickInputBar */
220px + env(safe-area-inset-bottom)
); /* DateStrip + PillNav + QuickInputBar */
}
@media (max-width: 768px) {
/* On mobile, toolbars are at bottom, extra padding at bottom instead */
/* On mobile, fixed height layout - no page scroll */
.main-content {
padding-bottom: calc(150px + env(safe-area-inset-bottom)); /* PillNav + QuickInputBar */
height: calc(100vh - 70px); /* Full height minus bottom nav */
overflow: hidden;
padding-bottom: 0;
display: flex;
flex-direction: column;
}
.main-content.has-toolbar {
padding-bottom: calc(
250px + env(safe-area-inset-bottom)
); /* DateStrip + Toolbar + BottomNav + QuickInputBar */
height: calc(100vh - 70px);
padding-bottom: 0;
}
.main-content.floating-mode.has-toolbar {
padding-top: 70px;
.main-content.floating-mode {
padding-top: 0; /* No top padding on mobile - everything is at bottom */
}
}
/* Mobile: Fixed height, internal scrolling only */
@media (max-width: 640px) {
.main-content {
height: calc(100vh - 70px);
overflow: hidden;
padding-bottom: 0;
}
.main-content.has-toolbar {
height: calc(100vh - 70px);
padding-bottom: 0;
}
}
@ -474,6 +809,22 @@
padding: 1rem;
position: relative;
z-index: 0;
/* Flex for calendar layout */
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* Mobile: no padding, full height */
@media (max-width: 768px) {
.content-wrapper {
padding: 0;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
}
@media (min-width: 640px) {
@ -496,5 +847,66 @@
padding: 0;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* Immersive Mode - fullscreen, no UI elements visible */
.main-content.immersive {
padding: 0 !important;
height: 100vh !important;
width: 100vw;
transition: padding 300ms ease;
}
.content-wrapper.immersive {
padding: 0 !important;
margin: 0;
height: 100vh;
width: 100vw;
max-width: 100vw;
}
/* Adjust InputBar when FABs are visible (toolbar FAB on right, DateStripFab on left) */
/* For a centered InputBar with max-width 450px, left edge is at 50% - 225px */
/* DateStripFab is positioned at: 50% - 225px - 8px gap - 54px fab width */
/* Note: In sidebar mode, InputBar uses default 700px max-width */
:global(.quick-input-bar.has-fab-right .input-container),
:global(.quick-input-bar.has-fab-left .input-container) {
max-width: 450px;
}
/* On smaller screens (<900px), FABs move to fixed positions (left: 1rem, right: 1rem) */
@media (max-width: 900px) {
:global(.quick-input-bar.has-fab-right .input-container) {
max-width: calc(100% - 140px); /* 54px FAB + padding */
margin-left: auto;
margin-right: 0;
}
:global(.quick-input-bar.has-fab-left .input-container) {
max-width: calc(100% - 140px); /* 54px FAB + padding */
margin-left: 0;
margin-right: auto;
}
:global(.quick-input-bar.has-fab-right.has-fab-left .input-container) {
max-width: calc(100% - 200px); /* Both FABs */
margin-left: auto;
margin-right: auto;
}
}
/* Mobile: InputBar in its own row (above PillNav), Settings FAB stays next to InputBar */
@media (max-width: 640px) {
/* InputBar takes all available space up to the FAB */
:global(.quick-input-bar.has-fab-right .input-container) {
max-width: none;
width: 100%;
margin: 0;
}
:global(.quick-input-bar.has-fab-right) {
padding-left: 1rem;
padding-right: calc(54px + 1rem + 8px); /* FAB width + margin + gap */
}
}
</style>

View file

@ -7,12 +7,7 @@
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { isSidebarMode as sidebarModeStore } from '$lib/stores/navigation';
import WeekView from '$lib/components/calendar/WeekView.svelte';
import DayView from '$lib/components/calendar/DayView.svelte';
import MonthView from '$lib/components/calendar/MonthView.svelte';
import MultiDayView from '$lib/components/calendar/MultiDayView.svelte';
import YearView from '$lib/components/calendar/YearView.svelte';
import ViewCarousel from '$lib/components/calendar/ViewCarousel.svelte';
import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte';
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
import { CalendarViewSkeleton } from '$lib/components/skeletons';
@ -103,8 +98,8 @@
</svelte:head>
<div class="calendar-layout">
<!-- Left Sidebar -->
<aside class="calendar-sidebar" class:collapsed={settingsStore.sidebarCollapsed}>
<!-- Desktop: Left Sidebar -->
<aside class="calendar-sidebar desktop-only" class:collapsed={settingsStore.sidebarCollapsed}>
<!-- Collapse button at top -->
<button
class="sidebar-collapse-btn"
@ -124,63 +119,25 @@
<TodoSidebarSection maxItems={5} />
</aside>
<!-- FAB when sidebar is collapsed -->
{#if settingsStore.sidebarCollapsed}
<div class="sidebar-fab" class:pill-sidebar={$sidebarModeStore}>
<button
class="fab-expand"
onclick={() => settingsStore.toggleSidebar()}
title={$_('calendar.showSidebar')}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</button>
</div>
{/if}
<!-- Main Calendar Area -->
<div class="calendar-main" class:expanded={settingsStore.sidebarCollapsed}>
<div class="calendar-content">
{#if !initialized}
<CalendarViewSkeleton />
{:else if viewStore.viewType === 'day'}
<DayView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{:else if viewStore.viewType === '5day'}
<MultiDayView
dayCount={5}
onQuickCreate={handleQuickCreate}
onEventClick={handleEventClick}
/>
{:else if viewStore.viewType === 'week'}
<WeekView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{:else if viewStore.viewType === '10day'}
<MultiDayView
dayCount={10}
onQuickCreate={handleQuickCreate}
onEventClick={handleEventClick}
/>
{:else if viewStore.viewType === '14day'}
<MultiDayView
dayCount={14}
onQuickCreate={handleQuickCreate}
onEventClick={handleEventClick}
/>
{:else if viewStore.viewType === 'month'}
<MonthView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{:else if viewStore.viewType === 'year'}
<YearView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{:else}
<WeekView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
<ViewCarousel onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{/if}
</div>
</div>
<!-- Mobile: Bottom Todo Section -->
<aside
class="calendar-sidebar-mobile mobile-only"
class:collapsed={settingsStore.sidebarCollapsed}
>
<TodoSidebarSection maxItems={3} />
</aside>
<!-- Quick Event Overlay (for both create and edit) -->
{#if showQuickOverlay}
{#key overlayKey}
@ -201,13 +158,24 @@
display: flex;
gap: 1.5rem;
width: 100%;
flex: 1;
min-height: 0;
position: relative;
}
/* Desktop only elements */
.desktop-only {
display: flex;
}
/* Mobile only elements - hidden by default */
.mobile-only {
display: none;
}
.calendar-sidebar {
width: 260px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 1rem;
position: relative;
@ -251,60 +219,13 @@
color: hsl(var(--color-foreground));
}
/* FAB container */
.sidebar-fab {
position: fixed;
left: 1rem;
bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 50;
animation: fab-slide-in 300ms cubic-bezier(0.4, 0, 0.2, 1);
transition: left 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar-fab.pill-sidebar {
left: 195px;
}
@keyframes fab-slide-in {
from {
opacity: 0;
transform: translateX(-20px) scale(0.8);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
.fab-expand {
width: 48px;
height: 48px;
border-radius: var(--radius-full);
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 150ms ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
border: 1px solid hsl(var(--color-border));
}
.fab-expand:hover {
background: hsl(var(--color-muted));
transform: scale(1.05);
}
.calendar-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
background: hsl(var(--color-surface));
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--color-border));
@ -318,15 +239,109 @@
.calendar-content {
flex: 1;
min-height: 0;
overflow: hidden;
}
@media (max-width: 1024px) {
.calendar-sidebar {
display: none;
/* Mobile: Bottom Todo Section */
.calendar-sidebar-mobile {
width: 100%;
flex-direction: column;
background: hsl(var(--color-surface));
border-top: 1px solid hsl(var(--color-border));
padding: 0.75rem;
overflow-y: auto;
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.calendar-sidebar-mobile.collapsed {
height: 0;
flex: 0;
padding: 0;
opacity: 0;
overflow: hidden;
border: none;
}
/* Mobile Layout - 50/50 Splitscreen */
@media (max-width: 768px) {
.calendar-layout {
flex-direction: column;
gap: 0;
flex: 1;
height: 100%; /* Fill parent container */
min-height: 0;
overflow: hidden;
}
.sidebar-fab {
/* Hide desktop elements on mobile */
.desktop-only {
display: none !important;
}
/* Show mobile elements */
.mobile-only {
display: flex;
}
/* Calendar container */
.calendar-main {
border-radius: 0;
border: none;
min-height: 0;
overflow: hidden;
}
/* When todos are visible: 50/50 split */
.calendar-layout:has(.calendar-sidebar-mobile:not(.collapsed)) .calendar-main {
flex: 0 0 50%;
height: 50%;
}
/* When todos are collapsed: calendar takes full space */
.calendar-layout:has(.calendar-sidebar-mobile.collapsed) .calendar-main {
flex: 1;
height: 100%;
}
/* Calendar content must scroll internally */
.calendar-content {
height: 100%;
overflow-y: auto;
}
/* Todos section takes other half */
.calendar-sidebar-mobile {
display: flex;
flex-direction: column;
flex: 0 0 50%;
height: 50%;
max-height: none;
border-radius: 0;
margin-bottom: 0;
padding: 0;
border-top: none;
overflow: hidden;
}
/* Make TodoSidebarSection fill the container */
.calendar-sidebar-mobile > :global(*) {
flex: 1;
min-height: 0;
}
.calendar-sidebar-mobile.collapsed {
flex: 0;
height: 0;
padding: 0;
border: none;
}
}
/* Tablet: Keep desktop layout but smaller sidebar */
@media (min-width: 769px) and (max-width: 1024px) {
.calendar-sidebar {
width: 220px;
}
}
</style>

View file

@ -1,238 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { format, parseISO, isToday, isTomorrow, addDays, startOfDay, endOfDay } from 'date-fns';
import { de } from 'date-fns/locale';
import { AgendaSkeleton } from '$lib/components/skeletons';
let loading = $state(true);
// Group events by date
let groupedEvents = $derived.by(() => {
// Safety check: ensure events is an array
const currentEvents = eventsStore.events ?? [];
if (!Array.isArray(currentEvents)) return [];
const groups: Map<string, typeof currentEvents> = new Map();
for (const event of currentEvents) {
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const dateKey = format(start, 'yyyy-MM-dd');
if (!groups.has(dateKey)) {
groups.set(dateKey, []);
}
groups.get(dateKey)!.push(event);
}
// Sort groups by date
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([dateKey, events]) => ({
date: parseISO(dateKey),
events: events.sort((a, b) => {
const aStart = typeof a.startTime === 'string' ? parseISO(a.startTime) : a.startTime;
const bStart = typeof b.startTime === 'string' ? parseISO(b.startTime) : b.startTime;
return aStart.getTime() - bStart.getTime();
}),
}));
});
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Fetch events for next 30 days
const start = startOfDay(new Date());
const end = endOfDay(addDays(start, 30));
await eventsStore.fetchEvents(start, end);
loading = false;
});
function formatDateHeader(date: Date) {
if (isToday(date)) {
return 'Heute';
}
if (isTomorrow(date)) {
return 'Morgen';
}
return format(date, 'EEEE, d. MMMM', { locale: de });
}
function handleEventClick(eventId: string) {
// Navigate to calendar with event modal
goto(`/?event=${eventId}`);
}
</script>
<svelte:head>
<title>Agenda | Kalender</title>
</svelte:head>
<div class="agenda-page">
<header class="page-header">
<h1>Agenda</h1>
<p class="subtitle">Ihre kommenden Termine</p>
</header>
{#if loading}
<AgendaSkeleton />
{:else if groupedEvents.length === 0}
<div class="empty-state card">
<p>Keine Termine in den nächsten 30 Tagen</p>
<button class="btn btn-primary" onclick={() => goto('/event/new')}> Termin erstellen </button>
</div>
{:else}
<div class="event-list">
{#each groupedEvents as group}
<div class="date-group">
<h2 class="date-header" class:today={isToday(group.date)}>
{formatDateHeader(group.date)}
</h2>
{#each group.events as event}
<button class="event-item card" onclick={() => handleEventClick(event.id)}>
<div
class="color-bar"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
></div>
<div class="event-content">
<div class="event-time">
{#if event.isAllDay}
Ganztägig
{:else}
{format(
typeof event.startTime === 'string'
? parseISO(event.startTime)
: event.startTime,
'HH:mm'
)} -
{format(
typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime,
'HH:mm'
)}
{/if}
</div>
<div class="event-title">{event.title}</div>
{#if event.location}
<div class="event-location">{event.location}</div>
{/if}
</div>
</button>
{/each}
</div>
{/each}
</div>
{/if}
</div>
<style>
.agenda-page {
max-width: 600px;
margin: 0 auto;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 700;
color: hsl(var(--color-foreground));
margin: 0 0 0.25rem 0;
}
.subtitle {
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.empty-state {
text-align: center;
padding: 3rem;
}
.empty-state p {
color: hsl(var(--color-muted-foreground));
margin-bottom: 1rem;
}
.event-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.date-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.date-header {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
padding-left: 0.5rem;
}
.date-header.today {
color: hsl(var(--color-primary));
}
.event-item {
display: flex;
gap: 1rem;
padding: 1rem;
cursor: pointer;
border: none;
text-align: left;
width: 100%;
transition: transform var(--transition-fast);
}
.event-item:hover {
transform: translateX(4px);
}
.color-bar {
width: 4px;
border-radius: 2px;
flex-shrink: 0;
}
.event-content {
flex: 1;
min-width: 0;
}
.event-time {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin-bottom: 0.25rem;
}
.event-title {
font-weight: 500;
color: hsl(var(--color-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-location {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.25rem;
}
</style>

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { SubscriptionPage } from '@manacore/shared-subscription-ui';
import { toast } from '$lib/stores/toast';
import { toast } from '$lib/stores/toast.svelte';
function handleSubscribe(planId: string) {
console.log('Subscribe to plan:', planId);

File diff suppressed because it is too large Load diff

View file

@ -1,86 +1,121 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { TagList, TagEditModal, type Tag } from '@manacore/shared-ui';
import { MagnifyingGlass, Plus, CaretLeft } from '@manacore/shared-icons';
import { Modal, Input, TagColorPicker, TagBadge } from '@manacore/shared-ui';
import { MagnifyingGlass, Plus, CaretLeft, FolderSimple } from '@manacore/shared-icons';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import { eventTagGroupsStore } from '$lib/stores/event-tag-groups.svelte';
import { GroupedTagList } from '$lib/components/tags';
import type { EventTag } from '@calendar/shared';
let searchQuery = $state('');
let showModal = $state(false);
let showTagModal = $state(false);
let editingTag = $state<EventTag | null>(null);
let selectedGroupId = $state<string | null>(null);
// Filtered tags based on search
const filteredTags = $derived.by(() => {
if (!searchQuery.trim()) return eventTagsStore.tags;
const query = searchQuery.toLowerCase();
return eventTagsStore.tags.filter((t) => t.name.toLowerCase().includes(query));
});
// Convert EventTag to Tag type for shared-ui components
function eventTagToTag(tag: EventTag): Tag {
return {
id: tag.id,
name: tag.name,
color: tag.color,
};
}
// Filter groups that have matching tags (for search)
const filteredGroups = $derived.by(() => {
if (!searchQuery.trim()) return eventTagGroupsStore.groups;
// Only show groups that have at least one matching tag
const tagGroupIds = new Set(filteredTags.map((t) => t.groupId).filter(Boolean));
return eventTagGroupsStore.groups.filter((g) => tagGroupIds.has(g.id));
});
function openCreateModal() {
editingTag = null;
showModal = true;
}
// Form state for custom tag edit modal with group selection
let tagName = $state('');
let tagColor = $state('#3B82F6');
function openEditModal(tag: Tag) {
const eventTag = eventTagsStore.tags.find((t) => t.id === tag.id);
if (eventTag) {
editingTag = eventTag;
showModal = true;
const DEFAULT_COLOR = '#3B82F6';
// Reset form when editing tag changes
$effect(() => {
if (showTagModal) {
tagName = editingTag?.name ?? '';
tagColor = editingTag?.color ?? DEFAULT_COLOR;
selectedGroupId = editingTag?.groupId ?? null;
}
});
function openCreateTagModal() {
editingTag = null;
selectedGroupId = null;
showTagModal = true;
}
function closeModal() {
showModal = false;
function openEditTagModal(tag: EventTag) {
editingTag = tag;
showTagModal = true;
}
function closeTagModal() {
showTagModal = false;
editingTag = null;
}
async function handleSave(name: string, color: string) {
async function handleSaveTag() {
if (!tagName.trim()) return;
try {
if (editingTag) {
await eventTagsStore.updateTag(editingTag.id, { name, color });
await eventTagsStore.updateTag(editingTag.id, {
name: tagName.trim(),
color: tagColor,
groupId: selectedGroupId,
});
} else {
await eventTagsStore.createTag({ name, color });
await eventTagsStore.createTag({
name: tagName.trim(),
color: tagColor,
groupId: selectedGroupId ?? undefined,
});
}
closeModal();
closeTagModal();
// Refresh groups to update tag counts
eventTagGroupsStore.fetchGroups();
} catch (e) {
console.error('Failed to save tag:', e);
}
}
async function handleDelete() {
async function handleDeleteTag() {
if (!editingTag) return;
if (!confirm(`Tag "${editingTag.name}" wirklich löschen?`)) return;
try {
await eventTagsStore.deleteTag(editingTag.id);
closeModal();
closeTagModal();
// Refresh groups to update tag counts
eventTagGroupsStore.fetchGroups();
} catch (e) {
console.error('Failed to delete tag:', e);
}
}
async function handleDeleteFromList(tag: Tag) {
if (!confirm(`Tag "${tag.name}" wirklich löschen?`)) return;
try {
await eventTagsStore.deleteTag(tag.id);
} catch (e) {
console.error('Failed to delete tag:', e);
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' && tagName.trim()) {
e.preventDefault();
handleSaveTag();
}
}
onMount(() => {
if (eventTagsStore.tags.length === 0) {
eventTagsStore.fetchTags();
}
const previewTag = $derived({ name: tagName || 'Tag Name', color: tagColor });
const isLoading = $derived(eventTagsStore.loading || eventTagGroupsStore.loading);
onMount(async () => {
// Fetch both tags and groups in parallel
await Promise.all([
eventTagsStore.tags.length === 0 ? eventTagsStore.fetchTags() : Promise.resolve(),
eventTagGroupsStore.groups.length === 0
? eventTagGroupsStore.fetchGroups()
: Promise.resolve(),
]);
});
</script>
@ -95,7 +130,10 @@
<CaretLeft size={20} weight="bold" />
</a>
<h1 class="title">Tags</h1>
<button onclick={openCreateModal} class="add-button" aria-label="Neues Tag">
<a href="/tags/groups" class="groups-button" aria-label="Gruppen verwalten">
<FolderSimple size={20} weight="bold" />
</a>
<button onclick={openCreateTagModal} class="add-button" aria-label="Neues Tag">
<Plus size={20} weight="bold" />
</button>
</header>
@ -111,34 +149,31 @@
/>
</div>
{#if eventTagsStore.error}
{#if eventTagsStore.error || eventTagGroupsStore.error}
<div class="error-banner" role="alert">
<span>{eventTagsStore.error}</span>
<span>{eventTagsStore.error || eventTagGroupsStore.error}</span>
</div>
{/if}
<!-- Tag List using shared component -->
<TagList
tags={filteredTags.map(eventTagToTag)}
loading={eventTagsStore.loading}
onEdit={openEditModal}
onDelete={handleDeleteFromList}
<!-- Grouped Tag List -->
<GroupedTagList
groups={filteredGroups}
tags={filteredTags}
loading={isLoading}
onEditTag={openEditTagModal}
emptyMessage={searchQuery ? 'Keine Tags gefunden' : 'Keine Tags vorhanden'}
emptyDescription={searchQuery
? `Kein Tag für "${searchQuery}" gefunden`
: 'Erstelle dein erstes Tag'}
/>
{#if !eventTagsStore.loading && eventTagsStore.tags.length > 0}
{#if !isLoading && eventTagsStore.tags.length > 0}
<p class="tags-count">
{eventTagsStore.tags.length}
{eventTagsStore.tags.length === 1 ? 'Tag' : 'Tags'}
</p>
{/if}
{#if !eventTagsStore.loading && eventTagsStore.tags.length === 0 && !searchQuery}
{#if !isLoading && eventTagsStore.tags.length === 0 && !searchQuery}
<div class="empty-cta">
<button onclick={openCreateModal} class="btn btn-primary">
<button onclick={openCreateTagModal} class="btn btn-primary">
<Plus size={16} weight="bold" />
Neues Tag
</button>
@ -146,22 +181,80 @@
{/if}
</div>
<!-- Create/Edit Modal using shared component -->
<TagEditModal
tag={editingTag ? eventTagToTag(editingTag) : null}
isOpen={showModal}
onClose={closeModal}
onSave={handleSave}
onDelete={editingTag ? handleDelete : undefined}
<!-- Custom Tag Edit Modal with Group Selection -->
<Modal
visible={showTagModal}
onClose={closeTagModal}
title={editingTag ? 'Tag bearbeiten' : 'Neues Tag'}
saveLabel={editingTag ? 'Speichern' : 'Erstellen'}
deleteLabel="Löschen"
cancelLabel="Abbrechen"
namePlaceholder="Tag Name"
colorLabel="Farbe"
previewLabel="Vorschau"
deleteConfirmMessage={`Tag "${editingTag?.name || ''}" wirklich löschen?`}
/>
maxWidth="sm"
>
<div class="space-y-6">
<!-- Name Input -->
<div>
<Input bind:value={tagName} placeholder="Tag Name" onkeydown={handleKeyDown} />
</div>
<!-- Group Selection -->
<div>
<span class="block text-sm font-medium text-muted-foreground mb-3"> Gruppe </span>
<select bind:value={selectedGroupId} class="group-select">
<option value={null}>Keine Gruppe</option>
{#each eventTagGroupsStore.groups as group (group.id)}
<option value={group.id}>
{group.name}
</option>
{/each}
</select>
</div>
<!-- Color Picker -->
<div>
<span class="block text-sm font-medium text-muted-foreground mb-3"> Farbe </span>
<TagColorPicker selectedColor={tagColor} onColorChange={(c) => (tagColor = c)} />
</div>
<!-- Preview -->
<div>
<span class="block text-sm font-medium text-muted-foreground mb-3"> Vorschau </span>
<div class="flex items-center gap-2">
<TagBadge tag={previewTag} />
</div>
</div>
</div>
{#snippet footer()}
<div class="flex items-center justify-between">
<div>
{#if editingTag}
<button
type="button"
onclick={handleDeleteTag}
class="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
Löschen
</button>
{/if}
</div>
<div class="flex items-center gap-3">
<button
type="button"
onclick={closeTagModal}
class="px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/10 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="button"
onclick={handleSaveTag}
disabled={!tagName.trim()}
class="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingTag ? 'Speichern' : 'Erstellen'}
</button>
</div>
</div>
{/snippet}
</Modal>
<style>
.page-container {
@ -174,7 +267,7 @@
.header {
display: flex;
align-items: center;
gap: 1rem;
gap: 0.75rem;
padding: 1rem 0;
margin-bottom: 0.5rem;
}
@ -203,6 +296,22 @@
color: hsl(var(--foreground));
}
.groups-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--muted));
color: hsl(var(--foreground));
transition: all 0.2s ease;
}
.groups-button:hover {
background: hsl(var(--muted-foreground) / 0.2);
}
.add-button {
display: flex;
align-items: center;
@ -254,6 +363,25 @@
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
}
/* Group Select */
.group-select {
width: 100%;
padding: 0.75rem 1rem;
border: 1.5px solid hsl(var(--border));
border-radius: 0.75rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.9375rem;
cursor: pointer;
transition: all 0.2s ease;
}
.group-select:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
}
/* Error */
.error-banner {
display: flex;

View file

@ -0,0 +1,375 @@
<script lang="ts">
import { onMount } from 'svelte';
import { CaretLeft, Plus, Pencil, Trash } from '@manacore/shared-icons';
import { eventTagGroupsStore } from '$lib/stores/event-tag-groups.svelte';
import { TagGroupEditModal } from '$lib/components/tags';
import type { EventTagGroup } from '@calendar/shared';
let showModal = $state(false);
let editingGroup = $state<EventTagGroup | null>(null);
function openCreateModal() {
editingGroup = null;
showModal = true;
}
function openEditModal(group: EventTagGroup) {
editingGroup = group;
showModal = true;
}
function closeModal() {
showModal = false;
editingGroup = null;
}
async function handleSave(name: string, color: string) {
try {
if (editingGroup) {
await eventTagGroupsStore.updateGroup(editingGroup.id, { name, color });
} else {
await eventTagGroupsStore.createGroup({ name, color });
}
closeModal();
} catch (e) {
console.error('Failed to save group:', e);
}
}
async function handleDelete() {
if (!editingGroup) return;
try {
await eventTagGroupsStore.deleteGroup(editingGroup.id);
closeModal();
} catch (e) {
console.error('Failed to delete group:', e);
}
}
async function handleDeleteFromList(group: EventTagGroup) {
if (
!confirm(
`Gruppe "${group.name}" wirklich löschen? Tags in dieser Gruppe werden nicht gelöscht.`
)
)
return;
try {
await eventTagGroupsStore.deleteGroup(group.id);
} catch (e) {
console.error('Failed to delete group:', e);
}
}
onMount(() => {
if (eventTagGroupsStore.groups.length === 0) {
eventTagGroupsStore.fetchGroups();
}
});
</script>
<svelte:head>
<title>Tag-Gruppen - Kalender</title>
</svelte:head>
<div class="page-container">
<!-- Header -->
<header class="header">
<a href="/tags" class="back-button" aria-label="Zurück zu Tags">
<CaretLeft size={20} weight="bold" />
</a>
<h1 class="title">Tag-Gruppen</h1>
<button onclick={openCreateModal} class="add-button" aria-label="Neue Gruppe">
<Plus size={20} weight="bold" />
</button>
</header>
{#if eventTagGroupsStore.error}
<div class="error-banner" role="alert">
<span>{eventTagGroupsStore.error}</span>
</div>
{/if}
{#if eventTagGroupsStore.loading}
<div class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
{:else if eventTagGroupsStore.groups.length === 0}
<div class="empty-state">
<p class="text-muted-foreground mb-4">Noch keine Gruppen vorhanden</p>
<button onclick={openCreateModal} class="btn btn-primary">
<Plus size={16} weight="bold" />
Neue Gruppe erstellen
</button>
</div>
{:else}
<div class="groups-list">
{#each eventTagGroupsStore.groups as group (group.id)}
<div class="group-item">
<div class="group-info">
<div class="group-color" style="background-color: {group.color}"></div>
<div class="group-details">
<span class="group-name">{group.name}</span>
<span class="group-tag-count">
{group.tagCount ?? 0}
{(group.tagCount ?? 0) === 1 ? 'Tag' : 'Tags'}
</span>
</div>
</div>
<div class="group-actions">
<button
type="button"
onclick={() => openEditModal(group)}
class="action-btn"
aria-label="Gruppe bearbeiten"
>
<Pencil size={16} />
</button>
<button
type="button"
onclick={() => handleDeleteFromList(group)}
class="action-btn action-btn-delete"
aria-label="Gruppe löschen"
>
<Trash size={16} />
</button>
</div>
</div>
{/each}
</div>
<p class="groups-count">
{eventTagGroupsStore.groups.length}
{eventTagGroupsStore.groups.length === 1 ? 'Gruppe' : 'Gruppen'}
</p>
{/if}
<!-- Info about ungrouped tags -->
{#if eventTagGroupsStore.ungroupedTagCount > 0}
<div class="ungrouped-info">
<span>
{eventTagGroupsStore.ungroupedTagCount}
{eventTagGroupsStore.ungroupedTagCount === 1 ? 'Tag' : 'Tags'} ohne Gruppe
</span>
</div>
{/if}
</div>
<!-- Group Edit Modal -->
<TagGroupEditModal
group={editingGroup}
isOpen={showModal}
onClose={closeModal}
onSave={handleSave}
onDelete={editingGroup ? handleDelete : undefined}
/>
<style>
.page-container {
max-width: 640px;
margin: 0 auto;
padding: 0 1rem 2rem;
}
/* Header */
.header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 0;
margin-bottom: 1rem;
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--muted));
color: hsl(var(--foreground));
transition: all 0.2s ease;
}
.back-button:hover {
background: hsl(var(--muted-foreground) / 0.2);
transform: translateX(-2px);
}
.title {
flex: 1;
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
}
.add-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.add-button:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
}
/* Error */
.error-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: hsl(0 84% 60% / 0.1);
border: 1px solid hsl(0 84% 60% / 0.3);
border-radius: 0.75rem;
color: hsl(0 84% 60%);
margin-bottom: 1.5rem;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 1rem;
}
/* Groups List */
.groups-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.group-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
transition: all 0.2s ease;
}
.group-item:hover {
background: hsl(var(--muted) / 0.3);
}
.group-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.group-color {
width: 1rem;
height: 1rem;
border-radius: 50%;
flex-shrink: 0;
}
.group-details {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.group-name {
font-weight: 500;
color: hsl(var(--foreground));
}
.group-tag-count {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.group-actions {
display: flex;
align-items: center;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
}
.group-item:hover .group-actions {
opacity: 1;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
border-radius: 0.5rem;
color: hsl(var(--muted-foreground));
background: transparent;
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.action-btn:hover {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
}
.action-btn-delete:hover {
color: hsl(0 84% 60%);
background: hsl(0 84% 60% / 0.1);
}
/* Count */
.groups-count {
text-align: center;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin-top: 1.5rem;
}
/* Ungrouped Info */
.ungrouped-info {
text-align: center;
font-size: 0.8125rem;
color: hsl(var(--muted-foreground));
margin-top: 0.75rem;
padding: 0.5rem;
background: hsl(var(--muted) / 0.3);
border-radius: 0.5rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border-radius: 0.625rem;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
border: none;
text-decoration: none;
}
.btn-primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.btn-primary:hover {
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
}
</style>

View file

@ -1,486 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import type { Task } from '$lib/api/todos';
import type { CalendarEvent } from '@calendar/shared';
import AgendaItem from '$lib/components/agenda/AgendaItem.svelte';
import AgendaFilters from '$lib/components/agenda/AgendaFilters.svelte';
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
import QuickAddTodo from '$lib/components/todo/QuickAddTodo.svelte';
import { AgendaSkeleton } from '$lib/components/skeletons';
import {
format,
parseISO,
isToday,
isTomorrow,
addDays,
startOfDay,
endOfDay,
isBefore,
} from 'date-fns';
import { de } from 'date-fns/locale';
import { CheckSquare, AlertTriangle, Plus } from 'lucide-svelte';
// State
let loading = $state(true);
let showEvents = $state(true);
let showTodos = $state(true);
let timeRange = $state<'7' | '30' | 'all'>('30');
let selectedTask = $state<Task | null>(null);
let showQuickAdd = $state(false);
// Combined and grouped items
type AgendaGroup = {
date: Date;
items: Array<{ type: 'event' | 'todo'; event?: CalendarEvent; todo?: Task }>;
};
let groupedItems = $derived.by(() => {
const groups = new Map<string, AgendaGroup['items']>();
const today = startOfDay(new Date());
// Add events
if (showEvents) {
const currentEvents = eventsStore.events ?? [];
if (Array.isArray(currentEvents)) {
for (const event of currentEvents) {
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const dateKey = format(start, 'yyyy-MM-dd');
if (!groups.has(dateKey)) {
groups.set(dateKey, []);
}
groups.get(dateKey)!.push({ type: 'event', event });
}
}
}
// Add todos
if (showTodos) {
const currentTodos = todosStore.todos ?? [];
if (Array.isArray(currentTodos)) {
for (const todo of currentTodos) {
if (todo.isCompleted) continue; // Skip completed todos
let dateKey: string;
if (todo.dueDate) {
const dueDate =
typeof todo.dueDate === 'string' ? parseISO(todo.dueDate) : todo.dueDate;
// Group overdue todos under today
if (isBefore(startOfDay(dueDate), today)) {
dateKey = format(today, 'yyyy-MM-dd');
} else {
dateKey = format(dueDate, 'yyyy-MM-dd');
}
} else {
// Todos without due date go under today
dateKey = format(today, 'yyyy-MM-dd');
}
if (!groups.has(dateKey)) {
groups.set(dateKey, []);
}
groups.get(dateKey)!.push({ type: 'todo', todo });
}
}
}
// Sort groups by date and items within each group
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([dateKey, items]) => ({
date: parseISO(dateKey),
items: items.sort((a, b) => {
// Todos before events
if (a.type !== b.type) return a.type === 'todo' ? -1 : 1;
// Sort events by time
if (a.type === 'event' && b.type === 'event' && a.event && b.event) {
const aStart =
typeof a.event.startTime === 'string'
? parseISO(a.event.startTime)
: a.event.startTime;
const bStart =
typeof b.event.startTime === 'string'
? parseISO(b.event.startTime)
: b.event.startTime;
return aStart.getTime() - bStart.getTime();
}
// Sort todos by priority
if (a.type === 'todo' && b.type === 'todo' && a.todo && b.todo) {
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 };
return priorityOrder[a.todo.priority] - priorityOrder[b.todo.priority];
}
return 0;
}),
}));
});
// Stats
const overdueCount = $derived(todosStore.overdueTodos.length);
const todayCount = $derived(todosStore.todaysTodos.length);
const totalActiveCount = $derived(todosStore.activeTodosCount);
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Fetch data based on time range
await fetchData();
loading = false;
});
async function fetchData() {
const start = startOfDay(new Date());
const days = timeRange === '7' ? 7 : timeRange === '30' ? 30 : 90;
const end = endOfDay(addDays(start, days));
await Promise.all([
eventsStore.fetchEvents(start, end),
todosStore.fetchTodos(start, end),
todosStore.fetchTodayTodos(),
]);
}
function formatDateHeader(date: Date) {
if (isToday(date)) {
return 'Heute';
}
if (isTomorrow(date)) {
return 'Morgen';
}
return format(date, 'EEEE, d. MMMM', { locale: de });
}
function handleEventClick(eventId: string) {
goto(`/?event=${eventId}`);
}
function handleTodoClick(task: Task) {
selectedTask = task;
}
function handleModalClose() {
selectedTask = null;
}
function toggleEvents() {
showEvents = !showEvents;
}
function toggleTodos() {
showTodos = !showTodos;
}
function handleRangeChange(range: '7' | '30' | 'all') {
timeRange = range;
loading = true;
fetchData().then(() => (loading = false));
}
</script>
<svelte:head>
<title>Aufgaben | Kalender</title>
</svelte:head>
<div class="tasks-page">
<header class="page-header">
<div class="header-content">
<div class="header-icon">
<CheckSquare size={24} />
</div>
<div>
<h1>Aufgaben</h1>
<p class="subtitle">Ihre Termine und Aufgaben auf einen Blick</p>
</div>
</div>
<!-- Stats -->
<div class="stats">
{#if overdueCount > 0}
<span class="stat overdue">
<AlertTriangle size={14} />
{overdueCount} überfällig
</span>
{/if}
<span class="stat">{todayCount} heute</span>
<span class="stat">{totalActiveCount} gesamt</span>
</div>
</header>
<!-- Filters -->
<AgendaFilters
{showEvents}
{showTodos}
{timeRange}
onToggleEvents={toggleEvents}
onToggleTodos={toggleTodos}
onRangeChange={handleRangeChange}
/>
<!-- Quick Add -->
<div class="quick-add-section">
{#if showQuickAdd}
<QuickAddTodo
placeholder="Neue Aufgabe hinzufügen..."
autofocus
showButton={false}
onsubmit={() => (showQuickAdd = false)}
oncancel={() => (showQuickAdd = false)}
/>
{:else}
<button type="button" class="quick-add-button" onclick={() => (showQuickAdd = true)}>
<Plus size={16} />
<span>Neue Aufgabe</span>
</button>
{/if}
</div>
<!-- Content -->
{#if loading}
<AgendaSkeleton />
{:else if !todosStore.serviceAvailable}
<div class="error-state card">
<AlertTriangle size={24} />
<p>Todo-Service ist nicht erreichbar</p>
<p class="hint">Bitte versuchen Sie es später erneut</p>
</div>
{:else if groupedItems.length === 0}
<div class="empty-state card">
<CheckSquare size={32} />
<p>Keine Einträge gefunden</p>
<p class="hint">
{#if !showEvents && !showTodos}
Aktivieren Sie mindestens einen Filter
{:else}
Erstellen Sie eine neue Aufgabe oder ändern Sie den Zeitraum
{/if}
</p>
</div>
{:else}
<div class="item-list">
{#each groupedItems as group}
<div class="date-group">
<h2 class="date-header" class:today={isToday(group.date)}>
{formatDateHeader(group.date)}
<span class="item-count">({group.items.length})</span>
</h2>
<div class="items">
{#each group.items as item}
{#if item.type === 'event' && item.event}
<AgendaItem
type="event"
event={item.event}
onclick={() => handleEventClick(item.event!.id)}
/>
{:else if item.type === 'todo' && item.todo}
<AgendaItem
type="todo"
todo={item.todo}
onclick={() => handleTodoClick(item.todo!)}
/>
{/if}
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Detail Modal -->
{#if selectedTask}
<TodoDetailModal task={selectedTask} onClose={handleModalClose} />
{/if}
<style>
.tasks-page {
max-width: 700px;
margin: 0 auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.5rem;
}
.header-content {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: var(--radius-lg);
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
}
h1 {
font-size: 1.5rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0;
}
.subtitle {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin: 0.25rem 0 0;
}
.stats {
display: flex;
align-items: center;
gap: 0.75rem;
}
.stat {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
padding: 0.25rem 0.5rem;
background: hsl(var(--color-muted) / 0.5);
border-radius: var(--radius-sm);
}
.stat.overdue {
color: hsl(var(--color-danger));
background: hsl(var(--color-danger) / 0.1);
}
.quick-add-section {
margin-bottom: 0.5rem;
}
.quick-add-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem;
border-radius: var(--radius-lg);
border: 1px dashed hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
cursor: pointer;
transition: all 150ms ease;
}
.quick-add-button:hover {
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.05);
}
.item-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.date-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.date-header {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: 0.5rem;
}
.date-header.today {
color: hsl(var(--color-primary));
}
.item-count {
font-weight: 400;
font-size: 0.75rem;
}
.items {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.empty-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 3rem 1.5rem;
text-align: center;
color: hsl(var(--color-muted-foreground));
}
.empty-state :global(svg),
.error-state :global(svg) {
opacity: 0.5;
}
.error-state {
color: hsl(var(--color-danger));
}
.hint {
font-size: 0.8125rem;
opacity: 0.7;
margin: 0;
}
.card {
background: hsl(var(--color-surface));
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--color-border));
}
@media (max-width: 640px) {
.tasks-page {
padding: 1rem;
}
.page-header {
flex-direction: column;
align-items: stretch;
}
.stats {
justify-content: flex-start;
}
}
</style>

View file

@ -1,9 +1,28 @@
/**
* Calendar view types
*/
export type CalendarViewType =
| 'day'
| '3day'
| '5day'
| 'week'
| '10day'
| '14day'
| '30day'
| '60day'
| '90day'
| '365day'
| 'month'
| 'year'
| 'agenda'
| 'custom';
/**
* Calendar settings stored in JSONB
*/
export interface CalendarSettings {
/** Default view when opening the calendar */
defaultView?: 'day' | '5day' | 'week' | '10day' | '14day' | 'month' | 'year' | 'agenda';
defaultView?: CalendarViewType;
/** 0 = Sunday, 1 = Monday */
weekStartsOn?: 0 | 1;
/** Show week numbers in calendar views */
@ -57,19 +76,6 @@ export interface UpdateCalendarInput {
settings?: CalendarSettings;
}
/**
* Calendar view types
*/
export type CalendarViewType =
| 'day'
| '5day'
| 'week'
| '10day'
| '14day'
| 'month'
| 'year'
| 'agenda';
/**
* Default calendar colors
*/

View file

@ -18,6 +18,34 @@ export interface EventAttendee {
company?: string;
}
/**
* Responsible person for an event (single person accountable for the event)
*/
export interface ResponsiblePerson {
email: string;
name?: string;
/** Contact reference for linked contacts */
contactId?: string;
/** Cached photo URL from contact */
photoUrl?: string;
/** Cached company from contact */
company?: string;
}
/**
* Event tag group for organizing tags
*/
export interface EventTagGroup {
id: string;
userId: string;
name: string;
color: string;
sortOrder: number;
tagCount?: number;
createdAt: Date | string;
updatedAt: Date | string;
}
/**
* Event tag with color
*/
@ -26,6 +54,8 @@ export interface EventTag {
userId: string;
name: string;
color: string;
groupId?: string | null;
sortOrder?: number;
createdAt: Date | string;
updatedAt: Date | string;
}
@ -57,7 +87,9 @@ export interface EventMetadata {
url?: string;
/** Video conference URL (Zoom, Meet, etc.) */
conferenceUrl?: string;
/** Event attendees */
/** Responsible person for this event */
responsiblePerson?: ResponsiblePerson;
/** Event attendees/participants */
attendees?: EventAttendee[];
/** Event organizer email */
organizer?: string;

View file

@ -20,8 +20,22 @@ import {
IsDateString,
IsUUID,
MaxLength,
IsArray,
ValidateNested,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
class CustomDateDto {
@IsUUID()
id: string;
@IsString()
@MaxLength(100)
label: string;
@IsDateString()
date: string;
}
class CreateContactDto {
@IsString()
@ -107,6 +121,78 @@ class CreateContactDto {
@IsOptional()
notes?: string;
@IsArray()
@IsOptional()
@ValidateNested({ each: true })
@Type(() => CustomDateDto)
customDates?: CustomDateDto[];
// Social Media
@IsString()
@IsOptional()
@MaxLength(255)
linkedin?: string;
@IsString()
@IsOptional()
@MaxLength(100)
twitter?: string;
@IsString()
@IsOptional()
@MaxLength(255)
facebook?: string;
@IsString()
@IsOptional()
@MaxLength(100)
instagram?: string;
@IsString()
@IsOptional()
@MaxLength(255)
xing?: string;
@IsString()
@IsOptional()
@MaxLength(100)
github?: string;
@IsString()
@IsOptional()
@MaxLength(255)
youtube?: string;
@IsString()
@IsOptional()
@MaxLength(100)
tiktok?: string;
@IsString()
@IsOptional()
@MaxLength(100)
telegram?: string;
@IsString()
@IsOptional()
@MaxLength(50)
whatsapp?: string;
@IsString()
@IsOptional()
@MaxLength(50)
signal?: string;
@IsString()
@IsOptional()
@MaxLength(100)
discord?: string;
@IsString()
@IsOptional()
@MaxLength(100)
bluesky?: string;
@IsUUID()
@IsOptional()
organizationId?: string;
@ -168,6 +254,16 @@ export class ContactController {
return { contacts, total };
}
/**
* Get all contacts with birthdays (for calendar integration)
* Returns lightweight data: id, displayName, firstName, lastName, birthday, photoUrl
*/
@Get('birthdays')
async getBirthdays(@CurrentUser() user: CurrentUserData) {
const contacts = await this.contactService.findWithBirthdays(user.userId);
return { contacts };
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const contact = await this.contactService.findById(id, user.userId);

View file

@ -1,10 +1,19 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, or, ilike, desc, sql } from 'drizzle-orm';
import { eq, and, or, ilike, desc, sql, isNotNull } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { contacts } from '../db/schema';
import type { Contact, NewContact } from '../db/schema';
export interface ContactBirthdaySummary {
id: string;
displayName: string | null;
firstName: string | null;
lastName: string | null;
birthday: string;
photoUrl: string | null;
}
export interface ContactFilters {
search?: string;
isFavorite?: boolean;
@ -148,4 +157,34 @@ export class ContactService {
return Number(result[0]?.count || 0);
}
/**
* Find all contacts with birthdays (for calendar integration)
* Returns only essential fields for lightweight transfer
*/
async findWithBirthdays(userId: string): Promise<ContactBirthdaySummary[]> {
const result = await this.db
.select({
id: contacts.id,
displayName: contacts.displayName,
firstName: contacts.firstName,
lastName: contacts.lastName,
birthday: contacts.birthday,
photoUrl: contacts.photoUrl,
})
.from(contacts)
.where(
and(
eq(contacts.userId, userId),
eq(contacts.isArchived, false),
isNotNull(contacts.birthday)
)
)
.orderBy(contacts.lastName, contacts.firstName);
return result.map((c) => ({
...c,
birthday: c.birthday || '',
}));
}
}

View file

@ -31,6 +31,22 @@ export const contacts = pgTable('contacts', {
birthday: date('birthday'),
notes: text('notes'),
photoUrl: varchar('photo_url', { length: 500 }),
customDates: jsonb('custom_dates').$type<CustomDate[]>().default([]),
// Social Media
linkedin: varchar('linkedin', { length: 255 }),
twitter: varchar('twitter', { length: 100 }),
facebook: varchar('facebook', { length: 255 }),
instagram: varchar('instagram', { length: 100 }),
xing: varchar('xing', { length: 255 }),
github: varchar('github', { length: 100 }),
youtube: varchar('youtube', { length: 255 }),
tiktok: varchar('tiktok', { length: 100 }),
telegram: varchar('telegram', { length: 100 }),
whatsapp: varchar('whatsapp', { length: 50 }),
signal: varchar('signal', { length: 50 }),
discord: varchar('discord', { length: 100 }),
bluesky: varchar('bluesky', { length: 100 }),
// Flags
isFavorite: boolean('is_favorite').default(false),
@ -50,3 +66,9 @@ export const contacts = pgTable('contacts', {
export type Contact = typeof contacts.$inferSelect;
export type NewContact = typeof contacts.$inferInsert;
export interface CustomDate {
id: string;
label: string;
date: string;
}

View file

@ -31,8 +31,6 @@
},
"dependencies": {
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-splitscreen": "workspace:*",
"@manacore/shared-tags": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",
@ -43,7 +41,9 @@
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-splitscreen": "workspace:*",
"@manacore/shared-subscription-ui": "workspace:*",
"@manacore/shared-tags": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
@ -52,6 +52,7 @@
"d3-force": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"date-fns": "^4.1.0",
"lucide-svelte": "^0.556.0",
"svelte-i18n": "^4.0.1"
},

View file

@ -1,30 +1,4 @@
import { authStore } from '$lib/stores/auth.svelte';
import { API_BASE } from './config';
async function fetchWithAuth(url: string, options: RequestInit = {}) {
const token = await authStore.getAccessToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${url}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || 'Request failed');
}
return response.json();
}
import { fetchWithAuth } from './client';
export interface BatchResult {
success: number;

View file

@ -0,0 +1,71 @@
/**
* Centralized API client with authentication
*/
import { authStore } from '$lib/stores/auth.svelte';
import { API_BASE } from './config';
/**
* Make an authenticated API request
* @param url API endpoint (will be prefixed with API_BASE)
* @param options Fetch options
* @returns Parsed JSON response
*/
export async function fetchWithAuth<T = unknown>(
url: string,
options: RequestInit = {}
): Promise<T> {
const token = await authStore.getAccessToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${url}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || 'Request failed');
}
return response.json();
}
/**
* Make an authenticated API request without JSON content type
* Used for file uploads (FormData)
*/
export async function fetchWithAuthFormData<T = unknown>(
url: string,
options: RequestInit = {}
): Promise<T> {
const token = await authStore.getAccessToken();
const headers: HeadersInit = {
...(options.headers || {}),
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${url}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || 'Request failed');
}
return response.json();
}

View file

@ -1,7 +1,13 @@
import { PUBLIC_BACKEND_URL } from '$env/static/public';
import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
/**
* API Configuration
* Uses environment variable PUBLIC_BACKEND_URL with fallback for development
* Uses environment variables with fallbacks for development
*/
export const API_BASE = `${PUBLIC_BACKEND_URL || 'http://localhost:3015'}/api/v1`;
/**
* Mana Core Auth URL
* Central authentication service URL
*/
export const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';

View file

@ -1,33 +1,9 @@
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/auth.svelte';
import { API_BASE } from './config';
import { MANA_AUTH_URL } from './config';
import { fetchWithAuth, fetchWithAuthFormData } from './client';
import { createTagsClient, type Tag } from '@manacore/shared-tags';
async function fetchWithAuth(url: string, options: RequestInit = {}) {
const token = await authStore.getAccessToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${url}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || 'Request failed');
}
return response.json();
}
export interface Contact {
id: string;
userId: string;
@ -49,6 +25,27 @@ export interface Contact {
birthday?: string | null;
notes?: string | null;
photoUrl?: string | null;
customDates?: Array<{
id: string;
label: string;
date: string;
}> | null;
// Social Media
linkedin?: string | null;
twitter?: string | null;
facebook?: string | null;
instagram?: string | null;
xing?: string | null;
github?: string | null;
youtube?: string | null;
tiktok?: string | null;
telegram?: string | null;
whatsapp?: string | null;
signal?: string | null;
discord?: string | null;
bluesky?: string | null;
// Tags (populated by API)
tags?: Array<{ id: string; name: string; color: string | null }>;
isFavorite: boolean;
isArchived: boolean;
organizationId?: string | null;
@ -90,9 +87,19 @@ export interface ContactFilters {
offset?: number;
}
// API Response types
interface ContactResponse {
contact: Contact;
}
interface ContactListResponse {
contacts: Contact[];
total: number;
}
// Contacts API
export const contactsApi = {
async list(filters: ContactFilters = {}) {
async list(filters: ContactFilters = {}): Promise<ContactListResponse> {
const params = new URLSearchParams();
if (filters.search) params.set('search', filters.search);
if (filters.isFavorite !== undefined) params.set('isFavorite', String(filters.isFavorite));
@ -102,16 +109,16 @@ export const contactsApi = {
if (filters.offset) params.set('offset', String(filters.offset));
const query = params.toString();
return fetchWithAuth(`/contacts${query ? `?${query}` : ''}`);
return fetchWithAuth<ContactListResponse>(`/contacts${query ? `?${query}` : ''}`);
},
async get(id: string): Promise<Contact> {
const response = await fetchWithAuth(`/contacts/${id}`);
const response = await fetchWithAuth<ContactResponse>(`/contacts/${id}`);
return response.contact;
},
async create(data: Partial<Contact>): Promise<Contact> {
const response = await fetchWithAuth('/contacts', {
const response = await fetchWithAuth<ContactResponse>('/contacts', {
method: 'POST',
body: JSON.stringify(data),
});
@ -119,7 +126,7 @@ export const contactsApi = {
},
async update(id: string, data: Partial<Contact>): Promise<Contact> {
const response = await fetchWithAuth(`/contacts/${id}`, {
const response = await fetchWithAuth<ContactResponse>(`/contacts/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
@ -133,14 +140,14 @@ export const contactsApi = {
},
async toggleFavorite(id: string): Promise<Contact> {
const response = await fetchWithAuth(`/contacts/${id}/favorite`, {
const response = await fetchWithAuth<ContactResponse>(`/contacts/${id}/favorite`, {
method: 'POST',
});
return response.contact;
},
async toggleArchive(id: string): Promise<Contact> {
const response = await fetchWithAuth(`/contacts/${id}/archive`, {
const response = await fetchWithAuth<ContactResponse>(`/contacts/${id}/archive`, {
method: 'POST',
});
return response.contact;
@ -150,16 +157,6 @@ export const contactsApi = {
// Tags API - Uses central Tags API from mana-core-auth
// Contact-tag associations still use the Contacts backend
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return 'http://localhost:3001';
}
// Lazy-initialized tags client
let _tagsClient: ReturnType<typeof createTagsClient> | null = null;
@ -167,7 +164,7 @@ function getTagsClient() {
if (!browser) return null;
if (!_tagsClient) {
_tagsClient = createTagsClient({
authUrl: getAuthUrl(),
authUrl: MANA_AUTH_URL,
getToken: async () => {
const token = await authStore.getAccessToken();
return token || '';
@ -212,19 +209,19 @@ export const tagsApi = {
// Contact-tag associations still use Contacts backend
async addToContact(tagId: string, contactId: string): Promise<{ success: boolean }> {
return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, {
return fetchWithAuth<{ success: boolean }>(`/tags/${tagId}/contacts/${contactId}`, {
method: 'POST',
});
},
async removeFromContact(tagId: string, contactId: string): Promise<{ success: boolean }> {
return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, {
return fetchWithAuth<{ success: boolean }>(`/tags/${tagId}/contacts/${contactId}`, {
method: 'DELETE',
});
},
async getForContact(contactId: string): Promise<{ tagIds: string[] }> {
return fetchWithAuth(`/tags/contact/${contactId}`);
return fetchWithAuth<{ tagIds: string[] }>(`/tags/contact/${contactId}`);
},
// Create default tags via central Tags API
@ -236,44 +233,68 @@ export const tagsApi = {
},
};
// Notes API Response types
interface NotesListResponse {
notes: ContactNote[];
}
interface NoteResponse {
note: ContactNote;
}
// Notes API
export const notesApi = {
async list(contactId: string) {
return fetchWithAuth(`/contacts/${contactId}/notes`);
async list(contactId: string): Promise<NotesListResponse> {
return fetchWithAuth<NotesListResponse>(`/contacts/${contactId}/notes`);
},
async create(contactId: string, data: { content: string; isPinned?: boolean }) {
return fetchWithAuth(`/contacts/${contactId}/notes`, {
async create(
contactId: string,
data: { content: string; isPinned?: boolean }
): Promise<NoteResponse> {
return fetchWithAuth<NoteResponse>(`/contacts/${contactId}/notes`, {
method: 'POST',
body: JSON.stringify(data),
});
},
async update(noteId: string, data: { content?: string; isPinned?: boolean }) {
return fetchWithAuth(`/notes/${noteId}`, {
async update(
noteId: string,
data: { content?: string; isPinned?: boolean }
): Promise<NoteResponse> {
return fetchWithAuth<NoteResponse>(`/notes/${noteId}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
},
async delete(noteId: string) {
return fetchWithAuth(`/notes/${noteId}`, {
async delete(noteId: string): Promise<void> {
await fetchWithAuth(`/notes/${noteId}`, {
method: 'DELETE',
});
},
async togglePin(noteId: string) {
return fetchWithAuth(`/notes/${noteId}/pin`, {
async togglePin(noteId: string): Promise<NoteResponse> {
return fetchWithAuth<NoteResponse>(`/notes/${noteId}/pin`, {
method: 'POST',
});
},
};
// Activities API Response types
interface ActivitiesListResponse {
activities: ContactActivity[];
}
interface ActivityResponse {
activity: ContactActivity;
}
// Activities API
export const activitiesApi = {
async list(contactId: string, limit?: number) {
async list(contactId: string, limit?: number): Promise<ActivitiesListResponse> {
const params = limit ? `?limit=${limit}` : '';
return fetchWithAuth(`/contacts/${contactId}/activities${params}`);
return fetchWithAuth<ActivitiesListResponse>(`/contacts/${contactId}/activities${params}`);
},
async create(
@ -283,8 +304,8 @@ export const activitiesApi = {
description?: string;
metadata?: Record<string, unknown>;
}
) {
return fetchWithAuth(`/contacts/${contactId}/activities`, {
): Promise<ActivityResponse> {
return fetchWithAuth<ActivityResponse>(`/contacts/${contactId}/activities`, {
method: 'POST',
body: JSON.stringify(data),
});
@ -294,25 +315,13 @@ export const activitiesApi = {
// Photo API
export const photoApi = {
async upload(contactId: string, file: File): Promise<{ photoUrl: string }> {
const token = await authStore.getAccessToken();
const formData = new FormData();
formData.append('photo', file);
const response = await fetch(`${API_BASE}/contacts/${contactId}/photo`, {
return fetchWithAuthFormData<{ photoUrl: string }>(`/contacts/${contactId}/photo`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Upload failed' }));
throw new Error(error.message || 'Upload failed');
}
return response.json();
},
async delete(contactId: string): Promise<void> {

View file

@ -0,0 +1,86 @@
<script lang="ts">
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { EyeSlash, ArrowsDownUp, Hash, ArrowsIn, ArrowsOut } from '@manacore/shared-icons';
import { contactsSettings } from '$lib/stores/settings.svelte';
import { contactsFilterStore } from '$lib/stores/filter.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: 'hide-inactive',
label: 'Inaktive Buchstaben ausblenden',
icon: EyeSlash,
toggle: true,
checked: contactsSettings.alphabetNavHideInactive,
action: () =>
contactsSettings.set(
'alphabetNavHideInactive',
!contactsSettings.alphabetNavHideInactive
),
},
{
id: 'compact',
label: 'Kompakte Ansicht',
icon: ArrowsIn,
toggle: true,
checked: contactsSettings.alphabetNavCompact,
action: () =>
contactsSettings.set('alphabetNavCompact', !contactsSettings.alphabetNavCompact),
},
{
id: 'reverse-order',
label: 'Umgekehrte Reihenfolge (Z-A)',
icon: ArrowsDownUp,
toggle: true,
checked: contactsSettings.alphabetNavReverseOrder,
action: () =>
contactsSettings.set(
'alphabetNavReverseOrder',
!contactsSettings.alphabetNavReverseOrder
),
},
{
id: 'show-hash',
label: '# Symbol anzeigen',
icon: Hash,
toggle: true,
checked: contactsSettings.alphabetNavShowHash,
action: () =>
contactsSettings.set('alphabetNavShowHash', !contactsSettings.alphabetNavShowHash),
},
{
id: 'divider-1',
label: '',
type: 'divider',
},
{
id: 'minimize',
label: contactsFilterStore.isAlphabetNavCollapsed ? 'Erweitern' : 'Minimieren',
icon: contactsFilterStore.isAlphabetNavCollapsed ? ArrowsOut : ArrowsIn,
action: () => contactsFilterStore.toggleAlphabetNav(),
},
];
});
function handleClose() {
visible = false;
}
export function show(clientX: number, clientY: number) {
x = clientX;
y = clientY;
visible = true;
}
export function hide() {
visible = false;
}
</script>
<ContextMenu {visible} {x} {y} items={menuItems} onClose={handleClose} />

View file

@ -5,6 +5,9 @@
import ContactNotes from './ContactNotes.svelte';
import ContactTasks from './ContactTasks.svelte';
import { ContactDetailSkeleton } from '$lib/components/skeletons';
import SocialMediaFields from './forms/SocialMediaFields.svelte';
import DateFields from './forms/DateFields.svelte';
import SocialMediaLinks from './SocialMediaLinks.svelte';
interface Props {
contactId: string;
@ -36,6 +39,25 @@
let country = $state('');
let notes = $state('');
// Dates
let birthday = $state('');
let customDates = $state<Array<{ id: string; label: string; date: string }>>([]);
// Social Media
let linkedin = $state('');
let twitter = $state('');
let facebook = $state('');
let instagram = $state('');
let xing = $state('');
let github = $state('');
let youtube = $state('');
let tiktok = $state('');
let telegram = $state('');
let whatsapp = $state('');
let signal = $state('');
let discord = $state('');
let bluesky = $state('');
const initials = $derived(() => {
if (!contact) return '?';
const f = contact.firstName?.[0] || '';
@ -70,6 +92,23 @@
postalCode = contact.postalCode || '';
country = contact.country || '';
notes = contact.notes || '';
// Dates
birthday = contact.birthday || '';
customDates = contact.customDates ? [...contact.customDates] : [];
// Social Media
linkedin = contact.linkedin || '';
twitter = contact.twitter || '';
facebook = contact.facebook || '';
instagram = contact.instagram || '';
xing = contact.xing || '';
github = contact.github || '';
youtube = contact.youtube || '';
tiktok = contact.tiktok || '';
telegram = contact.telegram || '';
whatsapp = contact.whatsapp || '';
signal = contact.signal || '';
discord = contact.discord || '';
bluesky = contact.bluesky || '';
}
function getDisplayName() {
@ -111,6 +150,23 @@
postalCode: postalCode || null,
country: country || null,
notes: notes || null,
// Dates
birthday: birthday || null,
customDates: customDates.filter((d) => d.label && d.date),
// Social Media
linkedin: linkedin || null,
twitter: twitter || null,
facebook: facebook || null,
instagram: instagram || null,
xing: xing || null,
github: github || null,
youtube: youtube || null,
tiktok: tiktok || null,
telegram: telegram || null,
whatsapp: whatsapp || null,
signal: signal || null,
discord: discord || null,
bluesky: bluesky || null,
});
editing = false;
} catch (e) {
@ -478,6 +534,26 @@
<textarea bind:value={notes} rows="4" class="input textarea"></textarea>
</section>
<!-- Dates Section -->
<DateFields bind:birthday bind:customDates />
<!-- Social Media Section -->
<SocialMediaFields
bind:linkedin
bind:twitter
bind:facebook
bind:instagram
bind:xing
bind:github
bind:youtube
bind:tiktok
bind:telegram
bind:whatsapp
bind:signal
bind:discord
bind:bluesky
/>
<!-- Action Buttons -->
<div class="actions">
<button
@ -819,6 +895,56 @@
</section>
{/if}
{#if contact.birthday || (contact.customDates && contact.customDates.length > 0)}
<section class="detail-section">
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<h3 class="section-title">Daten</h3>
</div>
<div class="detail-list">
{#if contact.birthday}
<div class="detail-item">
<div class="detail-content">
<span class="detail-label">Geburtstag</span>
<span class="detail-value"
>{new Date(contact.birthday).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}</span
>
</div>
</div>
{/if}
{#if contact.customDates}
{#each contact.customDates as customDate}
<div class="detail-item">
<div class="detail-content">
<span class="detail-label">{customDate.label}</span>
<span class="detail-value"
>{new Date(customDate.date).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}</span
>
</div>
</div>
{/each}
{/if}
</div>
</section>
{/if}
{#if contact.notes}
<section class="detail-section">
<div class="section-header">
@ -840,6 +966,8 @@
</section>
{/if}
<SocialMediaLinks {contact} />
<!-- Contact Notes (separate from contact.notes field) -->
<ContactNotes {contactId} />

View file

@ -3,31 +3,24 @@
import { _ } from 'svelte-i18n';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { viewModeStore } from '$lib/stores/view-mode.svelte';
import { contactsFilterStore } from '$lib/stores/filter.svelte';
import { goto } from '$app/navigation';
import type { ContactFilter, BirthdayFilter } from '$lib/components/FilterBar.svelte';
import ContactsToolbar, { type SortField } from '$lib/components/ContactsToolbar.svelte';
import ContactListView from '$lib/components/views/ContactListView.svelte';
import ContactGridView from '$lib/components/views/ContactGridView.svelte';
import ContactAlphabetView from '$lib/components/views/ContactAlphabetView.svelte';
import { ContactListSkeleton, ContactGridSkeleton } from '$lib/components/skeletons';
import ContactNetworkView from '$lib/components/views/ContactNetworkView.svelte';
import {
ContactListSkeleton,
ContactGridSkeleton,
NetworkGraphSkeleton,
} from '$lib/components/skeletons';
import { batchApi } from '$lib/api/batch';
import { toasts } from '$lib/stores/toast';
let searchQuery = $state('');
let sortField = $state<SortField>('lastName');
let searchTimeout: ReturnType<typeof setTimeout>;
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
// Infinite scroll
let scrollContainer: HTMLDivElement;
let intersectionObserver: IntersectionObserver | null = null;
let loadMoreTrigger: HTMLDivElement;
// Filter state
let selectedTagId = $state<string | null>(null);
let contactFilter = $state<ContactFilter>('all');
let birthdayFilter = $state<BirthdayFilter>('all');
let selectedCompany = $state<string | null>(null);
// Batch selection state
let selectionMode = $state(false);
let selectedIds = $state<Set<string>>(new Set());
@ -73,11 +66,31 @@
return !contact.phone && !contact.mobile && !contact.email;
}
// Filtered and sorted contacts
// Filtered and sorted contacts (using filter store)
let filteredContacts = $derived.by(() => {
let result = [...contactsStore.contacts];
// Apply contact filter
// Apply search filter from InputBar
const searchQuery = contactsFilterStore.searchQuery?.toLowerCase().trim();
if (searchQuery) {
result = result.filter((c) => {
const searchFields = [
c.firstName,
c.lastName,
c.displayName,
c.nickname,
c.company,
c.email,
c.phone,
c.mobile,
c.city,
];
return searchFields.some((field) => field?.toLowerCase().includes(searchQuery));
});
}
// Apply contact filter from store
const contactFilter = contactsFilterStore.contactFilter;
if (contactFilter === 'favorites') {
result = result.filter((c) => c.isFavorite);
} else if (contactFilter === 'hasPhone') {
@ -88,7 +101,8 @@
result = result.filter((c) => isContactIncomplete(c));
}
// Apply birthday filter
// Apply birthday filter from store
const birthdayFilter = contactsFilterStore.birthdayFilter;
if (birthdayFilter === 'today') {
result = result.filter((c) => isBirthdayToday(c.birthday));
} else if (birthdayFilter === 'thisWeek') {
@ -97,7 +111,8 @@
result = result.filter((c) => isBirthdayThisMonth(c.birthday));
}
// Apply company filter
// Apply company filter from store
const selectedCompany = contactsFilterStore.selectedCompany;
if (selectedCompany) {
result = result.filter((c) => c.company === selectedCompany);
}
@ -105,8 +120,9 @@
return result;
});
// Sorted contacts based on selected sort field
// Sorted contacts based on selected sort field from store
let sortedContacts = $derived.by(() => {
const sortField = contactsFilterStore.sortField;
return [...filteredContacts].sort((a, b) => {
const aValue =
(sortField === 'firstName'
@ -122,14 +138,6 @@
});
});
function handleSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
contactsStore.setSearch(searchQuery);
contactsStore.loadContacts();
}, 300);
}
async function handleToggleFavorite(e: MouseEvent, id: string) {
e.stopPropagation();
await contactsStore.toggleFavorite(id);
@ -266,11 +274,26 @@
intersectionObserver.observe(loadMoreTrigger);
}
});
// Reload contacts when tag filter changes (tag filtering is server-side)
let lastTagId: string | null = null;
$effect(() => {
const currentTagId = contactsFilterStore.selectedTagId;
if (currentTagId !== lastTagId) {
lastTagId = currentTagId;
if (currentTagId) {
contactsStore.setTagId(currentTagId);
} else {
contactsStore.setTagId(undefined);
}
contactsStore.loadContacts();
}
});
</script>
<div class="space-y-6">
<!-- Header -->
<h1 class="text-2xl font-bold text-foreground">{$_('contacts.title')}</h1>
<h1 class="text-2xl font-bold text-foreground text-center">{$_('contacts.title')}</h1>
<!-- Batch Actions Bar (shown when in selection mode) -->
{#if selectionMode}
@ -349,58 +372,11 @@
</div>
{/if}
<!-- Search Bar -->
<div class="relative">
<input
type="text"
placeholder={$_('contacts.search')}
bind:value={searchQuery}
oninput={handleSearch}
class="input w-full pl-10"
/>
<svg
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<!-- Unified Toolbar -->
<ContactsToolbar
contacts={contactsStore.contacts}
{sortField}
onSortFieldChange={(v) => (sortField = v)}
{contactFilter}
onContactFilterChange={(f) => (contactFilter = f)}
{birthdayFilter}
onBirthdayFilterChange={(f) => (birthdayFilter = f)}
{selectedTagId}
onTagChange={(id) => {
selectedTagId = id;
if (id) {
contactsStore.setTagId(id);
} else {
contactsStore.setTagId(undefined);
}
contactsStore.loadContacts();
}}
{selectedCompany}
onCompanyChange={(c) => (selectedCompany = c)}
{selectionMode}
onToggleSelectionMode={toggleSelectionMode}
/>
<!-- Loading state with skeleton -->
{#if contactsStore.loading}
{#if viewModeStore.mode === 'grid'}
{#if viewModeStore.mode === 'network'}
<NetworkGraphSkeleton />
{:else if viewModeStore.mode === 'grid'}
<ContactGridSkeleton count={8} />
{:else}
<ContactListSkeleton count={10} />
@ -411,13 +387,15 @@
<div class="text-6xl mb-4">👤</div>
<h2 class="text-xl font-semibold text-foreground mb-2">{$_('contacts.noContacts')}</h2>
<p class="text-muted-foreground mb-4">{$_('contacts.addFirst')}</p>
<a href="/contacts/new" class="btn btn-primary">
<button type="button" onclick={() => newContactModalStore.open()} class="btn btn-primary">
{$_('contacts.new')}
</a>
</button>
</div>
{:else}
<!-- Contacts View -->
{#if viewModeStore.mode === 'grid'}
{#if viewModeStore.mode === 'network'}
<ContactNetworkView />
{:else if viewModeStore.mode === 'grid'}
<ContactGridView
contacts={sortedContacts}
onContactClick={handleContactClick}
@ -426,7 +404,7 @@
{selectedIds}
onToggleSelection={toggleSelection}
/>
{:else if viewModeStore.mode === 'alphabet'}
{:else}
<ContactAlphabetView
contacts={sortedContacts}
onContactClick={handleContactClick}
@ -434,21 +412,12 @@
{selectionMode}
{selectedIds}
onToggleSelection={toggleSelection}
{sortField}
/>
{:else}
<ContactListView
contacts={sortedContacts}
onContactClick={handleContactClick}
onToggleFavorite={handleToggleFavorite}
{selectionMode}
{selectedIds}
onToggleSelection={toggleSelection}
sortField={contactsFilterStore.sortField}
/>
{/if}
<!-- Infinite scroll trigger & loading more indicator -->
{#if contactsStore.hasMore}
<!-- Infinite scroll trigger & loading more indicator (not for network view) -->
{#if viewModeStore.mode !== 'network' && contactsStore.hasMore}
<div bind:this={loadMoreTrigger} class="load-more-trigger">
{#if contactsStore.loadingMore}
<div class="loading-more">
@ -459,11 +428,13 @@
</div>
{/if}
<!-- Total count -->
<p class="text-sm text-muted-foreground text-center">
{contactsStore.contacts.length} / {contactsStore.total}
{contactsStore.total === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')}
</p>
<!-- Total count (not for network view) -->
{#if viewModeStore.mode !== 'network'}
<p class="text-sm text-muted-foreground text-center">
{contactsStore.contacts.length} / {contactsStore.total}
{contactsStore.total === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')}
</p>
{/if}
{/if}
</div>

View file

@ -1,185 +1,40 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { viewModeStore, type ViewMode } from '$lib/stores/view-mode.svelte';
import {
PillToolbar,
PillToolbarButton,
PillToolbarDivider,
PillViewSwitcher,
} from '@manacore/shared-ui';
import FilterBar, {
type ContactFilter,
type BirthdayFilter,
} from '$lib/components/FilterBar.svelte';
import { ExpandableToolbar } from '@manacore/shared-ui';
import ContactsToolbarContent from './ContactsToolbarContent.svelte';
import { contactsFilterStore } from '$lib/stores/filter.svelte';
import { viewModeStore } from '$lib/stores/view-mode.svelte';
import type { Contact } from '$lib/api/contacts';
export type SortField = 'firstName' | 'lastName';
interface Props {
isSidebarMode?: boolean;
contacts: Contact[];
sortField: SortField;
onSortFieldChange: (field: SortField) => void;
contactFilter: ContactFilter;
onContactFilterChange: (filter: ContactFilter) => void;
birthdayFilter: BirthdayFilter;
onBirthdayFilterChange: (filter: BirthdayFilter) => void;
selectedTagId: string | null;
onTagChange: (tagId: string | null) => void;
selectedCompany: string | null;
onCompanyChange: (company: string | null) => void;
/** Selection mode state */
selectionMode: boolean;
/** Toggle selection mode callback */
onToggleSelectionMode: () => void;
}
let {
contacts,
sortField,
onSortFieldChange,
contactFilter,
onContactFilterChange,
birthdayFilter,
onBirthdayFilterChange,
selectedTagId,
onTagChange,
selectedCompany,
onCompanyChange,
selectionMode,
onToggleSelectionMode,
}: Props = $props();
let { isSidebarMode = false, contacts }: Props = $props();
// Count favorites for quick filter button
let favoritesCount = $derived(contactsStore.contacts.filter((c) => c.isFavorite).length);
let showFavorites = $derived(contactFilter === 'favorites');
// Use store for collapsed state
let isCollapsed = $derived(contactsFilterStore.isToolbarCollapsed);
// Sort options
const sortOptions = [
{ id: 'firstName', label: $_('sort.firstName'), title: $_('sort.firstName') },
{ id: 'lastName', label: $_('sort.lastName'), title: $_('sort.lastName') },
];
// View mode options
const viewOptions = [
{ id: 'list', label: '', title: $_('views.list'), icon: 'list' },
{ id: 'grid', label: '', title: $_('views.grid'), icon: 'grid' },
{ id: 'alphabet', label: '', title: $_('views.alphabet'), icon: 'alphabet' },
];
function toggleFavorites() {
if (contactFilter === 'favorites') {
onContactFilterChange('all');
} else {
onContactFilterChange('favorites');
}
}
function handleSortChange(value: string) {
onSortFieldChange(value as SortField);
}
function handleViewModeChange(value: string) {
viewModeStore.setMode(value as ViewMode);
function handleCollapsedChange(collapsed: boolean) {
contactsFilterStore.setToolbarCollapsed(collapsed);
}
</script>
<PillToolbar topOffset="70px">
<!-- New Contact Button -->
<PillToolbarButton onclick={() => goto('/contacts/new')} title={$_('contacts.new')}>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span class="btn-label">{$_('contacts.new')}</span>
</PillToolbarButton>
<!-- Main Expandable Toolbar (uses its own fixed positioning) -->
<ExpandableToolbar
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
{isSidebarMode}
collapsedTitle="Filter & Optionen"
expandedTitle="Schließen"
>
<ContactsToolbarContent {contacts} />
</ExpandableToolbar>
<PillToolbarDivider />
<!-- Selection Mode Toggle -->
<PillToolbarButton
onclick={onToggleSelectionMode}
active={selectionMode}
title={selectionMode ? 'Auswahl beenden' : 'Mehrere auswählen'}
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
</PillToolbarButton>
<PillToolbarDivider />
<!-- Favorites Toggle -->
<PillToolbarButton
onclick={toggleFavorites}
active={showFavorites}
title={$_('filters.contact.favorites')}
>
<svg fill={showFavorites ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
{#if favoritesCount > 0}
<span class="count">{favoritesCount}</span>
{/if}
</PillToolbarButton>
<PillToolbarDivider />
<!-- Filter Dropdown -->
<FilterBar
{contacts}
{selectedTagId}
{onTagChange}
{contactFilter}
{onContactFilterChange}
{birthdayFilter}
{onBirthdayFilterChange}
{selectedCompany}
{onCompanyChange}
embedded={true}
/>
<PillToolbarDivider />
<!-- Sort Toggle -->
<PillViewSwitcher
options={sortOptions}
value={sortField}
onChange={handleSortChange}
primaryColor="#6366f1"
embedded={true}
/>
<PillToolbarDivider />
<!-- View Mode -->
<div class="view-mode-buttons">
<button
type="button"
class="view-btn"
class:active={viewModeStore.mode === 'list'}
onclick={() => viewModeStore.setMode('list')}
title={$_('views.list')}
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<!-- View Mode Pill - positioned to the LEFT of the FAB -->
{#if !isSidebarMode}
<div class="view-mode-pill" class:expanded={!isCollapsed}>
<button
type="button"
class="view-btn"
@ -212,29 +67,57 @@
/>
</svg>
</button>
<button
type="button"
class="view-btn"
class:active={viewModeStore.mode === 'network'}
onclick={() => viewModeStore.setMode('network')}
title={$_('views.network')}
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
/>
</svg>
</button>
</div>
</PillToolbar>
{/if}
<style>
.btn-label {
display: none;
}
@media (min-width: 640px) {
.btn-label {
display: inline;
}
}
.count {
font-size: 0.75rem;
font-weight: 600;
}
.view-mode-buttons {
/* View Mode Pill - positioned to the LEFT of the FAB (which is at right: calc(50% - 350px - 70px)) */
.view-mode-pill {
position: fixed;
/* Same vertical alignment as FAB: bottom offset + 9px + safe-area */
bottom: calc(70px + 9px + env(safe-area-inset-bottom, 0px));
/* Position to the left of the FAB: FAB is at right: calc(50% - 350px - 70px), FAB width is 54px, gap is 8px */
right: calc(50% - 350px - 70px + 54px + 8px);
z-index: 91;
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.375rem;
background: hsl(var(--color-surface) / 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--color-border));
border-radius: 9999px;
box-shadow: 0 2px 8px hsl(var(--color-foreground) / 0.08);
transition: bottom 0.2s ease;
}
/* When toolbar is expanded, move pill up with FAB */
.view-mode-pill.expanded {
bottom: calc(70px + 70px + 9px + env(safe-area-inset-bottom, 0px));
}
/* Responsive - on smaller screens, position relative to viewport edge */
@media (max-width: 900px) {
.view-mode-pill {
right: calc(1rem + 54px + 8px); /* FAB at right: 1rem, FAB width 54px, gap 8px */
}
}
.view-btn {
@ -246,29 +129,22 @@
border: none;
border-radius: 9999px;
cursor: pointer;
color: #374151;
color: hsl(var(--color-muted-foreground));
transition: all 0.15s ease;
}
:global(.dark) .view-btn {
color: #f3f4f6;
}
.view-btn:hover {
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .view-btn:hover {
background: rgba(255, 255, 255, 0.1);
background: hsl(var(--color-muted) / 0.5);
color: hsl(var(--color-foreground));
}
.view-btn.active {
background: color-mix(in srgb, #6366f1 15%, transparent 85%);
color: #6366f1;
background: color-mix(in srgb, #3b82f6 15%, transparent 85%);
color: #3b82f6;
}
.view-btn :global(svg) {
width: 1rem;
height: 1rem;
width: 1.125rem;
height: 1.125rem;
}
</style>

Some files were not shown because too many files have changed in this diff Show more