feat(calendar): type-specific styling, filter UI, cross-module navigation

- EventCard: visual differentiation per blockType (task=checkbox, habit=icon,
  timeEntry=striped+clock, focus=dashed, live=pulse animation)
- CalendarHeader: filter toggle with chips for event/task/habit/timeEntry types
- Calendar page: clicking external items navigates to source module

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-05 14:47:12 +02:00
parent 8c8b3f80da
commit 2502d6241d
5 changed files with 205 additions and 3 deletions

View file

@ -1,7 +1,17 @@
<script lang="ts">
import { calendarViewStore } from '../stores/view.svelte';
import type { CalendarViewType } from '../types';
import { CaretLeft, CaretRight, Plus } from '@manacore/shared-icons';
import type { TimeBlockType } from '$lib/data/time-blocks/types';
import {
CaretLeft,
CaretRight,
Plus,
CalendarBlank,
CheckSquare,
Timer,
Heart,
Funnel,
} from '@manacore/shared-icons';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
@ -11,6 +21,19 @@
let { onNewEvent }: Props = $props();
let showFilters = $state(false);
const blockTypeConfig: { type: TimeBlockType; label: string; icon: typeof CalendarBlank }[] = [
{ type: 'event', label: 'Termine', icon: CalendarBlank },
{ type: 'task', label: 'Aufgaben', icon: CheckSquare },
{ type: 'timeEntry', label: 'Zeiten', icon: Timer },
{ type: 'habit', label: 'Habits', icon: Heart },
];
let allActive = $derived(
blockTypeConfig.every((c) => calendarViewStore.visibleBlockTypes.has(c.type))
);
let headerLabel = $derived.by(() => {
if (calendarViewStore.viewType === 'month') {
return format(calendarViewStore.currentDate, 'MMMM yyyy', { locale: de });
@ -52,11 +75,36 @@
{/each}
</div>
<button
onclick={() => (showFilters = !showFilters)}
class="filter-btn"
class:active={!allActive}
aria-label="Filter"
>
<Funnel size={16} />
</button>
<button onclick={onNewEvent} class="new-event-btn">
<Plus size={16} />
Termin
</button>
</div>
{#if showFilters}
<div class="filter-bar">
{#each blockTypeConfig as cfg}
{@const isActive = calendarViewStore.visibleBlockTypes.has(cfg.type)}
<button
class="filter-chip"
class:active={isActive}
onclick={() => calendarViewStore.toggleBlockType(cfg.type)}
>
<svelte:component this={cfg.icon} size={14} />
{cfg.label}
</button>
{/each}
</div>
{/if}
</header>
<style>
@ -156,6 +204,60 @@
color: hsl(var(--color-primary-foreground));
}
.filter-btn {
padding: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
border-radius: var(--radius-md, 8px);
cursor: pointer;
color: hsl(var(--color-muted-foreground));
transition: all 0.15s ease;
}
.filter-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.filter-btn.active {
background: hsl(var(--color-primary) / 0.1);
border-color: hsl(var(--color-primary) / 0.3);
color: hsl(var(--color-primary));
}
.filter-bar {
display: flex;
gap: 0.375rem;
padding: 0.5rem 1rem;
border-top: 1px solid hsl(var(--color-border));
width: 100%;
}
.filter-chip {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 9999px;
background: transparent;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 0.15s ease;
}
.filter-chip:hover {
background: hsl(var(--color-muted));
}
.filter-chip.active {
background: hsl(var(--color-primary) / 0.1);
border-color: hsl(var(--color-primary) / 0.3);
color: hsl(var(--color-primary));
}
.new-event-btn {
display: flex;
align-items: center;

View file

@ -1,6 +1,8 @@
<script lang="ts">
import type { CalendarEvent } from '../types';
import { eventsStore } from '../stores/events.svelte';
import { CheckSquare, Clock, Timer, Lightning } from '@manacore/shared-icons';
import { getIconComponent } from '@manacore/shared-icons';
interface Props {
event: CalendarEvent;
@ -32,6 +34,11 @@
let isDraft = $derived(eventsStore.isDraftEvent(event.id));
/** Resolve the Phosphor icon component for habit blocks. */
let habitIconComponent = $derived(
event.blockType === 'habit' && event.icon ? getIconComponent(event.icon) : null
);
function handleClick(e: MouseEvent) {
if (isDragging || isResizing || isDraft) {
e.preventDefault();
@ -71,11 +78,12 @@
</script>
<div
class="event-card"
class="event-card block-type-{event.blockType}"
class:dragging={isDragging && !isDraggingSource}
class:dragging-source={isDraggingSource}
class:resizing={isResizing}
class:draft={isDraft}
class:live={event.isLive}
data-event-id={event.id}
{style}
style:background-color={color}
@ -98,7 +106,22 @@
></div>
{/if}
<span class="event-time">{formattedTime}</span>
<div class="event-header-row">
<!-- Type icon -->
{#if event.blockType === 'task'}
<span class="type-icon"><CheckSquare size={10} weight="bold" /></span>
{:else if event.blockType === 'timeEntry'}
<span class="type-icon"><Timer size={10} weight="bold" /></span>
{:else if event.blockType === 'habit' && habitIconComponent}
<span class="type-icon">
<svelte:component this={habitIconComponent} size={10} weight="bold" />
</span>
{:else if event.blockType === 'focus'}
<span class="type-icon"><Lightning size={10} weight="bold" /></span>
{/if}
<span class="event-time">{formattedTime}</span>
</div>
<span class="event-title">{event.title || (isDraft ? 'Neuer Termin' : '')}</span>
{#if event.location}
<span class="event-location">{event.location}</span>
@ -144,6 +167,50 @@
outline-offset: 1px;
}
/* ─── Block-type visual differentiation ─── */
.event-card.block-type-task {
border-left: 3px solid rgba(255, 255, 255, 0.6);
}
.event-card.block-type-habit {
border-radius: var(--radius-sm, 4px) 8px 8px var(--radius-sm, 4px);
}
.event-card.block-type-timeEntry {
border-style: solid;
border-width: 1px;
border-color: rgba(255, 255, 255, 0.3);
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 4px,
rgba(255, 255, 255, 0.05) 4px,
rgba(255, 255, 255, 0.05) 8px
);
}
.event-card.block-type-focus {
border: 2px dashed rgba(255, 255, 255, 0.5);
}
/* Live/running indicator */
.event-card.live {
animation: pulse-border 2s ease-in-out infinite;
}
@keyframes pulse-border {
0%,
100% {
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.4);
}
50% {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1);
}
}
/* ─── Drag/resize states ─── */
.event-card.dragging {
cursor: grabbing;
opacity: 0.9;
@ -177,6 +244,21 @@
background-color: hsl(var(--color-primary) / 0.3) !important;
}
/* ─── Content ─── */
.event-header-row {
display: flex;
align-items: center;
gap: 3px;
}
.type-icon {
display: flex;
align-items: center;
opacity: 0.85;
flex-shrink: 0;
}
.event-time {
display: block;
font-size: 0.6rem;
@ -201,6 +283,8 @@
text-overflow: ellipsis;
}
/* ─── Resize handles ─── */
.resize-handle {
position: absolute;
left: 0;

View file

@ -11,6 +11,7 @@
getCalendarColor,
} from '$lib/modules/calendar/queries';
import type { Calendar, CalendarEvent } from '$lib/modules/calendar/types';
import { goto } from '$app/navigation';
import CalendarHeader from '$lib/modules/calendar/components/CalendarHeader.svelte';
import DateStrip from '$lib/modules/calendar/components/DateStrip.svelte';
@ -78,6 +79,21 @@
let quickCreatePosition = $state({ x: 0, y: 0 });
function handleEventClick(event: CalendarEvent) {
// Cross-module navigation: external items open in their source module
if (event.calendarId === '__external__') {
const routeMap: Record<string, string> = {
todo: '/todo',
times: '/times',
habits: '/habits',
};
const route = routeMap[event.sourceModule];
if (route) {
goto(route);
return;
}
}
// Native calendar events: open detail modal
selectedEvent = event;
}