feat(web): Design Mosaik Runde 3 - Reiter in der Seitenleiste, lebendigere Kacheln
- Dashboard-Reiter stehen in der aufgeklappten Seitenleiste als Liste unter "Dashboard" (Ziehen sortiert senkrecht, Umbenennen/Loeschen/Neu im Bearbeitungsmodus); eingeklappt und auf dem Handy wie bisher in der Kopfzeile. Die Kopfzeile zeigt sonst den Namen des Dashboards. - Begruessung einzeilig auf Hoehe der Befehlsleiste. - Kachel-Groesse: kollidiert die gezogene Groesse mit einem Nachbarn, wird nur die freie Achse uebernommen - vorher verwarf react-grid-layout mit preventCollision die ganze Aenderung, sobald die Maus eine Zeile nach unten wackelte (Favoriten liessen sich deshalb oft nicht schmaler ziehen). - Ansicht: belegte Spalten mittig, Begruessung/Befehlsleiste ruecken mit. - Kalender: einzeilige Terminliste mit "Heute"/"Morgen" in Akzentfarbe, Ort im Tooltip, Standardhoehe 16 statt 12. - Hintergrund "Bluete" im Dunkelmodus weggelassen (Farbstufen), Ersatz Nebel. - Kacheln: Symbol-Chip in der Akzentfarbe, leichtes Anheben beim Zeigen, gestaffeltes Einblenden beim Laden (ohne bei reduzierter Bewegung). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -283,6 +283,27 @@ export function formatEventDate(event: CalendarEvent): string {
|
||||
}).format(start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstand des Terminbeginns zu heute in Kalendertagen (0 = heute,
|
||||
* 1 = morgen, negativ = laeuft seit gestern oder frueher). Gezaehlt wird in
|
||||
* Ortszeit, nicht in 24-Stunden-Bloecken.
|
||||
*/
|
||||
export function eventDayOffset(event: CalendarEvent, now: Date = new Date()): number {
|
||||
const start = new Date(event.start);
|
||||
const a = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate());
|
||||
const b = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
return Math.round((a - b) / 86_400_000);
|
||||
}
|
||||
|
||||
/** Kurzes Datum ohne Uhrzeit, „Sa., 03.10.“. */
|
||||
export function formatEventDay(event: CalendarEvent): string {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
}).format(new Date(event.start));
|
||||
}
|
||||
|
||||
/** Nur die Uhrzeit eines ISO-Zeitstempels, „09:05“. */
|
||||
export function formatEventTime(iso: string): string {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
|
||||
@@ -409,12 +409,15 @@ describe('CalendarWidget', () => {
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toHaveTextContent('E1');
|
||||
expect(items[1]).toHaveTextContent('E2');
|
||||
expect(items[1]).toHaveTextContent('Raum 2');
|
||||
// Runde 3: einzeilige Zeile, der Ort steht im Tooltip der Zeile.
|
||||
expect(items[1]).toHaveAttribute('title', expect.stringContaining('Raum 2'));
|
||||
|
||||
for (const item of items) {
|
||||
expect(within(item).getByTestId('event-color-dot')).toBeInTheDocument();
|
||||
}
|
||||
expect(items[0].textContent).toMatch(/\d{2}\.\d{2}\./);
|
||||
// Runde 3: morgen steht als Wort (Akzentfarbe), spaetere Tage als Datum.
|
||||
expect(items[0]).toHaveTextContent('calendar.tomorrow');
|
||||
expect(items[1].textContent).toMatch(/\d{2}\.\d{2}\./);
|
||||
});
|
||||
|
||||
it('Test 6: showMonth=false blendet das Raster aus, maxEvents=0 zeigt die Leermeldung', async () => {
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
buildCalendarDays,
|
||||
buildDayBadges,
|
||||
computeFetchWindow,
|
||||
eventDayOffset,
|
||||
formatEventDate,
|
||||
formatEventDay,
|
||||
formatEventTime,
|
||||
formatMonthLabel,
|
||||
groupEventsByDate,
|
||||
@@ -407,30 +409,48 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
{t('calendar.emptyNoEvents')}
|
||||
</p>
|
||||
) : (
|
||||
<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 space-y-0.5 overflow-y-auto">
|
||||
{upcoming.map((event) => (
|
||||
<li key={event.id} className="flex items-stretch gap-2.5 rounded-md px-1.5 py-1 hover:bg-muted">
|
||||
<span
|
||||
data-testid="event-color-dot"
|
||||
className="w-[3px] shrink-0 rounded-full"
|
||||
style={{ backgroundColor: event.color || 'var(--muted-foreground)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[11px] leading-4 text-muted-foreground">
|
||||
{formatEventDate(event)}
|
||||
</p>
|
||||
<p className="truncate text-[13px] font-medium leading-[18px] text-foreground">
|
||||
// Einzeilige Terminliste (Design „Mosaik“, Runde 3): Farbstrich,
|
||||
// Tag, Titel, Uhrzeit rechts — rund 26 px je Termin statt 40, so
|
||||
// passen auch in eine kleine Kachel mehrere Termine. „Heute“ und
|
||||
// „Morgen“ stehen in der Akzentfarbe.
|
||||
<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 overflow-y-auto">
|
||||
{upcoming.map((event) => {
|
||||
const offset = eventDayOffset(event);
|
||||
const dayLabel =
|
||||
offset === 0
|
||||
? t('calendar.today')
|
||||
: offset === 1
|
||||
? t('calendar.tomorrow')
|
||||
: formatEventDay(event);
|
||||
const soon = offset === 0 || offset === 1;
|
||||
return (
|
||||
<li
|
||||
key={event.id}
|
||||
title={[formatEventDate(event), event.title, event.location].filter(Boolean).join(' · ')}
|
||||
className="flex h-[26px] items-center gap-2 rounded-md px-1.5 hover:bg-muted"
|
||||
>
|
||||
<span
|
||||
data-testid="event-color-dot"
|
||||
className="h-3.5 w-[3px] shrink-0 rounded-full"
|
||||
style={{ backgroundColor: event.color || 'var(--muted-foreground)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className={`w-[62px] shrink-0 truncate text-[11px] tabular-nums ${
|
||||
soon ? 'font-semibold text-primary-strong' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{dayLabel}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">
|
||||
{event.title}
|
||||
</p>
|
||||
{event.location && (
|
||||
<p className="truncate text-[11px] leading-4 text-muted-foreground">
|
||||
{event.location}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||
{event.allDay ? t('calendar.allDay') : formatEventTime(event.start)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Kleines einfarbiges Symbol je Kacheltyp fuer die Kachel-Kopfzeile
|
||||
* (Design „Mosaik“, Runde 2): 16 px, gedaempftes Grau, rein dekorativ.
|
||||
* (Design „Mosaik“). Seit Runde 3 als „Chip“: 16-px-Symbol auf einer
|
||||
* 26-px-Flaeche, beides aus der persoenlichen Akzentfarbe abgeleitet
|
||||
* (`.widget-icon-chip` in globals.css) — bringt Farbe in die Kacheln, ohne
|
||||
* bunt zu werden. `plain` ist das nackte graue Symbol (Vorschlagsknoepfe).
|
||||
*/
|
||||
const PATHS: Record<string, ReactNode> = {
|
||||
calendar: (
|
||||
@@ -64,10 +67,10 @@ const PATHS: Record<string, ReactNode> = {
|
||||
),
|
||||
};
|
||||
|
||||
export function WidgetIcon({ type }: { type: string }) {
|
||||
export function WidgetIcon({ type, variant = 'chip' }: { type: string; variant?: 'chip' | 'plain' }) {
|
||||
const paths = PATHS[type];
|
||||
if (!paths) return null;
|
||||
return (
|
||||
const svg = (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-widget-icon={type}
|
||||
@@ -80,9 +83,15 @@ export function WidgetIcon({ type }: { type: string }) {
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
className={variant === 'plain' ? 'shrink-0 text-muted-foreground' : 'shrink-0'}
|
||||
>
|
||||
{paths}
|
||||
</svg>
|
||||
);
|
||||
if (variant === 'plain') return svg;
|
||||
return (
|
||||
<span aria-hidden="true" className="widget-icon-chip">
|
||||
{svg}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { WIDGET_REGISTRY, type WidgetType } from '../widget-registry';
|
||||
import { WidgetIcon } from './widget-icon';
|
||||
@@ -21,6 +22,8 @@ interface WidgetWrapperProps {
|
||||
};
|
||||
isEditMode: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
/** Position im Raster — staffelt das Einblenden beim Laden (Runde 3). */
|
||||
enterIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,7 +38,7 @@ interface WidgetWrapperProps {
|
||||
* .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx.
|
||||
* Renders the actual widget component via WIDGET_REGISTRY lookup.
|
||||
*/
|
||||
export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperProps) {
|
||||
export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }: WidgetWrapperProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
|
||||
const WidgetComponent = definition?.component;
|
||||
@@ -43,12 +46,13 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
|
||||
// Design „Mosaik“: in der Ansicht eine ruhige Fluent-Karte (hell Schatten,
|
||||
// dunkel Haarlinie), die Akzent-Umrandung nur im Bearbeitungsmodus.
|
||||
const cardClassName = isEditMode
|
||||
? 'widget-drag-handle relative h-full w-full cursor-grab overflow-hidden rounded-lg bg-card shadow-md active:cursor-grabbing'
|
||||
: 'relative h-full w-full overflow-hidden rounded-lg bg-card shadow-sm dark:border dark:border-border';
|
||||
? 'widget-card widget-drag-handle relative h-full w-full cursor-grab overflow-hidden rounded-lg bg-card shadow-md active:cursor-grabbing'
|
||||
: 'widget-card relative h-full w-full overflow-hidden rounded-lg bg-card shadow-sm dark:border dark:border-border';
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cardClassName}
|
||||
style={{ '--enter-delay': `${Math.min(enterIndex, 8) * 40}ms` } as CSSProperties}
|
||||
data-widget-id={widget.id}
|
||||
aria-label={definition ? t(definition.nameKey) : widget.widgetType}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user