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/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/CalendarToolbarContent.svelte b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte index 7e786232d..2d8a0eb65 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/StatsSidebarSection.svelte b/apps/calendar/apps/web/src/lib/components/calendar/StatsSidebarSection.svelte new file mode 100644 index 000000000..cd9b8c332 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/StatsSidebarSection.svelte @@ -0,0 +1,434 @@ + + +
+ + + +
+
+
+ +
+
+ {stats.eventsToday} + Heute +
+
+ +
+
+ +
+
+ {stats.eventsThisWeek} + Diese Woche +
+
+ +
+
+ +
+
+ {stats.upcomingEvents} + Anstehend +
+
+ +
+
+ +
+
+ {stats.busyHours}h + Stunden/Woche +
+
+
+ + +
+

+ + Letzte 7 Tage +

+
+ {#each miniTrend as day} +
+
0} + >
+ {day.label?.charAt(0) || ''} +
+ {/each} +
+
+ + + {#if stats.calendarActivity.length > 0} +
+

+ + Kalender-Aktivität +

+
+ {#each stats.calendarActivity.slice(0, 5) as cal} +
+
+ {cal.name} + {cal.total} +
+ {/each} +
+
+ {/if} + + +
+
+ + Ø {stats.avgDuration} Min +
+
+ + {stats.recurringEvents} wiederkehrend +
+
+ + {stats.allDayRatio.allDay} ganztägig +
+
+ + +
+ {stats.totalEvents} Events geladen +
+
+ + 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..7c75a1874 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,32 +171,87 @@ transition: all 0.15s ease; } - /* More pill with muted style */ - .more-pill { - color: #6b7280; + /* Selected tag state */ + .tag-pill.selected { + background: var(--tag-color) !important; + border-color: var(--tag-color) !important; } - .more-pill .tag-name { - color: #6b7280; + .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 neutral style */ + .more-pill { + color: #374151; + } + + .more-pill .tag-name { + 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; - padding: 0.5rem !important; + color: #374151; + } + + .create-pill .tag-name { + color: #374151; + font-weight: 500; } :global(.dark) .create-pill { - color: #60a5fa; + color: #f3f4f6; + } + + :global(.dark) .create-pill .tag-name { + color: #f3f4f6; } /* Glass tag styling - same as PillNavigation pills */ 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 ed43e762a..ed6b2a6e2 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, @@ -16,6 +17,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 +62,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); @@ -90,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) { @@ -172,12 +183,18 @@
{#each getMonthDays(month) as day} {@const eventCount = getEventCount(day)} + {@const heatmapLevel = getHeatmapLevel(day)} +
+ + + +
+{/if} + + 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/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/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/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)); + }); } /** diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index 47e13dd9f..575544714 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,10 @@ 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 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'; // App switcher items @@ -173,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; @@ -268,6 +275,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,9 +284,19 @@ 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: '/', + label: 'Statistiken', + icon: 'flame', + onClick: () => heatmapStore.toggle(), + active: heatmapStore.enabled, + }, + { + href: '/', + label: 'Einstellungen', + icon: 'settings', + onClick: () => (showSettingsModal = true), + }, { href: '/feedback', label: 'Feedback', icon: 'chat' }, ]); @@ -355,16 +373,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 +765,16 @@ + + + + + (showSettingsModal = false)} + {isSidebarMode} +/> + diff --git a/apps/chat/apps/web/src/routes/+layout.svelte b/apps/chat/apps/web/src/routes/+layout.svelte index 0c121b2cc..fb227426d 100644 --- a/apps/chat/apps/web/src/routes/+layout.svelte +++ b/apps/chat/apps/web/src/routes/+layout.svelte @@ -7,13 +7,12 @@ let { children } = $props(); - onMount(async () => { + onMount(() => { // Initialize runtime config first (12-factor pattern) - await initializeConfig(); + initializeConfig(); // Initialize theme - const cleanup = theme.initialize(); - return cleanup; + return theme.initialize(); }); diff --git a/apps/manacore/apps/web/src/lib/stores/user-settings.svelte.ts b/apps/manacore/apps/web/src/lib/stores/user-settings.svelte.ts index 4bd6f7ee1..3261b677f 100644 --- a/apps/manacore/apps/web/src/lib/stores/user-settings.svelte.ts +++ b/apps/manacore/apps/web/src/lib/stores/user-settings.svelte.ts @@ -11,17 +11,17 @@ import { createUserSettingsStore } from '@manacore/shared-theme'; import { authStore } from './auth.svelte'; import { getAuthUrl } from '$lib/config/runtime'; +// Initialize auth URL from runtime config +let authUrl = 'http://localhost:3001'; // default fallback +getAuthUrl().then((url) => { + authUrl = url; +}); + // Create store with async initialization export const userSettings = createUserSettingsStore({ appId: 'manacore', - authUrl: 'http://localhost:3001', // Will be updated after config loads + get authUrl() { + return authUrl; + }, getAccessToken: () => authStore.getAccessToken(), }); - -// Update auth URL after runtime config loads -getAuthUrl().then((url) => { - // Update the store's auth URL after config loads - if (userSettings.settings) { - (userSettings.settings as { authUrl: string }).authUrl = url; - } -}); diff --git a/apps/picture/apps/web/src/lib/config/runtime.ts b/apps/picture/apps/web/src/lib/config/runtime.ts index 7d462a686..befd5c83e 100644 --- a/apps/picture/apps/web/src/lib/config/runtime.ts +++ b/apps/picture/apps/web/src/lib/config/runtime.ts @@ -78,8 +78,8 @@ async function loadConfig(): Promise { if (!dev) { const result = ConfigSchema.safeParse(config); if (!result.success) { - const errors = result.error.errors - .map((e: { path: (string | number)[]; message: string }) => `${e.path.join('.')}: ${e.message}`) + const errors = result.error.issues + .map((e) => `${e.path.join('.')}: ${e.message}`) .join(', '); throw new Error(`[Picture] Invalid config.json schema: ${errors}`); } diff --git a/games/worldream/apps/web/src/lib/components/AiGenerator.svelte b/games/worldream/apps/web/src/lib/components/AiGenerator.svelte index 019ceddd1..5d2357727 100644 --- a/games/worldream/apps/web/src/lib/components/AiGenerator.svelte +++ b/games/worldream/apps/web/src/lib/components/AiGenerator.svelte @@ -108,6 +108,7 @@
e.key === 'Enter' && toggleDialog()} role="button" tabindex="-1" aria-label="Close dialog" diff --git a/games/worldream/apps/web/src/lib/components/AiImageGenerator.svelte b/games/worldream/apps/web/src/lib/components/AiImageGenerator.svelte index 8fc6a2c8a..75dbd5636 100644 --- a/games/worldream/apps/web/src/lib/components/AiImageGenerator.svelte +++ b/games/worldream/apps/web/src/lib/components/AiImageGenerator.svelte @@ -6,7 +6,7 @@ title?: string; description?: string; appearance?: string; - prompt?: string; + prompt?: string | null; imagePrompt?: string; imageUrl?: string | null; onImageGenerated?: (imageUrl: string) => void; @@ -17,7 +17,7 @@ title = '', description = '', appearance = '', - prompt = $bindable(''), + prompt = $bindable(null), imagePrompt = $bindable(''), imageUrl = $bindable(null), onImageGenerated, @@ -64,8 +64,6 @@ function getImageClass() { const aspectRatio = getAspectRatio(); switch (aspectRatio) { - case '21:9': - return 'w-full aspect-[21/9]'; // 21:9 ultrawide aspect ratio case '16:9': return 'w-full aspect-video'; // 16:9 aspect ratio case '9:16': @@ -183,7 +181,7 @@ function resetImage() { generatedImageUrl = null; - imagePrompt = null; + imagePrompt = ''; error = null; } @@ -331,7 +329,7 @@