From 4044de7bfcdc9e6a1434a7cc175bced86d144e2d Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 7 May 2026 22:30:01 -0400 Subject: [PATCH] Add timezone preference and fix all date handling to be timezone-aware 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 --- web/src/App.tsx | 8 ++++- web/src/components/ProductDetail.tsx | 29 ++++++++--------- .../components/modals/AddInventoryFlow.tsx | 4 +-- web/src/components/modals/AuditFlow.tsx | 7 +++-- .../components/modals/BulkCheckinModal.tsx | 4 +-- .../components/modals/BulkCheckoutModal.tsx | 4 +-- .../components/modals/BulkConsumeModal.tsx | 4 +-- web/src/components/modals/BulkGoneModal.tsx | 4 +-- web/src/components/modals/CheckinFlow.tsx | 11 ++++--- web/src/components/modals/CheckoutFlow.tsx | 7 +++-- web/src/components/modals/ConsumeFlow.tsx | 7 +++-- web/src/components/modals/MarkGoneFlow.tsx | 5 +-- web/src/format.ts | 29 +++++++++++------ web/src/stats.ts | 16 +++++++--- web/src/types.ts | 13 +++----- web/src/tz.ts | 21 +++++++++++++ web/src/views/BinsView.tsx | 5 +-- web/src/views/ChartsView.tsx | 5 +-- web/src/views/CustodyView.tsx | 7 +++-- web/src/views/Dashboard.tsx | 12 ++++--- web/src/views/Inventory.tsx | 17 +++++----- web/src/views/SettingsView.tsx | 31 ++++++++++++++++++- 22 files changed, 165 insertions(+), 85 deletions(-) create mode 100644 web/src/tz.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index a60d992..743515e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,7 @@ import { Routes, Route } from "react-router-dom"; import { api } from "./api.js"; import type { Bin, Bootstrap, Brand, Item, Shop } from "./types.js"; import { enrichItems } from "./types.js"; +import { getStoredTimezone, TZ_STORAGE_KEY } from "./tz.js"; import { computeStats } from "./stats.js"; import { Sidebar } from "./components/Sidebar.js"; import { Dashboard } from "./views/Dashboard.js"; @@ -70,12 +71,17 @@ export function App() { const [theme, setTheme] = useState( () => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light", ); + const [timezone, setTimezone] = useState(getStoredTimezone); useEffect(() => { document.documentElement.dataset.theme = theme; localStorage.setItem("apothecary.theme", theme); }, [theme]); + useEffect(() => { + localStorage.setItem(TZ_STORAGE_KEY, timezone); + }, [timezone]); + const { data, isLoading, error } = useQuery({ queryKey: ["bootstrap"], queryFn: api.bootstrap, @@ -225,7 +231,7 @@ export function App() { } /> } /> + } /> diff --git a/web/src/components/ProductDetail.tsx b/web/src/components/ProductDetail.tsx index f83cbaa..20c3659 100644 --- a/web/src/components/ProductDetail.tsx +++ b/web/src/components/ProductDetail.tsx @@ -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 : ], ["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} {item.status === "consumed" && ( - Consumed · {fmt.daysAgo(item.consumedDate)} + Consumed · {fmt.daysAgo(item.consumedDate, getStoredTimezone())} )} {item.status === "gone" && ( - Gone · {fmt.daysAgo(item.goneDate)} + Gone · {fmt.daysAgo(item.goneDate, getStoredTimezone())} )} {isCheckedOut && ( - Checked out · {fmt.daysAgo(item.checkoutDate)} + Checked out · {fmt.daysAgo(item.checkoutDate, getStoredTimezone())} )} {isActive && overdue && Audit overdue · {sinceCheck}d} @@ -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. )} @@ -369,7 +370,7 @@ export function ProductDetail({ {a.mode === "presence" && (a.confirmedBy === "lost" ? "Marked lost" : "Confirmed presence")}
- {fmt.date(a.date)} · {fmt.daysAgo(a.date)} + {fmt.date(a.date, getStoredTimezone())} · {fmt.daysAgo(a.date, getStoredTimezone())}
diff --git a/web/src/components/modals/AddInventoryFlow.tsx b/web/src/components/modals/AddInventoryFlow.tsx index e8d1cb7..ec480dc 100644 --- a/web/src/components/modals/AddInventoryFlow.tsx +++ b/web/src/components/modals/AddInventoryFlow.tsx @@ -4,10 +4,10 @@ import type { Bootstrap, InventoryItem, Item, Product, Strain } from "../../type import { ASSET_ID_RE, TYPES, - TODAY_STR, enrichItems, getLastInstance, } from "../../types.js"; +import { getToday, getStoredTimezone } from "../../tz.js"; import { fmt } from "../../format.js"; import { api } from "../../api.js"; import { Btn, Field, Input, Select } from "../primitives/index.js"; @@ -376,7 +376,7 @@ function InstanceDetailsStep({ thc: last?.thc ?? (cfg?.showCannabinoidPct !== false ? 22 : 0), cbd: last?.cbd ?? (cfg?.showCannabinoidPct !== false ? 0.4 : 0), totalCannabinoids: last?.totalCannabinoids ?? (cfg?.showCannabinoidPct !== false ? 26 : 0), - purchaseDate: TODAY_STR, + purchaseDate: getToday(getStoredTimezone()), }); const [newShopName, setNewShopName] = useState(""); const [newShopLocation, setNewShopLocation] = useState(""); diff --git a/web/src/components/modals/AuditFlow.tsx b/web/src/components/modals/AuditFlow.tsx index 56de161..e13b6f7 100644 --- a/web/src/components/modals/AuditFlow.tsx +++ b/web/src/components/modals/AuditFlow.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Bootstrap, Item } from "../../types.js"; -import { TYPES, helpers, TODAY_STR, enrichItems } from "../../types.js"; +import { TYPES, helpers, enrichItems } from "../../types.js"; +import { getToday, getStoredTimezone } from "../../tz.js"; import { api } from "../../api.js"; import { Btn, Field, Input, Select } from "../primitives/index.js"; import { ScanField, type ScanResult } from "../ScanField.js"; @@ -40,7 +41,7 @@ export function AuditFlow({ .sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a)); const [itemId, setItemId] = useState(initialItem?.id ?? ""); - const [date, setDate] = useState(TODAY_STR); + const [date, setDate] = useState(getToday(getStoredTimezone())); const [confirmedBy, setConfirmedBy] = useState<"asset" | "visual">("asset"); const item = allItems.find((i) => i.id === itemId); @@ -51,7 +52,7 @@ export function AuditFlow({ if (i.kind === "discrete") { return String(i.countLastAudit ?? i.countOriginal); } - return helpers.estimatedRemaining(i, TODAY_STR).toFixed(2); + return helpers.estimatedRemaining(i, getToday(getStoredTimezone())).toFixed(2); }; const [value, setValue] = useState(initialValueFor(item)); const [error, setError] = useState(null); diff --git a/web/src/components/modals/BulkCheckinModal.tsx b/web/src/components/modals/BulkCheckinModal.tsx index 867e33f..569151a 100644 --- a/web/src/components/modals/BulkCheckinModal.tsx +++ b/web/src/components/modals/BulkCheckinModal.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Bootstrap, Item } from "../../types.js"; -import { TODAY_STR } from "../../types.js"; +import { getToday, getStoredTimezone } from "../../tz.js"; import { api } from "../../api.js"; import type { BatchOp } from "../../api.js"; import { Btn, Field, Input, Select } from "../primitives/index.js"; @@ -22,7 +22,7 @@ export function BulkCheckinModal({ const eligible = items.filter((i) => i.status === "checked-out"); const excluded = items.length - eligible.length; - const [date, setDate] = useState(TODAY_STR); + const [date, setDate] = useState(getToday(getStoredTimezone())); const [binId, setBinId] = useState(data.bins[0]?.id ?? ""); const [error, setError] = useState(null); diff --git a/web/src/components/modals/BulkCheckoutModal.tsx b/web/src/components/modals/BulkCheckoutModal.tsx index d2b3fa8..d1c2c56 100644 --- a/web/src/components/modals/BulkCheckoutModal.tsx +++ b/web/src/components/modals/BulkCheckoutModal.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Bootstrap, Item } from "../../types.js"; -import { TODAY_STR } from "../../types.js"; +import { getToday, getStoredTimezone } from "../../tz.js"; import { api } from "../../api.js"; import type { BatchOp } from "../../api.js"; import { Btn, Field, Input } from "../primitives/index.js"; @@ -22,7 +22,7 @@ export function BulkCheckoutModal({ const eligible = items.filter((i) => i.status === "active"); const excluded = items.length - eligible.length; - const [date, setDate] = useState(TODAY_STR); + const [date, setDate] = useState(getToday(getStoredTimezone())); const [error, setError] = useState(null); const checkout = useMutation({ diff --git a/web/src/components/modals/BulkConsumeModal.tsx b/web/src/components/modals/BulkConsumeModal.tsx index 25036a1..990d221 100644 --- a/web/src/components/modals/BulkConsumeModal.tsx +++ b/web/src/components/modals/BulkConsumeModal.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Bootstrap, Item } from "../../types.js"; -import { TODAY_STR } from "../../types.js"; +import { getToday, getStoredTimezone } from "../../tz.js"; import { api } from "../../api.js"; import type { BatchOp } from "../../api.js"; import { Btn, Field, Icon, Input, Textarea } from "../primitives/index.js"; @@ -24,7 +24,7 @@ export function BulkConsumeModal({ const [rating, setRating] = useState(4); const [notes, setNotes] = useState(""); - const [date, setDate] = useState(TODAY_STR); + const [date, setDate] = useState(getToday(getStoredTimezone())); const [error, setError] = useState(null); const finish = useMutation({ diff --git a/web/src/components/modals/BulkGoneModal.tsx b/web/src/components/modals/BulkGoneModal.tsx index 8e5168b..5310bca 100644 --- a/web/src/components/modals/BulkGoneModal.tsx +++ b/web/src/components/modals/BulkGoneModal.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Bootstrap, Item } from "../../types.js"; -import { TODAY_STR } from "../../types.js"; +import { getToday, getStoredTimezone } from "../../tz.js"; import { api } from "../../api.js"; import type { BatchOp } from "../../api.js"; import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js"; @@ -32,7 +32,7 @@ export function BulkGoneModal({ const [reason, setReason] = useState("lost"); const [notes, setNotes] = useState(""); - const [date, setDate] = useState(TODAY_STR); + const [date, setDate] = useState(getToday(getStoredTimezone())); const [error, setError] = useState(null); const mark = useMutation({ diff --git a/web/src/components/modals/CheckinFlow.tsx b/web/src/components/modals/CheckinFlow.tsx index 85d958a..163a849 100644 --- a/web/src/components/modals/CheckinFlow.tsx +++ b/web/src/components/modals/CheckinFlow.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Bootstrap, Item } from "../../types.js"; -import { TYPES, helpers, TODAY_STR, enrichItems } from "../../types.js"; +import { TYPES, helpers, enrichItems } from "../../types.js"; +import { getToday, getStoredTimezone } from "../../tz.js"; import { fmt } from "../../format.js"; import { api } from "../../api.js"; import { Btn, Field, Input, Select } from "../primitives/index.js"; @@ -24,14 +25,14 @@ export function CheckinFlow({ const checkedOut = allItems.filter((i) => i.status === "checked-out"); const [itemId, setItemId] = useState(initialItem?.id ?? ""); const [binId, setBinId] = useState(data.bins[0]?.id ?? ""); - const [date, setDate] = useState(TODAY_STR); + const [date, setDate] = useState(getToday(getStoredTimezone())); const [remaining, setRemaining] = useState(""); const [error, setError] = useState(null); const item = allItems.find((i) => i.id === itemId); const isBulk = item?.kind === "bulk"; const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined; - const est = item ? helpers.estimatedRemaining(item, TODAY_STR) : 0; + const est = item ? helpers.estimatedRemaining(item, getToday(getStoredTimezone())) : 0; const checkin = useMutation({ mutationFn: () => { @@ -58,7 +59,7 @@ export function CheckinFlow({ setItemId(result.item.id); const scanned = allItems.find((i) => i.id === result.item.id); if (scanned?.kind === "bulk") { - setRemaining(helpers.estimatedRemaining(scanned, TODAY_STR).toFixed(2)); + setRemaining(helpers.estimatedRemaining(scanned, getToday(getStoredTimezone())).toFixed(2)); } } }; @@ -116,7 +117,7 @@ export function CheckinFlow({
{item.assetId} ·{" "} {helpers.brandName(data, item.brandId)} · checked out{" "} - {fmt.dateShort(item.checkoutDate)} + {fmt.dateShort(item.checkoutDate, getStoredTimezone())}
diff --git a/web/src/components/modals/CheckoutFlow.tsx b/web/src/components/modals/CheckoutFlow.tsx index 560ca3a..db1476a 100644 --- a/web/src/components/modals/CheckoutFlow.tsx +++ b/web/src/components/modals/CheckoutFlow.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Bootstrap, Item } from "../../types.js"; -import { helpers, TODAY_STR, enrichItems } from "../../types.js"; +import { helpers, enrichItems } from "../../types.js"; +import { getToday, getStoredTimezone } from "../../tz.js"; import { fmt } from "../../format.js"; import { api } from "../../api.js"; import { Btn, Field, Icon, Input } from "../primitives/index.js"; @@ -23,7 +24,7 @@ export function CheckoutFlow({ const allItems = enrichItems(data); const active = allItems.filter((i) => i.status === "active"); const [itemId, setItemId] = useState(initialItem?.id ?? ""); - const [date, setDate] = useState(TODAY_STR); + const [date, setDate] = useState(getToday(getStoredTimezone())); const [error, setError] = useState(null); const item = allItems.find((i) => i.id === itemId); @@ -103,7 +104,7 @@ export function CheckoutFlow({
{item.assetId} ·{" "} {helpers.brandName(data, item.brandId)} · {bin?.name ?? "no bin"} · - purchased {fmt.dateShort(item.purchaseDate)} + purchased {fmt.dateShort(item.purchaseDate, getStoredTimezone())}
diff --git a/web/src/components/modals/ConsumeFlow.tsx b/web/src/components/modals/ConsumeFlow.tsx index b446aa2..1c309e3 100644 --- a/web/src/components/modals/ConsumeFlow.tsx +++ b/web/src/components/modals/ConsumeFlow.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Bootstrap, Item } from "../../types.js"; -import { helpers, TODAY_STR, enrichItems } from "../../types.js"; +import { helpers, enrichItems } from "../../types.js"; +import { getToday, getStoredTimezone } from "../../tz.js"; import { fmt } from "../../format.js"; import { api } from "../../api.js"; import { Btn, Field, Icon, Input, Textarea } from "../primitives/index.js"; @@ -25,7 +26,7 @@ export function ConsumeFlow({ const [itemId, setItemId] = useState(initialItem?.id ?? ""); const [rating, setRating] = useState(4); const [notes, setNotes] = useState(""); - const [date, setDate] = useState(TODAY_STR); + const [date, setDate] = useState(getToday(getStoredTimezone())); const [error, setError] = useState(null); const item = allItems.find((i) => i.id === itemId); @@ -96,7 +97,7 @@ export function ConsumeFlow({
{item.assetId} · {helpers.brandName(data, item.brandId)} · {bin?.name} · purchased{" "} - {fmt.dateShort(item.purchaseDate)} + {fmt.dateShort(item.purchaseDate, getStoredTimezone())}
diff --git a/web/src/components/modals/MarkGoneFlow.tsx b/web/src/components/modals/MarkGoneFlow.tsx index 1beb855..856ac3c 100644 --- a/web/src/components/modals/MarkGoneFlow.tsx +++ b/web/src/components/modals/MarkGoneFlow.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Bootstrap, Item } from "../../types.js"; -import { helpers, TODAY_STR, enrichItems } from "../../types.js"; +import { helpers, enrichItems } from "../../types.js"; +import { getToday, getStoredTimezone } from "../../tz.js"; import { remainingShort } from "../../stats.js"; import { api } from "../../api.js"; import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js"; @@ -32,7 +33,7 @@ export function MarkGoneFlow({ const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? ""); const [reason, setReason] = useState("lost"); const [notes, setNotes] = useState(""); - const [date, setDate] = useState(TODAY_STR); + const [date, setDate] = useState(getToday(getStoredTimezone())); const [error, setError] = useState(null); const item = allItems.find((i) => i.id === itemId); diff --git a/web/src/format.ts b/web/src/format.ts index 314151e..5d133af 100644 --- a/web/src/format.ts +++ b/web/src/format.ts @@ -1,4 +1,9 @@ -// fmt.* — verbatim port from primitives.jsx +import { getToday } from "./tz.js"; + +function parseDate(s: string): Date { + return new Date(s.length === 10 ? s + "T12:00:00" : s); +} + export const fmt = { g(n: number | null | undefined): string { if (n == null) return "—"; @@ -17,22 +22,26 @@ export const fmt = { if (n == null) return "—"; return `${(+n).toFixed(1)}%`; }, - date(s: string | null | undefined): string { + date(s: string | null | undefined, tz?: string): string { if (!s) return "—"; - const d = new Date(s); - return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); + const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" }; + if (tz) opts.timeZone = tz; + return parseDate(s).toLocaleDateString("en-US", opts); }, - dateShort(s: string | null | undefined): string { + dateShort(s: string | null | undefined, tz?: string): string { if (!s) return "—"; - const d = new Date(s); - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" }; + if (tz) opts.timeZone = tz; + return parseDate(s).toLocaleDateString("en-US", opts); }, - daysAgo(s: string | null | undefined): string { + daysAgo(s: string | null | undefined, tz?: string): string { if (!s) return "—"; - const ms = Date.now() - new Date(s).getTime(); - const d = Math.floor(ms / 86_400_000); + const todayMs = +parseDate(tz ? getToday(tz) : new Date().toISOString().slice(0, 10)); + const thenMs = +parseDate(s); + const d = Math.floor((todayMs - thenMs) / 86_400_000); if (d === 0) return "today"; if (d === 1) return "yesterday"; + if (d < 0) return "in the future"; if (d < 30) return `${d}d ago`; if (d < 365) return `${Math.floor(d / 30)}mo ago`; return `${Math.floor(d / 365)}y ago`; diff --git a/web/src/stats.ts b/web/src/stats.ts index c49a903..45d7b43 100644 --- a/web/src/stats.ts +++ b/web/src/stats.ts @@ -4,7 +4,8 @@ // stay clean). Operates on the enriched Item[] view, not raw products. import type { Bootstrap, Item } from "./types.js"; -import { TYPES, TODAY_STR, helpers, enrichItems } from "./types.js"; +import { TYPES, helpers, enrichItems } from "./types.js"; +import { getToday, getStoredTimezone } from "./tz.js"; export interface Stats { dailyAvg: number; @@ -49,10 +50,17 @@ export interface Stats { } export function computeStats(data: Bootstrap): Stats { - const today = new Date(data.today || TODAY_STR); - const todayStr = today.toISOString().slice(0, 10); + const tz = getStoredTimezone(); + const todayStr = getToday(tz); + const today = new Date(todayStr + "T12:00:00"); const items = enrichItems(data); - const dayKey = (d: Date) => d.toISOString().slice(0, 10); + const dayKeyFmt = new Intl.DateTimeFormat("en-CA", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const dayKey = (d: Date) => dayKeyFmt.format(d); const active = items.filter((p) => p.status === "active" || p.status === "checked-out"); const consumed = items.filter((p) => p.status === "consumed" && p.consumedDate); diff --git a/web/src/types.ts b/web/src/types.ts index f1e742e..15cf5a5 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -125,15 +125,10 @@ export const TYPES: TypeConfig[] = [ // User-supplied 6-digit asset ids are printed on a roll of physical tags. export const ASSET_ID_RE = /^\d{6}$/; -// Local-time YYYY-MM-DD captured once at module load. Used as the default -// value for date inputs and as the "today" anchor for days-since math. -export const TODAY_STR = (() => { - const d = new Date(); - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; -})(); +import { getToday, getBrowserTimezone } from "./tz.js"; +export { getToday } from "./tz.js"; + +export const TODAY_STR = getToday(getBrowserTimezone()); // Build the joined Item[] view from bootstrap. Inventory items are dropped // silently if they reference a missing product — that shouldn't happen in diff --git a/web/src/tz.ts b/web/src/tz.ts new file mode 100644 index 0000000..86af8f2 --- /dev/null +++ b/web/src/tz.ts @@ -0,0 +1,21 @@ +export const TZ_STORAGE_KEY = "apothecary.timezone"; + +const enCA = (tz: string) => + new Intl.DateTimeFormat("en-CA", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + +export function getToday(tz: string): string { + return enCA(tz).format(new Date()); +} + +export function getBrowserTimezone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} + +export function getStoredTimezone(): string { + return localStorage.getItem(TZ_STORAGE_KEY) || getBrowserTimezone(); +} diff --git a/web/src/views/BinsView.tsx b/web/src/views/BinsView.tsx index 160bfca..ccffec1 100644 --- a/web/src/views/BinsView.tsx +++ b/web/src/views/BinsView.tsx @@ -1,7 +1,8 @@ import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Bootstrap, Bin, Item } from "../types.js"; -import { helpers, TODAY_STR, enrichItems } from "../types.js"; +import { helpers, enrichItems } from "../types.js"; +import { getToday, getStoredTimezone } from "../tz.js"; import { remainingShort } from "../stats.js"; import { fmt, TYPE_GLYPHS } from "../format.js"; import { api } from "../api.js"; @@ -120,7 +121,7 @@ export function BinsView({ ); const fillPct = slotsUsed / bin.capacity; const totalValue = binItems.reduce( - (s, i) => s + i.price * helpers.pctRemaining(i, TODAY_STR), + (s, i) => s + i.price * helpers.pctRemaining(i, getToday(getStoredTimezone())), 0, ); return ( diff --git a/web/src/views/ChartsView.tsx b/web/src/views/ChartsView.tsx index 0b5bd10..364e312 100644 --- a/web/src/views/ChartsView.tsx +++ b/web/src/views/ChartsView.tsx @@ -3,6 +3,7 @@ import { helpers } from "../types.js"; import type { Stats } from "../stats.js"; import { fmt } from "../format.js"; import { BarChart, Card } from "../components/primitives/index.js"; +import { getStoredTimezone } from "../tz.js"; export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) { const series = stats.series90.map((s) => ({ date: s.date, grams: s.grams })); @@ -88,7 +89,7 @@ export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) { return (
- {d.toLocaleDateString("en-US", { month: "short", year: "2-digit" })} + {d.toLocaleDateString("en-US", { month: "short", year: "2-digit", timeZone: getStoredTimezone() })}
{firstDay && new Date(firstDay.date).getDate() <= 7 - ? new Date(firstDay.date).toLocaleDateString("en-US", { month: "short" }) + ? new Date(firstDay.date).toLocaleDateString("en-US", { month: "short", timeZone: getStoredTimezone() }) : ""}
); diff --git a/web/src/views/CustodyView.tsx b/web/src/views/CustodyView.tsx index 7d7aad0..08c989d 100644 --- a/web/src/views/CustodyView.tsx +++ b/web/src/views/CustodyView.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import type { Bootstrap, Item } from "../types.js"; -import { helpers, TODAY_STR, enrichItems } from "../types.js"; +import { helpers, enrichItems } from "../types.js"; +import { getToday, getStoredTimezone } from "../tz.js"; import { remainingShort } from "../stats.js"; import { fmt, TYPE_GLYPHS } from "../format.js"; import { Btn, Card, Icon } from "../components/primitives/index.js"; @@ -127,7 +128,7 @@ function CustodyRow({ onMarkGone: () => void; }) { const glyph = TYPE_GLYPHS[item.type] ?? "·"; - const pct = helpers.pctRemaining(item, TODAY_STR); + const pct = helpers.pctRemaining(item, getToday(getStoredTimezone())); return (
- {fmt.daysAgo(item.checkoutDate)} + {fmt.daysAgo(item.checkoutDate, getStoredTimezone())}
)} {lowBulk.slice(0, 3).map((i) => { - const pct = helpers.pctRemaining(i, TODAY_STR); + const pct = helpers.pctRemaining(i, getToday(getStoredTimezone())); return (
i.status === "active"); const totalRemaining = active.reduce((s, i) => { - if (i.kind === "bulk") return s + helpers.estimatedRemaining(i, TODAY_STR); + if (i.kind === "bulk") return s + helpers.estimatedRemaining(i, getToday(getStoredTimezone())); return s + (i.countLastAudit ?? i.countOriginal); }, 0); const lastBuy = group.items.reduce((max, i) => { @@ -527,7 +528,7 @@ function GroupHeader({
last buy{" "} - {fmt.dateShort(new Date(lastBuy).toISOString())} + {fmt.dateShort(new Date(lastBuy).toISOString(), getStoredTimezone())}
)} @@ -552,9 +553,9 @@ function ItemRow({ onToggle: (id: string, shiftKey: boolean) => void; }) { const bin = data.bins.find((b) => b.id === i.binId); - const pctRemaining = helpers.pctRemaining(i, TODAY_STR); - const overdue = helpers.auditOverdue(i, TODAY_STR); - const sinceCheck = helpers.daysSinceCheck(i, TODAY_STR); + const pctRemaining = helpers.pctRemaining(i, getToday(getStoredTimezone())); + const overdue = helpers.auditOverdue(i, getToday(getStoredTimezone())); + const sinceCheck = helpers.daysSinceCheck(i, getToday(getStoredTimezone())); const last = helpers.lastAudit(i); const isInactive = i.status !== "active" && i.status !== "checked-out"; return ( diff --git a/web/src/views/SettingsView.tsx b/web/src/views/SettingsView.tsx index d480db4..751e171 100644 --- a/web/src/views/SettingsView.tsx +++ b/web/src/views/SettingsView.tsx @@ -1,5 +1,19 @@ import type { Bootstrap } from "../types.js"; -import { Btn, Card, Stat } from "../components/primitives/index.js"; +import { Btn, Card, Select, Stat } from "../components/primitives/index.js"; +import { getBrowserTimezone } from "../tz.js"; + +function getTimezoneOptions(): string[] { + try { + return (Intl as any).supportedValuesOf("timeZone") as string[]; + } catch { + return [ + "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", + "America/Anchorage", "Pacific/Honolulu", "America/Toronto", "America/Vancouver", + "Europe/London", "Europe/Paris", "Europe/Berlin", "Asia/Tokyo", + "Australia/Sydney", "Pacific/Auckland", "UTC", + ]; + } +} function download(filename: string, content: string, mime: string) { const blob = new Blob([content], { type: mime }); @@ -75,10 +89,14 @@ export function SettingsView({ data, theme, onThemeChange, + timezone, + onTimezoneChange, }: { data: Bootstrap; theme: ThemeKey; onThemeChange: (t: ThemeKey) => void; + timezone: string; + onTimezoneChange: (tz: string) => void; }) { return (
+ + +