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

@@ -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 }

View File

@@ -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',

View File

@@ -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);
});
});

View File

@@ -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
);
}

View File

@@ -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;
}

View File

@@ -155,9 +155,12 @@ import { LinkButton } from './link-button';
<a [href]="link(epic.path)" target="_blank" rel="noopener">{{
epic.title
}}</a>
<span class="content-page__muted project-roadmap__when">{{
dates(epic.startDate, epic.dueDate)
}}</span>
<span class="content-page__muted project-roadmap__when"
>{{ dates(epic.startDate, epic.dueDate) }}
@if (epic.votes && !epic.done) {
· {{ votes(epic.votes) }}
}
</span>
<span
class="project-roadmap__bar"
role="img"
@@ -169,6 +172,33 @@ import { LinkButton } from './link-button';
</ol>
</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) {
<section class="content-page__section">
<h2>{{ copy().project.releases }}</h2>
@@ -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) =>