Fix login hang after PIN + enrich Banquet mode
Some checks failed
Build Android APK / build-apk (push) Failing after 57m31s

- Auth: introduce a shared AuthProvider/useAuth so entering the PIN updates
  reactive state and the layout's gate navigates immediately (previously it
  cached the token check at startup, so login appeared to hang until a manual
  refresh). login/scanner now use signIn/signOut.
- Banquet: donor lookup now returns lifetime giving, last-12-months giving
  (computed from dated transactions), member/donor status, bear name, and tags.
  The banquet result screen shows status badge + lifetime and last-year figures.
  Tickets are irrelevant in banquet mode.
- Admin: fix "‹ Scanner" back link wrapping (remove fixed width).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-08 16:01:41 +00:00
parent dc39c41428
commit 1ca2c38fbf
7 changed files with 268 additions and 90 deletions

View file

@ -4,17 +4,22 @@ export interface DonorLookup {
found: boolean;
email: string;
name: string;
online: number;
offline: number;
total: number;
bearName: string;
lifetime: number; // total all-time giving
lastYear: number; // giving in the last 12 months
online: number; // lifetime online
offline: number; // lifetime offline
isMember: boolean; // appears to be a current member
tags: string[];
status: string; // short human status line
source: "master" | "transactions" | "none";
}
/**
* Looks up a donor's total giving for Banquet mode. Primary source is the
* "Donors Master List" (which carries pre-rolled Total Donations / Total
* Online / Total Offline). Falls back to summing the online + offline
* transaction tables by email when the donor isn't in the master list.
* Looks up a donor for Banquet mode. Lifetime totals come from the "Donors
* Master List" (authoritative pre-rolled totals); the last-12-months figure is
* computed from the online + offline transaction tables (the only source with
* dates). Tickets are irrelevant here banquet only cares about giving.
*/
export class DonorService {
private readonly base: string;
@ -59,44 +64,97 @@ export class DonorService {
async lookup(rawEmail: string): Promise<DonorLookup> {
const email = rawEmail.trim();
const esc = email.replace(/[(),]/g, " ");
const empty: DonorLookup = { found: false, email, name: "", online: 0, offline: 0, total: 0, source: "none" };
const empty: DonorLookup = {
found: false,
email,
name: "",
bearName: "",
lifetime: 0,
lastYear: 0,
online: 0,
offline: 0,
isMember: false,
tags: [],
status: "",
source: "none",
};
if (!email) return empty;
// 1) Master list (authoritative rolled-up totals), matching either email.
if (this.masterId) {
const rows = await this.list(
this.masterId,
`(Email,eq,${esc})~or(Alternate Email,eq,${esc})`,
1,
);
if (rows.length) {
const r = rows[0];
const online = num(r["Total Online Donations"]);
const offline = num(r["Total Offline Donations"]);
const total = r["Total Donations"] !== undefined ? num(r["Total Donations"]) : online + offline;
const name =
r["Display Name"] ||
[r["First Name"], r["Last Name"]].filter(Boolean).join(" ") ||
r["Bear Name"] ||
"";
return { found: true, email, name, online, offline, total, source: "master" };
}
// Always pull transactions (needed for the last-12-months figure and as a
// lifetime fallback). Runs in parallel with the master-list lookup.
const txnP: Promise<{ online: any[]; offline: any[] }> =
this.onlineId && this.offlineId
? Promise.all([
this.list(this.onlineId, `(Email,eq,${esc})`, 500),
this.list(this.offlineId, `(Email,eq,${esc})`, 500),
]).then(([online, offline]) => ({ online, offline }))
: Promise.resolve({ online: [], offline: [] });
const masterP: Promise<any | null> = this.masterId
? this.list(this.masterId, `(Email,eq,${esc})~or(Alternate Email,eq,${esc})`, 1).then((r) => r[0] ?? null)
: Promise.resolve(null);
const [{ online: onlineRows, offline: offlineRows }, master] = await Promise.all([txnP, masterP]);
// Last 12 months, summed from dated transactions (Paid or unspecified).
const cutoff = new Date();
cutoff.setFullYear(cutoff.getFullYear() - 1);
const lastYear =
sumSince(onlineRows, "Donation Amount", "Donation Date", cutoff) +
sumSince(offlineRows, "Donation Amount", "Donation Date", cutoff);
const txnOnline = sumPaid(onlineRows, "Donation Amount");
const txnOffline = sumPaid(offlineRows, "Donation Amount");
if (master) {
const online = master["Total Online Donations"] !== undefined ? num(master["Total Online Donations"]) : txnOnline;
const offline =
master["Total Offline Donations"] !== undefined ? num(master["Total Offline Donations"]) : txnOffline;
const lifetime =
master["Total Donations"] !== undefined ? num(master["Total Donations"]) : online + offline;
const name =
master["Display Name"] ||
[master["First Name"], master["Last Name"]].filter(Boolean).join(" ") ||
master["Bear Name"] ||
"";
const tags = splitTags(master["Tags"]);
const upcoming = String(master["Upcoming Rewards"] ?? "");
const isMember = /member/i.test(upcoming) || tags.some((t) => /member/i.test(t));
return {
found: true,
email,
name,
bearName: String(master["Bear Name"] ?? ""),
lifetime,
lastYear,
online,
offline,
isMember,
tags,
status: isMember ? "Member" : "Donor",
source: "master",
};
}
// 2) Fallback: sum transaction tables by email.
if (this.onlineId && this.offlineId) {
const [onlineRows, offlineRows] = await Promise.all([
this.list(this.onlineId, `(Email,eq,${esc})`, 200),
this.list(this.offlineId, `(Email,eq,${esc})`, 200),
]);
const online = sum(onlineRows, "Donation Amount");
const offline = sum(offlineRows, "Donation Amount");
const name = onlineRows[0]?.["Bear Name"] || onlineRows[0]?.["Name"] || offlineRows[0]?.["Name"] || "";
const found = online + offline > 0 || onlineRows.length + offlineRows.length > 0;
return { found, email, name, online, offline, total: online + offline, source: found ? "transactions" : "none" };
}
return empty;
// Not in the master list: build from transactions alone.
const found = txnOnline + txnOffline > 0 || onlineRows.length + offlineRows.length > 0;
if (!found) return empty;
const name = onlineRows[0]?.["Name"] || offlineRows[0]?.["Name"] || "";
const bearName = onlineRows[0]?.["Bear Name"] || offlineRows[0]?.["Bear Name"] || "";
return {
found: true,
email,
name,
bearName,
lifetime: txnOnline + txnOffline,
lastYear,
online: txnOnline,
offline: txnOffline,
isMember: false,
tags: [],
status: "Donor",
source: "transactions",
};
}
}
@ -105,6 +163,30 @@ function num(v: unknown): number {
return Number.isFinite(n) ? n : 0;
}
function sum(rows: any[], field: string): number {
return rows.reduce((acc, r) => acc + num(r[field]), 0);
// Count a transaction unless it's explicitly not paid (refunded/failed/pending).
function isPaid(row: any): boolean {
const s = String(row["Payment Status"] ?? "").trim();
if (!s) return true;
return /paid|complete|success/i.test(s);
}
function sumPaid(rows: any[], amountField: string): number {
return rows.reduce((acc, r) => (isPaid(r) ? acc + num(r[amountField]) : acc), 0);
}
function sumSince(rows: any[], amountField: string, dateField: string, cutoff: Date): number {
return rows.reduce((acc, r) => {
if (!isPaid(r)) return acc;
const raw = r[dateField];
if (!raw) return acc;
const d = new Date(raw);
if (isNaN(d.getTime()) || d < cutoff) return acc;
return acc + num(r[amountField]);
}, 0);
}
function splitTags(v: unknown): string[] {
if (Array.isArray(v)) return v.map((x) => String(x)).filter(Boolean);
if (typeof v === "string") return v.split(",").map((s) => s.trim()).filter(Boolean);
return [];
}