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 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<ThemeKey>(
() => (localStorage.getItem("apothecary.theme") as ThemeKey | null) ?? "light",
);
const [timezone, setTimezone] = useState<string>(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<Bootstrap>({
queryKey: ["bootstrap"],
queryFn: api.bootstrap,
@@ -225,7 +231,7 @@ export function App() {
} />
<Route path="/charts" element={<ChartsView data={data} stats={stats} />} />
<Route path="/settings" element={
<SettingsView data={data} theme={theme} onThemeChange={setTheme} />
<SettingsView data={data} theme={theme} onThemeChange={setTheme} timezone={timezone} onTimezoneChange={setTimezone} />
} />
</Routes>
</main>
+15 -14
View File
@@ -1,6 +1,7 @@
import { useEffect } from "react";
import type { Bootstrap, Item, Product } from "../types.js";
import { TYPES, helpers, TODAY_STR } from "../types.js";
import { TYPES, helpers } from "../types.js";
import { getToday, getStoredTimezone } from "../tz.js";
import { fmt, TYPE_GLYPHS } from "../format.js";
import { Btn, Pill, Icon } from "./primitives/index.js";
@@ -31,11 +32,11 @@ export function ProductDetail({
const bin = data.bins.find((b) => b.id === item.binId);
const cfg = TYPES.find((t) => t.id === item.type);
const product = data.products.find((p) => p.id === item.productId);
const pctRemaining = helpers.pctRemaining(item, TODAY_STR);
const est = helpers.estimatedRemaining(item, TODAY_STR);
const pctRemaining = helpers.pctRemaining(item, getToday(getStoredTimezone()));
const est = helpers.estimatedRemaining(item, getToday(getStoredTimezone()));
const last = helpers.lastAudit(item);
const overdue = helpers.auditOverdue(item, TODAY_STR);
const sinceCheck = helpers.daysSinceCheck(item, TODAY_STR);
const overdue = helpers.auditOverdue(item, getToday(getStoredTimezone()));
const sinceCheck = helpers.daysSinceCheck(item, getToday(getStoredTimezone()));
const isActive = item.status === "active";
const isCheckedOut = item.status === "checked-out";
@@ -64,7 +65,7 @@ export function ProductDetail({
...(cfg?.showCannabinoidPct !== false
? [["Total cannabinoids", `${item.totalCannabinoids.toFixed(1)}%`] as [string, React.ReactNode]]
: []),
["Purchase date", fmt.date(item.purchaseDate)],
["Purchase date", fmt.date(item.purchaseDate, getStoredTimezone())],
["Bin", isCheckedOut ? "In your custody" : bin ? bin.name : <span style={{ color: "var(--ink-3)" }}></span>],
["Audit cadence", `Every ${cfg?.cadenceDays ?? "—"} days · ${cfg?.auditMode ?? "—"}`],
[
@@ -77,11 +78,11 @@ export function ProductDetail({
],
];
if (item.status === "checked-out") {
detailRows.push(["Checked out", fmt.date(item.checkoutDate)]);
detailRows.push(["Checked out", fmt.date(item.checkoutDate, getStoredTimezone())]);
}
if (item.status === "consumed") {
detailRows.push(
["Date finished", fmt.date(item.consumedDate)],
["Date finished", fmt.date(item.consumedDate, getStoredTimezone())],
[
"Lasted",
`${Math.round((+new Date(item.consumedDate!) - +new Date(item.purchaseDate)) / 86_400_000)} days`,
@@ -90,7 +91,7 @@ export function ProductDetail({
}
if (item.status === "gone") {
detailRows.push(
["Date gone", fmt.date(item.goneDate)],
["Date gone", fmt.date(item.goneDate, getStoredTimezone())],
[
"After",
`${Math.round((+new Date(item.goneDate!) - +new Date(item.purchaseDate)) / 86_400_000)} days`,
@@ -174,13 +175,13 @@ export function ProductDetail({
{TYPE_GLYPHS[item.type]} {item.type}
</div>
{item.status === "consumed" && (
<Pill tone="terra">Consumed · {fmt.daysAgo(item.consumedDate)}</Pill>
<Pill tone="terra">Consumed · {fmt.daysAgo(item.consumedDate, getStoredTimezone())}</Pill>
)}
{item.status === "gone" && (
<Pill tone="amber">Gone · {fmt.daysAgo(item.goneDate)}</Pill>
<Pill tone="amber">Gone · {fmt.daysAgo(item.goneDate, getStoredTimezone())}</Pill>
)}
{isCheckedOut && (
<Pill tone="outline">Checked out · {fmt.daysAgo(item.checkoutDate)}</Pill>
<Pill tone="outline">Checked out · {fmt.daysAgo(item.checkoutDate, getStoredTimezone())}</Pill>
)}
{isActive && overdue && <Pill tone="amber">Audit overdue · {sinceCheck}d</Pill>}
</div>
@@ -289,7 +290,7 @@ export function ProductDetail({
fontStyle: "italic",
}}
>
Estimated by linear decay since last {last.mode} on {fmt.dateShort(last.date)} ({last.value}
Estimated by linear decay since last {last.mode} on {fmt.dateShort(last.date, getStoredTimezone())} ({last.value}
{cfg?.unit}). Re-audit to update.
</div>
)}
@@ -369,7 +370,7 @@ export function ProductDetail({
{a.mode === "presence" && (a.confirmedBy === "lost" ? "Marked lost" : "Confirmed presence")}
</div>
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
{fmt.date(a.date)} · {fmt.daysAgo(a.date)}
{fmt.date(a.date, getStoredTimezone())} · {fmt.daysAgo(a.date, getStoredTimezone())}
</div>
</div>
<div className="mono" style={{ fontSize: 13, textAlign: "right" }}>
@@ -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("");
+4 -3
View File
@@ -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<string>(initialValueFor(item));
const [error, setError] = useState<string | null>(null);
@@ -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<string | null>(null);
@@ -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<string | null>(null);
const checkout = useMutation({
@@ -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<string | null>(null);
const finish = useMutation({
+2 -2
View File
@@ -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<string | null>(null);
const mark = useMutation({
+6 -5
View File
@@ -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<string | null>(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({
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
<span className="mono">{item.assetId}</span> ·{" "}
{helpers.brandName(data, item.brandId)} · checked out{" "}
{fmt.dateShort(item.checkoutDate)}
{fmt.dateShort(item.checkoutDate, getStoredTimezone())}
</div>
</div>
+4 -3
View File
@@ -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<string | null>(null);
const item = allItems.find((i) => i.id === itemId);
@@ -103,7 +104,7 @@ export function CheckoutFlow({
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
<span className="mono">{item.assetId}</span> ·{" "}
{helpers.brandName(data, item.brandId)} · {bin?.name ?? "no bin"} ·
purchased {fmt.dateShort(item.purchaseDate)}
purchased {fmt.dateShort(item.purchaseDate, getStoredTimezone())}
</div>
</div>
<div style={{ textAlign: "right" }}>
+4 -3
View File
@@ -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<string | null>(null);
const item = allItems.find((i) => i.id === itemId);
@@ -96,7 +97,7 @@ export function ConsumeFlow({
</div>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
<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 style={{ textAlign: "right" }}>
+3 -2
View File
@@ -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<string | null>(null);
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 = {
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`;
+12 -4
View File
@@ -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);
+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.
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
+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 { 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 (
+3 -2
View File
@@ -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 (
<div key={m} style={{ display: "flex", alignItems: "center", gap: 12 }}>
<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
style={{
@@ -212,7 +213,7 @@ function Heatmap({ series }: { series: { date: string; grams: number }[] }) {
}}
>
{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>
);
+4 -3
View File
@@ -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 (
<div
@@ -158,7 +159,7 @@ function CustodyRow({
</span>
</div>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{fmt.daysAgo(item.checkoutDate)}
{fmt.daysAgo(item.checkoutDate, getStoredTimezone())}
</div>
<div
style={{ display: "flex", gap: 4, justifyContent: "flex-end" }}
+7 -5
View File
@@ -1,5 +1,6 @@
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 { remainingShort } from "../stats.js";
import { fmt } from "../format.js";
@@ -40,13 +41,14 @@ export function Dashboard({
const lowBulk = stats.lowStockBulk;
const lowDiscrete = stats.lowStockDiscreteGroups;
const todayStr = data.today || TODAY_STR;
const todayDate = new Date(todayStr + "T00:00:00");
const greetingDate = todayDate.toLocaleDateString("en-US", {
const tz = getStoredTimezone();
const todayStr = getToday(tz);
const greetingDate = new Date(todayStr + "T12:00:00").toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
timeZone: tz,
});
return (
@@ -325,7 +327,7 @@ export function Dashboard({
</div>
)}
{lowBulk.slice(0, 3).map((i) => {
const pct = helpers.pctRemaining(i, TODAY_STR);
const pct = helpers.pctRemaining(i, getToday(getStoredTimezone()));
return (
<div
key={i.id}
+9 -8
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
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 { fmt, TYPE_GLYPHS } from "../format.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 === "thc") return b.thc - a.thc;
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 === "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;
};
@@ -473,7 +474,7 @@ function GroupHeader({
}) {
const active = group.items.filter((i) => 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({
<div>
last buy{" "}
<span className="mono" style={{ color: "var(--ink-2)" }}>
{fmt.dateShort(new Date(lastBuy).toISOString())}
{fmt.dateShort(new Date(lastBuy).toISOString(), getStoredTimezone())}
</span>
</div>
)}
@@ -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 (
+30 -1
View File
@@ -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 (
<div
@@ -132,6 +150,17 @@ export function SettingsView({
))}
</div>
</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>
</Card>