Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/mobile/__tests__/components/calendar-time-grid.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CalendarDayEntry[]>([[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");
Expand Down
94 changes: 78 additions & 16 deletions apps/mobile/app/(tabs)/calendar/_components/calendar-time-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -255,6 +269,43 @@ function AllDayChip({
);
}

function AllDayMoreChip({
count,
onPress,
tokens,
}: Readonly<{ count: number; onPress: () => void; tokens: Tokens }>) {
return (
<Pressable
testID="time-grid-all-day-more"
accessibilityRole="button"
accessibilityLabel={`+${count}`}
onPress={onPress}
style={({ pressed }) => ({
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",
})}
>
<Text
style={{
fontFamily: "Roboto_500Medium",
fontSize: 11,
color: tokens.fg3,
fontVariant: ["tabular-nums"],
}}
>
+{count}
</Text>
</Pressable>
);
}

function ColumnHeader({
column,
colWidth,
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -434,21 +486,31 @@ export function CalendarTimeGrid({
</View>

<View style={[styles.allDayRow, { height: allDayBandHeight }]}>
{perColumn.map(({ column, allDay }) => (
<View
key={column.dateStr}
testID="time-grid-all-day"
style={[styles.allDayCell, { width: colWidth }]}
>
{allDay.map((entry) => (
<AllDayChip
key={entry.habitId}
entry={entry}
tokens={tokens}
/>
))}
</View>
))}
{perColumn.map(({ column, allDay }) => {
const { visible, overflow } = splitAllDay(allDay);
return (
<View
key={column.dateStr}
testID="time-grid-all-day"
style={[styles.allDayCell, { width: colWidth }]}
>
{visible.map((entry) => (
<AllDayChip
key={entry.habitId}
entry={entry}
tokens={tokens}
/>
))}
{overflow > 0 ? (
<AllDayMoreChip
count={overflow}
onPress={() => onSelectDay(column.dateStr)}
tokens={tokens}
/>
) : null}
</View>
);
})}
</View>

<ScrollView
Expand Down
17 changes: 17 additions & 0 deletions apps/web/__tests__/components/calendar/calendar-time-grid.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,23 @@ describe('CalendarTimeGrid', () => {
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<string, CalendarDayEntry[]>([[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())
Expand Down
93 changes: 74 additions & 19 deletions apps/web/components/calendar/calendar-time-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down Expand Up @@ -198,6 +212,41 @@ function AllDayChip({ entry }: Readonly<{ entry: CalendarDayEntry }>) {
)
}

function AllDayMoreChip({
count,
onSelect,
}: Readonly<{ count: number; onSelect: () => void }>) {
return (
<button
type="button"
data-testid="time-grid-all-day-more"
onClick={onSelect}
aria-label={`+${count}`}
className="flex items-center justify-center bg-transparent transition-[background-color] duration-[var(--dur-fast)] ease-[var(--ease-standard)] hover:bg-[var(--bg-elev)]"
style={{
appearance: 'none',
cursor: 'pointer',
padding: '3px 6px',
borderRadius: 6,
border: 0,
boxShadow: 'inset 0 0 0 1px var(--hairline)',
}}
>
<span
style={{
fontFamily: 'var(--font-mono)',
fontSize: 11,
fontWeight: 500,
color: 'var(--fg-3)',
fontVariantNumeric: 'tabular-nums',
}}
>
+{count}
</span>
</button>
)
}

/** 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
Expand Down Expand Up @@ -345,25 +394,31 @@ export function CalendarTimeGrid({
{allDayLabel}
</span>
</div>
{perColumn.map(({ column, allDay }) => (
<div
key={column.dateStr}
data-testid="time-grid-all-day"
data-date={column.dateStr}
className="flex flex-col"
style={{
gap: 3,
minHeight: 34,
padding: '6px 3px',
borderLeft: '1px solid var(--hairline)',
borderBottom: '1px solid var(--hairline)',
}}
>
{allDay.map((entry) => (
<AllDayChip key={entry.habitId} entry={entry} />
))}
</div>
))}
{perColumn.map(({ column, allDay }) => {
const { visible, overflow } = splitAllDay(allDay)
return (
<div
key={column.dateStr}
data-testid="time-grid-all-day"
data-date={column.dateStr}
className="flex flex-col"
style={{
gap: 3,
minHeight: 34,
padding: '6px 3px',
borderLeft: '1px solid var(--hairline)',
borderBottom: '1px solid var(--hairline)',
}}
>
{visible.map((entry) => (
<AllDayChip key={entry.habitId} entry={entry} />
))}
{overflow > 0 && (
<AllDayMoreChip count={overflow} onSelect={() => onSelectDay(column.dateStr)} />
)}
</div>
)
})}
</div>

<div className="grid" style={{ gridTemplateColumns: gridTemplate, minWidth: gridMinWidth }}>
Expand Down