mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
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:
@@ -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': [],
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user