feat(manacore/web): add DateStrip to calendar module

Port the horizontal scrolling DateStrip from the standalone calendar
app with all features: event indicator dots, moon phases, weekend
highlighting, month dividers, lazy-loading virtual scroll, smart
today button, and view range highlighting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 12:46:37 +02:00
parent 1cd89af80d
commit 67567605fa
3 changed files with 533 additions and 0 deletions

View file

@ -72,10 +72,12 @@
"@manacore/spiral-db": "workspace:*",
"@manacore/subscriptions": "workspace:*",
"@manacore/wallpaper-generator": "workspace:*",
"@types/suncalc": "^1.9.2",
"@zitare/content": "workspace:*",
"date-fns": "^4.1.0",
"dexie": "^4.0.11",
"marked": "^17.0.5",
"suncalc": "^1.9.0",
"svelte-dnd-action": "^0.9.68",
"svelte-i18n": "^4.0.0"
},

View file

@ -0,0 +1,527 @@
<script lang="ts">
import { getContext } from 'svelte';
import { calendarViewStore } from '../stores/view.svelte';
import { getEventsForDay } from '../queries';
import type { CalendarEvent } from '../types';
import {
format,
isToday,
isSameDay,
addDays,
subDays,
startOfDay,
isWithinInterval,
} from 'date-fns';
import { de } from 'date-fns/locale';
import { onMount, tick } from 'svelte';
import SunCalc from 'suncalc';
// Get events from layout context (live query)
const eventsCtx: { readonly value: CalendarEvent[] } = getContext('calendarEvents');
// Get event count for a day (max 5 dots displayed)
function getEventCount(date: Date): number {
const events = getEventsForDay(eventsCtx.value, date);
return Math.min(events.length, 5);
}
// Moon phase emojis (8 phases)
const MOON_EMOJIS = ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'];
function isSignificantMoonPhase(date: Date): { significant: boolean; emoji: string } {
const moonData = SunCalc.getMoonIllumination(date);
const phase = moonData.phase;
const tolerance = 0.017;
if (phase < tolerance || phase > 1 - tolerance) {
return { significant: true, emoji: '🌑' };
}
if (Math.abs(phase - 0.25) < tolerance) {
return { significant: true, emoji: '🌓' };
}
if (Math.abs(phase - 0.5) < tolerance) {
return { significant: true, emoji: '🌕' };
}
if (Math.abs(phase - 0.75) < tolerance) {
return { significant: true, emoji: '🌗' };
}
return { significant: false, emoji: '' };
}
// Reactive view range
let viewRange = $derived(calendarViewStore.viewRange);
let currentDate = $derived(calendarViewStore.currentDate);
// Settings (persisted in localStorage)
let showMoonPhases = $state(true);
let showEventIndicators = $state(true);
let highlightWeekends = $state(true);
// How many days to load in each direction
const DAYS_BUFFER = 60;
const LOAD_THRESHOLD = 20;
let startDate = $state(subDays(startOfDay(new Date()), DAYS_BUFFER));
let endDate = $state(addDays(startOfDay(new Date()), DAYS_BUFFER));
let isTodayVisible = $state(true);
let days = $derived.by(() => {
const result: Date[] = [];
let current = startDate;
while (current <= endDate) {
result.push(current);
current = addDays(current, 1);
}
return result;
});
let scrollContainer: HTMLDivElement;
let isLoadingMore = false;
// Scroll to selected date when it changes
$effect(() => {
if (scrollContainer && currentDate) {
scrollToDate(currentDate);
}
});
async function scrollToDate(date: Date, instant = false) {
await tick();
const targetDate = startOfDay(date);
if (targetDate < startDate) {
startDate = subDays(targetDate, DAYS_BUFFER);
await tick();
} else if (targetDate > endDate) {
endDate = addDays(targetDate, DAYS_BUFFER);
await tick();
}
const dayElement = scrollContainer?.querySelector(
`[data-date="${format(date, 'yyyy-MM-dd')}"]`
);
if (dayElement) {
dayElement.scrollIntoView({
behavior: instant ? 'instant' : 'smooth',
inline: 'center',
block: 'nearest',
});
}
}
function handleDayClick(day: Date) {
calendarViewStore.setDate(day);
}
function goToToday() {
calendarViewStore.setDate(new Date());
}
async function loadMoreDays(direction: 'past' | 'future') {
if (isLoadingMore) return;
isLoadingMore = true;
if (direction === 'past') {
const scrollLeftBefore = scrollContainer?.scrollLeft || 0;
startDate = subDays(startDate, DAYS_BUFFER);
await tick();
if (scrollContainer) {
scrollContainer.scrollLeft = scrollLeftBefore + DAYS_BUFFER * 54;
}
} else {
endDate = addDays(endDate, DAYS_BUFFER);
}
isLoadingMore = false;
}
function checkTodayVisibility() {
if (!scrollContainer) return;
const todayElement = scrollContainer.querySelector('[data-is-today="true"]');
if (!todayElement) {
isTodayVisible = false;
return;
}
const containerRect = scrollContainer.getBoundingClientRect();
const todayRect = todayElement.getBoundingClientRect();
isTodayVisible =
todayRect.left >= containerRect.left - 20 && todayRect.right <= containerRect.right + 20;
}
// Get the month of the center visible day
let visibleMonth = $state(format(new Date(), 'MMMM yyyy', { locale: de }));
function updateVisibleMonth() {
if (!scrollContainer) return;
const containerRect = scrollContainer.getBoundingClientRect();
const centerX = containerRect.left + containerRect.width / 2;
const dayElements = scrollContainer.querySelectorAll('.day-item');
for (const el of dayElements) {
const rect = el.getBoundingClientRect();
if (rect.left <= centerX && rect.right >= centerX) {
const dateStr = el.getAttribute('data-date');
if (dateStr) {
const date = new Date(dateStr);
visibleMonth = format(date, 'MMMM yyyy', { locale: de });
}
break;
}
}
}
function handleScroll() {
if (!scrollContainer || isLoadingMore) return;
checkTodayVisibility();
updateVisibleMonth();
const { scrollLeft, clientWidth } = scrollContainer;
const dayWidth = 54;
const visibleDayIndex = Math.floor(scrollLeft / dayWidth);
const totalDays = days.length;
if (visibleDayIndex < LOAD_THRESHOLD) {
loadMoreDays('past');
}
if (totalDays - visibleDayIndex - Math.floor(clientWidth / dayWidth) < LOAD_THRESHOLD) {
loadMoreDays('future');
}
}
onMount(async () => {
await scrollToDate(new Date(), true);
updateVisibleMonth();
checkTodayVisibility();
});
</script>
<div class="date-strip-wrapper">
<div class="date-strip-container">
<!-- Month label -->
<div class="month-header">
<span class="month-label">
{#if !isTodayVisible}
<button onclick={goToToday} title="Zum heutigen Tag" class="today-button">
<span class="today-label">Heute</span>
<span class="today-date">{format(new Date(), 'd. MMM', { locale: de })}</span>
</button>
{/if}
{visibleMonth}
</span>
</div>
<!-- Days row -->
<div class="days-scroll" bind:this={scrollContainer} onscroll={handleScroll}>
{#each days as day}
{@const dayIsToday = isToday(day)}
{@const dayIsSelected = isSameDay(day, currentDate)}
{@const dayIsWeekend = day.getDay() === 0 || day.getDay() === 6}
{@const dayInRange = isWithinInterval(day, {
start: viewRange.start,
end: viewRange.end,
})}
{@const dayIsRangeStart = isSameDay(day, viewRange.start)}
{@const dayIsRangeEnd = isSameDay(day, viewRange.end)}
{@const isFirstOfMonth = day.getDate() === 1}
{@const moonPhase = isSignificantMoonPhase(day)}
{@const eventCount = getEventCount(day)}
{#if isFirstOfMonth}
<div class="month-divider"></div>
{/if}
<button
class="day-item"
class:weekend={dayIsWeekend && highlightWeekends}
class:selected={dayIsSelected && !dayIsToday}
class:in-range={dayInRange && !dayIsToday}
class:range-start={dayIsRangeStart && !dayIsToday}
class:range-end={dayIsRangeEnd && !dayIsToday}
data-date={format(day, 'yyyy-MM-dd')}
data-is-today={dayIsToday}
onclick={() => handleDayClick(day)}
class:is-today={dayIsToday}
>
{#if moonPhase.significant && showMoonPhases}
<span class="moon-indicator">{moonPhase.emoji}</span>
{/if}
<span class="day-weekday">{format(day, 'EE', { locale: de })}</span>
<span class="day-number">{format(day, 'd')}</span>
{#if eventCount > 0 && showEventIndicators}
<div class="event-dots">
{#each Array(eventCount) as _, i}
<span class="event-dot"></span>
{/each}
</div>
{/if}
</button>
{/each}
</div>
</div>
</div>
<style>
.date-strip-wrapper {
width: 100%;
flex-shrink: 0;
}
.date-strip-container {
display: flex;
flex-direction: column;
background: hsl(var(--color-card));
border-bottom: 1px solid hsl(var(--color-border));
padding: 0.375rem 0;
width: 100%;
overflow: hidden;
}
.month-header {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.125rem 0.5rem 0.375rem;
}
.month-label {
position: relative;
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--color-foreground));
white-space: nowrap;
min-width: 150px;
text-align: center;
}
.today-button {
position: absolute;
right: 100%;
top: 50%;
transform: translateY(-50%);
margin-right: 1rem;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.25rem 0.75rem;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 9999px;
cursor: pointer;
color: hsl(var(--color-primary));
transition: all 0.2s ease;
box-shadow: 0 2px 8px hsl(var(--color-foreground) / 0.08);
}
.today-button:hover {
background: hsl(var(--color-muted));
border-color: hsl(var(--color-primary) / 0.3);
transform: translateY(-50%) scale(1.02);
}
.today-label {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.today-date {
font-size: 0.6875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.month-divider {
width: 1px;
height: 40px;
background: hsl(var(--color-border));
margin: 0 0.5rem;
flex-shrink: 0;
}
.days-scroll {
display: flex;
align-items: center;
gap: 2px;
overflow-x: auto;
overflow-y: visible;
scrollbar-width: none;
-ms-overflow-style: none;
scroll-behavior: auto;
padding: 1.25rem 1rem 0.25rem;
margin-top: -1rem;
mask-image: linear-gradient(to right, transparent 0%, black 8%, black 92%, transparent 100%);
-webkit-mask-image: linear-gradient(
to right,
transparent 0%,
black 8%,
black 92%,
transparent 100%
);
}
.days-scroll::-webkit-scrollbar {
display: none;
}
.day-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 48px;
height: 54px;
padding: 0.25rem;
background: transparent;
border: none;
border-radius: 10px;
cursor: pointer;
color: hsl(var(--color-foreground));
transition: all 0.15s ease;
flex-shrink: 0;
position: relative;
}
.moon-indicator {
position: absolute;
top: -14px;
left: 50%;
transform: translateX(-50%);
font-size: 1rem;
line-height: 1;
}
.event-dots {
display: flex;
gap: 2px;
justify-content: center;
margin-top: 2px;
}
.event-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: hsl(var(--color-primary));
opacity: 0.7;
}
.day-item:hover {
background: hsl(var(--color-muted));
}
.day-item.weekend {
background: transparent;
border: 1px solid hsl(var(--color-border));
}
.day-item.weekend:hover {
background: hsl(var(--color-muted) / 0.3);
}
.day-item.weekend.in-range {
background: hsl(var(--color-primary) / 0.15);
border: 1px solid hsl(var(--color-primary) / 0.4);
}
.day-item.selected {
background: hsl(var(--color-muted));
color: hsl(var(--color-primary));
font-weight: 600;
}
/* View range highlighting */
.day-item.in-range {
background: hsl(var(--color-primary) / 0.12);
border-radius: 0;
}
.day-item.in-range.range-start {
border-radius: 10px 0 0 10px;
}
.day-item.in-range.range-end {
border-radius: 0 10px 10px 0;
}
.day-item.in-range.range-start.range-end {
border-radius: 10px;
}
.day-item.in-range:hover {
background: hsl(var(--color-primary) / 0.2);
}
/* Today styling */
.day-item.is-today {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground, 0 0% 100%));
border-radius: 10px;
font-weight: 700;
box-shadow: 0 2px 8px hsl(var(--color-primary) / 0.4);
}
.day-item.is-today .day-weekday {
opacity: 1;
color: hsl(var(--color-primary-foreground, 0 0% 100%));
}
.day-item.is-today .day-number {
color: hsl(var(--color-primary-foreground, 0 0% 100%));
}
.day-item.is-today .event-dot {
background: hsl(var(--color-primary-foreground, 0 0% 100%));
}
.day-weekday {
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.025em;
opacity: 0.7;
}
.day-number {
font-size: 1rem;
font-weight: 600;
line-height: 1;
}
/* Responsive */
@media (max-width: 640px) {
.day-item {
min-width: 42px;
height: 48px;
}
.moon-indicator {
font-size: 0.875rem;
top: -12px;
}
.day-number {
font-size: 0.9375rem;
}
.day-weekday {
font-size: 0.625rem;
}
.month-divider {
height: 32px;
margin: 0 0.375rem;
}
.month-label {
font-size: 0.875rem;
}
}
</style>

View file

@ -13,6 +13,7 @@
import type { Calendar, CalendarEvent } from '$lib/modules/calendar/types';
import CalendarHeader from '$lib/modules/calendar/components/CalendarHeader.svelte';
import DateStrip from '$lib/modules/calendar/components/DateStrip.svelte';
import WeekView from '$lib/modules/calendar/components/WeekView.svelte';
import MonthView from '$lib/modules/calendar/components/MonthView.svelte';
import AgendaView from '$lib/modules/calendar/components/AgendaView.svelte';
@ -118,6 +119,9 @@
<!-- Header -->
<CalendarHeader onNewEvent={handleNewEvent} />
<!-- Date Strip -->
<DateStrip />
<!-- Main content area -->
<div class="calendar-content">
<!-- Sidebar (desktop only) -->