mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(targets): mobile-first responsive grid + card reflow
Per Marc's "mobile is co-equal with desktop" directive, every
breakpoint gets a deliberate layout — no afterthought reflow:
• default (<640px): grid-cols-1, full-width cards, gap-4. The
per-card Coach CTA spans full width (justify-center) so the
touch target is generous; the source link sits beneath
right-aligned.
• sm (640-1023px): grid-cols-2, gap-4. Card footer reverts to
horizontal — Coach CTA left, source link right.
• lg (1024px+): grid-cols-3, gap-6. Three cards per row matches
the dashboard / insights rhythm and lets the user see all six
core targets above the fold on a 1440px viewport.
Card internals also reflow:
• Headline row: icon + label stack with status pill below on
mobile (sm:flex-row sm:justify-between), inline on sm+.
• Footer row: flex-col items-stretch on mobile,
sm:flex-row sm:items-center sm:justify-between on sm+.
New `targets-responsive.test.tsx` pins the breakpoint classes so a
future refactor can't quietly collapse the grid back to v1.4.22's
sm:grid-cols-2 only layout.
Verified by inspection (Tailwind class audit). A Playwright sweep
across 375 / 768 / 1280 / 1440 viewports is queued for the W3e
verification phase.
This commit is contained in:
1 parent
9acee2bd81
commit
f223b4feae
2 files changed
+151
-13
No files matched your search
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.25 W3e — mobile-first responsive layout audit.
|
||||
*
|
||||
* Marc directive: "mobile layout must be designed alongside desktop,
|
||||
* not as an afterthought". This test pins the Tailwind responsive
|
||||
* utilities so a future refactor cannot quietly collapse the grid
|
||||
* back to the v1.4.22 `sm:grid-cols-2` and remove the lg three-col
|
||||
* row.
|
||||
*
|
||||
* The three breakpoints we care about:
|
||||
* • default (<640px): one column, full-width cards
|
||||
* • sm (640-1023px): two columns
|
||||
* • lg (1024px+): three columns, slightly wider gutters
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/targets",
|
||||
}));
|
||||
|
||||
const sampleData = {
|
||||
targets: [
|
||||
{
|
||||
type: "WEIGHT",
|
||||
label: "Weight",
|
||||
current: 78,
|
||||
average30: 78,
|
||||
trend: "stable",
|
||||
unit: "kg",
|
||||
range: { min: 60, max: 80 },
|
||||
classification: { category: "Normal", color: "#50fa7b" },
|
||||
source: "WHO BMI",
|
||||
daysInRange7d: 5,
|
||||
daysLogged7d: 7,
|
||||
lastMetGoalAt: null,
|
||||
streakDays: 0,
|
||||
insufficientData: false,
|
||||
consistency7d: ["in", "in", "in", "in", "in", "in", "in"],
|
||||
},
|
||||
],
|
||||
pageSummary: {
|
||||
targetsMetThisWeek: 1,
|
||||
totalTargets: 1,
|
||||
streakHighlight: null,
|
||||
},
|
||||
bpDiastolic: { current: null, average30: null, range: null },
|
||||
profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null },
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-query", () => {
|
||||
const noopClient = {
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
refetchQueries: vi.fn(),
|
||||
};
|
||||
return {
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") {
|
||||
return {
|
||||
data: { activeProvider: "openai" },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: sampleData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
},
|
||||
useQueryClient: () => noopClient,
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
reset: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
role: "USER",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import TargetsPage from "../targets/page";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<TargetsPage />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("/targets page — mobile-first responsive grid", () => {
|
||||
it("uses one column by default, two at sm, three at lg", () => {
|
||||
const html = render();
|
||||
expect(html).toMatch(/grid-cols-1/);
|
||||
expect(html).toMatch(/sm:grid-cols-2/);
|
||||
expect(html).toMatch(/lg:grid-cols-3/);
|
||||
});
|
||||
|
||||
it("uses gap-4 at mobile/sm, gap-6 at lg", () => {
|
||||
const html = render();
|
||||
expect(html).toMatch(/\bgap-4\b/);
|
||||
expect(html).toMatch(/lg:gap-6/);
|
||||
});
|
||||
|
||||
it("renders the Coach CTA full-width on mobile when AI is enabled", () => {
|
||||
const html = render();
|
||||
// The TargetCoachButton wraps the Sparkles + "Ask Coach" label;
|
||||
// its container element should carry the mobile full-width class.
|
||||
expect(html).toContain("w-full");
|
||||
expect(html).toContain("sm:w-auto");
|
||||
});
|
||||
});
|
||||
@@ -617,29 +617,34 @@ export function TargetCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 6: footer — Coach CTA on the left (gated), source link
|
||||
on the right (when available). Margin-top:auto pins the
|
||||
footer to the card bottom so the grid rows align. */}
|
||||
<div className="mt-auto flex flex-wrap items-center justify-between gap-3 pt-1">
|
||||
<TargetCoachButton
|
||||
prefill={coachPrefill}
|
||||
sources={coachSources}
|
||||
onAskCoach={onAskCoach}
|
||||
aiEnabled={aiEnabled}
|
||||
className="-ml-2"
|
||||
/>
|
||||
{/* Row 6: footer. Mobile-first reflow:
|
||||
- default (<640px): stack vertically; the Coach CTA
|
||||
spans full width so the touch target is generous, the
|
||||
source link sits beneath it right-aligned.
|
||||
- sm+ (≥640px): horizontal row, Coach left + source right.
|
||||
`mt-auto` pins to card bottom so grid cells align. */}
|
||||
<div className="mt-auto flex flex-col items-stretch gap-2 pt-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
{aiEnabled && (
|
||||
<TargetCoachButton
|
||||
prefill={coachPrefill}
|
||||
sources={coachSources}
|
||||
onAskCoach={onAskCoach}
|
||||
aiEnabled={aiEnabled}
|
||||
className="w-full justify-center sm:-ml-2 sm:w-auto sm:justify-start"
|
||||
/>
|
||||
)}
|
||||
{sourceLink ? (
|
||||
<a
|
||||
href={sourceLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground ml-auto inline-flex items-center gap-1 text-xs"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 self-end text-xs sm:ml-auto sm:self-auto"
|
||||
>
|
||||
<span>{target.source}</span>
|
||||
<ExternalLink className="size-3" aria-hidden="true" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground ml-auto text-xs">
|
||||
<span className="text-muted-foreground self-end text-xs sm:ml-auto sm:self-auto">
|
||||
{target.source}
|
||||
</span>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user