Add timezone preference and fix all date handling to be timezone-aware
Build and push image / build (push) Successful in 56s

Dates were computed using browser/server local time with no explicit timezone,
causing inconsistencies when server runs in UTC. Now all "today" computations
and date formatting use the user's chosen IANA timezone, persisted in
localStorage and selectable from Settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 22:30:01 -04:00
parent 946e96c3ea
commit 4044de7bfc
22 changed files with 165 additions and 85 deletions
+15 -14
View File
@@ -1,6 +1,7 @@
import { useEffect } from "react";
import type { Bootstrap, Item, Product } from "../types.js";
import { TYPES, helpers, TODAY_STR } from "../types.js";
import { TYPES, helpers } from "../types.js";
import { getToday, getStoredTimezone } from "../tz.js";
import { fmt, TYPE_GLYPHS } from "../format.js";
import { Btn, Pill, Icon } from "./primitives/index.js";
@@ -31,11 +32,11 @@ export function ProductDetail({
const bin = data.bins.find((b) => b.id === item.binId);
const cfg = TYPES.find((t) => t.id === item.type);
const product = data.products.find((p) => p.id === item.productId);
const pctRemaining = helpers.pctRemaining(item, TODAY_STR);
const est = helpers.estimatedRemaining(item, TODAY_STR);
const pctRemaining = helpers.pctRemaining(item, getToday(getStoredTimezone()));
const est = helpers.estimatedRemaining(item, getToday(getStoredTimezone()));
const last = helpers.lastAudit(item);
const overdue = helpers.auditOverdue(item, TODAY_STR);
const sinceCheck = helpers.daysSinceCheck(item, TODAY_STR);
const overdue = helpers.auditOverdue(item, getToday(getStoredTimezone()));
const sinceCheck = helpers.daysSinceCheck(item, getToday(getStoredTimezone()));
const isActive = item.status === "active";
const isCheckedOut = item.status === "checked-out";
@@ -64,7 +65,7 @@ export function ProductDetail({
...(cfg?.showCannabinoidPct !== false
? [["Total cannabinoids", `${item.totalCannabinoids.toFixed(1)}%`] as [string, React.ReactNode]]
: []),
["Purchase date", fmt.date(item.purchaseDate)],
["Purchase date", fmt.date(item.purchaseDate, getStoredTimezone())],
["Bin", isCheckedOut ? "In your custody" : bin ? bin.name : <span style={{ color: "var(--ink-3)" }}></span>],
["Audit cadence", `Every ${cfg?.cadenceDays ?? "—"} days · ${cfg?.auditMode ?? "—"}`],
[
@@ -77,11 +78,11 @@ export function ProductDetail({
],
];
if (item.status === "checked-out") {
detailRows.push(["Checked out", fmt.date(item.checkoutDate)]);
detailRows.push(["Checked out", fmt.date(item.checkoutDate, getStoredTimezone())]);
}
if (item.status === "consumed") {
detailRows.push(
["Date finished", fmt.date(item.consumedDate)],
["Date finished", fmt.date(item.consumedDate, getStoredTimezone())],
[
"Lasted",
`${Math.round((+new Date(item.consumedDate!) - +new Date(item.purchaseDate)) / 86_400_000)} days`,
@@ -90,7 +91,7 @@ export function ProductDetail({
}
if (item.status === "gone") {
detailRows.push(
["Date gone", fmt.date(item.goneDate)],
["Date gone", fmt.date(item.goneDate, getStoredTimezone())],
[
"After",
`${Math.round((+new Date(item.goneDate!) - +new Date(item.purchaseDate)) / 86_400_000)} days`,
@@ -174,13 +175,13 @@ export function ProductDetail({
{TYPE_GLYPHS[item.type]} {item.type}
</div>
{item.status === "consumed" && (
<Pill tone="terra">Consumed · {fmt.daysAgo(item.consumedDate)}</Pill>
<Pill tone="terra">Consumed · {fmt.daysAgo(item.consumedDate, getStoredTimezone())}</Pill>
)}
{item.status === "gone" && (
<Pill tone="amber">Gone · {fmt.daysAgo(item.goneDate)}</Pill>
<Pill tone="amber">Gone · {fmt.daysAgo(item.goneDate, getStoredTimezone())}</Pill>
)}
{isCheckedOut && (
<Pill tone="outline">Checked out · {fmt.daysAgo(item.checkoutDate)}</Pill>
<Pill tone="outline">Checked out · {fmt.daysAgo(item.checkoutDate, getStoredTimezone())}</Pill>
)}
{isActive && overdue && <Pill tone="amber">Audit overdue · {sinceCheck}d</Pill>}
</div>
@@ -289,7 +290,7 @@ export function ProductDetail({
fontStyle: "italic",
}}
>
Estimated by linear decay since last {last.mode} on {fmt.dateShort(last.date)} ({last.value}
Estimated by linear decay since last {last.mode} on {fmt.dateShort(last.date, getStoredTimezone())} ({last.value}
{cfg?.unit}). Re-audit to update.
</div>
)}
@@ -369,7 +370,7 @@ export function ProductDetail({
{a.mode === "presence" && (a.confirmedBy === "lost" ? "Marked lost" : "Confirmed presence")}
</div>
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
{fmt.date(a.date)} · {fmt.daysAgo(a.date)}
{fmt.date(a.date, getStoredTimezone())} · {fmt.daysAgo(a.date, getStoredTimezone())}
</div>
</div>
<div className="mono" style={{ fontSize: 13, textAlign: "right" }}>