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