Files
landing-app/tools/mock-api.mjs
pigui 63d4410296 Show proposals and votes from TaskStream on a project's page
The most voted bug reports and ideas, the link to send one while the
board takes them, and how many want each epic on the roadmap. The mock
API serves a showcase for the catalogue so every section can be seen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:21:40 +02:00

294 lines
18 KiB
JavaScript

/**
* A stand-in for the Pireon public API, with enough content to see every
* section of the landing at once.
*
* The real API answers `{"data": []}` for every collection until the panel has
* something published, so the page renders as a hero and a contact form and
* nothing else — correct, but impossible to review. Point the app at this
* instead:
*
* npm run mock # this server, on :4599
* npm run start:mock # the app against it
*
* `mock-api-down.mjs` is the other half: every endpoint answering 503, which is
* how the maintenance banner gets exercised.
*/
import { createServer } from 'node:http';
const tech = (id, name, slug, category, color, ring) => ({
id, name, slug, category, color, ring, projects_count: 3, members_count: 2,
description: `${name} es una de las herramientas con las que trabajamos a diario. Esta descripción viene de la API, como en producción.`,
website: `https://example.com/${slug}`,
// An inline SVG so the mock needs no file server: the real API serves these
// from its own storage.
logo: {
url: `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="${color}"/><text x="32" y="42" font-family="sans-serif" font-size="30" font-weight="700" fill="#fff" text-anchor="middle">${name[0]}</text></svg>`)}`,
alt: name, width: 64, height: 64, aspect_ratio: 1,
mime: 'image/svg+xml', placeholder: null, srcset: null,
},
});
const technologies = [
tech(1, 'Angular', 'angular', 'framework', '#dd0031', 'adopt'),
tech(2, 'TypeScript', 'typescript', 'language', '#3178c6', 'adopt'),
tech(3, 'Laravel', 'laravel', 'framework', '#ff2d20', 'trial'),
tech(4, 'Postgres', 'postgres', 'datastore', '#336791', 'assess'),
tech(5, 'Kubernetes', 'kubernetes', 'infrastructure', '#326ce5', 'hold'),
];
const project = (id, title, slug, status, featured) => ({
id, locale: 'es', title, slug, status,
tagline: 'Una herramienta pequeña que resuelve un problema concreto y aburrido.',
is_featured: featured, needs_help: id % 2 === 0,
gallery: [], services: [], testimonials: [], talks: [],
demo_url: 'https://example.com', updated_at: '2026-01-01T00:00:00Z',
technologies: technologies.slice(0, 3), members: [], calls: [],
repositories: [{ id, provider: 'gitea', full_name: `pireon/${slug}`, url: `https://git.example/${slug}`, languages: {}, topics: [], stars: 42 * id, forks: 3, contributors_count: 4, open_issues_count: 2, is_archived: false, is_dormant: false, good_first_issues: [] }],
alternates: {}, path: `/proyectos/${slug}`,
});
const member = (id, name, slug, founder) => ({
id, name, slug, headline: 'Backend y cosas que nadie quiere tocar',
links: { web: 'https://example.com' }, github_username: slug,
is_founder: founder, is_current: id !== 4, technologies: [], projects: [],
alternates: {}, path: `/equipo/${slug}`,
});
const page = (id, title, slug, featured) => ({
id, locale: 'es', title, slug, status: 'published', is_featured: featured,
excerpt: 'Una página publicada desde el panel, con su propio título, extracto y cuerpo.',
body: '<h2>Un apartado</h2><p>Texto de la página, tal como sale del editor del panel.</p><ul><li>Una lista</li><li>con dos elementos</li></ul>',
meta_title: `${title} · Pireon`, meta_description: 'Descripción para buscadores.',
published_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
category: { id: 1, name: 'El colectivo', slug: 'colectivo', color: '#4f46e5', position: 1 },
alternates: { es: `/${slug}`, en: `/en/${slug}-en`, ca: `/ca/${slug}-ca` }, path: `/${slug}`,
});
// A body with everything the editor can produce, images included, so the
// post's typography can be reviewed without writing one in the panel.
const richBody =
'<p>Volvimos a renderizar en el servidor por tres motivos.</p>' +
'<ul><li><p><strong>Rendimiento</strong> en móviles modestos.</p></li><li><p><strong>SEO</strong> sin trucos.</p></li></ul>' +
'<img src="/logo.png" alt="El logo de Pireon">' +
'<h2>Cómo lo hicimos</h2><p>Con <code>injectLoad</code> y un loader por página:</p>' +
'<pre><code>export const load = async () => fetchPage();</code></pre>' +
'<figure><img src="/logo.png" alt="Otra vez el logo"><figcaption>Una imagen con pie de foto.</figcaption></figure>' +
'<blockquote><p>Lo que no se mide en un móvil de gama baja, no está medido.</p></blockquote>';
const post = (id, title, slug, published_at, tags = []) => ({
...page(id, title, slug, false),
body: richBody,
published_at,
tags,
byline: { name: 'Ada Lovelace', slug: 'ada', headline: 'Backend', avatar: null, path: '/equipo/ada' },
excerpt: 'Lo que hemos aprendido este mes, contado sin prisa y con ejemplos de código.',
category: { id: 9, name: 'Blog', slug: 'blog', position: 3 },
});
const pages = [
page(1, 'Sobre nosotros', 'sobre-nosotros', true),
page(2, 'Contacto', 'contacto', false),
post(3, 'Por qué volvimos a renderizar en el servidor', 'volvimos-al-servidor', '2026-09-20T09:00:00Z', [{ name: 'Angular', slug: 'angular' }, { name: 'Rendimiento', slug: 'rendimiento' }]),
post(4, 'Nuestro primer año como colectivo', 'primer-ano', '2026-06-01T09:00:00Z', [{ name: 'Colectivo', slug: 'colectivo' }]),
];
const data = {
'/public/v1/pages': pages,
...Object.fromEntries(pages.map((p) => [`/public/v1/pages/${p.slug}`, p])),
'/public/v1/site': {
locales: [
{ code: 'es', name: 'Español', flag: '🇪🇸', is_default: true },
{ code: 'en', name: 'English', flag: '🇬🇧', is_default: false },
{ code: 'ca', name: 'Català', flag: '🏳️', is_default: false },
],
default_locale: 'es', content_version: 12, submissions_enabled: true,
stats: { projects: 9, active_projects: 4, members: 7, open_calls: 2, stars: 1280, good_first_issues: 5 },
},
'/public/v1/services': [
{ id: 1, locale: 'es', title: 'Producto a medida', slug: 'producto', tagline: 'Del boceto a producción sin capas que nadie pidió.', icon: '🛠', is_featured: true, position: 1, projects: [], technologies: [], testimonials: [], alternates: {} },
{ id: 2, locale: 'es', title: 'Rescate de sistemas', slug: 'rescate', tagline: 'Código heredado que hay que entender antes de tocar.', icon: '🧭', is_featured: false, position: 2, projects: [], technologies: [], testimonials: [], alternates: {} },
{ id: 3, locale: 'es', title: 'Formación', slug: 'formacion', tagline: 'Enseñamos lo que usamos, con el repositorio abierto.', icon: '📚', is_featured: false, position: 3, projects: [], technologies: [], testimonials: [], alternates: {} },
],
'/public/v1/projects': [project(1, 'Catálogo abierto', 'catalogo', 'active', true), project(2, 'Migrador de datos', 'migrador', 'completed', false), project(3, 'Panel de métricas', 'panel', 'paused', false)],
'/public/v1/clients': [
{ id: 1, name: 'Ajuntament de Girona', slug: 'girona', sector: 'Público', url: 'https://example.com', projects_count: 2 },
{ id: 2, name: 'Cooperativa Verd', slug: 'verd', sector: 'Cooperativa', projects_count: 1 },
{ id: 3, name: 'Fundació Oberta', slug: 'oberta', projects_count: 3 },
],
// Four, so the carousel actually has a second page and shows its controls.
'/public/v1/testimonials': [
{ id: 1, quote: 'Entregaron antes de tiempo y nos dejaron la documentación que nunca habíamos tenido.', original_locale: 'es', is_original: true, author: { name: 'Marta Ruiz', role: 'Directora técnica' }, client: { id: 1, name: 'Ajuntament de Girona', slug: 'girona', projects_count: 2 } },
{ id: 2, quote: 'Explican las decisiones. Eso vale más que el código.', original_locale: 'es', is_original: true, author: { name: 'Joan Pi', role: 'CTO' } },
{ id: 3, quote: 'Heredamos un sistema que nadie entendía. Lo documentaron, lo simplificaron y nos enseñaron a mantenerlo nosotros.', original_locale: 'es', is_original: true, author: { name: 'Nuria Sanz', role: 'Responsable de sistemas' }, client: { id: 2, name: 'Cooperativa Verd', slug: 'verd', projects_count: 1 } },
{ id: 4, quote: 'Trabajar con el repositorio abierto desde el primer día nos quitó el miedo a depender de nadie.', original_locale: 'es', is_original: true, author: { name: 'Pau Ferrer', role: 'Dirección' }, client: { id: 3, name: 'Fundació Oberta', slug: 'oberta', projects_count: 3 } },
],
'/public/v1/radar': technologies,
'/public/v1/technologies': technologies,
'/public/v1/members': [member(1, 'Ada Lovelace', 'ada', true), member(2, 'Linus T.', 'linus', false), member(3, 'Grace Hopper', 'grace', true), member(4, 'Alan Turing', 'alan', false)],
'/public/v1/calls': [
{ id: 1, locale: 'es', title: 'Frontend Angular', slug: 'frontend-angular', description: 'Ayúdanos con el catálogo público: componentes, accesibilidad y tests.', status: 'open', hours_per_week: 4, duration_weeks: 8, seniority: 'any', closes_at: '2026-10-01T00:00:00Z', project: project(1, 'Catálogo abierto', 'catalogo', 'active', true), technologies: technologies.slice(0, 2), alternates: {} },
{ id: 2, locale: 'es', title: 'Infra y despliegues', slug: 'infra', description: 'Automatizar lo que ahora hacemos a mano una vez por semana.', status: 'open', hours_per_week: 2, seniority: 'mid', project: project(3, 'Panel de métricas', 'panel', 'paused', false), technologies: technologies.slice(4), alternates: {} },
],
'/public/v1/good-first-issues': [
{ number: 12, title: 'Añadir mensajes de error traducidos al formulario', url: 'https://git.example/issues/12', labels: ['good first issue'], is_good_first_issue: true, is_help_wanted: false, is_available: true, comments: 3, repository: { full_name: 'pireon/catalogo', primary_language: 'TypeScript' } },
{ number: 31, title: 'Documentar el proceso de despliegue', url: 'https://git.example/issues/31', labels: ['help wanted'], is_good_first_issue: false, is_help_wanted: true, is_available: true, comments: 0, repository: { full_name: 'pireon/panel', primary_language: 'PHP' } },
],
'/public/v1/talks': [
{ id: 1, title: 'Servir HTML otra vez', language: 'es', summary: 'Por qué volvimos a renderizar en el servidor y qué nos costó.', event: 'Codemotion', city: 'Madrid', held_at: '2026-05-12T00:00:00Z', video_url: 'https://example.com/video' },
{ id: 2, title: 'Una API para un frontend que no controlas', language: 'es', event: 'DevFest', city: 'Barcelona', held_at: '2026-03-02T00:00:00Z', slides_url: 'https://example.com/slides' },
],
};
// Detail endpoints: what a project's or a member's own page asks for. Richer
// than the listings — gallery, prose, team — because that is what the detail
// endpoints add.
const prose = (topic) =>
`<p>${topic} empezó como una necesidad concreta de una asociación y acabó siendo una herramienta que usan varias.</p>` +
'<ul><li>Pensado para que lo mantenga gente que no programa.</li><li>Con pruebas y documentación desde el primer día.</li></ul>';
const team = data['/public/v1/members'].slice(0, 3).map((m, i) => ({
...m,
role: ['Responsable', 'Frontend', 'Backend'][i],
}));
for (const listed of data['/public/v1/projects']) {
data[`/public/v1/projects/${listed.slug}`] = {
...listed,
description: prose(listed.title),
decisions: '<p>Elegimos renderizar en el servidor porque la mayoría de visitas llegan desde móviles modestos.</p>',
postmortem: '<p>Subestimamos la importación de datos antiguos: nos llevó el doble de lo previsto.</p>',
meta_title: `${listed.title} · Pireon`,
meta_description: listed.tagline,
started_at: '2025-03-01T00:00:00Z',
ended_at: listed.status === 'completed' ? '2026-06-01T00:00:00Z' : null,
client: data['/public/v1/clients'][0],
members: team,
lead: team[0],
calls: data['/public/v1/calls'].slice(0, listed.id % 2 ? 1 : 0).map((c) => ({ ...c, path: `/colabora/${c.slug}` })),
alternates: { es: `/proyectos/${listed.slug}`, en: `/en/projects/${listed.slug}`, ca: `/ca/projectes/${listed.slug}` },
};
}
// The catalogue has a board in TaskStream: its showcase is served here too,
// so the roadmap, releases, proposals and first issues show without one.
const showcase = {
project: { id: 'ts-1', key: 'CAT', name: 'Catálogo abierto', description: null },
activeSprint: { name: 'Sprint 4', goal: 'Buscar por etiqueta', startDate: '2026-10-05', endDate: '2026-10-19', percent: 40 },
roadmap: [
{ key: 'CAT-1', title: 'Fichas accesibles', startDate: '2026-07-01', dueDate: '2026-09-15', percent: 100, done: true, votes: 2, path: '/p/CAT/issues/CAT-1' },
{ key: 'CAT-7', title: 'Búsqueda por etiquetas', startDate: '2026-09-20', dueDate: '2026-11-30', percent: 45, done: false, votes: 9, path: '/p/CAT/issues/CAT-7' },
{ key: 'CAT-12', title: 'Exportar a PDF', startDate: null, dueDate: '2027-02-01', percent: 0, done: false, votes: 1, path: '/p/CAT/issues/CAT-12' },
],
releases: [
{ name: '1.2', status: 'PLANNED', targetDate: '2026-11-30', releasedAt: null, percent: 60, notes: null },
{ name: '1.1', status: 'RELEASED', targetDate: null, releasedAt: '2026-09-16', percent: 100, notes: '- Fichas accesibles\n- Arreglos en móvil' },
],
goodFirst: [{ key: 'CAT-21', title: 'Traducir los avisos al catalán', type: 't-task', path: '/p/CAT/issues/CAT-21' }],
proposals: {
open: true,
path: '/p/CAT/proponer',
top: [
{ key: 'CAT-30', title: 'Modo oscuro', type: 't-feature', path: '/p/CAT/issues/CAT-30', votes: 14 },
{ key: 'CAT-28', title: 'El buscador no encuentra palabras con tilde', type: 't-bug', path: '/p/CAT/issues/CAT-28', votes: 6 },
{ key: 'CAT-33', title: 'Compartir una ficha por enlace corto', type: 't-feature', path: '/p/CAT/issues/CAT-33', votes: 1 },
],
},
};
data['/public/v1/projects/catalogo'].taskstream = {
key: 'CAT',
url: 'https://taskstream.pireon.dev/p/CAT',
web_url: 'https://taskstream.pireon.dev',
showcase_url: 'http://localhost:4599/taskstream/CAT/showcase',
};
// Calls link to their own page, and one of them is closed so the page can
// show what a visitor sees when a link to a filled call is still out there.
for (const call of data['/public/v1/calls']) {
call.path = `/colabora/${call.slug}`;
call.alternates = { es: call.path, en: `/en/contribute/${call.slug}`, ca: `/ca/col-labora/${call.slug}` };
data[`/public/v1/calls/${call.slug}`] = {
...call,
status: call.id === 2 ? 'filled' : 'open',
what_you_get: 'Revisiones de código con alguien del colectivo.\nUn proyecto real en tu portfolio.',
total_hours: call.hours_per_week * (call.duration_weeks ?? 4),
project: { ...call.project, path: `/proyectos/${call.project.slug}` },
};
}
for (const service of data['/public/v1/services']) {
service.path = `/servicios/${service.slug}`;
data[`/public/v1/services/${service.slug}`] = {
...service,
description: 'Empezamos escuchando: qué hace hoy la organización y qué le sobra.\nDespués construimos lo mínimo que resuelve el problema, y lo dejamos documentado para que lo pueda mantener otra persona.',
meta_description: service.tagline,
projects: data['/public/v1/projects'].slice(0, 2).map((p) => ({ ...p, path: `/proyectos/${p.slug}` })),
technologies: technologies.slice(0, 4),
testimonials: data['/public/v1/testimonials'].slice(0, 2),
alternates: { es: service.path, en: `/en/services/${service.slug}`, ca: `/ca/serveis/${service.slug}` },
};
}
for (const listed of data['/public/v1/members']) {
data[`/public/v1/members/${listed.slug}`] = {
...listed,
bio: 'Programa desde hace años en proyectos de todo tipo.\nAhora dedica unas horas a la semana al colectivo, sobre todo a revisar código y acompañar a quien empieza.',
joined_at: '2024-09-01T00:00:00Z',
technologies: technologies.slice(0, 4),
projects: data['/public/v1/projects'].slice(0, 2).map((p) => ({ ...p, path: `/proyectos/${p.slug}` })),
alternates: { es: `/equipo/${listed.slug}`, en: `/en/team/${listed.slug}`, ca: `/ca/equip/${listed.slug}` },
};
}
// What the sitemap is built from, and one renamed project to exercise the
// redirect middleware with.
data['/public/v1/slugs'] = {
pages: data['/public/v1/pages'].map((p) => ({ id: p.id, updated_at: p.updated_at, alternates: p.alternates })),
projects: data['/public/v1/projects'].map((p) => ({
id: p.id,
updated_at: p.updated_at,
alternates: data[`/public/v1/projects/${p.slug}`].alternates,
})),
};
data['/public/v1/redirects'] = [{ from: '/proyectos/catalogo-viejo', to: '/proyectos/catalogo', status: 301, locale: null }];
createServer((req, res) => {
const url = new URL(req.url, 'http://x');
const path = url.pathname.replace(/^\/api/, '');
let payload = data[path];
// The one filter the frontend relies on: pages by category slug.
if (path === '/public/v1/technologies') {
const inUse = url.searchParams.get('in_use');
payload =
inUse === 'false' ? payload : payload.filter((t) => t.projects_count > 0);
}
if (path === '/public/v1/pages' && url.searchParams.get('category')) {
payload = payload.filter((p) => p.category?.slug === url.searchParams.get('category'));
}
res.setHeader('content-type', 'application/json');
// TaskStream answers its showcase bare, not inside `data`.
if (path === '/taskstream/CAT/showcase') {
res.end(JSON.stringify(showcase));
return;
}
// The two write endpoints: accept whatever arrives, as the real API does
// for a valid submission.
if (req.method === 'POST') {
res.statusCode = 201;
res.end(JSON.stringify({ data: { id: 1, kind: 'contribute', status: 'new' } }));
return;
}
if (payload === undefined) {
res.statusCode = 404;
res.end(JSON.stringify({ message: 'not found' }));
return;
}
res.end(JSON.stringify({ data: payload }));
}).listen(4599, () => console.log('Mock API on http://localhost:4599/api'));