Skip to content

Commit 289b504

Browse files
committed
Manually applied two PRs
1 parent 2959c43 commit 289b504

8 files changed

Lines changed: 126 additions & 35 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@churchapps/helpers": major
3+
---
4+
5+
CurrencyHelper now caches exchange rates in memory. Call `initializeExchangeRates()` once at app start; `convertDonation`, `convertDonationTotals`, `convertAmount`, and `convertAmountWithLocale` then read the cached rates and no longer take a `rates` argument (breaking). Also: the localStorage rate cache is keyed per base currency, `convertDonation` gains an optional `withCurrencyLabel` flag, conversions round to 2 decimals, and `convertAmountWithLocale` is now synchronous.

.changeset/dropbox-numeric-sort.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@churchapps/content-providers": patch
3+
---
4+
5+
Dropbox provider now sorts folder contents by name numeric-aware, so lesson media with 01/02/03-style prefixes list and import in order ("2 Song" before "10 Closer") instead of Dropbox's arbitrary order.

apphelper/public/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@
354354
},
355355
"wrapper": {
356356
"chatWith": "Chat with",
357+
"currentChurch": "Current church",
357358
"deleteChurch": "Delete",
358359
"editAccount": "Edit Account",
359360
"editChurchProfile": "Edit Church Profile",

apphelper/src/components/wrapper/UserMenu.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,17 @@ const UserMenuContent: React.FC<Props> = React.memo((props) => {
105105
const churchId = UserHelper.currentUserChurch.church.id;
106106
const result: React.ReactElement[] = [];
107107

108+
const churchName = props.context?.userChurch?.church?.name;
109+
if (churchName) {
110+
result.push(
111+
<Box key="currentChurch" sx={{ px: 2, py: 1 }}>
112+
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>{getLabel("wrapper.currentChurch", "Current church")}</Typography>
113+
<Typography variant="body2" sx={{ fontWeight: 600 }}>{churchName}</Typography>
114+
</Box>
115+
);
116+
result.push(<div key="church-divider" style={{ borderTop: "1px solid #CCC", marginBottom: 4 }}></div>);
117+
}
118+
108119
result.push(<NavItem onClick={() => { modalStateStore.setShowPM(true); }} label={getLabel("wrapper.messages", "Messages")} icon="mail" key="/messages" onNavigate={props.onNavigate} badgeCount={directNotificationCounts.pmCount} />);
109120

110121
result.push(<NavItem onClick={() => { modalStateStore.setShowNotifications(true); }} label={getLabel("wrapper.notifications", "Notifications")} icon="notifications" key="/notifications" onNavigate={props.onNavigate} badgeCount={directNotificationCounts.notificationCount} />);

content-providers/src/providers/dropbox/DropboxConverters.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,10 @@ export function filterMediaEntries(entries: DropboxEntry[]): { folders: DropboxF
2424
if (entry[".tag"] === "folder") folders.push(entry);
2525
else if (entry[".tag"] === "file" && isMediaFile(entry.name)) mediaFiles.push(entry);
2626
}
27+
// Dropbox returns entries in arbitrary order; sort by name numeric-aware so 01/02/03-style
28+
// prefixes order naturally ("2 Song" before "10 Closer") instead of lexically.
29+
const byName = (a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
30+
folders.sort(byName);
31+
mediaFiles.sort(byName);
2732
return { folders, mediaFiles };
2833
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
import { filterMediaEntries } from "../src/providers/dropbox/DropboxConverters";
5+
import { DropboxEntry } from "../src/providers/dropbox/DropboxInterfaces";
6+
7+
const file = (name: string): DropboxEntry => ({ ".tag": "file", id: "id:" + name, name, path_lower: "/" + name.toLowerCase(), path_display: "/" + name, size: 100, is_downloadable: true });
8+
const folder = (name: string): DropboxEntry => ({ ".tag": "folder", id: "id:" + name, name, path_lower: "/" + name.toLowerCase(), path_display: "/" + name });
9+
10+
test("filterMediaEntries orders media files numeric-aware, not lexically", () => {
11+
const { mediaFiles } = filterMediaEntries([file("11 Closer.png"), file("2 Song.png"), file("01 Opener.png")]);
12+
assert.deepEqual(mediaFiles.map((f) => f.name), ["01 Opener.png", "2 Song.png", "11 Closer.png"]);
13+
});
14+
15+
test("filterMediaEntries sorts folders independently of files and drops non-media files", () => {
16+
const { folders, mediaFiles } = filterMediaEntries([
17+
folder("10 Week"),
18+
folder("2 Week"),
19+
file("notes.txt"),
20+
file("3 Clip.mp4"),
21+
file("1 Clip.mp4")
22+
]);
23+
assert.deepEqual(folders.map((f) => f.name), ["2 Week", "10 Week"]);
24+
assert.deepEqual(mediaFiles.map((f) => f.name), ["1 Clip.mp4", "3 Clip.mp4"]);
25+
});

helpers/src/CurrencyHelper.ts

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ type RatesCache = {
99
export class CurrencyHelper {
1010
static CACHE_KEY = "exchange_rates_cache";
1111
static CACHE_EXPIRATION = 12 * 60 * 60 * 1000; // 12 hours
12+
// Rates for the church's base currency, cached in memory by initializeExchangeRates()
13+
// so conversions don't each have to fetch or be handed a rates map.
14+
static rates: Record<string, number> = {};
15+
static currentBase = "";
1216

1317
static loadCurrency = async () => {
1418
const gateways = await ApiHelper.get("/gateways", "GivingApi");
@@ -92,10 +96,21 @@ export class CurrencyHelper {
9296
return currencyLocaleMap[currency] || "en-US";
9397
}
9498

99+
// Populate the in-memory rate cache for the church's base currency. Call once at app start;
100+
// it no-ops when the base currency is unchanged and rates are already loaded.
101+
static async initializeExchangeRates() {
102+
const baseCurrency = await this.loadCurrency();
103+
if (baseCurrency === this.currentBase && Object.keys(this.rates).length > 0) return;
104+
this.currentBase = baseCurrency;
105+
this.rates = await this.getExchangeRates(baseCurrency);
106+
}
107+
95108
static async getExchangeRates(
96109
baseCurrency: string
97110
): Promise<Record<string, number>> {
98-
const cached = localStorage.getItem(this.CACHE_KEY);
111+
// Keyed per base currency so switching bases doesn't return the wrong cached rates.
112+
const cacheKey = `${this.CACHE_KEY}_${baseCurrency}`;
113+
const cached = localStorage.getItem(cacheKey);
99114

100115
if (cached) {
101116
const parsed: RatesCache = JSON.parse(cached);
@@ -117,47 +132,42 @@ export class CurrencyHelper {
117132
timestamp: Date.now()
118133
};
119134

120-
localStorage.setItem(this.CACHE_KEY, JSON.stringify(cache));
135+
localStorage.setItem(cacheKey, JSON.stringify(cache));
121136

122137
return data.rates;
123138
}
124139

125140
static convertDonation(
126141
donation: { currency: string; amount: number },
127-
rates: Record<string, number>,
128-
targetCurrency: string
142+
targetCurrency: string,
143+
withCurrencyLabel: boolean = true
129144
) {
130145
const converted = this.convertAmount(
131146
Number(donation.amount || 0),
132147
donation.currency?.toUpperCase() || "USD",
133-
targetCurrency,
134-
rates
148+
targetCurrency
135149
);
136150

151+
if (!withCurrencyLabel) return Number(converted).toFixed(2);
152+
137153
return this.formatCurrencyWithLocale(converted, targetCurrency);
138154
}
139155

140156
static convertDonationTotals(
141157
donations: { currency: string; amount: number }[],
142-
rates: Record<string, number>,
143158
targetCurrency: string
144159
) {
145160
const grouped: Record<string, number> = {};
146161

147162
donations.forEach((donation) => {
148163
const currency = donation.currency?.toUpperCase() || "USD";
149-
150-
if (!grouped[currency]) {
151-
grouped[currency] = 0;
152-
}
153-
154-
grouped[currency] += Number(donation.amount || 0);
164+
grouped[currency] = (grouped[currency] || 0) + Number(donation.amount || 0);
155165
});
156166

157167
let total = 0;
158168

159169
Object.entries(grouped).forEach(([currency, amount]) => {
160-
total += this.convertAmount(amount, currency, targetCurrency, rates);
170+
total += this.convertAmount(amount, currency, targetCurrency);
161171
});
162172

163173
return this.formatCurrencyWithLocale(total, targetCurrency);
@@ -166,37 +176,33 @@ export class CurrencyHelper {
166176
static convertAmount(
167177
amount: number,
168178
fromCurrency: string,
169-
toCurrency: string,
170-
rates: Record<string, number>
179+
toCurrency: string
171180
): number {
172-
const from = fromCurrency.toUpperCase();
173-
const to = toCurrency.toUpperCase();
181+
const from = (fromCurrency || "USD").toUpperCase();
182+
const to = (toCurrency || "USD").toUpperCase();
174183

175184
if (from === to) {
176185
return amount;
177186
}
178187

179-
const rate = rates[from];
188+
const rate = this.rates[from];
180189

181190
if (!rate) {
182191
return amount;
183192
}
184193

185-
return amount / rate;
194+
return Number((amount / rate).toFixed(2));
186195
}
187196

188-
//this is just temporary, we can remove this later
189-
static async convertAmountWithLocale(
197+
static convertAmountWithLocale(
190198
amount: number,
191199
donationCurrency: string,
192-
selectedCurrency: string,
193-
rates: Record<string, number>
200+
selectedCurrency: string
194201
) {
195202
const converted = this.convertAmount(
196203
amount,
197204
donationCurrency,
198-
selectedCurrency,
199-
rates
205+
selectedCurrency
200206
);
201207

202208
return this.formatCurrencyWithLocale(converted, selectedCurrency);

helpers/tests/currencyHelper.test.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,28 +27,61 @@ test("formatCurrencyWithLocale renders amount with the currency's symbol, not it
2727
});
2828

2929
test("convertAmount returns the input amount unchanged when currencies match or the rate is missing", () => {
30-
assert.equal(CurrencyHelper.convertAmount(50, "usd", "USD", {}), 50);
31-
assert.equal(CurrencyHelper.convertAmount(50, "usd", "eur", {}), 50);
30+
CurrencyHelper.rates = {};
31+
assert.equal(CurrencyHelper.convertAmount(50, "usd", "USD"), 50);
32+
assert.equal(CurrencyHelper.convertAmount(50, "usd", "eur"), 50);
3233
});
3334

34-
test("convertAmount divides by the target's rate relative to the base currency", () => {
35-
// rates keyed by the *source* currency here, per convertAmount's `rates[from]` lookup
36-
const result = CurrencyHelper.convertAmount(100, "usd", "eur", { USD: 2 });
37-
assert.equal(result, 50);
35+
test("convertAmount divides by the source currency's cached rate and rounds to 2 decimals", () => {
36+
CurrencyHelper.rates = { USD: 2 };
37+
assert.equal(CurrencyHelper.convertAmount(100, "usd", "eur"), 50);
38+
// 100 / 3 = 33.333... rounds to 33.33
39+
CurrencyHelper.rates = { USD: 3 };
40+
assert.equal(CurrencyHelper.convertAmount(100, "usd", "eur"), 33.33);
3841
});
3942

40-
test("convertDonation converts and formats a single donation using its own currency", () => {
41-
const formatted = CurrencyHelper.convertDonation({ currency: "usd", amount: 100 }, { USD: 2 }, "eur");
43+
test("convertAmount tolerates a null/undefined currency instead of throwing", () => {
44+
CurrencyHelper.rates = {};
45+
assert.equal(CurrencyHelper.convertAmount(50, undefined as unknown as string, "eur"), 50);
46+
assert.equal(CurrencyHelper.convertAmount(50, "eur", null as unknown as string), 50);
47+
});
48+
49+
test("convertDonation converts and formats a single donation using the cached rates", () => {
50+
CurrencyHelper.rates = { USD: 2 };
51+
const formatted = CurrencyHelper.convertDonation({ currency: "usd", amount: 100 }, "eur");
4252
assert.ok(formatted.includes("€"), formatted);
53+
assert.ok(formatted.includes("50"), formatted);
54+
});
55+
56+
test("convertDonation with withCurrencyLabel=false returns a bare 2-decimal amount", () => {
57+
CurrencyHelper.rates = { USD: 2 };
58+
assert.equal(CurrencyHelper.convertDonation({ currency: "usd", amount: 100 }, "eur", false), "50.00");
4359
});
4460

4561
test("convertDonationTotals groups by currency before converting and summing", () => {
62+
CurrencyHelper.rates = { USD: 2 };
4663
const donations = [
4764
{ currency: "usd", amount: 100 },
4865
{ currency: "usd", amount: 50 },
4966
{ currency: "eur", amount: 10 }
5067
];
5168
// usd group (150) / rate 2 = 75, plus eur group (10, same-currency passthrough) = 85
52-
const formatted = CurrencyHelper.convertDonationTotals(donations, { USD: 2 }, "eur");
69+
const formatted = CurrencyHelper.convertDonationTotals(donations, "eur");
5370
assert.ok(formatted.includes("85"), formatted);
5471
});
72+
73+
test("initializeExchangeRates caches rates once and no-ops while the base is unchanged", async (t) => {
74+
CurrencyHelper.rates = {};
75+
CurrencyHelper.currentBase = "";
76+
t.mock.method(CurrencyHelper, "loadCurrency", async () => "usd");
77+
const getRates = t.mock.method(CurrencyHelper, "getExchangeRates", async () => ({ EUR: 0.9 }));
78+
79+
await CurrencyHelper.initializeExchangeRates();
80+
assert.deepEqual(CurrencyHelper.rates, { EUR: 0.9 });
81+
assert.equal(CurrencyHelper.currentBase, "usd");
82+
assert.equal(getRates.mock.callCount(), 1);
83+
84+
// Same base + rates already loaded → should short-circuit without refetching.
85+
await CurrencyHelper.initializeExchangeRates();
86+
assert.equal(getRates.mock.callCount(), 1);
87+
});

0 commit comments

Comments
 (0)