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 (
+ + +