fix: make the mini calendar a grid with arrow keys, full date names and event days in words

This commit is contained in:
Linus Rath
2026-10-29 17:53:52 +01:00
parent 693687dc0b
commit 19e0acde91
31 changed files with 349 additions and 97 deletions
@@ -0,0 +1,94 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, within } from '@testing-library/react';
import { useState } from 'react';
import { MiniCalendar } from '../mini-calendar';
import type { CalendarEvent } from '@/lib/jmap/types';
// SR-14: the mini calendar is a real grid, its days are named with the full
// date, event days are marked in words and the arrows move between days.
const event = {
id: 'e1',
'@type': 'Event',
title: 'Dentist',
start: '2026-10-29T10:00:00',
duration: 'PT1H',
showWithoutTime: false,
calendarIds: { c1: true },
} as unknown as CalendarEvent;
function Harness({ onSelectDate = vi.fn(), onChangeMonth }: { onSelectDate?: (d: Date) => void; onChangeMonth?: (d: Date) => void }) {
const [month, setMonth] = useState(new Date(2026, 9, 1));
return (
<MiniCalendar
selectedDate={new Date(2026, 9, 15)}
displayMonth={month}
onSelectDate={onSelectDate}
onChangeMonth={(d) => { onChangeMonth?.(d); setMonth(new Date(d.getFullYear(), d.getMonth(), 1)); }}
events={[event]}
showWeekNumbers
/>
);
}
const day = (name: RegExp) => screen.getByRole('button', { name });
describe('MiniCalendar', () => {
afterEach(cleanup);
it('is a grid of rows and cells with weekday column headers', () => {
render(<Harness />);
const grid = screen.getByRole('grid');
expect(within(grid).getAllByRole('columnheader').length).toBe(8);
expect(within(grid).getAllByRole('row').length).toBeGreaterThanOrEqual(5);
const cell = day(/October 15, 2026/).closest('[role="gridcell"]');
expect(cell).toHaveAttribute('aria-selected', 'true');
expect(within(grid).getAllByRole('rowheader', { name: /mini_week_number/ }).length).toBeGreaterThanOrEqual(5);
});
it('names each day with its full date and says which days have events', () => {
render(<Harness />);
expect(day(/^Thursday, October 29, 2026, mini_has_events$/)).toBeInTheDocument();
expect(day(/^Wednesday, October 28, 2026$/)).toBeInTheDocument();
});
it('is one tab stop, on the selected day', () => {
render(<Harness />);
const stops = screen.getByRole('grid').querySelectorAll('button[tabindex="0"]');
expect(stops).toHaveLength(1);
expect(stops[0]).toBe(day(/October 15, 2026/));
});
it('moves focus with the arrow keys without selecting', () => {
const onSelectDate = vi.fn();
render(<Harness onSelectDate={onSelectDate} />);
day(/October 15, 2026/).focus();
fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
expect(document.activeElement).toBe(day(/October 16, 2026/));
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
expect(document.activeElement).toBe(day(/October 23, 2026/));
expect(document.activeElement).toHaveAttribute('tabindex', '0');
expect(onSelectDate).not.toHaveBeenCalled();
fireEvent.click(document.activeElement!);
expect(onSelectDate).toHaveBeenCalledWith(new Date(2026, 9, 23));
});
it('turns the month when the arrows leave it and keeps focus on the new day', () => {
const onChangeMonth = vi.fn();
render(<Harness onChangeMonth={onChangeMonth} />);
day(/October 29, 2026/).focus();
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
expect(onChangeMonth).toHaveBeenCalled();
expect(screen.getByRole('grid')).toHaveAccessibleName(/November/);
expect(document.activeElement).toBe(day(/November 5, 2026/));
fireEvent.keyDown(document.activeElement!, { key: 'PageUp' });
expect(document.activeElement).toBe(day(/October 5, 2026/));
});
it('tells which month is selected in the month picker', () => {
render(<Harness />);
fireEvent.click(screen.getByRole('button', { name: /October 2026/ }));
expect(screen.getByRole('button', { name: 'months.oct' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'months.sep' })).toHaveAttribute('aria-pressed', 'false');
});
});
+168 -68
View File
@@ -1,10 +1,10 @@
"use client";
import { useState, useMemo, Fragment } from "react";
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { ChevronLeft, ChevronRight, ChevronDown } from "@/components/icons";
import {
addMonths, subMonths, addYears, subYears, setMonth, setYear,
addDays, addMonths, subMonths, addYears, subYears, setMonth, setYear,
getISOWeek, getWeek, format,
} from "date-fns";
import { cn } from "@/lib/utils";
@@ -12,6 +12,7 @@ import { getEventDayBounds } from "@/lib/calendar-utils";
import { displayNow } from "@/lib/timezone";
import type { CalendarEvent } from "@/lib/jmap/types";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
import { PluginDayLabel } from "./plugin-day-label";
import { IconButton } from "@/components/ui/icon-button";
@@ -27,6 +28,8 @@ interface MiniCalendarProps {
showWeekNumbers?: boolean;
}
const dayKey = (date: Date) => format(date, "yyyy-MM-dd");
export function MiniCalendar({
selectedDate,
displayMonth,
@@ -46,16 +49,23 @@ export function MiniCalendar({
checkIsSameDay,
formatDayNumber,
formatMonthYear,
formatFullDate,
getMonth,
getYear,
monthLabelKeys,
} = useCalendarLocale();
const intlFormatter = useDisplayDateFormatter();
const [pickerView, setPickerView] = useState<PickerView>("days");
const days = useMemo(
() => getMonthGridDays(displayMonth),
[displayMonth, getMonthGridDays],
);
const weeks = useMemo(() => {
const rows: Date[][] = [];
for (let i = 0; i < days.length; i += 7) rows.push(days.slice(i, i + 7));
return rows;
}, [days]);
const eventDates = useMemo(() => {
const set = new Set<string>();
@@ -64,7 +74,7 @@ export function MiniCalendar({
const { startDay, endDay } = getEventDayBounds(e);
const cursor = new Date(startDay);
while (cursor <= endDay) {
set.add(format(cursor, "yyyy-MM-dd"));
set.add(dayKey(cursor));
cursor.setDate(cursor.getDate() + 1);
}
} catch { /* skip */ }
@@ -72,16 +82,67 @@ export function MiniCalendar({
return set;
}, [events]);
// Compute week numbers for each row (one per 7-day chunk)
const weekNumbers = useMemo(() => {
if (!showWeekNumbers) return [];
const nums: number[] = [];
for (let i = 0; i < days.length; i += 7) {
// Use the first day of each row to determine the week number
nums.push(weekStartsOn === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
const weekNumberOf = (day: Date) =>
weekStartsOn === 1 ? getISOWeek(day) : getWeek(day, { weekStartsOn: 0 });
// The grid is one tab stop (WCAG 2.1.1, ARIA grid pattern): the day the
// arrows last moved to, else the selected day, today or the 1st.
const [focusedKey, setFocusedKey] = useState<string | null>(null);
const pendingFocus = useRef<string | null>(null);
const gridRef = useRef<HTMLTableElement>(null);
const tabStopKey = useMemo(() => {
const inMonth = (day: Date) => checkIsSameMonth(day, displayMonth);
const focused = focusedKey ? days.find((day) => dayKey(day) === focusedKey) : undefined;
if (focused && inMonth(focused)) return focusedKey!;
if (inMonth(selectedDate)) return dayKey(selectedDate);
const today = days.find((day) => inMonth(day) && checkIsToday(day));
if (today) return dayKey(today);
const first = days.find(inMonth);
return first ? dayKey(first) : null;
}, [focusedKey, days, displayMonth, selectedDate, checkIsSameMonth, checkIsToday]);
const dayButton = (key: string) =>
gridRef.current?.querySelector<HTMLButtonElement>(`[data-mini-day="${key}"]`) ?? null;
// After moving into another month, focus the day once that month is drawn.
useEffect(() => {
if (!pendingFocus.current) return;
const el = dayButton(pendingFocus.current);
pendingFocus.current = null;
el?.focus();
}, [days]);
const moveFocus = useCallback((date: Date) => {
const key = dayKey(date);
setFocusedKey(key);
if (checkIsSameMonth(date, displayMonth)) {
dayButton(key)?.focus();
} else {
pendingFocus.current = key;
onChangeMonth(date);
}
return nums;
}, [days, showWeekNumbers, weekStartsOn]);
}, [checkIsSameMonth, displayMonth, onChangeMonth]);
const handleDayKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, day: Date, column: number) => {
if (e.altKey || e.ctrlKey || e.metaKey) return;
const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
let next: Date | null = null;
switch (e.key) {
case "ArrowLeft": next = addDays(day, rtl ? 1 : -1); break;
case "ArrowRight": next = addDays(day, rtl ? -1 : 1); break;
case "ArrowUp": next = addDays(day, -7); break;
case "ArrowDown": next = addDays(day, 7); break;
case "Home": next = addDays(day, -column); break;
case "End": next = addDays(day, 6 - column); break;
case "PageUp": next = e.shiftKey ? subYears(day, 1) : subMonths(day, 1); break;
case "PageDown": next = e.shiftKey ? addYears(day, 1) : addMonths(day, 1); break;
default: return;
}
// The grid's keys, not the calendar's previous/next period.
e.preventDefault();
e.stopPropagation();
moveFocus(next);
};
const currentYear = getYear(displayMonth);
const currentMonth = getMonth(displayMonth);
@@ -120,7 +181,7 @@ export function MiniCalendar({
? formatMonthYear(displayMonth)
: pickerView === "months"
? String(currentYear)
: `${decadeStart}\u2013${decadeStart + 9}`;
: `${decadeStart}–${decadeStart + 9}`;
return (
<div className="select-none">
@@ -136,6 +197,7 @@ export function MiniCalendar({
onClick={handleHeaderClick}
disabled={pickerView === "years"}
title={pickerView !== "years" ? t("mini_calendar_change") : undefined}
aria-live="polite"
className={cn(
"text-sm font-medium px-2 py-1 rounded-md transition-colors inline-flex items-center gap-1",
pickerView !== "years" && "hover:bg-muted cursor-pointer",
@@ -144,7 +206,7 @@ export function MiniCalendar({
>
{headerLabel}
{pickerView !== "years" && (
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
<ChevronDown aria-hidden="true" className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
<IconButton
@@ -157,71 +219,106 @@ export function MiniCalendar({
</div>
{pickerView === "days" && (
<div className={cn("grid gap-0", showWeekNumbers ? "grid-cols-[auto_repeat(7,1fr)]" : "grid-cols-7")}>
{showWeekNumbers && (
<div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" />
)}
{dayHeaderKeys.map((d) => (
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">
{t(`days.${d}`)}
</div>
))}
{days.map((day, index) => {
const inMonth = checkIsSameMonth(day, displayMonth);
const selected = checkIsSameDay(day, selectedDate);
const today = checkIsToday(day);
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
const isFirstDayOfRow = index % 7 === 0;
return (
<Fragment key={day.toISOString()}>
{showWeekNumbers && isFirstDayOfRow && (
<div
key={`wk-${index}`}
className="flex items-center justify-center w-5 text-[9px] text-muted-foreground/60 font-medium"
>
{weekNumbers[index / 7]}
</div>
)}
<button
key={`day-${day.toISOString()}`}
onClick={() => onSelectDate(day)}
className="group/day flex items-center justify-center w-7 h-7 pointer-coarse:w-full pointer-coarse:h-11 text-xs"
aria-current={today ? "date" : undefined}
aria-pressed={selected}
// A real table grid: screen readers read the weekday with each day
// and move by row and column; the arrows move between days.
<table
ref={gridRef}
role="grid"
aria-label={formatMonthYear(displayMonth)}
className="w-full border-collapse table-fixed"
>
<thead>
<tr>
{showWeekNumbers && (
<th scope="col" className="w-5 p-0">
<span className="sr-only">{t("views.week")}</span>
</th>
)}
{dayHeaderKeys.map((d, index) => (
<th
key={d}
scope="col"
abbr={weeks[0]?.[index] ? intlFormatter.dateTime(weeks[0][index], { weekday: "long" }) : undefined}
className="text-center text-[10px] font-medium text-muted-foreground py-1"
>
<span
className={cn(
"relative flex items-center justify-center w-7 h-7 rounded-full transition-colors",
!inMonth && "text-muted-foreground/40",
// Today is the filled circle; the selected day is tinted.
today && "bg-primary text-primary-foreground font-semibold",
selected && !today && "bg-primary/15 text-primary font-semibold",
!today && !selected && "group-hover/day:bg-muted"
)}
{t(`days.${d}`)}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((week) => (
<tr key={dayKey(week[0])}>
{showWeekNumbers && (
<th
scope="row"
className="w-5 p-0 text-center text-[9px] text-muted-foreground/60 font-medium"
>
{formatDayNumber(day)}
{hasEvent && !selected && !today && (
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
)}
<PluginDayLabel surface="mini-calendar-marks" date={format(day, "yyyy-MM-dd")} />
</span>
</button>
</Fragment>
);
})}
</div>
<span aria-hidden="true">{weekNumberOf(week[0])}</span>
<span className="sr-only">{t("mini_week_number", { week: weekNumberOf(week[0]) })}</span>
</th>
)}
{week.map((day, column) => {
const key = dayKey(day);
const inMonth = checkIsSameMonth(day, displayMonth);
const selected = checkIsSameDay(day, selectedDate);
const today = checkIsToday(day);
const hasEvent = eventDates.has(key);
// "Wednesday, October 29, 2026, has events": the full date,
// and events in words, not only as a dot.
const label = hasEvent ? `${formatFullDate(day)}, ${t("mini_has_events")}` : formatFullDate(day);
return (
<td key={key} role="gridcell" aria-selected={selected} className="p-0 text-center">
<button
type="button"
data-mini-day={key}
tabIndex={key === tabStopKey ? 0 : -1}
onClick={() => { setFocusedKey(key); onSelectDate(day); }}
onKeyDown={(e) => handleDayKeyDown(e, day, column)}
className="group/day inline-flex items-center justify-center w-7 h-7 pointer-coarse:w-full pointer-coarse:h-11 text-xs"
aria-current={today ? "date" : undefined}
>
<span
className={cn(
"relative flex items-center justify-center w-7 h-7 rounded-full transition-colors",
!inMonth && "text-muted-foreground/40",
// Today is the filled circle; the selected day is tinted.
today && "bg-primary text-primary-foreground font-semibold",
selected && !today && "bg-primary/15 text-primary font-semibold",
!today && !selected && "group-hover/day:bg-muted"
)}
>
{/* Named by the hidden full date, not the bare number;
a plugin's mark below adds its own words. */}
<span className="sr-only">{label}</span>
<span aria-hidden="true">{formatDayNumber(day)}</span>
{hasEvent && !selected && !today && (
<span aria-hidden="true" className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
)}
<PluginDayLabel surface="mini-calendar-marks" date={key} />
</span>
</button>
</td>
);
})}
</tr>
))}
</tbody>
</table>
)}
{pickerView === "months" && (
<div className="grid grid-cols-3 gap-1 py-1">
<div className="grid grid-cols-3 gap-1 py-1" role="group" aria-label={String(currentYear)}>
{monthLabelKeys.map((labelKey, i) => {
const isCurrentMonth = i === currentMonth && currentYear === getYear(displayNow());
const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate);
return (
<button
key={i}
type="button"
onClick={() => handlePickMonth(i)}
aria-pressed={isSelected}
aria-current={isCurrentMonth ? "date" : undefined}
className={cn(
"py-2 text-xs rounded-md transition-colors",
isSelected && "bg-primary text-primary-foreground",
@@ -237,7 +334,7 @@ export function MiniCalendar({
)}
{pickerView === "years" && (
<div className="grid grid-cols-3 gap-1 py-1">
<div className="grid grid-cols-3 gap-1 py-1" role="group" aria-label={headerLabel}>
{years.map((year) => {
const inDecade = year >= decadeStart && year <= decadeStart + 9;
const isCurrentYear = year === getYear(displayNow());
@@ -245,7 +342,10 @@ export function MiniCalendar({
return (
<button
key={year}
type="button"
onClick={() => handlePickYear(year)}
aria-pressed={isSelected}
aria-current={isCurrentYear ? "date" : undefined}
className={cn(
"py-2 text-xs rounded-md transition-colors",
isSelected && "bg-primary text-primary-foreground",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "يبحث في العناوين والأماكن والأوصاف والضيوف.",
"repeats": "متكرر",
"results": "{count, plural, zero {لم يُعثر على أي حدث} one {تم العثور على حدث واحد} two {تم العثور على حدثين} few {تم العثور على # أحداث} many {تم العثور على # حدثًا} other {تم العثور على # حدث}}"
}
},
"mini_has_events": "فيه أحداث",
"mini_week_number": "الأسبوع {week}"
},
"calendar_event_notifications": {
"someone": "شخص ما",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "La cerca mira títols, llocs, descripcions i convidats.",
"repeats": "Es repeteix",
"results": "{count, plural, one {# esdeveniment trobat} other {# esdeveniments trobats}}"
}
},
"mini_has_events": "té esdeveniments",
"mini_week_number": "Setmana {week}"
},
"calendar_event_notifications": {
"someone": "Algú",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "Hledá se v názvech, místech, popisech a hostech.",
"repeats": "Opakuje se",
"results": "{count, plural, one {Nalezena # událost} few {Nalezeny # události} many {Nalezeno # události} other {Nalezeno # událostí}}"
}
},
"mini_has_events": "má události",
"mini_week_number": "Týden {week}"
},
"advanced_search": {
"title": "Pokročilé hledání",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "Søgningen ser på titler, steder, beskrivelser og gæster.",
"repeats": "Gentages",
"results": "{count, plural, one {# begivenhed fundet} other {# begivenheder fundet}}"
}
},
"mini_has_events": "har begivenheder",
"mini_week_number": "Uge {week}"
},
"calendar_event_notifications": {
"someone": "Nogen",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "Gesucht wird in Titeln, Orten, Beschreibungen und Gästen.",
"repeats": "Wiederholt sich",
"results": "{count, plural, one {# Termin gefunden} other {# Termine gefunden}}"
}
},
"mini_has_events": "hat Termine",
"mini_week_number": "Woche {week}"
},
"advanced_search": {
"title": "Erweiterte Suche",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "Search looks at titles, places, descriptions and guests.",
"repeats": "Repeats",
"results": "{count, plural, one {# event found} other {# events found}}"
}
},
"mini_has_events": "has events",
"mini_week_number": "Week {week}"
},
"calendar_event_notifications": {
"someone": "Someone",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "La búsqueda mira títulos, lugares, descripciones e invitados.",
"repeats": "Se repite",
"results": "{count, plural, one {# evento encontrado} other {# eventos encontrados}}"
}
},
"mini_has_events": "tiene eventos",
"mini_week_number": "Semana {week}"
},
"advanced_search": {
"title": "Búsqueda avanzada",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "Otsitakse pealkirjadest, kohtadest, kirjeldustest ja külalistest.",
"repeats": "Kordub",
"results": "{count, plural, one {Leiti # sündmus} other {Leiti # sündmust}}"
}
},
"mini_has_events": "on sündmusi",
"mini_week_number": "Nädal {week}"
},
"calendar_event_notifications": {
"someone": "Keegi",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "جستجو در عنوان‌ها، مکان‌ها، توضیحات و مهمان‌ها انجام می‌شود.",
"repeats": "تکرار می‌شود",
"results": "{count, plural, one {# رویداد پیدا شد} other {# رویداد پیدا شد}}"
}
},
"mini_has_events": "رویداد دارد",
"mini_week_number": "هفته {week}"
},
"calendar_event_notifications": {
"someone": "کسی",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "La recherche porte sur les titres, lieux, descriptions et invités.",
"repeats": "Se répète",
"results": "{count, plural, one {# événement trouvé} other {# événements trouvés}}"
}
},
"mini_has_events": "a des événements",
"mini_week_number": "Semaine {week}"
},
"advanced_search": {
"title": "Recherche avancée",
+3 -1
View File
@@ -4621,7 +4621,9 @@
"no_results_hint": "החיפוש בודק כותרות, מקומות, תיאורים ומוזמנים.",
"repeats": "חוזר",
"results": "{count, plural, one {נמצא אירוע אחד} two {נמצאו שני אירועים} other {נמצאו # אירועים}}"
}
},
"mini_has_events": "יש אירועים",
"mini_week_number": "שבוע {week}"
},
"advanced_search": {
"title": "חיפוש מתקדם",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "A keresés a címekben, helyszínekben, leírásokban és vendégekben keres.",
"repeats": "Ismétlődik",
"results": "{count, plural, one {# esemény található} other {# esemény található}}"
}
},
"mini_has_events": "vannak események",
"mini_week_number": "{week}. hét"
},
"calendar_event_notifications": {
"someone": "Valaki",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "La ricerca guarda titoli, luoghi, descrizioni e invitati.",
"repeats": "Si ripete",
"results": "{count, plural, one {# evento trovato} other {# eventi trovati}}"
}
},
"mini_has_events": "ha eventi",
"mini_week_number": "Settimana {week}"
},
"advanced_search": {
"title": "Ricerca avanzata",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "タイトル、場所、説明、ゲストを検索します。",
"repeats": "繰り返し",
"results": "{count, plural, other {# 件の予定が見つかりました}}"
}
},
"mini_has_events": "イベントあり",
"mini_week_number": "第{week}週"
},
"advanced_search": {
"title": "詳細検索",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "제목, 장소, 설명, 참석자에서 검색합니다.",
"repeats": "반복",
"results": "{count, plural, other {일정 #개를 찾았습니다}}"
}
},
"mini_has_events": "일정 있음",
"mini_week_number": "{week}주차"
},
"advanced_search": {
"title": "상세 검색",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "Meklē nosaukumos, vietās, aprakstos un viesos.",
"repeats": "Atkārtojas",
"results": "{count, plural, zero {Atrasti # notikumi} one {Atrasts # notikums} other {Atrasti # notikumi}}"
}
},
"mini_has_events": "ir notikumi",
"mini_week_number": "{week}. nedēļa"
},
"advanced_search": {
"title": "Izvērstā meklēšana",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "Гарчиг, газар, тайлбар, зочдоос хайна.",
"repeats": "Давтагдана",
"results": "{count, plural, one {# үйл явдал олдлоо} other {# үйл явдал олдлоо}}"
}
},
"mini_has_events": "үйл явдалтай",
"mini_week_number": "{week}-р долоо хоног"
},
"calendar_event_notifications": {
"someone": "Хэн нэгэн",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "Søket ser i titler, steder, beskrivelser og gjester.",
"repeats": "Gjentas",
"results": "{count, plural, one {# hendelse funnet} other {# hendelser funnet}}"
}
},
"mini_has_events": "har hendelser",
"mini_week_number": "Uke {week}"
},
"calendar_event_notifications": {
"someone": "Noen",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "Er wordt gezocht in titels, locaties, beschrijvingen en gasten.",
"repeats": "Herhaalt",
"results": "{count, plural, one {# afspraak gevonden} other {# afspraken gevonden}}"
}
},
"mini_has_events": "heeft afspraken",
"mini_week_number": "Week {week}"
},
"advanced_search": {
"title": "Geavanceerd zoeken",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "Wyszukiwanie obejmuje tytuły, miejsca, opisy i gości.",
"repeats": "Powtarza się",
"results": "{count, plural, one {Znaleziono # wydarzenie} few {Znaleziono # wydarzenia} many {Znaleziono # wydarzeń} other {Znaleziono # wydarzenia}}"
}
},
"mini_has_events": "ma wydarzenia",
"mini_week_number": "Tydzień {week}"
},
"advanced_search": {
"title": "Wyszukiwanie zaawansowane",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "A pesquisa procura em títulos, locais, descrições e convidados.",
"repeats": "Repete-se",
"results": "{count, plural, one {# evento encontrado} other {# eventos encontrados}}"
}
},
"mini_has_events": "tem eventos",
"mini_week_number": "Semana {week}"
},
"advanced_search": {
"title": "Pesquisa avançada",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "Căutarea se uită la titluri, locuri, descrieri și invitați.",
"repeats": "Se repetă",
"results": "{count, plural, one {# eveniment găsit} few {# evenimente găsite} other {# de evenimente găsite}}"
}
},
"mini_has_events": "are evenimente",
"mini_week_number": "Săptămâna {week}"
},
"calendar_event_notifications": {
"someone": "Cineva",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "Поиск идёт по названиям, местам, описаниям и гостям.",
"repeats": "Повторяется",
"results": "{count, plural, one {Найдено # событие} few {Найдено # события} many {Найдено # событий} other {Найдено # события}}"
}
},
"mini_has_events": "есть события",
"mini_week_number": "Неделя {week}"
},
"advanced_search": {
"title": "Расширенный поиск",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "Hľadá sa v názvoch, miestach, popisoch a hosťoch.",
"repeats": "Opakuje sa",
"results": "{count, plural, one {Nájdená # udalosť} few {Nájdené # udalosti} many {Nájdenej # udalosti} other {Nájdených # udalostí}}"
}
},
"mini_has_events": "má udalosti",
"mini_week_number": "Týždeň {week}"
},
"calendar_event_notifications": {
"someone": "Niekto",
+3 -1
View File
@@ -3653,7 +3653,9 @@
"no_results_hint": "ค้นหาจากชื่อ สถานที่ คำอธิบาย และแขก",
"repeats": "เกิดซ้ำ",
"results": "{count, plural, other {พบ # กิจกรรม}}"
}
},
"mini_has_events": "มีกิจกรรม",
"mini_week_number": "สัปดาห์ที่ {week}"
},
"settings": {
"title": "การตั้งค่า",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "Arama başlıklarda, yerlerde, açıklamalarda ve davetlilerde yapılır.",
"repeats": "Tekrarlanıyor",
"results": "{count, plural, one {# etkinlik bulundu} other {# etkinlik bulundu}}"
}
},
"mini_has_events": "etkinlik var",
"mini_week_number": "{week}. hafta"
},
"calendar_event_notifications": {
"someone": "Birisi",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "Пошук іде за назвами, місцями, описами й гостями.",
"repeats": "Повторюється",
"results": "{count, plural, one {Знайдено # подію} few {Знайдено # події} many {Знайдено # подій} other {Знайдено # події}}"
}
},
"mini_has_events": "є події",
"mini_week_number": "Тиждень {week}"
},
"advanced_search": {
"title": "Розширений пошук",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "搜尋範圍包括標題、地點、說明和邀請對象。",
"repeats": "重複",
"results": "{count, plural, other {找到 # 個活動}}"
}
},
"mini_has_events": "有活動",
"mini_week_number": "第 {week} 週"
},
"calendar_event_notifications": {
"someone": "有人",
+3 -1
View File
@@ -4703,7 +4703,9 @@
"no_results_hint": "搜索范围包括标题、地点、描述和邀请对象。",
"repeats": "重复",
"results": "{count, plural, other {找到 # 个活动}}"
}
},
"mini_has_events": "有事件",
"mini_week_number": "第 {week} 周"
},
"advanced_search": {
"title": "高级搜索",