From c2e4467dd89eb7fb5f36011b42c47d70cab50ca5 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 30 Sep 2026 03:20:16 +0200 Subject: [PATCH] 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) --- apps/api/src/reminders/dto/reminder.dto.ts | 32 +++++- .../reminders/reminder-mail.scheduler.spec.ts | 58 ++++++++-- .../src/reminders/reminder-mail.scheduler.ts | 11 +- .../reminders/reminders.controller.spec.ts | 37 ++++++- .../src/reminders/reminders.service.spec.ts | 103 ++++++++++++++++-- apps/api/src/reminders/reminders.service.ts | 44 +++++++- .../dashboard/widgets/reminder-form-modal.tsx | 21 ++-- .../widgets/reminder-widget.test.tsx | 68 +++++++++++- .../dashboard/widgets/reminder-widget.tsx | 29 +++-- .../reminders/reminder-notifier.test.tsx | 17 ++- apps/web/src/lib/reminder-notify.test.ts | 28 ++++- apps/web/src/lib/reminder-notify.ts | 32 ++++-- apps/web/src/lib/reminder-time.test.ts | 26 ++++- apps/web/src/lib/reminder-time.ts | 14 ++- 14 files changed, 442 insertions(+), 78 deletions(-) diff --git a/apps/api/src/reminders/dto/reminder.dto.ts b/apps/api/src/reminders/dto/reminder.dto.ts index 669a8fe..940e644 100644 --- a/apps/api/src/reminders/dto/reminder.dto.ts +++ b/apps/api/src/reminders/dto/reminder.dto.ts @@ -1,4 +1,3 @@ -import { PartialType } from '@nestjs/mapped-types'; import { Transform } from 'class-transformer'; import { IsBoolean, @@ -7,6 +6,7 @@ import { IsOptional, IsString, MaxLength, + ValidateIf, } from 'class-validator'; const trimString = ({ value }: { value: unknown }) => @@ -44,12 +44,40 @@ export class CreateReminderDto { 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. * Eine faellige Erinnerung laesst sich nicht aendern (409 im Dienst) — dafuer * 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). */ export class SnoozeReminderDto { diff --git a/apps/api/src/reminders/reminder-mail.scheduler.spec.ts b/apps/api/src/reminders/reminder-mail.scheduler.spec.ts index 2128685..24be17b 100644 --- a/apps/api/src/reminders/reminder-mail.scheduler.spec.ts +++ b/apps/api/src/reminders/reminder-mail.scheduler.spec.ts @@ -48,7 +48,11 @@ function row(over: Partial & { id: string }): Row { const sameTime = (a: Date | null, b: Date | null) => a === null || b === null ? a === b : a.getTime() === b.getTime(); -function makeStore(rows: Row[], emails: Record = { u1: 'u1@example.invalid' }) { +function makeStore( + rows: Row[], + emails: Record = { u1: 'u1@example.invalid' }, + inactive: string[] = [], +) { const systemFindMany = vi.fn(async ({ where, take, orderBy }: any) => { let list = rows.filter( (r) => @@ -58,8 +62,11 @@ function makeStore(rows: Row[], emails: Record = { u1: 'u r.dueAt.getTime() <= where.dueAt.lte.getTime() && r.dueAt.getTime() >= where.dueAt.gte.getTime(), ); - if (orderBy?.dueAt === 'asc') 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 })); + if (orderBy?.dueAt === 'asc') + 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[] = []; @@ -69,11 +76,13 @@ function makeStore(rows: Row[], emails: Record = { u1: 'u boundLog.push(`updateMany:${tenantId}`); let count = 0; 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 ('emailEnabled' in where && r.emailEnabled !== where.emailEnabled) 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.emailAttempts?.increment) r.emailAttempts += data.emailAttempts.increment; count++; @@ -89,7 +98,11 @@ function makeStore(rows: Row[], emails: Record = { u1: 'u }), }, 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 = { u1: 'u return { prisma, rows, systemFindMany, boundLog }; } -function makeScheduler( - prisma: any, - opts: { smtp?: unknown; sendResult?: boolean | Error } = {}, -) { +function makeScheduler(prisma: any, opts: { smtp?: unknown; sendResult?: boolean | Error } = {}) { const registry = { addInterval: vi.fn(), deleteInterval: vi.fn() }; const settings = { getSmtpConfig: vi.fn(async () => (opts.smtp === undefined ? {} : opts.smtp)) }; const mail = { @@ -112,7 +122,12 @@ function makeScheduler( 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 }; } @@ -126,7 +141,9 @@ describe('ReminderMailScheduler — Anspruch (T-IF2-06)', () => { const one = makeScheduler(store.prisma); const two = makeScheduler(store.prisma); 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].emailAttempts).toBe(1); }); @@ -223,6 +240,23 @@ describe('ReminderMailScheduler — Fehlschlag und Wiederholung (E-04)', () => { expect(mail.sendReminderEmail).not.toHaveBeenCalled(); 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)', () => { diff --git a/apps/api/src/reminders/reminder-mail.scheduler.ts b/apps/api/src/reminders/reminder-mail.scheduler.ts index 387b339..5c3224c 100644 --- a/apps/api/src/reminders/reminder-mail.scheduler.ts +++ b/apps/api/src/reminders/reminder-mail.scheduler.ts @@ -26,7 +26,7 @@ const BATCH = 200; * Scheitert der Transport, gibt der Planer den Anspruch wieder frei * (`emailSentAt = null`), sodass der naechste Durchlauf es erneut versucht — * 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 * („Spaeter erinnern“) setzt beide Felder zurueck (siehe `RemindersService`). * @@ -104,9 +104,7 @@ export class ReminderMailScheduler implements OnApplicationBootstrap { await this.processCandidate(candidate, now); } catch (err) { // Eine kaputte Zeile darf die uebrigen nicht anhalten. - this.logger.error( - `Reminder email for ${candidate.id} failed: ${(err as Error).message}`, - ); + this.logger.error(`Reminder email for ${candidate.id} failed: ${(err as Error).message}`); } } } finally { @@ -140,14 +138,15 @@ export class ReminderMailScheduler implements OnApplicationBootstrap { where: { id: c.id, tenantId: c.tenantId, dueAt: c.dueAt }, select: { title: true, description: true, dueAt: true }, }); + // Nur aktive Konten: ein deaktivierter Benutzer gilt wie einer ohne Adresse. 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 }, }); const smtp = await this.settingsService.getSmtpConfig(c.tenantId); if (!row || !user?.email || smtp === null) { 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) } diff --git a/apps/api/src/reminders/reminders.controller.spec.ts b/apps/api/src/reminders/reminders.controller.spec.ts index e78ef2b..459bed9 100644 --- a/apps/api/src/reminders/reminders.controller.spec.ts +++ b/apps/api/src/reminders/reminders.controller.spec.ts @@ -1,5 +1,5 @@ import 'reflect-metadata'; -import { ForbiddenException, ValidationPipe } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; import { ROLES_KEY } from '../auth/decorators/roles.decorator'; 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; 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(); }); @@ -55,7 +62,9 @@ describe('RemindersController — Mandant', () => { const controller = new RemindersController(makeService() as any); await expect(controller.list(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( controller.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }), ).rejects.toBeInstanceOf(ForbiddenException); @@ -96,6 +105,28 @@ describe('RemindersController — Pipe fuer Aendern und Verschieben', () => { 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 () => { const pipe = new ValidationPipe({ whitelist: true, transform: true }); const out: any = await pipe.transform( diff --git a/apps/api/src/reminders/reminders.service.spec.ts b/apps/api/src/reminders/reminders.service.spec.ts index 27d80ca..4de8044 100644 --- a/apps/api/src/reminders/reminders.service.spec.ts +++ b/apps/api/src/reminders/reminders.service.spec.ts @@ -16,7 +16,13 @@ function makeFakePrisma() { const reminder = { create: vi.fn(async ({ data }: { data: any }) => { 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); return row; }), @@ -37,6 +43,14 @@ function makeFakePrisma() { rows.set(where.id, 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 }) => { rows.delete(where.id); }), @@ -106,7 +120,9 @@ describe('RemindersService — anlegen', () => { service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) }), ).rejects.toBeInstanceOf(ConflictException); // 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) => { const { prisma, service } = setup(); seed(prisma, 'x', past(), owner); - await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(NotFoundException); - await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf(NotFoundException); + await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf( + NotFoundException, + ); + await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf( + NotFoundException, + ); await expect(service.remove('t1', 'u1', 'x')).rejects.toBeInstanceOf(NotFoundException); expect(prisma.rows.has('x')).toBe(true); }); it('eine unbekannte Kennung ergibt 404', async () => { 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 () => { @@ -173,10 +195,11 @@ describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)' tenantId: 't1', userId: 'u1', }); - expect(prisma.reminder.update.mock.calls[0]?.[0]?.where).toEqual({ + expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.where).toEqual({ id: 'x', tenantId: 't1', userId: 'u1', + dueAt: { gt: expect.any(Date) }, }); }); }); @@ -185,7 +208,9 @@ describe('RemindersService — aendern', () => { it('eine faellige Erinnerung laesst sich nicht aendern (409)', async () => { const { prisma, service } = setup(); 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 () => { @@ -200,14 +225,61 @@ describe('RemindersService — aendern', () => { const { prisma, service } = setup(); seed(prisma, 'x', future()); 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); 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: '', 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)', () => { @@ -276,7 +348,10 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => { hasEmail: false, }); 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 () => { @@ -298,7 +373,11 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => { ).rejects.toBeInstanceOf(BadRequestException); const noMail = setup(); 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); expect(noSmtp.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, ); 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 }); }); }); diff --git a/apps/api/src/reminders/reminders.service.ts b/apps/api/src/reminders/reminders.service.ts index 274807f..b4fa32d 100644 --- a/apps/api/src/reminders/reminders.service.ts +++ b/apps/api/src/reminders/reminders.service.ts @@ -5,8 +5,8 @@ import { NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; -import { SettingsService } from '../settings/settings.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; +import { SettingsService } from '../settings/settings.service'; import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto'; /** 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 * (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) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); 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'); } - 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.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) await this.assertEmailAvailable(tenantId, userId); data.emailEnabled = dto.emailEnabled; } - return tenantPrisma.reminder.update({ - where: { id, tenantId, userId }, + const result = await tenantPrisma.reminder.updateMany({ + where: { id, tenantId, userId, dueAt: { gt: new Date() } }, 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, }); + if (!updated) { + throw new NotFoundException('Erinnerung nicht gefunden'); + } + return updated; } /** diff --git a/apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx b/apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx index 5e3048f..e76e090 100644 --- a/apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx +++ b/apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx @@ -6,10 +6,10 @@ import { createPortal } from 'react-dom'; import { requestBrowserPermissionOnce } from '@/lib/reminder-notify'; import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time'; import { + createReminder, type Reminder, type ReminderEmailStatus, ReminderRequestError, - createReminder, updateReminder, } from '@/lib/reminders-api'; @@ -85,9 +85,15 @@ export function ReminderFormModal({ setError(t('reminder.titleRequired')); 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. - if (dueAt === null || new Date(dueAt).getTime() <= Date.now()) { + if (dueChanged && (!dueAt || new Date(dueAt).getTime() <= Date.now())) { setError(t('reminder.pastError')); return; } @@ -98,16 +104,15 @@ export function ReminderFormModal({ // ist: ein Server, der E-Mail nicht mehr anbietet, lehnt `true` mit 400 // ab — eine unveraenderte Alt-Einstellung darf das Speichern nicht kippen. const emailChanged = reminder ? emailEnabled !== reminder.emailEnabled : emailEnabled; - const input = { + const common = { title: trimmedTitle, description: description.trim(), - dueAt, ...(emailChanged ? { emailEnabled } : {}), }; if (reminder) { - await updateReminder(reminder.id, input); - } else { - await createReminder(input); + await updateReminder(reminder.id, { ...common, ...(dueAt ? { dueAt } : {}) }); + } else if (dueAt) { + await createReminder({ ...common, dueAt }); } onSaved(); onClose(); diff --git a/apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx b/apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx index 4248f0d..f7d5695 100644 --- a/apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx @@ -21,11 +21,11 @@ vi.mock('@/lib/reminders-api', async () => { }); import { - ReminderRequestError, createReminder, deleteReminder, getReminderEmailStatus, listReminders, + ReminderRequestError, snoozeReminder, updateReminder, } from '@/lib/reminders-api'; @@ -79,7 +79,9 @@ async function openForm() { function fillAndSubmit(title: string) { 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.click(screen.getByText('reminder.save')); } @@ -146,7 +148,9 @@ describe('ReminderWidget', () => { render(); await openForm(); 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')); expect(await screen.findByText('reminder.pastError')).toBeTruthy(); expect(mockCreate).not.toHaveBeenCalled(); @@ -260,6 +264,38 @@ describe('ReminderWidget — faellig und Aktionen (D-03)', () => { 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(); + 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(); + 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 () => { mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]); 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(); + 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)', () => { async function openFormAndWaitForStatus() { 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 () => { mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true }); 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({}); render(); diff --git a/apps/web/src/components/dashboard/widgets/reminder-widget.tsx b/apps/web/src/components/dashboard/widgets/reminder-widget.tsx index bbd6427..f5f35ae 100644 --- a/apps/web/src/components/dashboard/widgets/reminder-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/reminder-widget.tsx @@ -3,15 +3,15 @@ import { useLocale, useTranslations } from 'next-intl'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { browserPermissionState } from '@/lib/reminder-notify'; -import { type SnoozePreset, snoozeTarget } from '@/lib/reminder-time'; +import { isSameLocalDay, type SnoozePreset, snoozeTarget } from '@/lib/reminder-time'; import { + deleteReminder, + getReminderEmailStatus, + listReminders, REMINDERS_CHANGED_EVENT, type Reminder, type ReminderEmailStatus, ReminderRequestError, - deleteReminder, - getReminderEmailStatus, - listReminders, snoozeReminder, } from '@/lib/reminders-api'; 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. */ const handleFailure = (err: unknown, conflictKey: string) => { 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(); }; @@ -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) => { setNotice(null); setConfirmDeleteId(null); @@ -177,7 +189,9 @@ export function ReminderWidget(_props: WidgetProps) { } >
-

{r.title}

+

+ {r.title} +

{r.emailEnabled && ( {preset === '10m' && t('reminder.snooze10m')} {preset === '1h' && t('reminder.snooze1h')} - {preset === 'tomorrow' && - t('reminder.snoozeTomorrow', { time: timeOnly.format(new Date(r.dueAt)) })} + {preset === 'tomorrow' && tomorrowLabel(r)} ))}
diff --git a/apps/web/src/components/reminders/reminder-notifier.test.tsx b/apps/web/src/components/reminders/reminder-notifier.test.tsx index 4c04e06..0205bc0 100644 --- a/apps/web/src/components/reminders/reminder-notifier.test.tsx +++ b/apps/web/src/components/reminders/reminder-notifier.test.tsx @@ -17,12 +17,13 @@ vi.mock('@/lib/reminders-api', async () => { }); vi.mock('@/lib/reminder-notify', async () => { - const actual = await vi.importActual('@/lib/reminder-notify'); + const actual = + await vi.importActual('@/lib/reminder-notify'); return { ...actual, showReminderNotification: vi.fn(async () => undefined) }; }); -import { showReminderNotification } from '@/lib/reminder-notify'; -import { ReminderRequestError, listReminders } from '@/lib/reminders-api'; +import { resetNotifiedMemoryForTests, showReminderNotification } from '@/lib/reminder-notify'; +import { listReminders, ReminderRequestError } from '@/lib/reminders-api'; import { ReminderNotifier } from './reminder-notifier'; const mockList = listReminders as ReturnType; @@ -53,6 +54,7 @@ beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(NOW); window.localStorage.clear(); + resetNotifiedMemoryForTests(); mockList.mockReset(); mockShow.mockClear(); }); @@ -77,7 +79,9 @@ describe('ReminderNotifier', () => { }); 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(); await advance(30_000); expect(mockShow).not.toHaveBeenCalled(); @@ -85,7 +89,10 @@ describe('ReminderNotifier', () => { it('nimmt die Beschreibung als Text, gekuerzt auf 200 Zeichen', async () => { 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(); await advance(0); diff --git a/apps/web/src/lib/reminder-notify.test.ts b/apps/web/src/lib/reminder-notify.test.ts index b470d2d..74899a1 100644 --- a/apps/web/src/lib/reminder-notify.test.ts +++ b/apps/web/src/lib/reminder-notify.test.ts @@ -5,6 +5,7 @@ import { isTauriWebview, remindersToNotify, requestBrowserPermissionOnce, + resetNotifiedMemoryForTests, showReminderNotification, withNotifyLock, } from './reminder-notify'; @@ -34,6 +35,7 @@ function installNotification(permission: NotificationPermission) { beforeEach(() => { window.localStorage.clear(); + resetNotifiedMemoryForTests(); }); afterEach(() => { @@ -65,6 +67,28 @@ describe('claimNotification', () => { const stored = JSON.parse(window.localStorage.getItem('tessera.reminders.notified') ?? '{}'); 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', () => { @@ -93,7 +117,9 @@ describe('showReminderNotification', () => { const invoke = installTauri(); invoke.mockRejectedValue(new Error('nope')); 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); }); diff --git a/apps/web/src/lib/reminder-notify.ts b/apps/web/src/lib/reminder-notify.ts index f24994c..8eba589 100644 --- a/apps/web/src/lib/reminder-notify.ts +++ b/apps/web/src/lib/reminder-notify.ts @@ -30,12 +30,7 @@ export const CATCH_UP_WINDOW_MS = 24 * 60 * 60 * 1000; /** Eintraege der lokalen Merkliste, die aelter sind, werden entfernt. */ const NOTIFIED_TTL_MS = 7 * 24 * 60 * 60 * 1000; -export type BrowserPermissionState = - | 'desktop' - | 'granted' - | 'default' - | 'denied' - | 'unsupported'; +export type BrowserPermissionState = 'desktop' | 'granted' | 'default' | 'denied' | 'unsupported'; type TauriInvoke = (command: string, args?: Record) => Promise; @@ -135,6 +130,19 @@ function readNotified(): Record { } } +/** + * 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(); + +/** 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 * meldet jeder Client jede Faelligkeit hoechstens einmal, auch ueber mehrere @@ -143,16 +151,22 @@ function readNotified(): Record { * aelter als 7 Tage werden dabei entfernt. */ 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(); for (const [k, at] of Object.entries(record)) { if (nowMs - at > NOTIFIED_TTL_MS) delete record[k]; } - const first = !(key in record); - if (first) record[key] = nowMs; + const first = !(key in record) && !notifiedInMemory.has(key); + if (first) { + record[key] = nowMs; + notifiedInMemory.set(key, nowMs); + } try { window.localStorage.setItem(NOTIFIED_KEY, JSON.stringify(record)); } catch { - /* Speicher voll oder gesperrt: dann eben ohne Merkliste */ + /* Speicher voll oder gesperrt: dann greift nur die Merkliste im Arbeitsspeicher */ } return first; } diff --git a/apps/web/src/lib/reminder-time.test.ts b/apps/web/src/lib/reminder-time.test.ts index 172992e..df08e4b 100644 --- a/apps/web/src/lib/reminder-time.test.ts +++ b/apps/web/src/lib/reminder-time.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { defaultNewReminderInputs, isoToLocalInputs, + isSameLocalDay, localInputsToIso, snoozeTarget, } from './reminder-time'; @@ -50,7 +51,9 @@ describe('snoozeTarget (E-05)', () => { }); 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', () => { @@ -67,3 +70,24 @@ describe('snoozeTarget (E-05)', () => { 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); + }); +}); diff --git a/apps/web/src/lib/reminder-time.ts b/apps/web/src/lib/reminder-time.ts index 70f1ca8..06dca6e 100644 --- a/apps/web/src/lib/reminder-time.ts +++ b/apps/web/src/lib/reminder-time.ts @@ -45,7 +45,10 @@ export type SnoozePreset = '10m' | '1h' | 'tomorrow'; * Ortszeit und geht einen Kalendertag weiter (`setDate(+1)` ist sommerzeit- * 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, - * 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 { 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()); 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() + ); +}