feat: expose map layer controls and explicit data coverage

This commit is contained in:
2026-10-29 23:15:11 +02:00
parent 87b74f0c2a
commit 8ddfa93f45
6 changed files with 434 additions and 7 deletions
@@ -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.
@@ -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."
}
}
@@ -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.
@@ -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 (
<section
className="space-y-2 rounded border p-3"
aria-label="Cobertura dos dados do mapa"
>
<h3 className="font-semibold">Cobertura desta consulta</h3>
{truncated && (
<p role="note">
Resultados limitados pela consulta. Refine os filtros; as
contagens abaixo não representam o total do cadastro.
</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<caption className="text-left">
Registos recebidos para os filtros actuais, incluindo
camadas ocultas.
</caption>
<thead>
<tr>
<th scope="col">Camada</th>
<th scope="col">Registos</th>
<th scope="col">Sem fonte indicada</th>
<th scope="col">Sem data indicada</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.label}>
<th scope="row">{row.label}</th>
<td>{row.records.length}</td>
<td>
{
row.records.filter(
(record) =>
!record.source_reference?.trim(),
).length
}
</td>
<td>
{
row.records.filter(
(record) =>
!record.source_date?.trim(),
).length
}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p>
Investimentos com ponto nesta resposta:{' '}
{new Set(points.map((point) => point.properties.id)).size}. Um
investimento pode ter vários marcadores.
</p>
<p>
Investimentos sem ponto público utilizável nesta consulta:{' '}
{unlocated}.
{unlocated > 0 && ' As fichas continuam disponíveis abaixo.'}
</p>
<p className="text-sm">
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.
</p>
</section>
);
}
@@ -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<InvestmentMapArea[]>([]);
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({
<section className="space-y-3">
<SearchState
scope="Registos consultados pelo mapa"
countLabel="registos, incluindo os que não têm coordenadas"
countLabel="investimentos, incluindo os que não têm coordenadas"
loading={points === null && !error}
error={error}
total={(points?.length ?? 0) + missing.length}
total={matched}
retry={() => setRetry((value) => value + 1)}
/>
<InteractiveInvestmentMap points={points} areas={areas} />
<fieldset className="flex flex-wrap gap-4 rounded border p-3">
<legend className="font-semibold">
Camadas desta consulta
</legend>
<label>
<input
type="checkbox"
checked={showPoints}
onChange={(event) =>
setShowPoints(event.target.checked)
}
/>{' '}
Investimentos com ponto
</label>
<label>
<input
type="checkbox"
checked={showAreas}
onChange={(event) => setShowAreas(event.target.checked)}
/>{' '}
Áreas e limites
</label>
</fieldset>
{!showPoints && !showAreas && (
<p role="status">
Camadas ocultas. Active uma camada para voltar a consultar
os registos no mapa.
</p>
)}
<InteractiveInvestmentMap
points={points === null ? null : showPoints ? points : []}
areas={showAreas ? areas : []}
/>
{points !== null && (
<InvestmentMapCoverage
points={points}
areas={areas}
unlocated={missing.length}
truncated={truncated}
/>
)}
<TerritoryGeometryPreview
territoryId={
territoryId ||
@@ -0,0 +1,215 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { compileFunction } from 'node:vm';
import { createRequire } from 'node:module';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import ts from 'typescript';
const require = createRequire(import.meta.url);
function load(path, mocks = {}) {
const source = fs.readFileSync(new URL(path, import.meta.url), 'utf8');
const output = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
jsx: ts.JsxEmit.ReactJSX,
},
}).outputText;
const module = { exports: {} };
compileFunction(output, ['require', 'module', 'exports'])(
(name) => 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,
/<th scope="row">Marcadores de investimento<\/th><td>2<\/td><td>1<\/td><td>1<\/td>/,
);
assert.match(
html,
/<th scope="row">Áreas e limites<\/th><td>1<\/td><td>0<\/td><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,
/<th scope="row">Marcadores de investimento<\/th><td>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;
}
});