This commit is contained in:
David Arranz 2026-10-13 18:20:06 +02:00
parent 63d51b8c6d
commit a5dc3395e3
25 changed files with 429 additions and 82 deletions

View File

@ -11,6 +11,26 @@ https://presupuestos.uecko.com:13001 {
:443 {
root * /srv
file_server
try_files {path} /index.html # Esto asegura que las rutas en React funcionen correctamente
}
# Los bundles con hash son inmutables. Un bundle inexistente debe dar 404,
# nunca el HTML de la SPA (que no es un módulo JavaScript).
handle /assets/* {
@existing file
@missing not file
header @existing Cache-Control "public, max-age=31536000, immutable"
header @missing Cache-Control "no-cache"
file_server
}
handle /version.json {
header Cache-Control "no-store"
file_server
}
handle {
# También se aplica al HTML servido para /quotes, /login, etc.
header Cache-Control "no-cache"
try_files {path} /index.html
file_server
}
}

1
client/env.d.ts vendored
View File

@ -2,3 +2,4 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;
declare const __APP_BUILD_ID__: string;

View File

@ -1,7 +1,7 @@
{
"name": "@uecko-presupuestador/client",
"private": true,
"version": "1.2.3",
"version": "1.2.4",
"author": "Rodax Software <dev@rodax-software.com>",
"type": "module",
"scripts": {

View File

@ -9,6 +9,7 @@ import { LoadingOverlay, TailwindIndicator } from "./components";
import { createAxiosAuthActions, createAxiosDataProvider } from "./lib/axios";
import { DataSourceProvider } from "./lib/hooks";
import i18n from "./locales/i18n.ts";
import { AppUpdateNotice } from "./components/AppUpdateNotice";
function App() {
const queryClient = new QueryClient({
@ -22,6 +23,7 @@ function App() {
return (
<I18nextProvider i18n={i18n}>
<AppUpdateNotice />
<QueryClientProvider client={queryClient}>
<DataSourceProvider dataSource={createAxiosDataProvider(import.meta.env.VITE_API_URL)}>
<AuthProvider authActions={createAxiosAuthActions(import.meta.env.VITE_API_URL)}>

View File

@ -0,0 +1,28 @@
import { appUpdateMonitor } from "@/lib/appUpdates/appUpdateMonitor";
import { Button } from "@/ui/button";
import { useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
export function AppUpdateNotice() {
const status = useSyncExternalStore(appUpdateMonitor.subscribe, appUpdateMonitor.getSnapshot);
const { t } = useTranslation();
if (status === "current") return null;
return (
<div
role='alert'
className='fixed bottom-4 left-4 right-4 z-[100] flex flex-wrap items-center justify-between gap-3 rounded-md border bg-background p-4 shadow-lg'
>
<div>
<p className='font-semibold'>
{t(`app_update.${status === "load-error" ? "load_error" : "available"}`)}
</p>
<p className='text-sm text-muted-foreground'>{t("app_update.save_first")}</p>
</div>
<Button type='button' onClick={() => window.location.reload()}>
{t("app_update.reload")}
</Button>
</div>
);
}

View File

@ -0,0 +1,5 @@
import { createAppUpdateMonitor } from "./createAppUpdateMonitor";
export const appUpdateMonitor = createAppUpdateMonitor(
__APP_BUILD_ID__, `${import.meta.env.BASE_URL}version.json`
);

View File

@ -0,0 +1,79 @@
import { createAppUpdateMonitor } from "./createAppUpdateMonitor";
describe("app update monitor", () => {
const fetchMock = jest.fn();
let stop: (() => void) | undefined;
const flush = async () => { await Promise.resolve(); await Promise.resolve(); };
const response = (buildId: string) => ({ ok: true, json: async () => ({ buildId }) });
beforeEach(() => {
jest.useFakeTimers();
fetchMock.mockReset();
global.fetch = fetchMock;
});
afterEach(() => {
stop?.();
stop = undefined;
jest.useRealTimers();
});
it("detects a deployment in an open tab without reloading", async () => {
fetchMock.mockResolvedValueOnce(response("old")).mockResolvedValueOnce(response("new"));
const monitor = createAppUpdateMonitor("old", "/version.json");
const listener = jest.fn();
monitor.subscribe(listener);
stop = monitor.start();
await flush();
expect(monitor.getSnapshot()).toBe("current");
jest.advanceTimersByTime(60000);
await flush();
expect(monitor.getSnapshot()).toBe("available");
expect(listener).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith("/version.json", expect.objectContaining({ cache: "no-store" }));
});
it("retries after network failures and ignores invalid version responses", async () => {
fetchMock.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce({ ok: false })
.mockResolvedValueOnce({ ok: true, json: async () => ({ version: "new" }) })
.mockResolvedValueOnce(response("new"));
const monitor = createAppUpdateMonitor("old", "/version.json");
stop = monitor.start();
await flush();
for (let i = 0; i < 2; i++) {
window.dispatchEvent(new Event("focus"));
await flush();
expect(monitor.getSnapshot()).toBe("current");
}
window.dispatchEvent(new Event("online"));
await flush();
expect(monitor.getSnapshot()).toBe("available");
});
it("reports failed dynamic imports without swallowing the original error", async () => {
fetchMock.mockResolvedValue(response("old"));
const monitor = createAppUpdateMonitor("old", "/version.json");
stop = monitor.start();
await flush();
const event = new Event("vite:preloadError", { cancelable: true });
window.dispatchEvent(event);
expect(monitor.getSnapshot()).toBe("load-error");
expect(event.defaultPrevented).toBe(false);
});
it("stops polling and ignores an in-flight response after cleanup", async () => {
let resolve: (value: ReturnType<typeof response>) => void = () => {};
fetchMock.mockReturnValue(new Promise((done) => { resolve = done; }));
const monitor = createAppUpdateMonitor("old", "/version.json");
stop = monitor.start();
stop();
resolve(response("new"));
await flush();
window.dispatchEvent(new Event("vite:preloadError"));
window.dispatchEvent(new Event("focus"));
jest.advanceTimersByTime(120000);
expect(monitor.getSnapshot()).toBe("current");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,73 @@
export type AppUpdateStatus = "current" | "available" | "load-error";
export function createAppUpdateMonitor(buildId: string, versionUrl: string) {
let status: AppUpdateStatus = "current";
const listeners = new Set<() => void>();
const update = (next: AppUpdateStatus) => {
if (status === next) return;
status = next;
listeners.forEach((listener) => listener());
};
return {
getSnapshot: () => status,
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
start: () => {
let checking = false;
const controller = new AbortController();
const check = async () => {
if (checking || document.visibilityState === "hidden" || status !== "current") return;
checking = true;
const request = new AbortController();
const timeout = window.setTimeout(() => request.abort(), 10000);
const abort = () => request.abort();
controller.signal.addEventListener("abort", abort);
try {
const response = await fetch(versionUrl, { cache: "no-store", signal: request.signal });
if (!response.ok) return;
const version: unknown = await response.json();
if (
!controller.signal.aborted &&
typeof version === "object" &&
version !== null &&
"buildId" in version &&
typeof version.buildId === "string" &&
version.buildId.length > 0 &&
version.buildId !== buildId &&
status === "current"
) {
update("available");
}
} catch {
// Sin conexión o despliegue en curso: se vuelve a comprobar más tarde.
} finally {
window.clearTimeout(timeout);
controller.signal.removeEventListener("abort", abort);
checking = false;
}
};
const onLoadError = () => update("load-error");
window.addEventListener("vite:preloadError", onLoadError);
window.addEventListener("focus", check);
window.addEventListener("online", check);
document.addEventListener("visibilitychange", check);
const interval = window.setInterval(check, 60000);
void check();
return () => {
controller.abort();
window.clearInterval(interval);
window.removeEventListener("vite:preloadError", onLoadError);
window.removeEventListener("focus", check);
window.removeEventListener("online", check);
document.removeEventListener("visibilitychange", check);
};
},
};
}

View File

@ -1,5 +1,11 @@
{
"translation": {
"app_update": {
"available": "Hi ha una nova versió disponible.",
"load_error": "No s’ha pogut carregar una part de l’aplicació. Recarrega per tornar-ho a provar.",
"save_first": "Desa els canvis pendents abans de recarregar.",
"reload": "Recarregar"
},
"common": {
"today": "Avui"
},

View File

@ -1,5 +1,11 @@
{
"translation": {
"app_update": {
"available": "A new version is available.",
"load_error": "Part of the application could not be loaded. Reload to try again.",
"save_first": "Save any pending changes before reloading.",
"reload": "Reload"
},
"common": {
"required": "required",
"cancel": "Cancel",

View File

@ -1,5 +1,11 @@
{
"translation": {
"app_update": {
"available": "Hay una nueva versión disponible.",
"load_error": "No se ha podido cargar una parte de la aplicación. Recarga para volver a intentarlo.",
"save_first": "Guarda los cambios pendientes antes de recargar.",
"reload": "Recargar"
},
"common": {
"required": "obligatorio",
"cancel": "Cancelar",

View File

@ -1,5 +1,11 @@
{
"translation": {
"app_update": {
"available": "Une nouvelle version est disponible.",
"load_error": "Une partie de l’application n’a pas pu être chargée. Rechargez pour réessayer.",
"save_first": "Enregistrez les modifications en attente avant de recharger.",
"reload": "Recharger"
},
"common": {
"required": "obligatoire",
"cancel": "Annuler",

View File

@ -1,8 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { appUpdateMonitor } from "./lib/appUpdates/appUpdateMonitor";
import "./global.css";
if (import.meta.env.PROD) appUpdateMonitor.start();
ReactDOM.createRoot(document.getElementById("uecko")!).render(
<React.StrictMode>
<App />

View File

@ -1,6 +1,7 @@
import react from "@vitejs/plugin-react";
import { readFileSync } from "fs";
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import path from "node:path";
import { defineConfig, normalizePath } from "vite";
import { robots } from "vite-plugin-robots";
@ -8,6 +9,7 @@ import { viteStaticCopy } from "vite-plugin-static-copy";
const pkg = JSON.parse(readFileSync("./package.json", "utf-8"));
const require = createRequire(import.meta.url);
const buildId = randomUUID();
const pdfjsDistPath = path.dirname(require.resolve("pdfjs-dist/package.json"));
const cMapsDir = normalizePath(path.join(pdfjsDistPath, "cmaps"));
@ -18,12 +20,24 @@ const standardFontsDir = normalizePath(
export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
__APP_BUILD_ID__: JSON.stringify(buildId),
},
build: {
outDir: "../dist/client",
emptyOutDir: true,
},
plugins: [
{
name: "app-build-version",
apply: "build",
generateBundle() {
this.emitFile({
type: "asset",
fileName: "version.json",
source: JSON.stringify({ buildId }),
});
},
},
react(),
robots({}),

View File

@ -0,0 +1,73 @@
// vite.config.ts
import react from "file:///home/rodax/Documentos/uecko-presupuestos/client/node_modules/@vitejs/plugin-react/dist/index.mjs";
import { readFileSync } from "fs";
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import path from "node:path";
import { defineConfig, normalizePath } from "file:///home/rodax/Documentos/uecko-presupuestos/client/node_modules/vite/dist/node/index.js";
import { robots } from "file:///home/rodax/Documentos/uecko-presupuestos/client/node_modules/vite-plugin-robots/dist/index.mjs";
import { viteStaticCopy } from "file:///home/rodax/Documentos/uecko-presupuestos/client/node_modules/vite-plugin-static-copy/dist/index.js";
var __vite_injected_original_dirname = "/home/rodax/Documentos/uecko-presupuestos/client";
var __vite_injected_original_import_meta_url = "file:///home/rodax/Documentos/uecko-presupuestos/client/vite.config.ts";
var pkg = JSON.parse(readFileSync("./package.json", "utf-8"));
var require2 = createRequire(__vite_injected_original_import_meta_url);
var buildId = randomUUID();
var pdfjsDistPath = path.dirname(require2.resolve("pdfjs-dist/package.json"));
var cMapsDir = normalizePath(path.join(pdfjsDistPath, "cmaps"));
var standardFontsDir = normalizePath(
path.join(path.dirname(require2.resolve("pdfjs-dist/package.json")), "standard_fonts")
);
var vite_config_default = defineConfig({
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
__APP_BUILD_ID__: JSON.stringify(buildId)
},
build: {
outDir: "../dist/client",
emptyOutDir: true
},
plugins: [
{
name: "app-build-version",
apply: "build",
generateBundle() {
this.emitFile({
type: "asset",
fileName: "version.json",
source: JSON.stringify({ buildId })
});
}
},
react(),
robots({}),
viteStaticCopy({
targets: [
{
src: cMapsDir,
dest: ""
},
{
src: standardFontsDir,
dest: ""
}
]
})
],
css: { postcss: "./postcss.config.js" },
resolve: {
alias: [
{
find: "@",
replacement: path.resolve(__vite_injected_original_dirname, "./src")
},
{
find: "@shared",
replacement: path.resolve(__vite_injected_original_dirname, "../shared/lib/")
}
]
}
});
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvaG9tZS9yb2RheC9Eb2N1bWVudG9zL3VlY2tvLXByZXN1cHVlc3Rvcy9jbGllbnRcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9ob21lL3JvZGF4L0RvY3VtZW50b3MvdWVja28tcHJlc3VwdWVzdG9zL2NsaWVudC92aXRlLmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vaG9tZS9yb2RheC9Eb2N1bWVudG9zL3VlY2tvLXByZXN1cHVlc3Rvcy9jbGllbnQvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgcmVhY3QgZnJvbSBcIkB2aXRlanMvcGx1Z2luLXJlYWN0XCI7XG5pbXBvcnQgeyByZWFkRmlsZVN5bmMgfSBmcm9tIFwiZnNcIjtcbmltcG9ydCB7IGNyZWF0ZVJlcXVpcmUgfSBmcm9tIFwibm9kZTptb2R1bGVcIjtcbmltcG9ydCB7IHJhbmRvbVVVSUQgfSBmcm9tIFwibm9kZTpjcnlwdG9cIjtcbmltcG9ydCBwYXRoIGZyb20gXCJub2RlOnBhdGhcIjtcbmltcG9ydCB7IGRlZmluZUNvbmZpZywgbm9ybWFsaXplUGF0aCB9IGZyb20gXCJ2aXRlXCI7XG5pbXBvcnQgeyByb2JvdHMgfSBmcm9tIFwidml0ZS1wbHVnaW4tcm9ib3RzXCI7XG5pbXBvcnQgeyB2aXRlU3RhdGljQ29weSB9IGZyb20gXCJ2aXRlLXBsdWdpbi1zdGF0aWMtY29weVwiO1xuXG5jb25zdCBwa2cgPSBKU09OLnBhcnNlKHJlYWRGaWxlU3luYyhcIi4vcGFja2FnZS5qc29uXCIsIFwidXRmLThcIikpO1xuY29uc3QgcmVxdWlyZSA9IGNyZWF0ZVJlcXVpcmUoaW1wb3J0Lm1ldGEudXJsKTtcbmNvbnN0IGJ1aWxkSWQgPSByYW5kb21VVUlEKCk7XG5cbmNvbnN0IHBkZmpzRGlzdFBhdGggPSBwYXRoLmRpcm5hbWUocmVxdWlyZS5yZXNvbHZlKFwicGRmanMtZGlzdC9wYWNrYWdlLmpzb25cIikpO1xuY29uc3QgY01hcHNEaXIgPSBub3JtYWxpemVQYXRoKHBhdGguam9pbihwZGZqc0Rpc3RQYXRoLCBcImNtYXBzXCIpKTtcbmNvbnN0IHN0YW5kYXJkRm9udHNEaXIgPSBub3JtYWxpemVQYXRoKFxuICBwYXRoLmpvaW4ocGF0aC5kaXJuYW1lKHJlcXVpcmUucmVzb2x2ZShcInBkZmpzLWRpc3QvcGFja2FnZS5qc29uXCIpKSwgXCJzdGFuZGFyZF9mb250c1wiKVxuKTtcblxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKHtcbiAgZGVmaW5lOiB7XG4gICAgX19BUFBfVkVSU0lPTl9fOiBKU09OLnN0cmluZ2lmeShwa2cudmVyc2lvbiksXG4gICAgX19BUFBfQlVJTERfSURfXzogSlNPTi5zdHJpbmdpZnkoYnVpbGRJZCksXG4gIH0sXG4gIGJ1aWxkOiB7XG4gICAgb3V0RGlyOiBcIi4uL2Rpc3QvY2xpZW50XCIsXG4gICAgZW1wdHlPdXREaXI6IHRydWUsXG4gIH0sXG4gIHBsdWdpbnM6IFtcbiAgICB7XG4gICAgICBuYW1lOiBcImFwcC1idWlsZC12ZXJzaW9uXCIsXG4gICAgICBhcHBseTogXCJidWlsZFwiLFxuICAgICAgZ2VuZXJhdGVCdW5kbGUoKSB7XG4gICAgICAgIHRoaXMuZW1pdEZpbGUoe1xuICAgICAgICAgIHR5cGU6IFwiYXNzZXRcIixcbiAgICAgICAgICBmaWxlTmFtZTogXCJ2ZXJzaW9uLmpzb25cIixcbiAgICAgICAgICBzb3VyY2U6IEpTT04uc3RyaW5naWZ5KHsgYnVpbGRJZCB9KSxcbiAgICAgICAgfSk7XG4gICAgICB9LFxuICAgIH0sXG4gICAgcmVhY3QoKSxcbiAgICByb2JvdHMoe30pLFxuXG4gICAgdml0ZVN0YXRpY0NvcHkoe1xuICAgICAgdGFyZ2V0czogW1xuICAgICAgICB7XG4gICAgICAgICAgc3JjOiBjTWFwc0RpcixcbiAgICAgICAgICBkZXN0OiBcIlwiLFxuICAgICAgICB9LFxuICAgICAgICB7XG4gICAgICAgICAgc3JjOiBzdGFuZGFyZEZvbnRzRGlyLFxuICAgICAgICAgIGRlc3Q6IFwiXCIsXG4gICAgICAgIH0sXG4gICAgICBdLFxuICAgIH0pLFxuICBdLFxuXG4gIGNzczogeyBwb3N0Y3NzOiBcIi4vcG9zdGNzcy5jb25maWcuanNcIiB9LFxuICByZXNvbHZlOiB7XG4gICAgYWxpYXM6IFtcbiAgICAgIHtcbiAgICAgICAgZmluZDogXCJAXCIsXG4gICAgICAgIHJlcGxhY2VtZW50OiBwYXRoLnJlc29sdmUoX19kaXJuYW1lLCBcIi4vc3JjXCIpLFxuICAgICAgfSxcbiAgICAgIHtcbiAgICAgICAgZmluZDogXCJAc2hhcmVkXCIsXG4gICAgICAgIHJlcGxhY2VtZW50OiBwYXRoLnJlc29sdmUoX19kaXJuYW1lLCBcIi4uL3NoYXJlZC9saWIvXCIpLFxuICAgICAgfSxcbiAgICBdLFxuICB9LFxufSk7XG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQWtVLE9BQU8sV0FBVztBQUNwVixTQUFTLG9CQUFvQjtBQUM3QixTQUFTLHFCQUFxQjtBQUM5QixTQUFTLGtCQUFrQjtBQUMzQixPQUFPLFVBQVU7QUFDakIsU0FBUyxjQUFjLHFCQUFxQjtBQUM1QyxTQUFTLGNBQWM7QUFDdkIsU0FBUyxzQkFBc0I7QUFQL0IsSUFBTSxtQ0FBbUM7QUFBK0osSUFBTSwyQ0FBMkM7QUFTelAsSUFBTSxNQUFNLEtBQUssTUFBTSxhQUFhLGtCQUFrQixPQUFPLENBQUM7QUFDOUQsSUFBTUEsV0FBVSxjQUFjLHdDQUFlO0FBQzdDLElBQU0sVUFBVSxXQUFXO0FBRTNCLElBQU0sZ0JBQWdCLEtBQUssUUFBUUEsU0FBUSxRQUFRLHlCQUF5QixDQUFDO0FBQzdFLElBQU0sV0FBVyxjQUFjLEtBQUssS0FBSyxlQUFlLE9BQU8sQ0FBQztBQUNoRSxJQUFNLG1CQUFtQjtBQUFBLEVBQ3ZCLEtBQUssS0FBSyxLQUFLLFFBQVFBLFNBQVEsUUFBUSx5QkFBeUIsQ0FBQyxHQUFHLGdCQUFnQjtBQUN0RjtBQUVBLElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLFFBQVE7QUFBQSxJQUNOLGlCQUFpQixLQUFLLFVBQVUsSUFBSSxPQUFPO0FBQUEsSUFDM0Msa0JBQWtCLEtBQUssVUFBVSxPQUFPO0FBQUEsRUFDMUM7QUFBQSxFQUNBLE9BQU87QUFBQSxJQUNMLFFBQVE7QUFBQSxJQUNSLGFBQWE7QUFBQSxFQUNmO0FBQUEsRUFDQSxTQUFTO0FBQUEsSUFDUDtBQUFBLE1BQ0UsTUFBTTtBQUFBLE1BQ04sT0FBTztBQUFBLE1BQ1AsaUJBQWlCO0FBQ2YsYUFBSyxTQUFTO0FBQUEsVUFDWixNQUFNO0FBQUEsVUFDTixVQUFVO0FBQUEsVUFDVixRQUFRLEtBQUssVUFBVSxFQUFFLFFBQVEsQ0FBQztBQUFBLFFBQ3BDLENBQUM7QUFBQSxNQUNIO0FBQUEsSUFDRjtBQUFBLElBQ0EsTUFBTTtBQUFBLElBQ04sT0FBTyxDQUFDLENBQUM7QUFBQSxJQUVULGVBQWU7QUFBQSxNQUNiLFNBQVM7QUFBQSxRQUNQO0FBQUEsVUFDRSxLQUFLO0FBQUEsVUFDTCxNQUFNO0FBQUEsUUFDUjtBQUFBLFFBQ0E7QUFBQSxVQUNFLEtBQUs7QUFBQSxVQUNMLE1BQU07QUFBQSxRQUNSO0FBQUEsTUFDRjtBQUFBLElBQ0YsQ0FBQztBQUFBLEVBQ0g7QUFBQSxFQUVBLEtBQUssRUFBRSxTQUFTLHNCQUFzQjtBQUFBLEVBQ3RDLFNBQVM7QUFBQSxJQUNQLE9BQU87QUFBQSxNQUNMO0FBQUEsUUFDRSxNQUFNO0FBQUEsUUFDTixhQUFhLEtBQUssUUFBUSxrQ0FBVyxPQUFPO0FBQUEsTUFDOUM7QUFBQSxNQUNBO0FBQUEsUUFDRSxNQUFNO0FBQUEsUUFDTixhQUFhLEtBQUssUUFBUSxrQ0FBVyxnQkFBZ0I7QUFBQSxNQUN2RDtBQUFBLElBQ0Y7QUFBQSxFQUNGO0FBQ0YsQ0FBQzsiLAogICJuYW1lcyI6IFsicmVxdWlyZSJdCn0K

File diff suppressed because one or more lines are too long

1
dist/client/assets/index-Cipqnmx2.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
var Pv=Object.defineProperty;var qv=(n,o,r)=>o in n?Pv(n,o,{enumerable:!0,configurable:!0,writable:!0,value:r}):n[o]=r;var je=(n,o,r)=>qv(n,typeof o!="symbol"?o+"":o,r);import{u as Fc,a as rn,b as Bt,c as qn,d as Re,j as i,P as kv,e as $v,f as pe,g as Bv,N as as,R as Pe,J as Et,h as qe,i as dr,D as fr,l as Ua,M as Ot,V as Uv,r as T,k as Dc,m as Mc,n as hr,o as Tc,B as H,C as Vv,p as pr,q as xr,s as _r,S as go,t as vr,v as es,w as b,x as mo,y as os,z as ls,A as us,E as Kr,F as ho,G as kn,H as $n,I as Bn,K as Un,L as zv,O as Vt,Q as Hv,T as Gv,U as Kv,W as Te,X as ct,Y as ze,Z as We,_ as Oe,$ as bn,a0 as po,a1 as Wv,a2 as xo,a3 as wn,a4 as _o,a5 as vo,a6 as Zr,a7 as Rt,a8 as Oc,a9 as Qv,aa as ts,ab as Hn,ac as bo,ad as wo,ae as Ln,af as So,ag as Lc,ah as jo,ai as Co,aj as yo,ak as cs,al as Yv,am as On,an as Pt,ao as pc,ap as Zv,aq as Xv,ar as Jv,as as No,at as Ht,au as Cn,av as ds,aw as Pc,ax as _n,ay as e0,az as qc,aA as t0,aB as oe,aC as fs,aD as xc,aE as kc,aF as $c,aG as Bc,aH as Vn,aI as Uc,aJ as gs,aK as ei,aL as ti,aM as ni,aN as Xr,aO as n0,aP as Vc,aQ as Qe,aR as Ye,aS as to,aT as Ze,aU as gr,aV as br,aW as mr,aX as r0,aY as i0,aZ as s0,a_ as no,a$ as a0,b0 as o0,b1 as ur,b2 as zc,b3 as ns,b4 as Hc,b5 as zn,b6 as Jr,b7 as Ut,b8 as l0,b9 as hn,ba as ms,bb as Gc,bc as u0,bd as hs,be as c0,bf as Kc,bg as Wc,bh as Qc,bi as Yc,bj as Zc,bk as Xc,bl as Jc,bm as ed,bn as td,bo as d0,bp as Qi,bq as f0,br as g0,bs as m0,bt as h0,bu as p0,bv as x0,bw as _0,bx as v0,by as b0,bz as w0,bA as S0,bB as _c,bC as nd,bD as rd,bE as id,bF as sd,bG as ad,bH as od,bI as ld,bJ as j0,bK as Va,bL as Wr,bM as C0,bN as ud,bO as y0,bP as N0,bQ as cd,bR as dd,bS as ro,bT as io,bU as R0,bV as E0,bW as A0}from"./index-BdgZcBCl.js";const I0=n=>{const{onSuccess:o,...r}=n||{},a=qn(),{login:l}=Fc(),c=rn();return Bt({mutationKey:a().auth().action("login").get(),mutationFn:l,onSuccess:async(d,m,p)=>{d.success&&c.removeQueries({queryKey:a().auth().get()}),await(o==null?void 0:o(d,m,p))},...r})};/**
var Pv=Object.defineProperty;var qv=(n,o,r)=>o in n?Pv(n,o,{enumerable:!0,configurable:!0,writable:!0,value:r}):n[o]=r;var je=(n,o,r)=>qv(n,typeof o!="symbol"?o+"":o,r);import{u as Fc,a as rn,b as Bt,c as qn,d as Re,j as i,P as kv,e as $v,f as pe,g as Bv,N as as,R as Pe,J as Et,h as qe,i as dr,D as fr,l as Ua,M as Ot,V as Uv,r as T,k as Dc,m as Mc,n as hr,o as Tc,B as H,C as Vv,p as pr,q as xr,s as _r,S as go,t as vr,v as es,w as b,x as mo,y as os,z as ls,A as us,E as Kr,F as ho,G as kn,H as $n,I as Bn,K as Un,L as zv,O as Vt,Q as Hv,T as Gv,U as Kv,W as Te,X as ct,Y as ze,Z as We,_ as Oe,$ as bn,a0 as po,a1 as Wv,a2 as xo,a3 as wn,a4 as _o,a5 as vo,a6 as Zr,a7 as Rt,a8 as Oc,a9 as Qv,aa as ts,ab as Hn,ac as bo,ad as wo,ae as Ln,af as So,ag as Lc,ah as jo,ai as Co,aj as yo,ak as cs,al as Yv,am as On,an as Pt,ao as pc,ap as Zv,aq as Xv,ar as Jv,as as No,at as Ht,au as Cn,av as ds,aw as Pc,ax as _n,ay as e0,az as qc,aA as t0,aB as oe,aC as fs,aD as xc,aE as kc,aF as $c,aG as Bc,aH as Vn,aI as Uc,aJ as gs,aK as ei,aL as ti,aM as ni,aN as Xr,aO as n0,aP as Vc,aQ as Qe,aR as Ye,aS as to,aT as Ze,aU as gr,aV as br,aW as mr,aX as r0,aY as i0,aZ as s0,a_ as no,a$ as a0,b0 as o0,b1 as ur,b2 as zc,b3 as ns,b4 as Hc,b5 as zn,b6 as Jr,b7 as Ut,b8 as l0,b9 as hn,ba as ms,bb as Gc,bc as u0,bd as hs,be as c0,bf as Kc,bg as Wc,bh as Qc,bi as Yc,bj as Zc,bk as Xc,bl as Jc,bm as ed,bn as td,bo as d0,bp as Qi,bq as f0,br as g0,bs as m0,bt as h0,bu as p0,bv as x0,bw as _0,bx as v0,by as b0,bz as w0,bA as S0,bB as _c,bC as nd,bD as rd,bE as id,bF as sd,bG as ad,bH as od,bI as ld,bJ as j0,bK as Va,bL as Wr,bM as C0,bN as ud,bO as y0,bP as N0,bQ as cd,bR as dd,bS as ro,bT as io,bU as R0,bV as E0,bW as A0}from"./index-DCwYKvrD.js";const I0=n=>{const{onSuccess:o,...r}=n||{},a=qn(),{login:l}=Fc(),c=rn();return Bt({mutationKey:a().auth().action("login").get(),mutationFn:l,onSuccess:async(d,m,p)=>{d.success&&c.removeQueries({queryKey:a().auth().get()}),await(o==null?void 0:o(d,m,p))},...r})};/**
* @license lucide-react v0.427.0 - ISC
*
* This source code is licensed under the ISC license.

View File

@ -8,8 +8,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://fonts.upset.dev/css2?family=Poppins&display=swap" rel="stylesheet" />
<title>Presupuestador Uecko</title>
<script type="module" crossorigin src="/assets/index-BdgZcBCl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CSIsiVBT.css">
<script type="module" crossorigin src="/assets/index-DCwYKvrD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cipqnmx2.css">
</head>
<body>

1
dist/client/version.json vendored Normal file
View File

@ -0,0 +1 @@
{"buildId":"57cae076-0740-411c-921e-13d28d1741ae"}

24
doc/despliegue-spa.md Normal file
View File

@ -0,0 +1,24 @@
# Actualizaciones de la SPA
El HTML y las rutas de la SPA se sirven con `Cache-Control: no-cache`: el navegador puede guardarlos, pero debe revalidarlos antes de reutilizarlos. Los archivos existentes de `/assets/` tienen nombres con hash y se sirven con caché de un año e `immutable`. Los archivos que faltan en ese directorio devuelven 404, sin sustituirlos por HTML.
Cada build genera un identificador independiente de la versión de `package.json`, incluido tanto en JavaScript como en `version.json`. Las pestañas abiertas consultan ese archivo sin caché al inicio, cada minuto mientras están visibles y al recuperar el foco o la conexión. Una versión diferente muestra un aviso para guardar y recargar. Un fallo de importación dinámica de Vite muestra un aviso de recarga incluso si no es posible consultar la versión. No se recarga automáticamente ni se oculta el error original.
## Publicación
La configuración de Caddy debe aplicarse en producción para que las cabeceras tengan efecto. Si hay un proxy o CDN delante, debe respetar estas cabeceras y dejar de servir copias antiguas del HTML y `version.json`.
Actualmente `yarn build` del cliente borra `dist/client`, y Docker monta esa misma carpeta en Caddy. Compilar sobre la carpeta publicada provoca una ventana con archivos ausentes. Preparar el build en una carpeta de release separada, comprobarlo y publicar la release completa de forma atómica evita esa ventana.
La nueva release debe contener también los assets con hash de las releases anteriores durante el periodo en que se admitan pestañas abiertas (por ejemplo, varios días). Los assets se copian antes de publicar el HTML y `version.json` nuevos; nunca se sobrescribe un asset con hash con contenido distinto. El cambio atómico debe hacerse sobre una ruta visible para Caddy: sustituir solo un enlace o directorio en el host puede no actualizar un bind mount Docker existente. La mecánica concreta debe ajustarse al despliegue.
El aviso es una recuperación adicional, no sustituye la conservación de assets ni la publicación atómica. Una pestaña cargada antes de incorporar el monitor necesitará una primera recarga. Las cabeceras nuevas tampoco pueden invalidar por sí solas un HTML que ya tenga una caché vigente en un navegador o CDN.
## Comprobación en producción
- `/`, `/index.html` y `/quotes`: cabecera `Cache-Control: no-cache`.
- `/version.json`: JSON con `buildId` y cabecera `Cache-Control: no-store`.
- Un bundle existente en `/assets/`: `Cache-Control: public, max-age=31536000, immutable`.
- `/assets/inexistente.js`: 404, sin HTML de la SPA ni caché inmutable.
- Abrir una pestaña, publicar otra release y volver a la pestaña: aparece el aviso; al recargar se mantiene la URL y se carga la release nueva.
- Simular un fallo de carga de un módulo: aparece el aviso de recarga y no hay bucles de recarga automática.

View File

@ -1,6 +1,6 @@
{
"name": "uecko-presupuestador",
"version": "1.2.3",
"version": "1.2.4",
"author": "Rodax Software <dev@rodax-software.com>",
"license": "ISC",
"private": true,

View File

@ -1,7 +1,7 @@
{
"name": "@uecko-presupuestador/server",
"private": true,
"version": "1.2.3",
"version": "1.2.4",
"author": "Rodax Software <dev@rodax-software.com>",
"main": "./src/index.ts",
"scripts": {

View File

@ -1,7 +1,7 @@
{
"name": "@uecko-presupuestador/shared",
"private": false,
"version": "1.2.3",
"version": "1.2.4",
"main": "./index.ts",
"author": "Rodax Software <dev@rodax-software.com>",
"license": "ISC",