From f53cee7ec99c922e4bd3e6c8d5da8532b5e6c8ee Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:53:40 +0200 Subject: [PATCH] feat(calendar): lay out the agenda as a schedule with dates on the left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agenda showed each day as a grey header bar above its events. It now reads like a schedule: the weekday and date sit in a column on the left of the day's first event (today in a filled circle), a heading marks where each month starts and a quiet line names each week's range. An empty today says "Nothing planned. Tap to create." and tapping it opens a new event. In the drawer the view is now called Schedule. Events that fill a whole day are listed as all day instead of "00:00 – 00:00". --- locales/rn/ar.json | 1 + locales/rn/ca.json | 1 + locales/rn/cs.json | 1 + locales/rn/da.json | 1 + locales/rn/de.json | 1 + locales/rn/en.json | 1 + locales/rn/es.json | 1 + locales/rn/fa.json | 1 + locales/rn/fr.json | 1 + locales/rn/he.json | 1 + locales/rn/hu.json | 1 + locales/rn/it.json | 1 + locales/rn/ja.json | 1 + locales/rn/ko.json | 1 + locales/rn/lv.json | 1 + locales/rn/mn.json | 1 + locales/rn/nb.json | 1 + locales/rn/nl.json | 1 + locales/rn/pl.json | 1 + locales/rn/pt.json | 1 + locales/rn/ro.json | 1 + locales/rn/ru.json | 1 + locales/rn/sk.json | 1 + locales/rn/tr.json | 1 + locales/rn/uk.json | 1 + locales/rn/zh-TW.json | 1 + locales/rn/zh.json | 1 + src/components/calendar/AgendaView.tsx | 190 +++++++++++++++++-------- src/components/calendar/EventCard.tsx | 13 +- src/screens/CalendarScreen.tsx | 2 + 30 files changed, 171 insertions(+), 61 deletions(-) diff --git a/locales/rn/ar.json b/locales/rn/ar.json index 3959d574..2f8c9ecf 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "رابط", "export_ics_short": ".ics", + "nothing_planned": "لا شيء مخطط. انقر للإنشاء.", "tap_to_create": "اضغط + لإنشاء حدث" }, "fab": { diff --git a/locales/rn/ca.json b/locales/rn/ca.json index 56fee5a3..ef93de9b 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Enllaç", "export_ics_short": ".ics", + "nothing_planned": "Res planificat. Toca per crear.", "tap_to_create": "Toqueu + per crear-ne un" }, "fab": { diff --git a/locales/rn/cs.json b/locales/rn/cs.json index 58583cbe..7a91b5fe 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Odkaz", "export_ics_short": ".ics", + "nothing_planned": "Nic naplánováno. Klepnutím vytvoříte.", "tap_to_create": "Událost vytvoříte klepnutím na +" }, "fab": { diff --git a/locales/rn/da.json b/locales/rn/da.json index 662eb608..756977c8 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Link", "export_ics_short": ".ics", + "nothing_planned": "Intet planlagt. Tryk for at oprette.", "tap_to_create": "Tryk på + for at oprette en" }, "fab": { diff --git a/locales/rn/de.json b/locales/rn/de.json index 58fcba37..2032f8d8 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Link", "export_ics_short": ".ics", + "nothing_planned": "Nichts geplant. Zum Erstellen tippen.", "tap_to_create": "Tippen Sie auf +, um einen zu erstellen" }, "fab": { diff --git a/locales/rn/en.json b/locales/rn/en.json index eebec322..e31712a4 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Link", "export_ics_short": ".ics", + "nothing_planned": "Nothing planned. Tap to create.", "tap_to_create": "Tap + to create one" }, "fab": { diff --git a/locales/rn/es.json b/locales/rn/es.json index 5668fe0a..72e45092 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Enlace", "export_ics_short": ".ics", + "nothing_planned": "Nada planificado. Toca para crear.", "tap_to_create": "Toca + para crear uno" }, "fab": { diff --git a/locales/rn/fa.json b/locales/rn/fa.json index ce458222..cde97c86 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "لینک", "export_ics_short": ".ics", + "nothing_planned": "برنامه‌ای نیست. برای ایجاد ضربه بزنید.", "tap_to_create": "برای ایجاد، روی + ضربه بزنید" }, "fab": { diff --git a/locales/rn/fr.json b/locales/rn/fr.json index 67e54653..9b7cdb19 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Lien", "export_ics_short": ".ics", + "nothing_planned": "Rien de prévu. Touchez pour créer.", "tap_to_create": "Appuyez sur + pour en créer un" }, "fab": { diff --git a/locales/rn/he.json b/locales/rn/he.json index 44a820e2..bc93ac4c 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "קישור", "export_ics_short": ".ics", + "nothing_planned": "אין תוכניות. הקש כדי ליצור.", "tap_to_create": "הקש על + כדי ליצור" }, "fab": { diff --git a/locales/rn/hu.json b/locales/rn/hu.json index 1b04ed44..8865527e 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Hivatkozás", "export_ics_short": ".ics", + "nothing_planned": "Nincs terv. Koppintson a létrehozáshoz.", "tap_to_create": "Esemény létrehozásához koppints a + gombra" }, "fab": { diff --git a/locales/rn/it.json b/locales/rn/it.json index 785a68ee..769707e7 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Link", "export_ics_short": ".ics", + "nothing_planned": "Niente in programma. Tocca per creare.", "tap_to_create": "Tocca + per crearne uno" }, "fab": { diff --git a/locales/rn/ja.json b/locales/rn/ja.json index 6e6b7ac5..1902b9f9 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "リンク", "export_ics_short": ".ics", + "nothing_planned": "予定はありません。タップして作成", "tap_to_create": "+ をタップして作成" }, "fab": { diff --git a/locales/rn/ko.json b/locales/rn/ko.json index 6125577b..50718578 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "링크", "export_ics_short": ".ics", + "nothing_planned": "계획된 일정이 없습니다. 탭하여 만드세요.", "tap_to_create": "+를 눌러 만들어 보세요" }, "fab": { diff --git a/locales/rn/lv.json b/locales/rn/lv.json index 1f256434..a6f047f7 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Saite", "export_ics_short": ".ics", + "nothing_planned": "Nekas nav ieplānots. Pieskarieties, lai izveidotu.", "tap_to_create": "Pieskarieties +, lai izveidotu notikumu" }, "fab": { diff --git a/locales/rn/mn.json b/locales/rn/mn.json index 162422e6..a49c5f4b 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Холбоос", "export_ics_short": ".ics", + "nothing_planned": "Төлөвлөсөн зүйл алга. Үүсгэхийн тулд товшино уу.", "tap_to_create": "Үүсгэхийн тулд + товшино уу" }, "fab": { diff --git a/locales/rn/nb.json b/locales/rn/nb.json index a3754623..6bdcf0f2 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Lenke", "export_ics_short": ".ics", + "nothing_planned": "Ingenting planlagt. Trykk for å opprette.", "tap_to_create": "Trykk på + for å opprette en" }, "fab": { diff --git a/locales/rn/nl.json b/locales/rn/nl.json index 03d47732..ca7b3e6b 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Link", "export_ics_short": ".ics", + "nothing_planned": "Niets gepland. Tik om aan te maken.", "tap_to_create": "Tik op + om er een te maken" }, "fab": { diff --git a/locales/rn/pl.json b/locales/rn/pl.json index 58e8dbf0..0c9498c2 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Link", "export_ics_short": ".ics", + "nothing_planned": "Nic nie zaplanowano. Dotknij, aby utworzyć.", "tap_to_create": "Stuknij +, aby utworzyć wydarzenie" }, "fab": { diff --git a/locales/rn/pt.json b/locales/rn/pt.json index ac3bd7bd..b54889ca 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Link", "export_ics_short": ".ics", + "nothing_planned": "Nada planejado. Toque para criar.", "tap_to_create": "Toque em + para criar um" }, "fab": { diff --git a/locales/rn/ro.json b/locales/rn/ro.json index 064a8c8d..2a5dd945 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Link", "export_ics_short": ".ics", + "nothing_planned": "Nimic planificat. Atinge pentru a crea.", "tap_to_create": "Atingeți + pentru a crea unul" }, "fab": { diff --git a/locales/rn/ru.json b/locales/rn/ru.json index c2bea6d7..3c5bd301 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Ссылка", "export_ics_short": ".ics", + "nothing_planned": "Ничего не запланировано. Нажмите, чтобы создать.", "tap_to_create": "Нажмите +, чтобы создать" }, "fab": { diff --git a/locales/rn/sk.json b/locales/rn/sk.json index 089de996..2dda2114 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Odkaz", "export_ics_short": ".ics", + "nothing_planned": "Nič nie je naplánované. Ťuknutím vytvoríte.", "tap_to_create": "Udalosť vytvoríte klepnutím na +" }, "fab": { diff --git a/locales/rn/tr.json b/locales/rn/tr.json index 4fcba8b5..5f88e169 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Bağlantı", "export_ics_short": ".ics", + "nothing_planned": "Planlanmış bir şey yok. Oluşturmak için dokunun.", "tap_to_create": "Etkinlik oluşturmak için + simgesine dokunun" }, "fab": { diff --git a/locales/rn/uk.json b/locales/rn/uk.json index cfe4df86..147d95d0 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "Посилання", "export_ics_short": ".ics", + "nothing_planned": "Нічого не заплановано. Торкніться, щоб створити.", "tap_to_create": "Натисніть +, щоб створити" }, "fab": { diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index 05981e80..8c086673 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "連結", "export_ics_short": ".ics", + "nothing_planned": "沒有安排。輕觸即可建立。", "tap_to_create": "點選 + 即可建立" }, "fab": { diff --git a/locales/rn/zh.json b/locales/rn/zh.json index 3f275e9a..4f40afaf 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -25,6 +25,7 @@ "events": { "copy_link_short": "链接", "export_ics_short": ".ics", + "nothing_planned": "暂无安排。点按即可创建。", "tap_to_create": "点按 + 创建" }, "fab": { diff --git a/src/components/calendar/AgendaView.tsx b/src/components/calendar/AgendaView.tsx index 60a63ade..937c6a10 100644 --- a/src/components/calendar/AgendaView.tsx +++ b/src/components/calendar/AgendaView.tsx @@ -9,8 +9,8 @@ import { type LayoutChangeEvent, type ViewToken, } from 'react-native'; -import { format, type Locale } from 'date-fns'; -import { displayNow, isDisplayToday, isDisplayTomorrow } from '../../lib/calendar-timezone'; +import { endOfWeek, format, startOfWeek, type Locale } from 'date-fns'; +import { displayNow, isDisplayToday } from '../../lib/calendar-timezone'; import type { Calendar, CalendarEvent } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; @@ -47,25 +47,28 @@ interface AgendaViewProps { /** Reports the first day in view as the user scrolls. */ onVisibleDateChange?: (date: Date) => void; timeFormat?: TimeFormat; + weekStartsOn?: 0 | 1 | 6; /** The user's addresses, to draw events they declined as inactive. */ currentUserEmails?: string[]; onSelectEvent?: (event: CalendarEvent) => void; + /** Tapping an empty "today" row creates an event on that day. */ + onCreateAt?: (date: Date) => void; } interface DaySection extends AgendaDay { key: string; - title: string; + /** Set on the first day of a month in the list: the month's name. */ + monthTitle: string | null; + /** Set on the first day of a week in the list: the week's range. */ + weekTitle: string | null; } -function formatDayHeader( - date: Date, - t: (key: string, fallback?: string) => string, - locale: Locale, -): string { - // Today and tomorrow on a clock in the calendar's time zone. - if (isDisplayToday(date)) return t('calendar.events.today_header', 'Today'); - if (isDisplayTomorrow(date)) return t('calendar.events.tomorrow_header', 'Tomorrow'); - return format(date, 'EEEE, MMM d', { locale }); +// The week separators: "Oct 11 – 17", or "Sep 27 – Oct 3" across months. +function formatWeekRange(date: Date, weekStartsOn: 0 | 1 | 6, locale: Locale): string { + const start = startOfWeek(date, { weekStartsOn }); + const end = endOfWeek(date, { weekStartsOn }); + const sameMonth = start.getMonth() === end.getMonth(); + return `${format(start, 'MMM d', { locale })} – ${format(end, sameMonth ? 'd' : 'MMM d', { locale })}`; } const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 1 }; @@ -92,8 +95,10 @@ export function AgendaView({ isLoading = false, onVisibleDateChange, timeFormat, + weekStartsOn = 1, currentUserEmails, onSelectEvent, + onCreateAt, }: AgendaViewProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -104,15 +109,21 @@ export function AgendaView({ [eventsByDay, events], ); - const sections = React.useMemo( - () => - buildAgendaDays(index, loaded, displayNow()).map((day) => ({ - ...day, - key: dayKey(day.date), - title: formatDayHeader(day.date, t, locale), - })), - [index, loaded, t, locale], - ); + // A heading where a month starts and a separator where a week starts, + // then the days. + const sections = React.useMemo(() => { + let lastMonth = ''; + let lastWeek = ''; + return buildAgendaDays(index, loaded, displayNow()).map((day) => { + const month = format(day.date, 'yyyy-MM'); + const week = dayKey(startOfWeek(day.date, { weekStartsOn })); + const monthTitle = month !== lastMonth ? format(day.date, 'LLLL yyyy', { locale }) : null; + const weekTitle = week !== lastWeek ? formatWeekRange(day.date, weekStartsOn, locale) : null; + lastMonth = month; + lastWeek = week; + return { ...day, key: dayKey(day.date), monthTitle, weekTitle }; + }); + }, [index, loaded, weekStartsOn, locale]); const listRef = React.useRef>(null); const sectionsRef = React.useRef(sections); @@ -297,7 +308,7 @@ export function AgendaView({ ref={listRef} sections={sections} keyExtractor={(item, i) => `${item.id}:${i}`} - stickySectionHeadersEnabled + stickySectionHeadersEnabled={false} // Earlier days are inserted above what the user is reading without // moving it. maintainVisibleContentPosition={{ minIndexForVisible: 0 }} @@ -314,32 +325,42 @@ export function AgendaView({ initialNumToRender={12} maxToRenderPerBatch={12} windowSize={11} - renderSectionHeader={({ section }) => { - const today = isDisplayToday(section.date); - return ( - - - {section.title} - - {format(section.date, 'PP', { locale })} + renderSectionHeader={({ section }) => + section.monthTitle || section.weekTitle ? ( + + {section.monthTitle && {section.monthTitle}} + {section.weekTitle && {section.weekTitle}} + + ) : null + } + renderItem={({ item, index: i, section }) => ( + + + + - ); - }} - renderItem={({ item }) => ( - - )} renderSectionFooter={({ section }) => section.data.length === 0 ? ( - - {t('calendar.events.no_events', 'No events')} + + + onCreateAt(section.date) : undefined} + accessibilityRole={onCreateAt ? 'button' : undefined} + > + + {t('calendar.events.nothing_planned', 'Nothing planned. Tap to create.')} + + ) : null } @@ -347,24 +368,79 @@ export function AgendaView({ ); } +type AgendaStyles = ReturnType; + +// The day on the left of its first row: weekday over the day number, today +// in a filled circle. +function DateColumn({ + date, + show, + locale, + styles, +}: { + date: Date; + show: boolean; + locale: Locale; + styles: AgendaStyles; +}) { + if (!show) return ; + const today = isDisplayToday(date); + return ( + + + {format(date, 'EEE', { locale })} + + + + {format(date, 'd')} + + + + ); +} + +const DATE_COL_WIDTH = 52; + function makeStyles(c: ThemePalette) { return StyleSheet.create({ - sectionHeader: { - flexDirection: 'row', - alignItems: 'baseline', - gap: spacing.sm, + monthTitle: { + ...typography.h2, + fontWeight: '500', + color: c.text, paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, - backgroundColor: c.surface, - borderBottomWidth: 1, - borderBottomColor: c.border, + paddingTop: spacing.xxl, + paddingBottom: spacing.sm, }, - sectionTitle: { ...typography.bodyMedium, color: c.text }, - sectionTitleToday: { color: c.primary }, - sectionSub: { ...typography.caption, color: c.textMuted }, - itemWrap: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm }, - emptyDayWrap: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md }, - emptyDayText: { ...typography.caption, color: c.textMuted, textAlign: 'center' }, + weekTitle: { + ...typography.caption, + color: c.textMuted, + paddingLeft: DATE_COL_WIDTH + spacing.sm, + paddingTop: spacing.md, + paddingBottom: spacing.xs, + }, + row: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingRight: spacing.lg, + paddingLeft: spacing.sm, + }, + rowLast: { paddingBottom: spacing.sm }, + dateCol: { width: DATE_COL_WIDTH, alignItems: 'center' }, + dateWeekday: { ...typography.captionMedium, color: c.textSecondary }, + dateWeekdayToday: { color: c.primary }, + dateNumber: { + width: 34, + height: 34, + borderRadius: 17, + alignItems: 'center', + justifyContent: 'center', + }, + dateNumberToday: { backgroundColor: c.primary }, + dateNumberText: { fontSize: 20, lineHeight: 26, fontWeight: '500', color: c.text }, + dateNumberTextToday: { color: c.primaryForeground }, + eventCol: { flex: 1, marginLeft: spacing.md }, + emptyDay: { flex: 1, marginLeft: spacing.md, paddingTop: 18 }, + emptyDayText: { ...typography.body, color: c.textSecondary }, emptyWrap: { paddingVertical: spacing.xl, alignItems: 'center' }, edge: { paddingHorizontal: spacing.lg, diff --git a/src/components/calendar/EventCard.tsx b/src/components/calendar/EventCard.tsx index 3a461793..68a4e5e1 100644 --- a/src/components/calendar/EventCard.tsx +++ b/src/components/calendar/EventCard.tsx @@ -8,6 +8,7 @@ import { useColors } from '../../theme/colors'; import { eventTimeRange, getEventColor, + isTimedEventFullDayOnDate, timePattern, type TimeFormat, } from '../../lib/calendar-utils'; @@ -22,6 +23,8 @@ interface EventCardProps { currentUserEmails?: string[]; onPress?: (event: CalendarEvent) => void; onLongPress?: (event: CalendarEvent) => void; + /** The day the row is listed under: a timed event filling it reads as all day. */ + day?: Date; } function participantCount(event: CalendarEvent): number { @@ -33,12 +36,14 @@ function participantCount(event: CalendarEvent): number { * colour before the title, no card and no bar. Declined and cancelled events * get a hollow dot and a struck-through, muted title. */ -export function EventCard({ event, calendars, timeFormat, currentUserEmails, onPress, onLongPress }: EventCardProps) { +export function EventCard({ event, calendars, timeFormat, currentUserEmails, onPress, onLongPress, day }: EventCardProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const { locale, t } = useCalendarLocale(); const color = getEventColor(event, calendars); - const { start, end, allDay } = eventTimeRange(event); + const range = eventTimeRange(event); + const { start, end } = range; + const allDay = range.allDay || (!!day && isTimedEventFullDayOnDate(event, day)); const fmt = timePattern(timeFormat); const time = allDay ? t('calendar.events.all_day', 'All day') @@ -59,7 +64,7 @@ export function EventCard({ event, calendars, timeFormat, currentUserEmails, onP {event.title || t('calendar.events.no_title', '(No title)')} - {event.showWithoutTime && ( + {allDay && ( {t('calendar.events.all_day', 'All day')} @@ -70,7 +75,7 @@ export function EventCard({ event, calendars, timeFormat, currentUserEmails, onP )} - {!event.showWithoutTime && ( + {!allDay && ( {time} diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index b93064b8..57e2e290 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -1141,8 +1141,10 @@ export default function CalendarScreen() { eventsByDay={eventsByDay} calendars={calendars} timeFormat={calendarTimeFormat} + weekStartsOn={calendarFirstDayOfWeek} currentUserEmails={currentUserEmails} onSelectEvent={handleSelectEvent} + onCreateAt={openCreate} /> )}