mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 17:43:04 +00:00
170 lines
5.5 KiB
TypeScript
170 lines
5.5 KiB
TypeScript
/**
|
|
* S39 — pure contract of the home section that advertises the Central de
|
|
* Conteúdos: the lean projection of a published piece, the visitor's location
|
|
* labels resolved from the committed Bahia meshes and the sample selection
|
|
* (município → região → recentes).
|
|
*
|
|
* No I/O and no `server-only`: the cached read, the client island and the unit
|
|
* tests share this module. The visitor's position is only ever resolved in the
|
|
* browser — this module never sees an IP and never persists anything.
|
|
*/
|
|
import type {
|
|
MunicipalityGeometryModule,
|
|
MunicipalityZoneGeometryModule,
|
|
TerritoryGeometryModule,
|
|
} from '@/lib/bahiaGeometriesTypes'
|
|
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
|
|
import { findContainingFeature, type GeoPoint } from '@/lib/municipalityProximity'
|
|
import { slugify } from '@/lib/slug'
|
|
|
|
/**
|
|
* What the home card actually renders — the full public item carries the
|
|
* normalized `searchText` haystack (transcript + description) and the raw
|
|
* `description`, which must never ride the home's static HTML to every visitor.
|
|
*/
|
|
export type ContentPieceHomeItem = Pick<
|
|
ContentPiecePublicItem,
|
|
| 'id'
|
|
| 'slug'
|
|
| 'title'
|
|
| 'type'
|
|
| 'typeLabel'
|
|
| 'origin'
|
|
| 'originLabel'
|
|
| 'isLink'
|
|
| 'cityLabel'
|
|
| 'regionLabel'
|
|
| 'excerpt'
|
|
| 'durationLabel'
|
|
| 'metaLabel'
|
|
| 'publicPath'
|
|
| 'file'
|
|
>
|
|
|
|
/** Field-by-field on purpose: a new public-item field never leaks by spread. */
|
|
export const toContentPieceHomeItem = (item: ContentPiecePublicItem): ContentPieceHomeItem => ({
|
|
id: item.id,
|
|
slug: item.slug,
|
|
title: item.title,
|
|
type: item.type,
|
|
typeLabel: item.typeLabel,
|
|
origin: item.origin,
|
|
originLabel: item.originLabel,
|
|
isLink: item.isLink,
|
|
cityLabel: item.cityLabel,
|
|
regionLabel: item.regionLabel,
|
|
excerpt: item.excerpt,
|
|
durationLabel: item.durationLabel,
|
|
metaLabel: item.metaLabel,
|
|
publicPath: item.publicPath,
|
|
file: item.file,
|
|
})
|
|
|
|
/**
|
|
* Where the visitor is, in the same vocabulary the catalogue facets use: the
|
|
* município name (a Salvador ZE resolves to "Salvador — ZE N", the exact
|
|
* municipality record name) and the identity territory name.
|
|
*/
|
|
export type ContentPieceHomeVisitor = {
|
|
municipalityName: string | null
|
|
region: string | null
|
|
}
|
|
|
|
/**
|
|
* Point → visitor labels over the committed meshes, in containment order
|
|
* zone → município → território: the Salvador ZE mesh is finer than the
|
|
* municipal polygon and its feature names are the catalog names, so the zone
|
|
* match survives; a point outside the municipal mesh is outside Bahia.
|
|
*
|
|
* A coarse fix (network/IP guess, `accuracyM > COARSE_ACCURACY_M`) may name a
|
|
* neighbouring município with total confidence, so only the region is kept.
|
|
*/
|
|
export const resolveContentPieceHomeVisitor = ({
|
|
point,
|
|
coarse = false,
|
|
municipalityGeometry,
|
|
zoneGeometry,
|
|
territoryGeometry,
|
|
}: {
|
|
point: GeoPoint
|
|
coarse?: boolean
|
|
municipalityGeometry: Pick<MunicipalityGeometryModule, 'features'>
|
|
zoneGeometry?: Pick<MunicipalityZoneGeometryModule, 'features'> | null
|
|
territoryGeometry?: Pick<TerritoryGeometryModule, 'features'> | null
|
|
}): ContentPieceHomeVisitor | null => {
|
|
const containingZone = zoneGeometry
|
|
? findContainingFeature(zoneGeometry.features, point)
|
|
: undefined
|
|
const containingMunicipality = findContainingFeature(municipalityGeometry.features, point)
|
|
|
|
if (!containingZone && !containingMunicipality) return null
|
|
|
|
const municipalityName = coarse
|
|
? null
|
|
: (containingZone?.properties.name ?? containingMunicipality?.properties.name ?? null)
|
|
const region = territoryGeometry
|
|
? (findContainingFeature(territoryGeometry.features, point)?.properties.name ?? null)
|
|
: null
|
|
|
|
if (!municipalityName && !region) return null
|
|
|
|
return { municipalityName, region }
|
|
}
|
|
|
|
/** Where a sampled piece came from — the card's provenance tag. */
|
|
export type ContentPieceHomeMatch = 'municipality' | 'region' | 'recent'
|
|
|
|
export type ContentPieceHomeSelection = {
|
|
item: ContentPieceHomeItem
|
|
match: ContentPieceHomeMatch
|
|
}
|
|
|
|
const normalized = (value: string | null): string => (value ? slugify(value) : '')
|
|
|
|
/**
|
|
* Up to `limit` pieces for the visitor, in the intention's order: the município
|
|
* first, then the region, then the newest published pieces to fill the sample.
|
|
* `items` already arrives newest-first (the cached read sorts by `-publishedAt`),
|
|
* so every bucket keeps that order and the dedup is by id.
|
|
*
|
|
* `null` (or an out-of-Bahia) visitor is the recent selection — never an error.
|
|
*/
|
|
export const selectContentPieceHomeItems = (
|
|
items: readonly ContentPieceHomeItem[],
|
|
visitor: ContentPieceHomeVisitor | null,
|
|
limit = 3,
|
|
): ContentPieceHomeSelection[] => {
|
|
const selected: ContentPieceHomeSelection[] = []
|
|
const seen = new Set<number>()
|
|
|
|
const take = (item: ContentPieceHomeItem, match: ContentPieceHomeMatch): void => {
|
|
if (selected.length >= limit || seen.has(item.id)) return
|
|
seen.add(item.id)
|
|
selected.push({ item, match })
|
|
}
|
|
|
|
if (visitor) {
|
|
const municipality = normalized(visitor.municipalityName)
|
|
const region = normalized(visitor.region)
|
|
|
|
if (municipality) {
|
|
for (const item of items) {
|
|
if (selected.length >= limit) break
|
|
if (normalized(item.cityLabel) === municipality) take(item, 'municipality')
|
|
}
|
|
}
|
|
if (region) {
|
|
for (const item of items) {
|
|
if (selected.length >= limit) break
|
|
if (normalized(item.regionLabel) === region) take(item, 'region')
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const item of items) {
|
|
if (selected.length >= limit) break
|
|
take(item, 'recent')
|
|
}
|
|
|
|
return selected
|
|
}
|