mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 21:29:40 +02:00
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:
parent
1cd89af80d
commit
67567605fa
3 changed files with 533 additions and 0 deletions
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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) -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue