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,
createEntry,
deleteEntry as deleteEntryDocument,
isDuplicateDraft,
listEntries,
updateEntry,
} from "./lib/appwriteEntries";
@@ -52,7 +51,7 @@ import {
} from "./lib/userLimits";
import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel";
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 type {
AppView,
@@ -547,18 +546,18 @@ function App() {
}
}
async function confirmExcelImport() {
async function confirmImportPreview() {
if (!user || !importPreview) return;
const drafts = importPreview.rows
.filter((row) => row.entry && !row.errors.length && !row.duplicate)
.map((row) => row.entry as EntryDraft);
if (!drafts.length) {
setNotice("No valid new Excel rows to import.");
setNotice("No valid new rows to import.");
return;
}
setBusyAction("confirm-excel-import");
setBusyAction("confirm-import");
setSyncError("");
importInFlightRef.current = true;
setImportProgress({ done: 0, total: drafts.length });
@@ -566,7 +565,7 @@ function App() {
const saved = await createEntries(user.$id, drafts, (done, total) => setImportProgress({ done, total }));
setEntries((current) => sortEntries([...saved, ...current]));
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) {
setSyncError(appwriteErrorMessage(error));
} finally {
@@ -588,18 +587,12 @@ function App() {
setSyncError("");
try {
const drafts = parseImport(await file.text());
const uniqueDrafts = drafts.filter((draft) => !isDuplicateDraft(entries, draft));
if (!uniqueDrafts.length) {
setNotice("No new JSON entries found.");
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.`);
const preview = buildJsonImportPreview(drafts, entries, file.name);
setImportPreview(preview);
setNotice(`${preview.rows.length} JSON row${preview.rows.length === 1 ? "" : "s"} parsed for review.`);
} catch (error) {
setSyncError(error instanceof Error ? error.message : "JSON import failed.");
} finally {
importInFlightRef.current = false;
if (jsonFileInputRef.current) jsonFileInputRef.current.value = "";
setBusyAction(null);
}
@@ -810,11 +803,11 @@ function App() {
/>
<ImportPreviewModal
busy={busyAction === "confirm-excel-import"}
busy={busyAction === "confirm-import"}
preview={importPreview}
importProgress={importProgress}
onClose={() => setImportPreview(null)}
onConfirm={() => void confirmExcelImport()}
onConfirm={() => void confirmImportPreview()}
/>
<ConfirmDialog
+3 -1
View File
@@ -37,7 +37,9 @@ export function ImportPreviewModal({
>
<div className="mb-5 flex items-start justify-between gap-4">
<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">
Preview rows
</h2>
+2 -2
View File
@@ -142,7 +142,7 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
value={canInput}
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 className="grid gap-2 text-sm">
@@ -170,7 +170,7 @@ export function LimitsSettingsForm({ limits, check, saving, onSave }: LimitsSett
value={stopInput}
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>
{previewParts.length ? (
+1 -1
View File
@@ -61,7 +61,7 @@ export function SettingsView({
return (
<div className="grid gap-4 xl:grid-cols-[1fr_0.8fr]">
<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
limits={userLimits}
check={limitCheck}
+1 -6
View File
@@ -20,12 +20,7 @@ type GreetingResult = {
};
export function getBstHour(date = new Date()) {
const hour = new Intl.DateTimeFormat("en-GB", {
timeZone: "Europe/London",
hour: "numeric",
hour12: false,
}).format(date);
return Number.parseInt(hour, 10);
return date.getHours();
}
export function buildDynamicGreeting(input: GreetingInput): GreetingResult {
+28 -1
View File
@@ -1,5 +1,6 @@
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[]) {
return JSON.stringify(
@@ -28,6 +29,32 @@ export function parseImport(raw: string): EntryDraft[] {
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 {
if (!value || typeof value !== "object") return null;
const entry = value as Partial<RedBullEntry>;
+7 -21
View File
@@ -1,6 +1,6 @@
import type { EntryDraft, LimitCheckResult, LimitViolation, RedBullEntry, UserLimits } from "../types";
import { getBstHour } from "./greeting";
import { currency, spendFor, sum } from "./metrics";
import { currency, formatDateKey, spendFor, sum } from "./metrics";
export const DEFAULT_LIMITS: UserLimits = {};
@@ -59,25 +59,11 @@ export function mergePrefsWithLimits(
}
export function formatBstDateKey(date = new Date()) {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/London",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
return formatDateKey(date);
}
export function getBstMinutes(date = new Date()) {
const parts = new Intl.DateTimeFormat("en-GB", {
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;
return date.getHours() * 60 + date.getMinutes();
}
export function parseStopTimeMinutes(stopTime: string) {
@@ -101,7 +87,7 @@ export function formatStopTimeLabel(stopTime: string) {
}).format(date);
}
function entriesTodayBst(entries: RedBullEntry[], ref = new Date()) {
function entriesToday(entries: RedBullEntry[], ref = new Date()) {
const key = formatBstDateKey(ref);
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()) {
const todayEntries = entriesTodayBst(entries, ref).filter((entry) => entry.id !== excludeEntryId);
const todayEntries = entriesToday(entries, ref).filter((entry) => entry.id !== excludeEntryId);
return {
todayCans: sum(todayEntries, (entry) => entry.cans),
todaySpend: sum(todayEntries, spendFor),
@@ -171,7 +157,7 @@ export function limitStatusMessage(
if (violations.includes("cans") && limits.dailyCanLimit != null) {
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) {
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(" ");