feat(web): English paths, and a portfolio that updates itself
/cartera, /explorar and /p/:key/proponer are /portfolio, /explore and /p/:key/propose now, like every other path; the Spanish ones redirect, so links already shared keep working. Settings sections go by ?section= with English names. The portfolio reads itself again, quietly, when a ticket, sprint or release of the projects shown changes, when my projects change, or when the live connection comes back; «En directo» replaces the refresh button. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,3 +16,11 @@ export const portfolioApiEvents = eventGroup({
|
||||
loadFailed: type<string>(),
|
||||
},
|
||||
});
|
||||
|
||||
/** Something changed in one of my projects: reload, quietly. */
|
||||
export const portfolioLiveEvents = eventGroup({
|
||||
source: 'Portfolio Live',
|
||||
events: {
|
||||
changed: type<void>(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,10 +10,18 @@ import {
|
||||
import {
|
||||
PortfolioProject,
|
||||
ProjectsGateway,
|
||||
TrackerRealtimeGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { switchMap } from 'rxjs';
|
||||
import {
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
merge,
|
||||
switchMap,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
portfolioApiEvents,
|
||||
portfolioLiveEvents,
|
||||
portfolioPageEvents,
|
||||
} from '../events/portfolio.events';
|
||||
import { describeError } from '../notifications';
|
||||
@@ -25,7 +33,7 @@ interface PortfolioState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** All my active projects at a glance. Scoped to the portfolio page. */
|
||||
/** All my active projects at a glance, live. Scoped to the portfolio page. */
|
||||
export const PortfolioStore = signalStore(
|
||||
withState<PortfolioState>({
|
||||
projects: [],
|
||||
@@ -47,17 +55,46 @@ export const PortfolioStore = signalStore(
|
||||
})),
|
||||
),
|
||||
withEventHandlers(
|
||||
(_store, events = inject(Events), projects = inject(ProjectsGateway)) => ({
|
||||
load$: events.on(portfolioPageEvents.opened).pipe(
|
||||
switchMap(() =>
|
||||
projects.portfolio().pipe(
|
||||
mapResponse({
|
||||
next: (list) => portfolioApiEvents.loaded(list),
|
||||
error: (error) =>
|
||||
portfolioApiEvents.loadFailed(describeError(error)),
|
||||
}),
|
||||
(
|
||||
_store,
|
||||
events = inject(Events),
|
||||
projects = inject(ProjectsGateway),
|
||||
realtime = inject(TrackerRealtimeGateway),
|
||||
) => ({
|
||||
// Opened, or something changed (then quietly: no «loading»).
|
||||
load$: events
|
||||
.on(portfolioPageEvents.opened, portfolioLiveEvents.changed)
|
||||
.pipe(
|
||||
switchMap(() =>
|
||||
projects.portfolio().pipe(
|
||||
mapResponse({
|
||||
next: (list) => portfolioApiEvents.loaded(list),
|
||||
error: (error) =>
|
||||
portfolioApiEvents.loadFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Any ticket, sprint or release of the projects shown, the list of my
|
||||
// projects itself, or a cut connection: read it again, calmly.
|
||||
live$: events.on(portfolioApiEvents.loaded).pipe(
|
||||
map(({ payload }) => payload.map((p) => p.projectId).join(',')),
|
||||
distinctUntilChanged(),
|
||||
switchMap((ids) =>
|
||||
merge(
|
||||
...ids
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
.flatMap((id) => [
|
||||
realtime.issueChanges(id),
|
||||
realtime.sprintChanges(id),
|
||||
realtime.releaseChanges(id),
|
||||
]),
|
||||
realtime.myProjectsChanges(),
|
||||
realtime.reconnects(),
|
||||
).pipe(debounceTime(1500)),
|
||||
),
|
||||
map(() => portfolioLiveEvents.changed()),
|
||||
),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { GO_TO, isTyping, projectKeyIn } from './shortcuts';
|
||||
describe('shortcuts', () => {
|
||||
it('knows the project of a URL', () => {
|
||||
expect(projectKeyIn('/p/WEB/issues/WEB-3')).toBe('WEB');
|
||||
expect(projectKeyIn('/explorar')).toBeNull();
|
||||
expect(projectKeyIn('/explore')).toBeNull();
|
||||
expect(GO_TO['k']).toEqual({ path: 'backlog', inProject: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export const SHORTCUTS: { keys: string; what: string; where: string }[] = [
|
||||
/** Where `g` + key goes; project pages are relative to `/p/KEY`. */
|
||||
export const GO_TO: Record<string, { path: string; inProject: boolean }> = {
|
||||
p: { path: '/', inProject: false },
|
||||
e: { path: '/explorar', inProject: false },
|
||||
e: { path: '/explore', inProject: false },
|
||||
b: { path: 'board', inProject: true },
|
||||
k: { path: 'backlog', inProject: true },
|
||||
s: { path: 'sprints', inProject: true },
|
||||
|
||||
@@ -14,7 +14,7 @@ import { AuthFacade } from '@task-stream/web/identity/application';
|
||||
import { ExploreFacade } from '@task-stream/web/tracker/application';
|
||||
import { ProjectCardComponent } from '../ui/project-card';
|
||||
|
||||
/** /explorar — public projects, for anyone (no sign-in needed). */
|
||||
/** /explore — public projects, for anyone (no sign-in needed). */
|
||||
@Component({
|
||||
selector: 'ts-explore-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -48,14 +48,13 @@ const short = (day: string | null) =>
|
||||
riesgo, el sprint en curso, las próximas releases y las épicas.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-secondary"
|
||||
[disabled]="portfolio.loading()"
|
||||
(click)="portfolio.load()"
|
||||
<p
|
||||
class="ts-live"
|
||||
title="Se actualiza sola cuando cambia algo en tus proyectos"
|
||||
>
|
||||
{{ portfolio.loading() ? 'Actualizando…' : 'Actualizar' }}
|
||||
</button>
|
||||
<span class="ts-live__dot" aria-hidden="true"></span>
|
||||
En directo
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@if (portfolio.error(); as error) {
|
||||
@@ -201,6 +200,33 @@ const short = (day: string | null) =>
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ts-live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-live__dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--ts-c-green);
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--ts-c-green) 50%, transparent);
|
||||
animation: ts-live-pulse 2s ease-out infinite;
|
||||
}
|
||||
@keyframes ts-live-pulse {
|
||||
to {
|
||||
box-shadow: 0 0 0 0.5rem transparent;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ts-live__dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.ts-portfolio {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 26rem), 1fr));
|
||||
|
||||
@@ -42,7 +42,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
|
||||
@if (auth.isAuthenticated()) {
|
||||
<a class="ts-plain-link" routerLink="/">Proyectos</a> /
|
||||
} @else {
|
||||
<a class="ts-plain-link" routerLink="/explorar">Explorar</a> /
|
||||
<a class="ts-plain-link" routerLink="/explore">Explorar</a> /
|
||||
}
|
||||
{{ project.key }}
|
||||
@if (facade.myRole(); as role) {
|
||||
@@ -62,7 +62,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
|
||||
</p>
|
||||
}
|
||||
@if (project.isPublic && project.acceptsProposals) {
|
||||
<a class="ts-project-header__propose" routerLink="proponer">
|
||||
<a class="ts-project-header__propose" routerLink="propose">
|
||||
¿Has visto un error o tienes una idea? Cuéntanoslo →
|
||||
</a>
|
||||
}
|
||||
@@ -150,7 +150,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
|
||||
>Iniciar sesión</a
|
||||
>
|
||||
·
|
||||
<a class="ts-plain-link" routerLink="/explorar"
|
||||
<a class="ts-plain-link" routerLink="/explore"
|
||||
>Ver proyectos públicos</a
|
||||
>
|
||||
</p>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ProposalResult } from '@task-stream/web/tracker/domain';
|
||||
import { RichEditorComponent } from '../ui/rich-editor';
|
||||
|
||||
/**
|
||||
* /p/:key/proponer — anyone, without an account, reports a bug or proposes
|
||||
* /p/:key/propose — anyone, without an account, reports a bug or proposes
|
||||
* an idea for a public project that takes them. It becomes a ticket
|
||||
* labelled «propuesta» that the team looks at and others can vote for.
|
||||
*/
|
||||
|
||||
@@ -61,7 +61,7 @@ const SECTIONS: SettingsSection[] = [
|
||||
hint: 'Quién ve el proyecto y si el público puede proponer cosas.',
|
||||
},
|
||||
{
|
||||
id: 'flujo',
|
||||
id: 'workflow',
|
||||
draft: true,
|
||||
label: 'Flujo de trabajo',
|
||||
hint: 'Las columnas del tablero, a cuáles se puede pasar desde cada una y cuándo algo está hecho.',
|
||||
@@ -73,31 +73,31 @@ const SECTIONS: SettingsSection[] = [
|
||||
hint: 'Tipos, áreas y campos propios. Lo que ya se usa no se borra: se archiva y deja de ofrecerse.',
|
||||
},
|
||||
{
|
||||
id: 'equipos',
|
||||
id: 'teams',
|
||||
draft: true,
|
||||
label: 'Equipos',
|
||||
hint: 'Grupos de miembros, para asignarles tickets y avisarles con «@equipo».',
|
||||
},
|
||||
{
|
||||
id: 'automatizaciones',
|
||||
id: 'automations',
|
||||
label: 'Automatizaciones',
|
||||
hint: 'Reglas «cuando… si… entonces…» que hacen solas el trabajo repetido.',
|
||||
needs: 'manage',
|
||||
},
|
||||
{
|
||||
id: 'integraciones',
|
||||
id: 'integrations',
|
||||
label: 'Integraciones',
|
||||
hint: 'Commits y pull requests de Git en cada ticket, y avisos a otros sistemas.',
|
||||
needs: 'manage',
|
||||
},
|
||||
{
|
||||
id: 'datos',
|
||||
id: 'data',
|
||||
label: 'Importar y exportar',
|
||||
hint: 'Los tickets en CSV: sacarlos, o traerlos de TaskStream o de Jira.',
|
||||
needs: 'manage',
|
||||
},
|
||||
{
|
||||
id: 'peligro',
|
||||
id: 'danger',
|
||||
label: 'Zona peligrosa',
|
||||
hint: 'Archivar el proyecto o eliminarlo del todo.',
|
||||
needs: 'owner',
|
||||
@@ -106,13 +106,13 @@ const SECTIONS: SettingsSection[] = [
|
||||
|
||||
/** Which section holds each part of the draft. */
|
||||
const DRAFT_SECTION: Partial<Record<keyof ProjectSettings, string>> = {
|
||||
statuses: 'flujo',
|
||||
transitions: 'flujo',
|
||||
checklistBeforeDone: 'flujo',
|
||||
statuses: 'workflow',
|
||||
transitions: 'workflow',
|
||||
checklistBeforeDone: 'workflow',
|
||||
issueTypes: 'tickets',
|
||||
areas: 'tickets',
|
||||
customFields: 'tickets',
|
||||
teams: 'equipos',
|
||||
teams: 'teams',
|
||||
};
|
||||
|
||||
/** "Datos y análisis" → "DATOS_Y_ANALISIS": ids of new values (they never change after saving). */
|
||||
@@ -157,9 +157,9 @@ function idFrom(name: string): string {
|
||||
@for (item of sections(); track item.id) {
|
||||
<a
|
||||
[routerLink]="[]"
|
||||
[queryParams]="{ seccion: item.id }"
|
||||
[queryParams]="{ section: item.id }"
|
||||
[class.is-active]="item.id === current().id"
|
||||
[class.is-danger]="item.id === 'peligro'"
|
||||
[class.is-danger]="item.id === 'danger'"
|
||||
[attr.aria-current]="item.id === current().id ? 'page' : null"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
@@ -233,7 +233,7 @@ function idFrom(name: string): string {
|
||||
proponga una idea.
|
||||
} @else if (project.acceptsProposals) {
|
||||
Abierto: cualquiera puede enviar un error o una idea desde
|
||||
<a class="ts-plain-link" routerLink="../proponer">el formulario</a
|
||||
<a class="ts-plain-link" routerLink="../propose">el formulario</a
|
||||
>. Llegan como tickets con la etiqueta «propuesta», y se pueden
|
||||
votar.
|
||||
} @else {
|
||||
@@ -268,7 +268,7 @@ function idFrom(name: string): string {
|
||||
>
|
||||
<fieldset [disabled]="!facade.canManage()">
|
||||
@switch (current().id) {
|
||||
@case ('flujo') {
|
||||
@case ('workflow') {
|
||||
<!-- Statuses -->
|
||||
<section class="ts-panel ts-block" aria-labelledby="st-title">
|
||||
<header class="ts-block__head">
|
||||
@@ -735,7 +735,7 @@ function idFrom(name: string): string {
|
||||
</ol>
|
||||
</section>
|
||||
}
|
||||
@case ('equipos') {
|
||||
@case ('teams') {
|
||||
<!-- Teams -->
|
||||
<section class="ts-panel ts-block" aria-labelledby="teams-title">
|
||||
<header class="ts-block__head">
|
||||
@@ -802,24 +802,24 @@ function idFrom(name: string): string {
|
||||
|
||||
@if (facade.canManage() && facade.project(); as project) {
|
||||
@switch (current().id) {
|
||||
@case ('automatizaciones') {
|
||||
@case ('automations') {
|
||||
<ts-automations [projectId]="project.id" />
|
||||
}
|
||||
@case ('integraciones') {
|
||||
@case ('integrations') {
|
||||
<ts-git-integration
|
||||
[projectId]="project.id"
|
||||
[projectKey]="project.key"
|
||||
/>
|
||||
<ts-webhooks [projectId]="project.id" />
|
||||
}
|
||||
@case ('datos') {
|
||||
@case ('data') {
|
||||
<ts-transfer [projectId]="project.id" [projectKey]="project.key" />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@if (
|
||||
current().id === 'peligro' && facade.isOwner() && facade.project();
|
||||
current().id === 'danger' && facade.isOwner() && facade.project();
|
||||
as project
|
||||
) {
|
||||
<section
|
||||
@@ -1610,8 +1610,8 @@ export class SettingsPage {
|
||||
protected readonly icons = TYPE_ICONS;
|
||||
protected readonly fieldTypes = FIELD_TYPES;
|
||||
|
||||
/** `?seccion=` (`withComponentInputBinding`). */
|
||||
readonly seccion = input<string | undefined>();
|
||||
/** `?section=` (`withComponentInputBinding`). */
|
||||
readonly section = input<string | undefined>();
|
||||
|
||||
protected readonly sections = computed(() =>
|
||||
SECTIONS.filter(
|
||||
@@ -1623,7 +1623,7 @@ export class SettingsPage {
|
||||
);
|
||||
protected readonly current = computed(
|
||||
() =>
|
||||
this.sections().find((s) => s.id === this.seccion()) ??
|
||||
this.sections().find((s) => s.id === this.section()) ??
|
||||
this.sections()[0],
|
||||
);
|
||||
|
||||
|
||||
@@ -15,24 +15,29 @@ export const trackerRoutes: Routes = [
|
||||
import('./pages/projects.page').then((m) => m.ProjectsPage),
|
||||
},
|
||||
{
|
||||
path: 'cartera',
|
||||
path: 'portfolio',
|
||||
canActivate: [authGuard],
|
||||
title: 'Cartera · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/portfolio.page').then((m) => m.PortfolioPage),
|
||||
},
|
||||
{
|
||||
path: 'explorar',
|
||||
path: 'explore',
|
||||
title: 'Proyectos públicos · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/explore.page').then((m) => m.ExplorePage),
|
||||
},
|
||||
// The Spanish paths, from links shared before.
|
||||
{ path: 'cartera', redirectTo: 'portfolio' },
|
||||
{ path: 'explorar', redirectTo: 'explore' },
|
||||
{
|
||||
path: 'p/:key',
|
||||
loadComponent: () =>
|
||||
import('./pages/project-shell.page').then((m) => m.ProjectShellPage),
|
||||
children: [
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'board' },
|
||||
// The Spanish path, from links shared before.
|
||||
{ path: 'proponer', redirectTo: 'propose' },
|
||||
{
|
||||
path: 'board',
|
||||
title: 'Tablero · TaskStream',
|
||||
@@ -40,7 +45,7 @@ export const trackerRoutes: Routes = [
|
||||
import('./pages/board.page').then((m) => m.BoardPage),
|
||||
},
|
||||
{
|
||||
path: 'proponer',
|
||||
path: 'propose',
|
||||
title: 'Proponer · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/propose.page').then((m) => m.ProposePage),
|
||||
|
||||
Reference in New Issue
Block a user