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:
@@ -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';
|
||||
|
||||
32
libs/api/tracker/application/src/lib/my-day.service.ts
Normal file
32
libs/api/tracker/application/src/lib/my-day.service.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
145
libs/api/tracker/application/src/lib/my-day/my-day.spec.ts
Normal file
145
libs/api/tracker/application/src/lib/my-day/my-day.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
194
libs/api/tracker/application/src/lib/my-day/my-day.ts
Normal file
194
libs/api/tracker/application/src/lib/my-day/my-day.ts
Normal 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,
|
||||
];
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user