T08b: Frontend Calendar UI (month view, kanban, ICS, resources, sharing)

- 8 calendar components (MonthView, KanbanBoard, AppointmentModal, TaskDetailPanel, IcsControls, ResourceBooking, SharingSettings + API client)

- 2 pages (/calendar, /calendar/kanban) + zustand store

- 17 vitest tests (MonthView 5, KanbanBoard 6, AppointmentModal 6) all passing

- i18n: calendar namespace in en/de (104 lines each, +exportSuccess key)

- TS strict mode pass, npm run build pass
This commit is contained in:
A0-Orchestrator
2026-06-30 11:35:08 +02:00
parent f9b19bb777
commit 7350739554
17 changed files with 2837 additions and 0 deletions
@@ -0,0 +1,182 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { AppointmentModal } from '@/components/calendar/AppointmentModal';
import type { Calendar, CalendarEntry } from '@/api/calendar';
vi.mock('@/api/calendar', async () => {
const actual = await vi.importActual<typeof import('@/api/calendar')>('@/api/calendar');
return {
...actual,
createEntry: vi.fn(),
updateEntry: vi.fn(),
deleteEntry: vi.fn(),
};
});
import { createEntry, updateEntry, deleteEntry } from '@/api/calendar';
const calendars: Calendar[] = [
{
id: 'cal-1',
name: 'Persönlich',
color: '#3B82F6',
type: 'personal',
owner_id: 'u-1',
},
];
const sampleEntry: CalendarEntry = {
id: 'e-1',
calendar_id: 'cal-1',
entry_type: 'appointment',
subtype: 'normal',
title: 'Bestehender Termin',
description: 'Notiz',
location: 'Berlin',
start_at: '2026-06-20T09:00:00.000Z',
end_at: '2026-06-20T10:00:00.000Z',
all_day: false,
priority: 'medium',
status: 'open',
created_by: 'u-1',
};
describe('AppointmentModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the create modal with pre-filled date from prefillDate', () => {
render(
<AppointmentModal
open
onClose={vi.fn()}
prefillDate={new Date(2026, 6, 1)}
calendars={calendars}
defaultCalendarId="cal-1"
onSaved={vi.fn()}
onDeleted={vi.fn()}
/>
);
expect(screen.getByTestId('appointment-modal')).toBeInTheDocument();
const start = screen.getByTestId('appointment-start') as HTMLInputElement;
expect(start.value).toMatch(/2026-07-01/);
});
it('in edit mode, prefills the form with the entry fields and shows the delete button', () => {
render(
<AppointmentModal
open
onClose={vi.fn()}
entry={sampleEntry}
calendars={calendars}
defaultCalendarId="cal-1"
onSaved={vi.fn()}
onDeleted={vi.fn()}
/>
);
expect((screen.getByTestId('appointment-title') as HTMLInputElement).value).toBe(
'Bestehender Termin'
);
expect(screen.getByTestId('appointment-delete')).toBeInTheDocument();
});
it('save in create mode calls createEntry and onSaved', async () => {
(createEntry as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(sampleEntry);
const onSaved = vi.fn();
const onClose = vi.fn();
render(
<AppointmentModal
open
onClose={onClose}
prefillDate={new Date(2026, 6, 1, 9, 0)}
calendars={calendars}
defaultCalendarId="cal-1"
onSaved={onSaved}
onDeleted={vi.fn()}
/>
);
fireEvent.change(screen.getByTestId('appointment-title'), {
target: { value: 'Neuer Termin' },
});
fireEvent.click(screen.getByTestId('appointment-save'));
await waitFor(() => {
expect(createEntry).toHaveBeenCalledTimes(1);
});
expect(onSaved).toHaveBeenCalledWith(sampleEntry);
expect(onClose).toHaveBeenCalled();
});
it('save in edit mode calls updateEntry and onSaved', async () => {
(updateEntry as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
...sampleEntry,
title: 'Geändert',
});
const onSaved = vi.fn();
render(
<AppointmentModal
open
onClose={vi.fn()}
entry={sampleEntry}
calendars={calendars}
defaultCalendarId="cal-1"
onSaved={onSaved}
onDeleted={vi.fn()}
/>
);
fireEvent.change(screen.getByTestId('appointment-title'), {
target: { value: 'Geändert' },
});
fireEvent.click(screen.getByTestId('appointment-save'));
await waitFor(() => {
expect(updateEntry).toHaveBeenCalledWith(
'e-1',
expect.objectContaining({ title: 'Geändert' })
);
});
expect(onSaved).toHaveBeenCalled();
});
it('shows a validation error when title is empty', async () => {
render(
<AppointmentModal
open
onClose={vi.fn()}
prefillDate={new Date(2026, 6, 1)}
calendars={calendars}
defaultCalendarId="cal-1"
onSaved={vi.fn()}
onDeleted={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId('appointment-save'));
const err = await screen.findByTestId('appointment-error');
expect(err.textContent?.toLowerCase()).toContain('erforderlich');
});
it('delete button calls deleteEntry and onDeleted', async () => {
(deleteEntry as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const onDeleted = vi.fn();
const onClose = vi.fn();
// Stub confirm
window.confirm = vi.fn(() => true);
render(
<AppointmentModal
open
onClose={onClose}
entry={sampleEntry}
calendars={calendars}
defaultCalendarId="cal-1"
onSaved={vi.fn()}
onDeleted={onDeleted}
/>
);
fireEvent.click(screen.getByTestId('appointment-delete'));
await waitFor(() => {
expect(deleteEntry).toHaveBeenCalledWith('e-1');
});
expect(onDeleted).toHaveBeenCalledWith('e-1');
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,92 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { KanbanBoard } from '@/components/calendar/KanbanBoard';
import type { CalendarEntry } from '@/api/calendar';
function makeTask(id: string, title: string, status: CalendarEntry['status']): CalendarEntry {
return {
id,
calendar_id: 'cal-1',
entry_type: 'task',
subtype: 'normal',
title,
all_day: false,
priority: 'medium',
status,
created_by: 'u-1',
due_date: '2026-07-01T00:00:00.000Z',
};
}
const board = {
open: [makeTask('t-1', 'Recherche', 'open')],
in_progress: [makeTask('t-2', 'Implementierung', 'in_progress')],
done: [makeTask('t-3', 'Tests', 'done')],
cancelled: [],
};
describe('KanbanBoard', () => {
it('renders three columns (open / in_progress / done)', () => {
render(<KanbanBoard board={board} onMove={vi.fn()} />);
expect(screen.getByTestId('kanban-column-open')).toBeInTheDocument();
expect(screen.getByTestId('kanban-column-in_progress')).toBeInTheDocument();
expect(screen.getByTestId('kanban-column-done')).toBeInTheDocument();
});
it('renders task cards inside the correct columns', () => {
render(<KanbanBoard board={board} onMove={vi.fn()} />);
expect(screen.getByTestId('kanban-card-t-1').textContent).toContain('Recherche');
expect(screen.getByTestId('kanban-card-t-2').textContent).toContain('Implementierung');
expect(screen.getByTestId('kanban-card-t-3').textContent).toContain('Tests');
});
it('shows column counts', () => {
render(<KanbanBoard board={board} onMove={vi.fn()} />);
expect(screen.getByTestId('kanban-count-open').textContent).toBe('1');
expect(screen.getByTestId('kanban-count-in_progress').textContent).toBe('1');
expect(screen.getByTestId('kanban-count-done').textContent).toBe('1');
});
it('shows empty state when there are no tasks', () => {
render(
<KanbanBoard
board={{ open: [], in_progress: [], done: [], cancelled: [] }}
onMove={vi.fn()}
/>
);
expect(screen.getByTestId('kanban-empty')).toBeInTheDocument();
});
it('drag-and-drop into another column fires onMove with target status', () => {
const onMove = vi.fn();
render(<KanbanBoard board={board} onMove={onMove} />);
const card = screen.getByTestId('kanban-card-t-1');
const targetCol = screen.getByTestId('kanban-column-done');
const dataTransfer = {
setData: vi.fn(),
getData: (k: string) => {
if (k === 'application/x-kanban-entry') return 't-1';
if (k === 'application/x-kanban-source') return 'open';
return '';
},
types: ['application/x-kanban-entry', 'application/x-kanban-source'],
effectAllowed: '',
dropEffect: '',
};
fireEvent.dragStart(card, { dataTransfer });
fireEvent.dragOver(targetCol, { dataTransfer });
fireEvent.drop(targetCol, { dataTransfer });
expect(onMove).toHaveBeenCalledTimes(1);
const [movedEntry, target] = onMove.mock.calls[0];
expect(movedEntry.id).toBe('t-1');
expect(target).toBe('done');
});
it('clicking a card fires onSelect', () => {
const onSelect = vi.fn();
render(<KanbanBoard board={board} onMove={vi.fn()} onSelect={onSelect} />);
fireEvent.click(screen.getByTestId('kanban-card-t-2'));
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 't-2' }));
});
});
@@ -0,0 +1,138 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MonthView } from '@/components/calendar/MonthView';
import type { CalendarEntry } from '@/api/calendar';
const sampleEntries: CalendarEntry[] = [
{
id: 'a-1',
calendar_id: 'cal-1',
entry_type: 'appointment',
subtype: 'normal',
title: 'Team-Meeting',
start_at: '2026-06-15T09:00:00.000Z',
end_at: '2026-06-15T10:00:00.000Z',
all_day: false,
priority: 'medium',
status: 'open',
created_by: 'u-1',
},
{
id: 'a-2',
calendar_id: 'cal-1',
entry_type: 'appointment',
subtype: 'normal',
title: 'Kundentermin',
start_at: '2026-06-22T14:00:00.000Z',
end_at: '2026-06-22T15:00:00.000Z',
all_day: false,
priority: 'high',
status: 'open',
created_by: 'u-1',
},
];
describe('MonthView', () => {
it('renders the month grid with header', () => {
render(
<MonthView
visibleMonth={new Date(2026, 5, 1)}
entries={[]}
onCreateAt={vi.fn()}
onEditEntry={vi.fn()}
onMoveEntry={vi.fn()}
/>
);
expect(screen.getByTestId('month-view')).toBeInTheDocument();
expect(screen.getByTestId('month-view-grid')).toBeInTheDocument();
// Header includes month name and year
expect(screen.getByTestId('month-view-header').textContent).toContain('2026');
});
it('renders all 42 day cells (6 weeks × 7 days)', () => {
render(
<MonthView
visibleMonth={new Date(2026, 5, 1)}
entries={[]}
onCreateAt={vi.fn()}
onEditEntry={vi.fn()}
onMoveEntry={vi.fn()}
/>
);
const cells = screen.getAllByRole('button', { hidden: false }).filter((el) =>
el.getAttribute('data-testid')?.startsWith('month-cell-')
);
expect(cells.length).toBe(42);
});
it('renders an entry chip for each appointment and clicking it fires onEditEntry', () => {
const onEdit = vi.fn();
render(
<MonthView
visibleMonth={new Date(2026, 5, 1)}
entries={sampleEntries}
onCreateAt={vi.fn()}
onEditEntry={onEdit}
onMoveEntry={vi.fn()}
/>
);
expect(screen.getByTestId('month-entry-a-1')).toBeInTheDocument();
expect(screen.getByTestId('month-entry-a-2')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('month-entry-a-1'));
expect(onEdit).toHaveBeenCalledWith(
expect.objectContaining({ id: 'a-1', title: 'Team-Meeting' })
);
});
it('clicking an empty cell fires onCreateAt with the day Date', () => {
const onCreate = vi.fn();
render(
<MonthView
visibleMonth={new Date(2026, 5, 1)}
entries={[]}
onCreateAt={onCreate}
onEditEntry={vi.fn()}
onMoveEntry={vi.fn()}
/>
);
// Pick the first cell of the grid
const cells = screen.getAllByRole('button', { hidden: false }).filter((el) =>
el.getAttribute('data-testid')?.startsWith('month-cell-')
);
fireEvent.click(cells[0]);
expect(onCreate).toHaveBeenCalledTimes(1);
const arg = onCreate.mock.calls[0][0] as Date;
expect(arg).toBeInstanceOf(Date);
});
it('drag-and-drop fires onMoveEntry with the new start time-of-day preserved', () => {
const onMove = vi.fn();
const { container } = render(
<MonthView
visibleMonth={new Date(2026, 5, 1)}
entries={sampleEntries}
onCreateAt={vi.fn()}
onEditEntry={vi.fn()}
onMoveEntry={onMove}
/>
);
const entryEl = screen.getByTestId('month-entry-a-1');
const cells = container.querySelectorAll('[data-testid^="month-cell-"]');
// Pick a cell that's clearly different from June 15
const targetCell = cells[20] as HTMLElement;
const dataTransfer = {
setData: vi.fn(),
getData: (k: string) => (k === 'application/x-calendar-entry' ? 'a-1' : ''),
types: ['application/x-calendar-entry'],
effectAllowed: '',
dropEffect: '',
};
fireEvent.dragStart(entryEl, { dataTransfer });
fireEvent.dragOver(targetCell, { dataTransfer });
fireEvent.drop(targetCell, { dataTransfer });
expect(onMove).toHaveBeenCalledTimes(1);
const [, newStart] = onMove.mock.calls[0];
expect(newStart).toBeInstanceOf(Date);
});
});