Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Life Events Responsive Controls TDD Plan

Write the failing regression test before changing the component.

**Goal:** Keep the Life Events event pills and `+ Add event` control readable and non-overlapping on narrow web viewports and with enlarged text.

**Behavior:** Below the small breakpoint, the event list and add control occupy separate stacked rows, the event list may shrink and wrap, and the add control reserves the full available row. At the small breakpoint and above, the existing horizontal layout returns.

**Scope:** Responsive web only. Preserve event selection, pagination, form behavior, server contracts, and the desktop presentation. Do not add or change native mobile behavior.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

**Evidence:** Issue [#2159](https://github.com/Asherlc/dofek/issues/2159) and audit finding `TRACK-06` report that `+ Add event` overlaps the Travel Week event on mobile-width web layouts.

---

## Current Evidence

- `packages/web/src/components/LifeEventsPanel.tsx` puts the wrapping event list and a non-shrinking add button in one always-horizontal flex row.
- The event-list flex item retains its default automatic minimum width, so it can compete with the add button for space instead of shrinking within the row.
- `packages/web/src/components/LifeEventsPanel.stories.tsx` fixes the story frame at 760 px, preventing the existing story from reproducing a narrow viewport accurately.

## Test Strategy

- Unit: render a representative Travel Week event and assert the mobile-first stacking, shrinkability, and full-width add-control contracts, plus their `sm` desktop overrides.
- Storybook: add a representative Travel Week story in a fluid frame.
- Runtime responsive audit: build production Storybook and inspect element rectangles and horizontal overflow at 320, 375, and 390 CSS px.
- Large-text audit: at 320 CSS px, set the root font size to 32 px (200% of the default 16 px), repeat the rectangle and overflow checks, and visually inspect a screenshot.
- Integration: no server or database behavior changes, so no new integration test is needed.

## File Structure

- Modify: `packages/web/src/components/LifeEventsPanel.test.tsx` — add the failing responsive regression.
- Modify: `packages/web/src/components/LifeEventsPanel.tsx` — stack controls below `sm`, allow the event list to shrink, and reserve the add-control row.
- Modify: `packages/web/src/components/LifeEventsPanel.stories.tsx` — make the story frame fluid and add a Travel Week fixture.

## Tasks

### Task 1: Add the Failing Regression

- [ ] Render a Travel Week event in the colocated component test.
- [ ] Assert that the controls stack below `sm`, return to a row at `sm`, and that the event list can shrink.
- [ ] Assert that the add control is full width below `sm` and automatic width at `sm`.
- [ ] Run the focused test and confirm it fails against the current layout.

### Task 2: Implement the Responsive Layout

- [ ] Add the minimum mobile-first flex utilities to stack the control groups.
- [ ] Add `min-w-0` to the wrapping event-list flex item.
- [ ] Reserve the full narrow row for the add control and restore its intrinsic width at `sm`.
- [ ] Run the focused test and confirm it passes.

### Task 3: Make the Reproduction Executable

- [ ] Replace the fixed-width Storybook frame with a fluid frame capped at the existing desktop width.
- [ ] Add a Travel Week story using representative event data.
- [ ] Build production Storybook.
- [ ] Verify no intersection or horizontal overflow at 320, 375, and 390 CSS px.
- [ ] Repeat the 320 px check at a 32 px root font size and inspect screenshots.

### Task 4: Final Verification and Delivery

- [ ] Run relevant lint, typecheck, unit tests, and repository checks.
- [ ] Review the complete diff for scope, accessibility, and desktop regressions.
- [ ] Commit, push, and open one PR with `Fixes #2159`.
- [ ] Link the PR from the issue, move the issue to In review, and monitor CI and review feedback through merge.
24 changes: 23 additions & 1 deletion packages/web/src/components/LifeEventsPanel.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ const creatineEvent = {
notes: "5 g daily",
};

const travelWeekEvent = {
id: "event-travel-week",
label: "Travel Week",
started_at: "2026-07-20",
ended_at: "2026-07-27",
category: "lifestyle",
ongoing: false,
notes: null,
};

const defaultScenario: LifeEventStoryScenario = {
events: [
creatineEvent,
Expand Down Expand Up @@ -67,6 +77,11 @@ const defaultScenario: LifeEventStoryScenario = {
},
};

const travelWeekScenario: LifeEventStoryScenario = {
events: [travelWeekEvent],
analysis: null,
};

const emptyScenario: LifeEventStoryScenario = {
events: [],
analysis: null,
Expand Down Expand Up @@ -119,7 +134,7 @@ function LifeEventsStoryFrame({ scenario }: { scenario: LifeEventStoryScenario }
<UnitContext.Provider
value={{ unitSystem: scenario.unitSystem ?? "metric", setUnitSystem: () => {} }}
>
<div className="w-[760px] p-4">
<div className="w-full max-w-[760px] p-2 sm:p-4">
<LifeEventsPanel />
</div>
</UnitContext.Provider>
Expand Down Expand Up @@ -149,3 +164,10 @@ export const Imperial: Story = {
export const Empty: Story = {
render: () => <LifeEventsStoryFrame scenario={emptyScenario} />,
};

export const TravelWeek: Story = {
parameters: {
viewport: { defaultViewport: "mobile1" },
},
render: () => <LifeEventsStoryFrame scenario={travelWeekScenario} />,
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
};
51 changes: 51 additions & 0 deletions packages/web/src/components/LifeEventsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,57 @@ describe("LifeEventsPanel", () => {
expect(screen.getByText("Life events refresh failed")).toBeDefined();
});

it("stacks the Travel Week event and add control on narrow screens", () => {
mocks.listUseQuery.mockReturnValue({
data: [{ ...event, id: "event-travel", label: "Travel Week", category: "lifestyle" }],
error: null,
isLoading: false,
});

render(<LifeEventsPanel />);

const eventButton = screen.getByRole("button", { name: /Travel Week/i });
const addButton = screen.getByRole("button", { name: "+ Add event" });
const eventList = eventButton.parentElement;
const controls = addButton.parentElement;
if (!(eventList instanceof HTMLElement) || !(controls instanceof HTMLElement)) {
throw new Error("Expected life event controls");
}

expect(controls.classList).toContain("flex-col");
expect(controls.classList).toContain("sm:flex-row");
expect(eventList.classList).toContain("min-w-0");
expect(addButton.classList).toContain("w-full");
expect(addButton.classList).toContain("sm:w-auto");
});

it("stacks add-form fields and controls on narrow screens", () => {
render(<LifeEventsPanel />);
fireEvent.click(screen.getByRole("button", { name: "+ Add event" }));

const typeField = screen.getByText("Type").parentElement;
const typeChoices = screen.getByRole("button", { name: "One-time" }).parentElement;
const formGrid = typeField?.parentElement;
const formActions = screen.getByRole("button", { name: "Cancel" }).parentElement;
const labelField = screen.getByRole("textbox", { name: "Label" }).parentElement;
if (
!(typeChoices instanceof HTMLElement) ||
!(formGrid instanceof HTMLElement) ||
!(formActions instanceof HTMLElement) ||
!(labelField instanceof HTMLElement)
) {
throw new Error("Expected responsive life event form controls");
}

expect(formGrid.classList).toContain("grid-cols-1");
expect(formGrid.classList).toContain("sm:grid-cols-2");
expect(labelField.classList).toContain("sm:col-span-2");
expect(typeChoices.classList).toContain("flex-col");
expect(typeChoices.classList).toContain("sm:flex-row");
expect(formActions.classList).toContain("flex-col");
expect(formActions.classList).toContain("sm:flex-row");
});

it("paginates life events", () => {
mocks.listUseQuery.mockReturnValue({
data: Array.from({ length: 21 }, (_, index) => ({
Expand Down
16 changes: 8 additions & 8 deletions packages/web/src/components/LifeEventsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ export function LifeEventsPanel() {
) : null}

{/* Event list + add button */}
<div className="flex items-center justify-between">
<div className="flex flex-wrap gap-2">
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-wrap gap-2">
{visibleEvents?.map((e) => (
<button
key={e.id}
Expand All @@ -135,7 +135,7 @@ export function LifeEventsPanel() {
<button
type="button"
onClick={() => setShowForm(!showForm)}
className="shrink-0 text-xs px-3 py-1.5 rounded-lg bg-accent/10 border border-border-strong text-foreground hover:bg-surface-hover transition-colors"
className="w-full shrink-0 text-xs px-3 py-1.5 rounded-lg bg-accent/10 border border-border-strong text-foreground hover:bg-surface-hover transition-colors sm:w-auto"
>
+ Add event
</button>
Expand Down Expand Up @@ -230,8 +230,8 @@ function AddEventForm({

return (
<div className="card p-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<label htmlFor="life-event-label" className="text-xs text-subtle block mb-1">
Label
</label>
Expand All @@ -247,7 +247,7 @@ function AddEventForm({

<div>
<span className="text-xs text-subtle block mb-1">Type</span>
<div className="flex gap-2">
<div className="flex flex-col gap-2 sm:flex-row">
{(["point", "range", "ongoing"] as const).map((t) => (
<button
key={t}
Expand Down Expand Up @@ -312,7 +312,7 @@ function AddEventForm({
</div>
)}

<div className="col-span-2">
<div className="sm:col-span-2">
<label htmlFor="life-event-notes" className="text-xs text-subtle block mb-1">
Notes (optional)
</label>
Expand All @@ -327,7 +327,7 @@ function AddEventForm({
</div>
</div>

<div className="flex gap-2 justify-end">
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onCancel}
Expand Down
Loading