Compare commits

...

2 Commits

Author SHA1 Message Date
BOHA
4674ff3389 fix(dashboard): equal-height cards — stretch + fill instead of ragged
The dashboard content grids used alignItems:"start", so cards in a row sized to
their own content (ragged bottoms). Switch to alignItems:"stretch" so each row
shares one height, and make the wrapped cards actually fill the cell:

- DashProfile / DashSessions: motion-div + Card height:100% (the inner card was
  shorter than its stretched cell).
- Right column (Aktivní projekty + Nabídky): cards flex:1 so the stacked pair
  fills the column to match the neighbouring single cards.

CSS-only; no logic change. tsc clean, lint 0, 669 tests pass. Verified live via
Playwright (3-col row cells equal at 607; bottom 2-col cards equal at 257).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:34:17 +02:00
BOHA
4bb7de7247 feat(app): detect new deploy and prompt refresh (+ lazy-chunk recovery)
When a new version is deployed, open tabs no longer silently run stale code
until a manual F5.

- Server stamps every response with X-App-Version (src/middleware/version.ts,
  onSend hook in server.ts).
- Client bakes its build version via Vite define (__APP_VERSION__); apiFetch
  compares the header to it (no polling — piggybacks existing traffic) and
  latches a mismatch in a small store (appVersion.ts).
- UpdateBanner: a persistent bottom Snackbar "Je k dispozici nová verze
  aplikace. — Obnovit" → location.reload(). User-initiated, so an in-progress
  form is never interrupted.
- lazyWithReload: every React.lazy page reloads once if its hashed chunk 404s
  after a deploy (sessionStorage-guarded against loops), fixing the "old chunk
  gone" error screen.

Approach chosen after researching current SPA practice (version header + banner
beats polling / a service worker for a non-PWA admin app).

Tests: +4 (server header, client store latch/notify). Full suite 669 pass,
tsc -b clean, lint 0. Verified live via Playwright (header emitted; banner
appears on a simulated mismatch). Detection applies to deploys from the NEXT
release onward (current clients lack the detection code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:19:46 +02:00
15 changed files with 341 additions and 57 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.36", "version": "2.4.38",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "2.4.36", "version": "2.4.38",
"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.36", "version": "2.4.38",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {

View File

@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
reportServerVersion,
subscribeNewVersion,
getNewVersion,
BUILD_VERSION,
__resetVersionStateForTest,
} from "../admin/utils/appVersion";
/**
* Client store backing the "new version available" banner. The server stamps
* X-App-Version on every response; apiFetch feeds it here. A version that
* differs from the one baked into THIS bundle (BUILD_VERSION) means a deploy
* happened → latch it and notify subscribers (the banner).
*/
describe("appVersion store", () => {
beforeEach(() => __resetVersionStateForTest());
it("ignores the same version, empty, or null", () => {
reportServerVersion(BUILD_VERSION);
reportServerVersion("");
reportServerVersion(null);
reportServerVersion(undefined);
expect(getNewVersion()).toBeNull();
});
it("latches + notifies once when the server reports a different version", () => {
let notified = 0;
const unsub = subscribeNewVersion(() => {
notified++;
});
reportServerVersion(`${BUILD_VERSION}-next`);
expect(getNewVersion()).toBe(`${BUILD_VERSION}-next`);
expect(notified).toBe(1);
// Idempotent: once a new version is known, further reports don't re-notify
// or overwrite (the banner is already showing).
reportServerVersion("99.0.0");
expect(getNewVersion()).toBe(`${BUILD_VERSION}-next`);
expect(notified).toBe(1);
unsub();
});
});

View File

@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import Fastify from "fastify";
import { registerVersionHeader, readAppVersion } from "../middleware/version";
/**
* The server stamps every response with X-App-Version so the SPA can detect a
* deploy (the client compares it to the version baked into its bundle).
*/
describe("registerVersionHeader", () => {
it("adds X-App-Version to responses", async () => {
const app = Fastify({ logger: false });
registerVersionHeader(app, "9.9.9");
app.get("/ping", async () => ({ ok: true }));
const res = await app.inject({ method: "GET", url: "/ping" });
expect(res.headers["x-app-version"]).toBe("9.9.9");
await app.close();
});
it("defaults to the package.json version (semver string)", () => {
expect(readAppVersion()).toMatch(/^\d+\.\d+\.\d+/);
});
});

View File

@@ -7,65 +7,97 @@ import { queryClient } from "./lib/queryClient";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import AppShell from "./ui/AppShell"; import AppShell from "./ui/AppShell";
import AlertContainer from "./components/AlertContainer"; import AlertContainer from "./components/AlertContainer";
import UpdateBanner from "./components/UpdateBanner";
import { lazyWithReload } from "./utils/lazyWithReload";
import MuiProvider from "./ui/MuiProvider"; import MuiProvider from "./ui/MuiProvider";
import { LoadingState } from "./ui"; import { LoadingState } from "./ui";
import Login from "./pages/Login"; import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard"; import Dashboard from "./pages/Dashboard";
const Odin = lazy(() => import("./pages/Odin")); const Odin = lazyWithReload(() => import("./pages/Odin"));
const Users = lazy(() => import("./pages/Users")); const Users = lazyWithReload(() => import("./pages/Users"));
const Attendance = lazy(() => import("./pages/Attendance")); const Attendance = lazyWithReload(() => import("./pages/Attendance"));
const AttendanceHistory = lazy(() => import("./pages/AttendanceHistory")); const AttendanceHistory = lazyWithReload(
const AttendanceAdmin = lazy(() => import("./pages/AttendanceAdmin")); () => import("./pages/AttendanceHistory"),
const AttendanceBalances = lazy(() => import("./pages/AttendanceBalances")); );
const AttendanceCreate = lazy(() => import("./pages/AttendanceCreate")); const AttendanceAdmin = lazyWithReload(() => import("./pages/AttendanceAdmin"));
const LeaveRequests = lazy(() => import("./pages/LeaveRequests")); const AttendanceBalances = lazyWithReload(
const LeaveApproval = lazy(() => import("./pages/LeaveApproval")); () => import("./pages/AttendanceBalances"),
const AttendanceLocation = lazy(() => import("./pages/AttendanceLocation")); );
const PlanWork = lazy(() => import("./pages/PlanWork")); const AttendanceCreate = lazyWithReload(
const Trips = lazy(() => import("./pages/Trips")); () => import("./pages/AttendanceCreate"),
const TripsHistory = lazy(() => import("./pages/TripsHistory")); );
const TripsAdmin = lazy(() => import("./pages/TripsAdmin")); const LeaveRequests = lazyWithReload(() => import("./pages/LeaveRequests"));
const Vehicles = lazy(() => import("./pages/Vehicles")); const LeaveApproval = lazyWithReload(() => import("./pages/LeaveApproval"));
const Offers = lazy(() => import("./pages/Offers")); const AttendanceLocation = lazyWithReload(
const OfferDetail = lazy(() => import("./pages/OfferDetail")); () => import("./pages/AttendanceLocation"),
const OffersCustomers = lazy(() => import("./pages/OffersCustomers")); );
const OffersTemplates = lazy(() => import("./pages/OffersTemplates")); const PlanWork = lazyWithReload(() => import("./pages/PlanWork"));
const Orders = lazy(() => import("./pages/Orders")); const Trips = lazyWithReload(() => import("./pages/Trips"));
const OrderDetail = lazy(() => import("./pages/OrderDetail")); const TripsHistory = lazyWithReload(() => import("./pages/TripsHistory"));
const IssuedOrderDetail = lazy(() => import("./pages/IssuedOrderDetail")); const TripsAdmin = lazyWithReload(() => import("./pages/TripsAdmin"));
const Projects = lazy(() => import("./pages/Projects")); const Vehicles = lazyWithReload(() => import("./pages/Vehicles"));
const ProjectDetail = lazy(() => import("./pages/ProjectDetail")); const Offers = lazyWithReload(() => import("./pages/Offers"));
const Invoices = lazy(() => import("./pages/Invoices")); const OfferDetail = lazyWithReload(() => import("./pages/OfferDetail"));
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail")); const OffersCustomers = lazyWithReload(() => import("./pages/OffersCustomers"));
const Settings = lazy(() => import("./pages/Settings")); const OffersTemplates = lazyWithReload(() => import("./pages/OffersTemplates"));
const AuditLog = lazy(() => import("./pages/AuditLog")); const Orders = lazyWithReload(() => import("./pages/Orders"));
const NotFound = lazy(() => import("./pages/NotFound")); const OrderDetail = lazyWithReload(() => import("./pages/OrderDetail"));
const Warehouse = lazy(() => import("./pages/Warehouse")); const IssuedOrderDetail = lazyWithReload(
const WarehouseItems = lazy(() => import("./pages/WarehouseItems")); () => import("./pages/IssuedOrderDetail"),
const WarehouseItemDetail = lazy(() => import("./pages/WarehouseItemDetail")); );
const WarehouseReceipts = lazy(() => import("./pages/WarehouseReceipts")); const Projects = lazyWithReload(() => import("./pages/Projects"));
const WarehouseReceiptDetail = lazy( const ProjectDetail = lazyWithReload(() => import("./pages/ProjectDetail"));
const Invoices = lazyWithReload(() => import("./pages/Invoices"));
const InvoiceDetail = lazyWithReload(() => import("./pages/InvoiceDetail"));
const Settings = lazyWithReload(() => import("./pages/Settings"));
const AuditLog = lazyWithReload(() => import("./pages/AuditLog"));
const NotFound = lazyWithReload(() => import("./pages/NotFound"));
const Warehouse = lazyWithReload(() => import("./pages/Warehouse"));
const WarehouseItems = lazyWithReload(() => import("./pages/WarehouseItems"));
const WarehouseItemDetail = lazyWithReload(
() => import("./pages/WarehouseItemDetail"),
);
const WarehouseReceipts = lazyWithReload(
() => import("./pages/WarehouseReceipts"),
);
const WarehouseReceiptDetail = lazyWithReload(
() => import("./pages/WarehouseReceiptDetail"), () => import("./pages/WarehouseReceiptDetail"),
); );
const WarehouseReceiptForm = lazy(() => import("./pages/WarehouseReceiptForm")); const WarehouseReceiptForm = lazyWithReload(
const WarehouseIssues = lazy(() => import("./pages/WarehouseIssues")); () => import("./pages/WarehouseReceiptForm"),
const WarehouseIssueDetail = lazy(() => import("./pages/WarehouseIssueDetail")); );
const WarehouseIssueForm = lazy(() => import("./pages/WarehouseIssueForm")); const WarehouseIssues = lazyWithReload(() => import("./pages/WarehouseIssues"));
const WarehouseReservations = lazy( const WarehouseIssueDetail = lazyWithReload(
() => import("./pages/WarehouseIssueDetail"),
);
const WarehouseIssueForm = lazyWithReload(
() => import("./pages/WarehouseIssueForm"),
);
const WarehouseReservations = lazyWithReload(
() => import("./pages/WarehouseReservations"), () => import("./pages/WarehouseReservations"),
); );
const WarehouseInventory = lazy(() => import("./pages/WarehouseInventory")); const WarehouseInventory = lazyWithReload(
const WarehouseInventoryDetail = lazy( () => import("./pages/WarehouseInventory"),
);
const WarehouseInventoryDetail = lazyWithReload(
() => import("./pages/WarehouseInventoryDetail"), () => import("./pages/WarehouseInventoryDetail"),
); );
const WarehouseInventoryForm = lazy( const WarehouseInventoryForm = lazyWithReload(
() => import("./pages/WarehouseInventoryForm"), () => import("./pages/WarehouseInventoryForm"),
); );
const WarehouseReports = lazy(() => import("./pages/WarehouseReports")); const WarehouseReports = lazyWithReload(
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers")); () => import("./pages/WarehouseReports"),
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations")); );
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories")); const WarehouseSuppliers = lazyWithReload(
() => import("./pages/WarehouseSuppliers"),
);
const WarehouseLocations = lazyWithReload(
() => import("./pages/WarehouseLocations"),
);
const WarehouseCategories = lazyWithReload(
() => import("./pages/WarehouseCategories"),
);
const UiKit = import.meta.env.DEV ? lazy(() => import("./pages/UiKit")) : null; const UiKit = import.meta.env.DEV ? lazy(() => import("./pages/UiKit")) : null;
export default function AdminApp() { export default function AdminApp() {
@@ -75,6 +107,7 @@ export default function AdminApp() {
<AlertProvider> <AlertProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AlertContainer /> <AlertContainer />
<UpdateBanner />
<ErrorBoundary> <ErrorBoundary>
<Suspense fallback={<LoadingState />}> <Suspense fallback={<LoadingState />}>
<Routes> <Routes>

View File

@@ -0,0 +1,35 @@
import { useSyncExternalStore } from "react";
import Snackbar from "@mui/material/Snackbar";
import Button from "@mui/material/Button";
import { subscribeNewVersion, getNewVersion } from "../utils/appVersion";
/**
* Shows a persistent banner when the server reports a newer build than the one
* this tab is running (detected via the X-App-Version header in apiFetch). The
* reload is user-initiated — we never force it, so an in-progress form is safe.
*/
export default function UpdateBanner() {
const newVersion = useSyncExternalStore(
subscribeNewVersion,
getNewVersion,
() => null,
);
return (
<Snackbar
open={Boolean(newVersion)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
message="Je k dispozici nová verze aplikace."
action={
<Button
color="primary"
size="small"
variant="contained"
onClick={() => window.location.reload()}
>
Obnovit
</Button>
}
/>
);
}

View File

@@ -262,8 +262,9 @@ export default function DashProfile({
initial={reduce ? false : { opacity: 0, y: 12 }} initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }} animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }} transition={{ duration: 0.25, delay: 0.15 }}
style={{ height: "100%" }}
> >
<Card> <Card sx={{ height: "100%" }}>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",

View File

@@ -132,8 +132,9 @@ export default function DashSessions() {
initial={reduce ? false : { opacity: 0, y: 12 }} initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }} animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }} transition={{ duration: 0.25, delay: 0.15 }}
style={{ height: "100%" }}
> >
<Card sx={{ display: "flex", flexDirection: "column" }}> <Card sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",

View File

@@ -348,7 +348,9 @@ export default function Dashboard() {
display: "grid", display: "grid",
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" }, gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" },
gap: 3, gap: 3,
alignItems: "start", // stretch (not start) so the cards in a row share one height —
// even bottoms instead of ragged content-height cards.
alignItems: "stretch",
mb: 3, mb: 3,
}} }}
> >
@@ -360,10 +362,18 @@ export default function Dashboard() {
<DashAttendanceToday attendance={dashData?.attendance ?? null} /> <DashAttendanceToday attendance={dashData?.attendance ?? null} />
)} )}
{/* Pravy sloupec: projekty + nabidky */} {/* Pravy sloupec: projekty + nabidky — both grow so the stacked
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}> cards fill the column to match the neighbouring cards' height. */}
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 3,
height: "100%",
}}
>
{dashData?.projects && ( {dashData?.projects && (
<Card sx={{ display: "flex", flexDirection: "column" }}> <Card sx={{ display: "flex", flexDirection: "column", flex: 1 }}>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@@ -421,7 +431,7 @@ export default function Dashboard() {
)} )}
{dashData?.offers && ( {dashData?.offers && (
<Card sx={{ display: "flex", flexDirection: "column" }}> <Card sx={{ display: "flex", flexDirection: "column", flex: 1 }}>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@@ -498,7 +508,7 @@ export default function Dashboard() {
display: "grid", display: "grid",
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" },
gap: 3, gap: 3,
alignItems: "start", alignItems: "stretch",
}} }}
> >
<DashProfile <DashProfile

View File

@@ -1,3 +1,5 @@
import { reportServerVersion } from "./appVersion";
class ApiState { class ApiState {
showSessionExpiredAlert = false; showSessionExpiredAlert = false;
showLogoutAlert = false; showLogoutAlert = false;
@@ -120,6 +122,8 @@ export const apiFetch = async (
headers, headers,
credentials: "include", credentials: "include",
}); });
// Detect a new deploy: compare the server's build version to ours.
reportServerVersion(response.headers.get("X-App-Version"));
if (response.status === 401 && state.refreshFn) { if (response.status === 401 && state.refreshFn) {
// If the caller already unmounted (TanStack Query aborted the request), // If the caller already unmounted (TanStack Query aborted the request),

View File

@@ -0,0 +1,48 @@
/**
* New-deploy detection for the SPA.
*
* The server stamps every response with `X-App-Version`. `apiFetch` feeds that
* header here via `reportServerVersion`. When it differs from the version baked
* into THIS bundle at build time (`BUILD_VERSION`), a deploy has happened — we
* latch it and notify subscribers (the refresh banner). We never force a
* reload; the user picks the moment (they may be mid-form).
*/
// Injected by Vite `define` at build time; falls back to "dev" in tests / when
// running unbuilt. `typeof` guard avoids a ReferenceError when undefined.
declare const __APP_VERSION__: string;
export const BUILD_VERSION: string =
typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "dev";
let newVersion: string | null = null;
const listeners = new Set<() => void>();
/**
* Compare a server-reported version against this bundle's build version. Latches
* the first genuine mismatch and notifies subscribers exactly once.
*/
export function reportServerVersion(
serverVersion: string | null | undefined,
): void {
if (newVersion) return; // already latched — banner is up
if (!serverVersion || serverVersion === BUILD_VERSION) return;
newVersion = serverVersion;
for (const l of listeners) l();
}
export function subscribeNewVersion(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getNewVersion(): string | null {
return newVersion;
}
/** Test-only: reset the module singleton between cases. */
export function __resetVersionStateForTest(): void {
newVersion = null;
listeners.clear();
}

View File

@@ -0,0 +1,35 @@
import { lazy } from "react";
import type { ComponentType } from "react";
const RELOAD_KEY = "spa-chunk-reloaded";
/**
* React.lazy that survives a deploy. If a page's dynamic import fails because
* its hashed chunk was removed by a newer build, reload once to fetch the fresh
* bundle (guarded by sessionStorage so a genuine load failure can't loop). On a
* successful load the guard is cleared so the next deploy can recover too.
*
* The generic mirrors React.lazy's own `ComponentType<any>` constraint — there
* is no non-any equivalent that satisfies it, so the `any` is unavoidable here.
*/
export function lazyWithReload<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends ComponentType<any>,
>(factory: () => Promise<{ default: T }>) {
return lazy(() =>
factory()
.then((mod) => {
sessionStorage.removeItem(RELOAD_KEY);
return mod;
})
.catch((err: unknown) => {
if (!sessionStorage.getItem(RELOAD_KEY)) {
sessionStorage.setItem(RELOAD_KEY, "1");
window.location.reload();
// Never resolves — the reload replaces the document.
return new Promise<{ default: T }>(() => {});
}
throw err;
}),
);
}

33
src/middleware/version.ts Normal file
View File

@@ -0,0 +1,33 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
/**
* The build version (from package.json), read once. A static `import` of the
* root package.json would pull a file outside `src` into the program and expand
* tsc's rootDir, so resolve it at runtime relative to the compiled file.
*/
export function readAppVersion(): string {
try {
const pkg = JSON.parse(
readFileSync(join(__dirname, "../../package.json"), "utf8"),
) as { version?: string };
return pkg.version ?? "0.0.0";
} catch {
return "0.0.0";
}
}
/**
* Stamp every response with `X-App-Version` so the SPA can detect a new deploy
* (the client compares it to the version baked into its own bundle and prompts
* a refresh on mismatch).
*/
export function registerVersionHeader(
app: FastifyInstance,
version: string = readAppVersion(),
): void {
app.addHook("onSend", async (_request, reply) => {
reply.header("X-App-Version", version);
});
}

View File

@@ -7,6 +7,7 @@ import rateLimit from "@fastify/rate-limit";
import path from "path"; import path from "path";
import { config } from "./config/env"; import { config } from "./config/env";
import { securityHeaders } from "./middleware/security"; import { securityHeaders } from "./middleware/security";
import { registerVersionHeader } from "./middleware/version";
import prisma from "./config/database"; import prisma from "./config/database";
import authRoutes from "./routes/admin/auth"; import authRoutes from "./routes/admin/auth";
@@ -100,6 +101,9 @@ async function start() {
// --- Security headers --- // --- Security headers ---
app.addHook("onRequest", securityHeaders); app.addHook("onRequest", securityHeaders);
// --- Version header: lets the SPA detect a new deploy and prompt a refresh ---
registerVersionHeader(app);
// --- Global error handler — consistent { success, error } format --- // --- Global error handler — consistent { success, error } format ---
app.setErrorHandler( app.setErrorHandler(
(err: Error & { statusCode?: number }, request, reply) => { (err: Error & { statusCode?: number }, request, reply) => {

View File

@@ -1,10 +1,20 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { readFileSync } from "node:fs";
// Bake the package.json version into the bundle so the running SPA can compare
// it to the server's X-App-Version header and detect a new deploy.
const appVersion = (
JSON.parse(readFileSync("./package.json", "utf8")) as { version: string }
).version;
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
root: ".", root: ".",
publicDir: "public", publicDir: "public",
define: {
__APP_VERSION__: JSON.stringify(appVersion),
},
server: { server: {
port: 3000, port: 3000,
}, },