From 4e07557cb1567f0efeadbb7302c7de04ac221009 Mon Sep 17 00:00:00 2001 From: nala Date: Tue, 10 Nov 2026 10:41:48 +0200 Subject: [PATCH] fix(opportunities): distinguish missing verification and improve metadata --- resources/js/lib/opportunity-information.ts | 41 +++++++++++++++++++ resources/js/pages/investment/detail.tsx | 27 +++++++++++- .../frontend/opportunity-information.test.mjs | 23 +++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 resources/js/lib/opportunity-information.ts create mode 100644 tests/frontend/opportunity-information.test.mjs diff --git a/resources/js/lib/opportunity-information.ts b/resources/js/lib/opportunity-information.ts new file mode 100644 index 00000000..2be7b96d --- /dev/null +++ b/resources/js/lib/opportunity-information.ts @@ -0,0 +1,41 @@ +export function opportunityDescription(record: { + title: string; + summary?: string | null; +}): string { + const summary = record.summary + ?.replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + const text = + summary || `Consulte a ficha de ${record.title} no InvesteMoz.`; + return Array.from( + text + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(), + ) + .slice(0, 160) + .join(''); +} + +export function recordedVerificationDate(value?: string | null): string { + if (!value?.trim()) return 'Não registada'; + const parts = /^(\d{4})-(\d{2})-(\d{2})/.exec(value); + if (!parts) return 'Data não interpretável'; + const [, year, month, day] = parts.map(Number); + const calendar = new Date(Date.UTC(year, month - 1, day)); + if ( + calendar.getUTCFullYear() !== year || + calendar.getUTCMonth() !== month - 1 || + calendar.getUTCDate() !== day + ) + return 'Data não interpretável'; + if (value.length > 10 && !/(Z|[+-]\d{2}:\d{2})$/.test(value)) + return 'Fuso horário não registado'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return 'Data não interpretável'; + return new Intl.DateTimeFormat('pt-MZ', { + dateStyle: 'medium', + timeZone: 'Africa/Maputo', + }).format(date); +} diff --git a/resources/js/pages/investment/detail.tsx b/resources/js/pages/investment/detail.tsx index 520fc4e7..e5a3b94a 100644 --- a/resources/js/pages/investment/detail.tsx +++ b/resources/js/pages/investment/detail.tsx @@ -9,7 +9,11 @@ import { } from '@/lib/search-territory'; import type { EducationalArticle } from '@/components/educational-article-card'; import { ClassificationSummary } from '@/components/opportunity-classification'; -import { Link, router, usePage } from '@inertiajs/react'; +import { Head, Link, router, usePage } from '@inertiajs/react'; +import { + opportunityDescription, + recordedVerificationDate, +} from '@/lib/opportunity-information'; import { useState } from 'react'; import { downloadPublicOpportunityOffline } from '@/lib/public-opportunity-offline'; import InvestmentMapPanel from '@/components/investment-map-panel'; @@ -46,6 +50,23 @@ export default function Detail({ const [offlineMessage, setOfflineMessage] = useState(''); return ( + + + + + {r.maturity_label === 'Em preparação' && (

Em preparação. Consulte a oportunidade e @@ -273,6 +294,10 @@ export default function Detail({

{[ ['Promotor', r.promoter_name], + [ + 'Última verificação registada', + recordedVerificationDate(r.last_validated_at), + ], ['Investimento', money(r)], ['Maturidade', r.maturity_label], [ diff --git a/tests/frontend/opportunity-information.test.mjs b/tests/frontend/opportunity-information.test.mjs new file mode 100644 index 00000000..a0709afd --- /dev/null +++ b/tests/frontend/opportunity-information.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + opportunityDescription, + recordedVerificationDate, +} from '../../resources/js/lib/opportunity-information.ts'; + +test('metadata uses the actual summary, normalises markup and never invents investment terms', () => { + assert.equal(opportunityDescription({ title: 'Projecto', summary: 'Resumo\n declarado' }), 'Resumo declarado'); + const fallback = opportunityDescription({ title: 'Avicultura', summary: null }); + assert.match(fallback, /Avicultura/); + assert.match(opportunityDescription({ title: 'Projecto', summary: ' ' }), /Projecto/); + assert.doesNotMatch(fallback, /A negociar|USD|aprovad|benefício/i); + assert.equal(Array.from(opportunityDescription({ title: 'Projecto', summary: 'a'.repeat(300) })).length, 160); +}); + +test('unknown and invalid verification dates remain distinct from a recorded verification', () => { + assert.equal(recordedVerificationDate(null), 'Não registada'); + assert.equal(recordedVerificationDate('invalid'), 'Data não interpretável'); + assert.equal(recordedVerificationDate('2026-02-30T12:00:00Z'), 'Data não interpretável'); + assert.equal(recordedVerificationDate('2026-11-10 12:00:00'), 'Fuso horário não registado'); + assert.match(recordedVerificationDate('2026-11-10T12:00:00Z'), /2026/); +});