feat: size the week all-day area from the days in view and fold it to three rows (bulwarkmail/webmail#1122)

This commit is contained in:
Linus Rath
2026-10-31 05:44:28 +01:00
parent ecf1f3ecd9
commit 90ce7c1b3c
7 changed files with 208 additions and 16 deletions
@@ -11,6 +11,7 @@ const SRC = join(__dirname, '..', '..');
const TRANSLATED_FILES: Record<string, string[]> = {
'screens/CalendarScreen.tsx': [],
'components/calendar/AgendaView.tsx': [],
'components/calendar/AllDayToggle.tsx': [],
'components/calendar/EventBlock.tsx': [],
'components/calendar/EventCard.tsx': [],
'components/calendar/MonthScrollView.tsx': [],
+41
View File
@@ -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 (
<Pressable
onPress={onToggle}
hitSlop={8}
style={styles.button}
accessibilityRole="button"
accessibilityLabel={expanded ? t('calendar.events.show_less', 'Show less') : t('calendar.events.show_more', 'Show more')}
accessibilityState={{ expanded }}
>
{expanded ? null : <Text style={[styles.count, { color: c.textMuted }]}>+{hiddenRows}</Text>}
{expanded ? <ChevronUp size={12} color={c.textMuted} /> : <ChevronDown size={12} color={c.textMuted} />}
</Pressable>
);
}
const styles = StyleSheet.create({
button: { flexDirection: 'row', alignItems: 'center', gap: 1, marginTop: 'auto', paddingVertical: 2 },
count: { ...typography.small },
});
+18 -7
View File
@@ -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<CalendarWeekSegment[]>(
() => 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({
<Text style={styles.allDayLabel} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.6}>
{t('calendar.events.all_day', 'All day')}
</Text>
{allDayRows > shownAllDayRows(allDayRows, false) ? (
<AllDayToggle
hiddenRows={allDayRows - shownAllDayRows(allDayRows, false)}
expanded={allDayExpanded}
onToggle={() => setAllDayExpanded((v) => !v)}
/>
) : null}
</View>
)}
</View>
+16 -4
View File
@@ -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({
<View style={[styles.allDayStrip, { height: allDayStripHeight }]}>
<View style={[styles.gutter, styles.allDayLabelWrap]}>
<Text style={styles.allDayLabel} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.6}>{t('calendar.events.all_day', 'All day')}</Text>
{allDayHiddenRows > 0 ? (
<AllDayToggle
hiddenRows={allDayHiddenRows}
expanded={allDayExpanded}
onToggle={() => setAllDayExpanded((v) => !v)}
/>
) : null}
</View>
<View style={styles.allDayGrid}>
{/* Empty per-day columns to draw vertical separators */}
@@ -189,7 +201,7 @@ function WeekViewInner({
))}
{/* Segments overlay - multi-day events span their date range */}
<View style={styles.allDayOverlay} pointerEvents="box-none">
{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 (
@@ -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(/<AllDayToggle/);
const week = read('WeekView.tsx');
expect(week).toMatch(/return packAllDaySegments\(\[\.\.\.explicit, \.\.\.timedFull\]\);/);
expect(week).toMatch(/allDaySegments\.filter\(\(segment\) => segment\.row < allDayRowCount\)/);
expect(week).toMatch(/<AllDayToggle/);
});
});
+59 -1
View File
@@ -103,7 +103,65 @@ export function buildAllDaySegments(
raw.push({ ...segment, startIndex: segment.startIndex + from });
}
}
return packWeekSegments(raw);
return packAllDaySegments(raw);
}
/** The id prefix CalendarScreen gives the due tasks it draws on the grid. */
export const TASK_CHIP_ID_PREFIX = 'task:';
/**
* Packs the all-day bars into rows, with each day's task chips stacked under
* that day's all-day events rather than packed in among them (webmail
* calendar-week-view, #1107, #1270, #1122).
*/
export function packAllDaySegments(raw: CalendarWeekSegment[]): CalendarWeekSegment[] {
const isTask = (s: CalendarWeekSegment) => 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<number, number>();
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. */
+4 -4
View File
@@ -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;
}