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
+7 -1
View File
@@ -4,6 +4,7 @@ import { Routes, Route } from "react-router-dom";
import { api } from "./api.js"; import { api } from "./api.js";
import type { Bin, Bootstrap, Brand, Item, Shop } from "./types.js"; import type { Bin, Bootstrap, Brand, Item, Shop } from "./types.js";
import { enrichItems } from "./types.js"; import { enrichItems } from "./types.js";
import { getStoredTimezone, TZ_STORAGE_KEY } from "./tz.js";
import { computeStats } from "./stats.js"; import { computeStats } from "./stats.js";
import { Sidebar } from "./components/Sidebar.js"; import { Sidebar } from "./components/Sidebar.js";
import { Dashboard } from "./views/Dashboard.js"; import { Dashboard } from "./views/Dashboard.js";
@@ -70,12 +71,17 @@ export function App() {
const [theme, setTheme] = useState<ThemeKey>( const [theme, setTheme] = useState<ThemeKey>(
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light", () => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
); );
const [timezone, setTimezone] = useState<string>(getStoredTimezone);
useEffect(() => { useEffect(() => {
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = theme;
localStorage.setItem("apothecary.theme", theme); localStorage.setItem("apothecary.theme", theme);
}, [theme]); }, [theme]);
useEffect(() => {
localStorage.setItem(TZ_STORAGE_KEY, timezone);
}, [timezone]);
const { data, isLoading, error } = useQuery<Bootstrap>({ const { data, isLoading, error } = useQuery<Bootstrap>({
queryKey: ["bootstrap"], queryKey: ["bootstrap"],
queryFn: api.bootstrap, queryFn: api.bootstrap,
@@ -225,7 +231,7 @@ export function App() {
} /> } />
<Route path="/charts" element={<ChartsView data={data} stats={stats} />} /> <Route path="/charts" element={<ChartsView data={data} stats={stats} />} />
<Route path="/settings" element={ <Route path="/settings" element={
<SettingsView data={data} theme={theme} onThemeChange={setTheme} /> <SettingsView data={data} theme={theme} onThemeChange={setTheme} timezone={timezone} onTimezoneChange={setTimezone} />
} /> } />
</Routes> </Routes>
</main> </main>
+15 -14
View File
@@ -1,6 +1,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import type { Bootstrap, Item, Product } from "../types.js"; 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 { fmt, TYPE_GLYPHS } from "../format.js";
import { Btn, Pill, Icon } from "./primitives/index.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 bin = data.bins.find((b) => b.id === item.binId);
const cfg = TYPES.find((t) => t.id === item.type); const cfg = TYPES.find((t) => t.id === item.type);
const product = data.products.find((p) => p.id === item.productId); const product = data.products.find((p) => p.id === item.productId);
const pctRemaining = helpers.pctRemaining(item, TODAY_STR); const pctRemaining = helpers.pctRemaining(item, getToday(getStoredTimezone()));
const est = helpers.estimatedRemaining(item, TODAY_STR); const est = helpers.estimatedRemaining(item, getToday(getStoredTimezone()));
const last = helpers.lastAudit(item); const last = helpers.lastAudit(item);
const overdue = helpers.auditOverdue(item, TODAY_STR); const overdue = helpers.auditOverdue(item, getToday(getStoredTimezone()));
const sinceCheck = helpers.daysSinceCheck(item, TODAY_STR); const sinceCheck = helpers.daysSinceCheck(item, getToday(getStoredTimezone()));
const isActive = item.status === "active"; const isActive = item.status === "active";
const isCheckedOut = item.status === "checked-out"; const isCheckedOut = item.status === "checked-out";
@@ -64,7 +65,7 @@ export function ProductDetail({
...(cfg?.showCannabinoidPct !== false ...(cfg?.showCannabinoidPct !== false
? [["Total cannabinoids", `${item.totalCannabinoids.toFixed(1)}%`] as [string, React.ReactNode]] ? [["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>], ["Bin", isCheckedOut ? "In your custody" : bin ? bin.name : <span style={{ color: "var(--ink-3)" }}></span>],
["Audit cadence", `Every ${cfg?.cadenceDays ?? "—"} days · ${cfg?.auditMode ?? "—"}`], ["Audit cadence", `Every ${cfg?.cadenceDays ?? "—"} days · ${cfg?.auditMode ?? "—"}`],
[ [
@@ -77,11 +78,11 @@ export function ProductDetail({
], ],
]; ];
if (item.status === "checked-out") { 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") { if (item.status === "consumed") {
detailRows.push( detailRows.push(
["Date finished", fmt.date(item.consumedDate)], ["Date finished", fmt.date(item.consumedDate, getStoredTimezone())],
[ [
"Lasted", "Lasted",
`${Math.round((+new Date(item.consumedDate!) - +new Date(item.purchaseDate)) / 86_400_000)} days`, `${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") { if (item.status === "gone") {
detailRows.push( detailRows.push(
["Date gone", fmt.date(item.goneDate)], ["Date gone", fmt.date(item.goneDate, getStoredTimezone())],
[ [
"After", "After",
`${Math.round((+new Date(item.goneDate!) - +new Date(item.purchaseDate)) / 86_400_000)} days`, `${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} {TYPE_GLYPHS[item.type]} {item.type}
</div> </div>
{item.status === "consumed" && ( {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" && ( {item.status === "gone" && (
<Pill tone="amber">Gone · {fmt.daysAgo(item.goneDate)}</Pill> <Pill tone="amber">Gone · {fmt.daysAgo(item.goneDate, getStoredTimezone())}</Pill>
)} )}
{isCheckedOut && ( {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>} {isActive && overdue && <Pill tone="amber">Audit overdue · {sinceCheck}d</Pill>}
</div> </div>
@@ -289,7 +290,7 @@ export function ProductDetail({
fontStyle: "italic", 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. {cfg?.unit}). Re-audit to update.
</div> </div>
)} )}
@@ -369,7 +370,7 @@ export function ProductDetail({
{a.mode === "presence" && (a.confirmedBy === "lost" ? "Marked lost" : "Confirmed presence")} {a.mode === "presence" && (a.confirmedBy === "lost" ? "Marked lost" : "Confirmed presence")}
</div> </div>
<div style={{ fontSize: 11, color: "var(--ink-3)" }}> <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> </div>
<div className="mono" style={{ fontSize: 13, textAlign: "right" }}> <div className="mono" style={{ fontSize: 13, textAlign: "right" }}>
@@ -4,10 +4,10 @@ import type { Bootstrap, InventoryItem, Item, Product, Strain } from "../../type
import { import {
ASSET_ID_RE, ASSET_ID_RE,
TYPES, TYPES,
TODAY_STR,
enrichItems, enrichItems,
getLastInstance, getLastInstance,
} from "../../types.js"; } from "../../types.js";
import { getToday, getStoredTimezone } from "../../tz.js";
import { fmt } from "../../format.js"; import { fmt } from "../../format.js";
import { api } from "../../api.js"; import { api } from "../../api.js";
import { Btn, Field, Input, Select } from "../primitives/index.js"; import { Btn, Field, Input, Select } from "../primitives/index.js";
@@ -376,7 +376,7 @@ function InstanceDetailsStep({
thc: last?.thc ?? (cfg?.showCannabinoidPct !== false ? 22 : 0), thc: last?.thc ?? (cfg?.showCannabinoidPct !== false ? 22 : 0),
cbd: last?.cbd ?? (cfg?.showCannabinoidPct !== false ? 0.4 : 0), cbd: last?.cbd ?? (cfg?.showCannabinoidPct !== false ? 0.4 : 0),
totalCannabinoids: last?.totalCannabinoids ?? (cfg?.showCannabinoidPct !== false ? 26 : 0), totalCannabinoids: last?.totalCannabinoids ?? (cfg?.showCannabinoidPct !== false ? 26 : 0),
purchaseDate: TODAY_STR, purchaseDate: getToday(getStoredTimezone()),
}); });
const [newShopName, setNewShopName] = useState(""); const [newShopName, setNewShopName] = useState("");
const [newShopLocation, setNewShopLocation] = useState(""); const [newShopLocation, setNewShopLocation] = useState("");
+4 -3
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Item } from "../../types.js"; 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 { api } from "../../api.js";
import { Btn, Field, Input, Select } from "../primitives/index.js"; import { Btn, Field, Input, Select } from "../primitives/index.js";
import { ScanField, type ScanResult } from "../ScanField.js"; import { ScanField, type ScanResult } from "../ScanField.js";
@@ -40,7 +41,7 @@ export function AuditFlow({
.sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a)); .sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a));
const [itemId, setItemId] = useState(initialItem?.id ?? ""); 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 [confirmedBy, setConfirmedBy] = useState<"asset" | "visual">("asset");
const item = allItems.find((i) => i.id === itemId); const item = allItems.find((i) => i.id === itemId);
@@ -51,7 +52,7 @@ export function AuditFlow({
if (i.kind === "discrete") { if (i.kind === "discrete") {
return String(i.countLastAudit ?? i.countOriginal); 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<string>(initialValueFor(item)); const [value, setValue] = useState<string>(initialValueFor(item));
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Item } from "../../types.js"; 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 { api } from "../../api.js";
import type { BatchOp } from "../../api.js"; import type { BatchOp } from "../../api.js";
import { Btn, Field, Input, Select } from "../primitives/index.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 eligible = items.filter((i) => i.status === "checked-out");
const excluded = items.length - eligible.length; 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 [binId, setBinId] = useState(data.bins[0]?.id ?? "");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Item } from "../../types.js"; 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 { api } from "../../api.js";
import type { BatchOp } from "../../api.js"; import type { BatchOp } from "../../api.js";
import { Btn, Field, Input } from "../primitives/index.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 eligible = items.filter((i) => i.status === "active");
const excluded = items.length - eligible.length; const excluded = items.length - eligible.length;
const [date, setDate] = useState(TODAY_STR); const [date, setDate] = useState(getToday(getStoredTimezone()));
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const checkout = useMutation({ const checkout = useMutation({
@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Item } from "../../types.js"; 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 { api } from "../../api.js";
import type { BatchOp } from "../../api.js"; import type { BatchOp } from "../../api.js";
import { Btn, Field, Icon, Input, Textarea } from "../primitives/index.js"; import { Btn, Field, Icon, Input, Textarea } from "../primitives/index.js";
@@ -24,7 +24,7 @@ export function BulkConsumeModal({
const [rating, setRating] = useState(4); const [rating, setRating] = useState(4);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [date, setDate] = useState(TODAY_STR); const [date, setDate] = useState(getToday(getStoredTimezone()));
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const finish = useMutation({ const finish = useMutation({
+2 -2
View File
@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Item } from "../../types.js"; 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 { api } from "../../api.js";
import type { BatchOp } from "../../api.js"; import type { BatchOp } from "../../api.js";
import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js"; import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js";
@@ -32,7 +32,7 @@ export function BulkGoneModal({
const [reason, setReason] = useState("lost"); const [reason, setReason] = useState("lost");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [date, setDate] = useState(TODAY_STR); const [date, setDate] = useState(getToday(getStoredTimezone()));
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const mark = useMutation({ const mark = useMutation({
+6 -5
View File
@@ -1,7 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Item } from "../../types.js"; 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 { fmt } from "../../format.js";
import { api } from "../../api.js"; import { api } from "../../api.js";
import { Btn, Field, Input, Select } from "../primitives/index.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 checkedOut = allItems.filter((i) => i.status === "checked-out");
const [itemId, setItemId] = useState(initialItem?.id ?? ""); const [itemId, setItemId] = useState(initialItem?.id ?? "");
const [binId, setBinId] = useState(data.bins[0]?.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 [remaining, setRemaining] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const item = allItems.find((i) => i.id === itemId); const item = allItems.find((i) => i.id === itemId);
const isBulk = item?.kind === "bulk"; const isBulk = item?.kind === "bulk";
const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined; 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({ const checkin = useMutation({
mutationFn: () => { mutationFn: () => {
@@ -58,7 +59,7 @@ export function CheckinFlow({
setItemId(result.item.id); setItemId(result.item.id);
const scanned = allItems.find((i) => i.id === result.item.id); const scanned = allItems.find((i) => i.id === result.item.id);
if (scanned?.kind === "bulk") { 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({
<div style={{ fontSize: 12, color: "var(--ink-3)" }}> <div style={{ fontSize: 12, color: "var(--ink-3)" }}>
<span className="mono">{item.assetId}</span> ·{" "} <span className="mono">{item.assetId}</span> ·{" "}
{helpers.brandName(data, item.brandId)} · checked out{" "} {helpers.brandName(data, item.brandId)} · checked out{" "}
{fmt.dateShort(item.checkoutDate)} {fmt.dateShort(item.checkoutDate, getStoredTimezone())}
</div> </div>
</div> </div>
+4 -3
View File
@@ -1,7 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Item } from "../../types.js"; 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 { fmt } from "../../format.js";
import { api } from "../../api.js"; import { api } from "../../api.js";
import { Btn, Field, Icon, Input } from "../primitives/index.js"; import { Btn, Field, Icon, Input } from "../primitives/index.js";
@@ -23,7 +24,7 @@ export function CheckoutFlow({
const allItems = enrichItems(data); const allItems = enrichItems(data);
const active = allItems.filter((i) => i.status === "active"); const active = allItems.filter((i) => i.status === "active");
const [itemId, setItemId] = useState(initialItem?.id ?? ""); const [itemId, setItemId] = useState(initialItem?.id ?? "");
const [date, setDate] = useState(TODAY_STR); const [date, setDate] = useState(getToday(getStoredTimezone()));
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const item = allItems.find((i) => i.id === itemId); const item = allItems.find((i) => i.id === itemId);
@@ -103,7 +104,7 @@ export function CheckoutFlow({
<div style={{ fontSize: 12, color: "var(--ink-3)" }}> <div style={{ fontSize: 12, color: "var(--ink-3)" }}>
<span className="mono">{item.assetId}</span> ·{" "} <span className="mono">{item.assetId}</span> ·{" "}
{helpers.brandName(data, item.brandId)} · {bin?.name ?? "no bin"} · {helpers.brandName(data, item.brandId)} · {bin?.name ?? "no bin"} ·
purchased {fmt.dateShort(item.purchaseDate)} purchased {fmt.dateShort(item.purchaseDate, getStoredTimezone())}
</div> </div>
</div> </div>
<div style={{ textAlign: "right" }}> <div style={{ textAlign: "right" }}>
+4 -3
View File
@@ -1,7 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Item } from "../../types.js"; 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 { fmt } from "../../format.js";
import { api } from "../../api.js"; import { api } from "../../api.js";
import { Btn, Field, Icon, Input, Textarea } from "../primitives/index.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 [itemId, setItemId] = useState(initialItem?.id ?? "");
const [rating, setRating] = useState(4); const [rating, setRating] = useState(4);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [date, setDate] = useState(TODAY_STR); const [date, setDate] = useState(getToday(getStoredTimezone()));
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const item = allItems.find((i) => i.id === itemId); const item = allItems.find((i) => i.id === itemId);
@@ -96,7 +97,7 @@ export function ConsumeFlow({
</div> </div>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}> <div style={{ fontSize: 12, color: "var(--ink-3)" }}>
<span className="mono">{item.assetId}</span> · {helpers.brandName(data, item.brandId)} · {bin?.name} · purchased{" "} <span className="mono">{item.assetId}</span> · {helpers.brandName(data, item.brandId)} · {bin?.name} · purchased{" "}
{fmt.dateShort(item.purchaseDate)} {fmt.dateShort(item.purchaseDate, getStoredTimezone())}
</div> </div>
</div> </div>
<div style={{ textAlign: "right" }}> <div style={{ textAlign: "right" }}>
+3 -2
View File
@@ -1,7 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Item } from "../../types.js"; 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 { remainingShort } from "../../stats.js";
import { api } from "../../api.js"; import { api } from "../../api.js";
import { Btn, Field, Input, Select, Textarea } from "../primitives/index.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 [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? "");
const [reason, setReason] = useState("lost"); const [reason, setReason] = useState("lost");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [date, setDate] = useState(TODAY_STR); const [date, setDate] = useState(getToday(getStoredTimezone()));
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const item = allItems.find((i) => i.id === itemId); const item = allItems.find((i) => i.id === itemId);
+19 -10
View File
@@ -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 = { export const fmt = {
g(n: number | null | undefined): string { g(n: number | null | undefined): string {
if (n == null) return "—"; if (n == null) return "—";
@@ -17,22 +22,26 @@ export const fmt = {
if (n == null) return "—"; if (n == null) return "—";
return `${(+n).toFixed(1)}%`; return `${(+n).toFixed(1)}%`;
}, },
date(s: string | null | undefined): string { date(s: string | null | undefined, tz?: string): string {
if (!s) return "—"; if (!s) return "—";
const d = new Date(s); const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" };
return d.toLocaleDateString("en-US", { 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 "—"; if (!s) return "—";
const d = new Date(s); const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
return d.toLocaleDateString("en-US", { 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 "—"; if (!s) return "—";
const ms = Date.now() - new Date(s).getTime(); const todayMs = +parseDate(tz ? getToday(tz) : new Date().toISOString().slice(0, 10));
const d = Math.floor(ms / 86_400_000); const thenMs = +parseDate(s);
const d = Math.floor((todayMs - thenMs) / 86_400_000);
if (d === 0) return "today"; if (d === 0) return "today";
if (d === 1) return "yesterday"; if (d === 1) return "yesterday";
if (d < 0) return "in the future";
if (d < 30) return `${d}d ago`; if (d < 30) return `${d}d ago`;
if (d < 365) return `${Math.floor(d / 30)}mo ago`; if (d < 365) return `${Math.floor(d / 30)}mo ago`;
return `${Math.floor(d / 365)}y ago`; return `${Math.floor(d / 365)}y ago`;
+12 -4
View File
@@ -4,7 +4,8 @@
// stay clean). Operates on the enriched Item[] view, not raw products. // stay clean). Operates on the enriched Item[] view, not raw products.
import type { Bootstrap, Item } from "./types.js"; 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 { export interface Stats {
dailyAvg: number; dailyAvg: number;
@@ -49,10 +50,17 @@ export interface Stats {
} }
export function computeStats(data: Bootstrap): Stats { export function computeStats(data: Bootstrap): Stats {
const today = new Date(data.today || TODAY_STR); const tz = getStoredTimezone();
const todayStr = today.toISOString().slice(0, 10); const todayStr = getToday(tz);
const today = new Date(todayStr + "T12:00:00");
const items = enrichItems(data); 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 active = items.filter((p) => p.status === "active" || p.status === "checked-out");
const consumed = items.filter((p) => p.status === "consumed" && p.consumedDate); const consumed = items.filter((p) => p.status === "consumed" && p.consumedDate);
+4 -9
View File
@@ -125,15 +125,10 @@ export const TYPES: TypeConfig[] = [
// User-supplied 6-digit asset ids are printed on a roll of physical tags. // User-supplied 6-digit asset ids are printed on a roll of physical tags.
export const ASSET_ID_RE = /^\d{6}$/; export const ASSET_ID_RE = /^\d{6}$/;
// Local-time YYYY-MM-DD captured once at module load. Used as the default import { getToday, getBrowserTimezone } from "./tz.js";
// value for date inputs and as the "today" anchor for days-since math. export { getToday } from "./tz.js";
export const TODAY_STR = (() => {
const d = new Date(); export const TODAY_STR = getToday(getBrowserTimezone());
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}`;
})();
// Build the joined Item[] view from bootstrap. Inventory items are dropped // Build the joined Item[] view from bootstrap. Inventory items are dropped
// silently if they reference a missing product — that shouldn't happen in // silently if they reference a missing product — that shouldn't happen in
+21
View File
@@ -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();
}
+3 -2
View File
@@ -1,7 +1,8 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Bin, Item } from "../types.js"; 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 { remainingShort } from "../stats.js";
import { fmt, TYPE_GLYPHS } from "../format.js"; import { fmt, TYPE_GLYPHS } from "../format.js";
import { api } from "../api.js"; import { api } from "../api.js";
@@ -120,7 +121,7 @@ export function BinsView({
); );
const fillPct = slotsUsed / bin.capacity; const fillPct = slotsUsed / bin.capacity;
const totalValue = binItems.reduce( 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, 0,
); );
return ( return (
+3 -2
View File
@@ -3,6 +3,7 @@ import { helpers } from "../types.js";
import type { Stats } from "../stats.js"; import type { Stats } from "../stats.js";
import { fmt } from "../format.js"; import { fmt } from "../format.js";
import { BarChart, Card } from "../components/primitives/index.js"; import { BarChart, Card } from "../components/primitives/index.js";
import { getStoredTimezone } from "../tz.js";
export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) { export function ChartsView({ data, stats }: { data: Bootstrap; stats: Stats }) {
const series = stats.series90.map((s) => ({ date: s.date, grams: s.grams })); 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 ( return (
<div key={m} style={{ display: "flex", alignItems: "center", gap: 12 }}> <div key={m} style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)", width: 60 }}> <div className="smallcaps" style={{ color: "var(--ink-3)", width: 60 }}>
{d.toLocaleDateString("en-US", { month: "short", year: "2-digit" })} {d.toLocaleDateString("en-US", { month: "short", year: "2-digit", timeZone: getStoredTimezone() })}
</div> </div>
<div <div
style={{ style={{
@@ -212,7 +213,7 @@ function Heatmap({ series }: { series: { date: string; grams: number }[] }) {
}} }}
> >
{firstDay && new Date(firstDay.date).getDate() <= 7 {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() })
: ""} : ""}
</div> </div>
); );
+4 -3
View File
@@ -1,6 +1,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import type { Bootstrap, Item } from "../types.js"; 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 { remainingShort } from "../stats.js";
import { fmt, TYPE_GLYPHS } from "../format.js"; import { fmt, TYPE_GLYPHS } from "../format.js";
import { Btn, Card, Icon } from "../components/primitives/index.js"; import { Btn, Card, Icon } from "../components/primitives/index.js";
@@ -127,7 +128,7 @@ function CustodyRow({
onMarkGone: () => void; onMarkGone: () => void;
}) { }) {
const glyph = TYPE_GLYPHS[item.type] ?? "·"; const glyph = TYPE_GLYPHS[item.type] ?? "·";
const pct = helpers.pctRemaining(item, TODAY_STR); const pct = helpers.pctRemaining(item, getToday(getStoredTimezone()));
return ( return (
<div <div
@@ -158,7 +159,7 @@ function CustodyRow({
</span> </span>
</div> </div>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}> <div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{fmt.daysAgo(item.checkoutDate)} {fmt.daysAgo(item.checkoutDate, getStoredTimezone())}
</div> </div>
<div <div
style={{ display: "flex", gap: 4, justifyContent: "flex-end" }} style={{ display: "flex", gap: 4, justifyContent: "flex-end" }}
+7 -5
View File
@@ -1,5 +1,6 @@
import type { Bootstrap, Item } from "../types.js"; import type { Bootstrap, Item } from "../types.js";
import { helpers, TODAY_STR } from "../types.js"; import { helpers } from "../types.js";
import { getToday, getStoredTimezone } from "../tz.js";
import type { Stats } from "../stats.js"; import type { Stats } from "../stats.js";
import { remainingShort } from "../stats.js"; import { remainingShort } from "../stats.js";
import { fmt } from "../format.js"; import { fmt } from "../format.js";
@@ -40,13 +41,14 @@ export function Dashboard({
const lowBulk = stats.lowStockBulk; const lowBulk = stats.lowStockBulk;
const lowDiscrete = stats.lowStockDiscreteGroups; const lowDiscrete = stats.lowStockDiscreteGroups;
const todayStr = data.today || TODAY_STR; const tz = getStoredTimezone();
const todayDate = new Date(todayStr + "T00:00:00"); const todayStr = getToday(tz);
const greetingDate = todayDate.toLocaleDateString("en-US", { const greetingDate = new Date(todayStr + "T12:00:00").toLocaleDateString("en-US", {
weekday: "long", weekday: "long",
month: "long", month: "long",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
timeZone: tz,
}); });
return ( return (
@@ -325,7 +327,7 @@ export function Dashboard({
</div> </div>
)} )}
{lowBulk.slice(0, 3).map((i) => { {lowBulk.slice(0, 3).map((i) => {
const pct = helpers.pctRemaining(i, TODAY_STR); const pct = helpers.pctRemaining(i, getToday(getStoredTimezone()));
return ( return (
<div <div
key={i.id} key={i.id}
+9 -8
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { Bootstrap, Item } from "../types.js"; 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 { remainingShort } from "../stats.js"; import { remainingShort } from "../stats.js";
import { fmt, TYPE_GLYPHS } from "../format.js"; import { fmt, TYPE_GLYPHS } from "../format.js";
import { Btn, Card, Pill, Icon, Select, Checkbox, inputStyle } from "../components/primitives/index.js"; import { Btn, Card, Pill, Icon, Select, Checkbox, inputStyle } from "../components/primitives/index.js";
@@ -52,10 +53,10 @@ export function Inventory({
if (sortBy === "name") return a.name.localeCompare(b.name); if (sortBy === "name") return a.name.localeCompare(b.name);
if (sortBy === "thc") return b.thc - a.thc; if (sortBy === "thc") return b.thc - a.thc;
if (sortBy === "remaining") if (sortBy === "remaining")
return helpers.estimatedRemaining(b, TODAY_STR) - helpers.estimatedRemaining(a, TODAY_STR); return helpers.estimatedRemaining(b, getToday(getStoredTimezone())) - helpers.estimatedRemaining(a, getToday(getStoredTimezone()));
if (sortBy === "price") return b.price - a.price; if (sortBy === "price") return b.price - a.price;
if (sortBy === "audit") if (sortBy === "audit")
return helpers.daysSinceCheck(b, TODAY_STR) - helpers.daysSinceCheck(a, TODAY_STR); return helpers.daysSinceCheck(b, getToday(getStoredTimezone())) - helpers.daysSinceCheck(a, getToday(getStoredTimezone()));
return 0; return 0;
}; };
@@ -473,7 +474,7 @@ function GroupHeader({
}) { }) {
const active = group.items.filter((i) => i.status === "active"); const active = group.items.filter((i) => i.status === "active");
const totalRemaining = active.reduce((s, i) => { 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); return s + (i.countLastAudit ?? i.countOriginal);
}, 0); }, 0);
const lastBuy = group.items.reduce((max, i) => { const lastBuy = group.items.reduce((max, i) => {
@@ -527,7 +528,7 @@ function GroupHeader({
<div> <div>
last buy{" "} last buy{" "}
<span className="mono" style={{ color: "var(--ink-2)" }}> <span className="mono" style={{ color: "var(--ink-2)" }}>
{fmt.dateShort(new Date(lastBuy).toISOString())} {fmt.dateShort(new Date(lastBuy).toISOString(), getStoredTimezone())}
</span> </span>
</div> </div>
)} )}
@@ -552,9 +553,9 @@ function ItemRow({
onToggle: (id: string, shiftKey: boolean) => void; onToggle: (id: string, shiftKey: boolean) => void;
}) { }) {
const bin = data.bins.find((b) => b.id === i.binId); const bin = data.bins.find((b) => b.id === i.binId);
const pctRemaining = helpers.pctRemaining(i, TODAY_STR); const pctRemaining = helpers.pctRemaining(i, getToday(getStoredTimezone()));
const overdue = helpers.auditOverdue(i, TODAY_STR); const overdue = helpers.auditOverdue(i, getToday(getStoredTimezone()));
const sinceCheck = helpers.daysSinceCheck(i, TODAY_STR); const sinceCheck = helpers.daysSinceCheck(i, getToday(getStoredTimezone()));
const last = helpers.lastAudit(i); const last = helpers.lastAudit(i);
const isInactive = i.status !== "active" && i.status !== "checked-out"; const isInactive = i.status !== "active" && i.status !== "checked-out";
return ( return (
+30 -1
View File
@@ -1,5 +1,19 @@
import type { Bootstrap } from "../types.js"; 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) { function download(filename: string, content: string, mime: string) {
const blob = new Blob([content], { type: mime }); const blob = new Blob([content], { type: mime });
@@ -75,10 +89,14 @@ export function SettingsView({
data, data,
theme, theme,
onThemeChange, onThemeChange,
timezone,
onTimezoneChange,
}: { }: {
data: Bootstrap; data: Bootstrap;
theme: ThemeKey; theme: ThemeKey;
onThemeChange: (t: ThemeKey) => void; onThemeChange: (t: ThemeKey) => void;
timezone: string;
onTimezoneChange: (tz: string) => void;
}) { }) {
return ( return (
<div <div
@@ -132,6 +150,17 @@ export function SettingsView({
))} ))}
</div> </div>
</SettingRow> </SettingRow>
<SettingRow label="Timezone" hint={`Dates and "today" are shown in this timezone (detected: ${getBrowserTimezone().replace(/_/g, " ")})`}>
<Select
value={timezone}
onChange={(e) => onTimezoneChange(e.target.value)}
style={{ width: 280 }}
>
{getTimezoneOptions().map((tz) => (
<option key={tz} value={tz}>{tz.replace(/_/g, " ")}</option>
))}
</Select>
</SettingRow>
</div> </div>
</Card> </Card>