mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #191 from MBombeck/fix/medication-schedule-null-days-of-week
fix(medications): treat null/empty daysOfWeek as every-day schedule
This commit is contained in:
4 files changed
+481
-2
No files matched your search
@@ -7,8 +7,17 @@ vi.mock("@/lib/db", () => ({
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
// v1.4.39 W-SERVER-FIX — `scope=today` now backfills missing
|
||||
// rows for schedules whose window opens today (covers daily
|
||||
// meds with `daysOfWeek: null` whose reminder hasn't fired yet).
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
medication: {
|
||||
update: vi.fn(),
|
||||
// v1.4.39 W-SERVER-FIX — today's projection reads active meds +
|
||||
// their schedules to know what to backfill.
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
medication: { update: vi.fn() },
|
||||
medicationComplianceRollup: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
@@ -65,6 +74,13 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
// v1.4.39 W-SERVER-FIX — default the today-projection prisma reads
|
||||
// to "no active medications" so existing compliance / POST tests
|
||||
// exercise their original paths without an unmocked-call failure.
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
// v1.4.39 W-MED — default the coverage probe to "uncovered" so the
|
||||
// legacy compliance test still exercises the live-fallback branch
|
||||
// and finds the legacy mocked intake events.
|
||||
@@ -123,6 +139,112 @@ describe("GET /api/medications/intake", () => {
|
||||
expect(body.data[0].status).toBe("pending");
|
||||
});
|
||||
|
||||
it("backfills missing today rows for daily meds (daysOfWeek=null)", async () => {
|
||||
// v1.4.39 W-SERVER-FIX regression — the operator's Ramipril
|
||||
// (Morgens) ships `daysOfWeek: null` in the DB ("every day" per
|
||||
// schema). Pre-fix, the endpoint returned `[]` until the reminder
|
||||
// worker entered RED phase. Post-fix, the GET projects the schedule
|
||||
// and idempotently mints any missing rows so the iOS Dashboard +
|
||||
// "Erfassen" sheet have an intake row to mark.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "med-ramipril",
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-morgens",
|
||||
medicationId: "med-ramipril",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
// First findMany call (pre-backfill existence probe) returns empty;
|
||||
// second call (post-backfill list) returns the freshly minted row.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany)
|
||||
.mockResolvedValueOnce([] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "e-new",
|
||||
medicationId: "med-ramipril",
|
||||
scheduledFor: new Date("2026-05-21T05:00:00.000Z"),
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
medication: { id: "med-ramipril", snoozedUntil: null },
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/intake?scope=today"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: Array<{ id: string; status: string }>;
|
||||
};
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].id).toBe("e-new");
|
||||
expect(body.data[0].status).toBe("pending");
|
||||
expect(prisma.medicationIntakeEvent.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
vi.mocked(prisma.medicationIntakeEvent.createMany).mock.calls[0][0],
|
||||
).toMatchObject({
|
||||
data: [
|
||||
{
|
||||
userId: "user-1",
|
||||
medicationId: "med-ramipril",
|
||||
skipped: false,
|
||||
source: "REMINDER",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not double-mint when today's rows already exist", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const scheduledFor = new Date("2026-05-21T05:00:00.000Z");
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "med-ramipril",
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-morgens",
|
||||
medicationId: "med-ramipril",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
// The existence probe sees a row for the exact projected slot, so
|
||||
// createMany must not fire.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany)
|
||||
.mockResolvedValueOnce([
|
||||
{ medicationId: "med-ramipril", scheduledFor },
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "e-existing",
|
||||
medicationId: "med-ramipril",
|
||||
scheduledFor,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
medication: { id: "med-ramipril", snoozedUntil: null },
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/intake?scope=today"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns compliance buckets for the last N days", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
enqueueUserMedicationComplianceBackfill,
|
||||
type ComplianceBucket as RollupComplianceBucket,
|
||||
} from "@/lib/medications/compliance-rollups";
|
||||
import { expandTodayIntakes } from "@/lib/medication-schedule";
|
||||
|
||||
const querySchema = z.object({
|
||||
scope: z.enum(["today", "compliance"]),
|
||||
@@ -85,6 +86,69 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (scope === "today") {
|
||||
const todayStart = startOfDayInTz(new Date(), userTz);
|
||||
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
|
||||
|
||||
// v1.4.39 W-SERVER-FIX — project pending intake rows for every
|
||||
// active schedule whose window opens today, then idempotently
|
||||
// backfill any missing rows. Pre-fix the endpoint returned `[]`
|
||||
// for daily meds (`schedule.daysOfWeek = null` in the DB) until
|
||||
// the reminder worker entered the RED phase at the end of the
|
||||
// dose window — leaving the iOS Dashboard tile + the "Erfassen"
|
||||
// sheet empty for the whole morning. The projection re-uses the
|
||||
// canonical `parseScheduleRecurrence` (null/empty = every day)
|
||||
// so the daysOfWeek semantics stay aligned across every reader.
|
||||
const activeMedications = await prisma.medication.findMany({
|
||||
where: { userId: user.id, active: true },
|
||||
select: {
|
||||
id: true,
|
||||
schedules: {
|
||||
select: {
|
||||
id: true,
|
||||
medicationId: true,
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
daysOfWeek: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const projected = expandTodayIntakes(
|
||||
activeMedications.flatMap((m) => m.schedules),
|
||||
new Date(),
|
||||
userTz,
|
||||
);
|
||||
|
||||
const existing = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
scheduledFor: { gte: todayStart, lt: todayEnd },
|
||||
},
|
||||
select: { medicationId: true, scheduledFor: true },
|
||||
});
|
||||
const existingKey = new Set(
|
||||
existing.map((e) => `${e.medicationId}|${e.scheduledFor.toISOString()}`),
|
||||
);
|
||||
const missing = projected.filter(
|
||||
(p) =>
|
||||
!existingKey.has(`${p.medicationId}|${p.scheduledFor.toISOString()}`),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
await prisma.medicationIntakeEvent.createMany({
|
||||
data: missing.map((m) => ({
|
||||
userId: user.id,
|
||||
medicationId: m.medicationId,
|
||||
scheduledFor: m.scheduledFor,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
// `REMINDER` is the same source the reminder worker uses to
|
||||
// mint RED-phase rows — semantically "the server projected
|
||||
// this slot before the user logged anything". The IntakeSource
|
||||
// enum has no separate `SCHEDULER` literal; reusing REMINDER
|
||||
// keeps the doctor-report + analytics filters byte-stable.
|
||||
source: "REMINDER",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -96,7 +160,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({
|
||||
action: { name: "medications.intake.today" },
|
||||
meta: { count: events.length },
|
||||
meta: {
|
||||
count: events.length,
|
||||
projected: projected.length,
|
||||
backfilled: missing.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
expandTodayIntakes,
|
||||
parseScheduleRecurrence,
|
||||
serializeScheduleRecurrence,
|
||||
type TodayExpandableSchedule,
|
||||
} from "@/lib/medication-schedule";
|
||||
|
||||
describe("medication schedule recurrence", () => {
|
||||
@@ -52,3 +54,111 @@ describe("medication schedule recurrence", () => {
|
||||
).toBe("i4;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandTodayIntakes", () => {
|
||||
// 2026-05-21 is a Thursday → weekday 4. Pin a reference instant
|
||||
// safely inside the Europe/Berlin day so every test below shares
|
||||
// the same "today" anchor regardless of the host clock.
|
||||
const REFERENCE = new Date("2026-05-21T12:00:00Z");
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
const daily: TodayExpandableSchedule = {
|
||||
id: "sched-daily",
|
||||
medicationId: "med-daily",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
};
|
||||
|
||||
it("emits a slot for a schedule with daysOfWeek=null (every day)", () => {
|
||||
// This is the bug the W-SERVER-FIX patch addresses: the operator's
|
||||
// Ramipril (Morgens) + Metformin (Abends) both ship `daysOfWeek:
|
||||
// null` in the DB ("every day" per the schema annotation) and the
|
||||
// today-intake endpoint returned `[]`. With the fix, the projector
|
||||
// emits one slot per schedule.
|
||||
const slots = expandTodayIntakes([daily], REFERENCE, TZ);
|
||||
expect(slots).toHaveLength(1);
|
||||
expect(slots[0]).toMatchObject({
|
||||
scheduleId: "sched-daily",
|
||||
medicationId: "med-daily",
|
||||
});
|
||||
});
|
||||
|
||||
it('treats daysOfWeek="" (empty string) as every day', () => {
|
||||
const slots = expandTodayIntakes(
|
||||
[{ ...daily, daysOfWeek: "" }],
|
||||
REFERENCE,
|
||||
TZ,
|
||||
);
|
||||
expect(slots).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("treats all-seven-days (0..6) as every day", () => {
|
||||
const slots = expandTodayIntakes(
|
||||
[{ ...daily, daysOfWeek: "0,1,2,3,4,5,6" }],
|
||||
REFERENCE,
|
||||
TZ,
|
||||
);
|
||||
expect(slots).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("respects an explicit weekday filter that excludes today", () => {
|
||||
// Thursday is weekday 4; "1" = Monday only → no slot today.
|
||||
const slots = expandTodayIntakes(
|
||||
[{ ...daily, daysOfWeek: "1" }],
|
||||
REFERENCE,
|
||||
TZ,
|
||||
);
|
||||
expect(slots).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("includes a slot when the explicit weekday filter contains today", () => {
|
||||
// Thursday = weekday 4; allow {2,4} → today matches.
|
||||
const slots = expandTodayIntakes(
|
||||
[{ ...daily, daysOfWeek: "2,4" }],
|
||||
REFERENCE,
|
||||
TZ,
|
||||
);
|
||||
expect(slots).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("emits slots for two daysOfWeek-null schedules (Ramipril + Metformin shape)", () => {
|
||||
// The operator's real fixture: morning + evening daily meds.
|
||||
const morning: TodayExpandableSchedule = {
|
||||
id: "sched-ramipril",
|
||||
medicationId: "med-ramipril",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
};
|
||||
const evening: TodayExpandableSchedule = {
|
||||
id: "sched-metformin",
|
||||
medicationId: "med-metformin",
|
||||
windowStart: "19:00",
|
||||
windowEnd: "21:00",
|
||||
daysOfWeek: null,
|
||||
};
|
||||
const slots = expandTodayIntakes([morning, evening], REFERENCE, TZ);
|
||||
expect(slots).toHaveLength(2);
|
||||
expect(slots.map((s) => s.medicationId).sort()).toEqual([
|
||||
"med-metformin",
|
||||
"med-ramipril",
|
||||
]);
|
||||
});
|
||||
|
||||
it("anchors scheduledFor at the schedule's windowStart in the user tz", () => {
|
||||
const slots = expandTodayIntakes([daily], REFERENCE, TZ);
|
||||
expect(slots).toHaveLength(1);
|
||||
const iso = slots[0].scheduledFor.toISOString();
|
||||
// 07:00 Europe/Berlin on 2026-05-21 (DST in effect → UTC+2) → 05:00Z.
|
||||
expect(iso).toBe("2026-05-21T05:00:00.000Z");
|
||||
});
|
||||
|
||||
it("skips multi-week cadence (handled by the reminder worker)", () => {
|
||||
const biweekly: TodayExpandableSchedule = {
|
||||
...daily,
|
||||
daysOfWeek: "i2;1,3,5",
|
||||
};
|
||||
expect(expandTodayIntakes([biweekly], REFERENCE, TZ)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,41 @@ export interface ScheduleRecurrence {
|
||||
intervalWeeks: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal schedule shape consumed by `expandTodayIntakes`. Mirrors the
|
||||
* relevant subset of the Prisma `MedicationSchedule` row (`id`,
|
||||
* `windowStart`, `windowEnd`, `daysOfWeek`) plus the parent
|
||||
* `medicationId` so the projected slots carry enough context for the
|
||||
* intake-route caller to upsert into `MedicationIntakeEvent` without
|
||||
* re-joining.
|
||||
*
|
||||
* `daysOfWeek` is the encoded string per `serializeScheduleRecurrence`
|
||||
* (e.g. `null` for daily, `"1,3,5"` for Mon/Wed/Fri, `"i2;1"` for every
|
||||
* other Monday). The DB schema column documents `null = daily` as the
|
||||
* convention every reader honours.
|
||||
*/
|
||||
export interface TodayExpandableSchedule {
|
||||
id: string;
|
||||
medicationId: string;
|
||||
/** "HH:mm" 24h, user-tz reference. */
|
||||
windowStart: string;
|
||||
/** "HH:mm" 24h. May wrap midnight (`windowEnd < windowStart`). */
|
||||
windowEnd: string;
|
||||
/** Encoded recurrence string per `serializeScheduleRecurrence`. */
|
||||
daysOfWeek: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One projected dose slot for "today" in the user's timezone. The
|
||||
* `scheduledFor` is the UTC instant that corresponds to the schedule's
|
||||
* `windowStart` applied to today's local-day boundary in `timeZone`.
|
||||
*/
|
||||
export interface TodayExpandedSlot {
|
||||
scheduleId: string;
|
||||
medicationId: string;
|
||||
scheduledFor: Date;
|
||||
}
|
||||
|
||||
const ENCODED_PATTERN = /^i([1-4]);(.*)$/;
|
||||
|
||||
function normalizeDays(days: number[]): number[] {
|
||||
@@ -55,3 +90,147 @@ export function serializeScheduleRecurrence(
|
||||
if (intervalWeeks === 1) return days.join(",");
|
||||
return `i${intervalWeeks};${days.join(",")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the supplied schedules to the dose slots they would produce
|
||||
* for "today" in the user's timezone. Treats `daysOfWeek` of `null`,
|
||||
* `""`, or "no valid weekdays after normalization" as "every day" — the
|
||||
* documented DB convention (`prisma/schema.prisma` annotates the column
|
||||
* `null = daily`). Explicit weekday arrays (`"1,3,5"`) still filter as
|
||||
* before, so a Monday-only schedule emits nothing on a Sunday.
|
||||
*
|
||||
* Used by `/api/medications/intake?scope=today` to backfill placeholder
|
||||
* `MedicationIntakeEvent` rows for active medications whose reminder
|
||||
* window has not yet opened — without this projection the endpoint
|
||||
* returned `[]` for daily meds before the reminder worker entered the
|
||||
* RED phase, leaving the iOS Dashboard tile + the "Erfassen" sheet
|
||||
* empty for the whole morning.
|
||||
*
|
||||
* The function is pure (no DB access) so the test surface stays inside
|
||||
* this file. The route owns the upsert.
|
||||
*/
|
||||
export function expandTodayIntakes(
|
||||
schedules: TodayExpandableSchedule[],
|
||||
reference: Date,
|
||||
timeZone: string,
|
||||
): TodayExpandedSlot[] {
|
||||
const slots: TodayExpandedSlot[] = [];
|
||||
const today = localDayParts(reference, timeZone);
|
||||
|
||||
for (const schedule of schedules) {
|
||||
const recurrence = parseScheduleRecurrence(schedule.daysOfWeek);
|
||||
|
||||
// Day-of-week filter. Empty array (the parsed shape for `null` /
|
||||
// `""` / "no valid weekdays") means "every day" — no filter.
|
||||
if (
|
||||
recurrence.daysOfWeek.length > 0 &&
|
||||
!recurrence.daysOfWeek.includes(today.weekday)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Multi-week cadence isn't anchored without a medication start
|
||||
// date here, so `intervalWeeks > 1` is conservatively skipped from
|
||||
// the today projection. The reminder worker still mints those rows
|
||||
// when their window opens because it has the medication context;
|
||||
// those meds are GLP-1-style weeklies which the iOS today list
|
||||
// doesn't depend on as a primary surface.
|
||||
if (recurrence.intervalWeeks > 1) continue;
|
||||
|
||||
const [h, m] = schedule.windowStart.split(":").map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) continue;
|
||||
|
||||
const scheduledFor = instantAtLocalHm(today, h, m, timeZone);
|
||||
slots.push({
|
||||
scheduleId: schedule.id,
|
||||
medicationId: schedule.medicationId,
|
||||
scheduledFor,
|
||||
});
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
interface LocalDayParts {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
weekday: number;
|
||||
}
|
||||
|
||||
const WEEKDAY_MAP: Record<string, number> = {
|
||||
Sun: 0,
|
||||
Mon: 1,
|
||||
Tue: 2,
|
||||
Wed: 3,
|
||||
Thu: 4,
|
||||
Fri: 5,
|
||||
Sat: 6,
|
||||
};
|
||||
|
||||
function localDayParts(date: Date, timeZone: string): LocalDayParts {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
weekday: "short",
|
||||
}).formatToParts(date);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
||||
parts.find((p) => p.type === type)?.value ?? "0";
|
||||
return {
|
||||
year: Number(get("year")),
|
||||
month: Number(get("month")),
|
||||
day: Number(get("day")),
|
||||
weekday: WEEKDAY_MAP[get("weekday")] ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function instantAtLocalHm(
|
||||
day: LocalDayParts,
|
||||
hour: number,
|
||||
minute: number,
|
||||
timeZone: string,
|
||||
): Date {
|
||||
// Two-pass solver — first guess assumes wall-clock equals UTC, then
|
||||
// correct by the zone's offset at that approximate instant. Two
|
||||
// iterations suffice for every IANA zone (the second pass adjusts
|
||||
// across DST transitions).
|
||||
let guess = new Date(
|
||||
Date.UTC(day.year, day.month - 1, day.day, hour, minute, 0, 0),
|
||||
);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const offsetMin = tzOffsetMinutes(guess, timeZone);
|
||||
guess = new Date(
|
||||
Date.UTC(day.year, day.month - 1, day.day, hour, minute, 0, 0) -
|
||||
offsetMin * 60_000,
|
||||
);
|
||||
}
|
||||
return guess;
|
||||
}
|
||||
|
||||
function tzOffsetMinutes(date: Date, timeZone: string): number {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): number =>
|
||||
Number(parts.find((p) => p.type === type)?.value ?? "0");
|
||||
let hour = get("hour");
|
||||
if (hour === 24) hour = 0;
|
||||
const asIfUtc = Date.UTC(
|
||||
get("year"),
|
||||
get("month") - 1,
|
||||
get("day"),
|
||||
hour,
|
||||
get("minute"),
|
||||
get("second"),
|
||||
);
|
||||
return Math.round((asIfUtc - date.getTime()) / 60000);
|
||||
}
|
||||
Reference in new issue
Block a user