fix: keep events in their own time zone and ask for the time first in the event form

Saving an event wrote it in the user's zone, so a title change moved a
New York meeting to Europe/Berlin. The form now shows and saves the
times on the event's own clock, with a time zone field ("Time zone: New
York", or a "Time zone" link when it is yours), and the popover shows
"10:00–11:00 New York (16:00–17:00 your time)". The form asks for the
date and time right after the title and for the description last.
Participant answers read in plain words ("Not answered yet"), the
untitled preview is translated, and the quick editor grows to fit long
calendar names.

Changed assertions: rsvp-buttons.test expects the plain status key, and
event-modal-organizer.test expects an unchanged save to keep the event's
New York zone instead of rewriting it into the user's.
This commit is contained in:
Linus Rath
2026-11-04 09:35:22 +01:00
parent 9a0de93582
commit f607f48bf2
41 changed files with 1386 additions and 212 deletions
@@ -109,8 +109,8 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal organizer and a
// RFC 5546: when the organizer moves an event, the attendees are asked
// again; every other save keeps their answers. The editor shows and writes
// the event in the user's zone (here Tokyo, UTC+9), which is no move by
// itself.
// the event on its own clock (New York), whatever the user's zone (here
// Tokyo, UTC+9) is (pim-2).
describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal answers after a move on Stalwart %s', (version) => {
beforeEach(() => useSettingsStore.setState({ timeZone: 'Asia/Tokyo' }));
afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE }));
@@ -155,11 +155,11 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal answers after a
expect(statuses(data)).toEqual({ org: 'accepted', bob: 'needs-action', carol: 'needs-action' });
});
it('keeps the answers when only the time zone is rewritten', async () => {
it('keeps the answers, and the event in its own zone, when nothing changes', async () => {
const data = await save(own(), () => {});
// Written in the user's zone, at the same instant.
expect(data).toMatchObject({ start: '2026-10-12T21:00:00', timeZone: 'Asia/Tokyo', duration: 'PT1H' });
// Written in its own zone, as it was (pim-2); it was rewritten into the user's.
expect(data).toMatchObject({ start: '2026-10-12T08:00:00', timeZone: 'America/New_York', duration: 'PT1H' });
expect(statuses(data)).toEqual({ org: 'accepted', bob: 'accepted', carol: 'declined' });
});
@@ -0,0 +1,158 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EventModal } from '../event-modal';
import { useSettingsStore } from '@/stores/settings-store';
import { AUTO_TIME_ZONE, fromDisplayDate } from '@/lib/timezone';
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
import type { EventEditorState } from '@/lib/recurrence-scope-edit';
// pim-2: an edit kept the event's time but moved it into the user's zone
// (every save wrote timeZone: <user's zone>), and there was no way to put an
// event in another zone. pim-7: the form asked for the date and time last.
// pim-19, pim-22: plain words for the participant answers and "(No title)".
// Keys with their values, so a test can read what a line says.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
useLocale: () => 'en',
useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }),
}));
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
const RIGHTS = {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
};
const calendars = [
{ id: 'cal', name: 'Work', color: '#123456', isDefault: true, myRights: RIGHTS },
{ id: 'home', name: 'Home', color: '#654321', isDefault: false, myRights: RIGHTS },
] as unknown as Calendar[];
// 10:00-11:00 in New York on 5 November (UTC-5): 16:00-17:00 in Berlin.
const newYork = {
id: 'e1', uid: 'u1', '@type': 'Event', title: 'Call with New York office', description: '',
start: '2026-11-05T10:00:00', duration: 'PT1H', timeZone: 'America/New_York',
utcStart: '2026-11-05T15:00:00Z', utcEnd: '2026-11-05T16:00:00Z',
showWithoutTime: false, calendarIds: { cal: true }, participants: null, alerts: null, recurrenceRules: null,
} as unknown as CalendarEvent;
function editor(event: CalendarEvent | null, extra: Partial<Parameters<typeof EventModal>[0]> = {}) {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<EventModal event={event} startInEditMode calendars={calendars} onSave={onSave} onClose={vi.fn()} {...extra} />);
return onSave;
}
async function save(onSave: ReturnType<typeof vi.fn>): Promise<{ data: Partial<CalendarEvent>; state: EventEditorState }> {
fireEvent.click(screen.getByText('form.save'));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
return { data: onSave.mock.calls[0][0], state: onSave.mock.calls[0][2] };
}
const field = (label: string) => screen.getByLabelText(label) as HTMLInputElement;
beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' }));
afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE }));
describe('the event editor keeps the event in its own time zone (pim-2)', () => {
it('shows the times on the event clock and says which zone that is', () => {
editor(newYork);
expect(field('form.start_time').value).toBe('10:00');
expect(field('form.end_time').value).toBe('11:00');
expect(screen.getByText(/^time_zone_line:/).textContent).toBe('time_zone_line:{"zone":"New York"}');
expect(screen.getByText(/^your_time:/).textContent).toBe('your_time:{"times":"16:00–17:00"}');
});
it('saves a title change with the zone and start the event had', async () => {
const onSave = editor(newYork);
fireEvent.change(field('form.title'), { target: { value: 'Call with New York office, room 2' } });
const { data, state } = await save(onSave);
expect(data).toMatchObject({ title: 'Call with New York office, room 2', start: '2026-11-05T10:00:00', timeZone: 'America/New_York', duration: 'PT1H' });
// The editor state stays in display dates: the same instant.
expect(fromDisplayDate(state.when.start).toISOString()).toBe('2026-11-05T15:00:00.000Z');
expect(state.timeZoneChanged).toBeUndefined();
});
it('moves the event on its own clock', async () => {
const onSave = editor(newYork);
fireEvent.change(field('form.start_time'), { target: { value: '12:00' } });
const { data } = await save(onSave);
expect(data).toMatchObject({ start: '2026-11-05T12:00:00', timeZone: 'America/New_York', duration: 'PT1H' });
});
it('creates an event in another zone through the picker', async () => {
const onSave = editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0) });
fireEvent.change(field('form.title'), { target: { value: 'Tokyo partner sync' } });
// A new event is in the user's zone, behind a small link.
expect(screen.queryByText(/^time_zone_line/)).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'time_zone' }));
fireEvent.change(field('time_zone'), { target: { value: 'Asia/Tokyo' } });
const { data, state } = await save(onSave);
expect(data).toMatchObject({ start: '2026-11-12T10:00:00', timeZone: 'Asia/Tokyo', duration: 'PT1H' });
// 10:00 in Tokyo is 01:00 UTC.
expect(fromDisplayDate(state.when.start).toISOString()).toBe('2026-11-12T01:00:00.000Z');
});
it('tells the series save when the user picked another zone', async () => {
const onSave = editor(newYork);
fireEvent.click(screen.getByText(/^time_zone_line:/));
fireEvent.change(field('time_zone'), { target: { value: 'Asia/Tokyo' } });
const { data, state } = await save(onSave);
expect(data).toMatchObject({ start: '2026-11-05T10:00:00', timeZone: 'Asia/Tokyo' });
expect(state.timeZoneChanged).toBe(true);
});
it('leaves a floating event without a zone', async () => {
const onSave = editor({ ...newYork, timeZone: null, utcStart: undefined, utcEnd: undefined } as unknown as CalendarEvent);
expect(screen.queryByTestId('event-time-zone')).toBeNull();
fireEvent.change(field('form.title'), { target: { value: 'Floating' } });
const { data } = await save(onSave);
expect(data).toMatchObject({ start: '2026-11-05T10:00:00', timeZone: null });
});
it('shows the event clock beside the user one in the summary', () => {
render(<EventModal event={newYork} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
expect(screen.getByTestId('event-zone-times').textContent)
.toBe('zone_times:{"times":"10:00–11:00","zone":"New York","local":"16:00–17:00"}');
});
it('shows no zone line for an event on the user clock', () => {
render(<EventModal event={{ ...newYork, timeZone: 'Europe/Paris' } as CalendarEvent} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
expect(screen.queryByTestId('event-zone-times')).toBeNull();
});
});
describe('the event form order (pim-7)', () => {
it('asks for the time right after the title and for the description last', () => {
editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0) });
const order = ['form.title', 'form.all_day_event', 'form.start_date', 'form.end_time', 'recurrence.title', 'form.calendar_select',
'form.location', 'form.meeting_link', 'participants.title', 'alerts.title', 'form.description'];
const nodes = order.map((label) => screen.getAllByText(label).find((el) => el.closest('label')) ?? screen.getAllByText(label)[0]);
for (let i = 1; i < nodes.length; i++) {
const before = nodes[i - 1].compareDocumentPosition(nodes[i]) & Node.DOCUMENT_POSITION_FOLLOWING;
expect(before, `${order[i - 1]} before ${order[i]}`).toBeTruthy();
}
});
});
describe('plain words (pim-19, pim-22)', () => {
it('says "Not answered yet" for an attendee who has not answered', () => {
const meeting = {
...newYork,
participants: {
me: { '@type': 'Participant', name: 'Me', calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' },
ben: { '@type': 'Participant', name: 'Ben', calendarAddress: 'mailto:ben@example.com', roles: { attendee: true }, participationStatus: 'needs-action' },
},
} as unknown as CalendarEvent;
render(<EventModal event={meeting} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['me@example.com']} />);
expect(screen.getByText('status_needs_action')).toBeTruthy();
expect(screen.getByText('status_accepted')).toBeTruthy();
expect(screen.queryByText('participants.needs_action')).toBeNull();
});
it('labels the grid preview of an untitled event in the user language', () => {
const onPreviewChange = vi.fn();
editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0), onPreviewChange });
const last = onPreviewChange.mock.calls.filter(([p]) => p).pop()![0];
expect(last.title).toBe('events.no_title');
});
});
@@ -0,0 +1,106 @@
import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EventDetailPopover } from '../event-detail-popover';
import { QuickCreatePopover, computePosition } from '../quick-create-popover';
import { useSettingsStore } from '@/stores/settings-store';
import { AUTO_TIME_ZONE } from '@/lib/timezone';
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
// pim-2: the popover of an event in another zone said nothing about it.
// pim-19: "Needs action" for an attendee who hasn't answered.
// pim-21: the quick editor cut a long calendar name off.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
useLocale: () => 'en',
useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }),
}));
const calendar = { id: 'cal', name: 'Work', myRights: { mayWriteAll: true, mayWriteOwn: true } } as unknown as Calendar;
function event(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'e1', calendarIds: { cal: true }, title: 'Call with New York office', start: '2026-11-05T10:00:00', duration: 'PT1H',
timeZone: 'America/New_York', utcStart: '2026-11-05T15:00:00Z', utcEnd: '2026-11-05T16:00:00Z',
showWithoutTime: false, participants: null, alerts: null, useDefaultAlerts: false,
...overrides,
} as unknown as CalendarEvent;
}
function popover(e: CalendarEvent) {
render(
<EventDetailPopover event={e} calendar={calendar} anchorRect={new DOMRect(100, 100, 50, 20)}
onEdit={vi.fn()} onDelete={vi.fn()} onDuplicate={vi.fn()} onClose={vi.fn()} onSaveNote={vi.fn()}
currentUserEmails={['me@example.com']} />,
);
}
beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' }));
afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE }));
describe('event popover', () => {
it('shows the times on the event clock with the user times after them (pim-2)', () => {
popover(event());
expect(screen.getByTestId('event-zone-times').textContent)
.toBe('zone_times:{"times":"10:00–11:00","zone":"New York","local":"16:00–17:00"}');
});
it('names the weekday on the event clock when its day there is another', () => {
// 08:00 on Friday in Tokyo is 00:00 on Friday in Berlin; in New York
// (the user here) it is still Thursday.
useSettingsStore.setState({ timeZone: 'America/New_York' });
popover(event({ timeZone: 'Asia/Tokyo', start: '2026-11-06T08:00:00', utcStart: '2026-11-05T23:00:00Z', utcEnd: '2026-11-06T00:00:00Z' }));
expect(screen.getByTestId('event-zone-times').textContent)
.toBe('zone_times:{"times":"Fri 08:00–09:00","zone":"Tokyo","local":"18:00–19:00"}');
});
it('shows the plain times for an event on the user clock, an all-day and a floating one', () => {
popover(event({ timeZone: 'Europe/Berlin', start: '2026-11-05T16:00:00' }));
popover(event({ id: 'e2', showWithoutTime: true, timeZone: null, start: '2026-11-05T00:00:00', duration: 'P1D' }));
popover(event({ id: 'e3', timeZone: null, utcStart: undefined, utcEnd: undefined }));
expect(screen.queryByTestId('event-zone-times')).toBeNull();
});
it('says an attendee has not answered yet in plain words (pim-19)', () => {
popover(event({
participants: {
me: { '@type': 'Participant', name: 'Me', calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' },
ben: { '@type': 'Participant', name: 'Ben', calendarAddress: 'mailto:ben@example.com', roles: { attendee: true }, participationStatus: 'needs-action' },
eve: { '@type': 'Participant', name: 'Eve', calendarAddress: 'mailto:eve@example.com', roles: { attendee: true }, participationStatus: 'tentative' },
},
} as unknown as Partial<CalendarEvent>));
expect(screen.getByText('status_needs_action')).toBeTruthy();
expect(screen.getByText('status_tentative')).toBeTruthy();
expect(screen.queryByText('participants.needs_action')).toBeNull();
});
});
describe('quick create (pim-21)', () => {
const long = 'Stalwart Calendar (dso-ux-pim@example.org)';
const calendars = [
{ id: 'a', name: long, color: '#3b82f6' },
{ id: 'b', name: 'Work', color: '#22c55e' },
] as unknown as Calendar[];
it('grows to fit the calendar names and names the calendar on hover', () => {
render(
<QuickCreatePopover draft={{ start: new Date(2026, 10, 15), end: new Date(2026, 10, 15), title: '', calendarId: 'a', allDay: true }}
calendars={calendars} onChange={vi.fn()} onSave={vi.fn()} onMoreOptions={vi.fn()} onClose={vi.fn()} />,
);
const dialog = screen.getByRole('dialog');
// As wide as its content (the select is as wide as its longest name), from 340 px on.
expect(dialog.style.width).toBe('max-content');
expect(dialog.style.minWidth).toBe('340px');
expect(dialog.style.maxWidth).toContain('480px');
expect(screen.getByRole('combobox').getAttribute('title')).toBe(long);
});
it('places a wider popover by its own width', () => {
Object.defineProperty(window, 'innerWidth', { value: 1366, configurable: true });
Object.defineProperty(window, 'innerHeight', { value: 768, configurable: true });
// Beside an anchor at the right: 460 px don't fit on the right side, so it goes left of it.
const anchor = new DOMRect(1000, 300, 150, 20);
expect(computePosition(anchor, 200, 340)).toEqual({ top: 300, left: 652 });
expect(computePosition(anchor, 200, 460)).toEqual({ top: 300, left: 532 });
});
});
@@ -30,6 +30,7 @@ describe('participation status colours', () => {
it('colours a participant\'s answer the same way', () => {
render(<ParticipantStatusBadge status="accepted" />);
expect(screen.getByText('participants.accepted').className).toContain('text-green-700');
// In plain words (pim-19): ux_pim_event.status_accepted.
expect(screen.getByText('status_accepted').className).toContain('text-green-700');
});
});
@@ -15,6 +15,7 @@ import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { eventZonedTimes, zonedTimesText } from "@/lib/event-time-zone";
import { buildRecurrenceSummary } from "./recurrence-editor";
import { formatEventAlertsLabel } from "./alert-list-editor";
import {
@@ -147,6 +148,7 @@ export function EventDetailPopover({
const t = useTranslations("calendar");
const paint = useCvdColor();
const tCommon = useTranslations("common");
const tEvent = useTranslations("ux_pim_event");
const locale = useLocale();
const popoverRef = useRef<HTMLDivElement>(null);
const noteInputRef = useRef<HTMLTextAreaElement>(null);
@@ -166,6 +168,11 @@ export function EventDetailPopover({
const endDate = getEventEndDate(event);
const displayEndDate = getEventDisplayEndDate(event);
const isMultiDay = !isSameDay(startDate, displayEndDate);
// An event on another zone's clock shows its own times too (pim-2).
const zoned = eventZonedTimes(event, startDate, endDate);
const zonedText = zoned
? zonedTimesText(zoned, startDate, endDate, timeFormat === "12h" ? "h:mm a" : "HH:mm", locale)
: null;
const locationName = useMemo(() => primaryLocationName(event) ?? null, [event]);
// Re-computes the location link once the config is known (#903).
@@ -499,6 +506,11 @@ export function EventDetailPopover({
<div className="text-muted-foreground text-xs">
({formatDurationDisplay(durationMinutes)})
</div>
{zoned && zonedText && (
<div className="text-muted-foreground text-xs" data-testid="event-zone-times">
{tEvent("zone_only", { times: zonedText.times, zone: zoned.zone })}
</div>
)}
</>
)
) : (
@@ -508,6 +520,10 @@ export function EventDetailPopover({
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
) : zoned && zonedText ? (
<div className="text-muted-foreground" data-testid="event-zone-times">
{tEvent("zone_times", { times: zonedText.times, zone: zoned.zone, local: zonedText.local })}
</div>
) : (
<div className="text-muted-foreground">
<bdi dir="ltr">{formatTime(startDate)} – {formatTime(endDate)}</bdi>
+282 -195
View File
@@ -18,7 +18,8 @@ import { alertRowToOffset, describeAlert, getCalendarDefaultAlerts, newAlertRow,
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { displayNow, fromDisplayDate, fromWallClock, getEffectiveTimeZone, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone";
import { displayNow, fromDisplayDate, fromWallClock, getEffectiveTimeZone, listTimeZones, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone";
import { displayToZoneWall, eventFormTimeZone, eventZonedTimes, timeZoneCity, zonedTimesText, zoneWallToDisplay } from "@/lib/event-time-zone";
import { createRecurrenceRule, sameRecurrenceRules } from "@/lib/recurrence-rule";
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
import { ParticipantAvailability } from "./participant-availability";
@@ -278,6 +279,7 @@ export function EventModal({
const labelIdBase = useId();
const labelFor = (key: string) => `${labelIdBase}-${key}`;
const tCommon = useTranslations("common");
const tEvent = useTranslations("ux_pim_event");
const locale = useLocale();
const timeFormat = useSettingsStore((s) => s.timeFormat);
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
@@ -381,11 +383,37 @@ export function EventModal({
const [virtualLocation, setVirtualLocation] = useState(
event?.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || "" : ""
);
const [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
// The zone the date and time fields are in, and a save writes: the
// event's own, the user's for a new event, none for a floating one. An
// edit used to save every event in the user's zone (pim-2).
const userTimeZone = getEffectiveTimeZone();
const [timeZone, setTimeZone] = useState<string | null>(() => eventFormTimeZone(event, userTimeZone));
const [showZonePicker, setShowZonePicker] = useState(false);
const [initialStartWall] = useState(() => displayToZoneWall(getInitialStart(), timeZone));
const [initialEndWall] = useState(() => displayToZoneWall(getInitialEnd(), timeZone));
const [startDate, setStartDate] = useState(formatDateInput(initialStartWall));
const [startTime, setStartTime] = useState(formatTimeInput(initialStartWall));
const [endDate, setEndDate] = useState(formatDateInput(initialEndWall));
const [endTime, setEndTime] = useState(formatTimeInput(initialEndWall));
const [allDay, setAllDay] = useState(event?.showWithoutTime || defaultAllDay || false);
// The zone the fields are on: none for an all-day event.
const fieldZone = allDay ? null : timeZone;
const zoneOptions = useMemo(() => {
if (!showZonePicker) return [];
const zones = listTimeZones();
return timeZone && !zones.includes(timeZone) ? [timeZone, ...zones] : zones;
}, [showZonePicker, timeZone]);
// The form's times on the user's clock, beside another zone's.
const formYourTimes = useMemo(() => {
if (!fieldZone || fieldZone === userTimeZone) return null;
const from = zoneWallToDisplay(new Date(`${startDate}T${startTime || "00:00"}:00`), fieldZone);
const to = zoneWallToDisplay(new Date(`${endDate}T${endTime || "00:00"}:00`), fieldZone);
if (isNaN(from.getTime()) || isNaN(to.getTime())) return null;
const time = (d: Date) => format(d, timeDisplayFmt);
const onOtherDay = !isSameDay(from, new Date(`${startDate}T00:00:00`));
const day = onOtherDay ? `${new Intl.DateTimeFormat(locale, { weekday: "short" }).format(from)} ` : "";
return `${day}${time(from)}–${time(to)}`;
}, [fieldZone, userTimeZone, startDate, startTime, endDate, endTime, timeDisplayFmt, locale]);
// Editing the start shifts the end with it, preserving the event's current
// length (end - start) - so moving the start never leaves the end before it
@@ -524,9 +552,9 @@ export function EventModal({
// What the form holds against what it opened with: a form with changes
// asks before X, Cancel or Escape drop them (R5).
const formState = useMemo(() => ({
title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay,
title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, timeZone,
calendarId, recurrence, customRule, useDefaultAlerts, alertRows, preservedAlerts, attendees, sendInvitations,
}), [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay,
}), [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, timeZone,
calendarId, recurrence, customRule, useDefaultAlerts, alertRows, preservedAlerts, attendees, sendInvitations]);
const [initialForm] = useState(formState);
const isDirty = mode === "edit" && JSON.stringify(formState) !== JSON.stringify(initialForm);
@@ -537,6 +565,7 @@ export function EventModal({
const f = initialForm;
setTitle(f.title); setDescription(f.description); setLocation(f.location); setVirtualLocation(f.virtualLocation);
setStartDate(f.startDate); setStartTime(f.startTime); setEndDate(f.endDate); setEndTime(f.endTime); setAllDay(f.allDay);
setTimeZone(f.timeZone); setShowZonePicker(false);
setCalendarId(f.calendarId); setRecurrence(f.recurrence); setCustomRule(f.customRule); setShowRecurrenceEditor(false);
setUseDefaultAlerts(f.useDefaultAlerts); setAlertRows(f.alertRows); setPreservedAlerts(f.preservedAlerts); setRefreshDefaults(false);
setAttendees(f.attendees); setSendInvitations(f.sendInvitations);
@@ -565,10 +594,10 @@ export function EventModal({
const backToView = useCallback(() => whenDiscarded(() => { resetForm(); setMode("view"); }), [whenDiscarded, resetForm]);
// The event window the attendees' free/busy is checked against. The
// fields are a wall clock in the effective zone, as the save path labels
// fields are a wall clock in the form's zone, as the save path labels
// them, not in the browser's; an all-day event spans its whole days.
const availabilityWindow = useMemo(() => {
const zone = getEffectiveTimeZone();
const zone = fieldZone ?? getEffectiveTimeZone();
const instant = (value: string) => {
const wall = parseLocalDateTime(value);
if (!wall) return null;
@@ -581,7 +610,7 @@ export function EventModal({
start: startDate ? instant(startStr) : null,
end: endDate ? instant(endStr) : null,
};
}, [startDate, startTime, endDate, endTime, allDay]);
}, [startDate, startTime, endDate, endTime, allDay, fieldZone]);
// An existing event's own time, as saved: Suggest times doesn't count it
// as busy (the attendees who have it in their calendar show it so).
@@ -590,16 +619,16 @@ export function EventModal({
: null), [event]);
// A suggested slot moves the event there; the fields are a wall clock in
// the effective zone, so the instants are turned back into one.
// the form's zone, so the instants are turned back into one.
const handlePickSlot = useCallback((slot: { start: Date; end: Date }) => {
const zone = getEffectiveTimeZone();
const zone = fieldZone ?? getEffectiveTimeZone();
const from = toZonedDisplayDate(slot.start, zone);
const to = toZonedDisplayDate(slot.end, zone);
setStartDate(formatDateInput(from));
setStartTime(formatTimeInput(from));
setEndDate(formatDateInput(to));
setEndTime(formatTimeInput(to));
}, []);
}, [fieldZone]);
// Plugin transform: collect conflict warnings for the current event form.
// Re-runs (debounced) whenever fields that affect scheduling change.
@@ -683,7 +712,7 @@ export function EventModal({
description,
start: startStr,
duration,
timeZone: getEffectiveTimeZone(),
timeZone: fieldZone,
utcStart: null,
utcEnd: null,
isAllDay: allDay,
@@ -696,7 +725,7 @@ export function EventModal({
isRecurring: recurrence !== "none",
keywords: base?.keywords ?? [],
};
}, [event, calendarId, title, description, startDate, startTime, endDate, endTime, allDay, location, attendees, recurrence]);
}, [event, calendarId, title, description, startDate, startTime, endDate, endTime, allDay, fieldZone, location, attendees, recurrence]);
const [settledFormEvent, setSettledFormEvent] = useState(formEventView);
useEffect(() => {
const timer = setTimeout(() => setSettledFormEvent(formEventView), 500);
@@ -704,17 +733,20 @@ export function EventModal({
}, [formEventView]);
const eventFormProps = useMemo(() => ({ event: settledFormEvent }), [settledFormEvent]);
// Report live preview to parent for grid outline
// Report live preview to parent for grid outline. The label as a string:
// a new `t` on every render would report it again on each.
const noTitle = t("events.no_title");
useEffect(() => {
if (!onPreviewChange || isEdit) return;
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
const s = new Date(startStr);
const e = new Date(endStr);
// Where the grid shows it: the fields are on the form's clock.
const s = zoneWallToDisplay(new Date(startStr), fieldZone);
const e = zoneWallToDisplay(new Date(endStr), fieldZone);
if (isNaN(s.getTime()) || isNaN(e.getTime())) return;
onPreviewChange({ start: s, end: e, title: title || "(No title)", allDay, calendarId });
onPreviewChange({ start: s, end: e, title: title || noTitle, allDay, calendarId });
return () => onPreviewChange(null);
}, [startDate, startTime, endDate, endTime, allDay, title, calendarId, isEdit, onPreviewChange]);
}, [startDate, startTime, endDate, endTime, allDay, fieldZone, title, noTitle, calendarId, isEdit, onPreviewChange]);
const statusCounts = useMemo(() => {
if (!event?.participants) return null;
@@ -772,9 +804,15 @@ export function EventModal({
duration = buildDuration(start, end);
}
// The form's wall-clock fields are in the user's effective zone (the
// grid and prefill are display dates), so label them with that zone.
const timeZone = getEffectiveTimeZone();
// The fields are the wall clock in the form's zone: the event's own, so
// an edit keeps it, or the one the user picked. The editor state below
// is in display dates, as the rest of the calendar.
const when = {
allDay,
start: allDay ? start : zoneWallToDisplay(start, timeZone),
end: allDay ? end : zoneWallToDisplay(end, timeZone),
};
const timeZoneChanged = !!event && !allDay && timeZone !== initialForm.timeZone;
const trimmedDescription = description.trim();
@@ -875,7 +913,7 @@ export function EventModal({
userEmail: currentUserEmails[0],
userEmails: currentUserEmails,
resolveName: resolveContactName,
rescheduled: !!event && (changesWhen(event, { allDay, start, end }) || (ruleChanged && !event.recurrenceId)),
rescheduled: !!event && (changesWhen(event, when) || timeZoneChanged || (ruleChanged && !event.recurrenceId)),
}));
}
@@ -937,14 +975,15 @@ export function EventModal({
await onSave(data, shouldSendScheduling, {
initialAttendees,
attendees: effectiveAttendees,
when: { allDay, start, end },
when,
ruleChanged,
...(timeZoneChanged ? { timeZoneChanged } : {}),
});
} finally {
savingRef.current = false;
setIsSaving(false);
}
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, eventDescription, onSave, isSaving, t]);
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, timeZone, initialForm, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, eventDescription, onSave, isSaving, t]);
// The user's own answer, and the owner's in a calendar shared with them
// (lib/calendar-delegate.ts).
@@ -1030,6 +1069,9 @@ export function EventModal({
const alertLabel = formatEventAlertsLabel(event, calendars, t);
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
const color = paint(getEventColor(event, eventCalendar));
// An event on another zone's clock shows its own times too (pim-2).
const zoned = eventZonedTimes(event, startD, endD);
const zonedText = zoned ? zonedTimesText(zoned, startD, endD, timeDisplayFmt, locale) : null;
return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? mobileRootClass : "flex flex-col h-full bg-background"}>
@@ -1108,6 +1150,11 @@ export function EventModal({
<div className="text-muted-foreground text-xs">
({formatDurationDisplay(durMin)})
</div>
{zoned && zonedText && (
<div className="text-muted-foreground text-xs" data-testid="event-zone-times">
{tEvent("zone_only", { times: zonedText.times, zone: zoned.zone })}
</div>
)}
</>
);
}
@@ -1118,6 +1165,10 @@ export function EventModal({
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
) : zoned && zonedText ? (
<div className="text-muted-foreground" data-testid="event-zone-times">
{tEvent("zone_times", { times: zonedText.times, zone: zoned.zone, local: zonedText.local })}
</div>
) : (
<div className="text-muted-foreground">
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
@@ -1270,19 +1321,188 @@ export function EventModal({
/>
</div>
<div>
<label htmlFor={labelFor("description")} className="text-sm font-medium mb-1 block">{t("form.description")}</label>
<textarea
id={labelFor("description")}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("form.description")}
rows={3}
maxLength={10000}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm resize-none focus:outline-hidden"
<div className="flex items-center gap-2">
<input
type="checkbox"
id="allDay"
checked={allDay}
onChange={(e) => setAllDay(e.target.checked)}
className="rounded border-input"
/>
<label htmlFor="allDay" className="text-sm">{t("form.all_day_event")}</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label htmlFor={labelFor("start_date")} className="text-sm font-medium mb-1 block">{t("form.start_date")}</label>
<input
id={labelFor("start_date")}
type="date"
value={startDate}
onChange={(e) => handleStartDateChange(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
/>
</div>
{!allDay && (
<div>
<label htmlFor={labelFor("start_time")} className="text-sm font-medium mb-1 block">{t("form.start_time")}</label>
<input
id={labelFor("start_time")}
type="time"
value={startTime}
onChange={(e) => handleStartTimeChange(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
/>
</div>
)}
<div>
<label htmlFor={labelFor("end_date")} className="text-sm font-medium mb-1 block">{t("form.end_date")}</label>
<input
id={labelFor("end_date")}
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
/>
</div>
{!allDay && (
<div>
<label htmlFor={labelFor("end_time")} className="text-sm font-medium mb-1 block">{t("form.end_time")}</label>
<input
id={labelFor("end_time")}
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
/>
</div>
)}
</div>
{/* The zone the times are on (pim-2): a line when it isn't the
user's, a small link when it is; both open the picker. A
floating event stays without one. */}
{fieldZone && (
<div data-testid="event-time-zone">
{showZonePicker ? (
<>
<label htmlFor={labelFor("time_zone")} className="text-sm font-medium mb-1 block">{tEvent("time_zone")}</label>
<select
id={labelFor("time_zone")}
value={fieldZone}
onChange={(e) => setTimeZone(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
>
{zoneOptions.map((zone) => (
<option key={zone} value={zone}>{zone.replace(/_/g, " ")}</option>
))}
</select>
</>
) : fieldZone !== userTimeZone ? (
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
<button type="button" onClick={() => setShowZonePicker(true)} className="text-foreground hover:underline">
{tEvent("time_zone_line", { zone: timeZoneCity(fieldZone) })}
</button>
{formYourTimes && (
<span className="text-xs text-muted-foreground">{tEvent("your_time", { times: formYourTimes })}</span>
)}
</div>
) : (
<button type="button" onClick={() => setShowZonePicker(true)} className="text-xs text-primary hover:underline">
{tEvent("time_zone")}
</button>
)}
</div>
)}
{pluginConflictWarnings.length > 0 && (
<div className="space-y-1.5">
{pluginConflictWarnings.map(w => (
<div
key={w.key}
className={
w.severity === 'error'
? 'text-sm rounded-md border border-destructive/50 bg-destructive/10 text-destructive px-3 py-2'
: w.severity === 'info'
? 'text-sm rounded-md border border-border bg-muted/40 text-muted-foreground px-3 py-2'
: 'text-sm rounded-md border border-warning/50 bg-warning/10 text-amber-600 dark:text-amber-400 px-3 py-2'
}
title={w.message}
>
{w.message}
</div>
))}
</div>
)}
<div>
<label htmlFor={labelFor("title-1")} className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<div className="flex items-center gap-2">
<select
id={labelFor("title-1")}
value={recurrence}
onChange={(e) => {
const value = e.target.value as RecurrenceOption;
if (value === "custom") {
recurrenceBeforeCustomRef.current = recurrence;
setRecurrence("custom");
setShowRecurrenceEditor(true);
} else {
setRecurrence(value);
setShowRecurrenceEditor(false);
}
}}
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
>
<option value="none">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
<option value="custom">{customRuleSummary || t("recurrence.custom")}</option>
</select>
{recurrence === "custom" && !showRecurrenceEditor && (
<button
type="button"
onClick={() => setShowRecurrenceEditor(true)}
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("recurrence.edit_custom")}
>
<Pencil className="w-4 h-4" />
</button>
)}
</div>
{showRecurrenceEditor && (
<RecurrenceEditor
rule={customRule}
eventStart={(() => {
const d = new Date(`${startDate}T${allDay ? "00:00" : (startTime || "00:00")}:00`);
return isNaN(d.getTime()) ? displayNow() : d;
})()}
onSave={handleRecurrenceEditorSave}
onCancel={handleRecurrenceEditorCancel}
/>
)}
</div>
{selectableCalendars.length > 1 && (
<div>
<label htmlFor={labelFor("calendar_select")} className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
<select
id={labelFor("calendar_select")}
value={calendarId}
onChange={(e) => setCalendarId(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
>
{selectableCalendars.map((cal) => (
<option key={cal.id} value={cal.id}>
{cal.name}
</option>
))}
</select>
</div>
)}
<div>
<label htmlFor={labelFor("location")} className="text-sm font-medium mb-1 block">{t("form.location")}</label>
<Input
@@ -1380,151 +1600,22 @@ export function EventModal({
)}
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="allDay"
checked={allDay}
onChange={(e) => setAllDay(e.target.checked)}
className="rounded border-input"
/>
<label htmlFor="allDay" className="text-sm">{t("form.all_day_event")}</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label htmlFor={labelFor("start_date")} className="text-sm font-medium mb-1 block">{t("form.start_date")}</label>
<input
id={labelFor("start_date")}
type="date"
value={startDate}
onChange={(e) => handleStartDateChange(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
/>
</div>
{!allDay && (
<div>
<label htmlFor={labelFor("start_time")} className="text-sm font-medium mb-1 block">{t("form.start_time")}</label>
<input
id={labelFor("start_time")}
type="time"
value={startTime}
onChange={(e) => handleStartTimeChange(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
/>
</div>
)}
<div>
<label htmlFor={labelFor("end_date")} className="text-sm font-medium mb-1 block">{t("form.end_date")}</label>
<input
id={labelFor("end_date")}
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
/>
</div>
{!allDay && (
<div>
<label htmlFor={labelFor("end_time")} className="text-sm font-medium mb-1 block">{t("form.end_time")}</label>
<input
id={labelFor("end_time")}
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
/>
</div>
)}
</div>
{pluginConflictWarnings.length > 0 && (
<div className="space-y-1.5">
{pluginConflictWarnings.map(w => (
<div
key={w.key}
className={
w.severity === 'error'
? 'text-sm rounded-md border border-destructive/50 bg-destructive/10 text-destructive px-3 py-2'
: w.severity === 'info'
? 'text-sm rounded-md border border-border bg-muted/40 text-muted-foreground px-3 py-2'
: 'text-sm rounded-md border border-warning/50 bg-warning/10 text-amber-600 dark:text-amber-400 px-3 py-2'
}
title={w.message}
>
{w.message}
</div>
))}
</div>
)}
{selectableCalendars.length > 1 && (
<div>
<label htmlFor={labelFor("calendar_select")} className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
<select
id={labelFor("calendar_select")}
value={calendarId}
onChange={(e) => setCalendarId(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
>
{selectableCalendars.map((cal) => (
<option key={cal.id} value={cal.id}>
{cal.name}
</option>
))}
</select>
</div>
)}
<div>
<label htmlFor={labelFor("title-1")} className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
{/* Only the organizer's messages: the editor never changes the
user's own answer, which the answer buttons send. */}
{attendees.length > 0 && userIsOrganizer && (
<div className="flex items-center gap-2">
<select
id={labelFor("title-1")}
value={recurrence}
onChange={(e) => {
const value = e.target.value as RecurrenceOption;
if (value === "custom") {
recurrenceBeforeCustomRef.current = recurrence;
setRecurrence("custom");
setShowRecurrenceEditor(true);
} else {
setRecurrence(value);
setShowRecurrenceEditor(false);
}
}}
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
>
<option value="none">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
<option value="custom">{customRuleSummary || t("recurrence.custom")}</option>
</select>
{recurrence === "custom" && !showRecurrenceEditor && (
<button
type="button"
onClick={() => setShowRecurrenceEditor(true)}
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("recurrence.edit_custom")}
>
<Pencil className="w-4 h-4" />
</button>
)}
</div>
{showRecurrenceEditor && (
<RecurrenceEditor
rule={customRule}
eventStart={(() => {
const d = new Date(`${startDate}T${allDay ? "00:00" : (startTime || "00:00")}:00`);
return isNaN(d.getTime()) ? displayNow() : d;
})()}
onSave={handleRecurrenceEditorSave}
onCancel={handleRecurrenceEditorCancel}
<input
type="checkbox"
id="sendInvitations"
checked={sendInvitations}
onChange={(e) => setSendInvitations(e.target.checked)}
className="rounded border-input"
/>
)}
</div>
<label htmlFor="sendInvitations" className="text-sm">
{t("participants.send_invitations")}
</label>
</div>
)}
<div>
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
@@ -1558,22 +1649,18 @@ export function EventModal({
)}
</div>
{/* Only the organizer's messages: the editor never changes the
user's own answer, which the answer buttons send. */}
{attendees.length > 0 && userIsOrganizer && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="sendInvitations"
checked={sendInvitations}
onChange={(e) => setSendInvitations(e.target.checked)}
className="rounded border-input"
/>
<label htmlFor="sendInvitations" className="text-sm">
{t("participants.send_invitations")}
</label>
</div>
)}
<div>
<label htmlFor={labelFor("description")} className="text-sm font-medium mb-1 block">{t("form.description")}</label>
<textarea
id={labelFor("description")}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("form.description")}
rows={3}
maxLength={10000}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm resize-none focus:outline-hidden"
/>
</div>
<PluginSlot name="calendar-event-form" className="w-full" extraProps={eventFormProps} />
</div>
+13 -7
View File
@@ -33,6 +33,9 @@ interface QuickCreatePopoverProps {
}
const WIDTH = 340;
// Wider for a calendar name that needs it ("Stalwart Calendar
// (someone@example.org)" was cut off at 340 px, pim-21).
const MAX_WIDTH = 480;
const GAP = 8;
const MARGIN = 12;
@@ -40,14 +43,14 @@ const MARGIN = 12;
// popover sits beside it, or under it when neither side has room.
const PLACEHOLDER_SELECTOR = "[data-pending-preview]";
function computePosition(anchor: DOMRect | null, height: number): { top: number; left: number } {
export function computePosition(anchor: DOMRect | null, height: number, width = WIDTH): { top: number; left: number } {
const vw = window.innerWidth;
const vh = window.innerHeight;
if (!anchor) return { top: Math.max(MARGIN, (vh - height) / 2), left: Math.max(MARGIN, (vw - WIDTH) / 2) };
if (!anchor) return { top: Math.max(MARGIN, (vh - height) / 2), left: Math.max(MARGIN, (vw - width) / 2) };
const clampTop = (top: number) => Math.min(Math.max(MARGIN, top), vh - height - MARGIN);
const clampLeft = (left: number) => Math.min(Math.max(MARGIN, left), vw - WIDTH - MARGIN);
if (anchor.right + GAP + WIDTH + MARGIN <= vw) return { top: clampTop(anchor.top), left: anchor.right + GAP };
if (anchor.left - GAP - WIDTH >= MARGIN) return { top: clampTop(anchor.top), left: anchor.left - GAP - WIDTH };
const clampLeft = (left: number) => Math.min(Math.max(MARGIN, left), vw - width - MARGIN);
if (anchor.right + GAP + width + MARGIN <= vw) return { top: clampTop(anchor.top), left: anchor.right + GAP };
if (anchor.left - GAP - width >= MARGIN) return { top: clampTop(anchor.top), left: anchor.left - GAP - width };
if (anchor.bottom + GAP + height + MARGIN <= vh) return { top: anchor.bottom + GAP, left: clampLeft(anchor.left) };
return { top: Math.max(MARGIN, anchor.top - GAP - height), left: clampLeft(anchor.left) };
}
@@ -83,7 +86,7 @@ export function QuickCreatePopover({
const node = ref.current;
if (!node) return;
const anchor = document.querySelector(PLACEHOLDER_SELECTOR)?.getBoundingClientRect() ?? null;
setPosition(computePosition(anchor, node.offsetHeight));
setPosition(computePosition(anchor, node.offsetHeight, node.offsetWidth));
};
place();
const frame = requestAnimationFrame(place);
@@ -158,7 +161,9 @@ export function QuickCreatePopover({
aria-label={t("quick_create.aria_label")}
className="fixed z-[60] bg-background border border-border rounded-lg shadow-xl"
style={{
width: WIDTH,
width: "max-content",
minWidth: WIDTH,
maxWidth: `min(${MAX_WIDTH}px, calc(100vw - ${2 * MARGIN}px))`,
top: position?.top ?? -9999,
left: position?.left ?? -9999,
visibility: position ? "visible" : "hidden",
@@ -198,6 +203,7 @@ export function QuickCreatePopover({
value={draft.calendarId}
onChange={(e) => onChange({ ...draft, calendarId: e.target.value })}
aria-label={t("form.calendar_select")}
title={calendars.find((c) => c.id === draft.calendarId)?.name}
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-hidden"
>
{calendars.map((c) => (
+14 -2
View File
@@ -28,12 +28,24 @@ export const STATUS_LABEL: Record<string, string> = {
"needs-action": "participants.needs_action",
};
/**
* The statuses in plain words for the participant lists (pim-19): the
* standard's "Needs action" reads "Not answered yet". Keys of ux_pim_event.
*/
export const PARTICIPANT_STATUS_WORDS: Record<string, string> = {
accepted: "status_accepted",
declined: "status_declined",
tentative: "status_tentative",
"needs-action": "status_needs_action",
delegated: "status_delegated",
};
/** A participant's answer, as a short coloured word. */
export function ParticipantStatusBadge({ status, className }: { status: Status; className?: string }) {
const t = useTranslations("calendar");
const t = useTranslations("ux_pim_event");
return (
<span className={cn("text-xs flex-shrink-0", STATUS_TEXT[status] ?? "", className)}>
{t(STATUS_LABEL[status] ?? STATUS_LABEL["needs-action"])}
{t(PARTICIPANT_STATUS_WORDS[status] ?? PARTICIPANT_STATUS_WORDS["needs-action"])}
</span>
);
}
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { followingSeriesData, seriesUpdatesFromOccurrence, type EventEditorState } from '@/lib/recurrence-scope-edit';
import { getEventEndDate, getEventStartDate } from '@/lib/calendar-utils';
import { AUTO_TIME_ZONE, fromWallClock, toDisplayDate } from '@/lib/timezone';
import { useSettingsStore } from '@/stores/settings-store';
import type { CalendarEvent } from '@/lib/jmap/types';
// pim-2: a series moved to another zone with the editor's time zone picker
// goes on at the same time on the new zone's clock, its overrides on their
// keys. Without the picker, a series keeps its own zone.
// Mondays at 08:00 in New York from 5 October; 19 October has a title of its own.
const master = {
id: 'b', uid: 'weekly@example.org', '@type': 'Event', title: 'Planning', description: '',
start: '2026-10-05T08:00:00', duration: 'PT1H', timeZone: 'America/New_York', showWithoutTime: false,
utcStart: '2026-10-05T12:00:00Z', utcEnd: '2026-10-05T13:00:00Z', calendarIds: { cal: true },
participants: null, alerts: null,
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }],
recurrenceOverrides: { '2026-10-19T08:00:00': { title: 'Planning, budget' } },
} as unknown as CalendarEvent;
const occurrence = {
...master, id: 'maaaaac', baseEventId: 'b', start: '2026-10-12T08:00:00', recurrenceId: '2026-10-12T08:00:00',
recurrenceIdTimeZone: 'America/New_York', utcStart: '2026-10-12T12:00:00Z', utcEnd: '2026-10-12T13:00:00Z',
} as CalendarEvent;
const updates = (change: Partial<CalendarEvent>): Partial<CalendarEvent> => ({
title: 'Planning', description: '', start: '2026-10-12T08:00:00', duration: 'PT1H', timeZone: 'America/New_York',
showWithoutTime: false, calendarIds: { cal: true }, recurrenceRules: master.recurrenceRules, recurrenceId: occurrence.recurrenceId,
...change,
});
const editor = (when: EventEditorState['when'], change: Partial<EventEditorState> = {}): EventEditorState => ({
initialAttendees: [], attendees: [], when, ...change,
});
const unmoved = () => ({ allDay: false, start: getEventStartDate(occurrence), end: getEventEndDate(occurrence) });
// 08:00-09:00 on 12 October in Tokyo, as a display date.
const inTokyo = () => ({
allDay: false,
start: toDisplayDate(fromWallClock({ year: 2026, month: 10, day: 12, hour: 8, minute: 0, second: 0 }, 'Asia/Tokyo')),
end: toDisplayDate(fromWallClock({ year: 2026, month: 10, day: 12, hour: 9, minute: 0, second: 0 }, 'Asia/Tokyo')),
});
const context = { userEmail: 'me@example.org', userEmails: ['me@example.org'] };
beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' }));
afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE }));
describe('a series and its time zone (pim-2)', () => {
it('keeps its zone and start when every occurrence gets a new title', () => {
const series = seriesUpdatesFromOccurrence(occurrence, master, updates({ title: 'Planning, room 2' }), editor(unmoved()), context);
expect(series).toEqual({ title: 'Planning, room 2' });
});
it('goes on at the same time in the zone the user picked, overrides on their keys', () => {
const series = seriesUpdatesFromOccurrence(
occurrence, master, updates({ timeZone: 'Asia/Tokyo' }), editor(inTokyo(), { timeZoneChanged: true }), context,
);
expect(series.timeZone).toBe('Asia/Tokyo');
expect(series.start ?? master.start).toBe('2026-10-05T08:00:00');
expect(series.recurrenceOverrides ?? master.recurrenceOverrides).toHaveProperty(['2026-10-19T08:00:00']);
});
it('moves with the clock when the user also changed the time', () => {
const when = inTokyo();
when.start = new Date(when.start.getTime() + 3_600_000);
when.end = new Date(when.end.getTime() + 3_600_000);
const series = seriesUpdatesFromOccurrence(
occurrence, master, updates({ timeZone: 'Asia/Tokyo', start: '2026-10-12T09:00:00' }), editor(when, { timeZoneChanged: true }), context,
);
expect(series).toMatchObject({ start: '2026-10-05T09:00:00', timeZone: 'Asia/Tokyo' });
});
it('starts "this and following" in the picked zone at the occurrence time', () => {
const data = followingSeriesData(occurrence, master, updates({ timeZone: 'Asia/Tokyo' }), master.recurrenceRules, {
editor: editor(inTokyo(), { timeZoneChanged: true }),
overrides: { '2026-10-19T08:00:00': { title: 'Planning, budget' } } as CalendarEvent['recurrenceOverrides'] & object,
});
expect(data).toMatchObject({ start: '2026-10-12T08:00:00', timeZone: 'Asia/Tokyo' });
expect(data.recurrenceOverrides).toHaveProperty(['2026-10-19T08:00:00']);
});
});
+135
View File
@@ -0,0 +1,135 @@
import { format, isSameDay } from "date-fns";
import type { CalendarEvent } from "@/lib/jmap/types";
import {
canonicalTimeZone,
fromDisplayDate,
fromWallClock,
getEffectiveTimeZone,
getTimeZoneOffsetMs,
toDisplayDate,
toZonedDisplayDate,
} from "@/lib/timezone";
/**
* The event editor's time zone (pim-2). The form's date and time fields are
* the wall clock in the zone the event is in, and a save writes them back in
* that zone: an edit of a meeting in New York keeps it in New York, its start
* as the server has it. The rest of the calendar works on display dates (the
* wall clock in the user's zone, lib/timezone.ts); these helpers turn the
* form's fields into one and back.
*
* A zone of null is a floating event: the same wall clock everywhere, so its
* fields are the display date as it is, and it is saved without a zone.
*/
/**
* The zone the editor opens an event in: its own, spelled as Intl spells it;
* none for a timed event without one (floating). A new event, an all-day one
* (should it become timed) and one whose zone Intl doesn't know take the
* user's zone, as before.
*/
export function eventFormTimeZone(
event: Pick<CalendarEvent, "timeZone" | "showWithoutTime"> | null | undefined,
userZone: string = getEffectiveTimeZone(),
): string | null {
if (!event || event.showWithoutTime) return userZone;
if (event.timeZone === null) return null;
if (!event.timeZone) return userZone;
return canonicalTimeZone(event.timeZone) ?? userZone;
}
/** A Date whose local fields read the wall clock `display` (a display date) has in `zone`. */
export function displayToZoneWall(display: Date, zone: string | null): Date {
if (!zone || Number.isNaN(display.getTime())) return display;
if (zone === getEffectiveTimeZone()) return display;
return toZonedDisplayDate(fromDisplayDate(display), zone);
}
/** The display date at which the clock in `zone` reads the local fields of `wall`. */
export function zoneWallToDisplay(wall: Date, zone: string | null): Date {
if (!zone || Number.isNaN(wall.getTime())) return wall;
if (zone === getEffectiveTimeZone()) return wall;
return toDisplayDate(fromWallClock({
year: wall.getFullYear(),
month: wall.getMonth() + 1,
day: wall.getDate(),
hour: wall.getHours(),
minute: wall.getMinutes(),
second: wall.getSeconds(),
}, zone));
}
/** "America/New_York" as people say it: "New York". */
export function timeZoneCity(zone: string): string {
const last = zone.split("/").pop() || zone;
return last.replace(/_/g, " ");
}
/** Whether the clocks in `a` and `b` read alike at `instant`. */
export function sameClock(a: string, b: string, instant: Date): boolean {
if (a === b) return true;
try {
return getTimeZoneOffsetMs(instant, a) === getTimeZoneOffsetMs(instant, b);
} catch {
return false;
}
}
export interface ZonedTimes {
/** The event's zone, by its city: "New York". */
zone: string;
/** Start and end on the event's own clock: display-style Dates (read their local fields). */
start: Date;
end: Date;
/** The event's own clock is on another day than the user's at the start. */
otherDay: boolean;
}
/**
* When a timed event is on its own clock, for a line under the user's times
* ("10:00–11:00 New York"): only when its zone's clock reads differently
* from the user's at the event's start. `start` and `end` are the display
* dates the views work with. Null for all-day and floating events, and for
* an event in the user's zone or one with the same clock.
*/
export function eventZonedTimes(
event: Pick<CalendarEvent, "timeZone" | "showWithoutTime">,
start: Date,
end: Date,
userZone: string = getEffectiveTimeZone(),
): ZonedTimes | null {
if (event.showWithoutTime || !event.timeZone) return null;
const zone = canonicalTimeZone(event.timeZone);
if (!zone) return null;
const instant = fromDisplayDate(start);
if (Number.isNaN(instant.getTime()) || sameClock(zone, userZone, instant)) return null;
const zonedStart = toZonedDisplayDate(instant, zone);
const zonedEnd = toZonedDisplayDate(fromDisplayDate(end), zone);
return { zone: timeZoneCity(zone), start: zonedStart, end: zonedEnd, otherDay: !isSameDay(zonedStart, start) };
}
/**
* The times of eventZonedTimes as text: on the event's clock ("10:00–11:00",
* with the weekday when its day differs from the user's, or both ends when
* it spans days) and on the user's (`start`, `end`, display dates).
*/
export function zonedTimesText(
zoned: ZonedTimes,
start: Date,
end: Date,
timeFormat: string,
locale: string,
): { times: string; local: string } {
const time = (d: Date) => format(d, timeFormat);
let weekday: Intl.DateTimeFormat;
try {
weekday = new Intl.DateTimeFormat(locale, { weekday: "short" });
} catch {
weekday = new Intl.DateTimeFormat("en", { weekday: "short" });
}
const day = (d: Date) => weekday.format(d);
const span = (from: Date, to: Date, withDay: boolean) => (isSameDay(from, to)
? `${withDay ? `${day(from)} ` : ""}${time(from)}–${time(to)}`
: `${day(from)} ${time(from)} – ${day(to)} ${time(to)}`);
return { times: span(zoned.start, zoned.end, zoned.otherDay), local: span(start, end, false) };
}
+36 -2
View File
@@ -62,6 +62,8 @@ export interface EventEditorState {
when: EventWhen;
/** The user changed the recurrence rule: a series then happens on other days. */
ruleChanged?: boolean;
/** The user picked another time zone: the times in the editor are on that zone's clock. */
timeZoneChanged?: boolean;
}
/**
@@ -264,7 +266,12 @@ function seriesWhen(
master: CalendarEvent,
updates: Partial<CalendarEvent>,
when: EventWhen,
timeZoneChanged = false,
): SeriesWhen {
const newZone = timeZoneChanged && !when.allDay && !master.showWithoutTime && updates.timeZone !== undefined
? updates.timeZone
: undefined;
if (newZone !== undefined) return seriesInZone(occurrence, master, updates, when, newZone);
if (!changesWhen(occurrence, when)) return {};
const result: SeriesWhen = {};
const allDayChanged = !!master.showWithoutTime !== when.allDay;
@@ -296,6 +303,33 @@ function seriesWhen(
return result;
}
/**
* When the series happens after the editor put one of its occurrences on
* another zone's clock: its start keeps its place on the clock, moved by as
* much as the occurrence moved from its old zone's clock to the new one's
* (10:00 in New York, made 10:00 in Tokyo, keeps the series at 10:00 in
* Tokyo), in the new zone. The keys of its overrides stay, as they are on
* the series' clock.
*/
function seriesInZone(
occurrence: CalendarEvent,
master: CalendarEvent,
updates: Partial<CalendarEvent>,
when: EventWhen,
newZone: string | null,
): SeriesWhen {
const oldZone = master.timeZone && isValidTimeZone(master.timeZone) ? master.timeZone : null;
const from = getEventStartDate(occurrence);
const before = oldZone ? zonedMinutesMs(from, oldZone) : wallClockMinutesMs(from);
const after = newZone && isValidTimeZone(newZone) ? zonedMinutesMs(when.start, newZone) : wallClockMinutesMs(when.start);
const result: SeriesWhen = { timeZone: newZone };
if (after !== before) result.start = shiftLocalDateTime(master.start, after - before);
if (parseDurationSeconds(updates.duration) !== parseDurationSeconds(occurrence.duration ?? master.duration)) {
result.duration = updates.duration;
}
return result;
}
/** The editor's fields a save changes on its own: when (seriesWhen), who (participantFieldsToSave), which occurrence. */
const NOT_EDITOR_FIELDS = new Set<string>([...WHEN_KEYS, 'recurrenceId', 'participants', 'organizerCalendarAddress', 'replyTo']);
@@ -542,7 +576,7 @@ export function seriesUpdatesFromOccurrence(
): Partial<CalendarEvent> {
const changes = changedEditorFields(occurrence, updates, editor);
const series: Partial<CalendarEvent> = { ...changes };
const when = seriesWhen(occurrence, master, updates, editor.when);
const when = seriesWhen(occurrence, master, updates, editor.when, editor.timeZoneChanged);
Object.assign(series, when);
const rescheduled = Object.keys(when).length > 0 || !!editor.ruleChanged;
const participants = seriesParticipants(master, editor, { ...context, rescheduled });
@@ -927,7 +961,7 @@ export function followingSeriesData(
Object.assign(data, changed);
data.recurrenceRules = ruleChanged && editorRules !== undefined ? editorRules : remainingRules(originalRules, master.start, splitKey);
if (editor) {
Object.assign(data, seriesWhen(occurrence, { ...master, start: splitKey }, updates, editor.when));
Object.assign(data, seriesWhen(occurrence, { ...master, start: splitKey }, updates, editor.when, editor.timeZoneChanged));
data.start ??= splitKey;
for (const key of ['participants', 'organizerCalendarAddress'] as const) {
if (updates[key] !== undefined) (data as Record<string, unknown>)[key] = updates[key];
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "يحتاج هذا التغيير إلى اتصال بالخادم. غيّر هذا الحدث فقط الآن، أو حاول مرة أخرى عند عودة الاتصال."
},
"ux_pim_event": {
"time_zone": "المنطقة الزمنية",
"time_zone_line": "المنطقة الزمنية: {zone}",
"your_time": "{times} بتوقيتك",
"zone_times": "{times} {zone} ({local} بتوقيتك)",
"zone_only": "{times} {zone}",
"status_accepted": "مقبول",
"status_declined": "مرفوض",
"status_tentative": "ربما",
"status_needs_action": "لم يتم الرد بعد",
"status_delegated": "تم التحويل",
"answered_accepted": "قبلت الدعوة في {date}.",
"answered_declined": "رفضت الدعوة في {date}.",
"answered_tentative": "أجبت بـ\"ربما\" في {date}.",
"answered_accepted_nodate": "قبلت الدعوة.",
"answered_declined_nodate": "رفضت الدعوة.",
"answered_tentative_nodate": "أجبت بـ\"ربما\"."
},
"calendar_event_notifications": {
"someone": "شخص ما",
"invited": "دعاك {name} إلى \"{title}\"",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Aquest canvi necessita connexió amb el servidor. Canvia només aquest esdeveniment de moment, o torna-ho a provar quan tornis a estar connectat."
},
"ux_pim_event": {
"time_zone": "Fus horari",
"time_zone_line": "Fus horari: {zone}",
"your_time": "{times} la vostra hora",
"zone_times": "{times} {zone} ({local} la vostra hora)",
"zone_only": "{times} {zone}",
"status_accepted": "Acceptat",
"status_declined": "Rebutjat",
"status_tentative": "Potser",
"status_needs_action": "Encara sense resposta",
"status_delegated": "Delegat",
"answered_accepted": "Vau acceptar el {date}.",
"answered_declined": "Vau rebutjar el {date}.",
"answered_tentative": "Vau respondre potser el {date}.",
"answered_accepted_nodate": "Heu acceptat.",
"answered_declined_nodate": "Heu rebutjat.",
"answered_tentative_nodate": "Heu respost potser."
},
"calendar_event_notifications": {
"someone": "Algú",
"invited": "{name} t'ha convidat a \"{title}\"",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Tato změna vyžaduje připojení k serveru. Prozatím změňte jen tuto událost, nebo to zkuste znovu po obnovení připojení."
},
"ux_pim_event": {
"time_zone": "Časové pásmo",
"time_zone_line": "Časové pásmo: {zone}",
"your_time": "{times} váš čas",
"zone_times": "{times} {zone} ({local} váš čas)",
"zone_only": "{times} {zone}",
"status_accepted": "Přijato",
"status_declined": "Odmítnuto",
"status_tentative": "Možná",
"status_needs_action": "Zatím bez odpovědi",
"status_delegated": "Předáno",
"answered_accepted": "Přijali jste {date}.",
"answered_declined": "Odmítli jste {date}.",
"answered_tentative": "Odpověděli jste možná {date}.",
"answered_accepted_nodate": "Přijali jste.",
"answered_declined_nodate": "Odmítli jste.",
"answered_tentative_nodate": "Odpověděli jste možná."
},
"advanced_search": {
"title": "Pokročilé hledání",
"from": "Od",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Denne ændring kræver forbindelse til serveren. Ret kun denne begivenhed for nu, eller prøv igen, når du er online."
},
"ux_pim_event": {
"time_zone": "Tidszone",
"time_zone_line": "Tidszone: {zone}",
"your_time": "{times} din tid",
"zone_times": "{times} {zone} ({local} din tid)",
"zone_only": "{times} {zone}",
"status_accepted": "Accepteret",
"status_declined": "Afslået",
"status_tentative": "Måske",
"status_needs_action": "Ikke besvaret endnu",
"status_delegated": "Videregivet",
"answered_accepted": "Du accepterede {date}.",
"answered_declined": "Du afslog {date}.",
"answered_tentative": "Du svarede måske {date}.",
"answered_accepted_nodate": "Du accepterede.",
"answered_declined_nodate": "Du afslog.",
"answered_tentative_nodate": "Du svarede måske."
},
"calendar_event_notifications": {
"someone": "Nogen",
"invited": "{name} har inviteret dig til \"{title}\"",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Für diese Änderung ist eine Verbindung zum Server nötig. Ändern Sie vorerst nur diesen Termin, oder versuchen Sie es erneut, wenn Sie wieder online sind."
},
"ux_pim_event": {
"time_zone": "Zeitzone",
"time_zone_line": "Zeitzone: {zone}",
"your_time": "{times} Ihre Zeit",
"zone_times": "{times} {zone} ({local} Ihre Zeit)",
"zone_only": "{times} {zone}",
"status_accepted": "Zugesagt",
"status_declined": "Abgelehnt",
"status_tentative": "Vielleicht",
"status_needs_action": "Noch nicht beantwortet",
"status_delegated": "Weitergegeben",
"answered_accepted": "Sie haben am {date} zugesagt.",
"answered_declined": "Sie haben am {date} abgesagt.",
"answered_tentative": "Sie haben am {date} mit Vielleicht geantwortet.",
"answered_accepted_nodate": "Sie haben zugesagt.",
"answered_declined_nodate": "Sie haben abgesagt.",
"answered_tentative_nodate": "Sie haben mit Vielleicht geantwortet."
},
"advanced_search": {
"title": "Erweiterte Suche",
"from": "Von",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "This change needs a connection to the server. Change only this event for now, or try again when you're back online."
},
"ux_pim_event": {
"time_zone": "Time zone",
"time_zone_line": "Time zone: {zone}",
"your_time": "{times} your time",
"zone_times": "{times} {zone} ({local} your time)",
"zone_only": "{times} {zone}",
"status_accepted": "Accepted",
"status_declined": "Declined",
"status_tentative": "Maybe",
"status_needs_action": "Not answered yet",
"status_delegated": "Passed on",
"answered_accepted": "You accepted on {date}.",
"answered_declined": "You declined on {date}.",
"answered_tentative": "You answered maybe on {date}.",
"answered_accepted_nodate": "You accepted.",
"answered_declined_nodate": "You declined.",
"answered_tentative_nodate": "You answered maybe."
},
"calendar_event_notifications": {
"someone": "Someone",
"invited": "{name} invited you to \"{title}\"",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Este cambio necesita conexión con el servidor. Cambia solo este evento por ahora o inténtalo de nuevo cuando vuelvas a tener conexión."
},
"ux_pim_event": {
"time_zone": "Zona horaria",
"time_zone_line": "Zona horaria: {zone}",
"your_time": "{times} tu hora",
"zone_times": "{times} {zone} ({local} tu hora)",
"zone_only": "{times} {zone}",
"status_accepted": "Aceptado",
"status_declined": "Rechazado",
"status_tentative": "Quizás",
"status_needs_action": "Sin responder todavía",
"status_delegated": "Delegado",
"answered_accepted": "Aceptaste el {date}.",
"answered_declined": "Rechazaste el {date}.",
"answered_tentative": "Respondiste quizás el {date}.",
"answered_accepted_nodate": "Aceptaste.",
"answered_declined_nodate": "Rechazaste.",
"answered_tentative_nodate": "Respondiste quizás."
},
"advanced_search": {
"title": "Búsqueda avanzada",
"from": "De",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "See muudatus vajab ühendust serveriga. Muuda praegu ainult seda sündmust või proovi uuesti, kui oled taas võrgus."
},
"ux_pim_event": {
"time_zone": "Ajavöönd",
"time_zone_line": "Ajavöönd: {zone}",
"your_time": "{times} teie aja järgi",
"zone_times": "{times} {zone} ({local} teie aja järgi)",
"zone_only": "{times} {zone}",
"status_accepted": "Nõustunud",
"status_declined": "Keeldunud",
"status_tentative": "Võib-olla",
"status_needs_action": "Pole veel vastanud",
"status_delegated": "Edasi antud",
"answered_accepted": "Nõustusite {date}.",
"answered_declined": "Keeldusite {date}.",
"answered_tentative": "Vastasite võib-olla {date}.",
"answered_accepted_nodate": "Nõustusite.",
"answered_declined_nodate": "Keeldusite.",
"answered_tentative_nodate": "Vastasite võib-olla."
},
"calendar_event_notifications": {
"someone": "Keegi",
"invited": "{name} kutsus sind sündmusele \"{title}\"",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "این تغییر به اتصال به سرور نیاز دارد. فعلاً فقط همین رویداد را تغییر دهید، یا پس از برقراری اتصال دوباره تلاش کنید."
},
"ux_pim_event": {
"time_zone": "منطقه زمانی",
"time_zone_line": "منطقه زمانی: {zone}",
"your_time": "{times} به وقت شما",
"zone_times": "{times} {zone} ({local} به وقت شما)",
"zone_only": "{times} {zone}",
"status_accepted": "پذیرفته شد",
"status_declined": "رد شد",
"status_tentative": "شاید",
"status_needs_action": "هنوز پاسخ نداده",
"status_delegated": "واگذار شد",
"answered_accepted": "در {date} پذیرفتید.",
"answered_declined": "در {date} رد کردید.",
"answered_tentative": "در {date} پاسخ «شاید» دادید.",
"answered_accepted_nodate": "پذیرفتید.",
"answered_declined_nodate": "رد کردید.",
"answered_tentative_nodate": "پاسخ «شاید» دادید."
},
"calendar_event_notifications": {
"someone": "کسی",
"invited": "{name} شما را به «{title}» دعوت کرد",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Cette modification nécessite une connexion au serveur. Modifiez seulement cet événement pour l'instant, ou réessayez une fois de nouveau en ligne."
},
"ux_pim_event": {
"time_zone": "Fuseau horaire",
"time_zone_line": "Fuseau horaire : {zone}",
"your_time": "{times} votre heure",
"zone_times": "{times} {zone} ({local} votre heure)",
"zone_only": "{times} {zone}",
"status_accepted": "Accepté",
"status_declined": "Refusé",
"status_tentative": "Peut-être",
"status_needs_action": "Pas encore répondu",
"status_delegated": "Transmis",
"answered_accepted": "Vous avez accepté le {date}.",
"answered_declined": "Vous avez refusé le {date}.",
"answered_tentative": "Vous avez répondu peut-être le {date}.",
"answered_accepted_nodate": "Vous avez accepté.",
"answered_declined_nodate": "Vous avez refusé.",
"answered_tentative_nodate": "Vous avez répondu peut-être."
},
"advanced_search": {
"title": "Recherche avancée",
"from": "De",
+18
View File
@@ -4857,6 +4857,24 @@
"calendar_offline": {
"split_needs_server": "שינוי זה דורש חיבור לשרת. שנו כרגע רק את האירוע הזה, או נסו שוב כשהחיבור יחזור."
},
"ux_pim_event": {
"time_zone": "אזור זמן",
"time_zone_line": "אזור זמן: {zone}",
"your_time": "{times} לפי השעה שלך",
"zone_times": "{times} {zone} ({local} לפי השעה שלך)",
"zone_only": "{times} {zone}",
"status_accepted": "התקבל",
"status_declined": "נדחה",
"status_tentative": "אולי",
"status_needs_action": "עדיין לא ענו",
"status_delegated": "הועבר",
"answered_accepted": "אישרת ב-{date}.",
"answered_declined": "דחית ב-{date}.",
"answered_tentative": "ענית 'אולי' ב-{date}.",
"answered_accepted_nodate": "אישרת.",
"answered_declined_nodate": "דחית.",
"answered_tentative_nodate": "ענית 'אולי'."
},
"advanced_search": {
"title": "חיפוש מתקדם",
"from": "מ",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Ehhez a módosításhoz kapcsolat kell a kiszolgálóval. Egyelőre csak ezt az eseményt módosítsa, vagy próbálja újra, ha ismét online lesz."
},
"ux_pim_event": {
"time_zone": "Időzóna",
"time_zone_line": "Időzóna: {zone}",
"your_time": "{times} az Ön ideje szerint",
"zone_times": "{times} {zone} ({local} az Ön ideje szerint)",
"zone_only": "{times} {zone}",
"status_accepted": "Elfogadva",
"status_declined": "Elutasítva",
"status_tentative": "Talán",
"status_needs_action": "Még nem válaszolt",
"status_delegated": "Továbbadva",
"answered_accepted": "Ön elfogadta: {date}.",
"answered_declined": "Ön elutasította: {date}.",
"answered_tentative": "Ön talánnal válaszolt: {date}.",
"answered_accepted_nodate": "Ön elfogadta.",
"answered_declined_nodate": "Ön elutasította.",
"answered_tentative_nodate": "Ön talánnal válaszolt."
},
"calendar_event_notifications": {
"someone": "Valaki",
"invited": "{name} meghívott a(z) „{title}” eseményre",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Questa modifica richiede una connessione al server. Per ora modifica solo questo evento, oppure riprova quando sei di nuovo online."
},
"ux_pim_event": {
"time_zone": "Fuso orario",
"time_zone_line": "Fuso orario: {zone}",
"your_time": "{times} ora tua",
"zone_times": "{times} {zone} ({local} ora tua)",
"zone_only": "{times} {zone}",
"status_accepted": "Accettato",
"status_declined": "Rifiutato",
"status_tentative": "Forse",
"status_needs_action": "Non ha ancora risposto",
"status_delegated": "Delegato",
"answered_accepted": "Hai accettato il {date}.",
"answered_declined": "Hai rifiutato il {date}.",
"answered_tentative": "Hai risposto forse il {date}.",
"answered_accepted_nodate": "Hai accettato.",
"answered_declined_nodate": "Hai rifiutato.",
"answered_tentative_nodate": "Hai risposto forse."
},
"advanced_search": {
"title": "Ricerca avanzata",
"from": "Da",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "この変更にはサーバーへの接続が必要です。今はこの予定だけを変更するか、オンラインに戻ってからもう一度お試しください。"
},
"ux_pim_event": {
"time_zone": "タイムゾーン",
"time_zone_line": "タイムゾーン: {zone}",
"your_time": "あなたの時刻 {times}",
"zone_times": "{times} {zone}(あなたの時刻 {local})",
"zone_only": "{times} {zone}",
"status_accepted": "承諾",
"status_declined": "辞退",
"status_tentative": "未定",
"status_needs_action": "まだ回答なし",
"status_delegated": "委任済み",
"answered_accepted": "{date} に承諾しました。",
"answered_declined": "{date} に辞退しました。",
"answered_tentative": "{date} に未定と回答しました。",
"answered_accepted_nodate": "承諾しました。",
"answered_declined_nodate": "辞退しました。",
"answered_tentative_nodate": "未定と回答しました。"
},
"advanced_search": {
"title": "詳細検索",
"from": "差出人",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "이 변경에는 서버 연결이 필요합니다. 지금은 이 일정만 변경하거나 다시 온라인이 되면 다시 시도하세요."
},
"ux_pim_event": {
"time_zone": "시간대",
"time_zone_line": "시간대: {zone}",
"your_time": "내 시간 {times}",
"zone_times": "{times} {zone} (내 시간 {local})",
"zone_only": "{times} {zone}",
"status_accepted": "수락함",
"status_declined": "거절함",
"status_tentative": "미정",
"status_needs_action": "아직 응답 안 함",
"status_delegated": "위임됨",
"answered_accepted": "{date}에 수락했습니다.",
"answered_declined": "{date}에 거절했습니다.",
"answered_tentative": "{date}에 미정으로 응답했습니다.",
"answered_accepted_nodate": "수락했습니다.",
"answered_declined_nodate": "거절했습니다.",
"answered_tentative_nodate": "미정으로 응답했습니다."
},
"advanced_search": {
"title": "상세 검색",
"from": "보낸 사람",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Šīm izmaiņām nepieciešams savienojums ar serveri. Pagaidām mainiet tikai šo notikumu vai mēģiniet vēlreiz, kad būsiet tiešsaistē."
},
"ux_pim_event": {
"time_zone": "Laika josla",
"time_zone_line": "Laika josla: {zone}",
"your_time": "{times} jūsu laikā",
"zone_times": "{times} {zone} ({local} jūsu laikā)",
"zone_only": "{times} {zone}",
"status_accepted": "Pieņemts",
"status_declined": "Noraidīts",
"status_tentative": "Varbūt",
"status_needs_action": "Vēl nav atbildējis",
"status_delegated": "Nodots tālāk",
"answered_accepted": "Jūs pieņēmāt {date}.",
"answered_declined": "Jūs noraidījāt {date}.",
"answered_tentative": "Jūs atbildējāt varbūt {date}.",
"answered_accepted_nodate": "Jūs pieņēmāt.",
"answered_declined_nodate": "Jūs noraidījāt.",
"answered_tentative_nodate": "Jūs atbildējāt varbūt."
},
"advanced_search": {
"title": "Izvērstā meklēšana",
"from": "No",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Энэ өөрчлөлтөд сервертэй холболт шаардлагатай. Одоохондоо зөвхөн энэ үйл явдлыг өөрчлөх эсвэл онлайн болсны дараа дахин оролдоно уу."
},
"ux_pim_event": {
"time_zone": "Цагийн бүс",
"time_zone_line": "Цагийн бүс: {zone}",
"your_time": "Таны цагаар {times}",
"zone_times": "{times} {zone} (таны цагаар {local})",
"zone_only": "{times} {zone}",
"status_accepted": "Хүлээн зөвшөөрсөн",
"status_declined": "Татгалзсан",
"status_tentative": "Магадгүй",
"status_needs_action": "Хараахан хариулаагүй",
"status_delegated": "Шилжүүлсэн",
"answered_accepted": "Та {date}-нд зөвшөөрсөн.",
"answered_declined": "Та {date}-нд татгалзсан.",
"answered_tentative": "Та {date}-нд магадгүй гэж хариулсан.",
"answered_accepted_nodate": "Та зөвшөөрсөн.",
"answered_declined_nodate": "Та татгалзсан.",
"answered_tentative_nodate": "Та магадгүй гэж хариулсан."
},
"calendar_event_notifications": {
"someone": "Хэн нэгэн",
"invited": "{name} таныг \"{title}\"-д урилаа",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Denne endringen krever tilkobling til serveren. Endre bare denne hendelsen for nå, eller prøv igjen når du er tilkoblet."
},
"ux_pim_event": {
"time_zone": "Tidssone",
"time_zone_line": "Tidssone: {zone}",
"your_time": "{times} din tid",
"zone_times": "{times} {zone} ({local} din tid)",
"zone_only": "{times} {zone}",
"status_accepted": "Godtatt",
"status_declined": "Avslått",
"status_tentative": "Kanskje",
"status_needs_action": "Har ikke svart ennå",
"status_delegated": "Overført",
"answered_accepted": "Du godtok {date}.",
"answered_declined": "Du avslo {date}.",
"answered_tentative": "Du svarte kanskje {date}.",
"answered_accepted_nodate": "Du godtok.",
"answered_declined_nodate": "Du avslo.",
"answered_tentative_nodate": "Du svarte kanskje."
},
"calendar_event_notifications": {
"someone": "Noen",
"invited": "{name} har invitert deg til «{title}»",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Voor deze wijziging is een verbinding met de server nodig. Wijzig voorlopig alleen deze afspraak, of probeer het opnieuw zodra je weer online bent."
},
"ux_pim_event": {
"time_zone": "Tijdzone",
"time_zone_line": "Tijdzone: {zone}",
"your_time": "{times} jouw tijd",
"zone_times": "{times} {zone} ({local} jouw tijd)",
"zone_only": "{times} {zone}",
"status_accepted": "Geaccepteerd",
"status_declined": "Geweigerd",
"status_tentative": "Misschien",
"status_needs_action": "Nog niet geantwoord",
"status_delegated": "Doorgegeven",
"answered_accepted": "Je hebt op {date} geaccepteerd.",
"answered_declined": "Je hebt op {date} geweigerd.",
"answered_tentative": "Je hebt op {date} misschien geantwoord.",
"answered_accepted_nodate": "Je hebt geaccepteerd.",
"answered_declined_nodate": "Je hebt geweigerd.",
"answered_tentative_nodate": "Je hebt misschien geantwoord."
},
"advanced_search": {
"title": "Geavanceerd zoeken",
"from": "Van",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Ta zmiana wymaga połączenia z serwerem. Na razie zmień tylko to wydarzenie albo spróbuj ponownie, gdy wrócisz do trybu online."
},
"ux_pim_event": {
"time_zone": "Strefa czasowa",
"time_zone_line": "Strefa czasowa: {zone}",
"your_time": "{times} twojego czasu",
"zone_times": "{times} {zone} ({local} twojego czasu)",
"zone_only": "{times} {zone}",
"status_accepted": "Zaakceptowano",
"status_declined": "Odrzucono",
"status_tentative": "Może",
"status_needs_action": "Jeszcze bez odpowiedzi",
"status_delegated": "Przekazano",
"answered_accepted": "Zaakceptowano {date}.",
"answered_declined": "Odrzucono {date}.",
"answered_tentative": "Odpowiedziano „może” {date}.",
"answered_accepted_nodate": "Zaakceptowano.",
"answered_declined_nodate": "Odrzucono.",
"answered_tentative_nodate": "Odpowiedziano „może”."
},
"advanced_search": {
"title": "Wyszukiwanie zaawansowane",
"from": "Od",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Esta alteração precisa de ligação ao servidor. Altere apenas este evento por agora, ou tente de novo quando voltar a estar online."
},
"ux_pim_event": {
"time_zone": "Fuso horário",
"time_zone_line": "Fuso horário: {zone}",
"your_time": "{times} no seu horário",
"zone_times": "{times} {zone} ({local} no seu horário)",
"zone_only": "{times} {zone}",
"status_accepted": "Aceito",
"status_declined": "Recusado",
"status_tentative": "Talvez",
"status_needs_action": "Ainda sem resposta",
"status_delegated": "Delegado",
"answered_accepted": "Você aceitou em {date}.",
"answered_declined": "Você recusou em {date}.",
"answered_tentative": "Você respondeu talvez em {date}.",
"answered_accepted_nodate": "Você aceitou.",
"answered_declined_nodate": "Você recusou.",
"answered_tentative_nodate": "Você respondeu talvez."
},
"advanced_search": {
"title": "Pesquisa avançada",
"from": "De",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Această modificare are nevoie de conexiune la server. Modifică deocamdată doar acest eveniment sau încearcă din nou când ești din nou online."
},
"ux_pim_event": {
"time_zone": "Fus orar",
"time_zone_line": "Fus orar: {zone}",
"your_time": "{times} ora ta",
"zone_times": "{times} {zone} ({local} ora ta)",
"zone_only": "{times} {zone}",
"status_accepted": "Acceptat",
"status_declined": "Respins",
"status_tentative": "Poate",
"status_needs_action": "Încă fără răspuns",
"status_delegated": "Delegat",
"answered_accepted": "Ai acceptat pe {date}.",
"answered_declined": "Ai refuzat pe {date}.",
"answered_tentative": "Ai răspuns poate pe {date}.",
"answered_accepted_nodate": "Ai acceptat.",
"answered_declined_nodate": "Ai refuzat.",
"answered_tentative_nodate": "Ai răspuns poate."
},
"calendar_event_notifications": {
"someone": "Cineva",
"invited": "{name} te-a invitat la „{title}”",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Для этого изменения нужно подключение к серверу. Пока измените только это событие или повторите попытку, когда снова будете в сети."
},
"ux_pim_event": {
"time_zone": "Часовой пояс",
"time_zone_line": "Часовой пояс: {zone}",
"your_time": "{times} по вашему времени",
"zone_times": "{times} {zone} ({local} по вашему времени)",
"zone_only": "{times} {zone}",
"status_accepted": "Принято",
"status_declined": "Отклонено",
"status_tentative": "Может быть",
"status_needs_action": "Ещё не ответил",
"status_delegated": "Передано",
"answered_accepted": "Вы приняли {date}.",
"answered_declined": "Вы отклонили {date}.",
"answered_tentative": "Вы ответили «может быть» {date}.",
"answered_accepted_nodate": "Вы приняли.",
"answered_declined_nodate": "Вы отклонили.",
"answered_tentative_nodate": "Вы ответили «может быть»."
},
"advanced_search": {
"title": "Расширенный поиск",
"from": "От",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Táto zmena vyžaduje pripojenie k serveru. Zatiaľ zmeňte iba túto udalosť alebo to skúste znova, keď budete online."
},
"ux_pim_event": {
"time_zone": "Časové pásmo",
"time_zone_line": "Časové pásmo: {zone}",
"your_time": "{times} váš čas",
"zone_times": "{times} {zone} ({local} váš čas)",
"zone_only": "{times} {zone}",
"status_accepted": "Prijaté",
"status_declined": "Odmietnuté",
"status_tentative": "Možno",
"status_needs_action": "Zatiaľ bez odpovede",
"status_delegated": "Odovzdané",
"answered_accepted": "Prijali ste {date}.",
"answered_declined": "Odmietli ste {date}.",
"answered_tentative": "Odpovedali ste možno {date}.",
"answered_accepted_nodate": "Prijali ste.",
"answered_declined_nodate": "Odmietli ste.",
"answered_tentative_nodate": "Odpovedali ste možno."
},
"calendar_event_notifications": {
"someone": "Niekto",
"invited": "{name} vás pozval(a) na „{title}“",
+18
View File
@@ -4353,6 +4353,24 @@
"calendar_offline": {
"split_needs_server": "การเปลี่ยนแปลงนี้ต้องเชื่อมต่อกับเซิร์ฟเวอร์ ตอนนี้เปลี่ยนเฉพาะกิจกรรมนี้ หรือลองอีกครั้งเมื่อกลับมาออนไลน์"
},
"ux_pim_event": {
"time_zone": "เขตเวลา",
"time_zone_line": "เขตเวลา: {zone}",
"your_time": "{times} ตามเวลาของคุณ",
"zone_times": "{times} {zone} ({local} ตามเวลาของคุณ)",
"zone_only": "{times} {zone}",
"status_accepted": "ยอมรับแล้ว",
"status_declined": "ปฏิเสธแล้ว",
"status_tentative": "อาจจะ",
"status_needs_action": "ยังไม่ได้ตอบ",
"status_delegated": "มอบหมายต่อแล้ว",
"answered_accepted": "คุณตอบรับเมื่อ {date}",
"answered_declined": "คุณปฏิเสธเมื่อ {date}",
"answered_tentative": "คุณตอบว่าอาจจะเมื่อ {date}",
"answered_accepted_nodate": "คุณตอบรับแล้ว",
"answered_declined_nodate": "คุณปฏิเสธแล้ว",
"answered_tentative_nodate": "คุณตอบว่าอาจจะ"
},
"settings": {
"title": "การตั้งค่า",
"org_locked": "กำหนดโดยองค์กรของคุณ",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Bu değişiklik için sunucu bağlantısı gerekir. Şimdilik yalnızca bu etkinliği değiştirin veya yeniden çevrimiçi olduğunuzda tekrar deneyin."
},
"ux_pim_event": {
"time_zone": "Saat dilimi",
"time_zone_line": "Saat dilimi: {zone}",
"your_time": "{times} sizin saatinizle",
"zone_times": "{times} {zone} ({local} sizin saatinizle)",
"zone_only": "{times} {zone}",
"status_accepted": "Kabul edildi",
"status_declined": "Reddedildi",
"status_tentative": "Belki",
"status_needs_action": "Henüz yanıtlanmadı",
"status_delegated": "Devredildi",
"answered_accepted": "{date} tarihinde kabul ettiniz.",
"answered_declined": "{date} tarihinde reddettiniz.",
"answered_tentative": "{date} tarihinde belki dediniz.",
"answered_accepted_nodate": "Kabul ettiniz.",
"answered_declined_nodate": "Reddettiniz.",
"answered_tentative_nodate": "Belki dediniz."
},
"calendar_event_notifications": {
"someone": "Birisi",
"invited": "{name} sizi \"{title}\" etkinliğine davet etti",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "Для цієї зміни потрібне з'єднання із сервером. Поки що змініть лише цю подію або спробуйте ще раз, коли знову будете в мережі."
},
"ux_pim_event": {
"time_zone": "Часовий пояс",
"time_zone_line": "Часовий пояс: {zone}",
"your_time": "{times} за вашим часом",
"zone_times": "{times} {zone} ({local} за вашим часом)",
"zone_only": "{times} {zone}",
"status_accepted": "Прийнято",
"status_declined": "Відхилено",
"status_tentative": "Можливо",
"status_needs_action": "Ще не відповів",
"status_delegated": "Передано",
"answered_accepted": "Ви прийняли {date}.",
"answered_declined": "Ви відхилили {date}.",
"answered_tentative": "Ви відповіли «можливо» {date}.",
"answered_accepted_nodate": "Ви прийняли.",
"answered_declined_nodate": "Ви відхилили.",
"answered_tentative_nodate": "Ви відповіли «можливо»."
},
"advanced_search": {
"title": "Розширений пошук",
"from": "Від",
+18
View File
@@ -4972,6 +4972,24 @@
"calendar_offline": {
"split_needs_server": "Thay đổi này cần kết nối với máy chủ. Hiện chỉ thay đổi sự kiện này, hoặc thử lại khi bạn trực tuyến trở lại."
},
"ux_pim_event": {
"time_zone": "Múi giờ",
"time_zone_line": "Múi giờ: {zone}",
"your_time": "{times} theo giờ của bạn",
"zone_times": "{times} {zone} ({local} theo giờ của bạn)",
"zone_only": "{times} {zone}",
"status_accepted": "Đã chấp nhận",
"status_declined": "Đã từ chối",
"status_tentative": "Có thể",
"status_needs_action": "Chưa trả lời",
"status_delegated": "Đã chuyển giao",
"answered_accepted": "Bạn đã chấp nhận vào {date}.",
"answered_declined": "Bạn đã từ chối vào {date}.",
"answered_tentative": "Bạn đã trả lời có thể vào {date}.",
"answered_accepted_nodate": "Bạn đã chấp nhận.",
"answered_declined_nodate": "Bạn đã từ chối.",
"answered_tentative_nodate": "Bạn đã trả lời có thể."
},
"calendar_event_notifications": {
"someone": "Ai đó",
"invited": "{name} đã mời bạn tham gia \"{title}\"",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "此變更需要連線到伺服器。請暫時只變更此活動,或在恢復連線後再試一次。"
},
"ux_pim_event": {
"time_zone": "時區",
"time_zone_line": "時區:{zone}",
"your_time": "你的時間 {times}",
"zone_times": "{times} {zone}(你的時間 {local})",
"zone_only": "{times} {zone}",
"status_accepted": "已接受",
"status_declined": "已拒絕",
"status_tentative": "暫定",
"status_needs_action": "尚未回覆",
"status_delegated": "已委派",
"answered_accepted": "你已於 {date} 接受。",
"answered_declined": "你已於 {date} 拒絕。",
"answered_tentative": "你已於 {date} 回覆暫定。",
"answered_accepted_nodate": "你已接受。",
"answered_declined_nodate": "你已拒絕。",
"answered_tentative_nodate": "你已回覆暫定。"
},
"calendar_event_notifications": {
"someone": "有人",
"invited": "{name} 邀請您參加「{title}」",
+18
View File
@@ -4960,6 +4960,24 @@
"calendar_offline": {
"split_needs_server": "此更改需要连接到服务器。请暂时只更改此日程,或在恢复联网后重试。"
},
"ux_pim_event": {
"time_zone": "时区",
"time_zone_line": "时区:{zone}",
"your_time": "你的时间 {times}",
"zone_times": "{times} {zone}(你的时间 {local})",
"zone_only": "{times} {zone}",
"status_accepted": "已接受",
"status_declined": "已拒绝",
"status_tentative": "暂定",
"status_needs_action": "尚未答复",
"status_delegated": "已委托",
"answered_accepted": "你已于 {date} 接受。",
"answered_declined": "你已于 {date} 拒绝。",
"answered_tentative": "你已于 {date} 答复暂定。",
"answered_accepted_nodate": "你已接受。",
"answered_declined_nodate": "你已拒绝。",
"answered_tentative_nodate": "你已答复暂定。"
},
"advanced_search": {
"title": "高级搜索",
"from": "发件人",