diff --git a/apps/api/src/routes/scenarios.ts b/apps/api/src/routes/scenarios.ts index d7c4091..18d687e 100644 --- a/apps/api/src/routes/scenarios.ts +++ b/apps/api/src/routes/scenarios.ts @@ -6,9 +6,13 @@ import { eq } from 'drizzle-orm' import { ScenarioDTO, CreateScenarioDTO } from '@ymir/types' function rowToScenario(row: typeof scenarios.$inferSelect): ScenarioDTO { + // Legacy rows persisted before InitialCondition gained `instanceId` stored the + // body id under `vesselId`. Backfill it so the response satisfies the schema. + const initialConditions = (JSON.parse(row.initialConditions) as Array>) + .map((ic) => ({ instanceId: ic.instanceId ?? ic.vesselId, ...ic })) return { ...row, - initialConditions: JSON.parse(row.initialConditions), + initialConditions, description: row.description ?? undefined, areaId: row.areaId ?? undefined, createdAt: row.createdAt, diff --git a/apps/web/package.json b/apps/web/package.json index 72f572d..5688ada 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,6 +20,7 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-leaflet": "^5.0.0", + "react-router-dom": "^7.18.1", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6fc664f..c270503 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,5 +1,12 @@ +import { Routes, Route } from 'react-router-dom' +import { MyScenariosPage } from './features/scenarios/MyScenariosPage' import { AppShell } from './features/shell/AppShell' export default function App() { - return + return ( + + } /> + } /> + + ) } diff --git a/apps/web/src/features/scenarios/MyScenariosPage.test.tsx b/apps/web/src/features/scenarios/MyScenariosPage.test.tsx new file mode 100644 index 0000000..1a3bd56 --- /dev/null +++ b/apps/web/src/features/scenarios/MyScenariosPage.test.tsx @@ -0,0 +1,73 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import { MyScenariosPage } from './MyScenariosPage' + +const navigate = vi.fn() +vi.mock('react-router-dom', async (orig) => ({ + ...(await orig()), + useNavigate: () => navigate, +})) + +const scenarios = [ + { id: 1, name: 'Aproximação Santos', description: 'Manobra de atracação', duration: 3600, dt: 0.05, initialConditions: [], createdAt: '2026-01-02T00:00:00Z' }, + { id: 2, name: 'Saída Guanabara', duration: 3600, dt: 0.05, initialConditions: [], createdAt: '2026-01-03T00:00:00Z' }, +] + +function renderPage() { + return render() +} + +describe('MyScenariosPage', () => { + beforeEach(() => { + navigate.mockReset() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(scenarios) })) + }) + afterEach(() => vi.restoreAllMocks()) + + it('lists scenarios from the API', async () => { + renderPage() + expect(await screen.findByText('Aproximação Santos')).toBeInTheDocument() + expect(screen.getByText('Saída Guanabara')).toBeInTheDocument() + expect(screen.getAllByTestId('scenario-card')).toHaveLength(2) + }) + + it('filters by search query', async () => { + renderPage() + await screen.findByText('Aproximação Santos') + fireEvent.change(screen.getByLabelText(/Buscar cenários/i), { target: { value: 'santos' } }) + expect(screen.getByText('Aproximação Santos')).toBeInTheDocument() + expect(screen.queryByText('Saída Guanabara')).toBeNull() + }) + + it('navigates to the editor on "Novo cenário"', async () => { + renderPage() + await screen.findByText('Aproximação Santos') + fireEvent.click(screen.getByRole('button', { name: /Novo cenário/i })) + expect(navigate).toHaveBeenCalledWith('/editor') + }) + + it('opening a card navigates to the editor with the scenario id', async () => { + renderPage() + await screen.findByText('Aproximação Santos') + fireEvent.click(screen.getByRole('button', { name: /Abrir cenário Aproximação Santos/i })) + expect(navigate).toHaveBeenCalledWith('/editor?scenario=1') + }) + + it('deleting a card calls DELETE and removes it', async () => { + renderPage() + await screen.findByText('Aproximação Santos') + fireEvent.click(screen.getByRole('button', { name: /Excluir cenário Aproximação Santos/i })) + await waitFor(() => expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/scenarios/1'), + expect.objectContaining({ method: 'DELETE' }), + )) + await waitFor(() => expect(screen.queryByText('Aproximação Santos')).toBeNull()) + }) + + it('shows the empty state when there are no scenarios', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve([]) })) + renderPage() + expect(await screen.findByText(/Nenhum cenário criado ainda/i)).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/features/scenarios/MyScenariosPage.tsx b/apps/web/src/features/scenarios/MyScenariosPage.tsx new file mode 100644 index 0000000..feb5941 --- /dev/null +++ b/apps/web/src/features/scenarios/MyScenariosPage.tsx @@ -0,0 +1,66 @@ +import { useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import type { ScenarioDTO } from '@ymir/types' +import { useScenarios } from './use-scenarios' +import { ScenarioCard } from './ScenarioCard' +import { Button } from '../../ui/Button' +import { tokens } from '../../theme/tokens' + +/** Landing screen: lists saved scenarios with search + create (Figma "Meus cenários"). */ +export function MyScenariosPage() { + const { scenarios, loading, error, remove } = useScenarios() + const [query, setQuery] = useState('') + const navigate = useNavigate() + + const filtered = useMemo( + () => scenarios.filter(s => s.name.toLowerCase().includes(query.toLowerCase())), + [scenarios, query], + ) + + return ( +
+ + +
+
+

Meus cenários

+ +
+ + setQuery(e.target.value)} + placeholder="Buscar cenários" + aria-label="Buscar cenários" + style={{ width: '100%', maxWidth: 360, height: tokens.size.inputHeight, padding: `0 ${tokens.space.md}px`, marginBottom: tokens.space.lg, border: `1px solid ${tokens.color.border}`, borderRadius: tokens.radius.button, fontSize: tokens.fontSize.body, fontFamily: tokens.font.sans }} + /> + + {loading &&

Carregando cenários...

} + {error &&

Erro ao carregar cenários.

} + {!loading && !error && filtered.length === 0 && ( +

+ {scenarios.length === 0 ? 'Nenhum cenário criado ainda.' : 'Nenhum cenário encontrado.'} +

+ )} + +
+ {filtered.map(s => ( + navigate(`/editor?scenario=${sc.id}`)} + onDelete={(sc: ScenarioDTO) => void remove(sc.id)} + /> + ))} +
+
+
+ ) +} diff --git a/apps/web/src/features/scenarios/ScenarioCard.tsx b/apps/web/src/features/scenarios/ScenarioCard.tsx new file mode 100644 index 0000000..fcebdf9 --- /dev/null +++ b/apps/web/src/features/scenarios/ScenarioCard.tsx @@ -0,0 +1,64 @@ +import type { ScenarioDTO } from '@ymir/types' +import { tokens } from '../../theme/tokens' + +export interface ScenarioCardProps { + scenario: ScenarioDTO + onOpen: (s: ScenarioDTO) => void + onDelete: (s: ScenarioDTO) => void +} + +/** Grid card for a saved scenario (Figma "Meus cenários"). */ +export function ScenarioCard({ scenario, onOpen, onDelete }: ScenarioCardProps) { + const date = new Date(scenario.createdAt) + const dateLabel = isNaN(date.getTime()) ? '' : date.toLocaleDateString('pt-BR') + + return ( +
+ +
+
+ {scenario.name} +
+ {scenario.description && ( +
+ {scenario.description} +
+ )} +
+ {dateLabel} + +
+
+
+ ) +} diff --git a/apps/web/src/features/scenarios/use-scenarios.ts b/apps/web/src/features/scenarios/use-scenarios.ts new file mode 100644 index 0000000..b9dc9c5 --- /dev/null +++ b/apps/web/src/features/scenarios/use-scenarios.ts @@ -0,0 +1,31 @@ +import { useState, useEffect, useCallback } from 'react' +import type { ScenarioDTO } from '@ymir/types' + +const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3000' + +export function useScenarios() { + const [scenarios, setScenarios] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [tick, setTick] = useState(0) + + const refresh = useCallback(() => setTick(t => t + 1), []) + + useEffect(() => { + let cancelled = false + setLoading(true) + setError(null) + fetch(`${API_BASE}/scenarios`) + .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise }) + .then(data => { if (!cancelled) { setScenarios(data); setLoading(false) } }) + .catch(err => { if (!cancelled) { setError(String(err)); setLoading(false) } }) + return () => { cancelled = true } + }, [tick]) + + const remove = useCallback(async (id: number) => { + await fetch(`${API_BASE}/scenarios/${id}`, { method: 'DELETE' }) + setScenarios(s => s.filter(x => x.id !== id)) + }, []) + + return { scenarios, loading, error, refresh, remove } +} diff --git a/apps/web/src/features/shell/AppShell.test.tsx b/apps/web/src/features/shell/AppShell.test.tsx index 09d57ed..44f88e9 100644 --- a/apps/web/src/features/shell/AppShell.test.tsx +++ b/apps/web/src/features/shell/AppShell.test.tsx @@ -1,5 +1,6 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' +import { MemoryRouter } from 'react-router-dom' import { AppShell } from './AppShell' import { useScenarioStore } from '../scenario-creator/store' import { useSimulationStore } from '../../stores/simulationStore' @@ -31,7 +32,7 @@ describe('AppShell', () => { }) it('renders the top bar (breadcrumb + save) and floating controls', () => { - render() + render() expect(screen.getByRole('button', { name: /Salvar cenário/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /Aproximar/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /Afastar/i })).toBeInTheDocument() @@ -39,7 +40,7 @@ describe('AppShell', () => { }) it('opens the no-ownship alert when saving with no vessels', () => { - render() + render() fireEvent.click(screen.getByRole('button', { name: /Salvar cenário/i })) expect(screen.getByText('Seu cenário não possui um ownship')).toBeInTheDocument() // sidebar hooks fetch /areas + /vessels on mount; what must NOT happen is a scenario save @@ -50,7 +51,7 @@ describe('AppShell', () => { }) it('"Salvar mesmo assim" confirms the save and closes the dialog', async () => { - render() + render() fireEvent.click(screen.getByRole('button', { name: /Salvar cenário/i })) fireEvent.click(screen.getByRole('button', { name: /Salvar mesmo assim/i })) await waitFor(() => expect(fetch).toHaveBeenCalledWith( @@ -63,7 +64,7 @@ describe('AppShell', () => { it('saves directly (no dialog) when a vessel exists', async () => { useScenarioStore.getState().setArea(mockArea) useScenarioStore.getState().addVessel({ vesselId: 1, name: 'VLCC' }) - render() + render() fireEvent.click(screen.getByRole('button', { name: /Salvar cenário/i })) expect(screen.queryByText(/não possui um ownship/i)).toBeNull() await waitFor(() => expect(fetch).toHaveBeenCalled()) @@ -72,7 +73,7 @@ describe('AppShell', () => { it('Build triggers scenario save + simulation start', async () => { useScenarioStore.getState().setArea(mockArea) useScenarioStore.getState().addVessel({ vesselId: 1, name: 'VLCC' }) - render() + render() fireEvent.click(screen.getByRole('button', { name: /Build simulation/i })) await waitFor(() => expect(fetch).toHaveBeenCalled()) expect(['loading', 'running']).toContain(useSimulationStore.getState().status) diff --git a/apps/web/src/features/shell/AppShell.tsx b/apps/web/src/features/shell/AppShell.tsx index 71d1643..07eb60e 100644 --- a/apps/web/src/features/shell/AppShell.tsx +++ b/apps/web/src/features/shell/AppShell.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { useNavigate } from 'react-router-dom' import { TopBar } from '../../ui/TopBar' import { IconButton } from '../../ui/IconButton' import { Button } from '../../ui/Button' @@ -32,6 +33,7 @@ export function AppShell() { const { status, start, stop, loadScenario } = useSimulationStore() const { zoomIn, zoomOut } = useMapStore() const [confirmNoOwnship, setConfirmNoOwnship] = useState(false) + const navigate = useNavigate() async function saveScenario() { try { @@ -84,7 +86,7 @@ export function AppShell() { const right = (
- +
) diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 4788af5..3682185 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' import 'leaflet/dist/leaflet.css' import './theme/global.css' import { applyThemeVars } from './theme/tokens' @@ -9,6 +10,8 @@ applyThemeVars() createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 807d3cc..f48e635 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: react-leaflet: specifier: ^5.0.0 version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-router-dom: + specifier: ^7.18.1 + version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) zustand: specifier: ^5.0.0 version: 5.0.14(@types/react@19.2.17)(react@19.2.7) @@ -1311,6 +1314,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -1838,6 +1845,23 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-router-dom@7.18.1: + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.1: + resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} @@ -3050,6 +3074,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.1.1: {} + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -3579,6 +3605,20 @@ snapshots: react-refresh@0.17.0: {} + react-router-dom@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-router: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + + react-router@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + cookie: 1.1.1 + react: 19.2.7 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + react@19.2.7: {} readable-stream@3.6.2: