#307 Polish UpcomingMeetingAlert: AR plurals, 12h time, details table
- locales/{ar,en}.json: split minutesAway / postponeMinutesApply /
postponeConfirmPrompt / postponeConfirmYes / staleMeetingApplyAnyway
into i18next plural variants (AR uses zero/one/two/few/many/other).
Switched call sites to pass `count` so plural detection triggers.
- Added cascadeMinutesUnit_* AR/EN unit keys; cascadePromptHeader_* now
interpolates a pre-formatted {{minutesText}} so the {{minutes}} unit
inside the cascade prompt obeys the same Arabic plural rules.
- upcoming-meeting-alert.tsx: replaced the local 24h `formatTime` with
a 12-hour formatter that delegates to lib/i18n-format.formatTime with
`hour12: true`, keeping Latin digits. Applied to the popup time
window, the stale-meeting current-time line, and the new details row.
- DetailsPanel rewritten as a 2-col label/value grid (time, location,
meeting link, attendees grouped by internal/external/virtual). Empty
rows are skipped. Existing test-ids (alert-details-panel,
alert-details-attendees, alert-details-no-attendees, alert-details-url)
preserved; added alert-details-time / alert-details-location.
- Removed the postponeMinutesHint paragraph (and key from both locales);
the noTimeWindow warning still renders when a meeting has no times.
- PostponeDialog now also reads i18n.language so stale-meeting times
render in the user's locale.
tsc + the full executive-meetings-upcoming-alert.spec.mjs Playwright
suite (18 tests) pass after clearing leftover seeded test data.
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -5,12 +5,14 @@
|
||||
// invalidations from the executive_meetings_changed socket event.
|
||||
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { hexToRgba, useAlertPrefs } from "@/lib/upcoming-alert-prefs";
|
||||
import { formatTime as formatTimeLocale } from "@/lib/i18n-format";
|
||||
|
||||
const POSITION_KEY = "txos.upcomingMeetingAlert.position";
|
||||
const ALERT_LEAD_MINUTES = 5;
|
||||
@@ -103,11 +106,20 @@ function nowLocalMinutes(): number {
|
||||
const d = new Date();
|
||||
return d.getHours() * 60 + d.getMinutes();
|
||||
}
|
||||
function formatTime(t: string | null): string {
|
||||
// #307: Render scheduled HH:mm strings as a localized 12-hour clock
|
||||
// (e.g. "2:30 PM" / "2:30 م"). The stored value is always a wall-clock
|
||||
// "HH:mm[:ss]" so we anchor it on today's date just to feed Intl.
|
||||
function formatTime(t: string | null, lang: string): string {
|
||||
if (!t) return "";
|
||||
const m = /^(\d{1,2}):(\d{2})/.exec(t);
|
||||
if (!m) return t;
|
||||
return `${m[1].padStart(2, "0")}:${m[2]}`;
|
||||
const d = new Date();
|
||||
d.setHours(Number(m[1]), Number(m[2]), 0, 0);
|
||||
return formatTimeLocale(d, lang, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
// #283: Custom error so callers can inspect the structured response
|
||||
@@ -461,7 +473,7 @@ export function UpcomingMeetingAlert() {
|
||||
const headline =
|
||||
minutesAway === 0
|
||||
? t("executiveMeetings.alert.startsNow")
|
||||
: t("executiveMeetings.alert.minutesAway", { n: minutesAway });
|
||||
: t("executiveMeetings.alert.minutesAway", { count: minutesAway });
|
||||
|
||||
// Theme tokens derived from the user's saved preset. The accent is
|
||||
// used for the divider lines and the close-X hover; the bg/fg are
|
||||
@@ -563,9 +575,9 @@ export function UpcomingMeetingAlert() {
|
||||
>
|
||||
<Clock className="h-3 w-3" aria-hidden="true" />
|
||||
{meeting.endTime
|
||||
? `${formatTime(meeting.startTime)} – ${formatTime(meeting.endTime)}`
|
||||
? `${formatTime(meeting.startTime, i18n.language)} – ${formatTime(meeting.endTime, i18n.language)}`
|
||||
: t("executiveMeetings.alert.atTime", {
|
||||
time: formatTime(meeting.startTime),
|
||||
time: formatTime(meeting.startTime, i18n.language),
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -582,6 +594,7 @@ export function UpcomingMeetingAlert() {
|
||||
meeting={meeting}
|
||||
isRtl={isRtl}
|
||||
t={t}
|
||||
lang={i18n.language}
|
||||
dividerColor={dividerColor}
|
||||
sectionBg={detailsBg}
|
||||
/>
|
||||
@@ -813,6 +826,9 @@ function CascadePromptBlock({
|
||||
{t("executiveMeetings.alert.cascadePromptHeader", {
|
||||
count: prompt.followers.length,
|
||||
minutes: prompt.delta,
|
||||
minutesText: t("executiveMeetings.alert.cascadeMinutesUnit", {
|
||||
count: prompt.delta,
|
||||
}),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -886,7 +902,7 @@ function PostponeDialog({
|
||||
isRtl,
|
||||
onSaved,
|
||||
}: PostponeDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [minutes, setMinutes] = useState<string>("10");
|
||||
const [resDate, setResDate] = useState<string>(meeting.meetingDate);
|
||||
@@ -1369,11 +1385,11 @@ function PostponeDialog({
|
||||
className="space-y-2 focus:outline-none"
|
||||
data-testid="postpone-minutes-panel"
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{noTimeWindow
|
||||
? t("executiveMeetings.alert.noTimeWindow")
|
||||
: t("executiveMeetings.alert.postponeMinutesHint")}
|
||||
</p>
|
||||
{noTimeWindow ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("executiveMeetings.alert.noTimeWindow")}
|
||||
</p>
|
||||
) : null}
|
||||
{cascadePrompt && cascadePrompt.kind === "postpone" ? (
|
||||
<CascadePromptBlock
|
||||
prompt={cascadePrompt}
|
||||
@@ -1402,9 +1418,9 @@ function PostponeDialog({
|
||||
{staleConflict.currentStart ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("executiveMeetings.alert.staleMeetingCurrentTime", {
|
||||
start: formatTime(staleConflict.currentStart),
|
||||
start: formatTime(staleConflict.currentStart, i18n.language),
|
||||
end: staleConflict.currentEnd
|
||||
? formatTime(staleConflict.currentEnd)
|
||||
? formatTime(staleConflict.currentEnd, i18n.language)
|
||||
: "—",
|
||||
})}
|
||||
</p>
|
||||
@@ -1429,7 +1445,7 @@ function PostponeDialog({
|
||||
data-testid="postpone-stale-apply-anyway"
|
||||
>
|
||||
{t("executiveMeetings.alert.staleMeetingApplyAnyway", {
|
||||
n: staleConflict.minutes,
|
||||
count: staleConflict.minutes,
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -1495,7 +1511,7 @@ function PostponeDialog({
|
||||
data-testid="postpone-minutes-apply"
|
||||
>
|
||||
{t("executiveMeetings.alert.postponeMinutesApply", {
|
||||
n: Number(minutes) || 0,
|
||||
count: Number(minutes) || 0,
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1507,7 +1523,7 @@ function PostponeDialog({
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{t("executiveMeetings.alert.postponeConfirmPrompt", {
|
||||
n: pendingMinutes,
|
||||
count: pendingMinutes,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1528,7 +1544,7 @@ function PostponeDialog({
|
||||
data-testid="postpone-confirm-yes"
|
||||
>
|
||||
{t("executiveMeetings.alert.postponeConfirmYes", {
|
||||
n: pendingMinutes,
|
||||
count: pendingMinutes,
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -1710,6 +1726,7 @@ type DetailsPanelProps = {
|
||||
meeting: Meeting;
|
||||
isRtl: boolean;
|
||||
t: (k: string, opts?: Record<string, unknown>) => string;
|
||||
lang: string;
|
||||
dividerColor: string;
|
||||
sectionBg: string;
|
||||
};
|
||||
@@ -1718,6 +1735,7 @@ function DetailsPanel({
|
||||
meeting,
|
||||
isRtl,
|
||||
t,
|
||||
lang,
|
||||
dividerColor,
|
||||
sectionBg,
|
||||
}: DetailsPanelProps) {
|
||||
@@ -1754,6 +1772,126 @@ function DetailsPanel({
|
||||
}
|
||||
})();
|
||||
|
||||
// #307: Pre-compute the rendered time-window string so the row is
|
||||
// skipped entirely when the meeting has no scheduled times.
|
||||
const timeWindow = meeting.startTime
|
||||
? meeting.endTime
|
||||
? `${formatTime(meeting.startTime, lang)} – ${formatTime(meeting.endTime, lang)}`
|
||||
: formatTime(meeting.startTime, lang)
|
||||
: null;
|
||||
|
||||
// Each row renders a label cell + a value cell inside the parent
|
||||
// CSS grid. Skipping rows whose value is missing keeps the table
|
||||
// tight and avoids empty cells. The Icon is decorative — labels do
|
||||
// the real work for screen readers.
|
||||
type Row = {
|
||||
key: string;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
};
|
||||
const rows: Row[] = [];
|
||||
|
||||
if (timeWindow) {
|
||||
rows.push({
|
||||
key: "time",
|
||||
icon: <Clock className="h-3.5 w-3.5 opacity-70" aria-hidden="true" />,
|
||||
label: t("executiveMeetings.alert.detailsTime"),
|
||||
value: <span data-testid="alert-details-time">{timeWindow}</span>,
|
||||
});
|
||||
}
|
||||
|
||||
if (meeting.location && meeting.location.trim()) {
|
||||
rows.push({
|
||||
key: "location",
|
||||
icon: <Calendar className="h-3.5 w-3.5 opacity-70" aria-hidden="true" />,
|
||||
label: t("executiveMeetings.alert.detailsLocation"),
|
||||
value: (
|
||||
<span
|
||||
className="break-words"
|
||||
data-testid="alert-details-location"
|
||||
>
|
||||
{meeting.location}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (safeUrl) {
|
||||
rows.push({
|
||||
key: "url",
|
||||
icon: (
|
||||
<ExternalLink
|
||||
className="h-3.5 w-3.5 opacity-70"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
),
|
||||
label: t("executiveMeetings.alert.detailsUrl"),
|
||||
value: (
|
||||
<a
|
||||
href={safeUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="block truncate underline hover:no-underline"
|
||||
data-testid="alert-details-url"
|
||||
>
|
||||
{safeUrl}
|
||||
</a>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Attendees row is always shown (even when empty) so the user can
|
||||
// distinguish "no one yet" from "field not loaded".
|
||||
rows.push({
|
||||
key: "attendees",
|
||||
icon: <Users className="h-3.5 w-3.5 opacity-70" aria-hidden="true" />,
|
||||
label: t("executiveMeetings.alert.detailsAttendees", {
|
||||
n: persons.length,
|
||||
}),
|
||||
value:
|
||||
persons.length === 0 ? (
|
||||
<div className="opacity-80" data-testid="alert-details-no-attendees">
|
||||
{t("executiveMeetings.alert.detailsNoAttendees")}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="max-h-40 overflow-y-auto"
|
||||
data-testid="alert-details-attendees"
|
||||
>
|
||||
{(["internal", "external", "virtual"] as const).map((g) => {
|
||||
const list = groups[g];
|
||||
if (list.length === 0) return null;
|
||||
return (
|
||||
<div key={g} className="mb-1.5 last:mb-0">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide opacity-60">
|
||||
{t(`executiveMeetings.alert.detailsGroup.${g}`)}
|
||||
</div>
|
||||
<ul className="list-disc ps-4 leading-snug">
|
||||
{list.map((p, idx) => {
|
||||
// Attendee names are stored as sanitized rich-text
|
||||
// HTML; strip every tag for the popup. If the input
|
||||
// is tag-only (e.g. "<p></p>") we render nothing
|
||||
// rather than fall back to the raw value, so HTML
|
||||
// can never appear here.
|
||||
const cleanName = p.name
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.trim();
|
||||
if (!cleanName) return null;
|
||||
return (
|
||||
<li key={`${g}-${p.id ?? idx}-${p.name}`}>
|
||||
{cleanName}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
id="alert-details-panel"
|
||||
@@ -1762,81 +1900,35 @@ function DetailsPanel({
|
||||
data-testid="alert-details-panel"
|
||||
dir={isRtl ? "rtl" : "ltr"}
|
||||
>
|
||||
{safeUrl ? (
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
<ExternalLink
|
||||
className="mt-0.5 h-3.5 w-3.5 shrink-0 opacity-70"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold opacity-80">
|
||||
{t("executiveMeetings.alert.detailsUrl")}
|
||||
</div>
|
||||
<a
|
||||
href={safeUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="block truncate underline hover:no-underline"
|
||||
data-testid="alert-details-url"
|
||||
>
|
||||
{safeUrl}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Users
|
||||
className="mt-0.5 h-3.5 w-3.5 shrink-0 opacity-70"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold opacity-80">
|
||||
{t("executiveMeetings.alert.detailsAttendees", {
|
||||
n: persons.length,
|
||||
})}
|
||||
</div>
|
||||
{persons.length === 0 ? (
|
||||
<div className="opacity-80" data-testid="alert-details-no-attendees">
|
||||
{t("executiveMeetings.alert.detailsNoAttendees")}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid items-start gap-x-3 gap-y-2"
|
||||
style={{ gridTemplateColumns: "auto 1fr" }}
|
||||
>
|
||||
{rows.map((row, idx) => (
|
||||
<Fragment key={row.key}>
|
||||
<div
|
||||
className="mt-1 max-h-40 overflow-y-auto pr-1"
|
||||
data-testid="alert-details-attendees"
|
||||
className="flex items-center gap-1.5 self-start whitespace-nowrap font-semibold opacity-80"
|
||||
style={{
|
||||
paddingTop: idx === 0 ? 0 : 6,
|
||||
borderTop:
|
||||
idx === 0 ? "none" : `1px solid ${dividerColor}`,
|
||||
}}
|
||||
>
|
||||
{(["internal", "external", "virtual"] as const).map((g) => {
|
||||
const list = groups[g];
|
||||
if (list.length === 0) return null;
|
||||
return (
|
||||
<div key={g} className="mb-1.5 last:mb-0">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide opacity-60">
|
||||
{t(`executiveMeetings.alert.detailsGroup.${g}`)}
|
||||
</div>
|
||||
<ul className="list-disc ps-4 leading-snug">
|
||||
{list.map((p, idx) => {
|
||||
// Attendee names are stored as sanitized rich-text
|
||||
// HTML; strip every tag for the popup. If the input
|
||||
// is tag-only (e.g. "<p></p>") we render nothing
|
||||
// rather than fall back to the raw value, so HTML
|
||||
// can never appear here.
|
||||
const cleanName = p.name
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.trim();
|
||||
if (!cleanName) return null;
|
||||
return (
|
||||
<li key={`${g}-${p.id ?? idx}-${p.name}`}>
|
||||
{cleanName}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{row.icon}
|
||||
<span>{row.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="min-w-0 self-start text-start"
|
||||
style={{
|
||||
paddingTop: idx === 0 ? 0 : 6,
|
||||
borderTop:
|
||||
idx === 0 ? "none" : `1px solid ${dividerColor}`,
|
||||
}}
|
||||
>
|
||||
{row.value}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1054,7 +1054,12 @@
|
||||
},
|
||||
"alert": {
|
||||
"title": "اجتماع يبدأ قريبًا",
|
||||
"minutesAway": "يبدأ خلال {{n}} دقيقة",
|
||||
"minutesAway_zero": "يبدأ الآن",
|
||||
"minutesAway_one": "يبدأ خلال دقيقة",
|
||||
"minutesAway_two": "يبدأ خلال دقيقتين",
|
||||
"minutesAway_few": "يبدأ خلال {{count}} دقائق",
|
||||
"minutesAway_many": "يبدأ خلال {{count}} دقيقة",
|
||||
"minutesAway_other": "يبدأ خلال {{count}} دقيقة",
|
||||
"startsNow": "يبدأ الآن",
|
||||
"atTime": "في {{time}}",
|
||||
"done": "تم",
|
||||
@@ -1067,6 +1072,8 @@
|
||||
"details": "تفاصيل",
|
||||
"detailsClose": "إخفاء التفاصيل",
|
||||
"detailsUrl": "رابط الاجتماع",
|
||||
"detailsTime": "الوقت",
|
||||
"detailsLocation": "المكان",
|
||||
"detailsAttendees": "الحضور ({{n}})",
|
||||
"detailsNoAttendees": "لا يوجد حضور بعد.",
|
||||
"detailsGroup": {
|
||||
@@ -1079,11 +1086,25 @@
|
||||
"tabPostpone": "تأجيل",
|
||||
"tabReschedule": "إعادة الجدولة",
|
||||
"tabCancel": "إلغاء",
|
||||
"postponeMinutesHint": "ستتم إزاحة وقتي البداية والنهاية بنفس المقدار.",
|
||||
"postponeMinutesPlaceholder": "دقائق",
|
||||
"postponeMinutesApply": "تأجيل {{n}} دقيقة",
|
||||
"postponeConfirmPrompt": "هل تريد تأجيل الاجتماع {{n}} دقيقة؟",
|
||||
"postponeConfirmYes": "نعم، أجّل {{n}} دقيقة",
|
||||
"postponeMinutesApply_zero": "تأجيل",
|
||||
"postponeMinutesApply_one": "تأجيل دقيقة",
|
||||
"postponeMinutesApply_two": "تأجيل دقيقتين",
|
||||
"postponeMinutesApply_few": "تأجيل {{count}} دقائق",
|
||||
"postponeMinutesApply_many": "تأجيل {{count}} دقيقة",
|
||||
"postponeMinutesApply_other": "تأجيل {{count}} دقيقة",
|
||||
"postponeConfirmPrompt_zero": "هل تريد تأجيل الاجتماع؟",
|
||||
"postponeConfirmPrompt_one": "هل تريد تأجيل الاجتماع دقيقة واحدة؟",
|
||||
"postponeConfirmPrompt_two": "هل تريد تأجيل الاجتماع دقيقتين؟",
|
||||
"postponeConfirmPrompt_few": "هل تريد تأجيل الاجتماع {{count}} دقائق؟",
|
||||
"postponeConfirmPrompt_many": "هل تريد تأجيل الاجتماع {{count}} دقيقة؟",
|
||||
"postponeConfirmPrompt_other": "هل تريد تأجيل الاجتماع {{count}} دقيقة؟",
|
||||
"postponeConfirmYes_zero": "نعم، أجّل",
|
||||
"postponeConfirmYes_one": "نعم، أجّل دقيقة",
|
||||
"postponeConfirmYes_two": "نعم، أجّل دقيقتين",
|
||||
"postponeConfirmYes_few": "نعم، أجّل {{count}} دقائق",
|
||||
"postponeConfirmYes_many": "نعم، أجّل {{count}} دقيقة",
|
||||
"postponeConfirmYes_other": "نعم، أجّل {{count}} دقيقة",
|
||||
"rescheduleHint": "نقل الاجتماع إلى تاريخ أو وقت جديد.",
|
||||
"rescheduleDate": "التاريخ",
|
||||
"rescheduleStart": "البداية",
|
||||
@@ -1102,14 +1123,25 @@
|
||||
"staleMeetingTitle": "تم تعديل هذا الاجتماع للتو من قبل مستخدم آخر.",
|
||||
"staleMeetingByUser": "{{name}} عدّل هذا الاجتماع للتو.",
|
||||
"staleMeetingCurrentTime": "الوقت الحالي: {{start}} – {{end}}.",
|
||||
"staleMeetingApplyAnyway": "أضف {{n}} دقيقة إضافية على أي حال",
|
||||
"staleMeetingApplyAnyway_zero": "أضف وقتاً إضافياً على أي حال",
|
||||
"staleMeetingApplyAnyway_one": "أضف دقيقة إضافية على أي حال",
|
||||
"staleMeetingApplyAnyway_two": "أضف دقيقتين إضافيتين على أي حال",
|
||||
"staleMeetingApplyAnyway_few": "أضف {{count}} دقائق إضافية على أي حال",
|
||||
"staleMeetingApplyAnyway_many": "أضف {{count}} دقيقة إضافية على أي حال",
|
||||
"staleMeetingApplyAnyway_other": "أضف {{count}} دقيقة إضافية على أي حال",
|
||||
"cascadeLoading": "جارٍ التحقق من الاجتماعات اللاحقة…",
|
||||
"cascadeMinutesUnit_zero": "بدون دقائق",
|
||||
"cascadeMinutesUnit_one": "دقيقة واحدة",
|
||||
"cascadeMinutesUnit_two": "دقيقتين",
|
||||
"cascadeMinutesUnit_few": "{{count}} دقائق",
|
||||
"cascadeMinutesUnit_many": "{{count}} دقيقة",
|
||||
"cascadeMinutesUnit_other": "{{count}} دقيقة",
|
||||
"cascadePromptHeader_zero": "لا توجد اجتماعات لاحقة في هذا اليوم.",
|
||||
"cascadePromptHeader_one": "سيتأثر اجتماع واحد لاحق في هذا اليوم. هل تريد تأجيله أيضاً بـ {{minutes}} دقيقة؟",
|
||||
"cascadePromptHeader_two": "سيتأثر اجتماعان لاحقان في هذا اليوم. هل تريد تأجيلهما أيضاً بـ {{minutes}} دقيقة؟",
|
||||
"cascadePromptHeader_few": "سيتأثر {{count}} اجتماعات لاحقة في هذا اليوم. هل تريد تأجيلها أيضاً بـ {{minutes}} دقيقة؟",
|
||||
"cascadePromptHeader_many": "سيتأثر {{count}} اجتماعاً لاحقاً في هذا اليوم. هل تريد تأجيلها أيضاً بـ {{minutes}} دقيقة؟",
|
||||
"cascadePromptHeader_other": "سيتأثر {{count}} اجتماع لاحق في هذا اليوم. هل تريد تأجيلها أيضاً بـ {{minutes}} دقيقة؟",
|
||||
"cascadePromptHeader_one": "سيتأثر اجتماع واحد لاحق في هذا اليوم. هل تريد تأجيله أيضاً بـ {{minutesText}}؟",
|
||||
"cascadePromptHeader_two": "سيتأثر اجتماعان لاحقان في هذا اليوم. هل تريد تأجيلهما أيضاً بـ {{minutesText}}؟",
|
||||
"cascadePromptHeader_few": "سيتأثر {{count}} اجتماعات لاحقة في هذا اليوم. هل تريد تأجيلها أيضاً بـ {{minutesText}}؟",
|
||||
"cascadePromptHeader_many": "سيتأثر {{count}} اجتماعاً لاحقاً في هذا اليوم. هل تريد تأجيلها أيضاً بـ {{minutesText}}؟",
|
||||
"cascadePromptHeader_other": "سيتأثر {{count}} اجتماع لاحق في هذا اليوم. هل تريد تأجيلها أيضاً بـ {{minutesText}}؟",
|
||||
"cascadeListTitle": "الاجتماعات المتأثرة:",
|
||||
"cascadeListItem": "{{title}} — {{oldStart}} ← {{newStart}}",
|
||||
"cascadeShift_zero": "تأجيل الاجتماعات اللاحقة",
|
||||
|
||||
@@ -972,7 +972,8 @@
|
||||
},
|
||||
"alert": {
|
||||
"title": "Meeting starting soon",
|
||||
"minutesAway": "Starts in {{n}} min",
|
||||
"minutesAway_one": "Starts in {{count}} min",
|
||||
"minutesAway_other": "Starts in {{count}} min",
|
||||
"startsNow": "Starting now",
|
||||
"atTime": "at {{time}}",
|
||||
"done": "Done",
|
||||
@@ -985,6 +986,8 @@
|
||||
"details": "Details",
|
||||
"detailsClose": "Hide details",
|
||||
"detailsUrl": "Meeting link",
|
||||
"detailsTime": "Time",
|
||||
"detailsLocation": "Location",
|
||||
"detailsAttendees": "Attendees ({{n}})",
|
||||
"detailsNoAttendees": "No attendees yet.",
|
||||
"detailsGroup": {
|
||||
@@ -997,11 +1000,10 @@
|
||||
"tabPostpone": "Postpone",
|
||||
"tabReschedule": "Reschedule",
|
||||
"tabCancel": "Cancel",
|
||||
"postponeMinutesHint": "Shift both start and end by the same amount.",
|
||||
"postponeMinutesPlaceholder": "min",
|
||||
"postponeMinutesApply": "Postpone {{n}} min",
|
||||
"postponeConfirmPrompt": "Postpone meeting by {{n}} min?",
|
||||
"postponeConfirmYes": "Yes, postpone {{n}} min",
|
||||
"postponeMinutesApply": "Postpone {{count}} min",
|
||||
"postponeConfirmPrompt": "Postpone meeting by {{count}} min?",
|
||||
"postponeConfirmYes": "Yes, postpone {{count}} min",
|
||||
"rescheduleHint": "Move the meeting to a new date or time window.",
|
||||
"rescheduleDate": "Date",
|
||||
"rescheduleStart": "Start",
|
||||
@@ -1020,10 +1022,13 @@
|
||||
"staleMeetingTitle": "This meeting was just changed by another user.",
|
||||
"staleMeetingByUser": "{{name}} just changed this meeting.",
|
||||
"staleMeetingCurrentTime": "Current time: {{start}} – {{end}}.",
|
||||
"staleMeetingApplyAnyway": "Add {{n}} more minutes anyway",
|
||||
"staleMeetingApplyAnyway_one": "Add {{count}} more minute anyway",
|
||||
"staleMeetingApplyAnyway_other": "Add {{count}} more minutes anyway",
|
||||
"cascadeLoading": "Checking later meetings…",
|
||||
"cascadePromptHeader_one": "{{count}} later meeting on this day would be affected. Also shift it by {{minutes}} minutes?",
|
||||
"cascadePromptHeader_other": "{{count}} later meetings on this day would be affected. Also shift them by {{minutes}} minutes?",
|
||||
"cascadeMinutesUnit_one": "{{count}} minute",
|
||||
"cascadeMinutesUnit_other": "{{count}} minutes",
|
||||
"cascadePromptHeader_one": "{{count}} later meeting on this day would be affected. Also shift it by {{minutesText}}?",
|
||||
"cascadePromptHeader_other": "{{count}} later meetings on this day would be affected. Also shift them by {{minutesText}}?",
|
||||
"cascadeListTitle": "Affected meetings:",
|
||||
"cascadeListItem": "{{title}} — {{oldStart}} → {{newStart}}",
|
||||
"cascadeShift_one": "Shift {{count}} following meeting",
|
||||
|
||||
Reference in New Issue
Block a user