@upnextapp/core 0.17.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.
- package/README.md +5 -0
- package/dist/activeHours.d.ts +1 -0
- package/dist/activeHours.js +28 -0
- package/dist/coarsenIdleTime.d.ts +2 -0
- package/dist/coarsenIdleTime.js +4 -0
- package/dist/formatNextTrigger.d.ts +1 -0
- package/dist/formatNextTrigger.js +11 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +8 -0
- package/dist/integrityPayload.d.ts +1 -0
- package/dist/integrityPayload.js +6 -0
- package/dist/manifest.d.ts +90 -0
- package/dist/manifest.js +56 -0
- package/dist/scheduling.d.ts +11 -0
- package/dist/scheduling.js +198 -0
- package/dist/semver.d.ts +2 -0
- package/dist/semver.js +32 -0
- package/dist/shouldAutoClose.d.ts +1 -0
- package/dist/shouldAutoClose.js +3 -0
- package/dist/types.d.ts +36 -0
- package/dist/types.js +1 -0
- package/package.json +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# @upnextapp/core
|
|
2
|
+
|
|
3
|
+
Platform-agnostic core for UpNext: domain types, scheduling math, integrity payloads, semver utilities. Consumed by the desktop app (via source alias), the plugin SDK (dependency + re-export), and the future mobile client (via npm).
|
|
4
|
+
|
|
5
|
+
Versioning: lockstep with the root app version — never bump independently. See `docs/superpowers/specs/2026-08-15-plugin-sdk-split-design.md`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isTimeOfDayOutsideActiveHours(startDateTime: string, activeStart: string, activeEnd: string): boolean;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function isTimeOfDayOutsideActiveHours(startDateTime, activeStart, activeEnd) {
|
|
2
|
+
if (!startDateTime)
|
|
3
|
+
return false;
|
|
4
|
+
const start = new Date(startDateTime);
|
|
5
|
+
if (Number.isNaN(start.getTime()))
|
|
6
|
+
return false;
|
|
7
|
+
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
|
8
|
+
const [aSH, aSM] = activeStart.split(':').map(Number);
|
|
9
|
+
const [aEH, aEM] = activeEnd.split(':').map(Number);
|
|
10
|
+
if (Number.isNaN(aSH) || Number.isNaN(aSM) || Number.isNaN(aEH) || Number.isNaN(aEM))
|
|
11
|
+
return false;
|
|
12
|
+
const startMin = aSH * 60 + aSM;
|
|
13
|
+
const endMin = aEH * 60 + aEM;
|
|
14
|
+
const isLegacyMidnightWrap = (aEH === 0 && aEM === 0);
|
|
15
|
+
const isInverted = !isLegacyMidnightWrap && startMin > endMin;
|
|
16
|
+
if (isLegacyMidnightWrap) {
|
|
17
|
+
return startMinutes < startMin;
|
|
18
|
+
}
|
|
19
|
+
if (isInverted) {
|
|
20
|
+
// Note: for inverted windows the engine's firstSlotForDay would CLAMP a daytime
|
|
21
|
+
// anchor into the window (e.g. 12:00 daily → fires 22:00). Forms intentionally
|
|
22
|
+
// treat daytime anchors as outside so saves are blocked — silently moving a
|
|
23
|
+
// user's chosen time by 10 hours would be a footgun. This is deliberate
|
|
24
|
+
// form-stricter-than-engine behavior, not drift.
|
|
25
|
+
return startMinutes > endMin && startMinutes < startMin;
|
|
26
|
+
}
|
|
27
|
+
return startMinutes < startMin || startMinutes > endMin;
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function formatNextTrigger(ts: number | undefined, now?: number): string;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function formatNextTrigger(ts, now = Date.now()) {
|
|
2
|
+
if (!ts || ts <= 0)
|
|
3
|
+
return 'N/A';
|
|
4
|
+
const date = new Date(ts);
|
|
5
|
+
const currentDate = new Date(now);
|
|
6
|
+
const isToday = date.toDateString() === currentDate.toDateString();
|
|
7
|
+
const time = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
|
8
|
+
if (isToday)
|
|
9
|
+
return `Today ${time}`;
|
|
10
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
|
11
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type { ReminderStatus, ScheduledReminder, RecurrenceType, ResponsibilityDef, AppSettings, StoreSchema } from './types';
|
|
2
|
+
export { calculateNextTrigger, calculateNextTriggerFromNow, firstSlotForDay, isWithinActiveHours, matchesRecurrenceInterval, shouldTriggerOnDay, isStaleActive, isOccurrenceOverdue, STALE_ACTIVE_THRESHOLD_MS, HOURLY_STALE_THRESHOLD_MS } from './scheduling';
|
|
3
|
+
export { isTimeOfDayOutsideActiveHours } from './activeHours';
|
|
4
|
+
export { coarsenIdleTime, IDLE_TIME_BUCKET_SECONDS } from './coarsenIdleTime';
|
|
5
|
+
export { formatNextTrigger } from './formatNextTrigger';
|
|
6
|
+
export { buildSignedPayload } from './integrityPayload';
|
|
7
|
+
export { versionSatisfies, isNewer } from './semver';
|
|
8
|
+
export { shouldAutoClose } from './shouldAutoClose';
|
|
9
|
+
export { validatePluginManifest, pluginManifestSchema } from './manifest';
|
|
10
|
+
export type { PluginManifest } from './manifest';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { calculateNextTrigger, calculateNextTriggerFromNow, firstSlotForDay, isWithinActiveHours, matchesRecurrenceInterval, shouldTriggerOnDay, isStaleActive, isOccurrenceOverdue, STALE_ACTIVE_THRESHOLD_MS, HOURLY_STALE_THRESHOLD_MS } from './scheduling';
|
|
2
|
+
export { isTimeOfDayOutsideActiveHours } from './activeHours';
|
|
3
|
+
export { coarsenIdleTime, IDLE_TIME_BUCKET_SECONDS } from './coarsenIdleTime';
|
|
4
|
+
export { formatNextTrigger } from './formatNextTrigger';
|
|
5
|
+
export { buildSignedPayload } from './integrityPayload';
|
|
6
|
+
export { versionSatisfies, isNewer } from './semver';
|
|
7
|
+
export { shouldAutoClose } from './shouldAutoClose';
|
|
8
|
+
export { validatePluginManifest, pluginManifestSchema } from './manifest';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function buildSignedPayload(pluginId: string, version: string, entryPath: string, entrySha256: string): string;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// CANONICAL source of buildSignedPayload. The inline copy in scripts/sign-plugin.mjs
|
|
2
|
+
// MUST stay byte-for-byte in sync. Drift = plugin signature verification breaks.
|
|
3
|
+
// Sync test: src/tests/unit/integrityPayload.test.ts 'sign-plugin.mjs sync check'
|
|
4
|
+
export function buildSignedPayload(pluginId, version, entryPath, entrySha256) {
|
|
5
|
+
return `${pluginId}:${version}:${entryPath}:${entrySha256}`;
|
|
6
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const pluginManifestSchema: z.ZodObject<{
|
|
3
|
+
id: z.ZodString;
|
|
4
|
+
name: z.ZodString;
|
|
5
|
+
version: z.ZodString;
|
|
6
|
+
description: z.ZodOptional<z.ZodString>;
|
|
7
|
+
author: z.ZodOptional<z.ZodString>;
|
|
8
|
+
icon: z.ZodOptional<z.ZodString>;
|
|
9
|
+
color: z.ZodOptional<z.ZodString>;
|
|
10
|
+
minPlatformVersion: z.ZodOptional<z.ZodString>;
|
|
11
|
+
capabilities: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
12
|
+
client: z.ZodObject<{
|
|
13
|
+
entry: z.ZodString;
|
|
14
|
+
pages: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
15
|
+
id: z.ZodString;
|
|
16
|
+
label: z.ZodString;
|
|
17
|
+
icon: z.ZodString;
|
|
18
|
+
}, z.core.$strip>>>;
|
|
19
|
+
windows: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
20
|
+
id: z.ZodString;
|
|
21
|
+
size: z.ZodObject<{
|
|
22
|
+
width: z.ZodNumber;
|
|
23
|
+
height: z.ZodUnion<readonly [z.ZodNumber, z.ZodLiteral<"full">]>;
|
|
24
|
+
}, z.core.$strip>;
|
|
25
|
+
position: z.ZodOptional<z.ZodEnum<{
|
|
26
|
+
center: "center";
|
|
27
|
+
"right-edge": "right-edge";
|
|
28
|
+
"left-edge": "left-edge";
|
|
29
|
+
}>>;
|
|
30
|
+
frameless: z.ZodOptional<z.ZodBoolean>;
|
|
31
|
+
alwaysOnTop: z.ZodOptional<z.ZodBoolean>;
|
|
32
|
+
persistent: z.ZodOptional<z.ZodBoolean>;
|
|
33
|
+
skipTaskbar: z.ZodOptional<z.ZodBoolean>;
|
|
34
|
+
}, z.core.$strip>>>;
|
|
35
|
+
reminderTypes: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
36
|
+
settingsPanel: z.ZodOptional<z.ZodBoolean>;
|
|
37
|
+
componentMap: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
38
|
+
}, z.core.$strip>;
|
|
39
|
+
server: z.ZodOptional<z.ZodObject<{
|
|
40
|
+
entry: z.ZodString;
|
|
41
|
+
routes: z.ZodDefault<z.ZodArray<z.ZodAny>>;
|
|
42
|
+
webhooks: z.ZodDefault<z.ZodArray<z.ZodAny>>;
|
|
43
|
+
cronJobs: z.ZodDefault<z.ZodArray<z.ZodAny>>;
|
|
44
|
+
tables: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
45
|
+
env: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
46
|
+
}, z.core.$strip>>;
|
|
47
|
+
}, z.core.$strip>;
|
|
48
|
+
export type PluginManifest = z.infer<typeof pluginManifestSchema>;
|
|
49
|
+
export declare function validatePluginManifest(data: unknown): z.ZodSafeParseResult<{
|
|
50
|
+
id: string;
|
|
51
|
+
name: string;
|
|
52
|
+
version: string;
|
|
53
|
+
capabilities: string[];
|
|
54
|
+
client: {
|
|
55
|
+
entry: string;
|
|
56
|
+
pages: {
|
|
57
|
+
id: string;
|
|
58
|
+
label: string;
|
|
59
|
+
icon: string;
|
|
60
|
+
}[];
|
|
61
|
+
windows: {
|
|
62
|
+
id: string;
|
|
63
|
+
size: {
|
|
64
|
+
width: number;
|
|
65
|
+
height: number | "full";
|
|
66
|
+
};
|
|
67
|
+
position?: "center" | "right-edge" | "left-edge" | undefined;
|
|
68
|
+
frameless?: boolean | undefined;
|
|
69
|
+
alwaysOnTop?: boolean | undefined;
|
|
70
|
+
persistent?: boolean | undefined;
|
|
71
|
+
skipTaskbar?: boolean | undefined;
|
|
72
|
+
}[];
|
|
73
|
+
reminderTypes: string[];
|
|
74
|
+
settingsPanel?: boolean | undefined;
|
|
75
|
+
componentMap?: Record<string, string> | undefined;
|
|
76
|
+
};
|
|
77
|
+
description?: string | undefined;
|
|
78
|
+
author?: string | undefined;
|
|
79
|
+
icon?: string | undefined;
|
|
80
|
+
color?: string | undefined;
|
|
81
|
+
minPlatformVersion?: string | undefined;
|
|
82
|
+
server?: {
|
|
83
|
+
entry: string;
|
|
84
|
+
routes: any[];
|
|
85
|
+
webhooks: any[];
|
|
86
|
+
cronJobs: any[];
|
|
87
|
+
tables: string[];
|
|
88
|
+
env: string[];
|
|
89
|
+
} | undefined;
|
|
90
|
+
}>;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const windowConfigSchema = z.object({
|
|
3
|
+
id: z.string().min(1),
|
|
4
|
+
size: z.object({
|
|
5
|
+
width: z.number().positive(),
|
|
6
|
+
height: z.union([z.number().positive(), z.literal('full')]),
|
|
7
|
+
}),
|
|
8
|
+
position: z.enum(['center', 'right-edge', 'left-edge']).optional(),
|
|
9
|
+
frameless: z.boolean().optional(),
|
|
10
|
+
alwaysOnTop: z.boolean().optional(),
|
|
11
|
+
persistent: z.boolean().optional(),
|
|
12
|
+
skipTaskbar: z.boolean().optional(),
|
|
13
|
+
});
|
|
14
|
+
const pageConfigSchema = z.object({
|
|
15
|
+
id: z.string().min(1),
|
|
16
|
+
label: z.string(),
|
|
17
|
+
icon: z.string(),
|
|
18
|
+
});
|
|
19
|
+
export const pluginManifestSchema = z.object({
|
|
20
|
+
id: 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 __'),
|
|
21
|
+
name: z.string().min(1),
|
|
22
|
+
version: z.string().regex(/^\d+\.\d+\.\d+$/),
|
|
23
|
+
description: z.string().optional(),
|
|
24
|
+
author: z.string().optional(),
|
|
25
|
+
icon: z.string().optional(),
|
|
26
|
+
color: z.string().optional(),
|
|
27
|
+
minPlatformVersion: z.string().optional(),
|
|
28
|
+
capabilities: z.array(z.string()).default([]),
|
|
29
|
+
client: z.object({
|
|
30
|
+
entry: z.string().min(1).refine(e => !e.includes('..') && !e.startsWith('/'), {
|
|
31
|
+
message: 'Entry path must not contain ".." or start with "/"',
|
|
32
|
+
}),
|
|
33
|
+
pages: z.array(pageConfigSchema).default([]),
|
|
34
|
+
windows: z.array(windowConfigSchema).default([]),
|
|
35
|
+
reminderTypes: z.array(z.string()).default([]),
|
|
36
|
+
settingsPanel: z.boolean().optional(),
|
|
37
|
+
componentMap: z.record(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'), z.string().min(1).refine(e => !e.includes('..') && !e.startsWith('/'), {
|
|
38
|
+
message: 'Path must not contain ".." or start with "/"',
|
|
39
|
+
})).optional(),
|
|
40
|
+
}),
|
|
41
|
+
server: z
|
|
42
|
+
.object({
|
|
43
|
+
entry: z.string().min(1).refine(e => !e.includes('..') && !e.startsWith('/'), {
|
|
44
|
+
message: 'Entry path must not contain ".." or start with "/"',
|
|
45
|
+
}),
|
|
46
|
+
routes: z.array(z.any()).default([]),
|
|
47
|
+
webhooks: z.array(z.any()).default([]),
|
|
48
|
+
cronJobs: z.array(z.any()).default([]),
|
|
49
|
+
tables: z.array(z.string()).default([]),
|
|
50
|
+
env: z.array(z.string()).default([]),
|
|
51
|
+
})
|
|
52
|
+
.optional(),
|
|
53
|
+
});
|
|
54
|
+
export function validatePluginManifest(data) {
|
|
55
|
+
return pluginManifestSchema.safeParse(data);
|
|
56
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ResponsibilityDef, type ScheduledReminder } from './types';
|
|
2
|
+
export declare const STALE_ACTIVE_THRESHOLD_MS: number;
|
|
3
|
+
export declare const HOURLY_STALE_THRESHOLD_MS: number;
|
|
4
|
+
export declare function shouldTriggerOnDay(def: ResponsibilityDef, date: Date): boolean;
|
|
5
|
+
export declare function isWithinActiveHours(now: number, startTime: string, endTime: string): boolean;
|
|
6
|
+
export declare function matchesRecurrenceInterval(def: ResponsibilityDef, day: Date, startDate: Date, anchorDayStartMs: number): boolean;
|
|
7
|
+
export declare function calculateNextTrigger(def: ResponsibilityDef, fromTime: number, activeStart: string, activeEnd: string): number | null;
|
|
8
|
+
export declare function firstSlotForDay(def: ResponsibilityDef, day: Date, anchorInstant: number, activeStart: string, activeEnd: string, fromTime: number): number | null;
|
|
9
|
+
export declare function calculateNextTriggerFromNow(def: ResponsibilityDef, fromTime: number, activeStart: string, activeEnd: string): number | null;
|
|
10
|
+
export declare function isStaleActive(reminder: ScheduledReminder, now: number, def?: ResponsibilityDef): boolean;
|
|
11
|
+
export declare function isOccurrenceOverdue(def: ResponsibilityDef, reminder: ScheduledReminder, now: number, activeStart: string, activeEnd: string): boolean;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
export const STALE_ACTIVE_THRESHOLD_MS = 48 * 60 * 60 * 1000;
|
|
2
|
+
export const HOURLY_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
|
3
|
+
export function shouldTriggerOnDay(def, date) {
|
|
4
|
+
if (!def.daysOfWeek || def.daysOfWeek.length === 0)
|
|
5
|
+
return true;
|
|
6
|
+
const dayOfWeek = date.getDay();
|
|
7
|
+
return def.daysOfWeek.includes(dayOfWeek);
|
|
8
|
+
}
|
|
9
|
+
export function isWithinActiveHours(now, startTime, endTime) {
|
|
10
|
+
const date = new Date(now);
|
|
11
|
+
const [startH, startM] = startTime.split(':').map(Number);
|
|
12
|
+
const [endH, endM] = endTime.split(':').map(Number);
|
|
13
|
+
if (Number.isNaN(startH) || Number.isNaN(startM) || Number.isNaN(endH) || Number.isNaN(endM)) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const start = new Date(date);
|
|
17
|
+
start.setHours(startH, startM, 0, 0);
|
|
18
|
+
const end = new Date(date);
|
|
19
|
+
const isLegacyMidnightWrap = (endH === 0 && endM === 0);
|
|
20
|
+
const isInverted = !isLegacyMidnightWrap && (startH * 60 + startM) > (endH * 60 + endM);
|
|
21
|
+
if (isLegacyMidnightWrap || isInverted) {
|
|
22
|
+
end.setDate(end.getDate() + 1);
|
|
23
|
+
}
|
|
24
|
+
end.setHours(endH, endM, 0, 0);
|
|
25
|
+
if (isInverted && now < start.getTime()) {
|
|
26
|
+
start.setDate(start.getDate() - 1);
|
|
27
|
+
end.setDate(end.getDate() - 1);
|
|
28
|
+
}
|
|
29
|
+
return now >= start.getTime() && now <= end.getTime();
|
|
30
|
+
}
|
|
31
|
+
export function matchesRecurrenceInterval(def, day, startDate, anchorDayStartMs) {
|
|
32
|
+
const interval = def.interval && def.interval > 0 ? def.interval : 1;
|
|
33
|
+
const dayStart = new Date(day);
|
|
34
|
+
dayStart.setHours(0, 0, 0, 0);
|
|
35
|
+
const dayDiffMs = dayStart.getTime() - anchorDayStartMs;
|
|
36
|
+
if (dayDiffMs < 0)
|
|
37
|
+
return false;
|
|
38
|
+
switch (def.recurrenceType) {
|
|
39
|
+
case 'hourly':
|
|
40
|
+
return true;
|
|
41
|
+
case 'daily': {
|
|
42
|
+
const dayDiff = Math.floor(dayDiffMs / (24 * 60 * 60 * 1000));
|
|
43
|
+
return dayDiff % interval === 0;
|
|
44
|
+
}
|
|
45
|
+
case 'weekly': {
|
|
46
|
+
const weekDiff = Math.floor(dayDiffMs / (7 * 24 * 60 * 60 * 1000));
|
|
47
|
+
return weekDiff % interval === 0;
|
|
48
|
+
}
|
|
49
|
+
case 'monthly': {
|
|
50
|
+
const monthDiff = (day.getFullYear() - startDate.getFullYear()) * 12 + (day.getMonth() - startDate.getMonth());
|
|
51
|
+
return monthDiff >= 0 && monthDiff % interval === 0 && day.getDate() === startDate.getDate();
|
|
52
|
+
}
|
|
53
|
+
case 'yearly': {
|
|
54
|
+
const yearDiff = day.getFullYear() - startDate.getFullYear();
|
|
55
|
+
return yearDiff >= 0 && yearDiff % interval === 0
|
|
56
|
+
&& day.getMonth() === startDate.getMonth() && day.getDate() === startDate.getDate();
|
|
57
|
+
}
|
|
58
|
+
default:
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function calculateNextTrigger(def, fromTime, activeStart, activeEnd) {
|
|
63
|
+
if (!def.enabled)
|
|
64
|
+
return null;
|
|
65
|
+
const startMs = new Date(def.startDateTime).getTime();
|
|
66
|
+
if (def.recurrenceType === 'once') {
|
|
67
|
+
return startMs > fromTime ? startMs : null;
|
|
68
|
+
}
|
|
69
|
+
if (def.endTime && fromTime >= new Date(def.endTime).getTime())
|
|
70
|
+
return null;
|
|
71
|
+
const startDate = new Date(def.startDateTime);
|
|
72
|
+
const fromDate = new Date(fromTime);
|
|
73
|
+
fromDate.setHours(0, 0, 0, 0);
|
|
74
|
+
const anchorDayStart = new Date(def.startDateTime);
|
|
75
|
+
anchorDayStart.setHours(0, 0, 0, 0);
|
|
76
|
+
const anchorDayStartMs = anchorDayStart.getTime();
|
|
77
|
+
const interval = def.interval && def.interval > 0 ? def.interval : 1;
|
|
78
|
+
let maxDays = 400;
|
|
79
|
+
if (def.recurrenceType === 'yearly')
|
|
80
|
+
maxDays = Math.max(maxDays, interval * 366 * 4 + 10);
|
|
81
|
+
else if (def.recurrenceType === 'monthly')
|
|
82
|
+
maxDays = Math.max(maxDays, interval * 31 + 10);
|
|
83
|
+
for (let offset = 0; offset <= maxDays; offset++) {
|
|
84
|
+
const day = new Date(fromDate);
|
|
85
|
+
day.setDate(day.getDate() + offset);
|
|
86
|
+
// Empty daysOfWeek = no filter (fires every day). UI prevents empty array on save.
|
|
87
|
+
// If this assumption changes, add explicit guard for length === 0.
|
|
88
|
+
if (def.daysOfWeek && def.daysOfWeek.length > 0 && !def.daysOfWeek.includes(day.getDay()))
|
|
89
|
+
continue;
|
|
90
|
+
if (!matchesRecurrenceInterval(def, day, startDate, anchorDayStartMs))
|
|
91
|
+
continue;
|
|
92
|
+
const slot = firstSlotForDay(def, day, startMs, activeStart, activeEnd, fromTime);
|
|
93
|
+
if (slot !== null) {
|
|
94
|
+
if (def.endTime && slot >= new Date(def.endTime).getTime())
|
|
95
|
+
return null;
|
|
96
|
+
return slot;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
// Supports inverted active hours (activeStart > activeEnd, e.g. night shift 22:00-06:00).
|
|
102
|
+
// When inverted, boundsEnd wraps to the next day, producing an overnight window
|
|
103
|
+
// [activeStart today, activeEnd tomorrow]. For non-hourly recurrences, an anchor whose
|
|
104
|
+
// time-of-day falls in the early-morning portion of the inverted window ([00:00, activeEnd))
|
|
105
|
+
// is shifted to the next day so the slot lands inside the window.
|
|
106
|
+
export function firstSlotForDay(def, day, anchorInstant, activeStart, activeEnd, fromTime) {
|
|
107
|
+
const [aSH, aSM] = activeStart.split(':').map(Number);
|
|
108
|
+
const [aEH, aEM] = activeEnd.split(':').map(Number);
|
|
109
|
+
if (Number.isNaN(aSH) || Number.isNaN(aSM) || Number.isNaN(aEH) || Number.isNaN(aEM)) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const boundsStart = new Date(day);
|
|
113
|
+
boundsStart.setHours(aSH, aSM, 0, 0);
|
|
114
|
+
const boundsEnd = new Date(day);
|
|
115
|
+
boundsEnd.setHours(aEH, aEM, 0, 0);
|
|
116
|
+
const isLegacyMidnightWrap = (aEH === 0 && aEM === 0);
|
|
117
|
+
const startTodMin = aSH * 60 + aSM;
|
|
118
|
+
const endTodMin = aEH * 60 + aEM;
|
|
119
|
+
const isInverted = !isLegacyMidnightWrap && startTodMin > endTodMin;
|
|
120
|
+
if (isLegacyMidnightWrap || isInverted) {
|
|
121
|
+
boundsEnd.setDate(boundsEnd.getDate() + 1);
|
|
122
|
+
}
|
|
123
|
+
if (def.recurrenceType === 'hourly') {
|
|
124
|
+
const intervalHours = (def.interval && def.interval > 0 ? def.interval : 1);
|
|
125
|
+
// Hourly: strict > prevents re-firing at the same instant as a recent dismissal
|
|
126
|
+
const lowerBound = Math.max(boundsStart.getTime(), fromTime + 1);
|
|
127
|
+
if (lowerBound > boundsEnd.getTime())
|
|
128
|
+
return null;
|
|
129
|
+
// Grid phase anchored to LOCAL hour:minute of startDateTime (not absolute UTC ms).
|
|
130
|
+
// Absolute-ms grids drift across DST: an anchor saved during standard time displays
|
|
131
|
+
// +1h during daylight time because UTC offset changes. Reading hour:minute directly
|
|
132
|
+
// from the startDateTime string (format "YYYY-MM-DDTHH:mm") is TZ-independent —
|
|
133
|
+
// it's the literal wall-clock hour the user chose.
|
|
134
|
+
const timePart = def.startDateTime.split('T')[1] ?? '00:00';
|
|
135
|
+
const [aH, aM] = timePart.split(':').map(Number);
|
|
136
|
+
const anchorTodMin = (Number.isNaN(aH) ? 0 : aH) * 60 + (Number.isNaN(aM) ? 0 : aM);
|
|
137
|
+
const intervalMin = intervalHours * 60;
|
|
138
|
+
const slotStart = new Date(boundsStart);
|
|
139
|
+
const boundsStartTodMin = slotStart.getHours() * 60 + slotStart.getMinutes();
|
|
140
|
+
const phaseOffset = ((anchorTodMin - boundsStartTodMin) % intervalMin + intervalMin) % intervalMin;
|
|
141
|
+
slotStart.setMinutes(slotStart.getMinutes() + phaseOffset, 0, 0);
|
|
142
|
+
// setHours advances wall-clock hour by intervalHours — DST transitions are handled
|
|
143
|
+
// correctly (slots stay at consistent local hour regardless of UTC offset changes).
|
|
144
|
+
while (slotStart.getTime() < lowerBound) {
|
|
145
|
+
slotStart.setHours(slotStart.getHours() + intervalHours);
|
|
146
|
+
}
|
|
147
|
+
if (slotStart.getTime() > boundsEnd.getTime())
|
|
148
|
+
return null;
|
|
149
|
+
return slotStart.getTime();
|
|
150
|
+
}
|
|
151
|
+
const anchorDate = new Date(anchorInstant);
|
|
152
|
+
const anchorTodMin = anchorDate.getHours() * 60 + anchorDate.getMinutes();
|
|
153
|
+
const anchorDay = new Date(day);
|
|
154
|
+
if (isInverted && anchorTodMin < endTodMin) {
|
|
155
|
+
anchorDay.setDate(anchorDay.getDate() + 1);
|
|
156
|
+
}
|
|
157
|
+
const anchor = new Date(anchorDay);
|
|
158
|
+
anchor.setHours(anchorDate.getHours(), anchorDate.getMinutes(), 0, 0);
|
|
159
|
+
const anchorMs = anchor.getTime();
|
|
160
|
+
if (anchorMs > boundsEnd.getTime())
|
|
161
|
+
return null;
|
|
162
|
+
const effective = Math.max(anchorMs, boundsStart.getTime());
|
|
163
|
+
// Daily/weekly/etc: inclusive >= because same-instant re-fire is extremely unlikely (24h+ intervals).
|
|
164
|
+
// NOTE: edge case where fromTime exactly equals anchor (e.g. startup at scheduled fire time) DOES
|
|
165
|
+
// re-fire — intentional, ensures missed-on-startup triggers still activate.
|
|
166
|
+
return effective >= fromTime ? effective : null;
|
|
167
|
+
}
|
|
168
|
+
export function calculateNextTriggerFromNow(def, fromTime, activeStart, activeEnd) {
|
|
169
|
+
if (!def.enabled)
|
|
170
|
+
return null;
|
|
171
|
+
if (def.recurrenceType === 'once')
|
|
172
|
+
return null;
|
|
173
|
+
return calculateNextTrigger(def, fromTime, activeStart, activeEnd);
|
|
174
|
+
}
|
|
175
|
+
export function isStaleActive(reminder, now, def) {
|
|
176
|
+
if (!reminder.activatedAt)
|
|
177
|
+
return true;
|
|
178
|
+
const threshold = def?.recurrenceType === 'hourly' ? HOURLY_STALE_THRESHOLD_MS : STALE_ACTIVE_THRESHOLD_MS;
|
|
179
|
+
return (now - reminder.activatedAt) > threshold;
|
|
180
|
+
}
|
|
181
|
+
export function isOccurrenceOverdue(def, reminder, now, activeStart, activeEnd) {
|
|
182
|
+
// Auto-advance only when the next due slot is on a NEW calendar day than the
|
|
183
|
+
// current occurrence's slot. daily/weekly/monthly/yearly always cross days
|
|
184
|
+
// (so they reset each occurrence); hourly reminders keep escalating through
|
|
185
|
+
// same-day slots and only reset at the first slot after midnight. Because a
|
|
186
|
+
// recurring reminder always crosses a day within ~24h, this fires well before
|
|
187
|
+
// the 48h stale path above — so stale dismissal only reaches `once`/docked.
|
|
188
|
+
//
|
|
189
|
+
// Pass nextTriggerTime + 1: firstSlotForDay uses inclusive >= for non-hourly (so startup
|
|
190
|
+
// at exact fire-time re-fires missed triggers). Without +1 here, calculateNextTrigger
|
|
191
|
+
// would return the SAME slot, making this day-comparison always equal → no auto-advance.
|
|
192
|
+
// Note: +1 is only effective for non-hourly types; hourly already uses fromTime+1 internally,
|
|
193
|
+
// so this becomes a double-offset that Math.ceil absorbs (no slot skip).
|
|
194
|
+
const nextSlot = calculateNextTrigger(def, reminder.nextTriggerTime + 1, activeStart, activeEnd);
|
|
195
|
+
if (nextSlot === null || now < nextSlot)
|
|
196
|
+
return false;
|
|
197
|
+
return new Date(nextSlot).toDateString() !== new Date(reminder.nextTriggerTime).toDateString();
|
|
198
|
+
}
|
package/dist/semver.d.ts
ADDED
package/dist/semver.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function versionSatisfies(appVersion, minVersion) {
|
|
2
|
+
if (!minVersion)
|
|
3
|
+
return true;
|
|
4
|
+
const app = appVersion.split('.').map(Number);
|
|
5
|
+
const min = minVersion.split('.').map(Number);
|
|
6
|
+
for (let i = 0; i < 3; i++) {
|
|
7
|
+
const a = app[i] ?? 0;
|
|
8
|
+
const m = min[i] ?? 0;
|
|
9
|
+
if (Number.isNaN(a) || Number.isNaN(m))
|
|
10
|
+
return false;
|
|
11
|
+
if (a < m)
|
|
12
|
+
return false;
|
|
13
|
+
if (a > m)
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
export function isNewer(current, candidate) {
|
|
19
|
+
const cur = current.split('.').map(Number);
|
|
20
|
+
const cand = candidate.split('.').map(Number);
|
|
21
|
+
for (let i = 0; i < 3; i++) {
|
|
22
|
+
const c = cand[i] ?? 0;
|
|
23
|
+
const v = cur[i] ?? 0;
|
|
24
|
+
if (Number.isNaN(c) || Number.isNaN(v))
|
|
25
|
+
return false;
|
|
26
|
+
if (c > v)
|
|
27
|
+
return true;
|
|
28
|
+
if (c < v)
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function shouldAutoClose(expanded: boolean, dragHighlight: boolean, lastInteraction: number, now: number, thresholdMs?: number): boolean;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type ReminderStatus = 'scheduled' | 'active' | 'delayed' | 'completed';
|
|
2
|
+
export interface ScheduledReminder {
|
|
3
|
+
defId: string;
|
|
4
|
+
status: ReminderStatus;
|
|
5
|
+
nextTriggerTime: number;
|
|
6
|
+
activatedAt?: number;
|
|
7
|
+
delayUntil?: number;
|
|
8
|
+
lastCompletedAt?: number;
|
|
9
|
+
pluginData?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
export type RecurrenceType = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly';
|
|
12
|
+
export interface ResponsibilityDef {
|
|
13
|
+
id: string;
|
|
14
|
+
title: string;
|
|
15
|
+
recurrenceType: RecurrenceType;
|
|
16
|
+
interval?: number;
|
|
17
|
+
startDateTime: string;
|
|
18
|
+
endTime?: string;
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
daysOfWeek?: number[];
|
|
21
|
+
pluginId?: string;
|
|
22
|
+
reminderType?: string;
|
|
23
|
+
pluginData?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
export interface AppSettings {
|
|
26
|
+
activeStartTime: string;
|
|
27
|
+
activeEndTime: string;
|
|
28
|
+
autostart: boolean;
|
|
29
|
+
drawerEdge: 'right' | 'left';
|
|
30
|
+
}
|
|
31
|
+
export interface StoreSchema {
|
|
32
|
+
version: number;
|
|
33
|
+
responsibilities: ResponsibilityDef[];
|
|
34
|
+
reminders: ScheduledReminder[];
|
|
35
|
+
settings: AppSettings;
|
|
36
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@upnextapp/core",
|
|
3
|
+
"version": "0.17.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Platform-agnostic core for UpNext: domain types, scheduling math, integrity payloads",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "UpNext",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/morcth/upnext",
|
|
11
|
+
"directory": "packages/core"
|
|
12
|
+
},
|
|
13
|
+
"main": "dist/index.js",
|
|
14
|
+
"types": "dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc",
|
|
29
|
+
"prepublishOnly": "npm run build"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"zod": "^3.0.0 || ^4.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"typescript": "^5.9.3",
|
|
36
|
+
"zod": "^4.4.3"
|
|
37
|
+
}
|
|
38
|
+
}
|