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>
146 lines
4.4 KiB
TypeScript
146 lines
4.4 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|