feat(calendar): lay out the agenda as a schedule with dates on the left

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".
This commit is contained in:
Linus Rath
2026-10-10 22:53:40 +02:00
parent 21d469f7e8
commit f53cee7ec9
30 changed files with 171 additions and 61 deletions
+1
View File
@@ -25,6 +25,7 @@
"events": {
"copy_link_short": "رابط",
"export_ics_short": ".ics",
"nothing_planned": "لا شيء مخطط. انقر للإنشاء.",
"tap_to_create": "اضغط + لإنشاء حدث"
},
"fab": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -25,6 +25,7 @@
"events": {
"copy_link_short": "لینک",
"export_ics_short": ".ics",
"nothing_planned": "برنامه‌ای نیست. برای ایجاد ضربه بزنید.",
"tap_to_create": "برای ایجاد، روی + ضربه بزنید"
},
"fab": {
+1
View File
@@ -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": {
+1
View File
@@ -25,6 +25,7 @@
"events": {
"copy_link_short": "קישור",
"export_ics_short": ".ics",
"nothing_planned": "אין תוכניות. הקש כדי ליצור.",
"tap_to_create": "הקש על + כדי ליצור"
},
"fab": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -25,6 +25,7 @@
"events": {
"copy_link_short": "リンク",
"export_ics_short": ".ics",
"nothing_planned": "予定はありません。タップして作成",
"tap_to_create": "+ をタップして作成"
},
"fab": {
+1
View File
@@ -25,6 +25,7 @@
"events": {
"copy_link_short": "링크",
"export_ics_short": ".ics",
"nothing_planned": "계획된 일정이 없습니다. 탭하여 만드세요.",
"tap_to_create": "+를 눌러 만들어 보세요"
},
"fab": {
+1
View File
@@ -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": {
+1
View File
@@ -25,6 +25,7 @@
"events": {
"copy_link_short": "Холбоос",
"export_ics_short": ".ics",
"nothing_planned": "Төлөвлөсөн зүйл алга. Үүсгэхийн тулд товшино уу.",
"tap_to_create": "Үүсгэхийн тулд + товшино уу"
},
"fab": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -25,6 +25,7 @@
"events": {
"copy_link_short": "Ссылка",
"export_ics_short": ".ics",
"nothing_planned": "Ничего не запланировано. Нажмите, чтобы создать.",
"tap_to_create": "Нажмите +, чтобы создать"
},
"fab": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -25,6 +25,7 @@
"events": {
"copy_link_short": "Посилання",
"export_ics_short": ".ics",
"nothing_planned": "Нічого не заплановано. Торкніться, щоб створити.",
"tap_to_create": "Натисніть +, щоб створити"
},
"fab": {
+1
View File
@@ -25,6 +25,7 @@
"events": {
"copy_link_short": "連結",
"export_ics_short": ".ics",
"nothing_planned": "沒有安排。輕觸即可建立。",
"tap_to_create": "點選 + 即可建立"
},
"fab": {
+1
View File
@@ -25,6 +25,7 @@
"events": {
"copy_link_short": "链接",
"export_ics_short": ".ics",
"nothing_planned": "暂无安排。点按即可创建。",
"tap_to_create": "点按 + 创建"
},
"fab": {
+133 -57
View File
@@ -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<DaySection[]>(
() =>
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<DaySection[]>(() => {
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<SectionList<CalendarEvent, DaySection>>(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 (
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, today && styles.sectionTitleToday]}>
{section.title}
</Text>
<Text style={styles.sectionSub}>{format(section.date, 'PP', { locale })}</Text>
renderSectionHeader={({ section }) =>
section.monthTitle || section.weekTitle ? (
<View>
{section.monthTitle && <Text style={styles.monthTitle}>{section.monthTitle}</Text>}
{section.weekTitle && <Text style={styles.weekTitle}>{section.weekTitle}</Text>}
</View>
) : null
}
renderItem={({ item, index: i, section }) => (
<View style={[styles.row, i === section.data.length - 1 && styles.rowLast]}>
<DateColumn date={section.date} show={i === 0} locale={locale} styles={styles} />
<View style={styles.eventCol}>
<EventCard
event={item}
calendars={calendars}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
onPress={onSelectEvent}
day={section.date}
/>
</View>
);
}}
renderItem={({ item }) => (
<View style={styles.itemWrap}>
<EventCard
event={item}
calendars={calendars}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
onPress={onSelectEvent}
/>
</View>
)}
renderSectionFooter={({ section }) =>
section.data.length === 0 ? (
<View style={styles.emptyDayWrap}>
<Text style={styles.emptyDayText}>{t('calendar.events.no_events', 'No events')}</Text>
<View style={[styles.row, styles.rowLast]}>
<DateColumn date={section.date} show locale={locale} styles={styles} />
<Pressable
style={styles.emptyDay}
onPress={onCreateAt ? () => onCreateAt(section.date) : undefined}
accessibilityRole={onCreateAt ? 'button' : undefined}
>
<Text style={styles.emptyDayText}>
{t('calendar.events.nothing_planned', 'Nothing planned. Tap to create.')}
</Text>
</Pressable>
</View>
) : null
}
@@ -347,24 +368,79 @@ export function AgendaView({
);
}
type AgendaStyles = ReturnType<typeof makeStyles>;
// 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 <View style={styles.dateCol} />;
const today = isDisplayToday(date);
return (
<View style={styles.dateCol}>
<Text style={[styles.dateWeekday, today && styles.dateWeekdayToday]}>
{format(date, 'EEE', { locale })}
</Text>
<View style={[styles.dateNumber, today && styles.dateNumberToday]}>
<Text style={[styles.dateNumberText, today && styles.dateNumberTextToday]}>
{format(date, 'd')}
</Text>
</View>
</View>
);
}
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,
+9 -4
View File
@@ -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
<Text style={[styles.title, inactive && styles.titleInactive]} numberOfLines={1}>
{event.title || t('calendar.events.no_title', '(No title)')}
</Text>
{event.showWithoutTime && (
{allDay && (
<View style={styles.allDayBadge}>
<Text style={styles.allDayText}>{t('calendar.events.all_day', 'All day')}</Text>
</View>
@@ -70,7 +75,7 @@ export function EventCard({ event, calendars, timeFormat, currentUserEmails, onP
</View>
)}
</View>
{!event.showWithoutTime && (
{!allDay && (
<View style={styles.detailRow}>
<Clock size={12} color={c.textMuted} />
<Text style={styles.detailText}>{time}</Text>
+2
View File
@@ -1141,8 +1141,10 @@ export default function CalendarScreen() {
eventsByDay={eventsByDay}
calendars={calendars}
timeFormat={calendarTimeFormat}
weekStartsOn={calendarFirstDayOfWeek}
currentUserEmails={currentUserEmails}
onSelectEvent={handleSelectEvent}
onCreateAt={openCreate}
/>
)}