Show a project's roadmap, releases and first issues from TaskStream
When a project has a board in TaskStream, its page lists the epics with their dates and progress, the releases with their notes, the sprint under way and the tickets meant for newcomers, linking to the board. TaskStream being slow or down only leaves those sections out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -131,6 +131,11 @@ pass on the server:
|
|||||||
| Charlas | `/talks?limit=6` |
|
| Charlas | `/talks?limit=6` |
|
||||||
| Contact form | `POST /contact` |
|
| 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.
|
||||||
|
|
||||||
`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
|
||||||
are copies — refresh them from the API when the contract moves:
|
are copies — refresh them from the API when the contract moves:
|
||||||
|
|||||||
4362
landing/openapi.yaml
4362
landing/openapi.yaml
File diff suppressed because it is too large
Load Diff
@@ -241,6 +241,10 @@ export interface PublicProject {
|
|||||||
lead: PublicMember;
|
lead: PublicMember;
|
||||||
calls: PublicCall[];
|
calls: PublicCall[];
|
||||||
repositories: PublicRepository[];
|
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;
|
alternates: Alternates;
|
||||||
path?: string;
|
path?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,16 @@ export interface Copy {
|
|||||||
archived: string;
|
archived: string;
|
||||||
openCalls: string;
|
openCalls: string;
|
||||||
technologies: string;
|
technologies: string;
|
||||||
|
roadmap: string;
|
||||||
|
roadmapLead: string;
|
||||||
|
releases: string;
|
||||||
|
released: string;
|
||||||
|
planned: string;
|
||||||
|
sprint: string;
|
||||||
|
goodFirst: string;
|
||||||
|
goodFirstLead: string;
|
||||||
|
board: string;
|
||||||
|
done: string;
|
||||||
};
|
};
|
||||||
member: {
|
member: {
|
||||||
projects: string;
|
projects: string;
|
||||||
@@ -297,6 +307,16 @@ const es: Copy = {
|
|||||||
archived: 'Archivado',
|
archived: 'Archivado',
|
||||||
openCalls: 'Buscamos ayuda',
|
openCalls: 'Buscamos ayuda',
|
||||||
technologies: 'Tecnologías',
|
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',
|
||||||
},
|
},
|
||||||
member: {
|
member: {
|
||||||
projects: 'Proyectos',
|
projects: 'Proyectos',
|
||||||
@@ -506,6 +526,16 @@ const en: Copy = {
|
|||||||
archived: 'Archived',
|
archived: 'Archived',
|
||||||
openCalls: 'Looking for help',
|
openCalls: 'Looking for help',
|
||||||
technologies: 'Technologies',
|
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',
|
||||||
},
|
},
|
||||||
member: {
|
member: {
|
||||||
projects: 'Projects',
|
projects: 'Projects',
|
||||||
@@ -715,6 +745,16 @@ const ca: Copy = {
|
|||||||
archived: 'Arxivat',
|
archived: 'Arxivat',
|
||||||
openCalls: 'Busquem ajuda',
|
openCalls: 'Busquem ajuda',
|
||||||
technologies: 'Tecnologies',
|
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',
|
||||||
},
|
},
|
||||||
member: {
|
member: {
|
||||||
projects: 'Projectes',
|
projects: 'Projectes',
|
||||||
|
|||||||
@@ -25,11 +25,16 @@ import type {
|
|||||||
import { BLOG_CATEGORY, byPublication, isBlogPost } from './blog';
|
import { BLOG_CATEGORY, byPublication, isBlogPost } from './blog';
|
||||||
import { copyFor } from './copy';
|
import { copyFor } from './copy';
|
||||||
import { listingPath, resolveRoute, type RouteMatch } from './routes';
|
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. */
|
/** The record, tagged with its type so the template can switch on it. */
|
||||||
export type RoutedRecord =
|
export type RoutedRecord =
|
||||||
| { type: 'page'; record: PublicPage }
|
| { type: 'page'; record: PublicPage }
|
||||||
| { type: 'project'; record: PublicProject }
|
| { type: 'project'; record: PublicProject; board: ProjectBoard | null }
|
||||||
| { type: 'member'; record: PublicMember }
|
| { type: 'member'; record: PublicMember }
|
||||||
| { type: 'call'; record: PublicCall }
|
| { type: 'call'; record: PublicCall }
|
||||||
| { type: 'service'; record: PublicService }
|
| { type: 'service'; record: PublicService }
|
||||||
@@ -82,7 +87,9 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
|
|||||||
}
|
}
|
||||||
case 'project': {
|
case 'project': {
|
||||||
const { data } = await pireonApi.project(locale, slug);
|
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': {
|
case 'member': {
|
||||||
const { data } = await pireonApi.member(locale, slug);
|
const { data } = await pireonApi.member(locale, slug);
|
||||||
@@ -166,3 +173,13 @@ export function pageNavLinks(
|
|||||||
]
|
]
|
||||||
: featured;
|
: 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;
|
||||||
|
}
|
||||||
|
|||||||
47
landing/src/app/core/taskstream.spec.ts
Normal file
47
landing/src/app/core/taskstream.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
85
landing/src/app/core/taskstream.ts
Normal file
85
landing/src/app/core/taskstream.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* 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;
|
||||||
|
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 }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,50 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-roadmap,
|
||||||
|
.project-releases,
|
||||||
|
.project-first {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import type { Locale } from '../core/api';
|
import type { Locale } from '../core/api';
|
||||||
import type { PublicProject } from '../core/api.types';
|
import type { PublicProject } from '../core/api.types';
|
||||||
import type { Copy } from '../core/copy';
|
import type { Copy } from '../core/copy';
|
||||||
|
import { boardLink, type ProjectBoard } from '../core/taskstream';
|
||||||
import { ApiImage } from './api-image';
|
import { ApiImage } from './api-image';
|
||||||
import { ContactCta } from './contact-cta';
|
import { ContactCta } from './contact-cta';
|
||||||
import { PROJECT_STATUS_TONES } from './project-card';
|
import { PROJECT_STATUS_TONES } from './project-card';
|
||||||
@@ -143,6 +144,61 @@ import { LinkButton } from './link-button';
|
|||||||
</section>
|
</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)
|
||||||
|
}}</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.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) {
|
@if ((project().gallery ?? []).length > 0) {
|
||||||
<section class="content-page__section">
|
<section class="content-page__section">
|
||||||
<h2>{{ copy().project.gallery }}</h2>
|
<h2>{{ copy().project.gallery }}</h2>
|
||||||
@@ -202,6 +258,36 @@ import { LinkButton } from './link-button';
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</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) {
|
@if ((project().calls ?? []).length > 0) {
|
||||||
<nav [attr.aria-label]="copy().project.openCalls">
|
<nav [attr.aria-label]="copy().project.openCalls">
|
||||||
<p class="landing-eyebrow">{{ copy().project.openCalls }}</p>
|
<p class="landing-eyebrow">{{ copy().project.openCalls }}</p>
|
||||||
@@ -304,6 +390,26 @@ export class ProjectDetail {
|
|||||||
readonly project = input.required<PublicProject>();
|
readonly project = input.required<PublicProject>();
|
||||||
readonly copy = input.required<Copy>();
|
readonly copy = input.required<Copy>();
|
||||||
readonly locale = input.required<Locale>();
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "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(
|
protected readonly statusTone = computed(
|
||||||
() => PROJECT_STATUS_TONES[this.project().status],
|
() => PROJECT_STATUS_TONES[this.project().status],
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import { ProjectDetail } from './project-detail';
|
|||||||
@case ('project') {
|
@case ('project') {
|
||||||
<app-project-detail
|
<app-project-detail
|
||||||
[project]="found.record"
|
[project]="found.record"
|
||||||
|
[board]="found.board"
|
||||||
[copy]="copy()"
|
[copy]="copy()"
|
||||||
[locale]="data().locale"
|
[locale]="data().locale"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user