From 8ddfa93f454ae8df64b9b1c8fc145a05a2aa8cad Mon Sep 17 00:00:00 2001 From: nala Date: Thu, 29 Oct 2026 23:15:11 +0200 Subject: [PATCH] feat: expose map layer controls and explicit data coverage --- .../consolidated-review-matrix-2026-10-03.md | 13 ++ .../local-validation-20261029.json | 41 ++++ .../unification/traceability-and-manual.md | 13 ++ .../js/components/investment-map-coverage.tsx | 94 ++++++++ .../js/components/investment-map-panel.tsx | 65 +++++- tests/frontend/investment-map-panel.test.mjs | 215 ++++++++++++++++++ 6 files changed, 434 insertions(+), 7 deletions(-) create mode 100644 resources/js/components/investment-map-coverage.tsx create mode 100644 tests/frontend/investment-map-panel.test.mjs diff --git a/docs/investmoz/consolidated-review-matrix-2026-10-03.md b/docs/investmoz/consolidated-review-matrix-2026-10-03.md index 3d2281f9..d661562d 100644 --- a/docs/investmoz/consolidated-review-matrix-2026-10-03.md +++ b/docs/investmoz/consolidated-review-matrix-2026-10-03.md @@ -2185,3 +2185,16 @@ O editor captura corpo e sequência em conjunto. Antes do envio em fila e da apr Dois casos novos falharam contra o código anterior e passaram na candidata. Vinte e dois testes frontend dirigidos passaram: temporizador antigo após edição, envio ultrapassado em fila, resposta tardia depois de A→B→A, sequência crescente e confirmação somente do rascunho actual. Tipos, lint, formatação e compilação QA passaram; provas históricas aceites reutilizadas, sem repetição integral. Recibo/hashes em `context_autosave_ordering_increment` de `local-validation-20261029.json`. **Visual pendente pela equipa:** em QA, editar sucessivamente enquanto um envio automático aguarda, incluindo regressar ao valor inicial; aguardar a confirmação da revisão actual, sair/regressar e conferir os últimos valores recuperados. Usar atraso controlado de rede em QA, sem alterações sintéticas no activo. Registar revisão, perfil, percurso, esperado/observado e evidência nesta matriz. A prova com temporizadores/fila não substitui a sessão no navegador. Sem instalação ou avanço de main; requisitos transversais permanecem parciais. + + +### 29/10/2026 — ACT-20261029-02: camadas existentes e cobertura explícita do mapa + +Incremento funcional sobre `87b74f0c`, reutilizando o mapa, as APIs e os filtros territoriais existentes (V2-07/V2-10). Antes da execução foi conferida a matriz e o contrato de `InvestmentCatalogue::map`: pontos, áreas/limites, registos sem coordenadas, fonte/data, limite de resposta e contagens já estavam disponíveis. Não houve perda confirmada nem se reabriu a correcção provincial instalada. + +O painel permite ocultar/repor independentemente pontos e áreas/limites, ambos visíveis por defeito. As opções não mudam filtros, não fazem nova consulta nem eliminam registos. O novo resumo mostra marcadores/áreas recebidos, falta de fonte/data e investimentos sem ponto utilizável, incluindo camadas ocultas; mantém as ligações às fichas. Distingue investimento de marcador: um investimento pode ter vários locais. O total passa a usar `matched_count` da API, em vez de somar marcadores como investimentos; fallback deduplica os identificadores. A indicação de truncagem é agora aviso de cobertura, não falha de ligação. Fontes/datas vazias não passam a comprovadas; ausência de registos não é ausência de investimento. + +Dois testes frontend dirigidos passaram: contagens/proveniência desconhecida/resposta limitada/ausência de dados e ocultação/reposição sem perder registos, filtros ou fichas. O exemplo contém dois marcadores do mesmo investimento e distingue o total encontrado do subconjunto recebido. Tipos, lint, formatação e compilação QA passaram. Provas territoriais e das funcionalidades anteriores reutilizadas, sem repetição integral. Recibo/hashes no campo `map_layer_coverage_increment` de `local-validation-20261029.json`. + +**ACT-02 permanece parcial:** este recorte implementa controlo das camadas já existentes e explicitação de cobertura. Não cria camadas factuais de recursos, infra-estruturas, mão-de-obra ou potencial económico, nem declara completa a cobertura provincial/distrital. Backend, geometrias, divulgação, permissões, fontes, datas e cálculo preservados; sem importações ou escrita no activo. + +**Visual pendente pela equipa:** na candidata, conferir os mapas da ficha de oportunidade, carteira e zona de investimento; ocultar/repor cada camada e ambas, conferir contagens de investimentos/marcadores, fonte/data ausentes e fichas sem coordenadas, e mudar filtros territoriais. Registar revisão, perfil, percurso, esperado/observado e evidência nesta matriz. O painel de contexto administrativo separado mantém o seu âmbito. Sem instalação ou avanço de main. diff --git a/docs/investmoz/unification/local-validation-20261029.json b/docs/investmoz/unification/local-validation-20261029.json index f8e84b81..8f2b112e 100644 --- a/docs/investmoz/unification/local-validation-20261029.json +++ b/docs/investmoz/unification/local-validation-20261029.json @@ -1474,5 +1474,46 @@ "preservation": "Frontend sequencing only. Existing backend version/sequence conflict checks preserved, not disabled. No schema, data, permissions or tax changes and no active writes. Historical suites reused.", "visual_acceptance": "Pending team execution and recording; controlled timer/queue tests do not establish browser-session acceptance.", "installation": "Not triggered; main and active release unchanged." + }, + "map_layer_coverage_increment": { + "recorded_at": "2026-10-29T21:15:10.944454+00:00", + "base_commit": "87b74f0c2a0712bcc35ddea1f6788670d7390fe9", + "requirements": [ + "ACT-20261029-02", + "V2-07", + "V2-10" + ], + "behavior": [ + "Existing point and area/limit layers can be hidden and restored independently; both remain enabled by default. No extra query, filter change or record deletion on toggle.", + "Coverage table reports returned markers/areas, missing source/date and unlocated opportunities, including hidden layers. Whitespace-only source/date is treated as not indicated, not as evidence.", + "Count investments using existing API matched_count, with distinct point identifiers plus unlocated count as fallback. Distinguish marker count from distinct georeferenced investments.", + "A truncated response is a coverage warning rather than a failed request. Explicitly distinguish incomplete coverage from absence of investment or availability of resources/infrastructure/labour." + ], + "api_inventory": "InvestmentCatalogue::map already returns matched_count, georeferenced_count, marker_count, unlocated, areas, feature provenance and truncation; backend unchanged.", + "checks": { + "new_directed_frontend_cases": 2, + "passed": 2, + "types": "passed", + "lint": "passed", + "format": "passed", + "qa_build": "passed" + }, + "source_sha256": { + "resources/js/components/investment-map-panel.tsx": "04834dc0f80282ec57dd1ba596f82a0871b0195bf16b99f411afbfd4c9d7ffa8", + "resources/js/components/investment-map-coverage.tsx": "c0b6ea26dd5fdf6d93b7f5b061a48e65f74df6caa7e12490e9a264365671be1c", + "tests/frontend/investment-map-panel.test.mjs": "67526c11a9cce25e295b7e0e49ad639077d882dc6e6d83b2c603e25c180ab549" + }, + "evidence": { + "outputs/validation-20261029/map-coverage-tests.log": "ffc2170e5e98e2d4aefdd97ff8046291fa37f60fbb708d0244cba6d00f5616c6", + "outputs/validation-20261029/map-coverage-types.log": "82962651e47f564de653ad2883f8fd34e7dae4b4468e4ac459cb3331962a3879", + "outputs/validation-20261029/map-coverage-lint.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "outputs/validation-20261029/map-coverage-format-check.log": "def31e032d94cd0946ed119a7baceba3757ed29dfaf53c194fe000e28db43747", + "outputs/validation-20261029/map-coverage-build.log": "4fd244d1b2bd0016a156b99fd97f684ff207a8a778e689f93da93c8f78cd76f7", + "outputs/validation-20261029/map-coverage-build-exit.json": "124fd50cb6d223cdd18fedc166d00af7254199b6e6dedf9be82f71db87af6d01" + }, + "remaining": "ACT-02 remains partial: structured resource/infrastructure/labour/economic-potential layers and factual provincial/district coverage are not implemented or accepted by this increment.", + "preservation": "Existing territorial queries, publication/permissions, geometry/source/date values and unlocated links retained. No backend/schema/active data changes, new factual imports or fiscal changes. Prior proofs reused.", + "visual_acceptance": "Pending team candidate browser session and recording for portfolio, opportunity and investment-zone map consumers.", + "installation": "Not triggered; main and active release unchanged." } } diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index 1085f086..83e2854c 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -7722,3 +7722,16 @@ O editor captura corpo e sequência em conjunto. Antes do envio em fila e da apr Dois casos novos falharam contra o código anterior e passaram na candidata. Vinte e dois testes frontend dirigidos passaram: temporizador antigo após edição, envio ultrapassado em fila, resposta tardia depois de A→B→A, sequência crescente e confirmação somente do rascunho actual. Tipos, lint, formatação e compilação QA passaram; provas históricas aceites reutilizadas, sem repetição integral. Recibo/hashes em `context_autosave_ordering_increment` de `local-validation-20261029.json`. **Visual pendente pela equipa:** em QA, editar sucessivamente enquanto um envio automático aguarda, incluindo regressar ao valor inicial; aguardar a confirmação da revisão actual, sair/regressar e conferir os últimos valores recuperados. Usar atraso controlado de rede em QA, sem alterações sintéticas no activo. Registar revisão, perfil, percurso, esperado/observado e evidência nesta matriz. A prova com temporizadores/fila não substitui a sessão no navegador. Sem instalação ou avanço de main; requisitos transversais permanecem parciais. + + +### 29/10/2026 — ACT-20261029-02: camadas existentes e cobertura explícita do mapa + +Incremento funcional sobre `87b74f0c`, reutilizando o mapa, as APIs e os filtros territoriais existentes (V2-07/V2-10). Antes da execução foi conferida a matriz e o contrato de `InvestmentCatalogue::map`: pontos, áreas/limites, registos sem coordenadas, fonte/data, limite de resposta e contagens já estavam disponíveis. Não houve perda confirmada nem se reabriu a correcção provincial instalada. + +O painel permite ocultar/repor independentemente pontos e áreas/limites, ambos visíveis por defeito. As opções não mudam filtros, não fazem nova consulta nem eliminam registos. O novo resumo mostra marcadores/áreas recebidos, falta de fonte/data e investimentos sem ponto utilizável, incluindo camadas ocultas; mantém as ligações às fichas. Distingue investimento de marcador: um investimento pode ter vários locais. O total passa a usar `matched_count` da API, em vez de somar marcadores como investimentos; fallback deduplica os identificadores. A indicação de truncagem é agora aviso de cobertura, não falha de ligação. Fontes/datas vazias não passam a comprovadas; ausência de registos não é ausência de investimento. + +Dois testes frontend dirigidos passaram: contagens/proveniência desconhecida/resposta limitada/ausência de dados e ocultação/reposição sem perder registos, filtros ou fichas. O exemplo contém dois marcadores do mesmo investimento e distingue o total encontrado do subconjunto recebido. Tipos, lint, formatação e compilação QA passaram. Provas territoriais e das funcionalidades anteriores reutilizadas, sem repetição integral. Recibo/hashes no campo `map_layer_coverage_increment` de `local-validation-20261029.json`. + +**ACT-02 permanece parcial:** este recorte implementa controlo das camadas já existentes e explicitação de cobertura. Não cria camadas factuais de recursos, infra-estruturas, mão-de-obra ou potencial económico, nem declara completa a cobertura provincial/distrital. Backend, geometrias, divulgação, permissões, fontes, datas e cálculo preservados; sem importações ou escrita no activo. + +**Visual pendente pela equipa:** na candidata, conferir os mapas da ficha de oportunidade, carteira e zona de investimento; ocultar/repor cada camada e ambas, conferir contagens de investimentos/marcadores, fonte/data ausentes e fichas sem coordenadas, e mudar filtros territoriais. Registar revisão, perfil, percurso, esperado/observado e evidência nesta matriz. O painel de contexto administrativo separado mantém o seu âmbito. Sem instalação ou avanço de main. diff --git a/resources/js/components/investment-map-coverage.tsx b/resources/js/components/investment-map-coverage.tsx new file mode 100644 index 00000000..871f6329 --- /dev/null +++ b/resources/js/components/investment-map-coverage.tsx @@ -0,0 +1,94 @@ +import type { + InvestmentMapArea, + InvestmentMapFeature, +} from './interactive-investment-map'; + +export default function InvestmentMapCoverage({ + points, + areas, + unlocated, + truncated, +}: { + points: InvestmentMapFeature[]; + areas: InvestmentMapArea[]; + unlocated: number; + truncated: boolean; +}) { + const rows = [ + { + label: 'Marcadores de investimento', + records: points.map((point) => point.properties), + }, + { label: 'Áreas e limites', records: areas }, + ]; + return ( +
+

Cobertura desta consulta

+ {truncated && ( +

+ Resultados limitados pela consulta. Refine os filtros; as + contagens abaixo não representam o total do cadastro. +

+ )} +
+ + + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + +
+ Registos recebidos para os filtros actuais, incluindo + camadas ocultas. +
CamadaRegistosSem fonte indicadaSem data indicada
{row.label}{row.records.length} + { + row.records.filter( + (record) => + !record.source_reference?.trim(), + ).length + } + + { + row.records.filter( + (record) => + !record.source_date?.trim(), + ).length + } +
+
+

+ Investimentos com ponto nesta resposta:{' '} + {new Set(points.map((point) => point.properties.id)).size}. Um + investimento pode ter vários marcadores. +

+

+ Investimentos sem ponto público utilizável nesta consulta:{' '} + {unlocated}. + {unlocated > 0 && ' As fichas continuam disponíveis abaixo.'} +

+

+ A indicação de fonte ou data não comprova cobertura completa nem + actualidade. Ausência de registos não significa ausência de + investimento. Esta consulta não mede disponibilidade de + recursos, infra-estruturas ou mão-de-obra. +

+
+ ); +} diff --git a/resources/js/components/investment-map-panel.tsx b/resources/js/components/investment-map-panel.tsx index 2974abd5..c41d3ccb 100644 --- a/resources/js/components/investment-map-panel.tsx +++ b/resources/js/components/investment-map-panel.tsx @@ -1,6 +1,7 @@ import SearchState from '@/components/search-state'; import { useEffect, useState } from 'react'; import TerritoryGeometryPreview from './territory-geometry-preview'; +import InvestmentMapCoverage from './investment-map-coverage'; import InteractiveInvestmentMap, { type InvestmentMapFeature, type InvestmentMapArea, @@ -21,6 +22,10 @@ export default function InvestmentMapPanel({ const [error, setError] = useState(''); const [retry, setRetry] = useState(0); const [areas, setAreas] = useState([]); + const [truncated, setTruncated] = useState(false); + const [showPoints, setShowPoints] = useState(true); + const [showAreas, setShowAreas] = useState(true); + const [matched, setMatched] = useState(0); useEffect(() => { const controller = new AbortController(); const params = new URLSearchParams(JSON.parse(key)); @@ -28,6 +33,8 @@ export default function InvestmentMapPanel({ setError(''); setMissing([]); setAreas([]); + setTruncated(false); + setMatched(0); fetch(`/api/v1/investment/map?${params}`, { signal: controller.signal, headers: { Accept: 'application/json' }, @@ -41,10 +48,14 @@ export default function InvestmentMapPanel({ setPoints(data.features); setAreas(data.areas ?? []); setMissing(data.unlocated ?? []); - setError( - data.truncated - ? 'Cobertura limitada: refine os filtros.' - : '', + setTruncated(Boolean(data.truncated)); + setMatched( + data.matched_count ?? + new Set( + (data.features as InvestmentMapFeature[]).map( + (point) => point.properties.id, + ), + ).size + (data.unlocated?.length ?? 0), ); }) .catch(() => { @@ -57,13 +68,53 @@ export default function InvestmentMapPanel({
setRetry((value) => value + 1)} /> - +
+ + Camadas desta consulta + + + +
+ {!showPoints && !showAreas && ( +

+ Camadas ocultas. Active uma camada para voltar a consultar + os registos no mapa. +

+ )} + + {points !== null && ( + + )} mocks[name] ?? require(name), + module, + module.exports, + ); + return module.exports.default; +} +const Coverage = load( + '../../resources/js/components/investment-map-coverage.tsx', +); +const flat = (node) => + !node || typeof node !== 'object' + ? [] + : [node, ...React.Children.toArray(node.props.children).flatMap(flat)]; +const data = { + features: [ + { + geometry: { coordinates: [35, -20] }, + properties: { + id: 1, + title: 'Synthetic point', + url: '/synthetic/1', + source_reference: 'Synthetic source', + source_date: '2026-10-29', + }, + }, + { + geometry: { coordinates: [36, -21] }, + properties: { + id: 1, + title: 'Synthetic unknown', + url: '/synthetic/2', + source_reference: ' ', + source_date: null, + }, + }, + ], + areas: [ + { + title: 'Synthetic area', + geometry: { type: 'Polygon', coordinates: [] }, + url: '/synthetic/area', + source_reference: 'Synthetic area source', + source_date: '', + }, + ], + unlocated: [{ id: 3, title: 'Synthetic unlocated', url: '/synthetic/3' }], + truncated: true, + matched_count: 8, +}; +void test('map coverage distinguishes returned counts, missing provenance and truncated coverage without changing data', () => { + const before = JSON.stringify(data); + const html = renderToStaticMarkup( + React.createElement(Coverage, { + points: data.features, + areas: data.areas, + unlocated: data.unlocated.length, + truncated: true, + }), + ); + assert.match( + html, + /as contagens abaixo não representam o total do cadastro/, + ); + assert.match( + html, + /Marcadores de investimento<\/th>2<\/td>1<\/td>1<\/td>/, + ); + assert.match( + html, + /Áreas e limites<\/th>1<\/td>0<\/td>1<\/td>/, + ); + assert.match( + html, + /Investimentos sem ponto público utilizável nesta consulta: 1/, + ); + assert.match(html, /Investimentos com ponto nesta resposta: 1/); + assert.match( + html, + /Ausência de registos não significa ausência de investimento/, + ); + assert.equal(JSON.stringify(data), before); + const empty = renderToStaticMarkup( + React.createElement(Coverage, { + points: [], + areas: [], + unlocated: 0, + truncated: false, + }), + ); + assert.doesNotMatch(empty, /As fichas continuam disponíveis abaixo/); + assert.doesNotMatch(empty, /Resultados limitados/); +}); +void test('map layer choices preserve received records, coverage, unlocated links and filters without another request', async () => { + const previousFetch = globalThis.fetch; + let resolve; + const requests = []; + globalThis.fetch = (url, options) => { + requests.push({ url, options }); + return new Promise((done) => { + resolve = done; + }); + }; + const memory = []; + let cursor = 0; + const effects = []; + const hooks = { + ...React, + useState(initial) { + const index = cursor++; + if (!(index in memory)) memory[index] = initial; + return [ + memory[index], + (value) => { + memory[index] = + typeof value === 'function' + ? value(memory[index]) + : value; + }, + ]; + }, + useEffect(callback) { + effects.push(callback); + }, + }; + const MapView = () => null; + const SearchState = () => null; + const Panel = load( + '../../resources/js/components/investment-map-panel.tsx', + { + react: hooks, + '@/components/search-state': { default: SearchState }, + './territory-geometry-preview': { default: () => null }, + './interactive-investment-map': { default: MapView }, + './investment-map-coverage': { default: Coverage }, + }, + ); + const render = () => { + cursor = 0; + effects.length = 0; + return Panel({ filters: { province_id: 'synthetic-province' } }); + }; + let cleanup; + try { + assert.equal( + flat(render()).some((node) => node.type === Coverage), + false, + ); + cleanup = effects[0](); + resolve({ ok: true, json: async () => data }); + await new Promise((done) => setImmediate(done)); + let tree = render(); + assert.equal( + flat(tree).find((node) => node.type === SearchState).props.error, + '', + ); + assert.equal( + flat(tree).find((node) => node.type === SearchState).props.total, + 8, + ); + const map = () => flat(tree).find((node) => node.type === MapView); + assert.equal(map().props.points.length, 2); + assert.equal(map().props.areas.length, 1); + flat(tree) + .filter((node) => node.type === 'input')[0] + .props.onChange({ target: { checked: false } }); + tree = render(); + assert.deepEqual(map().props.points, []); + assert.equal(map().props.areas.length, 1); + flat(tree) + .filter((node) => node.type === 'input')[1] + .props.onChange({ target: { checked: false } }); + tree = render(); + assert.deepEqual(map().props.areas, []); + const html = renderToStaticMarkup(tree); + assert.match(html, /Camadas ocultas/); + assert.match(html, /incluindo camadas ocultas/); + assert.match(html, /href="\/synthetic\/3"/); + assert.match( + html, + /Marcadores de investimento<\/th>2<\/td>/, + ); + for (const checkbox of flat(tree).filter( + (node) => node.type === 'input', + )) + checkbox.props.onChange({ target: { checked: true } }); + tree = render(); + assert.equal(map().props.points, data.features); + assert.equal(map().props.areas, data.areas); + assert.equal(requests.length, 1); + assert.match(requests[0].url, /province_id=synthetic-province/); + } finally { + cleanup?.(); + globalThis.fetch = previousFetch; + } +});