fix: unify day boundaries on device-local time, JSON import preview parity

- Limits/streaks/greeting now use device-local days (was hardcoded Europe/London),
  matching dashboard metrics; label copy updated
- JSON imports now go through the same preview/duplicate-detection modal as Excel
This commit is contained in:
nh9961
2026-07-19 21:12:02 +00:00
parent 9e8f3c55d6
commit 767967f25d
7 changed files with 52 additions and 49 deletions
+10 -17
View File
@@ -21,7 +21,6 @@ import {
createEntries, createEntries,
createEntry, createEntry,
deleteEntry as deleteEntryDocument, deleteEntry as deleteEntryDocument,
isDuplicateDraft,
listEntries, listEntries,
updateEntry, updateEntry,
} from "./lib/appwriteEntries"; } from "./lib/appwriteEntries";
@@ -52,7 +51,7 @@ import {
} from "./lib/userLimits"; } from "./lib/userLimits";
import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel"; import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel";
import { buildEntryDerived, groupByDay, groupByFlavour, groupByWeek, humanDateTime, makeId } from "./lib/metrics"; import { buildEntryDerived, groupByDay, groupByFlavour, groupByWeek, humanDateTime, makeId } from "./lib/metrics";
import { exportPayload, parseImport } from "./lib/storage"; import { buildJsonImportPreview, exportPayload, parseImport } from "./lib/storage";
import { useToasts } from "./lib/toasts"; import { useToasts } from "./lib/toasts";
import type { import type {
AppView, AppView,
@@ -547,18 +546,18 @@ function App() {
} }
} }
async function confirmExcelImport() { async function confirmImportPreview() {
if (!user || !importPreview) return; if (!user || !importPreview) return;
const drafts = importPreview.rows const drafts = importPreview.rows
.filter((row) => row.entry && !row.errors.length && !row.duplicate) .filter((row) => row.entry && !row.errors.length && !row.duplicate)
.map((row) => row.entry as EntryDraft); .map((row) => row.entry as EntryDraft);
if (!drafts.length) { if (!drafts.length) {
setNotice("No valid new Excel rows to import."); setNotice("No valid new rows to import.");
return; return;
} }
setBusyAction("confirm-excel-import"); setBusyAction("confirm-import");
setSyncError(""); setSyncError("");
importInFlightRef.current = true; importInFlightRef.current = true;
setImportProgress({ done: 0, total: drafts.length }); setImportProgress({ done: 0, total: drafts.length });
@@ -566,7 +565,7 @@ function App() {
const saved = await createEntries(user.$id, drafts, (done, total) => setImportProgress({ done, total })); const saved = await createEntries(user.$id, drafts, (done, total) => setImportProgress({ done, total }));
setEntries((current) => sortEntries([...saved, ...current])); setEntries((current) => sortEntries([...saved, ...current]));
setImportPreview(null); setImportPreview(null);
setNotice(`${saved.length} Excel row${saved.length === 1 ? "" : "s"} saved to Appwrite.`); setNotice(`${saved.length} row${saved.length === 1 ? "" : "s"} saved to Appwrite.`);
} catch (error) { } catch (error) {
setSyncError(appwriteErrorMessage(error)); setSyncError(appwriteErrorMessage(error));
} finally { } finally {
@@ -588,18 +587,12 @@ function App() {
setSyncError(""); setSyncError("");
try { try {
const drafts = parseImport(await file.text()); const drafts = parseImport(await file.text());
const uniqueDrafts = drafts.filter((draft) => !isDuplicateDraft(entries, draft)); const preview = buildJsonImportPreview(drafts, entries, file.name);
if (!uniqueDrafts.length) { setImportPreview(preview);
setNotice("No new JSON entries found."); setNotice(`${preview.rows.length} JSON row${preview.rows.length === 1 ? "" : "s"} parsed for review.`);
return;
}
importInFlightRef.current = true;
const saved = await createEntries(user.$id, uniqueDrafts.map((draft) => ({ ...draft, source: "json" })));
setEntries((current) => sortEntries([...saved, ...current])); setNotice(`${saved.length} JSON entr${saved.length === 1 ? "y" : "ies"} saved to Appwrite.`);
} catch (error) { } catch (error) {
setSyncError(error instanceof Error ? error.message : "JSON import failed."); setSyncError(error instanceof Error ? error.message : "JSON import failed.");
} finally { } finally {
importInFlightRef.current = false;
if (jsonFileInputRef.current) jsonFileInputRef.current.value = ""; if (jsonFileInputRef.current) jsonFileInputRef.current.value = "";
setBusyAction(null); setBusyAction(null);
} }
@@ -810,11 +803,11 @@ function App() {
/> />
<ImportPreviewModal <ImportPreviewModal
busy={busyAction === "confirm-excel-import"} busy={busyAction === "confirm-import"}
preview={importPreview} preview={importPreview}
importProgress={importProgress} importProgress={importProgress}
onClose={() => setImportPreview(null)} onClose={() => setImportPreview(null)}
onConfirm={() => void confirmExcelImport()} onConfirm={() => void confirmImportPreview()}
/> />
<ConfirmDialog <ConfirmDialog
+3 -1
View File
@@ -37,7 +37,9 @@ export function ImportPreviewModal({
> >
<div className="mb-5 flex items-start justify-between gap-4"> <div className="mb-5 flex items-start justify-between gap-4">
<div> <div>
<p className="text-sm font-medium uppercase tracking-[0.18em] text-pink-100">Excel import</p> <p className="text-sm font-medium uppercase tracking-[0.18em] text-pink-100">
{preview.fileName.toLowerCase().endsWith(".json") ? "JSON import" : "Excel import"}
</p>
<h2 id="import-preview-title" className="mt-1 text-3xl font-semibold tracking-tight text-white"> <h2 id="import-preview-title" className="mt-1 text-3xl font-semibold tracking-tight text-white">
Preview rows Preview rows
</h2> </h2>
+2 -2
View File
@@ -142,7 +142,7 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
value={canInput} value={canInput}
onChange={(event) => handleCanInputChange(event.target.value)} onChange={(event) => handleCanInputChange(event.target.value)}
/> />
<span className="text-xs text-slate-500">Leave empty to remove. Counts use BST calendar days.</span> <span className="text-xs text-slate-500">Leave empty to remove. Counts use device-local calendar days.</span>
</label> </label>
<label className="grid gap-2 text-sm"> <label className="grid gap-2 text-sm">
@@ -170,7 +170,7 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
value={stopInput} value={stopInput}
onChange={(event) => setStopInput(event.target.value)} onChange={(event) => setStopInput(event.target.value)}
/> />
<span className="text-xs text-slate-500">Europe/London (BST/GMT). Leave empty to remove.</span> <span className="text-xs text-slate-500">Your device's local time. Leave empty to remove.</span>
</label> </label>
{previewParts.length ? ( {previewParts.length ? (
+1 -1
View File
@@ -61,7 +61,7 @@ export function SettingsView({
return ( return (
<div className="grid gap-4 xl:grid-cols-[1fr_0.8fr]"> <div className="grid gap-4 xl:grid-cols-[1fr_0.8fr]">
<div className="grid content-start gap-4"> <div className="grid content-start gap-4">
<AppCard title="Limits" subtitle="Personal caps for cans, spend, and stop time (BST)"> <AppCard title="Limits" subtitle="Personal caps for cans, spend, and stop time (local time)">
<LimitsSettingsForm <LimitsSettingsForm
limits={userLimits} limits={userLimits}
check={limitCheck} check={limitCheck}
+1 -6
View File
@@ -20,12 +20,7 @@ type GreetingResult = {
}; };
export function getBstHour(date = new Date()) { export function getBstHour(date = new Date()) {
const hour = new Intl.DateTimeFormat("en-GB", { return date.getHours();
timeZone: "Europe/London",
hour: "numeric",
hour12: false,
}).format(date);
return Number.parseInt(hour, 10);
} }
export function buildDynamicGreeting(input: GreetingInput): GreetingResult { export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
+28 -1
View File
@@ -1,5 +1,6 @@
import { flavourMeta } from "../data/flavours"; import { flavourMeta } from "../data/flavours";
import type { EntryDraft, RedBullEntry } from "../types"; import { makeImportKey } from "./metrics";
import type { EntryDraft, ImportPreview, ImportPreviewRow, RedBullEntry } from "../types";
export function exportPayload(entries: RedBullEntry[]) { export function exportPayload(entries: RedBullEntry[]) {
return JSON.stringify( return JSON.stringify(
@@ -28,6 +29,32 @@ export function parseImport(raw: string): EntryDraft[] {
return valid; return valid;
} }
export function buildJsonImportPreview(
drafts: EntryDraft[],
existingEntries: RedBullEntry[],
fileName: string,
): ImportPreview {
const seen = new Set(existingEntries.map((entry) => entry.importKey || makeImportKey(entry)));
const rows: ImportPreviewRow[] = drafts.map((draft, index) => {
const key = makeImportKey({
...draft,
dateTime: new Date(draft.dateTime).toISOString(),
notes: draft.notes ?? "",
store: draft.store ?? "",
});
const duplicate = seen.has(key);
if (!duplicate) seen.add(key);
return {
rowNumber: index + 1,
entry: draft,
errors: [],
duplicate,
duplicateReason: duplicate ? "Matches an existing or earlier imported row." : undefined,
};
});
return { fileName, rows };
}
function coerceEntryDraft(value: unknown): EntryDraft | null { function coerceEntryDraft(value: unknown): EntryDraft | null {
if (!value || typeof value !== "object") return null; if (!value || typeof value !== "object") return null;
const entry = value as Partial<RedBullEntry>; const entry = value as Partial<RedBullEntry>;
+7 -21
View File
@@ -1,6 +1,6 @@
import type { EntryDraft, LimitCheckResult, LimitViolation, RedBullEntry, UserLimits } from "../types"; import type { EntryDraft, LimitCheckResult, LimitViolation, RedBullEntry, UserLimits } from "../types";
import { getBstHour } from "./greeting"; import { getBstHour } from "./greeting";
import { currency, spendFor, sum } from "./metrics"; import { currency, formatDateKey, spendFor, sum } from "./metrics";
export const DEFAULT_LIMITS: UserLimits = {}; export const DEFAULT_LIMITS: UserLimits = {};
@@ -59,25 +59,11 @@ export function mergePrefsWithLimits(
} }
export function formatBstDateKey(date = new Date()) { export function formatBstDateKey(date = new Date()) {
return new Intl.DateTimeFormat("en-CA", { return formatDateKey(date);
timeZone: "Europe/London",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
} }
export function getBstMinutes(date = new Date()) { export function getBstMinutes(date = new Date()) {
const parts = new Intl.DateTimeFormat("en-GB", { return date.getHours() * 60 + date.getMinutes();
timeZone: "Europe/London",
hour: "numeric",
minute: "numeric",
hour12: false,
}).formatToParts(date);
const hour = Number(parts.find((part) => part.type === "hour")?.value ?? 0);
const minute = Number(parts.find((part) => part.type === "minute")?.value ?? 0);
return hour * 60 + minute;
} }
export function parseStopTimeMinutes(stopTime: string) { export function parseStopTimeMinutes(stopTime: string) {
@@ -101,7 +87,7 @@ export function formatStopTimeLabel(stopTime: string) {
}).format(date); }).format(date);
} }
function entriesTodayBst(entries: RedBullEntry[], ref = new Date()) { function entriesToday(entries: RedBullEntry[], ref = new Date()) {
const key = formatBstDateKey(ref); const key = formatBstDateKey(ref);
return entries.filter((entry) => formatBstDateKey(new Date(entry.dateTime)) === key); return entries.filter((entry) => formatBstDateKey(new Date(entry.dateTime)) === key);
} }
@@ -111,7 +97,7 @@ function spendForDraft(draft: EntryDraft) {
} }
function todayTotals(entries: RedBullEntry[], excludeEntryId?: string, ref = new Date()) { function todayTotals(entries: RedBullEntry[], excludeEntryId?: string, ref = new Date()) {
const todayEntries = entriesTodayBst(entries, ref).filter((entry) => entry.id !== excludeEntryId); const todayEntries = entriesToday(entries, ref).filter((entry) => entry.id !== excludeEntryId);
return { return {
todayCans: sum(todayEntries, (entry) => entry.cans), todayCans: sum(todayEntries, (entry) => entry.cans),
todaySpend: sum(todayEntries, spendFor), todaySpend: sum(todayEntries, spendFor),
@@ -171,7 +157,7 @@ export function limitStatusMessage(
if (violations.includes("cans") && limits.dailyCanLimit != null) { if (violations.includes("cans") && limits.dailyCanLimit != null) {
lines.push( lines.push(
`This would bring you to ${check.projectedCans.toFixed(1)}/${limits.dailyCanLimit} cans today (BST).`, `This would bring you to ${check.projectedCans.toFixed(1)}/${limits.dailyCanLimit} cans today.`,
); );
} }
@@ -182,7 +168,7 @@ export function limitStatusMessage(
} }
if (violations.includes("stopTime") && limits.stopTime) { if (violations.includes("stopTime") && limits.stopTime) {
lines.push(`You're past your stop time (${formatStopTimeLabel(limits.stopTime)} BST).`); lines.push(`You're past your stop time (${formatStopTimeLabel(limits.stopTime)}).`);
} }
return lines.join(" "); return lines.join(" ");