Add timezone preference and fix all date handling to be timezone-aware
Build and push image / build (push) Successful in 56s
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:
+7
-1
@@ -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>
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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" }}>
|
||||||
|
|||||||
@@ -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" }}>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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,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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" }}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user