diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..59d0b00
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,8 @@
+node_modules
+dist
+build
+coverage
+.gga
+openspec
+*.min.js
+package-lock.json
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..4023e42
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,9 @@
+{
+ "printWidth": 100,
+ "tabWidth": 2,
+ "semi": true,
+ "singleQuote": false,
+ "trailingComma": "es5",
+ "arrowParens": "always",
+ "endOfLine": "lf"
+}
diff --git a/frontend/README.md b/frontend/README.md
index e4c79e6..3201011 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -31,20 +31,20 @@ frontend/
## Páginas
-| Ruta | Componente | Descripción |
-|------|------------|-------------|
-| `/` | Home | Inicio |
-| `/historial` | History | Historial de sorteos |
-| `/estadisticas` | Statistics | Estadísticas |
-| `/heatmaps` | Heatmaps | Mapas de calor |
-| `/tendencias` | Trends | Tendencias |
-| `/redes` | Networks | Grafos |
-| `/monte-carlo` | MonteCarlo | Simulación |
-| `/ia` | IA | Asistente |
-| `/modelos` | Models | Modelos ML |
-| `/experimentos` | Experiments | Experimentos |
-| `/backtesting` | Backtesting | Backtesting |
-| `/generador` | Generator | Generador |
+| Ruta | Componente | Descripción |
+| --------------- | ----------- | -------------------- |
+| `/` | Home | Inicio |
+| `/historial` | History | Historial de sorteos |
+| `/estadisticas` | Statistics | Estadísticas |
+| `/heatmaps` | Heatmaps | Mapas de calor |
+| `/tendencias` | Trends | Tendencias |
+| `/redes` | Networks | Grafos |
+| `/monte-carlo` | MonteCarlo | Simulación |
+| `/ia` | IA | Asistente |
+| `/modelos` | Models | Modelos ML |
+| `/experimentos` | Experiments | Experimentos |
+| `/backtesting` | Backtesting | Backtesting |
+| `/generador` | Generator | Generador |
## Tests
diff --git a/frontend/e2e/core-cycle.spec.ts b/frontend/e2e/core-cycle.spec.ts
index 1400ab5..4d1a739 100644
--- a/frontend/e2e/core-cycle.spec.ts
+++ b/frontend/e2e/core-cycle.spec.ts
@@ -55,9 +55,8 @@ async function seedDraws(request: APIRequestContext, lotteryCode: string) {
const dir = mkdtempSync(join(tmpdir(), "lip-e2e-seed-"));
const csvPath = join(dir, "seed.csv");
const header = "draw_number,draw_date,numbers,super_number,jackpot,winners";
- const rows = DRAW_ROWS.map(
- ([drawNumber, drawDate, numbers, jackpot, winners]) =>
- [drawNumber, drawDate, `"${numbers.join(",")}"`, "", jackpot, winners].join(","),
+ const rows = DRAW_ROWS.map(([drawNumber, drawDate, numbers, jackpot, winners]) =>
+ [drawNumber, drawDate, `"${numbers.join(",")}"`, "", jackpot, winners].join(",")
);
writeFileSync(csvPath, [header, ...rows].join("\n") + "\n", "utf8");
try {
@@ -94,27 +93,19 @@ test("core cycle: seed → statistics snapshot → dashboard (TEST-005, D3)", as
// --- Statistics: generate snapshot → charts render -----------------------
await page.goto("/estadisticas");
await page.getByRole("button", { name: "Generate Snapshot" }).click();
- await expect(
- page.getByRole("img", { name: "Frequency distribution per number" }),
- ).toBeVisible();
+ await expect(page.getByRole("img", { name: "Frequency distribution per number" })).toBeVisible();
await expect(page.locator(".recharts-responsive-container")).toBeVisible();
await expect(page.getByText(/Snapshot #\d+/)).toBeVisible();
// --- Home: dashboard renders draws + frequencies from the snapshot --------
await page.goto("/");
await expect(page.locator("tbody")).toContainText("1 - 6 - 11 - 16 - 21");
- await expect(
- page.getByRole("heading", { name: "Frequency summary" }),
- ).toBeVisible();
- await expect(
- page.getByRole("heading", { name: "Most frequent" }),
- ).toBeVisible();
- await expect(
- page.getByRole("heading", { name: "Least frequent" }),
- ).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Frequency summary" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Most frequent" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Least frequent" })).toBeVisible();
// 1 and 6 each appear in two seeded draws → top frequency count is 2×.
await expect(page.locator("ol").first()).toContainText("2×");
// Frontend ↔ backend connectivity is proven by the System health block.
await expect(page.getByText("API status", { exact: true })).toBeVisible();
await expect(page.locator("dl")).toContainText("ok");
-});
\ No newline at end of file
+});
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index 79a552e..1fe5acc 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -19,10 +19,7 @@ export default tseslint.config(
},
rules: {
...reactHooks.configs.recommended.rules,
- "react-refresh/only-export-components": [
- "warn",
- { allowConstantExport: true },
- ],
+ "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
},
- },
+ }
);
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
index c97f07b..ecd182d 100644
--- a/frontend/playwright.config.ts
+++ b/frontend/playwright.config.ts
@@ -40,7 +40,7 @@ export default defineConfig({
{
// Migrate a fresh tmp DB, then serve the API from it on :8000.
command:
- 'DB=$(mktemp /tmp/lip-e2e-db-XXXXXX.db) && ' +
+ "DB=$(mktemp /tmp/lip-e2e-db-XXXXXX.db) && " +
'LIP_DATABASE_URL="sqlite:///$DB" .venv/bin/python -m alembic upgrade head && ' +
'LIP_DATABASE_URL="sqlite:///$DB" .venv/bin/uvicorn backend.app.main:create_app --factory --host 127.0.0.1 --port 8000',
cwd: BACKEND_DIR,
@@ -59,4 +59,4 @@ export default defineConfig({
env: { ...process.env, VITE_API_BASE_URL: "http://localhost:8000/api/v1" },
},
],
-});
\ No newline at end of file
+});
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 30b1b59..44a20b6 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -12,9 +12,7 @@ const env = (data: unknown) =>
HttpResponse.json({ success: true, data, timestamp: "2026-01-01T00:00:00Z" });
const server = setupServer(
- http.get("*/api/v1/lotteries", () =>
- env([{ id: 1, code: "L1", name: "Loto", country: "ES" }]),
- ),
+ http.get("*/api/v1/lotteries", () => env([{ id: 1, code: "L1", name: "Loto", country: "ES" }])),
http.get("*/api/v1/draws", () => env([])),
http.get("*/api/v1/statistics/L1/frequencies", () =>
env({
@@ -27,11 +25,11 @@ const server = setupServer(
draw_count: 0,
checksum: "x",
frequencies: [],
- }),
+ })
),
http.get("*/api/v1/health", () => env({ status: "ok" })),
http.get("*/api/v1/version", () =>
- env({ version: "1.0.0", app: "Lottery Intelligence Platform" }),
+ env({ version: "1.0.0", app: "Lottery Intelligence Platform" })
),
http.get("*/api/v1/ml/models", () =>
env({
@@ -43,7 +41,7 @@ const server = setupServer(
checksum: "abc",
input_fingerprint: "fp1",
cut: 1200,
- }),
+ })
),
http.get("*/api/v1/ml/metrics", () => env([])),
http.get("*/api/v1/dl/models", () =>
@@ -57,7 +55,7 @@ const server = setupServer(
input_fingerprint: "fp-dl",
cut: 305,
window: 10,
- }),
+ })
),
http.get("*/api/v1/dl/metrics", () => env([])),
http.get("*/api/v1/probability/L1/probabilities", () =>
@@ -71,10 +69,16 @@ const server = setupServer(
draw_count: 100,
checksum: "abc",
probabilities: [
- { model_id: "baseline", model_version: "v1", subject: "5", draw_number: 101, value: "0.1420" },
+ {
+ model_id: "baseline",
+ model_version: "v1",
+ subject: "5",
+ draw_number: 101,
+ value: "0.1420",
+ },
],
- }),
- ),
+ })
+ )
);
function renderAt(path: string) {
@@ -104,7 +108,7 @@ describe("App router", () => {
expect(container.querySelector("[aria-busy='true']")).not.toBeNull();
expect(
- await screen.findByRole("heading", { name: /operational summary/i }, ASYNC_TIMEOUT),
+ await screen.findByRole("heading", { name: /operational summary/i }, ASYNC_TIMEOUT)
).toBeInTheDocument();
}, 20000);
@@ -113,14 +117,10 @@ describe("App router", () => {
renderAt("/");
expect(
- await screen.findByRole("heading", { name: /operational summary/i }, ASYNC_TIMEOUT),
- ).toBeInTheDocument();
- expect(
- screen.getByRole("navigation", { name: "Main navigation" }),
- ).toBeInTheDocument();
- expect(
- screen.getByRole("complementary", { name: "Sidebar" }),
+ await screen.findByRole("heading", { name: /operational summary/i }, ASYNC_TIMEOUT)
).toBeInTheDocument();
+ expect(screen.getByRole("navigation", { name: "Main navigation" })).toBeInTheDocument();
+ expect(screen.getByRole("complementary", { name: "Sidebar" })).toBeInTheDocument();
}, 20000);
it("navigates between routes through the sidebar links", async () => {
@@ -131,7 +131,7 @@ describe("App router", () => {
fireEvent.click(screen.getByRole("link", { name: "Mis Números" }));
expect(
- await screen.findByRole("heading", { name: /mis n[uú]meros/i }, ASYNC_TIMEOUT),
+ await screen.findByRole("heading", { name: /mis n[uú]meros/i }, ASYNC_TIMEOUT)
).toBeInTheDocument();
});
@@ -139,7 +139,7 @@ describe("App router", () => {
renderAt("/unknown");
expect(
- await screen.findByRole("heading", { name: /page not found/i }, ASYNC_TIMEOUT),
+ await screen.findByRole("heading", { name: /page not found/i }, ASYNC_TIMEOUT)
).toBeInTheDocument();
});
@@ -148,11 +148,9 @@ describe("App router", () => {
renderAt("/ia");
expect(
- await screen.findByRole("heading", { name: /ai assistant/i }, ASYNC_TIMEOUT),
- ).toBeInTheDocument();
- expect(
- screen.getByRole("region", { name: "System status" }),
+ await screen.findByRole("heading", { name: /ai assistant/i }, ASYNC_TIMEOUT)
).toBeInTheDocument();
+ expect(screen.getByRole("region", { name: "System status" })).toBeInTheDocument();
}, 20000);
it("deep-links to the Deep Learning page at /dl through its lazy chunk", async () => {
@@ -160,11 +158,9 @@ describe("App router", () => {
renderAt("/dl");
expect(
- await screen.findByRole("heading", { name: /deep learning/i }, ASYNC_TIMEOUT),
- ).toBeInTheDocument();
- expect(
- screen.getByRole("region", { name: "Deep learning results" }),
+ await screen.findByRole("heading", { name: /deep learning/i }, ASYNC_TIMEOUT)
).toBeInTheDocument();
+ expect(screen.getByRole("region", { name: "Deep learning results" })).toBeInTheDocument();
}, 20000);
it("deep-links to the Mis Números page at /numeros through its lazy chunk", async () => {
@@ -172,7 +168,7 @@ describe("App router", () => {
renderAt("/numeros");
expect(
- await screen.findByRole("heading", { name: /mis números/i }, ASYNC_TIMEOUT),
+ await screen.findByRole("heading", { name: /mis números/i }, ASYNC_TIMEOUT)
).toBeInTheDocument();
}, 20000);
});
diff --git a/frontend/src/charts/AverageChart.tsx b/frontend/src/charts/AverageChart.tsx
index 19b74a2..2f798ff 100644
--- a/frontend/src/charts/AverageChart.tsx
+++ b/frontend/src/charts/AverageChart.tsx
@@ -1,12 +1,4 @@
-import {
- Bar,
- BarChart,
- CartesianGrid,
- ResponsiveContainer,
- Tooltip,
- XAxis,
- YAxis,
-} from "recharts";
+import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
export interface AverageSeriesRow {
series_key: string;
@@ -44,4 +36,4 @@ export default function AverageChart({ series }: AverageChartProps) {
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/charts/DistributionChart.tsx b/frontend/src/charts/DistributionChart.tsx
index 4b91733..5374a91 100644
--- a/frontend/src/charts/DistributionChart.tsx
+++ b/frontend/src/charts/DistributionChart.tsx
@@ -1,12 +1,4 @@
-import {
- Bar,
- BarChart,
- CartesianGrid,
- ResponsiveContainer,
- Tooltip,
- XAxis,
- YAxis,
-} from "recharts";
+import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
export interface DistributionRow {
subject: string;
@@ -29,10 +21,7 @@ function parseValue(value: number | string): number | undefined {
export default function DistributionChart({ rows }: DistributionChartProps) {
const data: { subject: string; value: number }[] = rows
.map((row) => ({ subject: row.subject, value: parseValue(row.value) }))
- .filter(
- (row): row is { subject: string; value: number } =>
- row.value !== undefined,
- );
+ .filter((row): row is { subject: string; value: number } => row.value !== undefined);
return (
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/charts/FrequencyChart.tsx b/frontend/src/charts/FrequencyChart.tsx
index 3eba178..1fc2dba 100644
--- a/frontend/src/charts/FrequencyChart.tsx
+++ b/frontend/src/charts/FrequencyChart.tsx
@@ -1,12 +1,4 @@
-import {
- Bar,
- BarChart,
- CartesianGrid,
- ResponsiveContainer,
- Tooltip,
- XAxis,
- YAxis,
-} from "recharts";
+import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import type { FrequencyRow } from "../types/statistics";
export interface FrequencyChartProps {
@@ -33,4 +25,4 @@ export default function FrequencyChart({ rows }: FrequencyChartProps) {
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/charts/GapChart.tsx b/frontend/src/charts/GapChart.tsx
index 8078dd0..0a5a883 100644
--- a/frontend/src/charts/GapChart.tsx
+++ b/frontend/src/charts/GapChart.tsx
@@ -60,4 +60,4 @@ export default function GapChart({ rows }: GapChartProps) {
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/charts/HeatmapChart.tsx b/frontend/src/charts/HeatmapChart.tsx
index 60f695f..4bffcfa 100644
--- a/frontend/src/charts/HeatmapChart.tsx
+++ b/frontend/src/charts/HeatmapChart.tsx
@@ -53,7 +53,7 @@ export default function HeatmapChart({ rows, numbers }: HeatmapChartProps) {
}
if (size <= 0) return { size: 0, matrix: [], min: 0, max: 0 };
const matrix: (number | null)[][] = Array.from({ length: size }, () =>
- Array.from({ length: size }, () => null),
+ Array.from({ length: size }, () => null)
);
let min = Infinity;
let max = -Infinity;
@@ -86,15 +86,31 @@ export default function HeatmapChart({ rows, numbers }: HeatmapChartProps) {
const value = grid.matrix[i]?.[j] ?? null;
const t = value === null ? 0 : (value - grid.min) / span;
nodes.push(
-
+
{value === null ? null : {`${i + 1}–${j + 1}: ${value}`}}
- ,
+
);
if (value !== null) {
nodes.push(
-
+
{value}
- ,
+
);
}
}
@@ -102,9 +118,14 @@ export default function HeatmapChart({ rows, numbers }: HeatmapChartProps) {
return (
-
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/charts/charts.test.tsx b/frontend/src/charts/charts.test.tsx
index 221bcb1..c675b75 100644
--- a/frontend/src/charts/charts.test.tsx
+++ b/frontend/src/charts/charts.test.tsx
@@ -38,9 +38,7 @@ describe("FrequencyChart", () => {
it("does not crash on empty rows", () => {
render();
- expect(
- screen.getByRole("img", { name: /frequency distribution/i }),
- ).toBeInTheDocument();
+ expect(screen.getByRole("img", { name: /frequency distribution/i })).toBeInTheDocument();
});
});
@@ -54,17 +52,11 @@ describe("GapChart", () => {
});
it("does not crash on null gaps or empty rows", () => {
- const withNulls = [
- { number: 4, count: 5, min_gap: null, max_gap: null, avg_gap: null },
- ];
+ const withNulls = [{ number: 4, count: 5, min_gap: null, max_gap: null, avg_gap: null }];
const { rerender } = render();
- expect(
- screen.getByRole("img", { name: /gap analysis/i }),
- ).toBeInTheDocument();
+ expect(screen.getByRole("img", { name: /gap analysis/i })).toBeInTheDocument();
rerender();
- expect(
- screen.getByRole("img", { name: /gap analysis/i }),
- ).toBeInTheDocument();
+ expect(screen.getByRole("img", { name: /gap analysis/i })).toBeInTheDocument();
});
});
@@ -78,17 +70,11 @@ describe("AverageChart", () => {
});
it("does not crash on null means or empty series", () => {
- const withNulls = [
- { series_key: "empty", mean: null, non_null_count: 0 },
- ];
+ const withNulls = [{ series_key: "empty", mean: null, non_null_count: 0 }];
const { rerender } = render();
- expect(
- screen.getByRole("img", { name: /average gap per series/i }),
- ).toBeInTheDocument();
+ expect(screen.getByRole("img", { name: /average gap per series/i })).toBeInTheDocument();
rerender();
- expect(
- screen.getByRole("img", { name: /average gap per series/i }),
- ).toBeInTheDocument();
+ expect(screen.getByRole("img", { name: /average gap per series/i })).toBeInTheDocument();
});
});
@@ -107,12 +93,8 @@ describe("DistributionChart", () => {
{ subject: "number-2", value: "" },
];
const { rerender } = render();
- expect(
- screen.getByRole("img", { name: /probability distribution/i }),
- ).toBeInTheDocument();
+ expect(screen.getByRole("img", { name: /probability distribution/i })).toBeInTheDocument();
rerender();
- expect(
- screen.getByRole("img", { name: /probability distribution/i }),
- ).toBeInTheDocument();
+ expect(screen.getByRole("img", { name: /probability distribution/i })).toBeInTheDocument();
});
-});
\ No newline at end of file
+});
diff --git a/frontend/src/components/AssistantPanel.tsx b/frontend/src/components/AssistantPanel.tsx
index ae0d6d5..b6c4d6f 100644
--- a/frontend/src/components/AssistantPanel.tsx
+++ b/frontend/src/components/AssistantPanel.tsx
@@ -60,8 +60,7 @@ export default function AssistantPanel({ lotteryCode }: AssistantPanelProps) {
};
const experimentIdNumber = Number(experimentId);
- const hasExperimentId =
- Number.isInteger(experimentIdNumber) && experimentIdNumber > 0;
+ const hasExperimentId = Number.isInteger(experimentIdNumber) && experimentIdNumber > 0;
if (!lotteryCode) {
return (
@@ -69,10 +68,7 @@ export default function AssistantPanel({ lotteryCode }: AssistantPanelProps) {
aria-labelledby="ia-assistant-title"
className="rounded-md border border-gray-200 bg-white p-4"
>
-
+
Assistant
@@ -104,9 +100,7 @@ export default function AssistantPanel({ lotteryCode }: AssistantPanelProps) {
if (result) {
return (
-
- {result.text}
-
+
{result.text}
);
}
@@ -118,10 +112,7 @@ export default function AssistantPanel({ lotteryCode }: AssistantPanelProps) {
aria-labelledby="ia-assistant-title"
className="rounded-md border border-gray-200 bg-white p-4"
>
-
+
Assistant
@@ -140,22 +131,13 @@ export default function AssistantPanel({ lotteryCode }: AssistantPanelProps) {
placeholder="¿Por qué cambió la frecuencia del número 5?"
className={FIELD_CLASS}
/>
-