From 90ce7c1b3c49be74e9ca3b9c29cee5e5e8486200 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:44:28 +0100 Subject: [PATCH] feat: size the week all-day area from the days in view and fold it to three rows (bulwarkmail/webmail#1122) --- .../__tests__/calendar-i18n.test.ts | 1 + src/components/calendar/AllDayToggle.tsx | 41 +++++++++++ .../calendar/TimeGridScrollView.tsx | 25 +++++-- src/components/calendar/WeekView.tsx | 20 ++++-- .../__tests__/calendar-all-day-area.test.ts | 69 +++++++++++++++++++ src/lib/calendar-time-grid.ts | 60 +++++++++++++++- src/screens/CalendarScreen.tsx | 8 +-- 7 files changed, 208 insertions(+), 16 deletions(-) create mode 100644 src/components/calendar/AllDayToggle.tsx create mode 100644 src/lib/__tests__/calendar-all-day-area.test.ts diff --git a/src/components/__tests__/calendar-i18n.test.ts b/src/components/__tests__/calendar-i18n.test.ts index e513bf86..e54c41ee 100644 --- a/src/components/__tests__/calendar-i18n.test.ts +++ b/src/components/__tests__/calendar-i18n.test.ts @@ -11,6 +11,7 @@ const SRC = join(__dirname, '..', '..'); const TRANSLATED_FILES: Record = { 'screens/CalendarScreen.tsx': [], 'components/calendar/AgendaView.tsx': [], + 'components/calendar/AllDayToggle.tsx': [], 'components/calendar/EventBlock.tsx': [], 'components/calendar/EventCard.tsx': [], 'components/calendar/MonthScrollView.tsx': [], diff --git a/src/components/calendar/AllDayToggle.tsx b/src/components/calendar/AllDayToggle.tsx new file mode 100644 index 00000000..673eb6b0 --- /dev/null +++ b/src/components/calendar/AllDayToggle.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Pressable, StyleSheet, Text } from 'react-native'; +import { ChevronDown, ChevronUp } from '../icons'; +import { typography } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; + +interface Props { + /** Rows the collapsed area leaves out. */ + hiddenRows: number; + expanded: boolean; + onToggle: () => void; +} + +/** + * Under the "All day" label of the week and day grids: "+N" opens the rows + * past the first three, and closes them again (webmail calendar-week-view, + * #1122). + */ +export function AllDayToggle({ hiddenRows, expanded, onToggle }: Props) { + const c = useColors(); + const t = useLocaleStore((s) => s.t); + return ( + + {expanded ? null : +{hiddenRows}} + {expanded ? : } + + ); +} + +const styles = StyleSheet.create({ + button: { flexDirection: 'row', alignItems: 'center', gap: 1, marginTop: 'auto', paddingVertical: 2 }, + count: { ...typography.small }, +}); diff --git a/src/components/calendar/TimeGridScrollView.tsx b/src/components/calendar/TimeGridScrollView.tsx index 8ded28a2..9ad9210d 100644 --- a/src/components/calendar/TimeGridScrollView.tsx +++ b/src/components/calendar/TimeGridScrollView.tsx @@ -37,7 +37,9 @@ import { displayNow, displayNowMinutes } from '../../lib/calendar-timezone'; import { isInactiveEvent } from '../../lib/calendar-participants'; import { eventBlockColors } from '../../lib/event-colors'; import { + allDayRowsInView, buildAllDaySegments, + shownAllDayRows, columnsPerScreen, headerColumnRange, hourAtOffset, @@ -46,6 +48,7 @@ import { type TimeGridMode, } from '../../lib/calendar-time-grid'; import { AllDayEventBar, TIME_LINE_MIN_MINUTES, TimedEventBlock } from './EventBlock'; +import { AllDayToggle } from './AllDayToggle'; const HOUR_HEIGHT = 48; const GRID_HEIGHT = 24 * HOUR_HEIGHT; @@ -174,16 +177,12 @@ function TimeGridScrollViewInner({ ); // All-day bars over the whole window (cut per week, or per day in the day - // view), so the strip keeps one height while scrolling. + // view). The area is sized from the columns in view, below. const allDaySegments = React.useMemo( () => buildAllDaySegments(index, days, perScreen), [index, days, perScreen], ); - const allDayRows = React.useMemo( - () => allDaySegments.reduce((max, s) => Math.max(max, s.row + 1), 0), - [allDaySegments], - ); - const allDayHeight = allDayRows > 0 ? allDayRows * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP) + spacing.xs : 0; + const [allDayExpanded, setAllDayExpanded] = React.useState(false); // The first column in view. Kept as a day so columns added at the start // don't move it. @@ -370,8 +369,13 @@ function TimeGridScrollViewInner({ />, ); } + // The all-day area holds the rows of the days in view, three until it is + // expanded (webmail #1122, #1293). + const allDayRows = allDayRowsInView(allDaySegments, anchorIndex, anchorIndex + perScreen - 1); + const allDayShownRows = shownAllDayRows(allDayRows, allDayExpanded); + const allDayHeight = allDayShownRows > 0 ? allDayShownRows * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP) + spacing.xs : 0; const visibleSegments = allDaySegments.filter( - (s) => s.startIndex <= to && s.startIndex + s.span - 1 >= from, + (s) => s.startIndex <= to && s.startIndex + s.span - 1 >= from && s.row < allDayShownRows, ); const threshold = perScreen === 1 ? 3 : 1; @@ -418,6 +422,13 @@ function TimeGridScrollViewInner({ {t('calendar.events.all_day', 'All day')} + {allDayRows > shownAllDayRows(allDayRows, false) ? ( + setAllDayExpanded((v) => !v)} + /> + ) : null} )} diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx index 0d7718c9..b1327fd9 100644 --- a/src/components/calendar/WeekView.tsx +++ b/src/components/calendar/WeekView.tsx @@ -21,13 +21,14 @@ import { getEventColor, isTimedEventFullDayOnDate, layoutOverlappingEvents, - packWeekSegments, type CalendarWeekSegment, type EventDayIndex, } from '../../lib/calendar-utils'; import { isInactiveEvent } from '../../lib/calendar-participants'; import { eventBlockColors } from '../../lib/event-colors'; import { AllDayEventBar, TIME_LINE_MIN_MINUTES, TimedEventBlock } from './EventBlock'; +import { AllDayToggle } from './AllDayToggle'; +import { packAllDaySegments, shownAllDayRows } from '../../lib/calendar-time-grid'; const HOUR_HEIGHT = 48; const GUTTER_WIDTH = 44; @@ -91,12 +92,16 @@ function WeekViewInner({ events.filter((e) => !e.showWithoutTime), weekDays, ); - return packWeekSegments([...explicit, ...timedFull]); + return packAllDaySegments([...explicit, ...timedFull]); }, [events, weekDays]); - const allDayRowCount = React.useMemo(() => { + const allDayRows = React.useMemo(() => { return allDaySegments.reduce((max, s) => Math.max(max, s.row + 1), 0); }, [allDaySegments]); + // Three rows until expanded (webmail #1122). + const [allDayExpanded, setAllDayExpanded] = React.useState(false); + const allDayRowCount = shownAllDayRows(allDayRows, allDayExpanded); + const allDayHiddenRows = allDayRows - shownAllDayRows(allDayRows, false); const allDayStripHeight = allDayRowCount > 0 @@ -181,6 +186,13 @@ function WeekViewInner({ {t('calendar.events.all_day', 'All day')} + {allDayHiddenRows > 0 ? ( + setAllDayExpanded((v) => !v)} + /> + ) : null} {/* Empty per-day columns to draw vertical separators */} @@ -189,7 +201,7 @@ function WeekViewInner({ ))} {/* Segments overlay - multi-day events span their date range */} - {allDaySegments.map((segment) => { + {allDaySegments.filter((segment) => segment.row < allDayRowCount).map((segment) => { const leftPct = (segment.startIndex / weekDays.length) * 100; const widthPct = (segment.span / weekDays.length) * 100; return ( diff --git a/src/lib/__tests__/calendar-all-day-area.test.ts b/src/lib/__tests__/calendar-all-day-area.test.ts new file mode 100644 index 00000000..4b0255dc --- /dev/null +++ b/src/lib/__tests__/calendar-all-day-area.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { CalendarEvent } from '../../api/types'; +import type { CalendarWeekSegment } from '../calendar-utils'; +import { + ALL_DAY_COLLAPSED_ROWS, + allDayRowsInView, + packAllDaySegments, + shownAllDayRows, +} from '../calendar-time-grid'; + +// The week grids' all-day area was sized from every week of the scroll +// window, packed task chips in among the events, and grew without limit +// (file 10 "week all-day area (#1122)"). Webmail calendar-week-view. + +const seg = (id: string, startIndex: number, span = 1, title = id): CalendarWeekSegment => ({ + event: { id, title, start: '2026-11-02T00:00:00', showWithoutTime: true } as CalendarEvent, + startIndex, + span, + row: 0, + continuesBefore: false, + continuesAfter: false, +}); + +const rowsOf = (segments: CalendarWeekSegment[]) => + Object.fromEntries(segments.map((s) => [s.event.id, s.row])); + +describe('week all-day area', () => { + it("stacks each day's tasks under that day's events", () => { + // "a task" sorts before "b event" by title; packed together it took row 0. + const packed = packAllDaySegments([ + seg('task:1', 1, 1, 'a task'), + seg('trip', 0, 3, 'b trip'), + seg('event', 1, 1, 'b event'), + seg('task:2', 1, 1, 'c task'), + seg('task:3', 4, 1, 'd task'), + ]); + expect(rowsOf(packed)).toEqual({ trip: 0, event: 1, 'task:1': 2, 'task:2': 3, 'task:3': 0 }); + }); + + it('is sized from the days in view', () => { + const packed = packAllDaySegments([seg('a', 0), seg('b', 0), seg('c', 0), seg('d', 0), seg('e', 8)]); + expect(allDayRowsInView(packed, 0, 6)).toBe(4); + // The next week holds one event: no empty rows from the crowded day. + expect(allDayRowsInView(packed, 7, 13)).toBe(1); + expect(allDayRowsInView(packed, 14, 20)).toBe(0); + }); + + it('shows three rows until expanded', () => { + expect(ALL_DAY_COLLAPSED_ROWS).toBe(3); + expect(shownAllDayRows(5, false)).toBe(3); + expect(shownAllDayRows(5, true)).toBe(5); + expect(shownAllDayRows(2, false)).toBe(2); + }); + + // No RN renderer in the test env: check the grids' wiring. + it('is drawn that way by both week grids', () => { + const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '../../components/calendar', rel), 'utf8'); + const scroll = read('TimeGridScrollView.tsx'); + expect(scroll).toMatch(/allDayRowsInView\(allDaySegments, anchorIndex, anchorIndex \+ perScreen - 1\)/); + expect(scroll).toMatch(/s\.row < allDayShownRows/); + expect(scroll).toMatch(/ segment\.row < allDayRowCount\)/); + expect(week).toMatch(/ s.event.id.startsWith(TASK_CHIP_ID_PREFIX); + const events = packWeekSegments(raw.filter((s) => !isTask(s))); + const tasks = raw.filter(isTask).sort((a, b) => + a.startIndex - b.startIndex || (a.event.title || '').localeCompare(b.event.title || '')); + if (tasks.length === 0) return events; + // Each day's next free row: under every event bar that covers it, then + // under the tasks already put there. + const nextRow = new Map(); + const rowAt = (index: number) => { + let row = nextRow.get(index); + if (row === undefined) { + row = events.reduce( + (max, s) => (index >= s.startIndex && index < s.startIndex + s.span ? Math.max(max, s.row + 1) : max), + 0, + ); + } + return row; + }; + const stacked = tasks.map((task) => { + // A task covers one day; a longer one stacks under every day it covers. + let row = 0; + for (let i = task.startIndex; i < task.startIndex + task.span; i++) row = Math.max(row, rowAt(i)); + for (let i = task.startIndex; i < task.startIndex + task.span; i++) nextRow.set(i, row + 1); + return { ...task, row }; + }); + return [...events, ...stacked]; +} + +/** + * Rows the all-day area needs for the columns `first`..`last` in view: a + * crowded day elsewhere in the window adds no empty rows here (webmail + * #1293). + */ +export function allDayRowsInView(segments: CalendarWeekSegment[], first: number, last: number): number { + let rows = 0; + for (const s of segments) { + if (s.startIndex <= last && s.startIndex + s.span - 1 >= first) rows = Math.max(rows, s.row + 1); + } + return rows; +} + +/** The all-day area shows this many rows until it is expanded (webmail DEFAULT_ALL_DAY_MAX_ROWS). */ +export const ALL_DAY_COLLAPSED_ROWS = 3; + +/** Rows drawn: all of them when expanded or few enough, else the collapsed three. */ +export function shownAllDayRows(rows: number, expanded: boolean): number { + return expanded ? rows : Math.min(rows, ALL_DAY_COLLAPSED_ROWS); } /** The hour a long press at `offsetY` in a day column lands on. */ diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 25c679e6..38eb0432 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -11,7 +11,7 @@ import { useWindowDimensions, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { multiDayCount } from '../lib/calendar-time-grid'; +import { TASK_CHIP_ID_PREFIX, multiDayCount } from '../lib/calendar-time-grid'; import { Calendar as CalendarIcon, ChevronDown, @@ -340,7 +340,7 @@ export default function CalendarScreen() { const allDay = !!task.showWithoutTime || dateOnly; out.push({ ...task, - id: `task:${task.id}`, + id: `${TASK_CHIP_ID_PREFIX}${task.id}`, start: dateOnly ? `${task.due}T00:00:00` : task.due, showWithoutTime: allDay, duration: allDay ? 'P1D' : 'PT30M', @@ -623,8 +623,8 @@ export default function CalendarScreen() { // Task chips route to the tasks sheet; everything else opens the detail sheet. const handleSelectEvent = React.useCallback((event: CalendarEvent) => { - if (event.id.startsWith('task:')) { - setTasksInitialId(event.id.slice('task:'.length)); + if (event.id.startsWith(TASK_CHIP_ID_PREFIX)) { + setTasksInitialId(event.id.slice(TASK_CHIP_ID_PREFIX.length)); setTasksVisible(true); return; }