Give the tech radar its own page

/radar (/en/radar, /ca/radar) shows every technology the collective has
placed, by ring: logo, category, what it is, why it sits there, its
website and how many projects use it. The landing's radar summary links
to it.

It is the first listing — a page this site composes rather than one
record's — so resolveRoute learns about those (LISTINGS), and builds
their addresses and hreflang alternates from the same table.

Logos sit on a light tile in both themes. A brand's mark is not ours to
recolour, and Rust's, which is black, would vanish in dark mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pigui
2026-09-29 15:07:19 +02:00
parent b5681a12ac
commit 06eeefc144
11 changed files with 558 additions and 13 deletions

View File

@@ -114,6 +114,19 @@ export interface Copy {
links: string;
since: string;
};
radar: {
eyebrow: string;
lead: string;
ringLead: Record<'adopt' | 'trial' | 'assess' | 'hold', string>;
categories: Record<
'language' | 'framework' | 'datastore' | 'infrastructure' | 'tool' | 'practice',
string
>;
website: string;
projects: string;
empty: string;
seeAll: string;
};
call: {
eyebrow: string;
seniority: Record<'any' | 'junior' | 'mid' | 'senior', string>;
@@ -248,6 +261,28 @@ const es: Copy = {
links: 'Enlaces',
since: 'En el colectivo desde',
},
radar: {
eyebrow: 'Radar tecnológico',
lead: 'Las herramientas con las que trabajamos y lo que opinamos de cada una: qué usamos sin dudar, qué estamos probando y qué preferimos evitar.',
ringLead: {
adopt: 'Lo usamos en proyectos reales y lo recomendamos sin reservas.',
trial: 'Lo estamos probando en proyectos donde el riesgo es asumible.',
assess: 'Nos interesa y lo estamos estudiando, sin usarlo aún en serio.',
hold: 'Por ahora preferimos no empezar nada nuevo con esto.',
},
categories: {
language: 'Lenguaje',
framework: 'Framework',
datastore: 'Datos',
infrastructure: 'Infraestructura',
tool: 'Herramienta',
practice: 'Práctica',
},
website: 'Web oficial',
projects: 'proyectos',
empty: 'Todavía no hemos colocado ninguna tecnología en el radar.',
seeAll: 'Ver el radar completo',
},
call: {
eyebrow: 'Convocatoria',
seniority: { any: 'Cualquier nivel', junior: 'Junior', mid: 'Intermedio', senior: 'Senior' },
@@ -386,6 +421,28 @@ const en: Copy = {
links: 'Links',
since: 'In the collective since',
},
radar: {
eyebrow: 'Tech radar',
lead: 'The tools we work with and what we think of each: what we use without a second thought, what we are trying out and what we would rather avoid.',
ringLead: {
adopt: 'We use it on real projects and recommend it without reservation.',
trial: 'We are trying it out on projects where the risk is acceptable.',
assess: 'It interests us and we are looking into it, without using it seriously yet.',
hold: 'For now we would rather not start anything new with it.',
},
categories: {
language: 'Language',
framework: 'Framework',
datastore: 'Data',
infrastructure: 'Infrastructure',
tool: 'Tool',
practice: 'Practice',
},
website: 'Official website',
projects: 'projects',
empty: 'We have not placed any technology on the radar yet.',
seeAll: 'See the full radar',
},
call: {
eyebrow: 'Open call',
seniority: { any: 'Any level', junior: 'Junior', mid: 'Mid-level', senior: 'Senior' },
@@ -524,6 +581,28 @@ const ca: Copy = {
links: 'Enllaços',
since: 'Al col·lectiu des de',
},
radar: {
eyebrow: 'Radar tecnològic',
lead: "Les eines amb què treballem i què en pensem de cadascuna: què fem servir sense dubtar, què estem provant i què preferim evitar.",
ringLead: {
adopt: 'El fem servir en projectes reals i el recomanem sense reserves.',
trial: "L'estem provant en projectes on el risc és assumible.",
assess: "Ens interessa i l'estem estudiant, sense fer-lo servir encara seriosament.",
hold: 'Ara per ara preferim no començar res de nou amb això.',
},
categories: {
language: 'Llenguatge',
framework: 'Framework',
datastore: 'Dades',
infrastructure: 'Infraestructura',
tool: 'Eina',
practice: 'Pràctica',
},
website: 'Web oficial',
projects: 'projectes',
empty: 'Encara no hem col·locat cap tecnologia al radar.',
seeAll: 'Veure el radar sencer',
},
call: {
eyebrow: 'Convocatòria',
seniority: { any: 'Qualsevol nivell', junior: 'Júnior', mid: 'Intermedi', senior: 'Sènior' },

View File

@@ -13,7 +13,13 @@ import {
type MaintenanceNotice,
type SitePayload,
} from './api';
import type { PublicCall, PublicMember, PublicPage, PublicProject } from './api.types';
import type {
PublicCall,
PublicMember,
PublicPage,
PublicProject,
PublicTechnology,
} from './api.types';
import { resolveRoute, type RouteMatch } from './routes';
/** The record, tagged with its type so the template can switch on it. */
@@ -21,7 +27,8 @@ export type RoutedRecord =
| { type: 'page'; record: PublicPage }
| { type: 'project'; record: PublicProject }
| { type: 'member'; record: PublicMember }
| { type: 'call'; record: PublicCall };
| { type: 'call'; record: PublicCall }
| { type: 'radar'; record: PublicTechnology[] };
export interface RoutedPageData {
locale: Locale;
@@ -74,6 +81,11 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
const { data } = await pireonApi.call(locale, slug);
return data ? { type: 'call', record: data } : null;
}
case 'radar': {
// An empty radar is still the radar: the page says so rather than 404.
const { data, error } = await pireonApi.radar(locale);
return error ? null : { type: 'radar', record: data ?? [] };
}
default:
// A type the site has no page for yet: an address the API hands out
// but nothing here renders, which is a 404 until it does.

View File

@@ -1,7 +1,13 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from './api';
import { homeHref, resolveRoute, SECTIONS } from './routes';
import {
homeHref,
listingAlternates,
listingPath,
resolveRoute,
SECTIONS,
} from './routes';
describe('resolveRoute', () => {
it('reads a page at the root as the default language', () => {
@@ -77,6 +83,33 @@ describe('resolveRoute', () => {
});
});
describe('listings', () => {
it('reads the radar in every language, before any page of that slug', () => {
expect(resolveRoute('/radar')).toEqual({
type: 'radar',
locale: 'es',
slug: '',
});
expect(resolveRoute('/ca/radar')).toEqual({
type: 'radar',
locale: 'ca',
slug: '',
});
});
it('builds the address resolveRoute reads back', () => {
for (const locale of SUPPORTED_LOCALES) {
expect(resolveRoute(listingPath('radar', locale))?.type).toBe('radar');
}
expect(listingAlternates('radar')).toEqual({
es: '/radar',
en: '/en/radar',
ca: '/ca/radar',
});
});
});
describe('homeHref', () => {
it('answers on ?lang= for every language but the default', () => {
expect(homeHref(DEFAULT_LOCALE)).toBe('/');

View File

@@ -15,6 +15,9 @@ import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from './api';
export type RecordType = 'page' | 'project' | 'member' | 'call' | 'service';
/** Pages this site composes itself rather than one record's: no slug. */
export type ListingType = 'radar';
/**
* The section each type lives under, per language. The API's own
* `config/site.php` (`paths`) is the source; a change there has to be made here
@@ -30,9 +33,18 @@ export const SECTIONS: Record<
service: { es: 'servicios', en: 'services', ca: 'serveis' },
};
/**
* Where each listing lives, per language. A panel page with the same slug
* would be shadowed by it, so these are few and deliberately named.
*/
export const LISTINGS: Record<ListingType, Record<Locale, string>> = {
radar: { es: 'radar', en: 'radar', ca: 'radar' },
};
export interface RouteMatch {
type: RecordType;
type: RecordType | ListingType;
locale: Locale;
/** Empty for a listing. */
slug: string;
}
@@ -51,7 +63,13 @@ export function resolveRoute(pathname: string): RouteMatch | null {
const rest = prefixed ? segments.slice(1) : segments;
if (rest.length === 1) {
return { type: 'page', locale, slug: rest[0] };
const listing = (Object.keys(LISTINGS) as ListingType[]).find(
(candidate) => LISTINGS[candidate][locale] === rest[0],
);
return listing
? { type: listing, locale, slug: '' }
: { type: 'page', locale, slug: rest[0] };
}
if (rest.length === 2) {
@@ -65,6 +83,20 @@ export function resolveRoute(pathname: string): RouteMatch | null {
return null;
}
/** A listing's address in one language, as `resolveRoute` reads it back. */
export function listingPath(listing: ListingType, locale: Locale): string {
return `${locale === DEFAULT_LOCALE ? '' : `/${locale}`}/${LISTINGS[listing][locale]}`;
}
/** The same listing in every language, for `hreflang`. */
export function listingAlternates(
listing: ListingType,
): Record<string, string> {
return Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, listingPath(listing, locale)]),
);
}
/** The landing itself in a given language. It answers on `?lang=`, not a prefix. */
export function homeHref(locale: Locale): string {
return locale === DEFAULT_LOCALE ? '/' : `/?lang=${locale}`;