Phase 18: Auto timezone detection and ability to change timezone #21

Merged
luckberg merged 38 commits from gsd/phase-18-auto-timezone-detection-and-ability-to-change-timezone into main 2026-06-15 09:55:53 -04:00
Showing only changes of commit 1fb431e8da - Show all commits
+34 -8
View File
@@ -373,7 +373,31 @@ interface DispatchResult {
error?: string;
}
async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
/**
* Lazily resolves the household timezone at most once, caching the promise.
* Threaded through a drain cycle (mirroring clientCache, IN-01) so an all-day
* create row and an all-day update row in the same cycle share a single
* app_config read instead of issuing two identical SELECTs (IN-03). The read is
* still lazy: cycles with no all-day work never touch the DB.
*/
type TimezoneResolver = () => Promise<string>;
function makeTimezoneResolver(): TimezoneResolver {
let cached: Promise<string> | undefined;
return () => {
if (cached === undefined) {
// D-05: route through the single stored-TZ accessor (no inline fallback duplicated here).
// D-06: getHouseholdTimezone falls back to process.env.TZ → Intl when no row is stored.
cached = getHouseholdTimezone(db);
}
return cached;
};
}
async function dispatchRow(
row: OutboxRow,
resolveTimezone: TimezoneResolver,
): Promise<DispatchResult> {
// CR-03: fail closed on credential errors — let loadClientForUser throw.
// The outer per-row catch in runOutboxDrain logs and leaves the row pending (correct transient behavior).
// Do NOT add an empty-credential fallback — that would silently PUT with no authentication.
@@ -499,9 +523,8 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
fields.allDay &&
fields.start
) {
// D-05: route through the single stored-TZ accessor (no inline fallback duplicated here).
// D-06: getHouseholdTimezone falls back to process.env.TZ → Intl when no row is stored.
const tz = await getHouseholdTimezone(db);
// IN-03: shared per-cycle resolver — one app_config read across all-day rows.
const tz = await resolveTimezone();
const leadDays = fields.reminderLeadMinutes / 1440;
allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz);
}
@@ -607,9 +630,8 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
// carries an explicit picker value or no reminder at all; no preserve path needed).
let allDayAlertInstantUtcCreate: Date | undefined;
if (fields.reminderLeadMinutes != null && fields.allDay && fields.start) {
// D-05: route through the single stored-TZ accessor (no inline fallback duplicated here).
// D-06: getHouseholdTimezone falls back to process.env.TZ → Intl when no row is stored.
const tz = await getHouseholdTimezone(db);
// IN-03: shared per-cycle resolver — one app_config read across all-day rows.
const tz = await resolveTimezone();
const leadDays = fields.reminderLeadMinutes / 1440;
allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz);
}
@@ -747,6 +769,10 @@ export async function runOutboxDrain(): Promise<void> {
// credential at most once per cycle. Discarded when the drain returns — never persisted.
const clientCache = new Map<number, FastmailClient>();
// IN-03: per-drain-cycle timezone resolver so multiple all-day rows in the same cycle
// share one app_config read. Lazy: cycles with no all-day work never hit the DB.
const resolveTimezone = makeTimezoneResolver();
for (const row of sorted) {
// D-04 fast path: if the create for this group already failed in this batch, skip the delete
if (row.operation === 'delete' && row.groupId && failedCreateGroups.has(row.groupId)) {
@@ -793,7 +819,7 @@ export async function runOutboxDrain(): Promise<void> {
}
try {
const result = await dispatchRow(row);
const result = await dispatchRow(row, resolveTimezone);
if (result.conflict) {
// WR-06: distinguish an edit-as-move create-412 from a same-calendar conflict.