@upnextapp/core 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isTimeOfDayOutsideActiveHours = isTimeOfDayOutsideActiveHours;
4
+ function isTimeOfDayOutsideActiveHours(startDateTime, activeStart, activeEnd) {
5
+ if (!startDateTime)
6
+ return false;
7
+ const start = new Date(startDateTime);
8
+ if (Number.isNaN(start.getTime()))
9
+ return false;
10
+ const startMinutes = start.getHours() * 60 + start.getMinutes();
11
+ const [aSH, aSM] = activeStart.split(':').map(Number);
12
+ const [aEH, aEM] = activeEnd.split(':').map(Number);
13
+ if (Number.isNaN(aSH) || Number.isNaN(aSM) || Number.isNaN(aEH) || Number.isNaN(aEM))
14
+ return false;
15
+ const startMin = aSH * 60 + aSM;
16
+ const endMin = aEH * 60 + aEM;
17
+ const isLegacyMidnightWrap = (aEH === 0 && aEM === 0);
18
+ const isInverted = !isLegacyMidnightWrap && startMin > endMin;
19
+ if (isLegacyMidnightWrap) {
20
+ return startMinutes < startMin;
21
+ }
22
+ if (isInverted) {
23
+ // Note: for inverted windows the engine's firstSlotForDay would CLAMP a daytime
24
+ // anchor into the window (e.g. 12:00 daily → fires 22:00). Forms intentionally
25
+ // treat daytime anchors as outside so saves are blocked — silently moving a
26
+ // user's chosen time by 10 hours would be a footgun. This is deliberate
27
+ // form-stricter-than-engine behavior, not drift.
28
+ return startMinutes > endMin && startMinutes < startMin;
29
+ }
30
+ return startMinutes < startMin || startMinutes > endMin;
31
+ }
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IDLE_TIME_BUCKET_SECONDS = void 0;
4
+ exports.coarsenIdleTime = coarsenIdleTime;
5
+ exports.IDLE_TIME_BUCKET_SECONDS = 15;
6
+ function coarsenIdleTime(seconds) {
7
+ return Math.floor(seconds / exports.IDLE_TIME_BUCKET_SECONDS) * exports.IDLE_TIME_BUCKET_SECONDS;
8
+ }
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatNextTrigger = formatNextTrigger;
4
+ function formatNextTrigger(ts, now = Date.now()) {
5
+ if (!ts || ts <= 0)
6
+ return 'N/A';
7
+ const date = new Date(ts);
8
+ const currentDate = new Date(now);
9
+ const isToday = date.toDateString() === currentDate.toDateString();
10
+ const time = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
11
+ if (isToday)
12
+ return `Today ${time}`;
13
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
14
+ }
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pluginManifestSchema = exports.validatePluginManifest = exports.shouldAutoClose = exports.isNewer = exports.versionSatisfies = exports.buildSignedPayload = exports.isPastOnce = exports.isEnded = exports.badgeFor = exports.formatNextTrigger = exports.IDLE_TIME_BUCKET_SECONDS = exports.coarsenIdleTime = exports.isTimeOfDayOutsideActiveHours = exports.STALE_ACTIVE_THRESHOLD_MS = exports.todaySlotTime = exports.isOccurrenceOverdue = exports.isStaleActive = exports.shouldTriggerOnDay = exports.matchesRecurrenceInterval = exports.isWithinActiveHours = exports.firstSlotForDay = exports.calculateNextTriggerFromNow = exports.calculateNextTrigger = exports.authorityOverride = void 0;
4
+ var writeAuthority_js_1 = require("./writeAuthority.js");
5
+ Object.defineProperty(exports, "authorityOverride", { enumerable: true, get: function () { return writeAuthority_js_1.authorityOverride; } });
6
+ var scheduling_js_1 = require("./scheduling.js");
7
+ Object.defineProperty(exports, "calculateNextTrigger", { enumerable: true, get: function () { return scheduling_js_1.calculateNextTrigger; } });
8
+ Object.defineProperty(exports, "calculateNextTriggerFromNow", { enumerable: true, get: function () { return scheduling_js_1.calculateNextTriggerFromNow; } });
9
+ Object.defineProperty(exports, "firstSlotForDay", { enumerable: true, get: function () { return scheduling_js_1.firstSlotForDay; } });
10
+ Object.defineProperty(exports, "isWithinActiveHours", { enumerable: true, get: function () { return scheduling_js_1.isWithinActiveHours; } });
11
+ Object.defineProperty(exports, "matchesRecurrenceInterval", { enumerable: true, get: function () { return scheduling_js_1.matchesRecurrenceInterval; } });
12
+ Object.defineProperty(exports, "shouldTriggerOnDay", { enumerable: true, get: function () { return scheduling_js_1.shouldTriggerOnDay; } });
13
+ Object.defineProperty(exports, "isStaleActive", { enumerable: true, get: function () { return scheduling_js_1.isStaleActive; } });
14
+ Object.defineProperty(exports, "isOccurrenceOverdue", { enumerable: true, get: function () { return scheduling_js_1.isOccurrenceOverdue; } });
15
+ Object.defineProperty(exports, "todaySlotTime", { enumerable: true, get: function () { return scheduling_js_1.todaySlotTime; } });
16
+ Object.defineProperty(exports, "STALE_ACTIVE_THRESHOLD_MS", { enumerable: true, get: function () { return scheduling_js_1.STALE_ACTIVE_THRESHOLD_MS; } });
17
+ var activeHours_js_1 = require("./activeHours.js");
18
+ Object.defineProperty(exports, "isTimeOfDayOutsideActiveHours", { enumerable: true, get: function () { return activeHours_js_1.isTimeOfDayOutsideActiveHours; } });
19
+ var coarsenIdleTime_js_1 = require("./coarsenIdleTime.js");
20
+ Object.defineProperty(exports, "coarsenIdleTime", { enumerable: true, get: function () { return coarsenIdleTime_js_1.coarsenIdleTime; } });
21
+ Object.defineProperty(exports, "IDLE_TIME_BUCKET_SECONDS", { enumerable: true, get: function () { return coarsenIdleTime_js_1.IDLE_TIME_BUCKET_SECONDS; } });
22
+ var formatNextTrigger_js_1 = require("./formatNextTrigger.js");
23
+ Object.defineProperty(exports, "formatNextTrigger", { enumerable: true, get: function () { return formatNextTrigger_js_1.formatNextTrigger; } });
24
+ var reminderBadges_js_1 = require("./reminderBadges.js");
25
+ Object.defineProperty(exports, "badgeFor", { enumerable: true, get: function () { return reminderBadges_js_1.badgeFor; } });
26
+ Object.defineProperty(exports, "isEnded", { enumerable: true, get: function () { return reminderBadges_js_1.isEnded; } });
27
+ Object.defineProperty(exports, "isPastOnce", { enumerable: true, get: function () { return reminderBadges_js_1.isPastOnce; } });
28
+ var integrityPayload_js_1 = require("./integrityPayload.js");
29
+ Object.defineProperty(exports, "buildSignedPayload", { enumerable: true, get: function () { return integrityPayload_js_1.buildSignedPayload; } });
30
+ var semver_js_1 = require("./semver.js");
31
+ Object.defineProperty(exports, "versionSatisfies", { enumerable: true, get: function () { return semver_js_1.versionSatisfies; } });
32
+ Object.defineProperty(exports, "isNewer", { enumerable: true, get: function () { return semver_js_1.isNewer; } });
33
+ var shouldAutoClose_js_1 = require("./shouldAutoClose.js");
34
+ Object.defineProperty(exports, "shouldAutoClose", { enumerable: true, get: function () { return shouldAutoClose_js_1.shouldAutoClose; } });
35
+ var manifest_js_1 = require("./manifest.js");
36
+ Object.defineProperty(exports, "validatePluginManifest", { enumerable: true, get: function () { return manifest_js_1.validatePluginManifest; } });
37
+ Object.defineProperty(exports, "pluginManifestSchema", { enumerable: true, get: function () { return manifest_js_1.pluginManifestSchema; } });
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildSignedPayload = buildSignedPayload;
4
+ // CANONICAL source of buildSignedPayload. The inline copy in scripts/sign-plugin.mjs
5
+ // MUST stay byte-for-byte in sync. Drift = plugin signature verification breaks.
6
+ // Sync test: src/tests/unit/integrityPayload.test.ts 'sign-plugin.mjs sync check'
7
+ function buildSignedPayload(pluginId, version, entryPath, entrySha256) {
8
+ return `${pluginId}:${version}:${entryPath}:${entrySha256}`;
9
+ }
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pluginManifestSchema = void 0;
4
+ exports.validatePluginManifest = validatePluginManifest;
5
+ const zod_1 = require("zod");
6
+ const windowConfigSchema = zod_1.z.object({
7
+ id: zod_1.z.string().min(1),
8
+ size: zod_1.z.object({
9
+ width: zod_1.z.number().positive(),
10
+ height: zod_1.z.union([zod_1.z.number().positive(), zod_1.z.literal('full')]),
11
+ }),
12
+ position: zod_1.z.enum(['center', 'right-edge', 'left-edge']).optional(),
13
+ frameless: zod_1.z.boolean().optional(),
14
+ alwaysOnTop: zod_1.z.boolean().optional(),
15
+ persistent: zod_1.z.boolean().optional(),
16
+ skipTaskbar: zod_1.z.boolean().optional(),
17
+ });
18
+ const pageConfigSchema = zod_1.z.object({
19
+ id: zod_1.z.string().min(1),
20
+ label: zod_1.z.string(),
21
+ icon: zod_1.z.string(),
22
+ });
23
+ exports.pluginManifestSchema = zod_1.z.object({
24
+ id: zod_1.z.string().min(1).regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, 'Plugin IDs may only contain letters, digits, dots, underscores, and hyphens').refine(id => !id.startsWith('__'), 'Plugin IDs cannot start with __'),
25
+ name: zod_1.z.string().min(1),
26
+ version: zod_1.z.string().regex(/^\d+\.\d+\.\d+$/),
27
+ description: zod_1.z.string().optional(),
28
+ author: zod_1.z.string().optional(),
29
+ icon: zod_1.z.string().optional(),
30
+ color: zod_1.z.string().optional(),
31
+ minPlatformVersion: zod_1.z.string().optional(),
32
+ capabilities: zod_1.z.array(zod_1.z.string()).default([]),
33
+ client: zod_1.z.object({
34
+ entry: zod_1.z.string().min(1).refine(e => !e.includes('..') && !e.startsWith('/'), {
35
+ message: 'Entry path must not contain ".." or start with "/"',
36
+ }),
37
+ pages: zod_1.z.array(pageConfigSchema).default([]),
38
+ windows: zod_1.z.array(windowConfigSchema).default([]),
39
+ reminderTypes: zod_1.z.array(zod_1.z.string()).default([]),
40
+ settingsPanel: zod_1.z.boolean().optional(),
41
+ componentMap: zod_1.z.record(zod_1.z.string().regex(/^(pages|windows|edit|css):[a-zA-Z0-9_-]+$/, 'componentMap keys must follow pattern "category:id" where category is pages, windows, edit, or css'), zod_1.z.string().min(1).refine(e => !e.includes('..') && !e.startsWith('/'), {
42
+ message: 'Path must not contain ".." or start with "/"',
43
+ })).optional(),
44
+ }),
45
+ server: zod_1.z
46
+ .object({
47
+ entry: zod_1.z.string().min(1).refine(e => !e.includes('..') && !e.startsWith('/'), {
48
+ message: 'Entry path must not contain ".." or start with "/"',
49
+ }),
50
+ routes: zod_1.z.array(zod_1.z.any()).default([]),
51
+ webhooks: zod_1.z.array(zod_1.z.any()).default([]),
52
+ cronJobs: zod_1.z.array(zod_1.z.any()).default([]),
53
+ tables: zod_1.z.array(zod_1.z.string()).default([]),
54
+ env: zod_1.z.array(zod_1.z.string()).default([]),
55
+ })
56
+ .optional(),
57
+ });
58
+ function validatePluginManifest(data) {
59
+ return exports.pluginManifestSchema.safeParse(data);
60
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isEnded = isEnded;
4
+ exports.isPastOnce = isPastOnce;
5
+ exports.badgeFor = badgeFor;
6
+ const formatNextTrigger_js_1 = require("./formatNextTrigger.js");
7
+ function isEnded(def, now = Date.now()) {
8
+ return def.endTime != null && now >= new Date(def.endTime).getTime();
9
+ }
10
+ function isPastOnce(def, now = Date.now()) {
11
+ return def.recurrenceType === 'once' && new Date(def.startDateTime).getTime() < now;
12
+ }
13
+ function badgeFor(def, reminder, dockedDefIds, now = Date.now()) {
14
+ if (isEnded(def, now))
15
+ return { label: 'Ended', tone: 'done' };
16
+ if (!reminder) {
17
+ return isPastOnce(def, now) ? { label: 'Done', tone: 'done' } : null;
18
+ }
19
+ if (reminder.status === 'active') {
20
+ return dockedDefIds.has(def.id) ? { label: 'Docked', tone: 'docked' } : { label: 'Active', tone: 'active' };
21
+ }
22
+ if (reminder.status === 'delayed') {
23
+ const until = (0, formatNextTrigger_js_1.formatNextTrigger)(reminder.delayUntil && reminder.delayUntil > 0 ? reminder.delayUntil : reminder.nextTriggerTime, now);
24
+ return until === 'N/A' ? { label: 'Snoozed', tone: 'snoozed' } : { label: `Snoozed until ${until}`, tone: 'snoozed' };
25
+ }
26
+ if (reminder.status === 'completed')
27
+ return { label: 'Done', tone: 'done' };
28
+ return null;
29
+ }
@@ -0,0 +1,229 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.STALE_ACTIVE_THRESHOLD_MS = void 0;
4
+ exports.shouldTriggerOnDay = shouldTriggerOnDay;
5
+ exports.todaySlotTime = todaySlotTime;
6
+ exports.isWithinActiveHours = isWithinActiveHours;
7
+ exports.matchesRecurrenceInterval = matchesRecurrenceInterval;
8
+ exports.calculateNextTrigger = calculateNextTrigger;
9
+ exports.firstSlotForDay = firstSlotForDay;
10
+ exports.calculateNextTriggerFromNow = calculateNextTriggerFromNow;
11
+ exports.isStaleActive = isStaleActive;
12
+ exports.isOccurrenceOverdue = isOccurrenceOverdue;
13
+ exports.STALE_ACTIVE_THRESHOLD_MS = 48 * 60 * 60 * 1000;
14
+ function shouldTriggerOnDay(def, date) {
15
+ if (!def.daysOfWeek || def.daysOfWeek.length === 0)
16
+ return true;
17
+ const dayOfWeek = date.getDay();
18
+ return def.daysOfWeek.includes(dayOfWeek);
19
+ }
20
+ // Today's occurrence slot for a def: the anchor's LOCAL wall-clock hour:minute
21
+ // (from startDateTime) applied to `now`'s calendar day, seconds/ms zeroed.
22
+ // Wall-clock-local by design (mirrors firstSlotForDay's non-hourly anchor):
23
+ // absolute-ms math drifts +1h across DST transitions. Consumers: the engine's
24
+ // missed-occurrence gates and the creation loop's due-ness check — a single
25
+ // source so the gates can never drift from settle's due-ness.
26
+ function todaySlotTime(def, now) {
27
+ const startTime = new Date(def.startDateTime);
28
+ const todayScheduled = new Date(now);
29
+ todayScheduled.setHours(startTime.getHours(), startTime.getMinutes(), 0, 0);
30
+ return todayScheduled.getTime();
31
+ }
32
+ function isWithinActiveHours(now, startTime, endTime) {
33
+ const date = new Date(now);
34
+ const [startH, startM] = startTime.split(':').map(Number);
35
+ const [endH, endM] = endTime.split(':').map(Number);
36
+ if (Number.isNaN(startH) || Number.isNaN(startM) || Number.isNaN(endH) || Number.isNaN(endM)) {
37
+ return false;
38
+ }
39
+ const start = new Date(date);
40
+ start.setHours(startH, startM, 0, 0);
41
+ const end = new Date(date);
42
+ const isLegacyMidnightWrap = (endH === 0 && endM === 0);
43
+ const isInverted = !isLegacyMidnightWrap && (startH * 60 + startM) > (endH * 60 + endM);
44
+ if (isLegacyMidnightWrap || isInverted) {
45
+ end.setDate(end.getDate() + 1);
46
+ }
47
+ end.setHours(endH, endM, 0, 0);
48
+ if (isInverted && now < start.getTime()) {
49
+ start.setDate(start.getDate() - 1);
50
+ end.setDate(end.getDate() - 1);
51
+ }
52
+ return now >= start.getTime() && now <= end.getTime();
53
+ }
54
+ function matchesRecurrenceInterval(def, day, startDate, anchorDayStartMs) {
55
+ const interval = def.interval && def.interval > 0 ? def.interval : 1;
56
+ const dayStart = new Date(day);
57
+ dayStart.setHours(0, 0, 0, 0);
58
+ const dayDiffMs = dayStart.getTime() - anchorDayStartMs;
59
+ if (dayDiffMs < 0)
60
+ return false;
61
+ switch (def.recurrenceType) {
62
+ case 'hourly':
63
+ return true;
64
+ case 'daily': {
65
+ const dayDiff = Math.floor(dayDiffMs / (24 * 60 * 60 * 1000));
66
+ return dayDiff % interval === 0;
67
+ }
68
+ case 'weekly': {
69
+ const weekDiff = Math.floor(dayDiffMs / (7 * 24 * 60 * 60 * 1000));
70
+ return weekDiff % interval === 0;
71
+ }
72
+ case 'monthly': {
73
+ const monthDiff = (day.getFullYear() - startDate.getFullYear()) * 12 + (day.getMonth() - startDate.getMonth());
74
+ return monthDiff >= 0 && monthDiff % interval === 0 && day.getDate() === startDate.getDate();
75
+ }
76
+ case 'yearly': {
77
+ const yearDiff = day.getFullYear() - startDate.getFullYear();
78
+ return yearDiff >= 0 && yearDiff % interval === 0
79
+ && day.getMonth() === startDate.getMonth() && day.getDate() === startDate.getDate();
80
+ }
81
+ default:
82
+ return true;
83
+ }
84
+ }
85
+ function calculateNextTrigger(def, fromTime, activeStart, activeEnd) {
86
+ if (!def.enabled)
87
+ return null;
88
+ const startMs = new Date(def.startDateTime).getTime();
89
+ if (def.recurrenceType === 'once') {
90
+ return startMs > fromTime ? startMs : null;
91
+ }
92
+ if (def.endTime && fromTime >= new Date(def.endTime).getTime())
93
+ return null;
94
+ const startDate = new Date(def.startDateTime);
95
+ const fromDate = new Date(fromTime);
96
+ fromDate.setHours(0, 0, 0, 0);
97
+ const anchorDayStart = new Date(def.startDateTime);
98
+ anchorDayStart.setHours(0, 0, 0, 0);
99
+ const anchorDayStartMs = anchorDayStart.getTime();
100
+ const interval = def.interval && def.interval > 0 ? def.interval : 1;
101
+ let maxDays = 400;
102
+ if (def.recurrenceType === 'yearly')
103
+ maxDays = Math.max(maxDays, interval * 366 * 4 + 10);
104
+ else if (def.recurrenceType === 'monthly')
105
+ maxDays = Math.max(maxDays, interval * 31 + 10);
106
+ for (let offset = 0; offset <= maxDays; offset++) {
107
+ const day = new Date(fromDate);
108
+ day.setDate(day.getDate() + offset);
109
+ // Empty daysOfWeek = no filter (fires every day). UI prevents empty array on save.
110
+ // If this assumption changes, add explicit guard for length === 0.
111
+ if (def.daysOfWeek && def.daysOfWeek.length > 0 && !def.daysOfWeek.includes(day.getDay()))
112
+ continue;
113
+ if (!matchesRecurrenceInterval(def, day, startDate, anchorDayStartMs))
114
+ continue;
115
+ const slot = firstSlotForDay(def, day, startMs, activeStart, activeEnd, fromTime);
116
+ if (slot !== null) {
117
+ if (def.endTime && slot >= new Date(def.endTime).getTime())
118
+ return null;
119
+ return slot;
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+ // Supports inverted active hours (activeStart > activeEnd, e.g. night shift 22:00-06:00).
125
+ // When inverted, boundsEnd wraps to the next day, producing an overnight window
126
+ // [activeStart today, activeEnd tomorrow]. For non-hourly recurrences, an anchor whose
127
+ // time-of-day falls in the early-morning portion of the inverted window ([00:00, activeEnd))
128
+ // is shifted to the next day so the slot lands inside the window.
129
+ function firstSlotForDay(def, day, anchorInstant, activeStart, activeEnd, fromTime) {
130
+ const [aSH, aSM] = activeStart.split(':').map(Number);
131
+ const [aEH, aEM] = activeEnd.split(':').map(Number);
132
+ if (Number.isNaN(aSH) || Number.isNaN(aSM) || Number.isNaN(aEH) || Number.isNaN(aEM)) {
133
+ return null;
134
+ }
135
+ const boundsStart = new Date(day);
136
+ boundsStart.setHours(aSH, aSM, 0, 0);
137
+ const boundsEnd = new Date(day);
138
+ boundsEnd.setHours(aEH, aEM, 0, 0);
139
+ const isLegacyMidnightWrap = (aEH === 0 && aEM === 0);
140
+ const startTodMin = aSH * 60 + aSM;
141
+ const endTodMin = aEH * 60 + aEM;
142
+ const isInverted = !isLegacyMidnightWrap && startTodMin > endTodMin;
143
+ if (isLegacyMidnightWrap || isInverted) {
144
+ boundsEnd.setDate(boundsEnd.getDate() + 1);
145
+ }
146
+ if (def.recurrenceType === 'hourly') {
147
+ const intervalHours = (def.interval && def.interval > 0 ? def.interval : 1);
148
+ // Hourly: strict > prevents re-firing at the same instant as a recent dismissal
149
+ const lowerBound = Math.max(boundsStart.getTime(), fromTime + 1);
150
+ if (lowerBound > boundsEnd.getTime())
151
+ return null;
152
+ // Grid phase anchored to LOCAL hour:minute of startDateTime (not absolute UTC ms).
153
+ // Absolute-ms grids drift across DST: an anchor saved during standard time displays
154
+ // +1h during daylight time because UTC offset changes. Reading hour:minute directly
155
+ // from the startDateTime string (format "YYYY-MM-DDTHH:mm") is TZ-independent —
156
+ // it's the literal wall-clock hour the user chose.
157
+ const timePart = def.startDateTime.split('T')[1] ?? '00:00';
158
+ const [aH, aM] = timePart.split(':').map(Number);
159
+ const anchorTodMin = (Number.isNaN(aH) ? 0 : aH) * 60 + (Number.isNaN(aM) ? 0 : aM);
160
+ const intervalMin = intervalHours * 60;
161
+ const slotStart = new Date(boundsStart);
162
+ const boundsStartTodMin = slotStart.getHours() * 60 + slotStart.getMinutes();
163
+ const phaseOffset = ((anchorTodMin - boundsStartTodMin) % intervalMin + intervalMin) % intervalMin;
164
+ slotStart.setMinutes(slotStart.getMinutes() + phaseOffset, 0, 0);
165
+ // setHours advances wall-clock hour by intervalHours — DST transitions are handled
166
+ // correctly (slots stay at consistent local hour regardless of UTC offset changes).
167
+ while (slotStart.getTime() < lowerBound) {
168
+ slotStart.setHours(slotStart.getHours() + intervalHours);
169
+ }
170
+ if (slotStart.getTime() > boundsEnd.getTime())
171
+ return null;
172
+ return slotStart.getTime();
173
+ }
174
+ const anchorDate = new Date(anchorInstant);
175
+ const anchorTodMin = anchorDate.getHours() * 60 + anchorDate.getMinutes();
176
+ const anchorDay = new Date(day);
177
+ if (isInverted && anchorTodMin < endTodMin) {
178
+ anchorDay.setDate(anchorDay.getDate() + 1);
179
+ }
180
+ const anchor = new Date(anchorDay);
181
+ anchor.setHours(anchorDate.getHours(), anchorDate.getMinutes(), 0, 0);
182
+ const anchorMs = anchor.getTime();
183
+ if (anchorMs > boundsEnd.getTime())
184
+ return null;
185
+ const effective = Math.max(anchorMs, boundsStart.getTime());
186
+ // Daily/weekly/etc: inclusive >= because same-instant re-fire is extremely unlikely (24h+ intervals).
187
+ // NOTE: edge case where fromTime exactly equals anchor (e.g. startup at scheduled fire time) DOES
188
+ // re-fire — intentional, ensures missed-on-startup triggers still activate.
189
+ return effective >= fromTime ? effective : null;
190
+ }
191
+ function calculateNextTriggerFromNow(def, fromTime, activeStart, activeEnd) {
192
+ if (!def.enabled)
193
+ return null;
194
+ if (def.recurrenceType === 'once')
195
+ return null;
196
+ return calculateNextTrigger(def, fromTime, activeStart, activeEnd);
197
+ }
198
+ function isStaleActive(reminder, now, def) {
199
+ // Single 48h threshold for every recurrence type, aligned with the server's P3
200
+ // progression floor (sync_lww_guard) and writeAuthority.ts — the sweep fires only
201
+ // when the server would accept the dismissal, so attempts succeed first try.
202
+ // Keyed to activated_at — the alive-as-active clock — because updated_at is
203
+ // re-stamped by every plugin tick, which starved the sweep on ticking rows (Obs 3).
204
+ // Legacy rows with a null activated_at fall back to updated_at (server COALESCE).
205
+ // def is retained for signature compatibility.
206
+ void def;
207
+ const aliveAsActiveAt = reminder.activatedAt ?? reminder.updatedAt;
208
+ if (aliveAsActiveAt == null)
209
+ return true;
210
+ return (now - aliveAsActiveAt) > exports.STALE_ACTIVE_THRESHOLD_MS;
211
+ }
212
+ function isOccurrenceOverdue(def, reminder, now, activeStart, activeEnd) {
213
+ // Auto-advance only when the next due slot is on a NEW calendar day than the
214
+ // current occurrence's slot. daily/weekly/monthly/yearly always cross days
215
+ // (so they reset each occurrence); hourly reminders keep escalating through
216
+ // same-day slots and only reset at the first slot after midnight. Because a
217
+ // recurring reminder always crosses a day within ~24h, this fires well before
218
+ // the 48h stale path above — so stale dismissal only reaches `once`/docked.
219
+ //
220
+ // Pass nextTriggerTime + 1: firstSlotForDay uses inclusive >= for non-hourly (so startup
221
+ // at exact fire-time re-fires missed triggers). Without +1 here, calculateNextTrigger
222
+ // would return the SAME slot, making this day-comparison always equal → no auto-advance.
223
+ // Note: +1 is only effective for non-hourly types; hourly already uses fromTime+1 internally,
224
+ // so this becomes a double-offset that Math.ceil absorbs (no slot skip).
225
+ const nextSlot = calculateNextTrigger(def, reminder.nextTriggerTime + 1, activeStart, activeEnd);
226
+ if (nextSlot === null || now < nextSlot)
227
+ return false;
228
+ return new Date(nextSlot).toDateString() !== new Date(reminder.nextTriggerTime).toDateString();
229
+ }
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.versionSatisfies = versionSatisfies;
4
+ exports.isNewer = isNewer;
5
+ function versionSatisfies(appVersion, minVersion) {
6
+ if (!minVersion)
7
+ return true;
8
+ const app = appVersion.split('.').map(Number);
9
+ const min = minVersion.split('.').map(Number);
10
+ for (let i = 0; i < 3; i++) {
11
+ const a = app[i] ?? 0;
12
+ const m = min[i] ?? 0;
13
+ if (Number.isNaN(a) || Number.isNaN(m))
14
+ return false;
15
+ if (a < m)
16
+ return false;
17
+ if (a > m)
18
+ return true;
19
+ }
20
+ return true;
21
+ }
22
+ function isNewer(current, candidate) {
23
+ const cur = current.split('.').map(Number);
24
+ const cand = candidate.split('.').map(Number);
25
+ for (let i = 0; i < 3; i++) {
26
+ const c = cand[i] ?? 0;
27
+ const v = cur[i] ?? 0;
28
+ if (Number.isNaN(c) || Number.isNaN(v))
29
+ return false;
30
+ if (c > v)
31
+ return true;
32
+ if (c < v)
33
+ return false;
34
+ }
35
+ return false;
36
+ }
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.shouldAutoClose = shouldAutoClose;
4
+ function shouldAutoClose(expanded, dragHighlight, lastInteraction, now, thresholdMs = 5000) {
5
+ return expanded && !dragHighlight && (now - lastInteraction) > thresholdMs;
6
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.authorityOverride = authorityOverride;
4
+ const scheduling_js_1 = require("./scheduling.js");
5
+ // Matches the server's jsonb BIGINT casts: NULL/missing coerces to 0.
6
+ function stamp(value) {
7
+ return Number(value) || 0;
8
+ }
9
+ // Client mirror of sync_lww_guard (supabase/migrations/20260920200000_p3_staleness_activation.sql):
10
+ // incoming user over local auto always wins; incoming auto over local user wins only via a
11
+ // progression rule — the user's own data says it's time:
12
+ // P1 delayed -> active once the user-set delay_until has passed
13
+ // P2 completed -> scheduled/active with a strictly LATER next_trigger_time (forward rebook only)
14
+ // P3 active -> scheduled/completed once the row is 48h stale by activated_at — the
15
+ // alive-as-active clock, falling back to updated_at when activated_at is NULL (legacy
16
+ // rows). updated_at alone is wrong: ticking plugins re-stamp it every tick, which
17
+ // starved the sweep on actively-ticking rows (Obs 3).
18
+ // P4 scheduled -> active once the user-scheduled next_trigger_time has arrived (the fire)
19
+ // Returns null when kinds match so the caller falls back to updated_at comparison.
20
+ // Missing kinds default to 'auto' (S9: pre-migration rows, old clients).
21
+ function authorityOverride(local, incoming, now) {
22
+ const localKind = local.writeKind ?? 'auto';
23
+ const incomingKind = incoming.writeKind ?? 'auto';
24
+ if (localKind === incomingKind)
25
+ return null;
26
+ if (incomingKind === 'user')
27
+ return true;
28
+ if (local.status === 'delayed'
29
+ && incoming.status === 'active'
30
+ && stamp(local.delayUntil) <= now)
31
+ return true;
32
+ if (local.status === 'completed'
33
+ && (incoming.status === 'scheduled' || incoming.status === 'active')
34
+ && stamp(incoming.nextTriggerTime) > stamp(local.nextTriggerTime))
35
+ return true;
36
+ if (local.status === 'active'
37
+ && (incoming.status === 'scheduled' || incoming.status === 'completed')
38
+ && stamp(local.activatedAt ?? local.updatedAt) <= now - scheduling_js_1.STALE_ACTIVE_THRESHOLD_MS)
39
+ return true;
40
+ if (local.status === 'scheduled'
41
+ && incoming.status === 'active'
42
+ && stamp(local.nextTriggerTime) <= now)
43
+ return true;
44
+ return false;
45
+ }
package/dist/index.d.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  export type { ReminderStatus, ScheduledReminder, RecurrenceType, ResponsibilityDef, AppSettings, StoreSchema, WriteKind } from './types.js';
2
2
  export { authorityOverride } from './writeAuthority.js';
3
3
  export type { AuthorityRow } from './writeAuthority.js';
4
- export { calculateNextTrigger, calculateNextTriggerFromNow, firstSlotForDay, isWithinActiveHours, matchesRecurrenceInterval, shouldTriggerOnDay, isStaleActive, isOccurrenceOverdue, STALE_ACTIVE_THRESHOLD_MS } from './scheduling.js';
4
+ export { calculateNextTrigger, calculateNextTriggerFromNow, firstSlotForDay, isWithinActiveHours, matchesRecurrenceInterval, shouldTriggerOnDay, isStaleActive, isOccurrenceOverdue, todaySlotTime, STALE_ACTIVE_THRESHOLD_MS } from './scheduling.js';
5
5
  export { isTimeOfDayOutsideActiveHours } from './activeHours.js';
6
6
  export { coarsenIdleTime, IDLE_TIME_BUCKET_SECONDS } from './coarsenIdleTime.js';
7
7
  export { formatNextTrigger } from './formatNextTrigger.js';
8
+ export { badgeFor, isEnded, isPastOnce } from './reminderBadges.js';
9
+ export type { ReminderBadge, ReminderBadgeTone } from './reminderBadges.js';
8
10
  export { buildSignedPayload } from './integrityPayload.js';
9
11
  export { versionSatisfies, isNewer } from './semver.js';
10
12
  export { shouldAutoClose } from './shouldAutoClose.js';
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  export { authorityOverride } from './writeAuthority.js';
2
- export { calculateNextTrigger, calculateNextTriggerFromNow, firstSlotForDay, isWithinActiveHours, matchesRecurrenceInterval, shouldTriggerOnDay, isStaleActive, isOccurrenceOverdue, STALE_ACTIVE_THRESHOLD_MS } from './scheduling.js';
2
+ export { calculateNextTrigger, calculateNextTriggerFromNow, firstSlotForDay, isWithinActiveHours, matchesRecurrenceInterval, shouldTriggerOnDay, isStaleActive, isOccurrenceOverdue, todaySlotTime, STALE_ACTIVE_THRESHOLD_MS } from './scheduling.js';
3
3
  export { isTimeOfDayOutsideActiveHours } from './activeHours.js';
4
4
  export { coarsenIdleTime, IDLE_TIME_BUCKET_SECONDS } from './coarsenIdleTime.js';
5
5
  export { formatNextTrigger } from './formatNextTrigger.js';
6
+ export { badgeFor, isEnded, isPastOnce } from './reminderBadges.js';
6
7
  export { buildSignedPayload } from './integrityPayload.js';
7
8
  export { versionSatisfies, isNewer } from './semver.js';
8
9
  export { shouldAutoClose } from './shouldAutoClose.js';
@@ -0,0 +1,9 @@
1
+ import type { ResponsibilityDef, ScheduledReminder } from './types.js';
2
+ export type ReminderBadgeTone = 'active' | 'docked' | 'snoozed' | 'done';
3
+ export interface ReminderBadge {
4
+ label: string;
5
+ tone: ReminderBadgeTone;
6
+ }
7
+ export declare function isEnded(def: ResponsibilityDef, now?: number): boolean;
8
+ export declare function isPastOnce(def: ResponsibilityDef, now?: number): boolean;
9
+ export declare function badgeFor(def: ResponsibilityDef, reminder: ScheduledReminder | undefined, dockedDefIds: ReadonlySet<string>, now?: number): ReminderBadge | null;
@@ -0,0 +1,24 @@
1
+ import { formatNextTrigger } from './formatNextTrigger.js';
2
+ export function isEnded(def, now = Date.now()) {
3
+ return def.endTime != null && now >= new Date(def.endTime).getTime();
4
+ }
5
+ export function isPastOnce(def, now = Date.now()) {
6
+ return def.recurrenceType === 'once' && new Date(def.startDateTime).getTime() < now;
7
+ }
8
+ export function badgeFor(def, reminder, dockedDefIds, now = Date.now()) {
9
+ if (isEnded(def, now))
10
+ return { label: 'Ended', tone: 'done' };
11
+ if (!reminder) {
12
+ return isPastOnce(def, now) ? { label: 'Done', tone: 'done' } : null;
13
+ }
14
+ if (reminder.status === 'active') {
15
+ return dockedDefIds.has(def.id) ? { label: 'Docked', tone: 'docked' } : { label: 'Active', tone: 'active' };
16
+ }
17
+ if (reminder.status === 'delayed') {
18
+ const until = formatNextTrigger(reminder.delayUntil && reminder.delayUntil > 0 ? reminder.delayUntil : reminder.nextTriggerTime, now);
19
+ return until === 'N/A' ? { label: 'Snoozed', tone: 'snoozed' } : { label: `Snoozed until ${until}`, tone: 'snoozed' };
20
+ }
21
+ if (reminder.status === 'completed')
22
+ return { label: 'Done', tone: 'done' };
23
+ return null;
24
+ }
@@ -1,6 +1,7 @@
1
1
  import { type ResponsibilityDef, type ScheduledReminder } from './types.js';
2
2
  export declare const STALE_ACTIVE_THRESHOLD_MS: number;
3
3
  export declare function shouldTriggerOnDay(def: ResponsibilityDef, date: Date): boolean;
4
+ export declare function todaySlotTime(def: ResponsibilityDef, now: number): number;
4
5
  export declare function isWithinActiveHours(now: number, startTime: string, endTime: string): boolean;
5
6
  export declare function matchesRecurrenceInterval(def: ResponsibilityDef, day: Date, startDate: Date, anchorDayStartMs: number): boolean;
6
7
  export declare function calculateNextTrigger(def: ResponsibilityDef, fromTime: number, activeStart: string, activeEnd: string): number | null;
@@ -5,6 +5,18 @@ export function shouldTriggerOnDay(def, date) {
5
5
  const dayOfWeek = date.getDay();
6
6
  return def.daysOfWeek.includes(dayOfWeek);
7
7
  }
8
+ // Today's occurrence slot for a def: the anchor's LOCAL wall-clock hour:minute
9
+ // (from startDateTime) applied to `now`'s calendar day, seconds/ms zeroed.
10
+ // Wall-clock-local by design (mirrors firstSlotForDay's non-hourly anchor):
11
+ // absolute-ms math drifts +1h across DST transitions. Consumers: the engine's
12
+ // missed-occurrence gates and the creation loop's due-ness check — a single
13
+ // source so the gates can never drift from settle's due-ness.
14
+ export function todaySlotTime(def, now) {
15
+ const startTime = new Date(def.startDateTime);
16
+ const todayScheduled = new Date(now);
17
+ todayScheduled.setHours(startTime.getHours(), startTime.getMinutes(), 0, 0);
18
+ return todayScheduled.getTime();
19
+ }
8
20
  export function isWithinActiveHours(now, startTime, endTime) {
9
21
  const date = new Date(now);
10
22
  const [startH, startM] = startTime.split(':').map(Number);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upnextapp/core",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "type": "module",
5
5
  "description": "Platform-agnostic core for UpNext: domain types, scheduling math, integrity payloads",
6
6
  "license": "MIT",
@@ -15,7 +15,8 @@
15
15
  "exports": {
16
16
  ".": {
17
17
  "types": "./dist/index.d.ts",
18
- "import": "./dist/index.js"
18
+ "import": "./dist/index.js",
19
+ "require": "./dist/cjs/index.js"
19
20
  }
20
21
  },
21
22
  "sideEffects": false,
@@ -26,7 +27,7 @@
26
27
  "access": "public"
27
28
  },
28
29
  "scripts": {
29
- "build": "tsc",
30
+ "build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist/cjs/package.json', JSON.stringify({ type: 'commonjs' }, null, 2) + '\\n')\"",
30
31
  "prepublishOnly": "npm run build"
31
32
  },
32
33
  "peerDependencies": {