Compare commits

...

8 Commits

Author SHA1 Message Date
BOHA
2dbacc3bec chore(release): v2.4.10 - mobile UX fixes (plan grid, Odin, toolbar)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:54:36 +02:00
BOHA
c21f02e5d1 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>
2026-06-10 20:53:52 +02:00
BOHA
f87e110359 chore(release): v2.4.9 - cache-header hotfix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:34:37 +02:00
BOHA
f1b1329c1b fix(spa): cacheControl:false so custom cache headers actually apply
@fastify/static's default Cache-Control management overwrote the
setHeaders values with 'public, max-age=0' (verified on prod after the
v2.4.8 deploy) - the README requires disabling it for custom headers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:34:31 +02:00
BOHA
88d5d43448 chore(release): v2.4.8 - deploy-skew self-healing (stale chunk auto-reload + cache headers)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:32:49 +02:00
BOHA
4745af3639 fix(spa): self-heal stale clients after deploys (vite:preloadError + cache headers)
After a release the old hashed chunks are deleted, so an open tab from
the previous build failed its next lazy-page import until a manual
refresh. Three-part fix per the Vite deploy guide:

- vite:preloadError listener reloads the page once (10s sessionStorage
  guard against loops) so stale tabs heal themselves
- static cache headers: /assets/* immutable for a year (content-hashed
  names), index.html no-cache so new visits always see fresh chunk refs
- missing /assets/* now 404 instead of getting the SPA index.html
  fallback (HTML-as-JS masked the failure)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:31:59 +02:00
BOHA
4258699b73 chore(release): v2.4.7 - mobile detail-header overflow fix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:22:24 +02:00
BOHA
bfd2c59ad3 fix(ui): detail-form headers no longer overflow on mobile
Two causes: theme h4 had no responsive size (desktop 2.125rem on
phones - now 1.5rem under 600px, applies to all page headlines), and
the Zpet+title header row had no flexWrap so long document titles
pushed past the viewport (issued orders, offers, both invoice views).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:21:29 +02:00
11 changed files with 303 additions and 165 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.6", "version": "2.4.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "2.4.6", "version": "2.4.10",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.102.0", "@anthropic-ai/sdk": "^0.102.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.6", "version": "2.4.10",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {

View File

@@ -1,6 +1,7 @@
import { useMemo } from "react"; import { useMemo, useState } from "react";
import type { CSSProperties } 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 Box from "@mui/material/Box";
import { import {
GridData, GridData,
@@ -11,7 +12,8 @@ import {
} from "../lib/queries/plan"; } from "../lib/queries/plan";
import type { Project } from "../lib/queries/projects"; import type { Project } from "../lib/queries/projects";
import PlanRangeChips from "./PlanRangeChips"; import PlanRangeChips from "./PlanRangeChips";
import { LoadingState } from "../ui"; import { LoadingState, Select, Field } from "../ui";
import { useAuth } from "../context/AuthContext";
import { fonts } from "../theme"; import { fonts } from "../theme";
/** /**
@@ -30,7 +32,10 @@ const PlanGridRoot = styled(Box)(({ theme }) => ({
border: `1px solid ${theme.vars!.palette.divider}`, border: `1px solid ${theme.vars!.palette.divider}`,
borderRadius: 14, borderRadius: 14,
boxShadow: theme.shadows[2], 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", isolation: "isolate",
"& .plan-grid": { "& .plan-grid": {
@@ -464,6 +469,15 @@ export default function PlanGrid({
pulseKey, pulseKey,
onCellClick, onCellClick,
}: Props) { }: 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 today = useMemo(() => todayIso(), []);
const catMap = useMemo(() => categoryMap(categories), [categories]); const catMap = useMemo(() => categoryMap(categories), [categories]);
// Index projects by id once per projects change instead of a linear // Index projects by id once per projects change instead of a linear
@@ -483,7 +497,15 @@ export default function PlanGrid({
); );
if (!data) return <LoadingState />; 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 // pulseKey?.nonce is included in the data-pulse attribute so a second
// mutation on the same cell re-triggers the animation (CSS animations // mutation on the same cell re-triggers the animation (CSS animations
// don't restart unless the keyframe applies to a fresh element/class // don't restart unless the keyframe applies to a fresh element/class
@@ -493,156 +515,172 @@ export default function PlanGrid({
: undefined; : undefined;
return ( return (
<PlanGridRoot data-pulse={pulseAttr}> <>
<table className="plan-grid"> {isMobile && allUsers.length > 1 && (
{/* The colgroup is what actually controls column width in a <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
table — `min-width` on `<th>`/`<td>` is just a floor that table — `min-width` on `<th>`/`<td>` is just a floor that
`table-layout: auto` happily blows past. The first column `table-layout: auto` happily blows past. The first column
gets a fixed width via the col element so the date stamp gets a fixed width via the col element so the date stamp
doesn't get stretched to share space with person columns. */} doesn't get stretched to share space with person columns. */}
<colgroup> <colgroup>
<col className="plan-grid-date-col" /> <col className="plan-grid-date-col" />
{users.map((u) => ( {users.map((u) => (
<col key={u.id} /> <col key={u.id} />
))} ))}
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th className="plan-grid-date-col">Datum</th> <th className="plan-grid-date-col">Datum</th>
{users.map((u) => { {users.map((u) => {
const { first, last } = splitName(u.full_name); const { first, last } = splitName(u.full_name);
return ( return (
<th key={u.id}> <th key={u.id}>
<span className="plan-person-head"> <span className="plan-person-head">
<span className="plan-person-dot" aria-hidden /> <span className="plan-person-dot" aria-hidden />
<span className="plan-person-name"> <span className="plan-person-name">
<strong title={u.full_name}> <strong title={u.full_name}>
{first} {first}
{last ? ` ${last}` : ""} {last ? ` ${last}` : ""}
</strong> </strong>
{shortRole(u.role_name) && ( {shortRole(u.role_name) && (
<small>{shortRole(u.role_name)}</small> <small>{shortRole(u.role_name)}</small>
)} )}
</span>
</span> </span>
</span> </th>
</th> );
})}
</tr>
</thead>
<tbody>
{days.map((date) => {
const dow = czechWeekday(date);
const dayNum = date.slice(8, 10);
const isToday = date === today;
const trCls = [
isWeekend(date) ? "plan-grid-weekend" : "",
isToday ? "is-today" : "",
]
.filter(Boolean)
.join(" ");
return (
<tr key={date} className={trCls}>
<td className="plan-grid-date-col">
<span className="plan-date-stamp">
<span className="plan-date-daynum">{dayNum}</span>
<span className="plan-date-dow">{dow}</span>
</span>
</td>
{users.map((u) => {
const cellArr = data.cells[u.id]?.[date] ?? [];
const cell = cellArr[0] ?? null;
const past = isPastDate(date, today);
// Past-day cells are read-only in the UI: an empty past
// cell is non-interactive (no create modal, no "+" hint),
// a past cell with data opens in view mode only. The
// server still enforces the past-date rule with a 403
// (defense in depth), but the click path here never
// reaches a create/edit submission for a past date.
const isLocked = !canEdit || past;
// `isPulsing` is true for the single (user, date) cell
// that the most recent successful mutation touched.
// CSS restarts the keyframe animation whenever the
// `nonce` changes (we embed it in data-pulse on the
// wrapper, see above), so back-to-back mutations on the
// same cell re-trigger the pulse.
const isPulsing =
!!pulseKey &&
pulseKey.userId === u.id &&
pulseKey.date === date;
let cls: string;
if (cell) {
cls = isLocked
? `plan-cell plan-cell--readonly${past ? " plan-cell--past" : ""}`
: "plan-cell";
} else {
cls = isLocked
? `plan-cell plan-cell--empty plan-cell--readonly${past ? " plan-cell--past" : ""}`
: "plan-cell plan-cell--empty";
}
if (isPulsing) cls += " plan-cell--pulse";
return (
<td key={u.id}>
<button
type="button"
className={cls}
style={
cell
? ({
"--cat-color": catMap[cell.category]?.color,
} as CSSProperties)
: undefined
}
onClick={() => onCellClick(u.id, date, cellArr)}
aria-label={
cell
? cellArr.length === 1
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
: `${u.full_name}, ${date}, ${cellArr.length} záznamy`
: isLocked
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
: `${u.full_name}, ${date}, prázdné — přidat záznam`
}
>
{cellArr.map((c, i) => (
<Box
key={
c.entryId != null
? c.entryId
: c.overrideId != null
? `o${c.overrideId}`
: i
}
className="plan-cell-record"
style={
{
"--cat-color": catMap[c.category]?.color,
} as CSSProperties
}
>
<PlanRangeChips
cell={c}
project={
c.project_id
? (projectMap.get(c.project_id) ?? null)
: null
}
categoryLabel={planCategoryLabel(
c.category,
catMap,
)}
/>
</Box>
))}
</button>
</td>
);
})}
</tr>
); );
})} })}
</tr> </tbody>
</thead> </table>
<tbody> </PlanGridRoot>
{days.map((date) => { </>
const dow = czechWeekday(date);
const dayNum = date.slice(8, 10);
const isToday = date === today;
const trCls = [
isWeekend(date) ? "plan-grid-weekend" : "",
isToday ? "is-today" : "",
]
.filter(Boolean)
.join(" ");
return (
<tr key={date} className={trCls}>
<td className="plan-grid-date-col">
<span className="plan-date-stamp">
<span className="plan-date-daynum">{dayNum}</span>
<span className="plan-date-dow">{dow}</span>
</span>
</td>
{users.map((u) => {
const cellArr = data.cells[u.id]?.[date] ?? [];
const cell = cellArr[0] ?? null;
const past = isPastDate(date, today);
// Past-day cells are read-only in the UI: an empty past
// cell is non-interactive (no create modal, no "+" hint),
// a past cell with data opens in view mode only. The
// server still enforces the past-date rule with a 403
// (defense in depth), but the click path here never
// reaches a create/edit submission for a past date.
const isLocked = !canEdit || past;
// `isPulsing` is true for the single (user, date) cell
// that the most recent successful mutation touched.
// CSS restarts the keyframe animation whenever the
// `nonce` changes (we embed it in data-pulse on the
// wrapper, see above), so back-to-back mutations on the
// same cell re-trigger the pulse.
const isPulsing =
!!pulseKey &&
pulseKey.userId === u.id &&
pulseKey.date === date;
let cls: string;
if (cell) {
cls = isLocked
? `plan-cell plan-cell--readonly${past ? " plan-cell--past" : ""}`
: "plan-cell";
} else {
cls = isLocked
? `plan-cell plan-cell--empty plan-cell--readonly${past ? " plan-cell--past" : ""}`
: "plan-cell plan-cell--empty";
}
if (isPulsing) cls += " plan-cell--pulse";
return (
<td key={u.id}>
<button
type="button"
className={cls}
style={
cell
? ({
"--cat-color": catMap[cell.category]?.color,
} as CSSProperties)
: undefined
}
onClick={() => onCellClick(u.id, date, cellArr)}
aria-label={
cell
? cellArr.length === 1
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
: `${u.full_name}, ${date}, ${cellArr.length} záznamy`
: isLocked
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
: `${u.full_name}, ${date}, prázdné — přidat záznam`
}
>
{cellArr.map((c, i) => (
<Box
key={
c.entryId != null
? c.entryId
: c.overrideId != null
? `o${c.overrideId}`
: i
}
className="plan-cell-record"
style={
{
"--cat-color": catMap[c.category]?.color,
} as CSSProperties
}
>
<PlanRangeChips
cell={c}
project={
c.project_id
? (projectMap.get(c.project_id) ?? null)
: null
}
categoryLabel={planCategoryLabel(
c.category,
catMap,
)}
/>
</Box>
))}
</button>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</PlanGridRoot>
); );
} }

View File

@@ -422,7 +422,12 @@ export default function OdinChat() {
return ( return (
<Box <Box
sx={{ 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", display: "flex",
border: 1, border: 1,
borderColor: "divider", borderColor: "divider",

View File

@@ -1163,7 +1163,16 @@ export default function InvoiceDetail() {
mb: 3, mb: 3,
}} }}
> >
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> {/* flexWrap: long document titles must drop below the Zpět button
on phones instead of overflowing the viewport. */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button <Button
component={RouterLink} component={RouterLink}
to="/invoices?tab=issued" to="/invoices?tab=issued"
@@ -1724,7 +1733,16 @@ export default function InvoiceDetail() {
mb: 3, mb: 3,
}} }}
> >
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> {/* flexWrap: long document titles must drop below the Zpět button
on phones instead of overflowing the viewport. */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button <Button
component={RouterLink} component={RouterLink}
to="/invoices?tab=issued" to="/invoices?tab=issued"

View File

@@ -573,7 +573,16 @@ export default function IssuedOrderDetail() {
mb: 3, mb: 3,
}} }}
> >
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> {/* flexWrap: long document titles must drop below the Zpět button on
phones instead of overflowing the viewport. */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button <Button
component={RouterLink} component={RouterLink}
to="/orders?tab=vydane" to="/orders?tab=vydane"

View File

@@ -620,7 +620,16 @@ export default function OfferDetail() {
mb: 3, mb: 3,
}} }}
> >
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> {/* flexWrap: long document titles must drop below the Zpět button
on phones instead of overflowing the viewport. */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button <Button
component={RouterLink} component={RouterLink}
to="/offers" to="/offers"

View File

@@ -667,19 +667,29 @@ export default function PlanWork() {
</Box> </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 <Box
sx={{ sx={{
display: "flex", display: "flex",
flexWrap: "wrap", flexDirection: { xs: "column", sm: "row" },
alignItems: "center", flexWrap: { sm: "wrap" },
alignItems: { xs: "stretch", sm: "center" },
gap: 1.5, gap: 1.5,
mb: 2, mb: 2,
}} }}
> >
{/* Nav buttons grouped so they stay on one row when the toolbar {/* Nav buttons grouped so they stay on one row when the toolbar
wraps on mobile. */} wraps on mobile. */}
<Box sx={{ display: "inline-flex", gap: 1 }}> <Box sx={{ display: "flex", gap: 1 }}>
<Button variant="outlined" color="inherit" onClick={goToToday}> <Button
variant="outlined"
color="inherit"
onClick={goToToday}
sx={{ flex: { xs: 1, sm: "0 0 auto" } }}
>
Dnes Dnes
</Button> </Button>
<Button <Button
@@ -701,8 +711,8 @@ export default function PlanWork() {
</Box> </Box>
<Box <Box
sx={{ sx={{
flex: 1, flex: { sm: 1 },
minWidth: 220, minWidth: { sm: 220 },
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
}} }}
@@ -727,7 +737,11 @@ export default function PlanWork() {
<Box <Box
role="group" role="group"
aria-label="Měřítko zobrazení" 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 <Button
variant={view === "week" ? "contained" : "outlined"} variant={view === "week" ? "contained" : "outlined"}

View File

@@ -58,7 +58,13 @@ export const theme = createTheme({
h1: { fontFamily: FONT_HEADING, fontWeight: 800 }, h1: { fontFamily: FONT_HEADING, fontWeight: 800 },
h2: { fontFamily: FONT_HEADING, fontWeight: 800 }, h2: { fontFamily: FONT_HEADING, fontWeight: 800 },
h3: { fontFamily: FONT_HEADING, fontWeight: 800 }, h3: { fontFamily: FONT_HEADING, fontWeight: 800 },
h4: { fontFamily: FONT_HEADING, fontWeight: 700 }, h4: {
fontFamily: FONT_HEADING,
fontWeight: 700,
// Page/detail headlines: MUI's default 2.125rem overflows phone
// viewports (long document titles + number). Scale down on xs only.
"@media (max-width:600px)": { fontSize: "1.5rem" },
},
h5: { fontFamily: FONT_HEADING, fontWeight: 700 }, h5: { fontFamily: FONT_HEADING, fontWeight: 700 },
h6: { fontFamily: FONT_HEADING, fontWeight: 700 }, h6: { fontFamily: FONT_HEADING, fontWeight: 700 },
button: { textTransform: "none", fontWeight: 600 }, button: { textTransform: "none", fontWeight: 600 },

View File

@@ -4,6 +4,22 @@ import { BrowserRouter } from "react-router-dom";
import App from "./App"; import App from "./App";
import { ThemeProvider } from "./context/ThemeContext"; import { ThemeProvider } from "./context/ThemeContext";
// Deploy-skew recovery (official Vite mechanism): after a release the old
// hashed chunks are deleted, so a tab still running the previous build fails
// its next lazy-page import. Vite emits `vite:preloadError` for exactly this —
// swallow the error and reload once, which loads the fresh index.html and new
// chunk names. The timestamp guard prevents a reload loop when a chunk is
// genuinely unloadable (e.g. offline): a second failure within 10 s falls
// through to the normal error path.
window.addEventListener("vite:preloadError", (event) => {
const KEY = "vite-preload-reloaded-at";
const last = Number(sessionStorage.getItem(KEY) || 0);
if (Date.now() - last < 10_000) return;
sessionStorage.setItem(KEY, String(Date.now()));
event.preventDefault();
window.location.reload();
});
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter <BrowserRouter

View File

@@ -206,12 +206,35 @@ async function start() {
root: path.join(__dirname, "..", "dist-client"), root: path.join(__dirname, "..", "dist-client"),
prefix: "/", prefix: "/",
wildcard: false, wildcard: false,
// The plugin's own Cache-Control management must be OFF or it
// overwrites the setHeaders values below with "public, max-age=0"
// (README: "To provide a custom Cache-Control header, set this option
// to false"). ETags stay on, so no-cache still revalidates cheaply.
cacheControl: false,
// Deploy-skew caching contract (per the Vite deploy guide):
// - /assets/* names are content-hashed → safe to cache forever
// (a changed file always gets a NEW name, so "immutable" is correct).
// - index.html (and any non-hashed file) must always be revalidated,
// otherwise a cached copy keeps referencing deleted old chunks.
setHeaders: (res, filePath) => {
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
} else {
res.setHeader("Cache-Control", "no-cache");
}
},
}); });
app.setNotFoundHandler((request, reply) => { app.setNotFoundHandler((request, reply) => {
if (request.url.startsWith("/api/")) { if (request.url.startsWith("/api/")) {
return reply.status(404).send({ success: false, error: "Not found" }); return reply.status(404).send({ success: false, error: "Not found" });
} }
// A missing hashed asset (old chunk after a deploy) must 404, NOT get
// the SPA index.html fallback — HTML-as-JS masks the failure and breaks
// the client's vite:preloadError reload recovery.
if (request.url.startsWith("/assets/")) {
return reply.status(404).send();
}
return reply.sendFile("index.html"); return reply.sendFile("index.html");
}); });
} }