fix(mobile): plan landscape collapse + person picker, Odin page scroll, plan toolbar stacking

- PlanGrid maxHeight gets a 360px floor: calc(100dvh - 240px) left
  <160px in phone landscape and the rows vanished under the sticky
  header
- Phones get a person picker above the plan grid (one column at a
  time, defaults to the logged-in user) - the multi-person grid never
  fit a portrait viewport; desktop unchanged
- Odin chat height uses 100svh instead of 100dvh: dvh grows live as
  Android Chrome collapses its address bar, so the page always allowed
  a chrome-height scroll; svh sizes for the bar-visible viewport
  (verified zero overflow at 390x844 via Playwright)
- Plan toolbar stacks into full-width rows on xs (headerActionsSx
  pattern from the detail pages); Dnes/arrows and Tyden/Mesic stay
  paired on one row each

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-10 20:53:52 +02:00
parent f87e110359
commit c21f02e5d1
3 changed files with 214 additions and 157 deletions

View File

@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import type { CSSProperties } from "react";
import { styled } from "@mui/material/styles";
import { styled, useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import Box from "@mui/material/Box";
import {
GridData,
@@ -11,7 +12,8 @@ import {
} from "../lib/queries/plan";
import type { Project } from "../lib/queries/projects";
import PlanRangeChips from "./PlanRangeChips";
import { LoadingState } from "../ui";
import { LoadingState, Select, Field } from "../ui";
import { useAuth } from "../context/AuthContext";
import { fonts } from "../theme";
/**
@@ -30,7 +32,10 @@ const PlanGridRoot = styled(Box)(({ theme }) => ({
border: `1px solid ${theme.vars!.palette.divider}`,
borderRadius: 14,
boxShadow: theme.shadows[2],
maxHeight: "calc(100dvh - 240px)",
// Never collapse below ~5 rows: on a phone in LANDSCAPE 100dvh is only
// ~360-400px, so the bare calc left <160px and the cells disappeared
// under the sticky header. The grid scrolls internally either way.
maxHeight: "max(calc(100dvh - 240px), 360px)",
isolation: "isolate",
"& .plan-grid": {
@@ -464,6 +469,15 @@ export default function PlanGrid({
pulseKey,
onCellClick,
}: Props) {
const theme = useTheme();
// Phone layout: the multi-person grid doesn't fit a portrait viewport, so
// a person picker shows ONE column at a time (Datum + selected person).
// Desktop/tablet keeps all columns.
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const { user: authUser } = useAuth();
// null = "no explicit choice yet" → defaults to the logged-in user when
// they are in the plan, else the first person.
const [mobileUserId, setMobileUserId] = useState<number | null>(null);
const today = useMemo(() => todayIso(), []);
const catMap = useMemo(() => categoryMap(categories), [categories]);
// Index projects by id once per projects change instead of a linear
@@ -483,7 +497,15 @@ export default function PlanGrid({
);
if (!data) return <LoadingState />;
const users = data.users;
const allUsers = data.users;
// Mobile: narrow to the picked person (derived, not stored — a stale pick
// after the user list changes falls back gracefully).
const mobileUser =
allUsers.find((u) => u.id === mobileUserId) ??
allUsers.find((u) => u.id === authUser?.id) ??
allUsers[0];
const users =
isMobile && allUsers.length > 1 && mobileUser ? [mobileUser] : allUsers;
// pulseKey?.nonce is included in the data-pulse attribute so a second
// mutation on the same cell re-triggers the animation (CSS animations
// don't restart unless the keyframe applies to a fresh element/class
@@ -493,6 +515,21 @@ export default function PlanGrid({
: undefined;
return (
<>
{isMobile && allUsers.length > 1 && (
<Box sx={{ mb: 1.5 }}>
<Field label="Osoba">
<Select
value={String(mobileUser?.id ?? "")}
onChange={(value) => setMobileUserId(Number(value))}
options={allUsers.map((u) => ({
value: String(u.id),
label: u.full_name,
}))}
/>
</Field>
</Box>
)}
<PlanGridRoot data-pulse={pulseAttr}>
<table className="plan-grid">
{/* The colgroup is what actually controls column width in a
@@ -644,5 +681,6 @@ export default function PlanGrid({
</tbody>
</table>
</PlanGridRoot>
</>
);
}

View File

@@ -422,7 +422,12 @@ export default function OdinChat() {
return (
<Box
sx={{
height: "calc(100dvh - 100px)",
// svh (NOT dvh): dvh grows live as the mobile URL bar collapses, so a
// dvh-sized full-height layout always lets the page scroll by the
// browser-chrome height. svh sizes for the bar-visible viewport — the
// page never scrolls and the chat's message list scrolls internally.
// Desktop: svh === vh.
height: "calc(100svh - 100px)",
display: "flex",
border: 1,
borderColor: "divider",

View File

@@ -667,19 +667,29 @@ export default function PlanWork() {
</Box>
)}
{/* Mobile: stacked full-width rows (same pattern as headerActionsSx on
the detail pages); sm+ keeps the single wrapping toolbar row. The
paired controls (Dnes/arrows, Týden/Měsíc) stay together as one
full-width row each. */}
<Box
sx={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
flexDirection: { xs: "column", sm: "row" },
flexWrap: { sm: "wrap" },
alignItems: { xs: "stretch", sm: "center" },
gap: 1.5,
mb: 2,
}}
>
{/* Nav buttons grouped so they stay on one row when the toolbar
wraps on mobile. */}
<Box sx={{ display: "inline-flex", gap: 1 }}>
<Button variant="outlined" color="inherit" onClick={goToToday}>
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="outlined"
color="inherit"
onClick={goToToday}
sx={{ flex: { xs: 1, sm: "0 0 auto" } }}
>
Dnes
</Button>
<Button
@@ -701,8 +711,8 @@ export default function PlanWork() {
</Box>
<Box
sx={{
flex: 1,
minWidth: 220,
flex: { sm: 1 },
minWidth: { sm: 220 },
display: "flex",
justifyContent: "center",
}}
@@ -727,7 +737,11 @@ export default function PlanWork() {
<Box
role="group"
aria-label="Měřítko zobrazení"
sx={{ display: "inline-flex", gap: 1 }}
sx={{
display: "flex",
gap: 1,
"& > button": { flex: { xs: 1, sm: "0 0 auto" } },
}}
>
<Button
variant={view === "week" ? "contained" : "outlined"}