Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions e2e/calendar.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1923,6 +1923,60 @@ test.describe('FreeShard WebCalendar E2E Suite', () => {
await expect(page.locator('#settings-drawer')).toHaveClass(/hidden/);
});

test.describe('Issue #106 — the status bar says loading, free, failed or a count in every view', () => {
// page.route needs the service worker off.
test.use({ serviceWorkers: 'block' });

test('every view states its own empty range, and the loading state precedes it', async ({ page }) => {
let releaseEvents;
const eventsHeld = new Promise(resolve => { releaseEvents = resolve; });
await page.route('**/api/events?*', async route => {
await eventsHeld;
await route.fulfill({ json: { events: [], errors: [] } });
});
await page.route('**/api/tasks', route => {
if (route.request().method() !== 'GET') return route.continue();
return route.fulfill({ json: [] });
});
await page.goto('/');
await expect(page.locator('#app')).not.toHaveClass(/hidden/, { timeout: 10000 });
const bar = page.locator('#status-bar');
await expect(bar).toHaveText('Lade Termine…', { timeout: 10000 });
releaseEvents();
await expect(bar).toHaveText('Keine Termine in diesem Monat.', { timeout: 10000 });

const expected = [
['week', 'Keine Termine in dieser Woche.'],
['workweek', 'Keine Termine in dieser Arbeitswoche.'],
['day', 'Keine Termine an diesem Tag.'],
['agenda', 'Keine Termine im gewählten Zeitraum.'],
['kanban', 'Keine Aufgaben vorhanden.'],
['month', 'Keine Termine in diesem Monat.']
];
for (const [view, text] of expected) {
// Blank the bar first: a view that never writes it must not pass on the previous view's text.
await bar.evaluate(el => { el.textContent = ''; });
await page.click(`#view-${view}`);
await expect(bar, `${view} view`).toHaveText(text, { timeout: 10000 });
}
});

test('a failed load keeps the error in the bar, also after switching views', async ({ page }) => {
await page.route('**/api/events?*', route =>
route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'Datenbankfehler' }) }));
await page.goto('/');
await expect(page.locator('#app')).not.toHaveClass(/hidden/, { timeout: 10000 });
const bar = page.locator('#status-bar');
await expect(page.locator('#toast-container .toast.error', { hasText: 'Fehler beim Laden der Termine' })).toBeVisible({ timeout: 10000 });
await expect(bar).toHaveText(/^Fehler: Fehler beim Laden der Termine: .*Datenbankfehler/);

await bar.evaluate(el => { el.textContent = ''; });
await page.click('#view-day');
await expect(bar).toHaveText(/Fehler beim Laden der Termine: .*Datenbankfehler/, { timeout: 10000 });
await expect(bar).not.toHaveText(/Keine Termine/);
});
});

test.describe('Issue #343 — load failures are shown, not swallowed', () => {
// page.route needs the service worker off.
test.use({ serviceWorkers: 'block' });
Expand Down
45 changes: 34 additions & 11 deletions public/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
import { store, toggleCalendar, invalidateEventCache, cacheEvents, getCachedEvents, setCalendarSyncCache, getCalendarSyncCache } from './store.js';
import { AccountsAPI, CalendarsAPI, EventsAPI, TasksAPI, WebcalAPI } from './api.js';
import { renderView, getToolbarTitle, navigate, getDateRange, renderMiniCalendar, sanitizeColor } from './calendar.js';
import { initEventModal, openCreateModal, openEditModal, closeModal, chooseRecurrenceScope } from './eventModal.js';

Check warning on line 9 in public/js/app.js

View workflow job for this annotation

GitHub Actions / lint

'closeModal' is defined but never used
import { initAccountModal, openAccountModal } from './accountModal.js';
import { initNotificationService, checkAndTriggerAlarms, requestNotificationPermission } from './notifications.js';
import { initTaskUI, openTaskModal, renderKanbanBoard, renderSidebarTasks } from './tasks.js';

Check warning on line 12 in public/js/app.js

View workflow job for this annotation

GitHub Actions / lint

'openTaskModal' is defined but never used
import { initInvitations } from './invitations.js';
import { initAvailabilityManager } from './availability.js';
import { initMSGraphModal, openMSGraphModal } from './msgraph.js';

Check warning on line 15 in public/js/app.js

View workflow job for this annotation

GitHub Actions / lint

'openMSGraphModal' is defined but never used
import { initDialogFocusManager, openDialog, closeDialog, confirmDialog, handleEscapeKey } from './dialogFocus.js';

const { initTheme, setTheme, getStoredTheme } = (typeof window !== 'undefined' && window.ThemeManager) ? window.ThemeManager : {};
Expand Down Expand Up @@ -282,15 +282,17 @@
// A partial result is not cached: the next load must ask the failed calendars again.
if (errors.length === 0) cacheEvents(cacheKey, events);
const activeCurrentSubs = webcalEvents.filter(ev => !hiddenWebcal.has(ev.subscriptionId));
store.set({ events: [...events, ...activeCurrentSubs] });
store.set({ events: [...events, ...activeCurrentSubs], eventsError: null });
renderCalendarView();
checkAndTriggerAlarms();
} catch (err) {
if (seq !== loadEventsSeq) return;
// With nothing cached the view is empty because of the failure, not because
// the range is free; the status bar must say so and keep saying it (#106).
store.set({ eventsError: cached ? null : err.message });
renderCalendarView();
if (!cached) {
showToast('Fehler beim Laden der Termine: ' + err.message, 'error');
setStatus('');
}
} finally {
if (seq === loadEventsSeq) {
Expand Down Expand Up @@ -580,6 +582,30 @@
.replace(/"/g, '"');
}

const EMPTY_RANGE_TEXT = {
month: 'Keine Termine in diesem Monat.',
week: 'Keine Termine in dieser Woche.',
workweek: 'Keine Termine in dieser Arbeitswoche.',
day: 'Keine Termine an diesem Tag.',
agenda: 'Keine Termine im gewählten Zeitraum.'
};

// The status bar must say which state the view is in — failed, partial, free
// or a count — never a bare "0" that reads like a failed load (#106).
function eventStatusText(view, count, query) {
const eventsError = store.get('eventsError');
if (eventsError) return `Fehler beim Laden der Termine: ${eventsError}`;
if (query) return `${count} Ergebnis${count !== 1 ? 'se' : ''} für „${query}“`;
const partial = (store.get('eventErrors') || []).length > 0;
if (count === 0) {
if (partial) return 'Keine Termine geladen – nicht alle Kalender konnten gelesen werden.';
if (!EMPTY_RANGE_TEXT[view]) throw new Error(`No empty-state text for view "${view}"`);
return EMPTY_RANGE_TEXT[view];
}
const text = `${count} Termin${count !== 1 ? 'e' : ''}`;
return partial ? `${text} – nicht alle Kalender konnten gelesen werden.` : text;
}

function renderCalendarView() {
const container = $('calendar-container');
const view = store.get('view');
Expand All @@ -603,6 +629,8 @@
announce('Fehler beim Laden der Aufgaben');
} else if (searchQuery && searchQuery.trim() && count === 0) {
announce(`0 Ergebnisse für „${searchQuery.trim()}“`);
} else if (count === 0) {
announce('Keine Aufgaben vorhanden.');
} else {
announce(`${count} Aufgabe${count !== 1 ? 'n' : ''}`);
}
Expand All @@ -628,19 +656,14 @@
}
});

if (searchQuery && searchQuery.trim() && events.length === 0) {
const query = searchQuery ? searchQuery.trim() : '';
if (query && events.length === 0 && !store.get('eventsError')) {
const banner = document.createElement('div');
banner.className = 'search-empty-banner';
banner.innerHTML = `<span class="search-empty-icon">🔍</span> Keine Termine für „${escapeHtml(searchQuery.trim())}“ gefunden.`;
banner.innerHTML = `<span class="search-empty-icon">🔍</span> Keine Termine für „${escapeHtml(query)}“ gefunden.`;
container.insertBefore(banner, container.firstChild);
announce(`0 Ergebnisse für „${searchQuery.trim()}“`);
} else if (searchQuery && searchQuery.trim()) {
const count = events.length;
announce(`${count} Ergebnis${count !== 1 ? 'se' : ''} für „${searchQuery.trim()}“`);
} else {
const count = events.length;
announce(`${count} Termin${count !== 1 ? 'e' : ''}`);
}
announce(eventStatusText(view, events.length, query));

// Mini calendar
renderMiniCalendar(
Expand Down
1 change: 1 addition & 0 deletions public/js/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const store = createStore({

// Loading state
loadingEvents: false,
eventsError: null, // message of the last failed load with nothing cached to show (#106)
loadingCalendars: false,

// Search
Expand Down
Loading
Loading