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>
This commit is contained in:
pigui
2026-10-12 20:21:40 +02:00
parent a3783222c8
commit 63d4410296
8 changed files with 169 additions and 13 deletions

View File

@@ -132,9 +132,13 @@ pass on the server:
| Contact form | `POST /contact` | | Contact form | `POST /contact` |
A project page also asks TaskStream, when the project has a board there A project page also asks TaskStream, when the project has a board there
(`taskstream.showcase_url` in its payload): roadmap, releases, the sprint under (`taskstream.showcase_url` in its payload): roadmap (with how many want each
way and tickets labelled `good-first-issue`. It is public, answers within four epic), releases, the sprint under way, tickets labelled `good-first-issue`, and
seconds or the page goes without those sections, and its links open the board. 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/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 `landing/src/app/core/api.types.ts` is the API's own generated type file. Both

View File

@@ -1621,7 +1621,14 @@ paths:
patch: patch:
tags: [Projects] tags: [Projects]
summary: Update a project 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: requestBody:
required: true required: true
content: content:
@@ -5574,12 +5581,12 @@ components:
demo_url: { type: string, format: uri, nullable: true } demo_url: { type: string, format: uri, nullable: true }
taskstream: taskstream:
type: boolean 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: taskstream_key:
type: string type: string
nullable: true nullable: true
pattern: "^[A-Za-z][A-Za-z0-9]{1,9}$" 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`. } cover_image: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=projects`. }
started_at: { type: string, format: date, nullable: true } started_at: { type: string, format: date, nullable: true }
ended_at: { type: string, format: date, nullable: true } ended_at: { type: string, format: date, nullable: true }

View File

@@ -117,6 +117,11 @@ export interface Copy {
goodFirstLead: string; goodFirstLead: string;
board: string; board: string;
done: string; done: string;
proposals: string;
proposalsLead: string;
propose: string;
voteOne: string;
voteMany: string;
}; };
member: { member: {
projects: string; projects: string;
@@ -317,6 +322,11 @@ const es: Copy = {
goodFirstLead: 'Tareas pensadas para quien llega nuevo.', goodFirstLead: 'Tareas pensadas para quien llega nuevo.',
board: 'Ver el tablero', board: 'Ver el tablero',
done: 'Hecho', 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: { member: {
projects: 'Proyectos', projects: 'Proyectos',
@@ -536,6 +546,11 @@ const en: Copy = {
goodFirstLead: 'Tasks meant for newcomers.', goodFirstLead: 'Tasks meant for newcomers.',
board: 'See the board', board: 'See the board',
done: 'Done', 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: { member: {
projects: 'Projects', projects: 'Projects',
@@ -755,6 +770,11 @@ const ca: Copy = {
goodFirstLead: 'Tasques pensades per a qui arriba de nou.', goodFirstLead: 'Tasques pensades per a qui arriba de nou.',
board: 'Veure el tauler', board: 'Veure el tauler',
done: 'Fet', 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: { member: {
projects: 'Projectes', projects: 'Projectes',

View File

@@ -44,4 +44,17 @@ describe('TaskStream showcase', () => {
expect(hasContent(showcase)).toBe(true); expect(hasContent(showcase)).toBe(true);
expect(hasContent({ ...showcase, goodFirst: [] })).toBe(false); 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);
});
}); });

View File

@@ -1,7 +1,7 @@
/** /**
* A project's board in TaskStream, the collective's issue tracker: its public * A project's board in TaskStream, the collective's issue tracker: its public
* showcase (roadmap, releases, the sprint under way and tickets for * showcase (roadmap, releases, the sprint under way, tickets for newcomers
* newcomers). Fetched on the server; a slow or absent TaskStream only means * 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. * the project page goes without those sections.
*/ */
@@ -26,6 +26,8 @@ export interface TaskStreamShowcase {
dueDate: string | null; dueDate: string | null;
percent: number; percent: number;
done: boolean; done: boolean;
/** How many want it; older TaskStreams leave it out. */
votes?: number;
path: string; path: string;
}[]; }[];
releases: { releases: {
@@ -37,6 +39,21 @@ export interface TaskStreamShowcase {
notes: string | null; notes: string | null;
}[]; }[];
goodFirst: { key: string; title: string; type: string; path: string }[]; 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. */ /** 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.roadmap.length > 0 ||
showcase.releases.length > 0 || showcase.releases.length > 0 ||
showcase.goodFirst.length > 0 || showcase.goodFirst.length > 0 ||
showcase.activeSprint !== null showcase.activeSprint !== null ||
!!showcase.proposals?.open ||
(showcase.proposals?.top.length ?? 0) > 0
); );
} }

View File

@@ -90,7 +90,8 @@
.project-roadmap, .project-roadmap,
.project-releases, .project-releases,
.project-first { .project-first,
.project-proposals {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
margin: 0; margin: 0;
@@ -134,3 +135,21 @@
font: inherit; font: inherit;
font-size: 0.9rem; 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;
}

View File

@@ -155,9 +155,12 @@ import { LinkButton } from './link-button';
<a [href]="link(epic.path)" target="_blank" rel="noopener">{{ <a [href]="link(epic.path)" target="_blank" rel="noopener">{{
epic.title epic.title
}}</a> }}</a>
<span class="content-page__muted project-roadmap__when">{{ <span class="content-page__muted project-roadmap__when"
dates(epic.startDate, epic.dueDate) >{{ dates(epic.startDate, epic.dueDate) }}
}}</span> @if (epic.votes && !epic.done) {
· {{ votes(epic.votes) }}
}
</span>
<span <span
class="project-roadmap__bar" class="project-roadmap__bar"
role="img" role="img"
@@ -169,6 +172,33 @@ import { LinkButton } from './link-button';
</ol> </ol>
</section> </section>
} }
@if (b.showcase.proposals; as proposals) {
@if (proposals.open || proposals.top.length > 0) {
<section class="content-page__section">
<h2>{{ copy().project.proposals }}</h2>
@if (proposals.top.length > 0) {
<p class="content-page__muted">{{ copy().project.proposalsLead }}</p>
<ol class="project-proposals">
@for (proposal of proposals.top; track proposal.key) {
<li>
<a [href]="link(proposal.path)" target="_blank" rel="noopener">{{
proposal.title
}}</a>
<span class="content-page__muted">{{ votes(proposal.votes) }}</span>
</li>
}
</ol>
}
@if (proposals.open && proposals.path) {
<p class="project-proposals__propose">
<a [href]="link(proposals.path)" target="_blank" rel="noopener"
>{{ copy().project.propose }} →</a
>
</p>
}
</section>
}
}
@if (b.showcase.releases.length > 0) { @if (b.showcase.releases.length > 0) {
<section class="content-page__section"> <section class="content-page__section">
<h2>{{ copy().project.releases }}</h2> <h2>{{ copy().project.releases }}</h2>
@@ -397,6 +427,12 @@ export class ProjectDetail {
return boardLink(this.board()?.webUrl ?? '', path); 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. */ /** "3 nov – 20 dic 2026", or one end when only one is known. */
protected dates(start: string | null, end: string | null): string { protected dates(start: string | null, end: string | null): string {
const format = (date: string | null) => const format = (date: string | null) =>

View File

@@ -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 // 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. // show what a visitor sees when a link to a filled call is still out there.
for (const call of data['/public/v1/calls']) { for (const call of data['/public/v1/calls']) {
@@ -237,6 +269,12 @@ createServer((req, res) => {
res.setHeader('content-type', 'application/json'); 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 // The two write endpoints: accept whatever arrives, as the real API does
// for a valid submission. // for a valid submission.
if (req.method === 'POST') { if (req.method === 'POST') {