From 742e923f0db3318c2955ad7f3e33afc54e6f3253 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 28 Jun 2026 01:05:30 -0300 Subject: [PATCH] fix(calendar): cap the all-day band so the timed grid stays visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the week/interval time grid, a day with many untimed habits let the all-day ("DIA INTEIRO") band grow without bound, pushing the timed hours almost entirely off-screen — the user could barely see or scroll the timed habits. Cap the band at 5 chips per column on both platforms; any overflow collapses into a single tappable "+N" chip that opens the day detail (where the full list lives). The band height is now bounded, so the timed grid always keeps usable, scrollable space. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/calendar-time-grid.test.tsx | 20 ++++ .../_components/calendar-time-grid.tsx | 94 +++++++++++++++---- .../calendar/calendar-time-grid.test.tsx | 17 ++++ .../calendar/calendar-time-grid.tsx | 93 ++++++++++++++---- 4 files changed, 189 insertions(+), 35 deletions(-) diff --git a/apps/mobile/__tests__/components/calendar-time-grid.test.tsx b/apps/mobile/__tests__/components/calendar-time-grid.test.tsx index df3316dbb..1a8074417 100644 --- a/apps/mobile/__tests__/components/calendar-time-grid.test.tsx +++ b/apps/mobile/__tests__/components/calendar-time-grid.test.tsx @@ -114,6 +114,26 @@ describe("CalendarTimeGrid (mobile)", () => { expect(hostsByTestID(tree, "time-grid-col-header")).toHaveLength(4); }); + it("caps the all-day stack and collapses the overflow into a +N that opens the day", () => { + const onSelectDay = vi.fn(); + const col = column("2025-06-16"); + const entries = Array.from({ length: 8 }, (_, i) => + makeEntry({ habitId: `ad-${i}`, title: `All ${i}`, dueTime: null }), + ); + const dayMap = new Map([[col.dateStr, entries]]); + const tree = renderGrid([col], dayMap, onSelectDay); + + expect(hostsByTestID(tree, "time-grid-all-day-event")).toHaveLength(4); + const more = hostsByTestID(tree, "time-grid-all-day-more"); + expect(more).toHaveLength(1); + expect(textValuesWithin(tree, "time-grid-all-day-more")).toContain(4); + + TestRenderer.act(() => { + more[0]!.props.onPress(); + }); + expect(onSelectDay).toHaveBeenCalledWith("2025-06-16"); + }); + it("opens the tapped day from a column header", () => { const onSelectDay = vi.fn(); const col = column("2025-06-16"); diff --git a/apps/mobile/app/(tabs)/calendar/_components/calendar-time-grid.tsx b/apps/mobile/app/(tabs)/calendar/_components/calendar-time-grid.tsx index d5243fe13..1351e3f29 100644 --- a/apps/mobile/app/(tabs)/calendar/_components/calendar-time-grid.tsx +++ b/apps/mobile/app/(tabs)/calendar/_components/calendar-time-grid.tsx @@ -27,8 +27,22 @@ const ALL_DAY_MIN_HEIGHT = 34; const ALL_DAY_CHIP_HEIGHT = 22; const ALL_DAY_GAP = 3; const ALL_DAY_PADDING = 12; +const ALL_DAY_MAX_VISIBLE = 5; const HOURS = Array.from({ length: 24 }, (_, h) => h); +/** Caps the all-day stack so a heavy day cannot push the timed grid off-screen: + * the first chips show, the rest collapse into a single tappable "+N". */ +function splitAllDay(allDay: CalendarDayEntry[]): { + visible: CalendarDayEntry[]; + overflow: number; +} { + if (allDay.length <= ALL_DAY_MAX_VISIBLE) return { visible: allDay, overflow: 0 }; + return { + visible: allDay.slice(0, ALL_DAY_MAX_VISIBLE - 1), + overflow: allDay.length - (ALL_DAY_MAX_VISIBLE - 1), + }; +} + export interface TimeGridColumn { date: Date; dateStr: string; @@ -255,6 +269,43 @@ function AllDayChip({ ); } +function AllDayMoreChip({ + count, + onPress, + tokens, +}: Readonly<{ count: number; onPress: () => void; tokens: Tokens }>) { + return ( + ({ + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + height: ALL_DAY_CHIP_HEIGHT - ALL_DAY_GAP, + paddingHorizontal: 6, + borderRadius: 6, + borderWidth: 1, + borderColor: tokens.hairline, + backgroundColor: pressed ? tokens.bgElev : "transparent", + })} + > + + +{count} + + + ); +} + function ColumnHeader({ column, colWidth, @@ -356,9 +407,10 @@ export function CalendarTimeGrid({ 0, ); if (maxChips === 0) return ALL_DAY_MIN_HEIGHT; + const rows = Math.min(maxChips, ALL_DAY_MAX_VISIBLE); return Math.max( ALL_DAY_MIN_HEIGHT, - ALL_DAY_PADDING + maxChips * ALL_DAY_CHIP_HEIGHT, + ALL_DAY_PADDING + rows * ALL_DAY_CHIP_HEIGHT, ); }, [perColumn]); @@ -434,21 +486,31 @@ export function CalendarTimeGrid({ - {perColumn.map(({ column, allDay }) => ( - - {allDay.map((entry) => ( - - ))} - - ))} + {perColumn.map(({ column, allDay }) => { + const { visible, overflow } = splitAllDay(allDay); + return ( + + {visible.map((entry) => ( + + ))} + {overflow > 0 ? ( + onSelectDay(column.dateStr)} + tokens={tokens} + /> + ) : null} + + ); + })} { expect(onSelectDay).toHaveBeenCalledWith('2025-06-16') }) + it('caps the all-day stack and collapses the overflow into a +N that opens the day', () => { + const onSelectDay = vi.fn() + const col = column(2025, 5, 16) + const entries = Array.from({ length: 8 }, (_, i) => + makeEntry({ habitId: `ad-${i}`, title: `All ${i}`, dueTime: null }), + ) + const dayMap = new Map([[col.dateStr, entries]]) + renderGrid([col], dayMap, onSelectDay) + + expect(screen.getAllByTestId('time-grid-all-day-event')).toHaveLength(4) + const more = screen.getByTestId('time-grid-all-day-more') + expect(more).toHaveTextContent('+4') + + fireEvent.click(more) + expect(onSelectDay).toHaveBeenCalledWith('2025-06-16') + }) + it('gives the pinned all-day band an opaque backdrop so scrolled hours never bleed through', () => { const col = column(2025, 5, 16) renderGrid([col], new Map()) diff --git a/apps/web/components/calendar/calendar-time-grid.tsx b/apps/web/components/calendar/calendar-time-grid.tsx index 7b2b20834..1e5dc33f6 100644 --- a/apps/web/components/calendar/calendar-time-grid.tsx +++ b/apps/web/components/calendar/calendar-time-grid.tsx @@ -13,8 +13,22 @@ const MIN_COL_WIDTH = 80 const HEADER_HEIGHT = 52 const BODY_MAX_HEIGHT = 520 const SCROLLER_MAX_HEIGHT = HEADER_HEIGHT + 40 + BODY_MAX_HEIGHT +const ALL_DAY_MAX_VISIBLE = 5 const HOURS = Array.from({ length: 24 }, (_, h) => h) +/** Caps the all-day stack so a heavy day cannot push the timed grid off-screen: + * the first chips show, the rest collapse into a single tappable "+N". */ +function splitAllDay(allDay: CalendarDayEntry[]): { + visible: CalendarDayEntry[] + overflow: number +} { + if (allDay.length <= ALL_DAY_MAX_VISIBLE) return { visible: allDay, overflow: 0 } + return { + visible: allDay.slice(0, ALL_DAY_MAX_VISIBLE - 1), + overflow: allDay.length - (ALL_DAY_MAX_VISIBLE - 1), + } +} + const CARD_BG = 'var(--bg-card)' const pinnedPaneBackground = { backgroundColor: 'var(--bg)', @@ -198,6 +212,41 @@ function AllDayChip({ entry }: Readonly<{ entry: CalendarDayEntry }>) { ) } +function AllDayMoreChip({ + count, + onSelect, +}: Readonly<{ count: number; onSelect: () => void }>) { + return ( + + ) +} + /** Google-Calendar-style time grid: a day column per entry in `columns`, an * untimed all-day band on top, and timed habits placed as blocks by dueTime. * Day columns keep a readable minimum width and scroll horizontally — the left @@ -345,25 +394,31 @@ export function CalendarTimeGrid({ {allDayLabel} - {perColumn.map(({ column, allDay }) => ( -
- {allDay.map((entry) => ( - - ))} -
- ))} + {perColumn.map(({ column, allDay }) => { + const { visible, overflow } = splitAllDay(allDay) + return ( +
+ {visible.map((entry) => ( + + ))} + {overflow > 0 && ( + onSelectDay(column.dateStr)} /> + )} +
+ ) + })}