Compare commits
6 Commits
bca3e4bcc6
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
289af6c3a1 | ||
|
|
1ee4f98a9e | ||
|
|
3366f57e14 | ||
|
|
334640d938 | ||
|
|
63d4410296 | ||
|
|
a3783222c8 |
@@ -131,6 +131,15 @@ pass on the server:
|
||||
| Charlas | `/talks?limit=6` |
|
||||
| Contact form | `POST /contact` |
|
||||
|
||||
A project page also asks TaskStream, when the project has a board there
|
||||
(`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
|
||||
are copies — refresh them from the API when the contract moves:
|
||||
|
||||
4369
landing/openapi.yaml
4369
landing/openapi.yaml
File diff suppressed because it is too large
Load Diff
@@ -241,6 +241,10 @@ export interface PublicProject {
|
||||
lead: PublicMember;
|
||||
calls: PublicCall[];
|
||||
repositories: PublicRepository[];
|
||||
/**
|
||||
* Its board in TaskStream, if it has one. `showcase_url` answers without a token: roadmap, releases, the sprint under way and tickets labelled good-first-issue.
|
||||
*/
|
||||
taskstream?: { key: string; url: string; web_url: string; showcase_url: string };
|
||||
alternates: Alternates;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,21 @@ export interface Copy {
|
||||
archived: string;
|
||||
openCalls: string;
|
||||
technologies: string;
|
||||
roadmap: string;
|
||||
roadmapLead: string;
|
||||
releases: string;
|
||||
released: string;
|
||||
planned: string;
|
||||
sprint: string;
|
||||
goodFirst: string;
|
||||
goodFirstLead: string;
|
||||
board: string;
|
||||
done: string;
|
||||
proposals: string;
|
||||
proposalsLead: string;
|
||||
propose: string;
|
||||
voteOne: string;
|
||||
voteMany: string;
|
||||
};
|
||||
member: {
|
||||
projects: string;
|
||||
@@ -297,6 +312,21 @@ const es: Copy = {
|
||||
archived: 'Archivado',
|
||||
openCalls: 'Buscamos ayuda',
|
||||
technologies: 'Tecnologías',
|
||||
roadmap: 'Hoja de ruta',
|
||||
roadmapLead: 'Lo que viene, según nuestro tablero.',
|
||||
releases: 'Versiones',
|
||||
released: 'Publicada',
|
||||
planned: 'Prevista',
|
||||
sprint: 'Ahora mismo',
|
||||
goodFirst: 'Para empezar a colaborar',
|
||||
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',
|
||||
@@ -506,6 +536,21 @@ const en: Copy = {
|
||||
archived: 'Archived',
|
||||
openCalls: 'Looking for help',
|
||||
technologies: 'Technologies',
|
||||
roadmap: 'Roadmap',
|
||||
roadmapLead: 'What is coming, from our board.',
|
||||
releases: 'Releases',
|
||||
released: 'Released',
|
||||
planned: 'Planned',
|
||||
sprint: 'Right now',
|
||||
goodFirst: 'Good first issues',
|
||||
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',
|
||||
@@ -715,6 +760,21 @@ const ca: Copy = {
|
||||
archived: 'Arxivat',
|
||||
openCalls: 'Busquem ajuda',
|
||||
technologies: 'Tecnologies',
|
||||
roadmap: 'Full de ruta',
|
||||
roadmapLead: 'El que ve, segons el nostre tauler.',
|
||||
releases: 'Versions',
|
||||
released: 'Publicada',
|
||||
planned: 'Prevista',
|
||||
sprint: 'Ara mateix',
|
||||
goodFirst: 'Per començar a col·laborar',
|
||||
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',
|
||||
|
||||
@@ -25,11 +25,16 @@ import type {
|
||||
import { BLOG_CATEGORY, byPublication, isBlogPost } from './blog';
|
||||
import { copyFor } from './copy';
|
||||
import { listingPath, resolveRoute, type RouteMatch } from './routes';
|
||||
import {
|
||||
fetchShowcase,
|
||||
hasContent,
|
||||
type ProjectBoard,
|
||||
} from './taskstream';
|
||||
|
||||
/** The record, tagged with its type so the template can switch on it. */
|
||||
export type RoutedRecord =
|
||||
| { type: 'page'; record: PublicPage }
|
||||
| { type: 'project'; record: PublicProject }
|
||||
| { type: 'project'; record: PublicProject; board: ProjectBoard | null }
|
||||
| { type: 'member'; record: PublicMember }
|
||||
| { type: 'call'; record: PublicCall }
|
||||
| { type: 'service'; record: PublicService }
|
||||
@@ -82,7 +87,9 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
|
||||
}
|
||||
case 'project': {
|
||||
const { data } = await pireonApi.project(locale, slug);
|
||||
return data ? { type: 'project', record: data } : null;
|
||||
return data
|
||||
? { type: 'project', record: data, board: await boardOf(data) }
|
||||
: null;
|
||||
}
|
||||
case 'member': {
|
||||
const { data } = await pireonApi.member(locale, slug);
|
||||
@@ -166,3 +173,13 @@ export function pageNavLinks(
|
||||
]
|
||||
: featured;
|
||||
}
|
||||
|
||||
/** The project's TaskStream board, when it has one with something to show. */
|
||||
async function boardOf(project: PublicProject): Promise<ProjectBoard | null> {
|
||||
const board = project.taskstream;
|
||||
if (!board?.showcase_url) return null;
|
||||
const showcase = await fetchShowcase(board.showcase_url);
|
||||
return showcase && hasContent(showcase)
|
||||
? { url: board.url, webUrl: board.web_url, showcase }
|
||||
: null;
|
||||
}
|
||||
|
||||
60
landing/src/app/core/taskstream.spec.ts
Normal file
60
landing/src/app/core/taskstream.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { boardLink, fetchShowcase, hasContent } from './taskstream';
|
||||
|
||||
const showcase = {
|
||||
project: { id: 'p', key: 'WEB', name: 'Web', description: null },
|
||||
activeSprint: null,
|
||||
roadmap: [],
|
||||
releases: [],
|
||||
goodFirst: [
|
||||
{ key: 'WEB-9', title: 'Typo', type: 'TASK', path: '/p/WEB/issues/WEB-9' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('TaskStream showcase', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('reads a showcase, and goes without it when TaskStream fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify(showcase))),
|
||||
);
|
||||
expect(await fetchShowcase('https://api.ts/showcase')).toEqual(showcase);
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('nope', { status: 404 })),
|
||||
);
|
||||
expect(await fetchShowcase('https://api.ts/showcase')).toBeNull();
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new Error('down');
|
||||
}),
|
||||
);
|
||||
expect(await fetchShowcase('https://api.ts/showcase')).toBeNull();
|
||||
});
|
||||
|
||||
it('builds links into the board and knows an empty one', () => {
|
||||
expect(
|
||||
boardLink('https://taskstream.pireon.dev/', '/p/WEB/issues/WEB-9'),
|
||||
).toBe('https://taskstream.pireon.dev/p/WEB/issues/WEB-9');
|
||||
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);
|
||||
});
|
||||
});
|
||||
104
landing/src/app/core/taskstream.ts
Normal file
104
landing/src/app/core/taskstream.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* A project's board in TaskStream, the collective's issue tracker: its public
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export interface TaskStreamShowcase {
|
||||
project: {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
};
|
||||
activeSprint: {
|
||||
name: string;
|
||||
goal: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
percent: number;
|
||||
} | null;
|
||||
roadmap: {
|
||||
key: string;
|
||||
title: string;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
percent: number;
|
||||
done: boolean;
|
||||
/** How many want it; older TaskStreams leave it out. */
|
||||
votes?: number;
|
||||
path: string;
|
||||
}[];
|
||||
releases: {
|
||||
name: string;
|
||||
status: 'PLANNED' | 'RELEASED' | 'ARCHIVED';
|
||||
targetDate: string | null;
|
||||
releasedAt: string | null;
|
||||
percent: number;
|
||||
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. */
|
||||
export interface ProjectBoard {
|
||||
url: string;
|
||||
webUrl: string;
|
||||
showcase: TaskStreamShowcase;
|
||||
}
|
||||
|
||||
export async function fetchShowcase(
|
||||
showcaseUrl: string,
|
||||
timeout = 4000,
|
||||
): Promise<TaskStreamShowcase | null> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
try {
|
||||
const response = await fetch(showcaseUrl, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const body = (await response.json()) as Partial<TaskStreamShowcase>;
|
||||
return Array.isArray(body.roadmap) && Array.isArray(body.goodFirst)
|
||||
? (body as TaskStreamShowcase)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Something worth showing: a board with nothing on it says nothing. */
|
||||
export function hasContent(showcase: TaskStreamShowcase): boolean {
|
||||
return (
|
||||
showcase.roadmap.length > 0 ||
|
||||
showcase.releases.length > 0 ||
|
||||
showcase.goodFirst.length > 0 ||
|
||||
showcase.activeSprint !== null ||
|
||||
!!showcase.proposals?.open ||
|
||||
(showcase.proposals?.top.length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** A showcase path as an address in TaskStream's web app. */
|
||||
export function boardLink(webUrl: string, path: string): string {
|
||||
return `${webUrl.replace(/\/$/, '')}${path}`;
|
||||
}
|
||||
@@ -87,3 +87,69 @@
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.project-roadmap,
|
||||
.project-releases,
|
||||
.project-first,
|
||||
.project-proposals {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.project-roadmap__item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.25rem 1rem;
|
||||
align-items: baseline;
|
||||
|
||||
&.is-done a {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.project-roadmap__bar {
|
||||
grid-column: 1 / -1;
|
||||
height: 0.375rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||
|
||||
span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--pi-color-primary, currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
.project-releases li {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.project-releases__notes {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import type { Locale } from '../core/api';
|
||||
import type { PublicProject } from '../core/api.types';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { boardLink, type ProjectBoard } from '../core/taskstream';
|
||||
import { ApiImage } from './api-image';
|
||||
import { ContactCta } from './contact-cta';
|
||||
import { PROJECT_STATUS_TONES } from './project-card';
|
||||
@@ -143,6 +144,91 @@ import { LinkButton } from './link-button';
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (board(); as b) {
|
||||
@if (b.showcase.roadmap.length > 0) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().project.roadmap }}</h2>
|
||||
<p class="content-page__muted">{{ copy().project.roadmapLead }}</p>
|
||||
<ol class="project-roadmap">
|
||||
@for (epic of b.showcase.roadmap; track epic.key) {
|
||||
<li class="project-roadmap__item" [class.is-done]="epic.done">
|
||||
<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) }}
|
||||
@if (epic.votes && !epic.done) {
|
||||
· {{ votes(epic.votes) }}
|
||||
}
|
||||
</span>
|
||||
<span
|
||||
class="project-roadmap__bar"
|
||||
role="img"
|
||||
[attr.aria-label]="epic.percent + '%'"
|
||||
><span [style.width.%]="epic.percent"></span
|
||||
></span>
|
||||
</li>
|
||||
}
|
||||
</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>
|
||||
<ul class="project-releases">
|
||||
@for (release of b.showcase.releases; track release.name) {
|
||||
<li>
|
||||
<strong>{{ release.name }}</strong>
|
||||
<span class="content-page__muted">
|
||||
{{
|
||||
release.status === 'RELEASED'
|
||||
? copy().project.released
|
||||
: copy().project.planned
|
||||
}}
|
||||
@if (release.releasedAt ?? release.targetDate; as day) {
|
||||
· {{ dates(day, null) }}
|
||||
}
|
||||
@if (release.status !== 'RELEASED') {
|
||||
· {{ release.percent }}%
|
||||
}
|
||||
</span>
|
||||
@if (release.notes) {
|
||||
<pre class="project-releases__notes">{{ release.notes }}</pre>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@if ((project().gallery ?? []).length > 0) {
|
||||
<section class="content-page__section">
|
||||
<h2>{{ copy().project.gallery }}</h2>
|
||||
@@ -202,6 +288,36 @@ import { LinkButton } from './link-button';
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@if (board(); as b) {
|
||||
<div class="landing-card content-page__panel">
|
||||
@if (b.showcase.activeSprint; as sprint) {
|
||||
<p class="landing-eyebrow">{{ copy().project.sprint }}</p>
|
||||
<p>
|
||||
<strong>{{ sprint.name }}</strong> · {{ sprint.percent }}%
|
||||
@if (sprint.goal) {
|
||||
<br /><span class="content-page__muted">{{ sprint.goal }}</span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
@if (b.showcase.goodFirst.length > 0) {
|
||||
<h2>{{ copy().project.goodFirst }}</h2>
|
||||
<p class="content-page__muted">{{ copy().project.goodFirstLead }}</p>
|
||||
<ul class="project-first">
|
||||
@for (issue of b.showcase.goodFirst; track issue.key) {
|
||||
<li>
|
||||
<a [href]="link(issue.path)" target="_blank" rel="noopener"
|
||||
>{{ issue.key }} · {{ issue.title }}</a
|
||||
>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
<a [href]="b.url" target="_blank" rel="noopener">{{
|
||||
copy().project.board
|
||||
}} →</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if ((project().calls ?? []).length > 0) {
|
||||
<nav [attr.aria-label]="copy().project.openCalls">
|
||||
<p class="landing-eyebrow">{{ copy().project.openCalls }}</p>
|
||||
@@ -304,6 +420,32 @@ export class ProjectDetail {
|
||||
readonly project = input.required<PublicProject>();
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
/** Its TaskStream board, when it has one with something to show. */
|
||||
readonly board = input<ProjectBoard | null>(null);
|
||||
|
||||
protected link(path: string): string {
|
||||
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) =>
|
||||
date
|
||||
? new Intl.DateTimeFormat(this.locale(), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}).format(new Date(`${date.slice(0, 10)}T00:00:00Z`))
|
||||
: null;
|
||||
return [format(start), format(end)].filter(Boolean).join(' – ');
|
||||
}
|
||||
|
||||
protected readonly statusTone = computed(
|
||||
() => PROJECT_STATUS_TONES[this.project().status],
|
||||
|
||||
@@ -61,6 +61,7 @@ import { ProjectDetail } from './project-detail';
|
||||
@case ('project') {
|
||||
<app-project-detail
|
||||
[project]="found.record"
|
||||
[board]="found.board"
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -22,7 +22,7 @@
|
||||
"@angular/platform-server": "~22.0.4",
|
||||
"@angular/router": "~22.0.4",
|
||||
"@ngx-translate/core": "^18.0.0",
|
||||
"@pi/ui": "^0.0.2",
|
||||
"@pi/ui": "^0.0.7",
|
||||
"front-matter": "^4.0.2",
|
||||
"marked": "^15.0.7",
|
||||
"marked-gfm-heading-id": "^4.1.1",
|
||||
@@ -9587,12 +9587,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@pi/ui": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://nexus.pireon.dev/repository/npm-releases/@pi/ui/-/ui-0.0.2.tgz",
|
||||
"integrity": "sha1-kCo1VJKozqkA7ag+oOYIRoGESAg=",
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://nexus.pireon.dev/repository/npm-releases/@pi/ui/-/ui-0.0.7.tgz",
|
||||
"integrity": "sha1-PkfcIIYypFMmuypt5C3UbxDNJbc=",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.0",
|
||||
"luxon": "^3.0.0",
|
||||
"ngx-infinite-scroll": "^22.0.0",
|
||||
"tslib": "^2.3.0"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@angular/platform-server": "~22.0.4",
|
||||
"@angular/router": "~22.0.4",
|
||||
"@ngx-translate/core": "^18.0.0",
|
||||
"@pi/ui": "^0.0.2",
|
||||
"@pi/ui": "^0.0.7",
|
||||
"front-matter": "^4.0.2",
|
||||
"marked": "^15.0.7",
|
||||
"marked-gfm-heading-id": "^4.1.1",
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user