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') {