mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Mood-reminder hotfix bundle on top of v1.4.38.1 plus the missing Settings toggle. Six-axis review of the v0.5.4 iOS-coord patch surfaced enough blockers that the feature could not have been used safely as shipped: - Locale resolver demoted 4 of 6 locales (es/fr/it/pl) to English. - Dedup ledger committed before delivery — a transient APNs blip silently nuked the day's nudge and blocked retries. - DST-unsafe arithmetic drifted the local-22:00 baseline by an hour twice a year for both the mood-reminder push and the medication iOS-snooze action. - APNs payload forwarded the Telegram `replyMarkup` inline-keyboard object and other channel-specific extras to Apple's servers and the iOS notification handler. - No Settings UI for the per-user opt-in flag — the feature was unreachable for end users regardless of the other fixes. v1.4.38.2 closes every item above plus adds the missing `MoodReminderCard` Switch, a 90-day retention sweep for the dispatch ledger, and a per-user `try` wrapper so one bad row cannot abort the 22:00 tick. See CHANGELOG.md.
140 lines
4.0 KiB
TypeScript
140 lines
4.0 KiB
TypeScript
/**
|
|
* Robust timezone utilities using Intl.DateTimeFormat.
|
|
* Replaces the fragile `new Date(date.toLocaleString("en-US", { timeZone }))` pattern
|
|
* which can produce incorrect results due to locale-dependent date string parsing.
|
|
*/
|
|
|
|
interface LocalDateParts {
|
|
year: number;
|
|
month: number;
|
|
day: number;
|
|
hour: number;
|
|
minute: number;
|
|
second: number;
|
|
dayOfWeek: number;
|
|
}
|
|
|
|
/**
|
|
* Extract date/time parts for a given instant in a specific timezone.
|
|
* Uses Intl.DateTimeFormat.formatToParts for reliable, locale-independent parsing.
|
|
*/
|
|
export function getLocalDateParts(date: Date, tz: string): LocalDateParts {
|
|
const fmt = new Intl.DateTimeFormat("en-US", {
|
|
timeZone: tz,
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
hour12: false,
|
|
weekday: "short",
|
|
});
|
|
|
|
const parts = fmt.formatToParts(date);
|
|
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
|
parts.find((p) => p.type === type)?.value ?? "0";
|
|
|
|
// Intl hour12:false can return "24" for midnight in some engines
|
|
const rawHour = parseInt(get("hour"), 10);
|
|
|
|
const weekdayStr = get("weekday");
|
|
const weekdayMap: Record<string, number> = {
|
|
Sun: 0,
|
|
Mon: 1,
|
|
Tue: 2,
|
|
Wed: 3,
|
|
Thu: 4,
|
|
Fri: 5,
|
|
Sat: 6,
|
|
};
|
|
|
|
return {
|
|
year: parseInt(get("year"), 10),
|
|
month: parseInt(get("month"), 10),
|
|
day: parseInt(get("day"), 10),
|
|
hour: rawHour === 24 ? 0 : rawHour,
|
|
minute: parseInt(get("minute"), 10),
|
|
second: parseInt(get("second"), 10),
|
|
dayOfWeek: weekdayMap[weekdayStr] ?? 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get the start (midnight) and end (23:59:59.999) of "today" in the user's timezone,
|
|
* returned as UTC Date objects suitable for database queries.
|
|
*/
|
|
export function getUserTodayBounds(
|
|
now: Date,
|
|
tz: string,
|
|
): { start: Date; end: Date } {
|
|
const parts = getLocalDateParts(now, tz);
|
|
|
|
// Build a UTC date that represents the user's local midnight
|
|
// by computing the offset between the real UTC time and the local representation
|
|
const localMidnightAsUtc = new Date(
|
|
Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0),
|
|
);
|
|
|
|
// Compute offset: how far ahead (positive) or behind (negative) the tz is from UTC
|
|
// localMidnightAsUtc represents "midnight in tz as if it were UTC"
|
|
// We need to find what UTC instant corresponds to midnight in the given tz
|
|
const localNowAsUtc = new Date(
|
|
Date.UTC(
|
|
parts.year,
|
|
parts.month - 1,
|
|
parts.day,
|
|
parts.hour,
|
|
parts.minute,
|
|
parts.second,
|
|
),
|
|
);
|
|
const offsetMs =
|
|
Math.round((localNowAsUtc.getTime() - now.getTime()) / 60000) * 60000;
|
|
|
|
const start = new Date(localMidnightAsUtc.getTime() - offsetMs);
|
|
const end = new Date(start.getTime() + 24 * 60 * 60 * 1000 - 1);
|
|
|
|
return { start, end };
|
|
}
|
|
|
|
/**
|
|
* Get the day of the week (0=Sun..6=Sat) for a given instant in a specific timezone.
|
|
*/
|
|
export function getDayOfWeekInTz(now: Date, tz: string): number {
|
|
return getLocalDateParts(now, tz).dayOfWeek;
|
|
}
|
|
|
|
/**
|
|
* Compute the UTC instant of local `hh:mm` on the local calendar day
|
|
* implied by `now` in `tz`. DST-safe — `todayStart.getTime() + h * 3.6e6`
|
|
* drifts by an hour on spring-forward / fall-back days because raw UTC
|
|
* arithmetic ignores the DST jump between local midnight and local
|
|
* `hh:mm`. This helper re-derives the offset at the target local time
|
|
* so the returned instant always represents local `hh:mm`.
|
|
*/
|
|
export function localHmAsUtc(
|
|
now: Date,
|
|
tz: string,
|
|
hour: number,
|
|
minute: number,
|
|
): Date {
|
|
const parts = getLocalDateParts(now, tz);
|
|
const localAtTargetAsUtc = new Date(
|
|
Date.UTC(parts.year, parts.month - 1, parts.day, hour, minute, 0, 0),
|
|
);
|
|
const localNowAsUtc = new Date(
|
|
Date.UTC(
|
|
parts.year,
|
|
parts.month - 1,
|
|
parts.day,
|
|
parts.hour,
|
|
parts.minute,
|
|
parts.second,
|
|
),
|
|
);
|
|
const offsetMs =
|
|
Math.round((localNowAsUtc.getTime() - now.getTime()) / 60000) * 60000;
|
|
return new Date(localAtTargetAsUtc.getTime() - offsetMs);
|
|
}
|