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] 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)); + }); } /**