From ad503c45c0d2ee046e5060efafe4f34eac010498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 15:44:00 +0200 Subject: [PATCH] fix(medications): keep a late take off a future slot the card supplied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A twice-daily card advances its display-due to the evening slot once the morning slot's catch-up window has lapsed, so a late-morning "Genommen" tap posts `scheduledFor = ` with `takenAt = now`. Band attribution correctly refuses the take (it lands in no window), but the intake routes' source-agnostic convergence probe then bound it to the evening slot's pending REMINDER row — recording the morning dose as the evening one, silently consuming a slot the user had not reached, and jumping the dashboard "next intake" to the following day. Gate the convergence probe with `mayConvergeOntoSuppliedSlot`: a taken write may not converge onto a slot whose anchor is in the future relative to the take (the same forward-grace invariant `resolveCanonicalSlotInstant` already enforces). Off-window late takes now record standalone (ad-hoc), leaving both real slots intact; skips and pending echoes still name their slot deliberately. Applied to both the per-medication and bulk intake routes. --- src/app/api/medications/[id]/intake/route.ts | 17 +- .../intake/bulk/__tests__/route.test.ts | 54 ++- src/app/api/medications/intake/bulk/route.ts | 16 +- .../may-converge-onto-supplied-slot.test.ts | 81 +++++ src/lib/medications/scheduling/slot-upsert.ts | 41 +++ .../intake-late-take-future-slot.test.ts | 336 ++++++++++++++++++ 6 files changed, 515 insertions(+), 30 deletions(-) create mode 100644 src/lib/medications/scheduling/__tests__/may-converge-onto-supplied-slot.test.ts create mode 100644 tests/integration/intake-late-take-future-slot.test.ts diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 48e44bb9d..042d4e587 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -29,6 +29,7 @@ import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication- import { applyCanonicalSlotWrite, findPinConflict, + mayConvergeOntoSuppliedSlot, resolveForcedSlotForWrite, resolveSlotForWriteByBand, resolveSlotInstantForWrite, @@ -344,8 +345,22 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // index carries `source` and cannot catch it — inflating the // compliance rollup's scheduled count. A defaulted anchor (takenAt / // now) never names a slot, so the probe is skipped on that hot path. + // + // Dose-safety guard: a TAKEN write must not converge onto a slot whose + // anchor is in the future relative to the take. The card advances its + // display-due to the evening slot once the morning slot's catch-up + // window lapses, so a late-morning "Genommen" posts the evening slot as + // `scheduledFor`; band attribution already rejected the take, and the + // probe must not re-bind it forward onto the evening pending row (a + // late-morning dose silently consuming the 21:00 slot). It records + // standalone (ad-hoc) instead. const existingSlotRow = - scheduledFor !== undefined + scheduledFor !== undefined && + mayConvergeOntoSuppliedSlot({ + skipped, + takenAt: resolvedTakenAt, + suppliedSlot: incomingScheduledFor, + }) ? await prisma.medicationIntakeEvent.findFirst({ where: { userId: user.id, diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index afc975db1..9e549a248 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -476,26 +476,16 @@ describe("POST /api/medications/intake/bulk — v1.15.19 resolver-null convergen vi.useRealTimers(); }); - it("converges an early taken-write onto the pre-minted pending REMINDER row instead of inserting a sibling", async () => { - // Source-agnostic probe finds the live REMINDER row on the incoming - // instant… - vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce({ - id: "row-reminder", - } as never); - // …and the shared slot upsert re-reads + updates it in place. - vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ - { - id: "row-reminder", - takenAt: null, - skipped: false, - idempotencyKey: null, - scheduledFor: new Date("2026-06-15T05:00:00Z"), - source: "REMINDER", - createdAt: new Date("2026-06-15T00:00:00Z"), - }, - ] as never); - vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({ - id: "row-reminder", + it("records an early taken-write standalone; it does NOT snap forward onto the future slot's pending row", async () => { + // Dose-safety invariant: a taken dose must never be attributed to a slot + // whose anchor is still in the future (a late-morning take must not + // consume the evening slot just because the card / client supplied it as + // `scheduledFor`). Band attribution already refused this off-window take + // (resolver null); the convergence probe must honour the same + // `mayConvergeOntoSuppliedSlot` guard rather than re-binding the take + // forward onto the pre-minted REMINDER row. It records standalone instead. + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValueOnce({ + id: "row-standalone", } as never); const res = await POST( @@ -517,14 +507,22 @@ describe("POST /api/medications/intake/bulk — v1.15.19 resolver-null convergen entries: Array<{ status: string; id?: string }>; }; }; - // The slot did NOT fork into a second live event. - expect(body.data.updated).toBe(1); - expect(body.data.inserted).toBe(0); - expect(body.data.entries[0].status).toBe("updated"); - expect(body.data.entries[0].id).toBe("row-reminder"); - expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); - expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: "row-reminder" } }), + // Records as a fresh ad-hoc row — the future slot is left untouched. + expect(body.data.inserted).toBe(1); + expect(body.data.updated).toBe(0); + expect(body.data.entries[0].status).toBe("inserted"); + // The guard short-circuits the convergence probe (no findFirst) and the + // future pending row is never updated. + expect(prisma.medicationIntakeEvent.findFirst).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + // Ad-hoc contract: the row anchors on its own intake instant. + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + scheduledFor: new Date("2026-06-15T00:30:00.000Z"), + takenAt: new Date("2026-06-15T00:30:00.000Z"), + }), + }), ); }); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 54b33dc8b..33ee65c3a 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -53,6 +53,7 @@ import { import { applyCanonicalSlotWrite, findPinConflict, + mayConvergeOntoSuppliedSlot, resolveForcedSlotForWrite, resolveSlotForWriteByBand, resolveSlotInstantForWrite, @@ -487,8 +488,21 @@ async function postBulk(request: NextRequest): Promise { // Only an explicit client `scheduledFor` names a slot a row could // already sit on; a defaulted anchor (takenAt / now) never does, so // the probe is skipped on that path. + // + // Dose-safety guard: a TAKEN entry must not converge onto a slot + // whose anchor is in the future relative to the take. The resolver's + // forward guard already refused to snap a late take onto a future + // slot; the probe must honour the same invariant rather than binding + // the take forward onto that slot's pending REMINDER row (a + // late-morning dose consuming the evening slot). It records + // standalone (ad-hoc) instead. const existingSlotRow = - entry.scheduledFor !== undefined + entry.scheduledFor !== undefined && + mayConvergeOntoSuppliedSlot({ + skipped: entry.skipped, + takenAt: entry.takenAt ?? null, + suppliedSlot: incomingScheduledFor, + }) ? await prisma.medicationIntakeEvent.findFirst({ where: { userId: user.id, diff --git a/src/lib/medications/scheduling/__tests__/may-converge-onto-supplied-slot.test.ts b/src/lib/medications/scheduling/__tests__/may-converge-onto-supplied-slot.test.ts new file mode 100644 index 000000000..4fff31d80 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/may-converge-onto-supplied-slot.test.ts @@ -0,0 +1,81 @@ +/** + * Dose-safety guard for the intake routes' ad-hoc convergence probe. + * + * Regression for the "late-morning take consumes the evening slot" bug: the + * medication card advances its display-due to the 21:00 slot once the 09:00 + * slot's catch-up window has lapsed, so a "Genommen" tap at 13:08 posts + * `scheduledFor = 21:00` with `takenAt = now`. Band attribution correctly + * refuses the take (it falls in no window), but the source-agnostic + * convergence probe used to bind it to the 21:00 pending REMINDER row — + * recording a morning dose as the evening one and silently consuming a slot + * the user had not reached. The guard keeps a taken write off any slot whose + * anchor is in the future relative to the take. + */ +import { describe, expect, it } from "vitest"; + +import { mayConvergeOntoSuppliedSlot } from "@/lib/medications/scheduling/slot-upsert"; + +const DAY = "2026-03-10"; +const at = (hm: string) => new Date(`${DAY}T${hm}:00.000Z`); + +describe("mayConvergeOntoSuppliedSlot", () => { + it("refuses a taken write onto a slot in the future (the 13:08 → 21:00 bug)", () => { + expect( + mayConvergeOntoSuppliedSlot({ + skipped: false, + takenAt: at("13:08"), + suppliedSlot: at("21:00"), + }), + ).toBe(false); + }); + + it("allows a taken write onto a PAST slot (ledger 'mark the missed 09:00 slot taken')", () => { + expect( + mayConvergeOntoSuppliedSlot({ + skipped: false, + takenAt: at("13:08"), + suppliedSlot: at("09:00"), + }), + ).toBe(true); + }); + + it("allows a taken write onto the current slot within the forward clock-skew grace", () => { + expect( + mayConvergeOntoSuppliedSlot({ + skipped: false, + takenAt: at("08:58"), + suppliedSlot: at("09:00"), + }), + ).toBe(true); + }); + + it("refuses a taken write onto a slot just beyond the forward grace", () => { + expect( + mayConvergeOntoSuppliedSlot({ + skipped: false, + takenAt: at("08:50"), + suppliedSlot: at("09:00"), + }), + ).toBe(false); + }); + + it("always allows a deliberate skip to name its slot (past or future)", () => { + expect( + mayConvergeOntoSuppliedSlot({ + skipped: true, + takenAt: null, + suppliedSlot: at("21:00"), + }), + ).toBe(true); + }); + + it("always allows a pending echo (no takenAt) to target a future slot", () => { + expect( + mayConvergeOntoSuppliedSlot({ + skipped: false, + takenAt: null, + suppliedSlot: at("21:00"), + }), + ).toBe(true); + }); +}); diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index 1d0d3021e..4bc18c085 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -114,6 +114,47 @@ const SLOT_ROW_SELECT = { createdAt: true, } as const; +/** + * Forward grace a TAKEN write may still snap onto a slot whose anchor sits + * ahead of the take — the same clock-skew / early-dose margin the resolver's + * `TAKEN_FORWARD_GRACE_MS` uses. + */ +export const TAKEN_SLOT_FORWARD_GRACE_MS = 5 * 60 * 1000; + +/** + * Whether a write may converge onto a live row already sitting on the + * client-supplied `scheduledFor` slot (the intake routes' "ad-hoc / off-slot" + * fallback probe, after band/canonical attribution returned null). + * + * A TAKEN dose must never fill a slot whose anchor is meaningfully in the + * FUTURE relative to when it was taken. The medication card shows the next + * open slot as its display-due; once a morning slot's catch-up window has + * lapsed the card advances to the EVENING slot, and a "Genommen" tap then + * posts `scheduledFor = ` with `takenAt = now`. Band attribution + * correctly refuses that late-morning take (it lands in no window), but the + * source-agnostic convergence probe would otherwise bind it to the evening + * slot's pending REMINDER row — recording the take as the evening dose and + * silently consuming a slot the user has not reached. This is the exact + * "late morning dose snapping forward onto the evening slot" failure the band + * model and the resolver's forward guard already forbid; the probe must honour + * the same invariant. + * + * Skips and pending echoes (no `takenAt`) name their slot deliberately — a + * deliberate future skip or an offline sync echo legitimately targets a + * future slot — so they are unaffected. + */ +export function mayConvergeOntoSuppliedSlot(input: { + skipped: boolean; + takenAt: Date | null; + suppliedSlot: Date; +}): boolean { + if (input.skipped || input.takenAt === null) return true; + return ( + input.suppliedSlot.getTime() <= + input.takenAt.getTime() + TAKEN_SLOT_FORWARD_GRACE_MS + ); +} + /** * Deterministically pick the slot row a write should converge onto when * the canonical instant carries more than one live row (pre-existing diff --git a/tests/integration/intake-late-take-future-slot.test.ts b/tests/integration/intake-late-take-future-slot.test.ts new file mode 100644 index 000000000..ddffd0969 --- /dev/null +++ b/tests/integration/intake-late-take-future-slot.test.ts @@ -0,0 +1,336 @@ +/** + * Regression: a late-morning take must not consume the evening slot. + * + * The medication card threads the DISPLAYED dose's slot onto the intake POST + * as `scheduledFor`. Once a morning slot's catch-up window has lapsed the card + * advances its display-due to the EVENING slot, so a "Genommen" tap for the + * (missed) morning dose posts `scheduledFor = 21:00` with `takenAt = now` + * (13:08). Band attribution correctly refuses the take (it lands in no + * window), but the route's source-agnostic convergence probe used to bind it + * to the 21:00 pending REMINDER row — recording the morning dose as the + * evening one, silently consuming a slot the user had not reached, and jumping + * the dashboard "next intake" to tomorrow. + * + * These tests drive the SAME shipped helpers the per-medication intake route + * runs for a taken write (`resolveSlotForWriteByBand` → the + * `mayConvergeOntoSuppliedSlot` guard → the convergence probe / + * `applyCanonicalSlotWrite` / standalone insert) against real Postgres for a + * twice-daily 09:00 / 21:00 medication, then assert the read model the + * dashboard composes (`computeDisplayDue` + the taken/scheduled tally) reflects + * the correct state. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +import type { PrismaClient } from "@/generated/prisma/client"; +import { + applyCanonicalSlotWrite, + mayConvergeOntoSuppliedSlot, + resolveSlotForWriteByBand, +} from "@/lib/medications/scheduling/slot-upsert"; +import { + computeDisplayDue, + toResolvedSlotMark, +} from "@/lib/medications/scheduling/next-due"; +import { localHmAsUtc } from "@/lib/tz/local-day"; + +const TEST_USER_ID = "user-late-take-future-slot"; +const TZ = "Europe/Berlin"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +// Fixed non-DST-boundary weekday so the slot instants are deterministic. +const DAY = new Date("2026-03-10T12:00:00.000Z"); +const slot0900 = () => localHmAsUtc(DAY, TZ, 9, 0); +const slot2100 = () => localHmAsUtc(DAY, TZ, 21, 0); +/** The real recording moment: 13:08, i.e. 8 minutes past the 09:00 slot's + * +4h catch-up cutoff (09:00 on-time ±1h → 10:00, overdue tail → 13:00). */ +const takenAt1308 = () => localHmAsUtc(DAY, TZ, 13, 8); + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: TEST_USER_ID, + username: "late-take-future-slot", + email: "late-take-future-slot@example.test", + timezone: TZ, + }, + }); +}); + +async function createTwiceDailyMed(): Promise { + const prisma = getPrismaClient(); + const med = await prisma.medication.create({ + data: { + userId: TEST_USER_ID, + name: "Ramipril", + dose: "5mg", + active: true, + startsOn: new Date("2026-01-01T00:00:00.000Z"), + schedules: { + create: { + windowStart: "09:00", + windowEnd: "21:00", + timesOfDay: ["09:00", "21:00"], + daysOfWeek: null, + scheduleType: "SCHEDULED", + }, + }, + }, + }); + return med.id; +} + +/** Mint the day's two pending REMINDER rows exactly as the projector would. */ +async function projectPendingSlots(medId: string): Promise { + const prisma = getPrismaClient(); + await prisma.medicationIntakeEvent.createMany({ + data: [ + { + userId: TEST_USER_ID, + medicationId: medId, + scheduledFor: slot0900(), + takenAt: null, + skipped: false, + source: "REMINDER", + }, + { + userId: TEST_USER_ID, + medicationId: medId, + scheduledFor: slot2100(), + takenAt: null, + skipped: false, + source: "REMINDER", + }, + ], + }); +} + +/** + * Replicate the per-medication intake route's taken-write branch for a card + * "Genommen" tap: band attribution on `takenAt`, then the ad-hoc fallback + * (the `mayConvergeOntoSuppliedSlot` guard gating the convergence probe, else + * a standalone insert). Uses the real shipped helpers so the guard under test + * is the one shipped in the route. + */ +async function recordViaCard( + prisma: PrismaClient, + medId: string, + suppliedSlot: Date, + takenAt: Date, +): Promise { + const attribution = await resolveSlotForWriteByBand({ + userId: TEST_USER_ID, + medicationId: medId, + userTz: TZ, + takenAt, + now: takenAt, + }); + const canonicalSlot = attribution.slotInstant; + + if (canonicalSlot) { + await applyCanonicalSlotWrite({ + client: prisma, + userId: TEST_USER_ID, + medicationId: medId, + canonicalSlot, + takenAt, + skipped: false, + isExplicitTaken: true, + isExplicitSkip: false, + idempotencyKey: null, + createSource: "WEB", + attributionSource: "AUTO", + }); + return; + } + + const existingSlotRow = mayConvergeOntoSuppliedSlot({ + skipped: false, + takenAt, + suppliedSlot, + }) + ? await prisma.medicationIntakeEvent.findFirst({ + where: { + userId: TEST_USER_ID, + medicationId: medId, + scheduledFor: suppliedSlot, + deletedAt: null, + }, + select: { id: true }, + }) + : null; + + if (existingSlotRow) { + await applyCanonicalSlotWrite({ + client: prisma, + userId: TEST_USER_ID, + medicationId: medId, + canonicalSlot: suppliedSlot, + takenAt, + skipped: false, + isExplicitTaken: true, + isExplicitSkip: false, + idempotencyKey: null, + createSource: "WEB", + attributionSource: "AUTO", + }); + } else { + // Genuinely standalone (ad-hoc): anchor on the intake instant. + await prisma.medicationIntakeEvent.create({ + data: { + userId: TEST_USER_ID, + medicationId: medId, + scheduledFor: takenAt, + takenAt, + skipped: false, + source: "WEB", + }, + }); + } +} + +async function readDisplayDue(medId: string, now: Date): Promise { + const prisma = getPrismaClient(); + const med = await prisma.medication.findUniqueOrThrow({ + where: { id: medId }, + include: { schedules: true }, + }); + // Mirror `buildMedsTodayBlock`: resolved slots = taken / skipped / autoMissed + // rows, each carrying its anchoring shape. + const resolvedEvents = await prisma.medicationIntakeEvent.findMany({ + where: { + userId: TEST_USER_ID, + medicationId: medId, + deletedAt: null, + OR: [{ takenAt: { not: null } }, { skipped: true }, { autoMissed: true }], + }, + select: { scheduledFor: true, takenAt: true }, + }); + const display = computeDisplayDue({ + medication: { + id: med.id, + startsOn: med.startsOn, + endsOn: med.endsOn, + oneShot: med.oneShot, + createdAt: med.createdAt, + }, + schedules: med.schedules, + now, + userTz: TZ, + lastIntakeAt: null, + resolvedSlots: resolvedEvents.map(toResolvedSlotMark), + }); + return display?.at ?? null; +} + +describe("late-morning take does not consume the evening slot", () => { + it("records the 13:08 take standalone; the 21:00 slot stays pending", async () => { + const prisma = getPrismaClient(); + const medId = await createTwiceDailyMed(); + await projectPendingSlots(medId); + + // Card shows 21:00 (09:00 catch-up lapsed); the tap posts scheduledFor=21:00. + await recordViaCard(prisma, medId, slot2100(), takenAt1308()); + + const eveningRow = await prisma.medicationIntakeEvent.findFirstOrThrow({ + where: { + userId: TEST_USER_ID, + medicationId: medId, + scheduledFor: slot2100(), + deletedAt: null, + }, + }); + // The evening slot must NOT have been consumed by the morning take. + expect(eveningRow.takenAt).toBeNull(); + expect(eveningRow.skipped).toBe(false); + + // Exactly one taken row, anchored ad-hoc on its own 13:08 instant. + const takenRows = await prisma.medicationIntakeEvent.findMany({ + where: { + userId: TEST_USER_ID, + medicationId: medId, + deletedAt: null, + takenAt: { not: null }, + }, + }); + expect(takenRows).toHaveLength(1); + expect(takenRows[0]!.takenAt!.getTime()).toBe(takenAt1308().getTime()); + // Ad-hoc contract: scheduledFor === takenAt (not a slot anchor). + expect(takenRows[0]!.scheduledFor.getTime()).toBe(takenAt1308().getTime()); + + // The morning slot stays open too (nothing filled it). + const morningRow = await prisma.medicationIntakeEvent.findFirstOrThrow({ + where: { + userId: TEST_USER_ID, + medicationId: medId, + scheduledFor: slot0900(), + deletedAt: null, + }, + }); + expect(morningRow.takenAt).toBeNull(); + }); + + it("next intake stays 21:00 today — it does not jump to tomorrow", async () => { + const prisma = getPrismaClient(); + const medId = await createTwiceDailyMed(); + await projectPendingSlots(medId); + + await recordViaCard(prisma, medId, slot2100(), takenAt1308()); + + const due = await readDisplayDue(medId, takenAt1308()); + expect(due).not.toBeNull(); + // The still-open evening slot today, NOT tomorrow's 09:00. + expect(due!.getTime()).toBe(slot2100().getTime()); + }); + + it("the pre-fix convergence would have consumed 21:00 and jumped to tomorrow (mechanism)", async () => { + const prisma = getPrismaClient(); + const medId = await createTwiceDailyMed(); + await projectPendingSlots(medId); + + // Reproduce the OLD behaviour explicitly: converge the 13:08 take onto the + // 21:00 pending row (what the ungated probe did). + await applyCanonicalSlotWrite({ + client: prisma, + userId: TEST_USER_ID, + medicationId: medId, + canonicalSlot: slot2100(), + takenAt: takenAt1308(), + skipped: false, + isExplicitTaken: true, + isExplicitSkip: false, + idempotencyKey: null, + createSource: "WEB", + attributionSource: "AUTO", + }); + + const eveningRow = await prisma.medicationIntakeEvent.findFirstOrThrow({ + where: { + userId: TEST_USER_ID, + medicationId: medId, + scheduledFor: slot2100(), + deletedAt: null, + }, + }); + // Confirms the bug shape: the evening slot carries the morning take. + expect(eveningRow.takenAt!.getTime()).toBe(takenAt1308().getTime()); + + // And the dashboard next-due jumps past today to tomorrow's 09:00. + const due = await readDisplayDue(medId, takenAt1308()); + expect(due).not.toBeNull(); + expect(due!.getTime()).toBeGreaterThan(slot2100().getTime()); + const tomorrow0900 = localHmAsUtc( + new Date(DAY.getTime() + 24 * 60 * 60 * 1000), + TZ, + 9, + 0, + ); + expect(due!.getTime()).toBe(tomorrow0900.getTime()); + }); +});