fix(reminders): keine Dauermeldung ohne localStorage, Mail nach Zeitaenderung, null-Pruefung
- Merkliste zusaetzlich im Arbeitsspeicher (sonst alle 10 s dieselbe Meldung) - Aendern der Faelligkeit atomar gegen gleichzeitiges Faelligwerden, setzt Mail-Spur zurueck - UpdateReminderDto lehnt null ab (400 statt 500) - keine Mails an deaktivierte Benutzer - Spaeter erinnern beschriftet heute/morgen nach dem berechneten Zeitpunkt - Bearbeiten schickt dueAt nur bei geaenderter Zeit Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
import { PartialType } from '@nestjs/mapped-types';
|
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
@@ -7,6 +6,7 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
const trimString = ({ value }: { value: unknown }) =>
|
const trimString = ({ value }: { value: unknown }) =>
|
||||||
@@ -44,12 +44,40 @@ export class CreateReminderDto {
|
|||||||
emailEnabled?: boolean;
|
emailEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nur ein FEHLENDES Feld wird uebersprungen — `null` wird geprueft und damit abgelehnt. */
|
||||||
|
const whenPresent = ValidateIf((_obj: object, value: unknown) => value !== undefined);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen.
|
* Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen.
|
||||||
* Eine faellige Erinnerung laesst sich nicht aendern (409 im Dienst) — dafuer
|
* Eine faellige Erinnerung laesst sich nicht aendern (409 im Dienst) — dafuer
|
||||||
* gibt es „Erledigt“ (loeschen) und „Spaeter erinnern“ (`SnoozeReminderDto`).
|
* gibt es „Erledigt“ (loeschen) und „Spaeter erinnern“ (`SnoozeReminderDto`).
|
||||||
|
*
|
||||||
|
* WARUM KEIN `PartialType`: das setzt `@IsOptional()`, und das laesst auch
|
||||||
|
* `null` ungeprueft durch — `{"title": null}` kaeme dann als 500 aus der
|
||||||
|
* Datenbank statt als 400 aus der Pruefung. `whenPresent` ueberspringt nur
|
||||||
|
* fehlende Felder.
|
||||||
*/
|
*/
|
||||||
export class UpdateReminderDto extends PartialType(CreateReminderDto) {}
|
export class UpdateReminderDto {
|
||||||
|
@whenPresent
|
||||||
|
@Transform(trimString)
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(200)
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@whenPresent
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@whenPresent
|
||||||
|
@IsISO8601({ strict: true })
|
||||||
|
dueAt?: string;
|
||||||
|
|
||||||
|
@whenPresent
|
||||||
|
@IsBoolean()
|
||||||
|
emailEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** Neuer Zeitpunkt beim Spaeter-Erinnern (D-03); der Client rechnet ihn aus (E-05). */
|
/** Neuer Zeitpunkt beim Spaeter-Erinnern (D-03); der Client rechnet ihn aus (E-05). */
|
||||||
export class SnoozeReminderDto {
|
export class SnoozeReminderDto {
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ function row(over: Partial<Row> & { id: string }): Row {
|
|||||||
const sameTime = (a: Date | null, b: Date | null) =>
|
const sameTime = (a: Date | null, b: Date | null) =>
|
||||||
a === null || b === null ? a === b : a.getTime() === b.getTime();
|
a === null || b === null ? a === b : a.getTime() === b.getTime();
|
||||||
|
|
||||||
function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u1@example.invalid' }) {
|
function makeStore(
|
||||||
|
rows: Row[],
|
||||||
|
emails: Record<string, string | null> = { u1: 'u1@example.invalid' },
|
||||||
|
inactive: string[] = [],
|
||||||
|
) {
|
||||||
const systemFindMany = vi.fn(async ({ where, take, orderBy }: any) => {
|
const systemFindMany = vi.fn(async ({ where, take, orderBy }: any) => {
|
||||||
let list = rows.filter(
|
let list = rows.filter(
|
||||||
(r) =>
|
(r) =>
|
||||||
@@ -58,8 +62,11 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
|
|||||||
r.dueAt.getTime() <= where.dueAt.lte.getTime() &&
|
r.dueAt.getTime() <= where.dueAt.lte.getTime() &&
|
||||||
r.dueAt.getTime() >= where.dueAt.gte.getTime(),
|
r.dueAt.getTime() >= where.dueAt.gte.getTime(),
|
||||||
);
|
);
|
||||||
if (orderBy?.dueAt === 'asc') list = [...list].sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime());
|
if (orderBy?.dueAt === 'asc')
|
||||||
return list.slice(0, take).map(({ id, tenantId, userId, dueAt }) => ({ id, tenantId, userId, dueAt }));
|
list = [...list].sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime());
|
||||||
|
return list
|
||||||
|
.slice(0, take)
|
||||||
|
.map(({ id, tenantId, userId, dueAt }) => ({ id, tenantId, userId, dueAt }));
|
||||||
});
|
});
|
||||||
|
|
||||||
const boundLog: string[] = [];
|
const boundLog: string[] = [];
|
||||||
@@ -69,11 +76,13 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
|
|||||||
boundLog.push(`updateMany:${tenantId}`);
|
boundLog.push(`updateMany:${tenantId}`);
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (r.id !== where.id || r.tenantId !== where.tenantId || r.tenantId !== tenantId) continue;
|
if (r.id !== where.id || r.tenantId !== where.tenantId || r.tenantId !== tenantId)
|
||||||
|
continue;
|
||||||
if ('dueAt' in where && !sameTime(r.dueAt, where.dueAt)) continue;
|
if ('dueAt' in where && !sameTime(r.dueAt, where.dueAt)) continue;
|
||||||
if ('emailEnabled' in where && r.emailEnabled !== where.emailEnabled) continue;
|
if ('emailEnabled' in where && r.emailEnabled !== where.emailEnabled) continue;
|
||||||
if ('emailSentAt' in where && !sameTime(r.emailSentAt, where.emailSentAt)) continue;
|
if ('emailSentAt' in where && !sameTime(r.emailSentAt, where.emailSentAt)) continue;
|
||||||
if (where.emailAttempts?.lt !== undefined && !(r.emailAttempts < where.emailAttempts.lt)) continue;
|
if (where.emailAttempts?.lt !== undefined && !(r.emailAttempts < where.emailAttempts.lt))
|
||||||
|
continue;
|
||||||
if (data.emailSentAt !== undefined) r.emailSentAt = data.emailSentAt;
|
if (data.emailSentAt !== undefined) r.emailSentAt = data.emailSentAt;
|
||||||
if (data.emailAttempts?.increment) r.emailAttempts += data.emailAttempts.increment;
|
if (data.emailAttempts?.increment) r.emailAttempts += data.emailAttempts.increment;
|
||||||
count++;
|
count++;
|
||||||
@@ -89,7 +98,11 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
findFirst: vi.fn(async ({ where }: any) => ({ email: emails[where.id] ?? null })),
|
findFirst: vi.fn(async ({ where }: any) => {
|
||||||
|
const isActive = !inactive.includes(where.id);
|
||||||
|
if ('isActive' in where && where.isActive !== isActive) return null;
|
||||||
|
return { email: emails[where.id] ?? null };
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,10 +113,7 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
|
|||||||
return { prisma, rows, systemFindMany, boundLog };
|
return { prisma, rows, systemFindMany, boundLog };
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeScheduler(
|
function makeScheduler(prisma: any, opts: { smtp?: unknown; sendResult?: boolean | Error } = {}) {
|
||||||
prisma: any,
|
|
||||||
opts: { smtp?: unknown; sendResult?: boolean | Error } = {},
|
|
||||||
) {
|
|
||||||
const registry = { addInterval: vi.fn(), deleteInterval: vi.fn() };
|
const registry = { addInterval: vi.fn(), deleteInterval: vi.fn() };
|
||||||
const settings = { getSmtpConfig: vi.fn(async () => (opts.smtp === undefined ? {} : opts.smtp)) };
|
const settings = { getSmtpConfig: vi.fn(async () => (opts.smtp === undefined ? {} : opts.smtp)) };
|
||||||
const mail = {
|
const mail = {
|
||||||
@@ -112,7 +122,12 @@ function makeScheduler(
|
|||||||
return opts.sendResult ?? true;
|
return opts.sendResult ?? true;
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const scheduler = new ReminderMailScheduler(registry as any, prisma, settings as any, mail as any);
|
const scheduler = new ReminderMailScheduler(
|
||||||
|
registry as any,
|
||||||
|
prisma,
|
||||||
|
settings as any,
|
||||||
|
mail as any,
|
||||||
|
);
|
||||||
return { scheduler, registry, settings, mail };
|
return { scheduler, registry, settings, mail };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +141,9 @@ describe('ReminderMailScheduler — Anspruch (T-IF2-06)', () => {
|
|||||||
const one = makeScheduler(store.prisma);
|
const one = makeScheduler(store.prisma);
|
||||||
const two = makeScheduler(store.prisma);
|
const two = makeScheduler(store.prisma);
|
||||||
await Promise.all([one.scheduler.runTick(NOW), two.scheduler.runTick(NOW)]);
|
await Promise.all([one.scheduler.runTick(NOW), two.scheduler.runTick(NOW)]);
|
||||||
expect(one.mail.sendReminderEmail.mock.calls.length + two.mail.sendReminderEmail.mock.calls.length).toBe(1);
|
expect(
|
||||||
|
one.mail.sendReminderEmail.mock.calls.length + two.mail.sendReminderEmail.mock.calls.length,
|
||||||
|
).toBe(1);
|
||||||
expect(store.rows[0].emailSentAt).toEqual(NOW);
|
expect(store.rows[0].emailSentAt).toEqual(NOW);
|
||||||
expect(store.rows[0].emailAttempts).toBe(1);
|
expect(store.rows[0].emailAttempts).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -223,6 +240,23 @@ describe('ReminderMailScheduler — Fehlschlag und Wiederholung (E-04)', () => {
|
|||||||
expect(mail.sendReminderEmail).not.toHaveBeenCalled();
|
expect(mail.sendReminderEmail).not.toHaveBeenCalled();
|
||||||
expect(store.rows[0].emailSentAt).toEqual(NOW);
|
expect(store.rows[0].emailSentAt).toEqual(NOW);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deaktivierter Benutzer: kein Versand, Anspruch bleibt wie ohne Adresse', async () => {
|
||||||
|
const store = makeStore([row({ id: 'a' })], { u1: 'u1@example.invalid' }, ['u1']);
|
||||||
|
const { scheduler, mail } = makeScheduler(store.prisma);
|
||||||
|
const spy = vi.spyOn(store.prisma, '__tenantClient');
|
||||||
|
await scheduler.runTick(NOW);
|
||||||
|
await scheduler.runTick(new Date(NOW.getTime() + 30_000));
|
||||||
|
expect(mail.sendReminderEmail).not.toHaveBeenCalled();
|
||||||
|
expect(store.rows[0].emailSentAt).toEqual(NOW);
|
||||||
|
expect(store.rows[0].emailAttempts).toBe(1);
|
||||||
|
const client = spy.mock.results[0].value;
|
||||||
|
expect(client.user.findFirst.mock.calls[0][0].where).toEqual({
|
||||||
|
id: 'u1',
|
||||||
|
tenantId: 't1',
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ReminderMailScheduler — Kandidaten (E-03, T-IF2-07)', () => {
|
describe('ReminderMailScheduler — Kandidaten (E-03, T-IF2-07)', () => {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const BATCH = 200;
|
|||||||
* Scheitert der Transport, gibt der Planer den Anspruch wieder frei
|
* Scheitert der Transport, gibt der Planer den Anspruch wieder frei
|
||||||
* (`emailSentAt = null`), sodass der naechste Durchlauf es erneut versucht —
|
* (`emailSentAt = null`), sodass der naechste Durchlauf es erneut versucht —
|
||||||
* hoechstens dreimal. Fehlt beim Senden die SMTP-Einrichtung oder die Adresse
|
* hoechstens dreimal. Fehlt beim Senden die SMTP-Einrichtung oder die Adresse
|
||||||
* des Benutzers, bleibt der Anspruch: die Faelligkeit gilt als erledigt und
|
* des Benutzers oder ist sein Konto deaktiviert (`isActive = false`), bleibt der Anspruch: die Faelligkeit gilt als erledigt und
|
||||||
* wird nur protokolliert, es gibt keine Wiederholschleife. Ein Verschieben
|
* wird nur protokolliert, es gibt keine Wiederholschleife. Ein Verschieben
|
||||||
* („Spaeter erinnern“) setzt beide Felder zurueck (siehe `RemindersService`).
|
* („Spaeter erinnern“) setzt beide Felder zurueck (siehe `RemindersService`).
|
||||||
*
|
*
|
||||||
@@ -104,9 +104,7 @@ export class ReminderMailScheduler implements OnApplicationBootstrap {
|
|||||||
await this.processCandidate(candidate, now);
|
await this.processCandidate(candidate, now);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Eine kaputte Zeile darf die uebrigen nicht anhalten.
|
// Eine kaputte Zeile darf die uebrigen nicht anhalten.
|
||||||
this.logger.error(
|
this.logger.error(`Reminder email for ${candidate.id} failed: ${(err as Error).message}`);
|
||||||
`Reminder email for ${candidate.id} failed: ${(err as Error).message}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -140,14 +138,15 @@ export class ReminderMailScheduler implements OnApplicationBootstrap {
|
|||||||
where: { id: c.id, tenantId: c.tenantId, dueAt: c.dueAt },
|
where: { id: c.id, tenantId: c.tenantId, dueAt: c.dueAt },
|
||||||
select: { title: true, description: true, dueAt: true },
|
select: { title: true, description: true, dueAt: true },
|
||||||
});
|
});
|
||||||
|
// Nur aktive Konten: ein deaktivierter Benutzer gilt wie einer ohne Adresse.
|
||||||
const user = await tenantPrisma.user.findFirst({
|
const user = await tenantPrisma.user.findFirst({
|
||||||
where: { id: c.userId, tenantId: c.tenantId },
|
where: { id: c.userId, tenantId: c.tenantId, isActive: true },
|
||||||
select: { email: true },
|
select: { email: true },
|
||||||
});
|
});
|
||||||
const smtp = await this.settingsService.getSmtpConfig(c.tenantId);
|
const smtp = await this.settingsService.getSmtpConfig(c.tenantId);
|
||||||
if (!row || !user?.email || smtp === null) {
|
if (!row || !user?.email || smtp === null) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Reminder email for ${c.id} übersprungen (${!row ? 'Zeile geändert' : !user?.email ? 'keine E-Mail-Adresse' : 'kein E-Mail-Versand eingerichtet'})`,
|
`Reminder email for ${c.id} übersprungen (${!row ? 'Zeile geändert' : !user ? 'Benutzer deaktiviert' : !user.email ? 'keine E-Mail-Adresse' : 'kein E-Mail-Versand eingerichtet'})`,
|
||||||
);
|
);
|
||||||
return; // Anspruch bleibt: gilt als erledigt, keine Wiederholschleife (E-04)
|
return; // Anspruch bleibt: gilt als erledigt, keine Wiederholschleife (E-04)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||||
import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
|
import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
|
||||||
@@ -24,7 +24,14 @@ const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any;
|
|||||||
const proto = RemindersController.prototype as any;
|
const proto = RemindersController.prototype as any;
|
||||||
|
|
||||||
describe('RemindersController — Rollen', () => {
|
describe('RemindersController — Rollen', () => {
|
||||||
it.each(['list', 'emailStatus', 'create', 'update', 'snooze', 'remove'])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
|
it.each([
|
||||||
|
'list',
|
||||||
|
'emailStatus',
|
||||||
|
'create',
|
||||||
|
'update',
|
||||||
|
'snooze',
|
||||||
|
'remove',
|
||||||
|
])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
|
||||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,7 +62,9 @@ describe('RemindersController — Mandant', () => {
|
|||||||
const controller = new RemindersController(makeService() as any);
|
const controller = new RemindersController(makeService() as any);
|
||||||
await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
|
await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
await expect(controller.emailStatus(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
|
await expect(controller.emailStatus(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
await expect(controller.update(req(), user, 'x', {})).rejects.toBeInstanceOf(ForbiddenException);
|
await expect(controller.update(req(), user, 'x', {})).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
await expect(
|
await expect(
|
||||||
controller.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }),
|
controller.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }),
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
@@ -96,6 +105,28 @@ describe('RemindersController — Pipe fuer Aendern und Verschieben', () => {
|
|||||||
expect(out).toEqual({ title: 'b' });
|
expect(out).toEqual({ title: 'b' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'dueAt',
|
||||||
|
'emailEnabled',
|
||||||
|
])('Aendern mit %s: null ergibt 400 statt eines Datenbankfehlers', async (field) => {
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
await expect(
|
||||||
|
pipe.transform({ [field]: null }, { type: 'body', metatype: UpdateReminderDto }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Aendern: ein leerer Titel wird abgelehnt, ein leeres Objekt ist erlaubt', async () => {
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
await expect(
|
||||||
|
pipe.transform({ title: ' ' }, { type: 'body', metatype: UpdateReminderDto }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
await expect(
|
||||||
|
pipe.transform({}, { type: 'body', metatype: UpdateReminderDto }),
|
||||||
|
).resolves.toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
it('Verschieben verlangt einen ISO-Zeitpunkt und verwirft Fremdfelder', async () => {
|
it('Verschieben verlangt einen ISO-Zeitpunkt und verwirft Fremdfelder', async () => {
|
||||||
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
const out: any = await pipe.transform(
|
const out: any = await pipe.transform(
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ function makeFakePrisma() {
|
|||||||
const reminder = {
|
const reminder = {
|
||||||
create: vi.fn(async ({ data }: { data: any }) => {
|
create: vi.fn(async ({ data }: { data: any }) => {
|
||||||
const id = `r-${++seq}`;
|
const id = `r-${++seq}`;
|
||||||
const row = { id, createdAt: new Date(), updatedAt: new Date(), emailEnabled: false, ...data };
|
const row = {
|
||||||
|
id,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
emailEnabled: false,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
rows.set(id, row);
|
rows.set(id, row);
|
||||||
return row;
|
return row;
|
||||||
}),
|
}),
|
||||||
@@ -37,6 +43,14 @@ function makeFakePrisma() {
|
|||||||
rows.set(where.id, row);
|
rows.set(where.id, row);
|
||||||
return row;
|
return row;
|
||||||
}),
|
}),
|
||||||
|
// Wie die eine SQL-Anweisung: Bedingung pruefen und schreiben ohne `await` dazwischen.
|
||||||
|
updateMany: vi.fn(async ({ where, data }: { where: any; data: any }) => {
|
||||||
|
const r = rows.get(where.id);
|
||||||
|
if (!r || r.tenantId !== where.tenantId || r.userId !== where.userId) return { count: 0 };
|
||||||
|
if (where.dueAt?.gt && !(r.dueAt.getTime() > where.dueAt.gt.getTime())) return { count: 0 };
|
||||||
|
rows.set(where.id, { ...r, ...data });
|
||||||
|
return { count: 1 };
|
||||||
|
}),
|
||||||
delete: vi.fn(async ({ where }: { where: any }) => {
|
delete: vi.fn(async ({ where }: { where: any }) => {
|
||||||
rows.delete(where.id);
|
rows.delete(where.id);
|
||||||
}),
|
}),
|
||||||
@@ -106,7 +120,9 @@ describe('RemindersService — anlegen', () => {
|
|||||||
service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) }),
|
service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) }),
|
||||||
).rejects.toBeInstanceOf(ConflictException);
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
// ein anderer Benutzer ist davon nicht betroffen
|
// ein anderer Benutzer ist davon nicht betroffen
|
||||||
await expect(service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) })).resolves.toBeTruthy();
|
await expect(
|
||||||
|
service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) }),
|
||||||
|
).resolves.toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,15 +169,21 @@ describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)'
|
|||||||
])('%s: aendern, verschieben und loeschen ergeben 404', async (_n, owner) => {
|
])('%s: aendern, verschieben und loeschen ergeben 404', async (_n, owner) => {
|
||||||
const { prisma, service } = setup();
|
const { prisma, service } = setup();
|
||||||
seed(prisma, 'x', past(), owner);
|
seed(prisma, 'x', past(), owner);
|
||||||
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(NotFoundException);
|
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(
|
||||||
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf(NotFoundException);
|
NotFoundException,
|
||||||
|
);
|
||||||
|
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
await expect(service.remove('t1', 'u1', 'x')).rejects.toBeInstanceOf(NotFoundException);
|
await expect(service.remove('t1', 'u1', 'x')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
expect(prisma.rows.has('x')).toBe(true);
|
expect(prisma.rows.has('x')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('eine unbekannte Kennung ergibt 404', async () => {
|
it('eine unbekannte Kennung ergibt 404', async () => {
|
||||||
const { service } = setup();
|
const { service } = setup();
|
||||||
await expect(service.remove('t1', 'u1', 'gibt-es-nicht')).rejects.toBeInstanceOf(NotFoundException);
|
await expect(service.remove('t1', 'u1', 'gibt-es-nicht')).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('das where jeder Abfrage traegt Mandant und Benutzer', async () => {
|
it('das where jeder Abfrage traegt Mandant und Benutzer', async () => {
|
||||||
@@ -173,10 +195,11 @@ describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)'
|
|||||||
tenantId: 't1',
|
tenantId: 't1',
|
||||||
userId: 'u1',
|
userId: 'u1',
|
||||||
});
|
});
|
||||||
expect(prisma.reminder.update.mock.calls[0]?.[0]?.where).toEqual({
|
expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.where).toEqual({
|
||||||
id: 'x',
|
id: 'x',
|
||||||
tenantId: 't1',
|
tenantId: 't1',
|
||||||
userId: 'u1',
|
userId: 'u1',
|
||||||
|
dueAt: { gt: expect.any(Date) },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -185,7 +208,9 @@ describe('RemindersService — aendern', () => {
|
|||||||
it('eine faellige Erinnerung laesst sich nicht aendern (409)', async () => {
|
it('eine faellige Erinnerung laesst sich nicht aendern (409)', async () => {
|
||||||
const { prisma, service } = setup();
|
const { prisma, service } = setup();
|
||||||
seed(prisma, 'x', past());
|
seed(prisma, 'x', past());
|
||||||
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(ConflictException);
|
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('eine vergangene neue Faelligkeit ergibt 400', async () => {
|
it('eine vergangene neue Faelligkeit ergibt 400', async () => {
|
||||||
@@ -200,14 +225,61 @@ describe('RemindersService — aendern', () => {
|
|||||||
const { prisma, service } = setup();
|
const { prisma, service } = setup();
|
||||||
seed(prisma, 'x', future());
|
seed(prisma, 'x', future());
|
||||||
await service.update('t1', 'u1', 'x', { title: 'neu' });
|
await service.update('t1', 'u1', 'x', { title: 'neu' });
|
||||||
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ title: 'neu' });
|
expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.data).toEqual({ title: 'neu' });
|
||||||
const newDue = inHours(9);
|
const newDue = inHours(9);
|
||||||
await service.update('t1', 'u1', 'x', { dueAt: newDue, description: '' });
|
await service.update('t1', 'u1', 'x', { dueAt: newDue, description: '' });
|
||||||
expect(prisma.reminder.update.mock.calls[1]?.[0]?.data).toEqual({
|
expect(prisma.reminder.updateMany.mock.calls[1]?.[0]?.data).toEqual({
|
||||||
description: '',
|
description: '',
|
||||||
dueAt: new Date(newDue),
|
dueAt: new Date(newDue),
|
||||||
|
emailSentAt: null,
|
||||||
|
emailAttempts: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('eine neue Faelligkeit setzt emailSentAt und emailAttempts zurueck, sonst bleiben sie', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
seed(prisma, 'x', future());
|
||||||
|
await service.update('t1', 'u1', 'x', { title: 'neu' });
|
||||||
|
expect(prisma.rows.get('x').emailAttempts).toBe(2);
|
||||||
|
expect(prisma.rows.get('x').emailSentAt).not.toBeNull();
|
||||||
|
const newDue = inHours(5);
|
||||||
|
const out: any = await service.update('t1', 'u1', 'x', { dueAt: newDue });
|
||||||
|
expect(prisma.rows.get('x')).toMatchObject({
|
||||||
|
dueAt: new Date(newDue),
|
||||||
|
emailSentAt: null,
|
||||||
|
emailAttempts: 0,
|
||||||
|
});
|
||||||
|
expect(out.dueAt).toEqual(new Date(newDue));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wird die Erinnerung zwischen Pruefung und Schreiben faellig, gibt es 409 und keine Aenderung', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
seed(prisma, 'x', future());
|
||||||
|
// Nach dem Laden (Pruefung bestanden) ist die Zeile inzwischen faellig.
|
||||||
|
prisma.reminder.findFirst.mockImplementationOnce(async () => {
|
||||||
|
const r = prisma.rows.get('x');
|
||||||
|
const loaded = { ...r };
|
||||||
|
prisma.rows.set('x', { ...r, dueAt: past() });
|
||||||
|
return loaded;
|
||||||
|
});
|
||||||
|
await expect(service.update('t1', 'u1', 'x', { title: 'neu' })).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
expect(prisma.rows.get('x').title).toBe('alt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wird die Erinnerung zwischen Pruefung und Schreiben geloescht, gibt es 404', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
seed(prisma, 'x', future());
|
||||||
|
prisma.reminder.findFirst.mockImplementationOnce(async () => {
|
||||||
|
const loaded = { ...prisma.rows.get('x') };
|
||||||
|
prisma.rows.delete('x');
|
||||||
|
return loaded;
|
||||||
|
});
|
||||||
|
await expect(service.update('t1', 'u1', 'x', { title: 'neu' })).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('RemindersService — spaeter erinnern (D-03)', () => {
|
describe('RemindersService — spaeter erinnern (D-03)', () => {
|
||||||
@@ -276,7 +348,10 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
|
|||||||
hasEmail: false,
|
hasEmail: false,
|
||||||
});
|
});
|
||||||
expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u-ohne-mail');
|
expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u-ohne-mail');
|
||||||
expect(prisma.user.findFirst.mock.calls[0]?.[0]?.where).toEqual({ id: 'u-ohne-mail', tenantId: 't1' });
|
expect(prisma.user.findFirst.mock.calls[0]?.[0]?.where).toEqual({
|
||||||
|
id: 'u-ohne-mail',
|
||||||
|
tenantId: 't1',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('anlegen mit emailEnabled speichert das Feld, wenn E-Mail moeglich ist', async () => {
|
it('anlegen mit emailEnabled speichert das Feld, wenn E-Mail moeglich ist', async () => {
|
||||||
@@ -298,7 +373,11 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
|
|||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
const noMail = setup();
|
const noMail = setup();
|
||||||
await expect(
|
await expect(
|
||||||
noMail.service.create('t1', 'u-ohne-mail', { title: 'a', dueAt: inHours(1), emailEnabled: true }),
|
noMail.service.create('t1', 'u-ohne-mail', {
|
||||||
|
title: 'a',
|
||||||
|
dueAt: inHours(1),
|
||||||
|
emailEnabled: true,
|
||||||
|
}),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
expect(noSmtp.prisma.rows.size).toBe(0);
|
expect(noSmtp.prisma.rows.size).toBe(0);
|
||||||
expect(noMail.prisma.rows.size).toBe(0);
|
expect(noMail.prisma.rows.size).toBe(0);
|
||||||
@@ -311,6 +390,6 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
await service.update('t1', 'u1', 'x', { emailEnabled: false });
|
await service.update('t1', 'u1', 'x', { emailEnabled: false });
|
||||||
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false });
|
expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SettingsService } from '../settings/settings.service';
|
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import { SettingsService } from '../settings/settings.service';
|
||||||
import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
|
import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
|
||||||
|
|
||||||
/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */
|
/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */
|
||||||
@@ -107,26 +107,58 @@ export class RemindersService {
|
|||||||
/**
|
/**
|
||||||
* Bearbeiten einer noch NICHT faelligen Erinnerung. Eine faellige ist tabu
|
* Bearbeiten einer noch NICHT faelligen Erinnerung. Eine faellige ist tabu
|
||||||
* (409): dafuer gibt es „Erledigt“ und „Spaeter erinnern“ (D-03).
|
* (409): dafuer gibt es „Erledigt“ und „Spaeter erinnern“ (D-03).
|
||||||
|
*
|
||||||
|
* WARUM `updateMany` MIT `dueAt > jetzt`: Pruefung und Schreiben sind sonst
|
||||||
|
* getrennt — wird die Erinnerung dazwischen faellig (und der E-Mail-Planer
|
||||||
|
* hat sie womoeglich schon beansprucht), wuerde trotzdem geschrieben. Die
|
||||||
|
* Bedingung im selben Schreibzugriff schliesst das aus; `count === 0` heisst
|
||||||
|
* dann 409 (oder 404, falls die Zeile inzwischen geloescht ist).
|
||||||
|
*
|
||||||
|
* Eine neue Faelligkeit setzt wie beim Verschieben die Spuren des
|
||||||
|
* E-Mail-Planers zurueck, damit zur neuen Faelligkeit eine Mail geht.
|
||||||
*/
|
*/
|
||||||
async update(tenantId: string, userId: string, id: string, dto: UpdateReminderDto) {
|
async update(tenantId: string, userId: string, id: string, dto: UpdateReminderDto) {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
const row = await this.loadOwn(tenantPrisma, tenantId, userId, id);
|
const row = await this.loadOwn(tenantPrisma, tenantId, userId, id);
|
||||||
if (row.dueAt.getTime() <= Date.now()) {
|
const now = Date.now();
|
||||||
|
if (row.dueAt.getTime() <= now) {
|
||||||
throw new ConflictException('Die Erinnerung ist bereits fällig');
|
throw new ConflictException('Die Erinnerung ist bereits fällig');
|
||||||
}
|
}
|
||||||
const data: { title?: string; description?: string; dueAt?: Date; emailEnabled?: boolean } = {};
|
const data: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
dueAt?: Date;
|
||||||
|
emailEnabled?: boolean;
|
||||||
|
emailSentAt?: null;
|
||||||
|
emailAttempts?: number;
|
||||||
|
} = {};
|
||||||
if (dto.title !== undefined) data.title = dto.title;
|
if (dto.title !== undefined) data.title = dto.title;
|
||||||
if (dto.description !== undefined) data.description = dto.description;
|
if (dto.description !== undefined) data.description = dto.description;
|
||||||
if (dto.dueAt !== undefined) data.dueAt = this.assertValidDueAt(dto.dueAt);
|
if (dto.dueAt !== undefined) {
|
||||||
|
data.dueAt = this.assertValidDueAt(dto.dueAt);
|
||||||
|
data.emailSentAt = null;
|
||||||
|
data.emailAttempts = 0;
|
||||||
|
}
|
||||||
if (dto.emailEnabled !== undefined) {
|
if (dto.emailEnabled !== undefined) {
|
||||||
if (dto.emailEnabled) await this.assertEmailAvailable(tenantId, userId);
|
if (dto.emailEnabled) await this.assertEmailAvailable(tenantId, userId);
|
||||||
data.emailEnabled = dto.emailEnabled;
|
data.emailEnabled = dto.emailEnabled;
|
||||||
}
|
}
|
||||||
return tenantPrisma.reminder.update({
|
const result = await tenantPrisma.reminder.updateMany({
|
||||||
where: { id, tenantId, userId },
|
where: { id, tenantId, userId, dueAt: { gt: new Date() } },
|
||||||
data,
|
data,
|
||||||
|
});
|
||||||
|
if (result.count !== 1) {
|
||||||
|
await this.loadOwn(tenantPrisma, tenantId, userId, id); // inzwischen geloescht: 404
|
||||||
|
throw new ConflictException('Die Erinnerung ist bereits fällig');
|
||||||
|
}
|
||||||
|
const updated = await tenantPrisma.reminder.findFirst({
|
||||||
|
where: { id, tenantId, userId },
|
||||||
select: REMINDER_SELECT,
|
select: REMINDER_SELECT,
|
||||||
});
|
});
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundException('Erinnerung nicht gefunden');
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { createPortal } from 'react-dom';
|
|||||||
import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
|
import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
|
||||||
import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time';
|
import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time';
|
||||||
import {
|
import {
|
||||||
|
createReminder,
|
||||||
type Reminder,
|
type Reminder,
|
||||||
type ReminderEmailStatus,
|
type ReminderEmailStatus,
|
||||||
ReminderRequestError,
|
ReminderRequestError,
|
||||||
createReminder,
|
|
||||||
updateReminder,
|
updateReminder,
|
||||||
} from '@/lib/reminders-api';
|
} from '@/lib/reminders-api';
|
||||||
|
|
||||||
@@ -85,9 +85,15 @@ export function ReminderFormModal({
|
|||||||
setError(t('reminder.titleRequired'));
|
setError(t('reminder.titleRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const dueAt = localInputsToIso(date, time);
|
// Beim Bearbeiten nur senden, wenn Datum oder Uhrzeit wirklich geaendert
|
||||||
|
// wurden: das Formular kennt keine Sekunden, ein unveraendertes Feld wuerde
|
||||||
|
// die Faelligkeit sonst um bis zu 59 s verschieben — und kurz vor der
|
||||||
|
// Faelligkeit faelschlich „in der Vergangenheit“ melden. Ist sie inzwischen
|
||||||
|
// faellig, antwortet der Server ohnehin mit 409 (`alreadyDue`).
|
||||||
|
const dueChanged = !reminder || date !== initial.date || time !== initial.time;
|
||||||
|
const dueAt = dueChanged ? localInputsToIso(date, time) : undefined;
|
||||||
// Spiegel der Serverregel: die Faelligkeit muss in der Zukunft liegen.
|
// Spiegel der Serverregel: die Faelligkeit muss in der Zukunft liegen.
|
||||||
if (dueAt === null || new Date(dueAt).getTime() <= Date.now()) {
|
if (dueChanged && (!dueAt || new Date(dueAt).getTime() <= Date.now())) {
|
||||||
setError(t('reminder.pastError'));
|
setError(t('reminder.pastError'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -98,16 +104,15 @@ export function ReminderFormModal({
|
|||||||
// ist: ein Server, der E-Mail nicht mehr anbietet, lehnt `true` mit 400
|
// ist: ein Server, der E-Mail nicht mehr anbietet, lehnt `true` mit 400
|
||||||
// ab — eine unveraenderte Alt-Einstellung darf das Speichern nicht kippen.
|
// ab — eine unveraenderte Alt-Einstellung darf das Speichern nicht kippen.
|
||||||
const emailChanged = reminder ? emailEnabled !== reminder.emailEnabled : emailEnabled;
|
const emailChanged = reminder ? emailEnabled !== reminder.emailEnabled : emailEnabled;
|
||||||
const input = {
|
const common = {
|
||||||
title: trimmedTitle,
|
title: trimmedTitle,
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
dueAt,
|
|
||||||
...(emailChanged ? { emailEnabled } : {}),
|
...(emailChanged ? { emailEnabled } : {}),
|
||||||
};
|
};
|
||||||
if (reminder) {
|
if (reminder) {
|
||||||
await updateReminder(reminder.id, input);
|
await updateReminder(reminder.id, { ...common, ...(dueAt ? { dueAt } : {}) });
|
||||||
} else {
|
} else if (dueAt) {
|
||||||
await createReminder(input);
|
await createReminder({ ...common, dueAt });
|
||||||
}
|
}
|
||||||
onSaved();
|
onSaved();
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ vi.mock('@/lib/reminders-api', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ReminderRequestError,
|
|
||||||
createReminder,
|
createReminder,
|
||||||
deleteReminder,
|
deleteReminder,
|
||||||
getReminderEmailStatus,
|
getReminderEmailStatus,
|
||||||
listReminders,
|
listReminders,
|
||||||
|
ReminderRequestError,
|
||||||
snoozeReminder,
|
snoozeReminder,
|
||||||
updateReminder,
|
updateReminder,
|
||||||
} from '@/lib/reminders-api';
|
} from '@/lib/reminders-api';
|
||||||
@@ -79,7 +79,9 @@ async function openForm() {
|
|||||||
|
|
||||||
function fillAndSubmit(title: string) {
|
function fillAndSubmit(title: string) {
|
||||||
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: title } });
|
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: title } });
|
||||||
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2099-03-04' } });
|
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), {
|
||||||
|
target: { value: '2099-03-04' },
|
||||||
|
});
|
||||||
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '14:30' } });
|
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '14:30' } });
|
||||||
fireEvent.click(screen.getByText('reminder.save'));
|
fireEvent.click(screen.getByText('reminder.save'));
|
||||||
}
|
}
|
||||||
@@ -146,7 +148,9 @@ describe('ReminderWidget', () => {
|
|||||||
render(<ReminderWidget {...props} />);
|
render(<ReminderWidget {...props} />);
|
||||||
await openForm();
|
await openForm();
|
||||||
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'x' } });
|
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'x' } });
|
||||||
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2001-01-01' } });
|
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), {
|
||||||
|
target: { value: '2001-01-01' },
|
||||||
|
});
|
||||||
fireEvent.click(screen.getByText('reminder.save'));
|
fireEvent.click(screen.getByText('reminder.save'));
|
||||||
expect(await screen.findByText('reminder.pastError')).toBeTruthy();
|
expect(await screen.findByText('reminder.pastError')).toBeTruthy();
|
||||||
expect(mockCreate).not.toHaveBeenCalled();
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
@@ -260,6 +264,38 @@ describe('ReminderWidget — faellig und Aktionen (D-03)', () => {
|
|||||||
expect(requestPermission).not.toHaveBeenCalled();
|
expect(requestPermission).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Bearbeiten ohne Aenderung von Datum/Uhrzeit sendet kein dueAt (keine Sekunden-Verschiebung)', async () => {
|
||||||
|
// Faellig in 30 s, mit Sekunden: auf Minuten gekuerzt laege sie in der Vergangenheit.
|
||||||
|
const due = new Date(Date.now() + 30_000);
|
||||||
|
due.setSeconds(due.getSeconds(), 500);
|
||||||
|
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString())]);
|
||||||
|
mockUpdate.mockResolvedValue({});
|
||||||
|
render(<ReminderWidget {...props} />);
|
||||||
|
fireEvent.click(await screen.findByLabelText('reminder.edit'));
|
||||||
|
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'Neu' } });
|
||||||
|
fireEvent.click(screen.getByText('reminder.save'));
|
||||||
|
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||||
|
expect(screen.queryByText('reminder.pastError')).toBeNull();
|
||||||
|
expect(mockUpdate.mock.calls[0][1]).not.toHaveProperty('dueAt');
|
||||||
|
expect(mockUpdate.mock.calls[0][1].title).toBe('Neu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Bearbeiten mit geaenderter Uhrzeit sendet das neue dueAt', async () => {
|
||||||
|
const due = new Date(Date.now() + 2 * 3600_000);
|
||||||
|
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString())]);
|
||||||
|
mockUpdate.mockResolvedValue({});
|
||||||
|
render(<ReminderWidget {...props} />);
|
||||||
|
fireEvent.click(await screen.findByLabelText('reminder.edit'));
|
||||||
|
const next = new Date(Date.now() + 26 * 3600_000);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const nextDate = `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}`;
|
||||||
|
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: nextDate } });
|
||||||
|
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '09:15' } });
|
||||||
|
fireEvent.click(screen.getByText('reminder.save'));
|
||||||
|
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mockUpdate.mock.calls[0][1].dueAt).toBe(new Date(`${nextDate}T09:15`).toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
it('409 beim Verschieben zeigt den Hinweis und laedt neu', async () => {
|
it('409 beim Verschieben zeigt den Hinweis und laedt neu', async () => {
|
||||||
mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]);
|
mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]);
|
||||||
mockSnooze.mockRejectedValue(new ReminderRequestError(409));
|
mockSnooze.mockRejectedValue(new ReminderRequestError(409));
|
||||||
@@ -313,6 +349,27 @@ describe('ReminderWidget — Faelligkeit ohne Neuladen', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ReminderWidget — Spaeter erinnern kurz nach Mitternacht', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('faellig gestern 23:50, jetzt 00:10: die Option heisst „Heute“ und verschiebt auf heute 23:50', async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
vi.setSystemTime(new Date(2026, 8, 30, 0, 10));
|
||||||
|
mockList.mockResolvedValue([
|
||||||
|
reminder('a', 'Spaet', new Date(2026, 8, 29, 23, 50).toISOString()),
|
||||||
|
]);
|
||||||
|
mockSnooze.mockResolvedValue({});
|
||||||
|
render(<ReminderWidget {...props} />);
|
||||||
|
fireEvent.click(await screen.findByText('reminder.snooze'));
|
||||||
|
expect(screen.queryByText('reminder.snoozeTomorrow')).toBeNull();
|
||||||
|
fireEvent.click(await screen.findByText('reminder.snoozeToday'));
|
||||||
|
await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mockSnooze.mock.calls[0][1]).toBe(new Date(2026, 8, 30, 23, 50).toISOString());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
|
describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
|
||||||
async function openFormAndWaitForStatus() {
|
async function openFormAndWaitForStatus() {
|
||||||
await openForm();
|
await openForm();
|
||||||
@@ -383,7 +440,10 @@ describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
|
|||||||
it('Bearbeiten einer Erinnerung mit angehakter Mail sendet emailEnabled nur bei Aenderung', async () => {
|
it('Bearbeiten einer Erinnerung mit angehakter Mail sendet emailEnabled nur bei Aenderung', async () => {
|
||||||
mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true });
|
mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true });
|
||||||
mockList.mockResolvedValue([
|
mockList.mockResolvedValue([
|
||||||
{ ...reminder('a', 'Alt', new Date(Date.now() + 3600_000).toISOString()), emailEnabled: true },
|
{
|
||||||
|
...reminder('a', 'Alt', new Date(Date.now() + 3600_000).toISOString()),
|
||||||
|
emailEnabled: true,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
mockUpdate.mockResolvedValue({});
|
mockUpdate.mockResolvedValue({});
|
||||||
render(<ReminderWidget {...props} />);
|
render(<ReminderWidget {...props} />);
|
||||||
|
|||||||
@@ -3,15 +3,15 @@
|
|||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { browserPermissionState } from '@/lib/reminder-notify';
|
import { browserPermissionState } from '@/lib/reminder-notify';
|
||||||
import { type SnoozePreset, snoozeTarget } from '@/lib/reminder-time';
|
import { isSameLocalDay, type SnoozePreset, snoozeTarget } from '@/lib/reminder-time';
|
||||||
import {
|
import {
|
||||||
|
deleteReminder,
|
||||||
|
getReminderEmailStatus,
|
||||||
|
listReminders,
|
||||||
REMINDERS_CHANGED_EVENT,
|
REMINDERS_CHANGED_EVENT,
|
||||||
type Reminder,
|
type Reminder,
|
||||||
type ReminderEmailStatus,
|
type ReminderEmailStatus,
|
||||||
ReminderRequestError,
|
ReminderRequestError,
|
||||||
deleteReminder,
|
|
||||||
getReminderEmailStatus,
|
|
||||||
listReminders,
|
|
||||||
snoozeReminder,
|
snoozeReminder,
|
||||||
} from '@/lib/reminders-api';
|
} from '@/lib/reminders-api';
|
||||||
import type { WidgetProps } from '../widget-registry';
|
import type { WidgetProps } from '../widget-registry';
|
||||||
@@ -107,7 +107,9 @@ export function ReminderWidget(_props: WidgetProps) {
|
|||||||
/** Fehler einer Zeilenaktion: 409 heisst, der Stand war veraltet — Text zeigen und neu laden. */
|
/** Fehler einer Zeilenaktion: 409 heisst, der Stand war veraltet — Text zeigen und neu laden. */
|
||||||
const handleFailure = (err: unknown, conflictKey: string) => {
|
const handleFailure = (err: unknown, conflictKey: string) => {
|
||||||
setNotice(
|
setNotice(
|
||||||
err instanceof ReminderRequestError && err.status === 409 ? t(conflictKey) : t('reminder.saveError'),
|
err instanceof ReminderRequestError && err.status === 409
|
||||||
|
? t(conflictKey)
|
||||||
|
: t('reminder.saveError'),
|
||||||
);
|
);
|
||||||
void refetch();
|
void refetch();
|
||||||
};
|
};
|
||||||
@@ -134,6 +136,16 @@ export function ReminderWidget(_props: WidgetProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// „Heute“ oder „Morgen“ aus dem tatsaechlich berechneten Zeitpunkt: kurz nach
|
||||||
|
// Mitternacht liegt das naechste Vorkommen der Uhrzeit noch heute.
|
||||||
|
const tomorrowLabel = (r: Reminder) => {
|
||||||
|
const target = snoozeTarget('tomorrow', new Date(r.dueAt), new Date(now));
|
||||||
|
const time = timeOnly.format(target);
|
||||||
|
return isSameLocalDay(target, new Date(now))
|
||||||
|
? t('reminder.snoozeToday', { time })
|
||||||
|
: t('reminder.snoozeTomorrow', { time });
|
||||||
|
};
|
||||||
|
|
||||||
const remove = async (id: string) => {
|
const remove = async (id: string) => {
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
setConfirmDeleteId(null);
|
setConfirmDeleteId(null);
|
||||||
@@ -177,7 +189,9 @@ export function ReminderWidget(_props: WidgetProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{r.title}</p>
|
<p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
|
||||||
|
{r.title}
|
||||||
|
</p>
|
||||||
{r.emailEnabled && (
|
{r.emailEnabled && (
|
||||||
<span
|
<span
|
||||||
className="shrink-0 text-muted-foreground"
|
className="shrink-0 text-muted-foreground"
|
||||||
@@ -244,8 +258,7 @@ export function ReminderWidget(_props: WidgetProps) {
|
|||||||
>
|
>
|
||||||
{preset === '10m' && t('reminder.snooze10m')}
|
{preset === '10m' && t('reminder.snooze10m')}
|
||||||
{preset === '1h' && t('reminder.snooze1h')}
|
{preset === '1h' && t('reminder.snooze1h')}
|
||||||
{preset === 'tomorrow' &&
|
{preset === 'tomorrow' && tomorrowLabel(r)}
|
||||||
t('reminder.snoozeTomorrow', { time: timeOnly.format(new Date(r.dueAt)) })}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ vi.mock('@/lib/reminders-api', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('@/lib/reminder-notify', async () => {
|
vi.mock('@/lib/reminder-notify', async () => {
|
||||||
const actual = await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify');
|
const actual =
|
||||||
|
await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify');
|
||||||
return { ...actual, showReminderNotification: vi.fn(async () => undefined) };
|
return { ...actual, showReminderNotification: vi.fn(async () => undefined) };
|
||||||
});
|
});
|
||||||
|
|
||||||
import { showReminderNotification } from '@/lib/reminder-notify';
|
import { resetNotifiedMemoryForTests, showReminderNotification } from '@/lib/reminder-notify';
|
||||||
import { ReminderRequestError, listReminders } from '@/lib/reminders-api';
|
import { listReminders, ReminderRequestError } from '@/lib/reminders-api';
|
||||||
import { ReminderNotifier } from './reminder-notifier';
|
import { ReminderNotifier } from './reminder-notifier';
|
||||||
|
|
||||||
const mockList = listReminders as ReturnType<typeof vi.fn>;
|
const mockList = listReminders as ReturnType<typeof vi.fn>;
|
||||||
@@ -53,6 +54,7 @@ beforeEach(() => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(NOW);
|
vi.setSystemTime(NOW);
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
|
resetNotifiedMemoryForTests();
|
||||||
mockList.mockReset();
|
mockList.mockReset();
|
||||||
mockShow.mockClear();
|
mockShow.mockClear();
|
||||||
});
|
});
|
||||||
@@ -77,7 +79,9 @@ describe('ReminderNotifier', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('meldet nichts, solange nichts faellig ist', async () => {
|
it('meldet nichts, solange nichts faellig ist', async () => {
|
||||||
mockList.mockResolvedValue([reminder({ dueAt: new Date(NOW.getTime() + 3600_000).toISOString() })]);
|
mockList.mockResolvedValue([
|
||||||
|
reminder({ dueAt: new Date(NOW.getTime() + 3600_000).toISOString() }),
|
||||||
|
]);
|
||||||
render(<ReminderNotifier />);
|
render(<ReminderNotifier />);
|
||||||
await advance(30_000);
|
await advance(30_000);
|
||||||
expect(mockShow).not.toHaveBeenCalled();
|
expect(mockShow).not.toHaveBeenCalled();
|
||||||
@@ -85,7 +89,10 @@ describe('ReminderNotifier', () => {
|
|||||||
|
|
||||||
it('nimmt die Beschreibung als Text, gekuerzt auf 200 Zeichen', async () => {
|
it('nimmt die Beschreibung als Text, gekuerzt auf 200 Zeichen', async () => {
|
||||||
mockList.mockResolvedValue([
|
mockList.mockResolvedValue([
|
||||||
reminder({ dueAt: new Date(NOW.getTime() - 1000).toISOString(), description: 'x'.repeat(300) }),
|
reminder({
|
||||||
|
dueAt: new Date(NOW.getTime() - 1000).toISOString(),
|
||||||
|
description: 'x'.repeat(300),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
render(<ReminderNotifier />);
|
render(<ReminderNotifier />);
|
||||||
await advance(0);
|
await advance(0);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
isTauriWebview,
|
isTauriWebview,
|
||||||
remindersToNotify,
|
remindersToNotify,
|
||||||
requestBrowserPermissionOnce,
|
requestBrowserPermissionOnce,
|
||||||
|
resetNotifiedMemoryForTests,
|
||||||
showReminderNotification,
|
showReminderNotification,
|
||||||
withNotifyLock,
|
withNotifyLock,
|
||||||
} from './reminder-notify';
|
} from './reminder-notify';
|
||||||
@@ -34,6 +35,7 @@ function installNotification(permission: NotificationPermission) {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
|
resetNotifiedMemoryForTests();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -65,6 +67,28 @@ describe('claimNotification', () => {
|
|||||||
const stored = JSON.parse(window.localStorage.getItem('tessera.reminders.notified') ?? '{}');
|
const stored = JSON.parse(window.localStorage.getItem('tessera.reminders.notified') ?? '{}');
|
||||||
expect(Object.keys(stored)).toEqual(['neu']);
|
expect(Object.keys(stored)).toEqual(['neu']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ohne nutzbaren localStorage (wirft beim Lesen und Schreiben) meldet es trotzdem nur einmal', () => {
|
||||||
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new Error('SecurityError');
|
||||||
|
});
|
||||||
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw new Error('QuotaExceededError');
|
||||||
|
});
|
||||||
|
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW)).toBe(true);
|
||||||
|
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 10_000)).toBe(false);
|
||||||
|
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 3600_000)).toBe(false);
|
||||||
|
// ein neuer Schluessel (verschoben) wird weiterhin gemeldet
|
||||||
|
expect(claimNotification('r1|2026-09-29T12:10:00.000Z', NOW)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne window.localStorage (Zugriff wirft) meldet es trotzdem nur einmal', () => {
|
||||||
|
vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
|
||||||
|
throw new Error('SecurityError');
|
||||||
|
});
|
||||||
|
expect(claimNotification('k', NOW)).toBe(true);
|
||||||
|
expect(claimNotification('k', NOW + 10_000)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('remindersToNotify', () => {
|
describe('remindersToNotify', () => {
|
||||||
@@ -93,7 +117,9 @@ describe('showReminderNotification', () => {
|
|||||||
const invoke = installTauri();
|
const invoke = installTauri();
|
||||||
invoke.mockRejectedValue(new Error('nope'));
|
invoke.mockRejectedValue(new Error('nope'));
|
||||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
await expect(showReminderNotification({ title: 'T', body: 'B', tag: 'x' })).resolves.toBeUndefined();
|
await expect(
|
||||||
|
showReminderNotification({ title: 'T', body: 'B', tag: 'x' }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
expect(warn).toHaveBeenCalledTimes(1);
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,12 +30,7 @@ export const CATCH_UP_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|||||||
/** Eintraege der lokalen Merkliste, die aelter sind, werden entfernt. */
|
/** Eintraege der lokalen Merkliste, die aelter sind, werden entfernt. */
|
||||||
const NOTIFIED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
const NOTIFIED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
export type BrowserPermissionState =
|
export type BrowserPermissionState = 'desktop' | 'granted' | 'default' | 'denied' | 'unsupported';
|
||||||
| 'desktop'
|
|
||||||
| 'granted'
|
|
||||||
| 'default'
|
|
||||||
| 'denied'
|
|
||||||
| 'unsupported';
|
|
||||||
|
|
||||||
type TauriInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
type TauriInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
|
||||||
@@ -135,6 +130,19 @@ function readNotified(): Record<string, number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merkliste im Arbeitsspeicher dieses Tabs. Sie greift IMMER, auch wenn der
|
||||||
|
* localStorage gesperrt, voll oder im privaten Modus nicht nutzbar ist — sonst
|
||||||
|
* meldete der Melder dieselbe faellige Erinnerung 24 Stunden lang bei jeder
|
||||||
|
* Pruefung erneut. Der localStorage bleibt fuer die Abstimmung zwischen Tabs.
|
||||||
|
*/
|
||||||
|
const notifiedInMemory = new Map<string, number>();
|
||||||
|
|
||||||
|
/** Nur fuer Tests: leert die Merkliste im Arbeitsspeicher. */
|
||||||
|
export function resetNotifiedMemoryForTests(): void {
|
||||||
|
notifiedInMemory.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merkt sich `key` (`${id}|${dueAt}`) lokal. `true` nur beim ERSTEN Mal — so
|
* Merkt sich `key` (`${id}|${dueAt}`) lokal. `true` nur beim ERSTEN Mal — so
|
||||||
* meldet jeder Client jede Faelligkeit hoechstens einmal, auch ueber mehrere
|
* meldet jeder Client jede Faelligkeit hoechstens einmal, auch ueber mehrere
|
||||||
@@ -143,16 +151,22 @@ function readNotified(): Record<string, number> {
|
|||||||
* aelter als 7 Tage werden dabei entfernt.
|
* aelter als 7 Tage werden dabei entfernt.
|
||||||
*/
|
*/
|
||||||
export function claimNotification(key: string, nowMs: number): boolean {
|
export function claimNotification(key: string, nowMs: number): boolean {
|
||||||
|
for (const [k, at] of notifiedInMemory) {
|
||||||
|
if (nowMs - at > NOTIFIED_TTL_MS) notifiedInMemory.delete(k);
|
||||||
|
}
|
||||||
const record = readNotified();
|
const record = readNotified();
|
||||||
for (const [k, at] of Object.entries(record)) {
|
for (const [k, at] of Object.entries(record)) {
|
||||||
if (nowMs - at > NOTIFIED_TTL_MS) delete record[k];
|
if (nowMs - at > NOTIFIED_TTL_MS) delete record[k];
|
||||||
}
|
}
|
||||||
const first = !(key in record);
|
const first = !(key in record) && !notifiedInMemory.has(key);
|
||||||
if (first) record[key] = nowMs;
|
if (first) {
|
||||||
|
record[key] = nowMs;
|
||||||
|
notifiedInMemory.set(key, nowMs);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(NOTIFIED_KEY, JSON.stringify(record));
|
window.localStorage.setItem(NOTIFIED_KEY, JSON.stringify(record));
|
||||||
} catch {
|
} catch {
|
||||||
/* Speicher voll oder gesperrt: dann eben ohne Merkliste */
|
/* Speicher voll oder gesperrt: dann greift nur die Merkliste im Arbeitsspeicher */
|
||||||
}
|
}
|
||||||
return first;
|
return first;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
defaultNewReminderInputs,
|
defaultNewReminderInputs,
|
||||||
isoToLocalInputs,
|
isoToLocalInputs,
|
||||||
|
isSameLocalDay,
|
||||||
localInputsToIso,
|
localInputsToIso,
|
||||||
snoozeTarget,
|
snoozeTarget,
|
||||||
} from './reminder-time';
|
} from './reminder-time';
|
||||||
@@ -50,7 +51,9 @@ describe('snoozeTarget (E-05)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('morgen = urspruengliche Ortszeit am naechsten Tag', () => {
|
it('morgen = urspruengliche Ortszeit am naechsten Tag', () => {
|
||||||
expect(snoozeTarget('tomorrow', original, now).getTime()).toBe(local(2026, 9, 30, 14, 0).getTime());
|
expect(snoozeTarget('tomorrow', original, now).getTime()).toBe(
|
||||||
|
local(2026, 9, 30, 14, 0).getTime(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('morgen ueber das Monatsende', () => {
|
it('morgen ueber das Monatsende', () => {
|
||||||
@@ -67,3 +70,24 @@ describe('snoozeTarget (E-05)', () => {
|
|||||||
expect(target.getTime()).toBe(local(2026, 9, 30, 8, 15).getTime());
|
expect(target.getTime()).toBe(local(2026, 9, 30, 8, 15).getTime());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('snoozeTarget kurz nach Mitternacht / isSameLocalDay', () => {
|
||||||
|
it('faellig gestern 23:50, verschoben heute 00:10 -> heute 23:50 (heute, nicht morgen)', () => {
|
||||||
|
const n = local(2026, 9, 30, 0, 10);
|
||||||
|
const target = snoozeTarget('tomorrow', local(2026, 9, 29, 23, 50), n);
|
||||||
|
expect(target.getTime()).toBe(local(2026, 9, 30, 23, 50).getTime());
|
||||||
|
expect(isSameLocalDay(target, n)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Normalfall liegt am naechsten Tag', () => {
|
||||||
|
const n = local(2026, 9, 29, 14, 5);
|
||||||
|
const target = snoozeTarget('tomorrow', local(2026, 9, 29, 14, 0), n);
|
||||||
|
expect(isSameLocalDay(target, n)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isSameLocalDay vergleicht den Kalendertag in Ortszeit', () => {
|
||||||
|
expect(isSameLocalDay(local(2026, 9, 30, 0, 0), local(2026, 9, 30, 23, 59))).toBe(true);
|
||||||
|
expect(isSameLocalDay(local(2026, 9, 30, 23, 59), local(2026, 10, 1, 0, 0))).toBe(false);
|
||||||
|
expect(isSameLocalDay(local(2026, 9, 30), local(2025, 9, 30))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ export type SnoozePreset = '10m' | '1h' | 'tomorrow';
|
|||||||
* Ortszeit und geht einen Kalendertag weiter (`setDate(+1)` ist sommerzeit-
|
* Ortszeit und geht einen Kalendertag weiter (`setDate(+1)` ist sommerzeit-
|
||||||
* sicher), so oft, bis das Ergebnis in der Zukunft liegt: faellig heute 14:00,
|
* sicher), so oft, bis das Ergebnis in der Zukunft liegt: faellig heute 14:00,
|
||||||
* verschoben um 14:05 -> morgen 14:00; war die Erinnerung schon Tage ueberfaellig,
|
* verschoben um 14:05 -> morgen 14:00; war die Erinnerung schon Tage ueberfaellig,
|
||||||
* landet sie beim naechsten kuenftigen Vorkommen dieser Uhrzeit.
|
* landet sie beim naechsten kuenftigen Vorkommen dieser Uhrzeit. Das kann auch
|
||||||
|
* HEUTE sein (faellig gestern 23:50, verschoben heute 00:10 -> heute 23:50) —
|
||||||
|
* die Beschriftung leitet „heute“/„morgen“ deshalb aus dem Ergebnis ab
|
||||||
|
* (`isSameLocalDay`), nicht aus dem Namen der Option.
|
||||||
*/
|
*/
|
||||||
export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Date): Date {
|
export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Date): Date {
|
||||||
if (preset === '10m') return new Date(now.getTime() + 10 * 60_000);
|
if (preset === '10m') return new Date(now.getTime() + 10 * 60_000);
|
||||||
@@ -56,3 +59,12 @@ export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Dat
|
|||||||
} while (d.getTime() <= now.getTime());
|
} while (d.getTime() <= now.getTime());
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Liegen beide Zeitpunkte am selben Kalendertag (Ortszeit)? */
|
||||||
|
export function isSameLocalDay(a: Date, b: Date): boolean {
|
||||||
|
return (
|
||||||
|
a.getFullYear() === b.getFullYear() &&
|
||||||
|
a.getMonth() === b.getMonth() &&
|
||||||
|
a.getDate() === b.getDate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user