Files
memind/schedule-time.mjs
T
john 2c91689692 feat(schedule): 待办提醒推送、行事历仅展示提醒与管理 API
单次提醒 worker 投递、local 时间写入校验、未来 7 天提醒列表与忽略/批量删除;行事历去掉事项重复展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 18:08:06 +08:00

214 lines
6.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const DEFAULT_TIMEZONE = 'Asia/Shanghai';
function pad2(value) {
return String(value).padStart(2, '0');
}
export function normalizeTimezone(timezone) {
return String(timezone || process.env.H5_DEFAULT_TIMEZONE || DEFAULT_TIMEZONE).trim() || DEFAULT_TIMEZONE;
}
export function getLocalParts(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: normalizeTimezone(timezone),
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const parts = Object.fromEntries(
formatter.formatToParts(new Date(epochMs)).map((part) => [part.type, part.value]),
);
return {
year: Number(parts.year),
month: Number(parts.month),
day: Number(parts.day),
hour: Number(parts.hour === '24' ? 0 : parts.hour),
minute: Number(parts.minute),
second: Number(parts.second),
};
}
export function localDateKey(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) {
const parts = getLocalParts(epochMs, timezone);
return `${parts.year}-${pad2(parts.month)}-${pad2(parts.day)}`;
}
export function localDateLabel(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) {
const parts = getLocalParts(epochMs, timezone);
return `${parts.month}${parts.day}`;
}
export function startOfLocalDay(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) {
const parts = getLocalParts(epochMs, timezone);
return zonedTimeToEpochMs(
{
year: parts.year,
month: parts.month,
day: parts.day,
hour: 0,
minute: 0,
second: 0,
},
timezone,
);
}
export function addLocalDays(epochMs, days, timezone = DEFAULT_TIMEZONE) {
const parts = getLocalParts(epochMs, timezone);
const utc = Date.UTC(parts.year, parts.month - 1, parts.day + Number(days || 0), parts.hour, parts.minute, parts.second);
const shifted = new Date(utc);
return zonedTimeToEpochMs(
{
year: shifted.getUTCFullYear(),
month: shifted.getUTCMonth() + 1,
day: shifted.getUTCDate(),
hour: shifted.getUTCHours(),
minute: shifted.getUTCMinutes(),
second: shifted.getUTCSeconds(),
},
timezone,
);
}
export function zonedTimeToEpochMs(parts, timezone = DEFAULT_TIMEZONE) {
const tz = normalizeTimezone(timezone);
let guess = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour ?? 0),
Number(parts.minute ?? 0),
Number(parts.second ?? 0),
0,
);
for (let i = 0; i < 3; i += 1) {
const actual = getLocalParts(guess, tz);
const actualAsUtc = Date.UTC(
actual.year,
actual.month - 1,
actual.day,
actual.hour,
actual.minute,
actual.second,
0,
);
const desiredAsUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour ?? 0),
Number(parts.minute ?? 0),
Number(parts.second ?? 0),
0,
);
const delta = actualAsUtc - desiredAsUtc;
if (delta === 0) break;
guess -= delta;
}
return guess;
}
export function nextDailyRunAt({ hour, minute = 0, timezone = DEFAULT_TIMEZONE, now = Date.now() }) {
const tz = normalizeTimezone(timezone);
const todayStart = startOfLocalDay(now, tz);
const todayParts = getLocalParts(todayStart, tz);
let runAt = zonedTimeToEpochMs(
{
year: todayParts.year,
month: todayParts.month,
day: todayParts.day,
hour: Number(hour),
minute: Number(minute),
second: 0,
},
tz,
);
if (runAt <= now) {
const tomorrowStart = addLocalDays(todayStart, 1, tz);
const tomorrowParts = getLocalParts(tomorrowStart, tz);
runAt = zonedTimeToEpochMs(
{
year: tomorrowParts.year,
month: tomorrowParts.month,
day: tomorrowParts.day,
hour: Number(hour),
minute: Number(minute),
second: 0,
},
tz,
);
}
return runAt;
}
export function formatLocalTime(epochMs, timezone = DEFAULT_TIMEZONE) {
const parts = getLocalParts(epochMs, timezone);
return `${pad2(parts.hour)}:${pad2(parts.minute)}`;
}
/**
* Parse a wall-clock datetime in the given timezone, e.g. "2026-07-01 06:00".
*/
export function parseLocalDateTimeString(value, timezone = DEFAULT_TIMEZONE) {
const raw = String(value ?? '').trim();
if (!raw) return null;
const match = raw.match(
/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/,
);
if (!match) {
throw new Error(`无法解析本地时间「${raw}」,请使用 YYYY-MM-DD HH:mm`);
}
return zonedTimeToEpochMs(
{
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
hour: Number(match[4] ?? 0),
minute: Number(match[5] ?? 0),
second: Number(match[6] ?? 0),
},
timezone,
);
}
export function resolveScheduleTimestamp({
epochMs = null,
localString = null,
timezone = DEFAULT_TIMEZONE,
fieldName = '时间',
now = Date.now(),
} = {}) {
const local = String(localString ?? '').trim();
if (local) return parseLocalDateTimeString(local, timezone);
if (epochMs == null || epochMs === '') return null;
return assertReasonableScheduleEpoch(epochMs, { now, fieldName });
}
export function assertReasonableScheduleEpoch(
epochMs,
{ now = Date.now(), fieldName = '时间', maxPastDays = 2, maxFutureDays = 400 } = {},
) {
const safe = Number(epochMs);
if (!Number.isFinite(safe) || safe <= 0) {
throw new Error(`${fieldName}无效,请改用 YYYY-MM-DD HH:mm 的 local 字段`);
}
const min = now - maxPastDays * 86400000;
const max = now + maxFutureDays * 86400000;
if (safe < min || safe > max) {
const label = new Intl.DateTimeFormat('zh-CN', {
timeZone: normalizeTimezone(process.env.H5_DEFAULT_TIMEZONE),
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(safe));
throw new Error(
`${fieldName}${label})超出合理范围。请改用 startLocal/remindLocalYYYY-MM-DD HH:mm,用户时区),禁止自行估算 Unix 毫秒。`,
);
}
return safe;
}