From 781bc529ba0d97f2839680a2edfa2cacb6d51567 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 16 Dec 2025 17:43:38 +0100 Subject: [PATCH 1/6] fix(calendar): equal cell heights in month view on smaller screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix flexbox layout for month view grid cells by: - Adding height: 100% and min-height: 0 to .month-view container - Using flex: 1 1 0 on week rows for equal distribution - Adding overflow: hidden on day cells to prevent content overflow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../web/src/lib/components/calendar/MonthView.svelte | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte index ebbc02494..f068a85bf 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte @@ -402,6 +402,8 @@ .month-view { display: flex; flex-direction: column; + height: 100%; + min-height: 0; } .weekday-headers { @@ -427,13 +429,14 @@ flex: 1; display: flex; flex-direction: column; + min-height: 0; } .week-row { - flex: 1; + flex: 1 1 0; display: grid; grid-template-columns: repeat(var(--column-count, 7), 1fr); - min-height: 100px; + min-height: 0; } .day-cell { @@ -447,6 +450,8 @@ display: flex; flex-direction: column; transition: background-color var(--transition-fast); + min-height: 0; + overflow: hidden; } .day-cell:first-child { From c7c451e439622d2847d1b2bf0ab9937f25f9e6fb Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 16 Dec 2025 17:59:28 +0100 Subject: [PATCH 2/6] feat(calendar): add tag filtering to calendar views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add selectedTagIds to settings store with toggle/clear methods - Update TagStrip to select tags for filtering instead of navigation - Add filterByTags function to eventFiltering utils - Apply tag filtering across all calendar views: - MultiDayView (timed & all-day events) - AgendaView (with empty group removal) - MonthView - YearView (event counts) - Add "Filter löschen" button (hidden when no tags selected) - Rename buttons: "Mehr" → "Alle Tags", "Neu" → "Neuer Tag" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../lib/components/calendar/AgendaView.svelte | 23 +++-- .../lib/components/calendar/MonthView.svelte | 21 +++-- .../components/calendar/MultiDayView.svelte | 6 +- .../lib/components/calendar/TagStrip.svelte | 88 +++++++++++++++++-- .../lib/components/calendar/YearView.svelte | 6 +- .../web/src/lib/stores/settings.svelte.ts | 36 ++++++++ .../apps/web/src/lib/utils/eventFiltering.ts | 48 +++++++++- 7 files changed, 200 insertions(+), 28 deletions(-) diff --git a/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte index 43a94001c..a480ca04e 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte @@ -2,7 +2,9 @@ import { viewStore } from '$lib/stores/view.svelte'; import { eventsStore } from '$lib/stores/events.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; + import { settingsStore } from '$lib/stores/settings.svelte'; import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; + import { filterByTags } from '$lib/utils/eventFiltering'; import EventContextMenu from '$lib/components/event/EventContextMenu.svelte'; import { format, parseISO, isToday, isTomorrow, startOfDay } from 'date-fns'; import { de } from 'date-fns/locale'; @@ -33,6 +35,9 @@ const groups: Map = new Map(); + // Get selected tag IDs for filtering + const selectedTagIds = settingsStore.selectedTagIds; + for (const event of currentEvents) { // Skip events from hidden calendars if (!visibleCalendarIds.has(event.calendarId)) continue; @@ -50,17 +55,21 @@ groups.get(dateKey)!.push(event); } - // Sort groups by date + // Sort groups by date and apply tag filtering 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(); - }), - })); + events: filterByTags( + events.sort((a, b) => { + const aStart = toDate(a.startTime); + const bStart = toDate(b.startTime); + return aStart.getTime() - bStart.getTime(); + }), + selectedTagIds + ), + })) + .filter((group) => group.events.length > 0); // Remove empty groups after tag filtering }); function formatDateHeader(date: Date) { diff --git a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte index f068a85bf..ad2691745 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte @@ -31,7 +31,7 @@ } from 'date-fns'; import { de } from 'date-fns/locale'; import { _ } from 'svelte-i18n'; - import { filterByVisibleCalendars } from '$lib/utils/eventFiltering'; + import { filterByVisibleCalendars, filterByTags } from '$lib/utils/eventFiltering'; import type { CalendarEvent } from '@calendar/shared'; @@ -201,17 +201,20 @@ // Event Handlers // ============================================================================ 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( + let events = filterByVisibleCalendars( eventsStore.getEventsForDay(day), calendarsStore.visibleCalendars ); + events = filterByTags(events, settingsStore.selectedTagIds); + return events.slice(0, 3); // Max 3 events shown + } + + function getAllEventsForDay(day: Date): CalendarEvent[] { + let events = filterByVisibleCalendars( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars + ); + return filterByTags(events, settingsStore.selectedTagIds); } function handleDayClick(day: Date, e: MouseEvent) { diff --git a/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte index cc5787666..476c7b549 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte @@ -155,6 +155,7 @@ filterHoursEnabled: settingsStore.filterHoursEnabled, dayStartHour: settingsStore.dayStartHour, dayEndHour: settingsStore.dayEndHour, + selectedTagIds: settingsStore.selectedTagIds, } ); } @@ -174,7 +175,10 @@ function getAllDayEventsForDay(day: Date): CalendarEvent[] { return getVisibleAllDayEvents( eventsStore.getEventsForDay(day), - calendarsStore.visibleCalendars + calendarsStore.visibleCalendars, + { + selectedTagIds: settingsStore.selectedTagIds, + } ); } diff --git a/apps/calendar/apps/web/src/lib/components/calendar/TagStrip.svelte b/apps/calendar/apps/web/src/lib/components/calendar/TagStrip.svelte index 4ffeaf04e..2e4147a71 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/TagStrip.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/TagStrip.svelte @@ -4,7 +4,7 @@ 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 { DotsThree, Plus, X } from '@manacore/shared-icons'; import TagStripModal from './TagStripModal.svelte'; interface Props { @@ -16,10 +16,16 @@ let showModal = $state(false); function handleTagClick(tagId: string) { - // Navigate to tags page with the tag selected for editing - goto(`/tags?edit=${tagId}`); + // Toggle tag selection for filtering calendar view + settingsStore.toggleTagSelection(tagId); } + function isTagSelected(tagId: string): boolean { + return settingsStore.isTagSelected(tagId); + } + + const hasSelectedTags = $derived(settingsStore.hasSelectedTags); + function handleOpenModal() { showModal = true; } @@ -58,10 +64,22 @@
+ + + {#if eventTagsStore.loading} @@ -75,6 +93,7 @@ {#each sortedTags as tag (tag.id)} {/if}
@@ -141,7 +161,8 @@ .tag-pill, .more-pill, - .create-pill { + .create-pill, + .clear-filter-pill { display: flex; align-items: center; gap: 0.5rem; @@ -150,6 +171,53 @@ transition: all 0.15s ease; } + /* Selected tag state */ + .tag-pill.selected { + background: var(--tag-color) !important; + border-color: var(--tag-color) !important; + } + + .tag-pill.selected .tag-dot { + background-color: white; + } + + .tag-pill.selected .tag-name { + color: white; + } + + /* Clear filter pill */ + .clear-filter-pill { + color: #ef4444; + background: rgba(239, 68, 68, 0.1) !important; + border-color: rgba(239, 68, 68, 0.3) !important; + } + + .clear-filter-pill .tag-name { + color: #ef4444; + font-weight: 600; + } + + :global(.dark) .clear-filter-pill { + color: #f87171; + background: rgba(239, 68, 68, 0.15) !important; + border-color: rgba(239, 68, 68, 0.3) !important; + } + + :global(.dark) .clear-filter-pill .tag-name { + color: #f87171; + } + + .clear-filter-pill:hover:not(.hidden) { + background: rgba(239, 68, 68, 0.2) !important; + border-color: rgba(239, 68, 68, 0.5) !important; + } + + /* Hidden state for clear filter pill (prevents layout shift) */ + .clear-filter-pill.hidden { + visibility: hidden; + pointer-events: none; + } + /* More pill with muted style */ .more-pill { color: #6b7280; @@ -171,13 +239,21 @@ /* Create pill with primary accent */ .create-pill { color: #3b82f6; - padding: 0.5rem !important; + } + + .create-pill .tag-name { + color: #3b82f6; + font-weight: 600; } :global(.dark) .create-pill { color: #60a5fa; } + :global(.dark) .create-pill .tag-name { + color: #60a5fa; + } + /* Glass tag styling - same as PillNavigation pills */ .glass-tag { padding: 0.5rem 1rem; diff --git a/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte index ed43e762a..ace2f26fd 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte @@ -16,6 +16,7 @@ } from 'date-fns'; import { de } from 'date-fns/locale'; import { toDate } from '$lib/utils/eventDateHelpers'; + import { filterByTags } from '$lib/utils/eventFiltering'; import type { CalendarViewType, CalendarEvent } from '@calendar/shared'; interface Props { @@ -60,7 +61,10 @@ // Precompute event counts for performance let eventCountsByDay = $derived.by(() => { const counts = new Map(); - const events = eventsStore.events ?? []; + let events = eventsStore.events ?? []; + + // Apply tag filter if tags are selected + events = filterByTags(events, settingsStore.selectedTagIds); for (const event of events) { const start = toDate(event.startTime); diff --git a/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts b/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts index 9a63b0322..32de61472 100644 --- a/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/settings.svelte.ts @@ -45,6 +45,7 @@ export interface CalendarAppSettings { // TagStrip settings tagStripCollapsed: boolean; // Whether TagStrip is hidden + selectedTagIds: string[]; // Tags selected for filtering calendar view // Immersive Mode settings immersiveModeEnabled: boolean; // Fullscreen mode - hides all UI elements @@ -91,6 +92,7 @@ const DEFAULT_SETTINGS: CalendarAppSettings = { dateStripCollapsed: false, // TagStrip defaults tagStripCollapsed: true, // Hidden by default + selectedTagIds: [], // No tags selected by default // Immersive Mode defaults immersiveModeEnabled: false, // Birthday defaults @@ -241,6 +243,12 @@ export const settingsStore = { get tagStripCollapsed() { return settings.tagStripCollapsed; }, + get selectedTagIds() { + return settings.selectedTagIds; + }, + get hasSelectedTags() { + return settings.selectedTagIds.length > 0; + }, // Immersive Mode settings get immersiveModeEnabled() { return settings.immersiveModeEnabled; @@ -316,6 +324,34 @@ export const settingsStore = { syncToCloud(); }, + /** + * Toggle a tag selection for filtering + */ + toggleTagSelection(tagId: string) { + const currentIds = settings.selectedTagIds; + const isSelected = currentIds.includes(tagId); + const newIds = isSelected ? currentIds.filter((id) => id !== tagId) : [...currentIds, tagId]; + settings = { ...settings, selectedTagIds: newIds }; + saveSettings(settings); + syncToCloud(); + }, + + /** + * Check if a tag is selected + */ + isTagSelected(tagId: string): boolean { + return settings.selectedTagIds.includes(tagId); + }, + + /** + * Clear all tag selections + */ + clearTagSelection() { + settings = { ...settings, selectedTagIds: [] }; + saveSettings(settings); + syncToCloud(); + }, + /** * Toggle Immersive Mode (fullscreen, hide all UI) */ diff --git a/apps/calendar/apps/web/src/lib/utils/eventFiltering.ts b/apps/calendar/apps/web/src/lib/utils/eventFiltering.ts index 5b0e4dcad..52ff744ad 100644 --- a/apps/calendar/apps/web/src/lib/utils/eventFiltering.ts +++ b/apps/calendar/apps/web/src/lib/utils/eventFiltering.ts @@ -114,7 +114,7 @@ export function getOverflowEvents( } /** - * Combined filter: Get visible timed events for a day with optional hour filtering + * Combined filter: Get visible timed events for a day with optional hour and tag filtering */ export function getVisibleTimedEvents( events: CalendarEvent[], @@ -123,6 +123,7 @@ export function getVisibleTimedEvents( filterHoursEnabled?: boolean; dayStartHour?: number; dayEndHour?: number; + selectedTagIds?: string[]; } ): CalendarEvent[] { let filtered = filterByVisibleCalendars(events, visibleCalendars); @@ -136,18 +137,57 @@ export function getVisibleTimedEvents( filtered = filterByHourRange(filtered, options.dayStartHour, options.dayEndHour); } + // Apply tag filter if tags are selected + if (options?.selectedTagIds) { + filtered = filterByTags(filtered, options.selectedTagIds); + } + return filtered; } /** - * Combined filter: Get visible all-day events for a day + * Combined filter: Get visible all-day events for a day with optional tag filtering */ export function getVisibleAllDayEvents( events: CalendarEvent[], - visibleCalendars: Calendar[] + visibleCalendars: Calendar[], + options?: { + selectedTagIds?: string[]; + } ): CalendarEvent[] { let filtered = filterByVisibleCalendars(events, visibleCalendars); - return filterAllDayEvents(filtered); + filtered = filterAllDayEvents(filtered); + + // Apply tag filter if tags are selected + if (options?.selectedTagIds) { + filtered = filterByTags(filtered, options.selectedTagIds); + } + + return filtered; +} + +/** + * Filter events by selected tag IDs + * If no tags are selected (empty array), returns all events + * If tags are selected, returns only events that have at least one of the selected tags + */ +export function filterByTags(events: CalendarEvent[], selectedTagIds: string[]): CalendarEvent[] { + // If no tags are selected, show all events + if (selectedTagIds.length === 0) { + return events; + } + + const selectedTagSet = new Set(selectedTagIds); + + return events.filter((event) => { + // If event has no tags, don't show it when filtering by tags + if (!event.tags || event.tags.length === 0) { + return false; + } + + // Check if event has at least one of the selected tags + return event.tags.some((tag) => selectedTagSet.has(tag.id)); + }); } /** From 484efccb4501354a1b11149d391ec9c0d686ba69 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 16 Dec 2025 18:08:11 +0100 Subject: [PATCH 3/6] style(calendar): use neutral colors for TagStrip action buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../lib/components/calendar/TagStrip.svelte | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/calendar/apps/web/src/lib/components/calendar/TagStrip.svelte b/apps/calendar/apps/web/src/lib/components/calendar/TagStrip.svelte index 2e4147a71..7c75a1874 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/TagStrip.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/TagStrip.svelte @@ -218,40 +218,40 @@ pointer-events: none; } - /* More pill with muted style */ + /* More pill with neutral style */ .more-pill { - color: #6b7280; + color: #374151; } .more-pill .tag-name { - color: #6b7280; - font-weight: 600; + color: #374151; + font-weight: 500; } :global(.dark) .more-pill { - color: #9ca3af; + color: #f3f4f6; } :global(.dark) .more-pill .tag-name { - color: #9ca3af; + color: #f3f4f6; } - /* Create pill with primary accent */ + /* Create pill with neutral style */ .create-pill { - color: #3b82f6; + color: #374151; } .create-pill .tag-name { - color: #3b82f6; - font-weight: 600; + color: #374151; + font-weight: 500; } :global(.dark) .create-pill { - color: #60a5fa; + color: #f3f4f6; } :global(.dark) .create-pill .tag-name { - color: #60a5fa; + color: #f3f4f6; } /* Glass tag styling - same as PillNavigation pills */ From 31f187b8166908b858b81ade425b14450214ffa5 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 16 Dec 2025 18:35:03 +0100 Subject: [PATCH 4/6] feat(calendar): integrate network view into homepage with heatmap stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add network view as "N" option in view switcher (like contacts app pattern) - Create view-mode store to switch between calendar/network modes - Move NetworkView from /network route to embedded component - Add heatmap mode with StatsOverlay for event density visualization - Extend network service to create connections by: - Shared tags (highest priority, variable strength) - Same calendar (strength 50%) - Same date (strength 40%) - Same location (strength 60%) - Fix network controller route prefix (was /api/v1/api/v1/network) - Remove separate /network and /statistics pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../backend/src/network/network.controller.ts | 2 +- .../backend/src/network/network.service.ts | 93 ++++-- apps/calendar/apps/web/src/lib/api/network.ts | 2 +- .../calendar/CalendarToolbarContent.svelte | 11 + .../lib/components/calendar/DayView.svelte | 50 +++ .../lib/components/calendar/MonthView.svelte | 68 ++++- .../components/calendar/MultiDayView.svelte | 50 ++- .../components/calendar/NetworkView.svelte} | 35 +-- .../components/calendar/StatsOverlay.svelte | 257 ++++++++++++++++ .../lib/components/calendar/WeekView.svelte | 50 ++- .../lib/components/calendar/YearView.svelte | 7 + .../apps/web/src/lib/stores/heatmap.svelte.ts | 190 ++++++++++++ .../web/src/lib/stores/view-mode.svelte.ts | 76 +++++ .../apps/web/src/routes/(app)/+layout.svelte | 51 +++- .../apps/web/src/routes/(app)/+page.svelte | 122 ++++---- .../src/routes/(app)/statistics/+page.svelte | 287 ------------------ 16 files changed, 950 insertions(+), 401 deletions(-) rename apps/calendar/apps/web/src/{routes/(app)/network/+page.svelte => lib/components/calendar/NetworkView.svelte} (95%) create mode 100644 apps/calendar/apps/web/src/lib/components/calendar/StatsOverlay.svelte create mode 100644 apps/calendar/apps/web/src/lib/stores/heatmap.svelte.ts create mode 100644 apps/calendar/apps/web/src/lib/stores/view-mode.svelte.ts delete mode 100644 apps/calendar/apps/web/src/routes/(app)/statistics/+page.svelte diff --git a/apps/calendar/apps/backend/src/network/network.controller.ts b/apps/calendar/apps/backend/src/network/network.controller.ts index e2fd0ee77..8bdb3940e 100644 --- a/apps/calendar/apps/backend/src/network/network.controller.ts +++ b/apps/calendar/apps/backend/src/network/network.controller.ts @@ -2,7 +2,7 @@ import { Controller, Get, UseGuards, Headers } from '@nestjs/common'; import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; import { NetworkService } from './network.service'; -@Controller('api/v1/network') +@Controller('network') @UseGuards(JwtAuthGuard) export class NetworkController { constructor(private readonly networkService: NetworkService) {} diff --git a/apps/calendar/apps/backend/src/network/network.service.ts b/apps/calendar/apps/backend/src/network/network.service.ts index beddb942a..1225187c6 100644 --- a/apps/calendar/apps/backend/src/network/network.service.ts +++ b/apps/calendar/apps/backend/src/network/network.service.ts @@ -24,7 +24,7 @@ export interface NetworkNode { export interface NetworkLink { source: string; target: string; - type: 'tag'; + type: 'tag' | 'calendar' | 'date' | 'location'; strength: number; sharedTags: string[]; } @@ -114,14 +114,8 @@ export class NetworkService { eventTagsMap.set(event.id, tags); } - // 5. Filter events that have at least one tag - const eventsWithTagsList = eventsData.filter((e) => { - const tags = eventTagsMap.get(e.event.id) || []; - return tags.length > 0; - }); - - // 6. Build nodes - const nodes: NetworkNode[] = eventsWithTagsList.map(({ event }) => { + // 5. Build nodes from ALL events (not just those with tags) + const nodes: NetworkNode[] = eventsData.map(({ event }) => { const tags = eventTagsMap.get(event.id) || []; return { id: event.id, @@ -134,41 +128,88 @@ export class NetworkService { }; }); - // 7. Build links based on shared tags + // 6. Build links based on multiple criteria const links: NetworkLink[] = []; const connectionCounts = new Map(); + const linkSet = new Set(); // Track unique links to avoid duplicates - for (let i = 0; i < nodes.length; i++) { - for (let j = i + 1; j < nodes.length; j++) { + for (let i = 0; i < eventsData.length; i++) { + for (let j = i + 1; j < eventsData.length; j++) { + const event1 = eventsData[i].event; + const event2 = eventsData[j].event; const node1 = nodes[i]; const node2 = nodes[j]; + const linkKey = `${event1.id}-${event2.id}`; - // Find shared tags - const sharedTags = node1.tags - .filter((t1) => node2.tags.some((t2) => t2.id === t1.id)) - .map((t) => t.name); + // Skip if link already exists + if (linkSet.has(linkKey)) continue; - if (sharedTags.length > 0) { - // Calculate strength based on number of shared tags - const maxTags = Math.max(node1.tags.length, node2.tags.length); - const strength = Math.round((sharedTags.length / maxTags) * 100); + let linked = false; + let linkType: 'tag' | 'calendar' | 'date' | 'location' = 'tag'; + let strength = 0; + const sharedTags: string[] = []; + // 6a. Check for shared tags (highest priority) + const tags1 = eventTagsMap.get(event1.id) || []; + const tags2 = eventTagsMap.get(event2.id) || []; + const commonTags = tags1.filter((t1) => tags2.some((t2) => t2.id === t1.id)); + + if (commonTags.length > 0) { + linked = true; + linkType = 'tag'; + const maxTags = Math.max(tags1.length, tags2.length); + strength = Math.round((commonTags.length / maxTags) * 100); + sharedTags.push(...commonTags.map((t) => t.name)); + } + + // 6b. Check for same calendar (if not already linked) + if (!linked && event1.calendarId === event2.calendarId) { + linked = true; + linkType = 'calendar'; + strength = 50; + } + + // 6c. Check for same date (if not already linked) + if (!linked) { + const date1 = new Date(event1.startTime).toDateString(); + const date2 = new Date(event2.startTime).toDateString(); + if (date1 === date2) { + linked = true; + linkType = 'date'; + strength = 40; + } + } + + // 6d. Check for same location (if not already linked and both have location) + if ( + !linked && + event1.location && + event2.location && + event1.location.toLowerCase() === event2.location.toLowerCase() + ) { + linked = true; + linkType = 'location'; + strength = 60; + } + + if (linked) { links.push({ - source: node1.id, - target: node2.id, - type: 'tag', + source: event1.id, + target: event2.id, + type: linkType, strength, sharedTags, }); + linkSet.add(linkKey); // Update connection counts - connectionCounts.set(node1.id, (connectionCounts.get(node1.id) || 0) + 1); - connectionCounts.set(node2.id, (connectionCounts.get(node2.id) || 0) + 1); + connectionCounts.set(event1.id, (connectionCounts.get(event1.id) || 0) + 1); + connectionCounts.set(event2.id, (connectionCounts.get(event2.id) || 0) + 1); } } } - // 8. Update connection counts in nodes + // 7. Update connection counts in nodes for (const node of nodes) { node.connectionCount = connectionCounts.get(node.id) || 0; } diff --git a/apps/calendar/apps/web/src/lib/api/network.ts b/apps/calendar/apps/web/src/lib/api/network.ts index d9ccf04b6..0bdc15860 100644 --- a/apps/calendar/apps/web/src/lib/api/network.ts +++ b/apps/calendar/apps/web/src/lib/api/network.ts @@ -23,7 +23,7 @@ export interface NetworkNode { export interface NetworkLink { source: string; target: string; - type: 'tag'; + type: 'tag' | 'calendar' | 'date' | 'location'; strength: number; sharedTags: string[]; } diff --git a/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte index af591ef61..84df580eb 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte @@ -1,6 +1,7 @@ - - Netzwerk - Kalender - - -
+
networkStore.selectNode(null)} - aria-label="Schließen" + aria-label="Schlie\u00dfen" >
+ {#if collapsed} + + + {:else} + +
+
+
+ + {periodLabel} +
+ +
+ +
+
+ + Heute + {stats.eventsToday} +
+ +
+ + Diese Woche + {stats.eventsThisWeek} +
+ +
+ + Stunden/Woche + {stats.busyHours}h +
+ + {#if stats.avgDuration > 0} +
+ Ø Dauer + {stats.avgDuration}min +
+ {/if} +
+ + +
+ {/if} +
+{/if} + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte index b92335d4a..6dbb7078d 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte @@ -7,6 +7,7 @@ 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 { heatmapStore } from '$lib/stores/heatmap.svelte'; import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte'; import { useVisibleHours, useCurrentTimeIndicator, useBirthdayPopover } from '$lib/composables'; import { toDate } from '$lib/utils/eventDateHelpers'; @@ -886,9 +887,21 @@
{#each days as day} -
+ {@const heatmapLevel = heatmapStore.enabled ? heatmapStore.getLevel(day) : 0} +
{format(day, 'EEE', { locale: currentDateLocale })} {format(day, 'd')} + {#if heatmapStore.enabled && heatmapLevel > 0} + {heatmapStore.getDisplayValue(day)} + {/if}
{/each}
@@ -1240,6 +1253,41 @@ align-items: center; padding: 0.5rem; border-left: 1px solid hsl(var(--color-border)); + transition: background-color 150ms ease; + } + + /* Heatmap level colors for day headers */ + .day-header.heatmap-1 { + background-color: hsl(var(--color-primary) / 0.1); + } + .day-header.heatmap-2 { + background-color: hsl(var(--color-primary) / 0.2); + } + .day-header.heatmap-3 { + background-color: hsl(var(--color-primary) / 0.35); + } + .day-header.heatmap-4 { + background-color: hsl(var(--color-primary) / 0.5); + } + .day-header.heatmap-5 { + background-color: hsl(var(--color-primary) / 0.65); + } + + .heatmap-badge { + font-size: 0.625rem; + font-weight: 600; + color: hsl(var(--color-muted-foreground)); + padding: 1px 6px; + background: hsl(var(--color-muted) / 0.5); + border-radius: var(--radius-sm); + margin-top: 0.25rem; + } + + /* Better contrast for higher heatmap levels */ + .day-header.heatmap-4 .heatmap-badge, + .day-header.heatmap-5 .heatmap-badge { + background: hsl(var(--color-background) / 0.8); + color: hsl(var(--color-foreground)); } .day-name { diff --git a/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte index ace2f26fd..265e7a172 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte @@ -2,6 +2,7 @@ import { viewStore } from '$lib/stores/view.svelte'; import { eventsStore } from '$lib/stores/events.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; + import { heatmapStore, type HeatmapLevel } from '$lib/stores/heatmap.svelte'; import { format, startOfMonth, @@ -94,6 +95,12 @@ return eventCountsByDay.get(key) || 0; } + // Get heatmap level for a day (when heatmap is enabled) + function getHeatmapLevel(day: Date): HeatmapLevel { + if (!heatmapStore.enabled) return 0; + return heatmapStore.getLevel(day); + } + // Event handlers function handleDayClick(day: Date, e: MouseEvent) { if (onQuickCreate) { diff --git a/apps/calendar/apps/web/src/lib/stores/heatmap.svelte.ts b/apps/calendar/apps/web/src/lib/stores/heatmap.svelte.ts new file mode 100644 index 000000000..03c20dc79 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/stores/heatmap.svelte.ts @@ -0,0 +1,190 @@ +/** + * Heatmap Store - Manages heatmap visualization state for calendar views + */ + +import { eventsStore } from './events.svelte'; +import { viewStore } from './view.svelte'; +import { format, eachDayOfInterval, differenceInMinutes } from 'date-fns'; +import { toDate } from '$lib/utils/eventDateHelpers'; +import { browser } from '$app/environment'; + +// Heatmap metric type +export type HeatmapMetric = 'events' | 'hours'; + +// Heatmap level (0-5) +export type HeatmapLevel = 0 | 1 | 2 | 3 | 4 | 5; + +// State +let enabled = $state(false); +let metric = $state('events'); + +// Load from localStorage +if (browser) { + const savedEnabled = localStorage.getItem('calendar-heatmap-enabled'); + if (savedEnabled === 'true') { + enabled = true; + } + const savedMetric = localStorage.getItem('calendar-heatmap-metric'); + if (savedMetric === 'events' || savedMetric === 'hours') { + metric = savedMetric; + } +} + +// Daily counts cache - computed based on events and view range +let dailyEventCounts = $derived.by(() => { + const counts = new Map(); + const range = viewStore.viewRange; + + if (!range) return counts; + + // Get all days in the current view range (plus some buffer for carousel) + try { + const days = eachDayOfInterval({ start: range.start, end: range.end }); + + for (const day of days) { + const dayKey = format(day, 'yyyy-MM-dd'); + const dayEvents = eventsStore.getEventsForDay(day, false); // Don't include draft + counts.set(dayKey, dayEvents.length); + } + } catch { + // Invalid interval, return empty + } + + return counts; +}); + +// Daily busy hours cache +let dailyBusyHours = $derived.by(() => { + const hours = new Map(); + const range = viewStore.viewRange; + + if (!range) return hours; + + try { + const days = eachDayOfInterval({ start: range.start, end: range.end }); + + for (const day of days) { + const dayKey = format(day, 'yyyy-MM-dd'); + const dayEvents = eventsStore.getEventsForDay(day, false); + + let totalMinutes = 0; + for (const event of dayEvents) { + if (!event.isAllDay) { + const start = toDate(event.startTime); + const end = toDate(event.endTime); + totalMinutes += differenceInMinutes(end, start); + } + } + + hours.set(dayKey, totalMinutes / 60); + } + } catch { + // Invalid interval, return empty + } + + return hours; +}); + +export const heatmapStore = { + // Getters + get enabled() { + return enabled; + }, + get metric() { + return metric; + }, + + // Toggle heatmap on/off + toggle() { + enabled = !enabled; + if (browser) { + localStorage.setItem('calendar-heatmap-enabled', String(enabled)); + } + }, + + // Enable heatmap + enable() { + enabled = true; + if (browser) { + localStorage.setItem('calendar-heatmap-enabled', 'true'); + } + }, + + // Disable heatmap + disable() { + enabled = false; + if (browser) { + localStorage.setItem('calendar-heatmap-enabled', 'false'); + } + }, + + // Set metric type + setMetric(newMetric: HeatmapMetric) { + metric = newMetric; + if (browser) { + localStorage.setItem('calendar-heatmap-metric', newMetric); + } + }, + + /** + * Get event count for a specific date + */ + getEventCount(date: Date): number { + const dayKey = format(date, 'yyyy-MM-dd'); + return dailyEventCounts.get(dayKey) ?? 0; + }, + + /** + * Get busy hours for a specific date + */ + getBusyHours(date: Date): number { + const dayKey = format(date, 'yyyy-MM-dd'); + return dailyBusyHours.get(dayKey) ?? 0; + }, + + /** + * Get heatmap level (0-5) for a specific date based on current metric + */ + getLevel(date: Date): HeatmapLevel { + if (metric === 'events') { + const count = this.getEventCount(date); + if (count === 0) return 0; + if (count <= 2) return 1; + if (count <= 4) return 2; + if (count <= 6) return 3; + if (count <= 9) return 4; + return 5; + } else { + // Hours metric + const hours = this.getBusyHours(date); + if (hours === 0) return 0; + if (hours <= 1) return 1; + if (hours <= 2) return 2; + if (hours <= 4) return 3; + if (hours <= 6) return 4; + return 5; + } + }, + + /** + * Get CSS class for heatmap level + */ + getLevelClass(date: Date): string { + const level = this.getLevel(date); + return level === 0 ? '' : `heatmap-${level}`; + }, + + /** + * Get display value for a date (count or hours depending on metric) + */ + getDisplayValue(date: Date): string { + if (metric === 'events') { + const count = this.getEventCount(date); + return count > 0 ? String(count) : ''; + } else { + const hours = this.getBusyHours(date); + if (hours === 0) return ''; + return hours < 1 ? `${Math.round(hours * 60)}m` : `${hours.toFixed(1)}h`; + } + }, +}; diff --git a/apps/calendar/apps/web/src/lib/stores/view-mode.svelte.ts b/apps/calendar/apps/web/src/lib/stores/view-mode.svelte.ts new file mode 100644 index 000000000..8235cafa9 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/stores/view-mode.svelte.ts @@ -0,0 +1,76 @@ +/** + * View Mode Store - Manages app view mode (calendar vs network) + * Similar pattern to Contacts app view-mode store + */ + +import { browser } from '$app/environment'; + +export type AppViewMode = 'calendar' | 'network'; + +const STORAGE_KEY = 'calendar-app-view-mode'; + +// Valid view modes +const VALID_MODES: AppViewMode[] = ['calendar', 'network']; + +function isValidMode(mode: string | null): mode is AppViewMode { + return mode !== null && VALID_MODES.includes(mode as AppViewMode); +} + +// Get initial mode from sessionStorage or default to 'calendar' +function getInitialMode(): AppViewMode { + if (!browser) return 'calendar'; + + const sessionMode = sessionStorage.getItem(STORAGE_KEY); + if (isValidMode(sessionMode)) { + return sessionMode; + } + + return 'calendar'; +} + +let mode = $state(getInitialMode()); + +export const viewModeStore = { + get mode() { + return mode; + }, + + setMode(newMode: AppViewMode) { + mode = newMode; + if (browser) { + sessionStorage.setItem(STORAGE_KEY, newMode); + } + }, + + /** + * Toggle between calendar and network mode + */ + toggle() { + const newMode = mode === 'calendar' ? 'network' : 'calendar'; + this.setMode(newMode); + }, + + /** + * Reset to default view (calendar) + */ + resetToDefault() { + mode = 'calendar'; + if (browser) { + sessionStorage.removeItem(STORAGE_KEY); + } + }, + + /** + * Initialize mode from sessionStorage (call on app load) + */ + initialize() { + if (!browser) return; + + const sessionMode = sessionStorage.getItem(STORAGE_KEY); + if (isValidMode(sessionMode)) { + mode = sessionMode; + } else { + mode = 'calendar'; + } + }, +}; diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index 47e13dd9f..930616492 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -44,6 +44,7 @@ isNavCollapsed as collapsedStore, isToolbarCollapsed as toolbarCollapsedStore, } from '$lib/stores/navigation'; + import { viewModeStore } from '$lib/stores/view-mode.svelte'; import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n'; import { getPillAppItems } from '@manacore/shared-branding'; import { setLocale, supportedLocales } from '$lib/i18n'; @@ -63,7 +64,9 @@ 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 StatsOverlay from '$lib/components/calendar/StatsOverlay.svelte'; import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; + import { heatmapStore } from '$lib/stores/heatmap.svelte'; import type { CalendarViewType } from '@calendar/shared'; // App switcher items @@ -268,6 +271,7 @@ // Base navigation items for Calendar (without Kalender/Aufgaben - handled by tab group) // Note: Tags uses onClick to toggle TagStrip visibility instead of navigating + // Note: Statistiken uses onClick to toggle heatmap mode (shows stats overlay + event density) let baseNavItems = $derived([ { href: '/tags', @@ -276,8 +280,13 @@ onClick: handleTagsToggle, active: isTagStripVisible, }, - { href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' }, - { href: '/network', label: 'Netzwerk', icon: 'share-2' }, + { + href: '/', + label: 'Statistiken', + icon: 'flame', + onClick: () => heatmapStore.toggle(), + active: heatmapStore.enabled, + }, { href: '/settings', label: 'Einstellungen', icon: 'settings' }, { href: '/feedback', label: 'Feedback', icon: 'chat' }, ]); @@ -355,16 +364,37 @@ return viewLabels[view]; } + // Handle view/mode change - switches between calendar views and network mode + function handleViewModeChange(id: string) { + if (id === 'network') { + viewModeStore.setMode('network'); + } else { + // Switch to calendar mode and set the view type + viewModeStore.setMode('calendar'); + viewStore.setViewType(id as CalendarViewType); + } + } + + // Current view value - shows 'network' when in network mode, otherwise the calendar view type + let currentViewValue = $derived( + viewModeStore.mode === 'network' ? 'network' : viewStore.viewType + ); + // View switcher tab group (only shown on calendar main page) + // Includes calendar views + network option let viewSwitcherTabGroup = $derived({ 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), + options: [ + ...enabledViews.map((view) => ({ + id: view, + label: getViewLabel(view), + title: + view === 'custom' ? `${settingsStore.customDayCount}-Tage-Ansicht` : viewTitles[view], + })), + { id: 'network', label: 'N', title: 'Netzwerk-Ansicht' }, + ], + value: currentViewValue, + onChange: handleViewModeChange, onContextMenu: handleViewContextMenu, }); @@ -726,6 +756,9 @@ + + + From ea856214fe2b7005839ee7ccf17b8d1237b3ead2 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 16 Dec 2025 18:37:11 +0100 Subject: [PATCH 5/6] feat(calendar): show settings as modal on homepage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create SettingsModal component with all settings sections - Update layout to show settings modal when clicking Settings in PillNav - Modal appears above the input bar with glassmorphism styling - Settings changes are saved immediately via settingsStore 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../components/settings/SettingsModal.svelte | 1232 +++++++++++++++++ .../apps/web/src/routes/(app)/+layout.svelte | 18 +- 2 files changed, 1249 insertions(+), 1 deletion(-) create mode 100644 apps/calendar/apps/web/src/lib/components/settings/SettingsModal.svelte diff --git a/apps/calendar/apps/web/src/lib/components/settings/SettingsModal.svelte b/apps/calendar/apps/web/src/lib/components/settings/SettingsModal.svelte new file mode 100644 index 000000000..00f234422 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/settings/SettingsModal.svelte @@ -0,0 +1,1232 @@ + + + + +{#if visible} + + + + + + + +{/if} + + diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index 930616492..575544714 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -65,6 +65,7 @@ import EventContextMenu from '$lib/components/event/EventContextMenu.svelte'; import ViewModePillContextMenu from '$lib/components/calendar/ViewModePillContextMenu.svelte'; import StatsOverlay from '$lib/components/calendar/StatsOverlay.svelte'; + import SettingsModal from '$lib/components/settings/SettingsModal.svelte'; import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; import { heatmapStore } from '$lib/stores/heatmap.svelte'; import type { CalendarViewType } from '@calendar/shared'; @@ -176,6 +177,9 @@ let helpModalOpen = $state(false); let helpModalMode = $state<'shortcuts' | 'syntax'>('shortcuts'); + // Settings modal state + let showSettingsModal = $state(false); + function handleShowShortcuts() { helpModalMode = 'shortcuts'; helpModalOpen = true; @@ -287,7 +291,12 @@ onClick: () => heatmapStore.toggle(), active: heatmapStore.enabled, }, - { href: '/settings', label: 'Einstellungen', icon: 'settings' }, + { + href: '/', + label: 'Einstellungen', + icon: 'settings', + onClick: () => (showSettingsModal = true), + }, { href: '/feedback', label: 'Feedback', icon: 'chat' }, ]); @@ -759,6 +768,13 @@ + + (showSettingsModal = false)} + {isSidebarMode} +/> + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte index 265e7a172..ed6b2a6e2 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte @@ -183,12 +183,18 @@
{#each getMonthDays(month) as day} {@const eventCount = getEventCount(day)} + {@const heatmapLevel = getHeatmapLevel(day)} - + {#if heatmapStore.enabled} + + {:else} + + {/if} @@ -144,7 +150,11 @@ class="calendar-sidebar-mobile mobile-only" class:collapsed={settingsStore.sidebarCollapsed} > - + {#if heatmapStore.enabled} + + {:else} + + {/if}