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:
2026-10-20 20:18:10 +02:00
parent b88296c22b
commit dfb5f973f4
31 changed files with 2183 additions and 50 deletions

View File

@@ -46,6 +46,9 @@ export * from './lib/tree-follows.service';
export * from './lib/reminders/issue-reminders';
export * from './lib/ports/issue-reminders.repository';
export * from './lib/issue-reminders.service';
export * from './lib/my-day/my-day';
export * from './lib/ports/my-days.repository';
export * from './lib/my-day.service';
export * from './lib/tracker-application.module';
export * from './lib/ports/presence.store';
export * from './lib/presence.service';

View File

@@ -0,0 +1,32 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import {
AddToMyDayCommand,
GetMyDayQuery,
MyDay,
RemoveFromMyDayCommand,
} from './my-day/my-day';
/** «Mi día»: the tickets I picked for today. */
@Injectable()
export class MyDayService {
constructor(
private readonly commands: CommandRunner,
private readonly queryBus: QueryBus,
) {}
get(userId: string, day: string): Promise<MyDay> {
return this.queryBus.execute(new GetMyDayQuery(userId, day));
}
add(userId: string, day: string, issueIds: string[]): Promise<void> {
return this.commands.execute(new AddToMyDayCommand(userId, day, issueIds));
}
remove(userId: string, day: string, issueId: string): Promise<void> {
return this.commands.execute(
new RemoveFromMyDayCommand(userId, day, issueId),
);
}
}

View File

@@ -0,0 +1,145 @@
import {
CommandBus,
CqrsModule,
IQueryHandler,
QueryBus,
QueryHandler,
} from '@nestjs/cqrs';
import { Test } from '@nestjs/testing';
import {
BusinessRuleError,
NotFoundError,
ValidationError,
} from '@task-stream/api/shared/domain';
import {
IssueReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { MyDayRecord, MyDaysRepository } from '../ports/my-days.repository';
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
import {
AddToMyDayCommand,
GetMyDayQuery,
MAX_MY_DAY,
MY_DAY,
MyDay,
RemoveFromMyDayCommand,
} from './my-day';
class InMemoryDays extends MyDaysRepository {
rows: MyDayRecord[] = [];
async find(userId: string, day: string) {
return this.rows.find((r) => r.userId === userId && r.day === day) ?? null;
}
async latestBefore(userId: string, day: string) {
return (
this.rows
.filter((r) => r.userId === userId && r.day < day)
.sort((a, b) => b.day.localeCompare(a.day))[0] ?? null
);
}
async save(record: MyDayRecord) {
this.rows = [
...this.rows.filter(
(r) => !(r.userId === record.userId && r.day === record.day),
),
record,
];
}
}
/** Ana reads i0…i11; i3 is done. */
const issues = new Map<string, IssueReadModel>();
@QueryHandler(GetIssuesByIdsQuery)
class FakeIssuesByIds implements IQueryHandler<GetIssuesByIdsQuery> {
async execute(query: GetIssuesByIdsQuery) {
return query.viewerId === 'ana'
? query.ids.flatMap((id) => issues.get(id) ?? [])
: [];
}
}
const day = (offset: number) =>
new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10);
async function setup() {
issues.clear();
for (let i = 0; i < 12; i++)
issues.set(`i${i}`, {
id: `i${i}`,
key: `WEB-${i}`,
statusCategory: i === 3 ? StatusCategory.DONE : StatusCategory.TODO,
trashedAt: null,
} as IssueReadModel);
const rows = new InMemoryDays();
const moduleRef = await Test.createTestingModule({
imports: [CqrsModule.forRoot()],
providers: [
...MY_DAY,
FakeIssuesByIds,
{ provide: MyDaysRepository, useValue: rows },
],
}).compile();
await moduleRef.init();
const commands = moduleRef.get(CommandBus);
const queries = moduleRef.get(QueryBus);
return {
rows,
commands,
keys: async (d: string) => {
const myDay: MyDay = await queries.execute(new GetMyDayQuery('ana', d));
return {
issues: myDay.issues.map((i) => i.key),
leftover: myDay.leftover?.issues.map((i) => i.key) ?? null,
};
},
};
}
describe('Mi día', () => {
it('keeps the tickets picked for today, in order, once each', async () => {
const { commands, keys } = await setup();
await commands.execute(new AddToMyDayCommand('ana', day(0), ['i1', 'i2']));
await commands.execute(new AddToMyDayCommand('ana', day(0), ['i2', 'i3']));
await commands.execute(new RemoveFromMyDayCommand('ana', day(0), 'i1'));
expect((await keys(day(0))).issues).toEqual(['WEB-2', 'WEB-3']);
});
it(`takes ${MAX_MY_DAY} at most, only readable tickets, only around today`, async () => {
const { commands } = await setup();
const many = Array.from({ length: MAX_MY_DAY + 1 }, (_, i) => `i${i}`);
await expect(
commands.execute(new AddToMyDayCommand('ana', day(0), many)),
).rejects.toBeInstanceOf(BusinessRuleError);
await expect(
commands.execute(new AddToMyDayCommand('ana', day(0), ['nope'])),
).rejects.toBeInstanceOf(NotFoundError);
await expect(
commands.execute(new AddToMyDayCommand('ana', day(5), ['i1'])),
).rejects.toBeInstanceOf(ValidationError);
await expect(
commands.execute(new AddToMyDayCommand('ana', '20-10-2026', ['i1'])),
).rejects.toBeInstanceOf(ValidationError);
});
it('offers what was left unfinished on the last day, but not the done ones', async () => {
const { commands, rows, keys } = await setup();
rows.rows.push({
userId: 'ana',
day: day(-1),
issueIds: ['i1', 'i3', 'i4'],
updatedAt: '',
});
expect(await keys(day(0))).toEqual({
issues: [],
leftover: ['WEB-1', 'WEB-4'],
});
// Carried over: no longer offered.
await commands.execute(new AddToMyDayCommand('ana', day(0), ['i1', 'i4']));
expect(await keys(day(0))).toEqual({
issues: ['WEB-1', 'WEB-4'],
leftover: null,
});
});
});

View File

@@ -0,0 +1,194 @@
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryBus,
QueryHandler,
} from '@nestjs/cqrs';
import {
BusinessRuleError,
NotFoundError,
assertThat,
} from '@task-stream/api/shared/domain';
import {
IssueReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { MyDayRecord, MyDaysRepository } from '../ports/my-days.repository';
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
/*
* «Mi día»: the few tickets someone picks each morning for today, and at
* the end what got done. The day is the person's own (`YYYY-MM-DD` from
* their browser). Private, no history.
*/
export const MAX_MY_DAY = 10;
/** How far back what was left counts as «from before». */
const CARRY_OVER_DAYS = 7;
const DAY_FORMAT = /^\d{4}-\d{2}-\d{2}$/;
const DAY_MS = 86_400_000;
const finished = (issue: IssueReadModel) =>
issue.statusCategory === StatusCategory.DONE ||
issue.statusCategory === StatusCategory.CANCELLED;
/** A day of the person's: well formed and near today (time zones apart). */
export function assertDay(day: string, now = Date.now()): void {
const at = Date.parse(`${day}T12:00:00Z`);
assertThat(DAY_FORMAT.test(day) && !Number.isNaN(at), 'A day is YYYY-MM-DD');
assertThat(
Math.abs(at - now) <= 2 * DAY_MS,
'Only today can be planned (in your time zone)',
);
}
export interface MyDay {
day: string;
/** Picked for the day, in order (done ones included). */
issues: IssueReadModel[];
/** What was left unfinished on the latest earlier day, if recent. */
leftover: { day: string; issues: IssueReadModel[] } | null;
}
export class AddToMyDayCommand extends Command<void> {
constructor(
public readonly userId: string,
public readonly day: string,
/** Several at once: carrying over what was left. */
public readonly issueIds: string[],
) {
super();
}
}
export class RemoveFromMyDayCommand extends Command<void> {
constructor(
public readonly userId: string,
public readonly day: string,
public readonly issueId: string,
) {
super();
}
}
export class GetMyDayQuery extends Query<MyDay> {
constructor(
public readonly userId: string,
public readonly day: string,
) {
super();
}
}
@CommandHandler(AddToMyDayCommand)
export class AddToMyDayCommandHandler
implements ICommandHandler<AddToMyDayCommand>
{
constructor(
private readonly days: MyDaysRepository,
private readonly queryBus: QueryBus,
) {}
async execute(command: AddToMyDayCommand): Promise<void> {
assertDay(command.day);
const wanted = [...new Set(command.issueIds)];
const readable: IssueReadModel[] = await this.queryBus.execute(
new GetIssuesByIdsQuery(command.userId, wanted),
);
const ok = new Set(readable.filter((i) => !i.trashedAt).map((i) => i.id));
// One I cannot read does not exist, as far as I know.
const missing = wanted.find((id) => !ok.has(id));
if (missing) throw new NotFoundError('Issue', missing);
const current = await this.days.find(command.userId, command.day);
const issueIds = [
...(current?.issueIds ?? []),
...wanted.filter((id) => !current?.issueIds.includes(id)),
];
if (issueIds.length > MAX_MY_DAY)
throw new BusinessRuleError(
`Mi día takes ${MAX_MY_DAY} tickets at most: finish or take some out first`,
);
await this.days.save({
userId: command.userId,
day: command.day,
issueIds,
updatedAt: new Date().toISOString(),
});
}
}
@CommandHandler(RemoveFromMyDayCommand)
export class RemoveFromMyDayCommandHandler
implements ICommandHandler<RemoveFromMyDayCommand>
{
constructor(private readonly days: MyDaysRepository) {}
async execute(command: RemoveFromMyDayCommand): Promise<void> {
assertDay(command.day);
const current = await this.days.find(command.userId, command.day);
if (!current?.issueIds.includes(command.issueId)) return;
await this.days.save({
...current,
issueIds: current.issueIds.filter((id) => id !== command.issueId),
updatedAt: new Date().toISOString(),
});
}
}
@QueryHandler(GetMyDayQuery)
export class GetMyDayQueryHandler
implements IQueryHandler<GetMyDayQuery, MyDay>
{
constructor(
private readonly days: MyDaysRepository,
private readonly queryBus: QueryBus,
) {}
async execute(query: GetMyDayQuery): Promise<MyDay> {
assertDay(query.day);
const [today, before] = await Promise.all([
this.days.find(query.userId, query.day),
this.days.latestBefore(query.userId, query.day),
]);
const recent =
before &&
Date.parse(`${query.day}T00:00:00Z`) -
Date.parse(`${before.day}T00:00:00Z`) <=
CARRY_OVER_DAYS * DAY_MS
? before
: null;
const ids = [
...new Set([...(today?.issueIds ?? []), ...(recent?.issueIds ?? [])]),
];
const found: IssueReadModel[] = ids.length
? await this.queryBus.execute(new GetIssuesByIdsQuery(query.userId, ids))
: [];
const byId = new Map(
found.filter((i) => !i.trashedAt).map((i) => [i.id, i]),
);
const pick = (record: MyDayRecord | null) =>
(record?.issueIds ?? []).flatMap((id) => byId.get(id) ?? []);
const issues = pick(today);
const leftover = pick(recent).filter(
(i) => !finished(i) && !today?.issueIds.includes(i.id),
);
return {
day: query.day,
issues,
leftover:
recent && leftover.length
? { day: recent.day, issues: leftover }
: null,
};
}
}
export const MY_DAY = [
AddToMyDayCommandHandler,
RemoveFromMyDayCommandHandler,
GetMyDayQueryHandler,
];

View File

@@ -0,0 +1,19 @@
/** The tickets someone picked for one day of theirs. */
export interface MyDayRecord {
userId: string;
/** `YYYY-MM-DD` in the person's own time zone. */
day: string;
/** In the order they were picked. */
issueIds: string[];
updatedAt: string;
}
export abstract class MyDaysRepository {
abstract find(userId: string, day: string): Promise<MyDayRecord | null>;
/** The latest day before `day`, if any (to carry over what was left). */
abstract latestBefore(
userId: string,
day: string,
): Promise<MyDayRecord | null>;
abstract save(record: MyDayRecord): Promise<void>;
}

View File

@@ -39,6 +39,8 @@ import { PINS } from './saved-filters/pins';
import { ALERT_HANDLERS } from './alerts/alerts';
import { TREE_FOLLOWS } from './follows/tree-follows';
import { ISSUE_REMINDERS } from './reminders/issue-reminders';
import { MY_DAY } from './my-day/my-day';
import { MyDayService } from './my-day.service';
import { IssueRemindersService } from './issue-reminders.service';
import { TreeFollowsService } from './tree-follows.service';
import { AlertsService } from './alerts.service';
@@ -90,6 +92,7 @@ const SERVICES = [
AlertsService,
TreeFollowsService,
IssueRemindersService,
MyDayService,
SprintsService,
GitService,
WebhooksService,
@@ -124,6 +127,7 @@ const SERVICES = [
...ALERT_HANDLERS,
...TREE_FOLLOWS,
...ISSUE_REMINDERS,
...MY_DAY,
...GIT_HANDLERS,
...WEBHOOK_HANDLERS,
...TRANSFER_HANDLERS,

View File

@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
MyDayRecord,
MyDaysRepository,
} from '@task-stream/api/tracker/application';
import { Model } from 'mongoose';
import { ReadModels } from './read-model.schemas';
type Stored = MyDayRecord & { _id?: unknown };
const strip = ({ _id: _ignored, ...record }: Stored): MyDayRecord => ({
...record,
// Mongo leaves an emptied list out.
issueIds: record.issueIds ?? [],
});
@Injectable()
export class MongoMyDaysRepository extends MyDaysRepository {
constructor(
@InjectModel(ReadModels.MyDay) private readonly days: Model<Stored>,
) {
super();
}
async find(userId: string, day: string): Promise<MyDayRecord | null> {
const doc = await this.days.findOne({ userId, day }).lean<Stored>();
return doc ? strip(doc) : null;
}
async latestBefore(userId: string, day: string): Promise<MyDayRecord | null> {
const doc = await this.days
.findOne({ userId, day: { $lt: day } })
.sort({ day: -1 })
.lean<Stored>();
return doc ? strip(doc) : null;
}
async save(record: MyDayRecord): Promise<void> {
await this.days.updateOne(
{ userId: record.userId, day: record.day },
{ $set: record },
{ upsert: true },
);
}
}

View File

@@ -111,6 +111,7 @@ export const ReadModels = {
AlertTrace: 'AlertTrace',
TreeFollow: 'TreeFollow',
IssueReminder: 'IssueReminder',
MyDay: 'MyDay',
Sprint: 'SprintReadModel',
GitIntegration: 'GitIntegration',
OutgoingWebhook: 'OutgoingWebhook',
@@ -385,6 +386,15 @@ export const IssueReminderSchema = SchemaFactory.createForClass(
IssueReminderSchema.index({ sentAt: 1, at: 1 });
IssueReminderSchema.index({ userId: 1, issueId: 1 });
@Schema({ ...LOOSE, collection: 'my_days' })
class MyDayDocument {
@Prop({ type: String }) userId!: string;
@Prop({ type: String }) day!: string;
@Prop({ type: [String] }) issueIds!: string[];
}
export const MyDaySchema = SchemaFactory.createForClass(MyDayDocument);
MyDaySchema.index({ userId: 1, day: -1 }, { unique: true });
@Schema({ ...LOOSE, collection: 'git_integrations' })
class GitIntegrationDocument {
/** The project's id. */
@@ -492,6 +502,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema },
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema },
{ name: ReadModels.IssueReminder, schema: IssueReminderSchema },
{ name: ReadModels.MyDay, schema: MyDaySchema },
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },

View File

@@ -17,6 +17,7 @@ import {
AlertIntegrationsRepository,
TreeFollowsRepository,
IssueRemindersRepository,
MyDaysRepository,
GitIntegrationsRepository,
OutgoingWebhooksRepository,
AutomationsRepository,
@@ -86,6 +87,7 @@ import { MongoPinsRepository } from './mongo/pins.repository';
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository';
import { MongoIssueRemindersRepository } from './mongo/issue-reminders.repository';
import { MongoMyDaysRepository } from './mongo/my-days.repository';
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
@@ -172,6 +174,7 @@ const PORTS = [
provide: IssueRemindersRepository,
useClass: MongoIssueRemindersRepository,
},
{ provide: MyDaysRepository, useClass: MongoMyDaysRepository },
{
provide: GitIntegrationsRepository,
useClass: MongoGitIntegrationsRepository,

View File

@@ -20,6 +20,7 @@ import {
PinsService,
TreeFollowsService,
IssueRemindersService,
MyDayService,
PresenceService,
WorkflowsService,
} from '@task-stream/api/tracker/application';
@@ -54,6 +55,7 @@ import {
IssuePageGql,
IssueParentGql,
IssueReminderGql,
MyDayGql,
ReminderOnIssueGql,
PresenceGql,
} from './types';
@@ -69,6 +71,7 @@ export class IssueResolver {
private readonly pins: PinsService,
private readonly treeFollows: TreeFollowsService,
private readonly reminders: IssueRemindersService,
private readonly myDayService: MyDayService,
) {}
@Mutation(() => [PresenceGql], {
@@ -442,6 +445,40 @@ export class IssueResolver {
return true;
}
@Query(() => MyDayGql, {
description:
'«Mi día»: the tickets I picked for today, and what was left on my last day',
})
myDay(
@CurrentUser() me: User,
@Args('day', { description: 'Today, YYYY-MM-DD in my time zone' })
day: string,
) {
return this.myDayService.get(me.id, day);
}
@Mutation(() => Boolean, {
description: 'Add tickets to «Mi día» (10 at most)',
})
async addToMyDay(
@CurrentUser() me: User,
@Args('day') day: string,
@Args('issueIds', { type: () => [ID] }) issueIds: string[],
): Promise<boolean> {
await this.myDayService.add(me.id, day, issueIds);
return true;
}
@Mutation(() => Boolean, { description: 'Take a ticket out of «Mi día»' })
async removeFromMyDay(
@CurrentUser() me: User,
@Args('day') day: string,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.myDayService.remove(me.id, day, issueId);
return true;
}
@Query(() => [ReminderOnIssueGql], {
description: 'My waiting reminders on tickets, soonest first',
})

View File

@@ -1393,3 +1393,22 @@ export class ReminderOnIssueGql {
@Field(() => IssueReminderGql) reminder!: IssueReminderGql;
@Field(() => IssueGql) issue!: IssueGql;
}
@ObjectType('MyDayLeftover', {
description: 'What was left unfinished on the latest earlier day',
})
export class MyDayLeftoverGql {
@Field() day!: string;
@Field(() => [IssueGql]) issues!: IssueGql[];
}
@ObjectType('MyDay', {
description: '«Mi día»: the tickets I picked for today',
})
export class MyDayGql {
@Field({ description: 'YYYY-MM-DD, in my time zone' }) day!: string;
@Field(() => [IssueGql], { description: 'In order, done ones included' })
issues!: IssueGql[];
@Field(() => MyDayLeftoverGql, { nullable: true })
leftover!: MyDayLeftoverGql | null;
}

View File

@@ -1552,6 +1552,12 @@ export class PushEndpointDto {
endpoint!: string;
}
export class MyDayQueryDto {
@ApiProperty({ example: '2026-10-20', description: 'Today, in my time zone' })
@Matches(/^\d{4}-\d{2}-\d{2}$/)
day!: string;
}
export class SetReminderDto {
@ApiProperty({
format: 'date-time',

View File

@@ -1427,3 +1427,15 @@ export class ReminderOnIssueDto {
@ApiProperty({ type: IssueReminderDto }) reminder!: IssueReminderDto;
@ApiProperty({ type: () => IssueDto }) issue!: IssueDto;
}
export class MyDayLeftoverDto {
@ApiProperty() day!: string;
@ApiProperty({ type: () => [IssueDto] }) issues!: IssueDto[];
}
export class MyDayDto {
@ApiProperty({ description: 'YYYY-MM-DD, in my time zone' }) day!: string;
@ApiProperty({ type: () => [IssueDto] }) issues!: IssueDto[];
@ApiProperty({ type: MyDayLeftoverDto, nullable: true })
leftover!: MyDayLeftoverDto | null;
}

View File

@@ -35,6 +35,7 @@ import {
PinsService,
TreeFollowsService,
IssueRemindersService,
MyDayService,
PresenceService,
ProjectsService,
} from '@task-stream/api/tracker/application';
@@ -49,6 +50,7 @@ import {
CreateIssueDto,
DecisionsQueryDto,
SetReminderDto,
MyDayQueryDto,
IssueSearchQueryDto,
PresenceBodyDto,
LinkIssueDto,
@@ -69,6 +71,7 @@ import {
CommentPageDto,
DecisionDto,
IssueReminderDto,
MyDayDto,
ReminderOnIssueDto,
EpicDto,
IssueDto,
@@ -106,6 +109,7 @@ export class IssuesController {
private readonly pins: PinsService,
private readonly treeFollows: TreeFollowsService,
private readonly reminders: IssueRemindersService,
private readonly myDayService: MyDayService,
) {}
@Get('me/home')
@@ -210,6 +214,59 @@ export class IssuesController {
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false);
}
@Get('me/day')
@ApiOperation({
summary: '«Mi día»: the tickets I picked for today',
description: 'And what was left unfinished on my latest earlier day.',
})
@ApiOkResponse({ type: MyDayDto })
@ApiReadErrors()
async myDay(@CurrentUser() me: User, @Query() query: MyDayQueryDto) {
const myDay = await this.myDayService.get(me.id, query.day);
return {
day: myDay.day,
issues: await this.refs.issueDtos(myDay.issues, viewerOf(me)),
leftover: myDay.leftover && {
day: myDay.leftover.day,
issues: await this.refs.issueDtos(myDay.leftover.issues, viewerOf(me)),
},
};
}
@Put('me/day/:issue')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Add a ticket to «Mi día» (10 at most)' })
@ApiNoContentResponse()
@ApiCommandErrors()
async addToMyDay(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Query() query: MyDayQueryDto,
) {
await this.myDayService.add(me.id, query.day, [
await this.refs.issueId(me.id, issue),
]);
}
@Delete('me/day/:issue')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Take a ticket out of «Mi día»' })
@ApiNoContentResponse()
@ApiCommandErrors()
async removeFromMyDay(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Query() query: MyDayQueryDto,
) {
await this.myDayService.remove(
me.id,
query.day,
await this.refs.issueId(me.id, issue),
);
}
@Get('me/reminders')
@ApiOperation({ summary: 'My waiting reminders on tickets, soonest first' })
@ApiOkResponse({ type: [ReminderOnIssueDto] })

View File

@@ -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';

View 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;
}

View File

@@ -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.

View File

@@ -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 })

View File

@@ -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: [

View File

@@ -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

View File

@@ -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());

View File

@@ -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 {

View 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 }),
});
}
}