From 63d44102961745761b48a41c1c957c560037d255 Mon Sep 17 00:00:00 2001 From: pigui Date: Mon, 12 Oct 2026 20:21:40 +0200 Subject: [PATCH] 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 --- README.md | 10 ++++-- landing/openapi.yaml | 13 ++++++-- landing/src/app/core/copy.ts | 20 ++++++++++++ landing/src/app/core/taskstream.spec.ts | 13 ++++++++ landing/src/app/core/taskstream.ts | 25 +++++++++++++-- landing/src/app/ui/project-detail.scss | 21 ++++++++++++- landing/src/app/ui/project-detail.ts | 42 +++++++++++++++++++++++-- tools/mock-api.mjs | 38 ++++++++++++++++++++++ 8 files changed, 169 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c463309..8a77ac8 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,13 @@ pass on the server: | Contact form | `POST /contact` | A project page also asks TaskStream, when the project has a board there -(`taskstream.showcase_url` in its payload): roadmap, releases, the sprint under -way and tickets labelled `good-first-issue`. It is public, answers within four -seconds or the page goes without those sections, and its links open the board. +(`taskstream.showcase_url` in its payload): roadmap (with how many want each +epic), releases, the sprint under way, tickets labelled `good-first-issue`, and +the most voted bug reports and ideas from the public, with a link to send one +while the board takes them. It is public, answers within four seconds or the +page goes without those sections, and its links open the board (voting needs +signing in there). `npm run mock` serves a showcase for the «Catálogo abierto» +project, so every section can be seen without TaskStream. `landing/openapi.yaml` is the specification the app was written against, and `landing/src/app/core/api.types.ts` is the API's own generated type file. Both diff --git a/landing/openapi.yaml b/landing/openapi.yaml index 375da4e..2605010 100644 --- a/landing/openapi.yaml +++ b/landing/openapi.yaml @@ -1621,7 +1621,14 @@ paths: patch: tags: [Projects] summary: Update a project - description: Partial update. Translatable fields merge into the existing map. + description: | + Partial update. Translatable fields merge into the existing map. + + `taskstream: true` opens a TaskStream board for a project that has none + yet, as on creation (`taskstream_key`, or one made from the slug); if + TaskStream refuses, the update is not saved either. A project that + already has a board keeps it, and `taskstream_key` without the flag is + ignored, so a client can PATCH back what it read. requestBody: required: true content: @@ -5574,12 +5581,12 @@ components: demo_url: { type: string, format: uri, nullable: true } taskstream: type: boolean - description: On creation only. Also open a public board for it in TaskStream; see `POST /v1/projects`. + description: Also open a public board for it in TaskStream, on creation or when it has none yet; see `POST /v1/projects`. taskstream_key: type: string nullable: true pattern: "^[A-Za-z][A-Za-z0-9]{1,9}$" - description: On creation only, with `taskstream`. The key in TaskStream; made from the slug when left out. + description: Only read with `taskstream`. The key in TaskStream; made from the slug when left out. cover_image: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=projects`. } started_at: { type: string, format: date, nullable: true } ended_at: { type: string, format: date, nullable: true } diff --git a/landing/src/app/core/copy.ts b/landing/src/app/core/copy.ts index dbbbfe9..4b4b6e4 100644 --- a/landing/src/app/core/copy.ts +++ b/landing/src/app/core/copy.ts @@ -117,6 +117,11 @@ export interface Copy { goodFirstLead: string; board: string; done: string; + proposals: string; + proposalsLead: string; + propose: string; + voteOne: string; + voteMany: string; }; member: { projects: string; @@ -317,6 +322,11 @@ const es: Copy = { goodFirstLead: 'Tareas pensadas para quien llega nuevo.', board: 'Ver el tablero', done: 'Hecho', + proposals: 'Ideas y errores', + proposalsLead: 'Lo que más se pide. Para votar, entra en el tablero.', + propose: 'Proponer una idea o avisar de un error', + voteOne: '1 voto', + voteMany: '{n} votos', }, member: { projects: 'Proyectos', @@ -536,6 +546,11 @@ const en: Copy = { goodFirstLead: 'Tasks meant for newcomers.', board: 'See the board', done: 'Done', + proposals: 'Ideas and bugs', + proposalsLead: 'What people ask for most. To vote, sign in on the board.', + propose: 'Suggest an idea or report a bug', + voteOne: '1 vote', + voteMany: '{n} votes', }, member: { projects: 'Projects', @@ -755,6 +770,11 @@ const ca: Copy = { goodFirstLead: 'Tasques pensades per a qui arriba de nou.', board: 'Veure el tauler', done: 'Fet', + proposals: 'Idees i errors', + proposalsLead: 'El que més es demana. Per votar, entra al tauler.', + propose: "Proposar una idea o avisar d'un error", + voteOne: '1 vot', + voteMany: '{n} vots', }, member: { projects: 'Projectes', diff --git a/landing/src/app/core/taskstream.spec.ts b/landing/src/app/core/taskstream.spec.ts index 652a48e..d0c4801 100644 --- a/landing/src/app/core/taskstream.spec.ts +++ b/landing/src/app/core/taskstream.spec.ts @@ -44,4 +44,17 @@ describe('TaskStream showcase', () => { expect(hasContent(showcase)).toBe(true); expect(hasContent({ ...showcase, goodFirst: [] })).toBe(false); }); + + it('counts a board that takes proposals as something to show', () => { + const empty = { ...showcase, goodFirst: [] }; + expect( + hasContent({ + ...empty, + proposals: { open: true, path: '/p/WEB/proponer', top: [] }, + }), + ).toBe(true); + expect( + hasContent({ ...empty, proposals: { open: false, path: null, top: [] } }), + ).toBe(false); + }); }); diff --git a/landing/src/app/core/taskstream.ts b/landing/src/app/core/taskstream.ts index 40c19f5..60b2c5b 100644 --- a/landing/src/app/core/taskstream.ts +++ b/landing/src/app/core/taskstream.ts @@ -1,7 +1,7 @@ /** * A project's board in TaskStream, the collective's issue tracker: its public - * showcase (roadmap, releases, the sprint under way and tickets for - * newcomers). Fetched on the server; a slow or absent TaskStream only means + * showcase (roadmap, releases, the sprint under way, tickets for newcomers + * and the proposals people send and vote for). Fetched on the server; a slow or absent TaskStream only means * the project page goes without those sections. */ @@ -26,6 +26,8 @@ export interface TaskStreamShowcase { dueDate: string | null; percent: number; done: boolean; + /** How many want it; older TaskStreams leave it out. */ + votes?: number; path: string; }[]; releases: { @@ -37,6 +39,21 @@ export interface TaskStreamShowcase { notes: string | null; }[]; goodFirst: { key: string; title: string; type: string; path: string }[]; + /** Bug reports and ideas from anyone; older TaskStreams leave it out. */ + proposals?: { + /** Whether it takes them now. */ + open: boolean; + /** The form in TaskStream's web app, while it takes them. */ + path: string | null; + /** Open ones, most voted first. */ + top: { + key: string; + title: string; + type: string; + path: string; + votes: number; + }[]; + }; } /** What the page renders: the showcase plus where its links go. */ @@ -75,7 +92,9 @@ export function hasContent(showcase: TaskStreamShowcase): boolean { showcase.roadmap.length > 0 || showcase.releases.length > 0 || showcase.goodFirst.length > 0 || - showcase.activeSprint !== null + showcase.activeSprint !== null || + !!showcase.proposals?.open || + (showcase.proposals?.top.length ?? 0) > 0 ); } diff --git a/landing/src/app/ui/project-detail.scss b/landing/src/app/ui/project-detail.scss index 4f62c6b..51f03c7 100644 --- a/landing/src/app/ui/project-detail.scss +++ b/landing/src/app/ui/project-detail.scss @@ -90,7 +90,8 @@ .project-roadmap, .project-releases, -.project-first { +.project-first, +.project-proposals { display: grid; gap: 0.75rem; margin: 0; @@ -134,3 +135,21 @@ font: inherit; font-size: 0.9rem; } + +.project-proposals li { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + justify-content: space-between; + align-items: baseline; +} + +// Clear of the lead above and of the link below. +.project-roadmap, +.project-proposals { + margin-top: 0.75rem; +} + +.project-proposals__propose { + margin: 1.25rem 0 0; +} diff --git a/landing/src/app/ui/project-detail.ts b/landing/src/app/ui/project-detail.ts index 6a64c82..6198d1f 100644 --- a/landing/src/app/ui/project-detail.ts +++ b/landing/src/app/ui/project-detail.ts @@ -155,9 +155,12 @@ import { LinkButton } from './link-button'; {{ epic.title }} - {{ - dates(epic.startDate, epic.dueDate) - }} + {{ dates(epic.startDate, epic.dueDate) }} + @if (epic.votes && !epic.done) { + · {{ votes(epic.votes) }} + } + } + @if (b.showcase.proposals; as proposals) { + @if (proposals.open || proposals.top.length > 0) { +
+

{{ copy().project.proposals }}

+ @if (proposals.top.length > 0) { +

{{ copy().project.proposalsLead }}

+
    + @for (proposal of proposals.top; track proposal.key) { +
  1. + {{ + proposal.title + }} + {{ votes(proposal.votes) }} +
  2. + } +
+ } + @if (proposals.open && proposals.path) { +

+ {{ copy().project.propose }} → +

+ } +
+ } + } @if (b.showcase.releases.length > 0) {

{{ copy().project.releases }}

@@ -397,6 +427,12 @@ export class ProjectDetail { return boardLink(this.board()?.webUrl ?? '', path); } + /** "1 voto", "3 votos". */ + protected votes(count: number): string { + const copy = this.copy().project; + return count === 1 ? copy.voteOne : copy.voteMany.replace('{n}', String(count)); + } + /** "3 nov – 20 dic 2026", or one end when only one is known. */ protected dates(start: string | null, end: string | null): string { const format = (date: string | null) => diff --git a/tools/mock-api.mjs b/tools/mock-api.mjs index 6b5f531..936c2fb 100644 --- a/tools/mock-api.mjs +++ b/tools/mock-api.mjs @@ -169,6 +169,38 @@ for (const listed of data['/public/v1/projects']) { }; } +// 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']) { @@ -237,6 +269,12 @@ createServer((req, res) => { 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') {