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:
+10
-17
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(" ");
|
||||
|
||||
Reference in New Issue
Block a user