feat(calendar): add tag filtering to calendar views

- 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 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-16 17:59:28 +01:00
parent 781bc529ba
commit c7c451e439
7 changed files with 200 additions and 28 deletions

View file

@ -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<string, CalendarEvent[]> = 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) {

View file

@ -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) {

View file

@ -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,
}
);
}

View file

@ -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 @@
<div class="tag-strip-wrapper" class:sidebar-mode={isSidebarMode}>
<div class="tag-strip-container">
<!-- Clear Filter Button (always rendered to prevent layout shift) -->
<button
class="clear-filter-pill glass-tag"
class:hidden={!hasSelectedTags}
onclick={() => settingsStore.clearTagSelection()}
title="Filter löschen"
disabled={!hasSelectedTags}
>
<X size={16} weight="bold" />
<span class="tag-name">Filter</span>
</button>
<!-- More Pill (opens modal) -->
<button class="more-pill glass-tag" onclick={handleOpenModal} title="Alle Tags anzeigen">
<DotsThree size={18} weight="bold" />
<span class="tag-name">Mehr</span>
<span class="tag-name">Alle Tags</span>
</button>
{#if eventTagsStore.loading}
@ -75,6 +93,7 @@
{#each sortedTags as tag (tag.id)}
<button
class="tag-pill glass-tag"
class:selected={isTagSelected(tag.id)}
onclick={() => handleTagClick(tag.id)}
title={tag.name}
style="--tag-color: {tag.color || '#3b82f6'}"
@ -91,6 +110,7 @@
title="Neuer Tag"
>
<Plus size={16} weight="bold" />
<span class="tag-name">Neuer Tag</span>
</button>
{/if}
</div>
@ -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;

View file

@ -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<string, number>();
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);

View file

@ -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)
*/

View file

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