);
}
diff --git a/apps/web/src/components/dashboard/dashboard-tabs.tsx b/apps/web/src/components/dashboard/dashboard-tabs.tsx
index 8a09a6d..6f88be8 100644
--- a/apps/web/src/components/dashboard/dashboard-tabs.tsx
+++ b/apps/web/src/components/dashboard/dashboard-tabs.tsx
@@ -12,7 +12,8 @@ import {
useState,
} from 'react';
import { createPortal } from 'react-dom';
-import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
+import { HEADER_CENTER_SLOT_ID, SIDEBAR_DASHBOARD_SLOT_ID } from '@/components/layout/header-slot';
+import { useSidebarStore } from '@/lib/stores/sidebar-store';
import type { DashboardTab } from '@/lib/dashboard-api';
interface DashboardTabsProps {
@@ -46,6 +47,30 @@ interface DragState {
id: string;
pointerId: number;
startX: number;
+ startY: number;
+}
+
+/**
+ * Wo die Reiter stehen (Design „Mosaik“, Runde 3): in der aufgeklappten
+ * Seitenleiste am Desktop als Liste unter „Dashboard“ — dort kosten sie
+ * keinen Platz ueber den Kacheln, und die Kopfzeile zeigt nur den Namen des
+ * gewaehlten Dashboards. Eingeklappt oder auf dem Handy gibt es diese Liste
+ * nicht, dann wie bisher als Umschalter in der Kopfzeile.
+ */
+type Placement = 'sidebar' | 'header';
+
+function useTabsPlacement(): Placement {
+ const isCollapsed = useSidebarStore((st) => st.isCollapsed);
+ const [isDesktop, setIsDesktop] = useState(false);
+ useEffect(() => {
+ if (typeof window.matchMedia !== 'function') return;
+ const mq = window.matchMedia('(min-width: 768px)');
+ const update = () => setIsDesktop(mq.matches);
+ update();
+ mq.addEventListener('change', update);
+ return () => mq.removeEventListener('change', update);
+ }, []);
+ return isDesktop && !isCollapsed ? 'sidebar' : 'header';
}
/**
@@ -57,16 +82,16 @@ interface DragState {
function computeReorderedIds(
order: string[],
draggedId: string,
- rects: Map,
- clientX: number,
+ rects: Map,
+ pointer: number,
): string[] {
const others = order.filter((id) => id !== draggedId);
let insertAt = others.length;
for (let i = 0; i < others.length; i++) {
const rect = rects.get(others[i]);
if (!rect) continue;
- const midpoint = rect.left + rect.width / 2;
- if (clientX < midpoint) {
+ const midpoint = rect.start + rect.size / 2;
+ if (pointer < midpoint) {
insertAt = i;
break;
}
@@ -131,13 +156,20 @@ export function DashboardTabs({
const scrollRef = useRef(null);
const hintId = useId();
const [slot, setSlot] = useState(null);
+ const [headerSlot, setHeaderSlot] = useState(null);
const [overflow, setOverflow] = useState({ left: false, right: false });
+ const placement = useTabsPlacement();
+ const vertical = placement === 'sidebar';
- // Einhaengepunkt der Kopfzeile suchen. Die Kopfzeile steht im selben
- // Seitenrahmen (AppShell) und ist nach dem ersten Zeichnen im DOM.
+ // Einhaengepunkte suchen. Kopfzeile und Seitenleiste stehen im selben
+ // Seitenrahmen (AppShell) und sind nach dem ersten Zeichnen im DOM; der
+ // Punkt der Seitenleiste entsteht bzw. verschwindet mit dem Ein-/Ausklappen
+ // im selben Durchgang, der `placement` aendert.
useEffect(() => {
- setSlot(document.getElementById(HEADER_CENTER_SLOT_ID));
- }, []);
+ const header = document.getElementById(HEADER_CENTER_SLOT_ID);
+ setHeaderSlot(header);
+ setSlot(vertical ? (document.getElementById(SIDEBAR_DASHBOARD_SLOT_ID) ?? header) : header);
+ }, [vertical]);
/** Misst, ob die Spur links/rechts ueberlaeuft — steuert die weiche Ausblendung. */
const measureOverflow = useCallback(() => {
@@ -200,7 +232,7 @@ export function DashboardTabs({
function handlePointerDown(e: PointerEvent, tabId: string) {
if (e.button !== 0 || renamingId) return;
- dragRef.current = { id: tabId, pointerId: e.pointerId, startX: e.clientX };
+ dragRef.current = { id: tabId, pointerId: e.pointerId, startX: e.clientX, startY: e.clientY };
}
function handlePointerMove(e: PointerEvent) {
@@ -209,7 +241,8 @@ export function DashboardTabs({
const wasNotDragging = previewOrder === null;
if (wasNotDragging) {
- if (Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD_PX) return;
+ const moved = vertical ? e.clientY - drag.startY : e.clientX - drag.startX;
+ if (Math.abs(moved) < DRAG_THRESHOLD_PX) return;
hasDraggedRef.current = true;
capturePointer(e.currentTarget, drag.pointerId);
}
@@ -221,15 +254,18 @@ export function DashboardTabs({
// wird) keine Verschiebung zeigen, weil der ERSTE Move-Aufruf nur in den
// Ziehzustand wechselt, ohne die Position auszuwerten.
const baseOrder = previewOrder ?? dashboards.map((d) => d.id);
- const rects = new Map();
+ const rects = new Map();
for (const id of baseOrder) {
const el = tabRefs.current.get(id);
if (el) {
const rect = el.getBoundingClientRect();
- rects.set(id, { left: rect.left, width: rect.width });
+ rects.set(
+ id,
+ vertical ? { start: rect.top, size: rect.height } : { start: rect.left, size: rect.width },
+ );
}
}
- const next = computeReorderedIds(baseOrder, drag.id, rects, e.clientX);
+ const next = computeReorderedIds(baseOrder, drag.id, rects, vertical ? e.clientY : e.clientX);
if (wasNotDragging || next.join('\u0000') !== baseOrder.join('\u0000')) {
setPreviewOrder(next);
}
@@ -287,8 +323,10 @@ export function DashboardTabs({
function handleTabKeyDown(e: KeyboardEvent, index: number) {
const count = orderedTabs.length;
let next: number;
- if (e.key === 'ArrowRight') next = (index + 1) % count;
- else if (e.key === 'ArrowLeft') next = (index - 1 + count) % count;
+ const forward = vertical ? 'ArrowDown' : 'ArrowRight';
+ const backward = vertical ? 'ArrowUp' : 'ArrowLeft';
+ if (e.key === forward) next = (index + 1) % count;
+ else if (e.key === backward) next = (index - 1 + count) % count;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = count - 1;
else return;
@@ -492,6 +530,153 @@ export function DashboardTabs({
);
+ // Senkrechte Liste unter „Dashboard“ in der Seitenleiste (Runde 3).
+ // Gleiche Bedienung wie die Leiste: Klick waehlt, Ziehen sortiert (hier
+ // senkrecht), Pfeiltasten hoch/runter wandern, Umbenennen/Loeschen/Neu nur
+ // im Bearbeitungsmodus. Mit nur einem Dashboard und ausserhalb des
+ // Bearbeitungsmodus bleibt die Liste weg — dann gibt es nichts zu waehlen.
+ const pencilIcon = (
+
+ );
+ const closeIcon = (
+
+ );
+
+ const showList = dashboards.length > 1 || isEditMode;
+ const list = showList ? (
+
+ ) : null;
+
+ // Kopfzeile, solange die Reiter in der Seitenleiste stehen: nur der Name
+ // des gewaehlten Dashboards, wie die anderen Seiten ihren Titel zeigen.
+ const activeName = dashboards.find((d) => d.id === activeDashboardId)?.name ?? '';
+ const headerTitle = (
+ {activeName}
+ );
+ const inSidebar = vertical && slot !== null && slot !== headerSlot;
+
// Der Loeschdialog haengt am Dokumentkoerper, nicht in der Kopfzeile —
// sonst saesse er in deren Stapelkontext.
const dialog = pendingDeleteTab
@@ -542,7 +727,14 @@ export function DashboardTabs({
return (
<>
- {createPortal(bar, slot)}
+ {inSidebar ? (
+ <>
+ {list && createPortal(list, slot)}
+ {headerSlot && createPortal(headerTitle, headerSlot)}
+ >
+ ) : (
+ createPortal(bar, slot)
+ )}
{dialog}
>
);
diff --git a/apps/web/src/components/dashboard/widget-registry.test.tsx b/apps/web/src/components/dashboard/widget-registry.test.tsx
index 8ac9b5f..b2d2295 100644
--- a/apps/web/src/components/dashboard/widget-registry.test.tsx
+++ b/apps/web/src/components/dashboard/widget-registry.test.tsx
@@ -74,7 +74,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
expect(WIDGET_CONSTRAINTS).toEqual({
clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 },
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
- calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 12 },
+ calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 16 },
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
favorites: { minW: 1, minH: 3, defaultW: 6, defaultH: 10 },
diff --git a/apps/web/src/components/dashboard/widget-registry.tsx b/apps/web/src/components/dashboard/widget-registry.tsx
index d7d61ca..2005e93 100644
--- a/apps/web/src/components/dashboard/widget-registry.tsx
+++ b/apps/web/src/components/dashboard/widget-registry.tsx
@@ -50,7 +50,9 @@ export const WIDGET_CONSTRAINTS: Record<
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
// quick-260916-htc: Monatsraster braucht Breite fuer 7 Spalten und Hoehe
// fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block.
- calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 12 },
+ // Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter
+ // den Monat nur ein Termin.
+ calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 16 },
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
// Phase 8 new widgets (D-01)
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat
diff --git a/apps/web/src/components/dashboard/widgets/calendar-month.ts b/apps/web/src/components/dashboard/widgets/calendar-month.ts
index 7d46865..8bf1015 100644
--- a/apps/web/src/components/dashboard/widgets/calendar-month.ts
+++ b/apps/web/src/components/dashboard/widgets/calendar-month.ts
@@ -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', {
diff --git a/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx b/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
index f087fce..41dd182 100644
--- a/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
+++ b/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
@@ -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 () => {
diff --git a/apps/web/src/components/dashboard/widgets/calendar-widget.tsx b/apps/web/src/components/dashboard/widgets/calendar-widget.tsx
index 68f1c62..c3b11b9 100644
--- a/apps/web/src/components/dashboard/widgets/calendar-widget.tsx
+++ b/apps/web/src/components/dashboard/widgets/calendar-widget.tsx
@@ -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')}
) : (
-
- {upcoming.map((event) => (
-
-
-
-
- {formatEventDate(event)}
-
-
+ // 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.
+