fix: show booking calendar state across cards, say when a plugin stops a change and make the calendar picker keyboard friendly

This commit is contained in:
Linus Rath
2026-10-30 05:40:19 +01:00
parent 87d9f89238
commit 3aac5a5c4c
32 changed files with 240 additions and 28 deletions
@@ -77,7 +77,11 @@ beforeEach(() => {
vi.setSystemTime(NOW);
client.queryCalendarEvents.mockReset();
calendarHolds([]);
createEvent.mockReset().mockImplementation(async (_client, event: Partial<CalendarEvent>) => ({ ...event, id: `new-${event.uid}` }));
createEvent.mockReset().mockImplementation(async (_client, event: Partial<CalendarEvent>) => {
const created = { ...event, id: `new-${event.uid}` } as CalendarEvent;
useCalendarStore.setState((s) => ({ events: [...s.events, created] }));
return created;
});
updateEvent.mockReset().mockResolvedValue(true);
deleteEvent.mockReset().mockResolvedValue(true);
setCalendarGate(true);
@@ -148,6 +152,9 @@ describe('useBookingCalendar', () => {
const [, id, patch, scheduling] = updateEvent.mock.calls[0];
expect(id).toBe('e1');
expect(patch).toMatchObject({ start: '2026-12-01T12:40:00', timeZone: 'Europe/Berlin', duration: 'PT8H55M' });
// The stored description isn't the generated one any more: title and notes stay the user's.
expect(patch).not.toHaveProperty('title');
expect(patch).not.toHaveProperty('description');
expect(scheduling).toBeUndefined();
expect(result.current.status).toBe('in-calendar');
});
@@ -241,12 +248,60 @@ describe('useBookingCalendar', () => {
});
it('says why an add failed', async () => {
createEvent.mockResolvedValue(null);
createEvent.mockImplementation(async () => {
useCalendarStore.setState({ error: 'Failed to create event' });
return null;
});
const { result } = await settled(await bookingFrom([OUT]));
await act(() => result.current.add());
expect(result.current.error).toBe('action_failed');
expect(result.current.status).toBe('add');
});
it('says so when a plugin stops an add, an update or a removal', async () => {
createEvent.mockResolvedValue(null);
const fresh = await settled(await bookingFrom([OUT]));
await act(() => fresh.result.current.add());
expect(fresh.result.current).toMatchObject({ notice: 'blocked', error: null, status: 'add' });
const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' });
calendarHolds([stored(old, 'e1')]);
updateEvent.mockResolvedValue(false);
const moved = await settled(await bookingFrom([OUT_MOVED]));
await act(() => moved.result.current.update());
expect(moved.result.current).toMatchObject({ notice: 'blocked', status: 'update' });
deleteEvent.mockResolvedValue(false);
const cancelled = await settled(await bookingFrom([OUT_CANCELLED]));
await act(() => cancelled.result.current.remove());
expect(cancelled.result.current).toMatchObject({ notice: 'blocked', status: 'remove' });
});
it('shows a booking added from another card for the same booking as in the calendar', async () => {
const booking = await bookingFrom([OUT]);
const first = await settled(booking);
const second = await settled(await bookingFrom([OUT]));
expect(first.result.current.status).toBe('add');
expect(second.result.current.status).toBe('add');
await act(() => second.result.current.add());
expect(second.result.current.status).toBe('in-calendar');
await waitFor(() => expect(first.result.current.status).toBe('in-calendar'));
expect(createEvent).toHaveBeenCalledTimes(1);
});
it('shows a booking removed from another card as gone', async () => {
const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' });
calendarHolds([stored(old, 'e1')]);
deleteEvent.mockImplementation(async (_client, id: string) => {
useCalendarStore.setState((s) => ({ events: s.events.filter((e) => e.id !== id) }));
return true;
});
const first = await settled(await bookingFrom([OUT_CANCELLED]));
const second = await settled(await bookingFrom([OUT_CANCELLED]));
expect(first.result.current.status).toBe('remove');
await act(() => second.result.current.remove());
await waitFor(() => expect(first.result.current.status).toBe('cancelled'));
});
});
describe('BookingCalendarActions', () => {
@@ -272,6 +327,53 @@ describe('BookingCalendarActions', () => {
expect(await screen.findByText('added')).toBeTruthy();
});
async function openPicker() {
useCalendarStore.setState({ calendars: [cal('Personal', { isDefault: true }), cal('Work'), cal('Travel')] });
render(<BookingCalendarActions booking={await bookingFrom([OUT])} email={EMAIL} />);
const trigger = await screen.findByRole('button', { name: /add_to_calendar/ });
fireEvent.click(trigger);
return { trigger, menu: screen.getByRole('menu') };
}
it('moves focus into the picker and through it with the arrow keys, Home and End', async () => {
const { menu } = await openPicker();
const items = screen.getAllByRole('menuitem');
expect(document.activeElement).toBe(items[0]);
fireEvent.keyDown(menu, { key: 'ArrowDown' });
expect(document.activeElement).toBe(items[1]);
fireEvent.keyDown(menu, { key: 'End' });
expect(document.activeElement).toBe(items[2]);
fireEvent.keyDown(menu, { key: 'ArrowDown' });
expect(document.activeElement).toBe(items[0]);
fireEvent.keyDown(menu, { key: 'ArrowUp' });
expect(document.activeElement).toBe(items[2]);
fireEvent.keyDown(menu, { key: 'Home' });
expect(document.activeElement).toBe(items[0]);
expect(menu.querySelector('div')?.getAttribute('role')).toBe('presentation');
});
it('closes the picker on Escape and gives focus back to its button', async () => {
const { trigger, menu } = await openPicker();
fireEvent.keyDown(menu, { key: 'Escape' });
expect(screen.queryByRole('menu')).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it('stays open while its own list scrolls, and closes when the page scrolls', async () => {
const { menu } = await openPicker();
fireEvent.scroll(menu);
expect(screen.queryByRole('menu')).not.toBeNull();
fireEvent.scroll(window);
expect(screen.queryByRole('menu')).toBeNull();
});
it('puts focus on the outcome after a pick', async () => {
await openPicker();
fireEvent.click(screen.getByRole('menuitem', { name: 'Work' }));
await waitFor(() => expect(document.activeElement?.getAttribute('role')).toBe('status'));
expect(document.activeElement?.textContent).toBe('added');
});
it('keeps the picker inside a 390 px wide screen when the button sits at the right edge', async () => {
useCalendarStore.setState({ calendars: [cal('Personal', { isDefault: true }), cal('Work')] });
const width = window.innerWidth;
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { createPortal } from 'react-dom';
import { useTranslations } from 'next-intl';
import {
@@ -53,35 +53,77 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
const [picker, setPicker] = useState<PickerPosition | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
// An action was started from here: once it is done, focus goes to its
// outcome instead of being lost with the button or picker that vanished.
const focusAfterAction = useRef(false);
const closePicker = useCallback(() => setPicker(null), []);
const pickerOpen = picker !== null;
useEffect(() => {
if (!picker) return;
if (!pickerOpen) return;
pickerRef.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
const onPointer = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node;
if (pickerRef.current?.contains(target) || triggerRef.current?.contains(target)) return;
closePicker();
};
// Scrolling the list itself must not close it; scrolling the page moves the trigger away.
const onScroll = (event: Event) => {
if (event.target instanceof Node && pickerRef.current?.contains(event.target)) return;
closePicker();
};
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closePicker();
triggerRef.current?.focus();
}
};
window.addEventListener('scroll', closePicker, true);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', closePicker);
document.addEventListener('mousedown', onPointer);
document.addEventListener('touchstart', onPointer);
document.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('scroll', closePicker, true);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', closePicker);
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('touchstart', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [picker, closePicker]);
}, [pickerOpen, closePicker]);
useEffect(() => {
if (busy || !focusAfterAction.current) return;
focusAfterAction.current = false;
const row = rowRef.current;
if (!row) return;
const target = row.querySelector<HTMLElement>('[role="alert"]')
?? row.querySelector<HTMLElement>('[role="status"]')
?? row.querySelector<HTMLElement>('button');
target?.focus();
}, [busy, status, notice, error]);
const onMenuKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
const items = Array.from(pickerRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []);
if (items.length === 0) return;
const at = items.indexOf(document.activeElement as HTMLElement);
let next: number | null = null;
if (event.key === 'ArrowDown') next = at < 0 ? 0 : (at + 1) % items.length;
else if (event.key === 'ArrowUp') next = at < 0 ? items.length - 1 : (at - 1 + items.length) % items.length;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = items.length - 1;
else if (event.key === 'Tab') closePicker();
if (next === null) return;
event.preventDefault();
items[next].focus();
};
const act = (action: () => Promise<void>) => {
focusAfterAction.current = true;
void action();
};
if (status === 'hidden' || status === 'loading') return null;
// A cancelled booking with nothing in the calendar has no action; only the
@@ -90,7 +132,7 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
const onAdd = () => {
if (calendars.length <= 1) {
void calendar.add(calendars[0]?.id);
act(() => calendar.add(calendars[0]?.id));
return;
}
if (picker) {
@@ -101,10 +143,11 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
};
const noticeText = notice === 'added' ? t('added') : notice === 'updated' ? t('updated') : notice === 'removed' ? t('removed') : null;
const blocked = notice === 'blocked';
const showView = status === 'in-calendar' || status === 'update';
return (
<div className="flex flex-wrap items-center gap-1.5">
<div ref={rowRef} className="flex flex-wrap items-center gap-1.5">
{status === 'add' && (
<>
<button
@@ -125,20 +168,22 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
ref={pickerRef}
role="menu"
aria-label={t('select_calendar')}
onKeyDown={onMenuKey}
className="fixed bg-background rounded-lg shadow-lg border border-border z-50 py-1 overflow-y-auto"
style={{ top: picker.top, left: picker.left, right: picker.right, maxHeight: picker.maxHeight, width: `min(${PICKER_WIDTH}px, calc(100vw - ${2 * EDGE}px))` }}
>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">{t('select_calendar')}</div>
<div role="presentation" className="px-3 py-1.5 text-xs font-medium text-muted-foreground">{t('select_calendar')}</div>
{calendars.map((cal) => (
<button
key={cal.id}
type="button"
role="menuitem"
tabIndex={-1}
onClick={() => {
closePicker();
void calendar.add(cal.id);
act(() => calendar.add(cal.id));
}}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 min-h-[36px]"
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted focus:bg-muted focus:outline-none flex items-center gap-2 min-h-[36px]"
>
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: sanitizeColor(cal.color) }} />
<span className="truncate text-foreground">{cal.name}</span>
@@ -151,7 +196,7 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
)}
{status === 'update' && (
<button type="button" onClick={() => void calendar.update()} disabled={busy} className={BUTTON}>
<button type="button" onClick={() => act(calendar.update)} disabled={busy} className={BUTTON}>
<CalendarCheck className="w-3.5 h-3.5" />
{t('update_in_calendar')}
</button>
@@ -160,7 +205,7 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
{status === 'remove' && (
<button
type="button"
onClick={() => void calendar.remove()}
onClick={() => act(calendar.remove)}
disabled={busy}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-destructive px-3 py-1.5 rounded-md border border-border hover:border-destructive/30 hover:bg-destructive/10 transition-colors min-h-[36px] disabled:opacity-50"
>
@@ -174,7 +219,11 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
)}
{noticeText && (
<span role="status" className="rounded-full bg-success/15 px-2 py-0.5 text-[0.6875rem] font-medium text-success">{noticeText}</span>
<span role="status" tabIndex={-1} className="rounded-full bg-success/15 px-2 py-0.5 text-[0.6875rem] font-medium text-success focus:outline-none">{noticeText}</span>
)}
{blocked && (
<span role="status" tabIndex={-1} className="rounded-full bg-muted px-2 py-0.5 text-[0.6875rem] font-medium text-muted-foreground focus:outline-none">{t('blocked')}</span>
)}
{showView && (
@@ -192,7 +241,7 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
{busy && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
{error && (
<p role="alert" className="basis-full text-xs text-destructive">{error}</p>
<p role="alert" tabIndex={-1} className="basis-full text-xs text-destructive focus:outline-none">{error}</p>
)}
</div>
);
@@ -41,8 +41,8 @@ export interface BookingCalendar {
busy: boolean;
/** What went wrong with the last action, in the user's language. */
error: string | null;
/** What the last action did, for a short confirmation. */
notice: 'added' | 'updated' | 'removed' | null;
/** What the last action did, for a short confirmation; `blocked` when a plugin stopped it. */
notice: 'added' | 'updated' | 'removed' | 'blocked' | null;
/** Add every live event, into `calendarId` or the default calendar. */
add: (calendarId?: string) => Promise<void>;
/** Add the missing live events, update the changed ones, remove the cancelled ones. */
@@ -52,6 +52,13 @@ export interface BookingCalendar {
viewInCalendar: () => void;
}
/**
* Uids whose events a card removed in this session. Another card for the
* same booking (a conversation showing two of its mails) may still hold the
* event from its own lookup; this tells it the event is gone.
*/
const removedUids = new Set<string>();
type Lookup = { key: string; failed: false; events: Record<string, CalendarEvent | null> } | { key: string; failed: true };
interface Plan {
@@ -174,6 +181,7 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e
const events: Record<string, CalendarEvent | null> = {};
uids.forEach((uid, i) => {
events[uid] = results[i].find((e) => e.uid === uid && !e.recurrenceId) ?? null;
if (events[uid]) removedUids.delete(uid);
});
setLookup({ key: uidKey, failed: false, events });
const hits = Object.values(events).filter((e): e is CalendarEvent => e !== null);
@@ -200,11 +208,14 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e
const known = lookup && lookup.key === uidKey && !lookup.failed ? lookup.events : null;
const existingOf = useCallback((uid: string): CalendarEvent | null => {
if (removedUids.has(uid)) return null;
const stored = known?.[uid];
if (!stored) return null;
// The store's copy of the same event is fresher after a change elsewhere.
return storeEvents.find((e) => e.id === stored.id) ?? stored;
}, [known, storeEvents]);
if (stored) return storeEvents.find((e) => e.id === stored.id) ?? stored;
// Added since the lookup, maybe by another card for the same booking.
return storeEvents.find((e) => e.uid === uid && !e.recurrenceId && !e.isShared
&& (!e.localAccountId || e.localAccountId === activeAccountId)) ?? null;
}, [known, storeEvents, activeAccountId]);
const plan = useMemo<Plan>(() => {
if (!active) return { status: 'hidden', live: [], stale: [] };
@@ -219,14 +230,17 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e
setLookup((prev) => (prev && !prev.failed && prev.key === uidKey ? { ...prev, events: { ...prev.events, [uid]: event } } : prev));
}, [uidKey]);
/** Run one action; on failure say why and look the events up again. */
/**
* Run one action. `work` resolves to false when a plugin stopped a step,
* which is said as such; on failure say why and look the events up again.
*/
const run = useCallback(async (done: NonNullable<BookingCalendar['notice']>, work: () => Promise<boolean>) => {
if (!client || busy) return;
setBusy(true);
setError(null);
setNotice(null);
try {
if (await work()) setNotice(done);
setNotice((await work()) ? done : 'blocked');
} catch (err) {
setError(describeError(err, t('action_failed')));
setRefresh((n) => n + 1);
@@ -239,9 +253,16 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e
if (!client) return false;
for (const { slot, existing } of plan.live) {
if (existing) continue;
// createEvent answers null both when a plugin stops it and when the
// server refuses (the uid exists after all, say); only the latter sets
// the store's error.
useCalendarStore.setState({ error: null });
const created = await createEvent(client, { ...slot.event, calendarIds: { [calendarId]: true } });
// null: refused (a plugin, or the server, e.g. the uid exists after all).
if (!created) throw new Error('create failed');
if (!created) {
if (useCalendarStore.getState().error) throw new Error('create failed');
return false;
}
removedUids.delete(slot.uid);
remember(slot.uid, created);
}
return true;
@@ -250,7 +271,16 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e
const removeStale = useCallback(async (): Promise<boolean> => {
if (!client) return false;
for (const { slot, existing } of plan.stale) {
if (!(await deleteEvent(client, existing.id))) return false;
// Marked before the store drops the event, so other cards that
// re-render on that change already see it gone.
removedUids.add(slot.uid);
let removed = false;
try {
removed = await deleteEvent(client, existing.id);
} finally {
if (!removed) removedUids.delete(slot.uid);
}
if (!removed) return false;
remember(slot.uid, null);
}
return true;
@@ -266,7 +296,9 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e
if (!client) return false;
for (const { slot, existing } of plan.live) {
if (!existing || !eventNeedsUpdate(existing, slot.event as Partial<CalendarEvent>)) continue;
const patch = bookingEventPatch(existing, slot.event as Partial<CalendarEvent>);
// Only what moved; title and notes the user changed stay.
const patch = bookingEventPatch(existing, slot.event as Partial<CalendarEvent>, senderDomain);
if (Object.keys(patch).length === 0) continue;
if (!(await updateEvent(client, existing.id, patch))) return false;
const updated = { ...existing, ...patch } as CalendarEvent;
remember(slot.uid, updated);
@@ -281,7 +313,7 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e
if (!(await createMissing(target))) return false;
}
return removeStale();
}), [run, client, plan.live, targets, updateEvent, remember, createMissing, removeStale]);
}), [run, client, plan.live, senderDomain, targets, updateEvent, remember, createMissing, removeStale]);
const remove = useCallback(() => run('removed', removeStale), [run, removeStale]);
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "تم تحديث التقويم",
"removed": "تمت الإزالة من تقويمك",
"action_failed": "تعذّر تغيير تقويمك. حاول مرة أخرى.",
"blocked": "أوقفت إضافةٌ هذا التغيير في التقويم.",
"title": {
"rental_car": "سيارة مستأجرة",
"rental_car_company": "سيارة مستأجرة: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "S’ha actualitzat el calendari",
"removed": "S’ha tret del vostre calendari",
"action_failed": "No s’ha pogut modificar el calendari. Torneu-ho a provar.",
"blocked": "Un connector ha impedit aquest canvi al calendari.",
"title": {
"rental_car": "Cotxe de lloguer",
"rental_car_company": "Cotxe de lloguer: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Kalendář aktualizován",
"removed": "Odebráno z vašeho kalendáře",
"action_failed": "Kalendář se nepodařilo změnit. Zkuste to znovu.",
"blocked": "Tuto změnu kalendáře zastavil plugin.",
"title": {
"rental_car": "Pronájem auta",
"rental_car_company": "Pronájem auta: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Kalender opdateret",
"removed": "Fjernet fra din kalender",
"action_failed": "Din kalender kunne ikke ændres. Prøv igen.",
"blocked": "Et plugin stoppede denne ændring i kalenderen.",
"title": {
"rental_car": "Lejebil",
"rental_car_company": "Lejebil: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Kalender aktualisiert",
"removed": "Aus Ihrem Kalender entfernt",
"action_failed": "Ihr Kalender konnte nicht geändert werden. Bitte versuchen Sie es erneut.",
"blocked": "Ein Plugin hat diese Kalenderänderung verhindert.",
"title": {
"rental_car": "Mietwagen",
"rental_car_company": "Mietwagen: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Calendar updated",
"removed": "Removed from your calendar",
"action_failed": "Couldn't change your calendar. Try again.",
"blocked": "A plugin stopped this calendar change.",
"title": {
"rental_car": "Rental car",
"rental_car_company": "Rental car: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Calendario actualizado",
"removed": "Quitado de tu calendario",
"action_failed": "No se pudo cambiar tu calendario. Inténtalo de nuevo.",
"blocked": "Un complemento ha impedido este cambio en el calendario.",
"title": {
"rental_car": "Coche de alquiler",
"rental_car_company": "Coche de alquiler: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Kalender uuendatud",
"removed": "Eemaldatud sinu kalendrist",
"action_failed": "Kalendrit ei õnnestunud muuta. Proovi uuesti.",
"blocked": "Pistikprogramm peatas selle kalendrimuudatuse.",
"title": {
"rental_car": "Rendiauto",
"rental_car_company": "Rendiauto: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "تقویم به‌روزرسانی شد",
"removed": "از تقویم شما حذف شد",
"action_failed": "تغییر تقویم انجام نشد. دوباره تلاش کنید.",
"blocked": "یک افزونه این تغییر تقویم را متوقف کرد.",
"title": {
"rental_car": "خودروی اجاره‌ای",
"rental_car_company": "خودروی اجاره‌ای: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Calendrier mis à jour",
"removed": "Retiré de votre calendrier",
"action_failed": "Impossible de modifier votre calendrier. Réessayez.",
"blocked": "Un plugin a empêché cette modification du calendrier.",
"title": {
"rental_car": "Voiture de location",
"rental_car_company": "Voiture de location : {company}"
+1
View File
@@ -5964,6 +5964,7 @@
"updated": "היומן עודכן",
"removed": "הוסר מהיומן שלך",
"action_failed": "לא ניתן היה לשנות את היומן. נסה שוב.",
"blocked": "תוסף עצר את השינוי הזה ביומן.",
"title": {
"rental_car": "רכב שכור",
"rental_car_company": "רכב שכור: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Naptár frissítve",
"removed": "Eltávolítva a naptáradból",
"action_failed": "Nem sikerült módosítani a naptárat. Próbáld újra.",
"blocked": "Egy bővítmény megakadályozta ezt a naptárváltozást.",
"title": {
"rental_car": "Bérautó",
"rental_car_company": "Bérautó: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Calendario aggiornato",
"removed": "Rimosso dal tuo calendario",
"action_failed": "Impossibile modificare il calendario. Riprova.",
"blocked": "Un plugin ha impedito questa modifica al calendario.",
"title": {
"rental_car": "Auto a noleggio",
"rental_car_company": "Auto a noleggio: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "カレンダーを更新しました",
"removed": "カレンダーから削除しました",
"action_failed": "カレンダーを変更できませんでした。もう一度お試しください。",
"blocked": "プラグインがこのカレンダーの変更を止めました。",
"title": {
"rental_car": "レンタカー",
"rental_car_company": "レンタカー: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "캘린더가 업데이트되었어요",
"removed": "캘린더에서 제거되었어요",
"action_failed": "캘린더를 변경할 수 없어요. 다시 시도해 주세요.",
"blocked": "플러그인이 이 캘린더 변경을 막았어요.",
"title": {
"rental_car": "렌터카",
"rental_car_company": "렌터카: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Kalendārs atjaunināts",
"removed": "Noņemts no jūsu kalendāra",
"action_failed": "Neizdevās mainīt kalendāru. Mēģiniet vēlreiz.",
"blocked": "Spraudnis apturēja šīs kalendāra izmaiņas.",
"title": {
"rental_car": "Nomas auto",
"rental_car_company": "Nomas auto: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Хуанли шинэчлэгдлээ",
"removed": "Таны хуанлиас хассан",
"action_failed": "Хуанлийг өөрчилж чадсангүй. Дахин оролдоно уу.",
"blocked": "Нэмэлт өргөтгөл энэ хуанлийн өөрчлөлтийг зогсоосон.",
"title": {
"rental_car": "Түрээсийн машин",
"rental_car_company": "Түрээсийн машин: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Kalenderen er oppdatert",
"removed": "Fjernet fra kalenderen din",
"action_failed": "Kunne ikke endre kalenderen. Prøv igjen.",
"blocked": "Et programtillegg stoppet denne endringen i kalenderen.",
"title": {
"rental_car": "Leiebil",
"rental_car_company": "Leiebil: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Agenda bijgewerkt",
"removed": "Uit je agenda verwijderd",
"action_failed": "Je agenda kon niet worden gewijzigd. Probeer het opnieuw.",
"blocked": "Een plug-in heeft deze agendawijziging tegengehouden.",
"title": {
"rental_car": "Huurauto",
"rental_car_company": "Huurauto: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Zaktualizowano kalendarz",
"removed": "Usunięto z Twojego kalendarza",
"action_failed": "Nie udało się zmienić kalendarza. Spróbuj ponownie.",
"blocked": "Wtyczka zablokowała tę zmianę w kalendarzu.",
"title": {
"rental_car": "Wynajem samochodu",
"rental_car_company": "Wynajem samochodu: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Calendário atualizado",
"removed": "Removido do seu calendário",
"action_failed": "Não foi possível alterar seu calendário. Tente novamente.",
"blocked": "Um plugin impediu esta alteração no calendário.",
"title": {
"rental_car": "Carro alugado",
"rental_car_company": "Carro alugado: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Calendar actualizat",
"removed": "Eliminat din calendarul dvs.",
"action_failed": "Calendarul nu a putut fi modificat. Încercați din nou.",
"blocked": "Un plugin a oprit această modificare în calendar.",
"title": {
"rental_car": "Mașină închiriată",
"rental_car_company": "Mașină închiriată: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Календарь обновлён",
"removed": "Удалено из вашего календаря",
"action_failed": "Не удалось изменить календарь. Попробуйте ещё раз.",
"blocked": "Плагин остановил это изменение календаря.",
"title": {
"rental_car": "Аренда автомобиля",
"rental_car_company": "Аренда автомобиля: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Kalendár bol aktualizovaný",
"removed": "Odstránené z vášho kalendára",
"action_failed": "Kalendár sa nepodarilo zmeniť. Skúste to znova.",
"blocked": "Túto zmenu kalendára zastavil doplnok.",
"title": {
"rental_car": "Prenájom auta",
"rental_car_company": "Prenájom auta: {company}"
+1
View File
@@ -5971,6 +5971,7 @@
"updated": "อัปเดตปฏิทินแล้ว",
"removed": "นำออกจากปฏิทินของคุณแล้ว",
"action_failed": "เปลี่ยนแปลงปฏิทินไม่สำเร็จ โปรดลองอีกครั้ง",
"blocked": "ปลั๊กอินหยุดการเปลี่ยนแปลงปฏิทินนี้",
"title": {
"rental_car": "รถเช่า",
"rental_car_company": "รถเช่า: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Takvim güncellendi",
"removed": "Takviminizden kaldırıldı",
"action_failed": "Takviminiz değiştirilemedi. Tekrar deneyin.",
"blocked": "Bir eklenti bu takvim değişikliğini durdurdu.",
"title": {
"rental_car": "Kiralık araç",
"rental_car_company": "Kiralık araç: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "Календар оновлено",
"removed": "Видалено з вашого календаря",
"action_failed": "Не вдалося змінити календар. Спробуйте ще раз.",
"blocked": "Плагін зупинив цю зміну календаря.",
"title": {
"rental_car": "Оренда авто",
"rental_car_company": "Оренда авто: {company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "行事曆已更新",
"removed": "已從您的行事曆移除",
"action_failed": "無法變更您的行事曆,請再試一次。",
"blocked": "某個外掛程式阻止了此行事曆變更。",
"title": {
"rental_car": "租車",
"rental_car_company": "租車:{company}"
+1
View File
@@ -5963,6 +5963,7 @@
"updated": "日历已更新",
"removed": "已从你的日历中移除",
"action_failed": "无法更改你的日历,请重试。",
"blocked": "某个插件阻止了此日历更改。",
"title": {
"rental_car": "租车",
"rental_car_company": "租车:{company}"