feat: saved views of my work, across my projects

A person saves named views of «my work»: projects, assigned to me, that I
follow, status categories, priorities, bugs only, overdue and text. They
are private (Mongo my_views, no history), listed and deleted by their
owner (GraphQL myViews / saveMyView / deleteMyView, REST /api/me/views),
and run as the cross-project search, which gains project, priority,
bugsOnly and overdue filters (also in MCP my_issues). The home page shows
them as a bar above «my work», with a drawer to save a new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 14:22:20 +02:00
parent e14f445e6b
commit 5b0396bc10
46 changed files with 2648 additions and 35 deletions

View File

@@ -0,0 +1,481 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
signal,
TemplateRef,
viewChild,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import {
PiActionButtonComponent,
PiCheckboxComponent,
PiLabelDirective,
PiTextFieldComponent,
} from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import {
MyViewsFacade,
ProjectsFacade,
} from '@task-stream/web/tracker/application';
import {
IssuePriority,
IssueSummary,
MyViewCriteria,
PRIORITY_ORDER,
StatusCategory,
Workflow,
} from '@task-stream/web/tracker/domain';
import {
CATEGORY,
PRIORITY,
projectKeyOf,
projectTone,
statusLook,
typeLook,
} from './copy';
const CATEGORIES = Object.values(StatusCategory);
/** Toggles a value in a list kept as a signal. */
function toggle<T>(
list: ReturnType<typeof signal<T[]>>,
value: T,
on: boolean,
) {
list.update((values) =>
on ? [...new Set([...values, value])] : values.filter((v) => v !== value),
);
}
/**
* My saved views of «my work», across my projects: a bar to pick one, its
* tickets, and the drawer to save a new one.
*/
@Component({
selector: 'ts-my-views',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterLink,
PiActionButtonComponent,
PiCheckboxComponent,
PiLabelDirective,
PiTextFieldComponent,
],
template: `
<nav class="ts-views" aria-label="Mis vistas">
<span class="ts-views__label">Vistas</span>
@for (view of views.views(); track view.id) {
<button
type="button"
class="ts-views__chip"
[class.is-on]="views.selected()?.id === view.id"
[attr.aria-pressed]="views.selected()?.id === view.id"
(click)="
views.select(views.selected()?.id === view.id ? null : view.id)
"
>
{{ view.name }}
</button>
}
<button type="button" class="ts-views__new" (click)="openForm()">
+ Nueva vista
</button>
</nav>
@if (views.selected(); as view) {
<section class="ts-panel ts-views__panel" aria-labelledby="view-title">
<header class="ts-views__head">
<h2 id="view-title">{{ view.name }}</h2>
<span class="ts-muted">{{ summary() }}</span>
<span class="ts-muted ts-mono">{{ views.total() }}</span>
<span class="ts-spacer"></span>
<button
type="button"
class="ts-plain-link"
(click)="views.remove(view.id)"
>
Borrar vista
</button>
<button
type="button"
class="ts-plain-link"
(click)="views.select(null)"
>
Cerrar
</button>
</header>
@if (views.loading()) {
<p class="ts-muted">Cargando…</p>
} @else if (views.error(); as error) {
<p class="ts-muted">{{ error }}</p>
} @else {
<ul class="ts-views__list">
@for (issue of views.results(); track issue.id) {
<li>
<a
class="ts-views__row"
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
[style.--type]="typeTone(issue)"
>
<span class="ts-key">{{ issue.key }}</span>
<span class="ts-views__title"
><span
class="ts-views__status"
[style.--tone]="statusTone(issue)"
>{{ statusLabel(issue) }}</span
>
{{ issue.title }}</span
>
<span class="ts-views__who">{{
issue.assignee?.name ?? 'Sin asignar'
}}</span>
<span
class="ts-views__project"
[style.--tone]="projectToneOf(issue.key)"
>{{ keyOf(issue.key) }}</span
>
</a>
</li>
} @empty {
<li class="ts-muted">Ningún ticket encaja ahora mismo.</li>
}
</ul>
@if (views.total() > views.results().length) {
<p class="ts-muted ts-views__more">
Se muestran {{ views.results().length }} de {{ views.total() }}.
</p>
}
}
</section>
}
<ng-template #form>
<form
class="ts-form ts-views__form"
novalidate
(submit)="$event.preventDefault(); save()"
>
<pi-text-field [(value)]="name" placeholder="Bugs críticos">
<pi-label [isRequired]="true">Nombre</pi-label>
</pi-text-field>
<fieldset>
<legend>Proyectos <span>(ninguno: todos los míos)</span></legend>
@for (project of projects.sorted(); track project.id) {
<pi-checkbox
[label]="project.key + ' · ' + project.name"
[checked]="projectIds().includes(project.id)"
(checkedChange)="pick(projectIds, project.id, $event)"
/>
}
</fieldset>
<fieldset>
<legend>Quién</legend>
<pi-checkbox
label="Asignados a mí"
[checked]="assignedToMe()"
(checkedChange)="assignedToMe.set($event)"
/>
<pi-checkbox
label="Que sigo"
[checked]="watching()"
(checkedChange)="watching.set($event)"
/>
</fieldset>
<fieldset>
<legend>Estado <span>(ninguno: los abiertos)</span></legend>
@for (category of categories; track category) {
<pi-checkbox
[label]="categoryLabel(category)"
[checked]="categoriesPicked().includes(category)"
(checkedChange)="pick(categoriesPicked, category, $event)"
/>
}
</fieldset>
<fieldset>
<legend>Prioridad <span>(ninguna: todas)</span></legend>
@for (priority of priorities; track priority) {
<pi-checkbox
[label]="priorityLabel(priority)"
[checked]="prioritiesPicked().includes(priority)"
(checkedChange)="pick(prioritiesPicked, priority, $event)"
/>
}
</fieldset>
<fieldset>
<legend>Además</legend>
<pi-checkbox
label="Solo bugs"
[checked]="bugsOnly()"
(checkedChange)="bugsOnly.set($event)"
/>
<pi-checkbox
label="Solo vencidos"
[checked]="overdue()"
(checkedChange)="overdue.set($event)"
/>
</fieldset>
<pi-text-field [(value)]="search" placeholder="pago, Safari…">
<pi-label>Texto (clave, título o descripción)</pi-label>
</pi-text-field>
<pi-action-button
size="w100"
color="primary"
[text]="views.saving() ? 'Guardando…' : 'Guardar vista'"
[disabled]="!name().trim() || views.saving()"
(execute)="save()"
/>
</form>
</ng-template>
`,
styles: `
:host {
display: grid;
gap: 1rem;
margin-bottom: 1.25rem;
}
.ts-views {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.ts-views__label {
color: var(--ts-muted);
font-size: 0.8125rem;
}
.ts-views__chip,
.ts-views__new {
padding: 0.3125rem 0.75rem;
border: 1px solid var(--ts-line);
border-radius: 999px;
background: var(--ts-surface);
color: var(--ts-ink);
font: inherit;
font-size: 0.8125rem;
cursor: pointer;
&:hover {
border-color: var(--ts-brand);
}
}
.ts-views__chip.is-on {
border-color: var(--ts-brand);
background: color-mix(in srgb, var(--ts-brand) 14%, var(--ts-surface));
font-weight: 600;
}
.ts-views__new {
border-style: dashed;
color: var(--ts-brand);
}
.ts-views__panel {
padding: 1.25rem 1.5rem;
}
.ts-views__head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
h2 {
margin: 0;
font-size: 1.125rem;
}
.ts-muted {
font-size: 0.8125rem;
}
}
.ts-spacer {
flex: 1;
}
.ts-views__list {
display: grid;
gap: 0.125rem;
margin: 0;
padding: 0;
list-style: none;
}
.ts-views__row {
display: grid;
grid-template-columns: 5.5rem minmax(0, 1fr) minmax(0, 9rem) 3.5rem;
align-items: center;
gap: 0.75rem;
padding: 0.375rem 0.5rem;
border-left: 3px solid var(--type, var(--ts-line));
border-radius: 0.375rem;
color: inherit;
font-size: 0.875rem;
text-decoration: none;
&:hover {
background: var(--pi-color-primary-25);
}
}
.ts-views__title,
.ts-views__who {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ts-views__who {
color: var(--ts-muted);
font-size: 0.8125rem;
}
.ts-views__status {
margin-right: 0.375rem;
color: var(--tone);
font-size: 0.75rem;
font-weight: 600;
}
.ts-views__project {
justify-self: end;
color: var(--tone);
font-family: var(--ts-font-mono);
font-size: 0.75rem;
}
.ts-views__more {
margin: 0.75rem 0 0;
font-size: 0.75rem;
}
.ts-views__form fieldset {
display: grid;
gap: 0.25rem;
margin: 0;
padding: 0;
border: 0;
}
.ts-views__form fieldset + pi-text-field {
margin-top: 0.75rem;
}
.ts-views__form legend {
margin-bottom: 0.25rem;
font-size: 0.875rem;
font-weight: 600;
span {
color: var(--ts-muted);
font-weight: 400;
}
}
@media (max-width: 640px) {
.ts-views__row {
grid-template-columns: 5rem minmax(0, 1fr) 3rem;
}
.ts-views__who {
display: none;
}
}
`,
})
export class MyViewsComponent implements OnInit {
protected readonly views = inject(MyViewsFacade);
protected readonly projects = inject(ProjectsFacade);
private readonly drawer = inject(DrawerService);
private readonly form = viewChild.required<TemplateRef<unknown>>('form');
protected readonly categories = CATEGORIES;
protected readonly priorities = PRIORITY_ORDER;
/* The form */
protected readonly name = signal('');
protected readonly projectIds = signal<string[]>([]);
protected readonly assignedToMe = signal(false);
protected readonly watching = signal(false);
protected readonly categoriesPicked = signal<StatusCategory[]>([]);
protected readonly prioritiesPicked = signal<IssuePriority[]>([]);
protected readonly bugsOnly = signal(false);
protected readonly overdue = signal(false);
protected readonly search = signal('');
ngOnInit(): void {
this.views.open();
}
protected readonly pick = toggle;
protected openForm(): void {
this.name.set('');
this.projectIds.set([]);
this.assignedToMe.set(false);
this.watching.set(false);
this.categoriesPicked.set([]);
this.prioritiesPicked.set([]);
this.bugsOnly.set(false);
this.overdue.set(false);
this.search.set('');
this.drawer.open('Nueva vista de mi trabajo', this.form());
}
protected save(): void {
const name = this.name().trim();
if (!name) return;
const criteria: MyViewCriteria = {
projectIds: this.projectIds(),
assignedToMe: this.assignedToMe(),
watching: this.watching(),
categories: this.categoriesPicked(),
priorities: this.prioritiesPicked(),
bugsOnly: this.bugsOnly(),
overdue: this.overdue(),
search: this.search().trim() || null,
};
this.views.save(name, criteria);
this.drawer.closeAll();
}
/** What the picked view narrows to, in words. */
protected readonly summary = computed(() => {
const c = this.views.selected()?.criteria;
if (!c) return '';
const keys = (c.projectIds ?? [])
.map((id) => this.projects.projects().find((p) => p.id === id)?.key)
.filter(Boolean);
return [
keys.length ? keys.join(', ') : 'todos mis proyectos',
c.assignedToMe ? 'asignados a mí' : null,
c.watching ? 'que sigo' : null,
c.categories?.length
? c.categories.map((x) => CATEGORY[x].label.toLowerCase()).join('/')
: 'abiertos',
c.priorities?.length
? c.priorities.map((p) => PRIORITY[p].label.toLowerCase()).join('/')
: null,
c.bugsOnly ? 'solo bugs' : null,
c.overdue ? 'vencidos' : null,
c.search ? `«${c.search}»` : null,
]
.filter(Boolean)
.join(' · ');
});
protected readonly categoryLabel = (c: StatusCategory) => CATEGORY[c].label;
protected readonly priorityLabel = (p: IssuePriority) => PRIORITY[p].label;
protected readonly keyOf = projectKeyOf;
protected readonly projectToneOf = (issueKey: string) =>
projectTone(projectKeyOf(issueKey));
/** Each ticket with its own project's names and colours. */
private readonly workflows = computed(
() =>
new Map(
this.projects
.projects()
.map((p) => [p.id, new Workflow(p.settings)] as const),
),
);
private workflowOf(issue: IssueSummary): Workflow {
return this.workflows().get(issue.projectId) ?? new Workflow();
}
protected readonly statusLabel = (issue: IssueSummary) =>
statusLook(this.workflowOf(issue), issue.status).label;
protected readonly statusTone = (issue: IssueSummary) =>
statusLook(this.workflowOf(issue), issue.status).tone;
protected readonly typeTone = (issue: IssueSummary) =>
typeLook(this.workflowOf(issue), issue.type).tone;
}