feat: «Mi día», the few tickets I pick for today
A home panel with the tickets picked for today (10 at most), crossed out
as they get done and counted («1 de 3 terminados»), my open tickets to
add with one click, and what was left unfinished on my last day to carry
over. «☀ Mi día» on a ticket adds it or takes it out. The day is the
person's own (YYYY-MM-DD from the browser); private, no history (Mongo
`my_days`).
API: myDay(day), addToMyDay, removeFromMyDay; GET /api/me/day,
PUT/DELETE /api/me/day/{issue}. MCP: my_day.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ export * from './lib/models/enums';
|
||||
export * from './lib/models/models';
|
||||
export * from './lib/models/bulk';
|
||||
export * from './lib/models/reminders';
|
||||
export * from './lib/models/my-day';
|
||||
export * from './lib/models/inputs';
|
||||
export * from './lib/models/workflow';
|
||||
export * from './lib/ports/gateways';
|
||||
|
||||
11
libs/web/tracker/domain/src/lib/models/my-day.ts
Normal file
11
libs/web/tracker/domain/src/lib/models/my-day.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IssueSummary } from './models';
|
||||
|
||||
/** «Mi día»: the tickets I picked for today. */
|
||||
export interface MyDay {
|
||||
/** `YYYY-MM-DD` in my time zone. */
|
||||
day: string;
|
||||
/** In order, done ones included. */
|
||||
issues: IssueSummary[];
|
||||
/** What was left unfinished on my latest earlier day. */
|
||||
leftover: { day: string; issues: IssueSummary[] } | null;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IssuePriority } from '../models/enums';
|
||||
import { MyDay } from '../models/my-day';
|
||||
import { Home } from '../models/home';
|
||||
import { PortfolioProject, ProjectInsights } from '../models/insights';
|
||||
import {
|
||||
@@ -157,6 +158,10 @@ export abstract class IssuesGateway {
|
||||
note: string | null,
|
||||
): Observable<void>;
|
||||
abstract clearReminder(issueId: string): Observable<void>;
|
||||
/** «Mi día» on `day` (today, `YYYY-MM-DD` in my time zone). */
|
||||
abstract myDay(day: string): Observable<MyDay>;
|
||||
abstract addToMyDay(day: string, issueIds: string[]): Observable<void>;
|
||||
abstract removeFromMyDay(day: string, issueId: string): Observable<void>;
|
||||
/**
|
||||
* Decisions (comments marked as such) of all my projects by their text,
|
||||
* most recent first: here and not in CommentsGateway, for the palette.
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
IssueListFilter,
|
||||
IssueSummary,
|
||||
LinkIssuesInput,
|
||||
MyDay,
|
||||
MyIssuesFilter,
|
||||
mergeBulkResults,
|
||||
UpdateIssueInput,
|
||||
@@ -53,6 +54,9 @@ import {
|
||||
MyPinsDocument,
|
||||
FollowTreeDocument,
|
||||
SetReminderDocument,
|
||||
MyDayDocument,
|
||||
AddToMyDayDocument,
|
||||
RemoveFromMyDayDocument,
|
||||
ClearReminderDocument,
|
||||
UnfollowTreeDocument,
|
||||
SearchDecisionsDocument,
|
||||
@@ -232,6 +236,31 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
myDay(day: string): Observable<MyDay> {
|
||||
return this.gql.query(MyDayDocument, { day }).pipe(
|
||||
map(({ myDay }) => ({
|
||||
day: myDay.day,
|
||||
issues: myDay.issues.map(toIssueSummary),
|
||||
leftover: myDay.leftover && {
|
||||
day: myDay.leftover.day,
|
||||
issues: myDay.leftover.issues.map(toIssueSummary),
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
addToMyDay(day: string, issueIds: string[]): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(AddToMyDayDocument, { day, issueIds })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
removeFromMyDay(day: string, issueId: string): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(RemoveFromMyDayDocument, { day, issueId })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
clearReminder(issueId: string): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(ClearReminderDocument, { issueId })
|
||||
|
||||
@@ -1155,6 +1155,91 @@ export type MyPinsQuery = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type MyDayQueryVariables = Exact<{
|
||||
day: string;
|
||||
}>;
|
||||
|
||||
export type MyDayQuery = {
|
||||
myDay: {
|
||||
day: string;
|
||||
issues: Array<{
|
||||
id: string;
|
||||
projectId: string;
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
priority: IssuePriority;
|
||||
labels: Array<string>;
|
||||
commentCount: number;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
votes: number;
|
||||
voted: boolean;
|
||||
rank: string;
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||
customFields: Array<{ fieldId: string; value: string }>;
|
||||
}>;
|
||||
leftover: {
|
||||
day: string;
|
||||
issues: Array<{
|
||||
id: string;
|
||||
projectId: string;
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
priority: IssuePriority;
|
||||
labels: Array<string>;
|
||||
commentCount: number;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
votes: number;
|
||||
voted: boolean;
|
||||
rank: string;
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||
customFields: Array<{ fieldId: string; value: string }>;
|
||||
}>;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type AddToMyDayMutationVariables = Exact<{
|
||||
day: string;
|
||||
issueIds: Array<string> | string;
|
||||
}>;
|
||||
|
||||
export type AddToMyDayMutation = { addToMyDay: boolean };
|
||||
|
||||
export type RemoveFromMyDayMutationVariables = Exact<{
|
||||
day: string;
|
||||
issueId: string;
|
||||
}>;
|
||||
|
||||
export type RemoveFromMyDayMutation = { removeFromMyDay: boolean };
|
||||
|
||||
export type SetReminderMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
at: string;
|
||||
@@ -7507,6 +7592,346 @@ export const MyPinsDocument = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<MyPinsQuery, MyPinsQueryVariables>;
|
||||
export const MyDayDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'MyDay' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'day' } },
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'String' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'myDay' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'day' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'day' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'day' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'issues' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'leftover' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'day' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'issues' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: {
|
||||
kind: 'Name',
|
||||
value: 'IssueSummaryFields',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Issue' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'commentCount' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'assignee' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'release' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'sprint' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'parent' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'blockedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'votes' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'voted' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<MyDayQuery, MyDayQueryVariables>;
|
||||
export const AddToMyDayDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'AddToMyDay' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'day' } },
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'String' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueIds' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'ListType',
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'ID' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'addToMyDay' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'day' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'day' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueIds' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueIds' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<AddToMyDayMutation, AddToMyDayMutationVariables>;
|
||||
export const RemoveFromMyDayDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'RemoveFromMyDay' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'day' } },
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'String' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'removeFromMyDay' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'day' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'day' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
RemoveFromMyDayMutation,
|
||||
RemoveFromMyDayMutationVariables
|
||||
>;
|
||||
export const SetReminderDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
|
||||
@@ -101,6 +101,29 @@ query MyPins {
|
||||
}
|
||||
}
|
||||
|
||||
query MyDay($day: String!) {
|
||||
myDay(day: $day) {
|
||||
day
|
||||
issues {
|
||||
...IssueSummaryFields
|
||||
}
|
||||
leftover {
|
||||
day
|
||||
issues {
|
||||
...IssueSummaryFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation AddToMyDay($day: String!, $issueIds: [ID!]!) {
|
||||
addToMyDay(day: $day, issueIds: $issueIds)
|
||||
}
|
||||
|
||||
mutation RemoveFromMyDay($day: String!, $issueId: ID!) {
|
||||
removeFromMyDay(day: $day, issueId: $issueId)
|
||||
}
|
||||
|
||||
mutation SetReminder($issueId: ID!, $at: String!, $note: String) {
|
||||
setReminder(issueId: $issueId, at: $at, note: $note) {
|
||||
at
|
||||
|
||||
@@ -52,6 +52,8 @@ import {
|
||||
IssuePriority,
|
||||
isTyping,
|
||||
POINT_SCALE,
|
||||
IssuesGateway,
|
||||
localDay,
|
||||
ReleaseStatus,
|
||||
ReminderChoice,
|
||||
reminderChoices,
|
||||
@@ -209,6 +211,21 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
(execute)="facade.setWatching(!facade.watching())"
|
||||
/>
|
||||
}
|
||||
@if (project.myRole() && inMyDay() !== null) {
|
||||
<pi-icon-button
|
||||
icon="sun"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
[text]="inMyDay() ? 'En mi día' : 'Mi día'"
|
||||
[attr.aria-pressed]="inMyDay()"
|
||||
[attr.title]="
|
||||
inMyDay()
|
||||
? 'Quitar de lo que quiero sacar hoy'
|
||||
: 'Añadir a lo que quiero sacar hoy (en el inicio)'
|
||||
"
|
||||
(execute)="toggleMyDay(issue)"
|
||||
/>
|
||||
}
|
||||
@if (project.myRole()) {
|
||||
<pi-icon-button
|
||||
icon="bell"
|
||||
@@ -1669,6 +1686,18 @@ export class IssuePage {
|
||||
if (id && this.facade.comments().some((c) => c.id === id))
|
||||
untracked(() => setTimeout(() => this.goToComment(id)));
|
||||
});
|
||||
// Whether it is in today's «Mi día», once per ticket.
|
||||
effect(() => {
|
||||
const issueId = this.openIssueId();
|
||||
if (!issueId) return;
|
||||
untracked(() =>
|
||||
this.issuesGateway.myDay(localDay(new Date())).subscribe({
|
||||
next: (myDay) =>
|
||||
this.inMyDay.set(myDay.issues.some((i) => i.id === issueId)),
|
||||
error: () => this.inMyDay.set(null),
|
||||
}),
|
||||
);
|
||||
});
|
||||
// Writing an answer or a comment: the others see it as I type.
|
||||
effect(() => {
|
||||
if (this.replyBody().trim())
|
||||
@@ -1735,6 +1764,34 @@ export class IssuePage {
|
||||
this.parentKey.set(null);
|
||||
}
|
||||
|
||||
/** Whether it is in today's «Mi día» (null: not known yet). */
|
||||
protected readonly inMyDay = signal<boolean | null>(null);
|
||||
/** Changes with the ticket, not with each reload of it. */
|
||||
private readonly openIssueId = computed(() => this.facade.issue()?.id);
|
||||
private readonly issuesGateway = inject(IssuesGateway);
|
||||
|
||||
protected toggleMyDay(issue: Issue): void {
|
||||
const day = localDay(new Date());
|
||||
const on = !this.inMyDay();
|
||||
this.inMyDay.set(on);
|
||||
(on
|
||||
? this.issuesGateway.addToMyDay(day, [issue.id])
|
||||
: this.issuesGateway.removeFromMyDay(day, issue.id)
|
||||
).subscribe({
|
||||
next: () =>
|
||||
this.toast.success(
|
||||
on ? `${issue.key} está en tu día` : `${issue.key} fuera de tu día`,
|
||||
),
|
||||
error: (error) => {
|
||||
this.inMyDay.set(!on);
|
||||
this.toast.error(
|
||||
(error as { message?: string })?.message?.split('\n')[0] ||
|
||||
'No se pudo cambiar',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected openReminder(): void {
|
||||
const current = this.facade.issue()?.myReminder;
|
||||
this.reminderChoices.set(reminderChoices());
|
||||
|
||||
@@ -54,6 +54,7 @@ import { WeekAgendaComponent } from '../ui/home/week-agenda';
|
||||
import { HomeKpisComponent } from '../ui/home/kpis';
|
||||
import { MyViewsComponent } from '../ui/my-views';
|
||||
import { PinnedComponent } from '../ui/home/pinned';
|
||||
import { MyDayComponent } from '../ui/home/my-day';
|
||||
|
||||
/** How often the home page refreshes its reading while open. */
|
||||
const REFRESH_MS = 60_000;
|
||||
@@ -77,6 +78,7 @@ const REFRESH_MS = 60_000;
|
||||
HomeKpisComponent,
|
||||
MyViewsComponent,
|
||||
PinnedComponent,
|
||||
MyDayComponent,
|
||||
PiActionButtonComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiSkeletonComponent,
|
||||
@@ -158,6 +160,7 @@ const REFRESH_MS = 60_000;
|
||||
|
||||
@if (facade.projects().length) {
|
||||
<ts-pinned />
|
||||
<ts-my-day />
|
||||
@if (myHome.home(); as h) {
|
||||
<ts-home-kpis
|
||||
[mine]="home.mineTotal()"
|
||||
@@ -275,6 +278,7 @@ const REFRESH_MS = 60_000;
|
||||
}
|
||||
ts-first-steps,
|
||||
ts-pinned,
|
||||
ts-my-day,
|
||||
ts-home-kpis,
|
||||
ts-week-agenda,
|
||||
ts-my-views {
|
||||
|
||||
315
libs/web/tracker/presenters/src/lib/ui/home/my-day.ts
Normal file
315
libs/web/tracker/presenters/src/lib/ui/home/my-day.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ToastService } from '@task-stream/web/shared/ui';
|
||||
import {
|
||||
IssuesGateway,
|
||||
IssueSummary,
|
||||
localDay,
|
||||
MyDay,
|
||||
StatusCategory,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { catchError, Observable, of } from 'rxjs';
|
||||
|
||||
const OPEN = [
|
||||
StatusCategory.TODO,
|
||||
StatusCategory.IN_PROGRESS,
|
||||
StatusCategory.QA,
|
||||
];
|
||||
|
||||
const finished = (issue: IssueSummary) =>
|
||||
issue.statusCategory === StatusCategory.DONE ||
|
||||
issue.statusCategory === StatusCategory.CANCELLED;
|
||||
|
||||
/**
|
||||
* «Mi día»: the few tickets I pick for today, crossed out as they get done,
|
||||
* with my open ones to add and what was left from my last day.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-my-day',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: {
|
||||
class: 'ts-panel',
|
||||
role: 'region',
|
||||
'aria-labelledby': 'my-day-title',
|
||||
},
|
||||
styleUrl: './home-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="my-day-title">☀ Mi día</h2>
|
||||
@if (picked().length) {
|
||||
<span class="ts-muted"
|
||||
>{{ done() }} de {{ picked().length }} terminados</span
|
||||
>
|
||||
}
|
||||
</header>
|
||||
@if (myDay(); as day) {
|
||||
@if (day.leftover; as left) {
|
||||
<p class="ts-my-day__leftover">
|
||||
{{
|
||||
left.issues.length === 1
|
||||
? 'Quedó 1 ticket sin terminar'
|
||||
: 'Quedaron ' + left.issues.length + ' tickets sin terminar'
|
||||
}}
|
||||
{{ dayLabel(left.day) }}.
|
||||
<button
|
||||
type="button"
|
||||
class="ts-my-day__link"
|
||||
(click)="carryOver(left.issues)"
|
||||
>
|
||||
{{ left.issues.length === 1 ? 'Pasarlo a hoy' : 'Pasarlos a hoy' }}
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
@if (picked().length) {
|
||||
<ul class="ts-issues ts-my-day__list">
|
||||
@for (issue of picked(); track issue.id) {
|
||||
<li [class.is-done]="isDone(issue)">
|
||||
<span class="ts-my-day__check" aria-hidden="true">{{
|
||||
isDone(issue) ? '✓' : '○'
|
||||
}}</span>
|
||||
<a
|
||||
class="ts-title"
|
||||
[routerLink]="['/p', projectOf(issue.key), 'issues', issue.key]"
|
||||
><span class="ts-key">{{ issue.key }}</span>
|
||||
{{ issue.title }}</a
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-my-day__remove"
|
||||
[attr.aria-label]="'Quitar ' + issue.key + ' de mi día'"
|
||||
title="Quitar de mi día"
|
||||
(click)="remove(issue)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (allDone()) {
|
||||
<p class="ts-empty">Todo hecho por hoy. 🎉</p>
|
||||
}
|
||||
} @else {
|
||||
<p class="ts-empty">
|
||||
Elige lo que quieres sacar hoy: tres o cuatro tickets mejor que diez.
|
||||
</p>
|
||||
}
|
||||
@if (suggestions().length && picked().length < 10) {
|
||||
<p class="ts-my-day__subtitle">Tus tickets abiertos</p>
|
||||
<ul class="ts-my-day__suggestions">
|
||||
@for (issue of suggestions(); track issue.id) {
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-my-day__add"
|
||||
[attr.title]="'Añadir ' + issue.key + ' a mi día'"
|
||||
(click)="add([issue])"
|
||||
>
|
||||
+ <span class="ts-key">{{ issue.key }}</span>
|
||||
{{ issue.title }}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-my-day__list li {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
&.is-done .ts-title {
|
||||
color: var(--ts-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
.ts-my-day__check {
|
||||
width: 1.25rem;
|
||||
color: var(--ts-c-green);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
.ts-key {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.ts-my-day__leftover {
|
||||
margin: 0 0 0.875rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: color-mix(in srgb, var(--ts-c-amber) 14%, transparent);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.ts-my-day__subtitle {
|
||||
margin: 1rem 0 0.5rem;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ts-my-day__suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
/* Plain buttons, not @pi/ui's sized ones. */
|
||||
.ts-my-day__add,
|
||||
.ts-my-day__remove,
|
||||
.ts-my-day__link {
|
||||
height: auto !important;
|
||||
min-width: 0 !important;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ts-my-day__add {
|
||||
max-width: 20rem;
|
||||
overflow: hidden;
|
||||
padding: 0.25rem 0.75rem !important;
|
||||
border: 1px dashed var(--ts-line-strong) !important;
|
||||
border-radius: 999px !important;
|
||||
background: transparent !important;
|
||||
color: var(--ts-ink-soft);
|
||||
font-size: 0.8125rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--ts-brand);
|
||||
color: var(--ts-ink);
|
||||
}
|
||||
}
|
||||
.ts-my-day__remove {
|
||||
width: 1.75rem !important;
|
||||
height: 1.75rem !important;
|
||||
padding: 0 !important;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--ts-muted);
|
||||
font-size: 1.125rem;
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--ts-sunken);
|
||||
color: var(--ts-ink);
|
||||
}
|
||||
}
|
||||
.ts-my-day__link {
|
||||
padding: 0 !important;
|
||||
border: 0;
|
||||
background: none !important;
|
||||
color: var(--ts-brand);
|
||||
font-weight: 600;
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MyDayComponent {
|
||||
private readonly issues = inject(IssuesGateway);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly day = localDay(new Date());
|
||||
protected readonly myDay = signal<MyDay | null>(null);
|
||||
private readonly mine = signal<IssueSummary[]>([]);
|
||||
|
||||
protected readonly picked = computed(() => this.myDay()?.issues ?? []);
|
||||
protected readonly done = computed(
|
||||
() => this.picked().filter(finished).length,
|
||||
);
|
||||
protected readonly allDone = computed(
|
||||
() => this.picked().length > 0 && this.done() === this.picked().length,
|
||||
);
|
||||
/** My open tickets not picked yet (nor offered as left over). */
|
||||
protected readonly suggestions = computed(() => {
|
||||
const taken = new Set([
|
||||
...this.picked().map((i) => i.id),
|
||||
...(this.myDay()?.leftover?.issues ?? []).map((i) => i.id),
|
||||
]);
|
||||
return this.mine()
|
||||
.filter((i) => !taken.has(i.id))
|
||||
.slice(0, 6);
|
||||
});
|
||||
protected readonly isDone = finished;
|
||||
|
||||
constructor() {
|
||||
this.reload();
|
||||
this.issues
|
||||
.mine({ assignedToMe: true, categories: OPEN, limit: 20 })
|
||||
.pipe(
|
||||
catchError(() => of({ items: [], total: 0 })),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((page) => this.mine.set(page.items));
|
||||
}
|
||||
|
||||
protected projectOf(key: string): string {
|
||||
return key.slice(0, key.lastIndexOf('-'));
|
||||
}
|
||||
|
||||
protected dayLabel(day: string): string {
|
||||
const yesterday = localDay(new Date(Date.now() - 86_400_000));
|
||||
if (day === yesterday) return 'ayer';
|
||||
return (
|
||||
'el ' +
|
||||
new Date(`${day}T12:00:00`).toLocaleDateString('es', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
protected add(issues: IssueSummary[]): void {
|
||||
this.run(
|
||||
this.issues.addToMyDay(
|
||||
this.day,
|
||||
issues.map((i) => i.id),
|
||||
),
|
||||
'No se pudo añadir',
|
||||
);
|
||||
}
|
||||
|
||||
protected carryOver(issues: IssueSummary[]): void {
|
||||
this.add(issues);
|
||||
}
|
||||
|
||||
protected remove(issue: IssueSummary): void {
|
||||
this.run(
|
||||
this.issues.removeFromMyDay(this.day, issue.id),
|
||||
'No se pudo quitar',
|
||||
);
|
||||
}
|
||||
|
||||
private run(command$: Observable<void>, failure: string): void {
|
||||
command$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: () => this.reload(),
|
||||
error: (error) =>
|
||||
this.toast.error(
|
||||
(error as { message?: string })?.message?.split('\n')[0] || failure,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
private reload(): void {
|
||||
this.issues
|
||||
.myDay(this.day)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (myDay) => this.myDay.set(myDay),
|
||||
error: () =>
|
||||
this.myDay.set({ day: this.day, issues: [], leftover: null }),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user