Skip to content

Commit 925897a

Browse files
committed
fix: don't just say USD when no conversion has taken place
1 parent c8f282b commit 925897a

2 files changed

Lines changed: 152 additions & 61 deletions

File tree

packages/econify/src/batch/batch.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -640,9 +640,24 @@ function processItem<T extends BatchItem>(
640640
const scale = targetMagnitude ?? "ones";
641641
normalizedUnit = scale === "ones" ? "ones" : titleCase(scale);
642642
} else {
643+
// Choose label currency for units:
644+
// - If FX would occur (known currency, target set, fx available and different), use target currency
645+
// - Else, use known effective currency (if any)
646+
const effectiveCurrencyKnown = (effectiveCurrency &&
647+
effectiveCurrency !== "UNKNOWN")
648+
? effectiveCurrency
649+
: undefined;
650+
const willConvert = !shouldSkipCurrency &&
651+
!!effectiveCurrencyKnown &&
652+
!!options.toCurrency &&
653+
!!options.fx &&
654+
effectiveCurrencyKnown !== options.toCurrency;
655+
const labelCurrency = willConvert
656+
? options.toCurrency
657+
: effectiveCurrencyKnown;
643658
normalizedUnit = buildNormalizedUnit(
644659
item.unit,
645-
options.toCurrency,
660+
labelCurrency,
646661
targetMagnitude,
647662
options.toTimeScale,
648663
indicatorType,
@@ -716,7 +731,27 @@ function buildNormalizedUnit(
716731
): string {
717732
const parsed = parseUnit(original);
718733

719-
const cur = (currency || parsed.currency)?.toUpperCase();
734+
// Special-case placeholder currencies (e.g., "National currency"): preserve original label,
735+
// add magnitude if any, and append time dimension when appropriate.
736+
if (parsed.currency === "UNKNOWN") {
737+
const mag = magnitude ?? parsed.scale ?? getScale(original);
738+
const ts = timeScale ?? parsed.timeScale;
739+
const parts: string[] = [original];
740+
if (mag && mag !== "ones") parts.push(String(mag));
741+
let out = parts.join(" ");
742+
const shouldIncludeTime = ts &&
743+
allowsTimeConversion(indicatorType, temporalAggregation);
744+
if (shouldIncludeTime) {
745+
out = `${out}${out ? " " : ""}per ${ts}`;
746+
}
747+
return out || original;
748+
}
749+
750+
// Sanitize provided currency: ignore placeholder values
751+
const provided = currency && currency.toUpperCase() !== "UNKNOWN"
752+
? currency
753+
: undefined;
754+
const cur = (provided || parsed.currency)?.toUpperCase();
720755
// Fallback to detect scale from unit text when parser misses singular forms (e.g., "Thousand")
721756
const mag = magnitude ?? parsed.scale ?? getScale(original);
722757
const ts = timeScale ?? parsed.timeScale;

packages/econify/src/normalization/explain.ts

Lines changed: 115 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export function buildExplainMetadata(
4343

4444
// Use explicit fields if provided, otherwise fall back to parsed values
4545
const effectiveCurrency = options.explicitCurrency || parsed.currency;
46+
const hasKnownCurrency = !!effectiveCurrency &&
47+
effectiveCurrency !== "UNKNOWN";
4648
const effectiveScale = options.explicitScale || parsed.scale;
4749

4850
// Time scale priority:
@@ -61,7 +63,7 @@ export function buildExplainMetadata(
6163

6264
// FX information
6365
if (
64-
effectiveCurrency && options.toCurrency && options.fx &&
66+
hasKnownCurrency && options.toCurrency && options.fx &&
6567
effectiveCurrency !== options.toCurrency
6668
) {
6769
const rate = options.fx.rates[effectiveCurrency];
@@ -79,6 +81,9 @@ export function buildExplainMetadata(
7981
}
8082
}
8183

84+
// Determine if FX conversion actually occurred (based on presence of explain.fx)
85+
const didFX = !!explain.fx;
86+
8287
// Magnitude information - only provide when scaling actually occurs
8388
const originalScale = effectiveScale || getScale(originalUnit);
8489
const targetScale = options.toMagnitude || originalScale;
@@ -257,74 +262,123 @@ export function buildExplainMetadata(
257262
}
258263
} else {
259264
// Monetary/currency-based units (default behavior)
260-
originalUnitString = buildOriginalUnitString(
261-
effectiveCurrency,
262-
originalScale,
263-
);
264-
// For monetary units, only preserve original time scale if it was explicitly in the unit string
265-
// (not just in metadata periodicity). This prevents adding "per month" to stock indicators.
266-
const hasTimeInUnit = !!parsed.timeScale;
267-
268-
// Use target time scale unless conversion was explicitly blocked
269-
// Check if time conversion was blocked (not just "no conversion needed")
270-
const timeWasBlocked = explain.periodicity?.adjusted === false &&
271-
explain.periodicity?.description?.includes("blocked");
272-
const effectiveTargetTime: TimeScale | undefined = timeWasBlocked
273-
? (hasTimeInUnit && originalTimeScale ? originalTimeScale : undefined)
274-
: (options.toTimeScale ||
275-
(hasTimeInUnit && originalTimeScale ? originalTimeScale : undefined));
276-
277-
normalizedUnitString = buildNormalizedUnitString(
278-
options.toCurrency || effectiveCurrency,
279-
targetScale,
280-
effectiveTargetTime,
281-
);
282-
283-
// Build full unit strings with time periods
284-
originalFullUnit = buildFullUnitString(
285-
effectiveCurrency,
286-
originalScale,
287-
originalTimeScale || undefined,
288-
);
289-
normalizedFullUnit = buildFullUnitString(
290-
options.toCurrency || effectiveCurrency,
291-
targetScale,
292-
effectiveTargetTime,
293-
) || normalizedUnitString;
294-
295-
// Per-capita: avoid adding scale label like 'millions' to currency units
296-
if (isPerCapita) {
297-
normalizedUnitString = buildNormalizedUnitString(
298-
options.toCurrency || effectiveCurrency,
299-
"ones",
300-
options.toTimeScale,
265+
// Special handling for placeholder currency (e.g., "National currency") with no FX:
266+
// Present units using the original label without injecting a target currency.
267+
if (!hasKnownCurrency && !didFX) {
268+
const base = originalUnit;
269+
// For placeholder currencies, reflect target time scale if provided
270+
if (options.toTimeScale) {
271+
originalUnitString = base;
272+
normalizedUnitString = `${base} per ${options.toTimeScale}`;
273+
originalFullUnit = originalUnitString;
274+
normalizedFullUnit = normalizedUnitString;
275+
} else {
276+
originalUnitString = base;
277+
normalizedUnitString = base;
278+
originalFullUnit = base;
279+
normalizedFullUnit = base;
280+
}
281+
} else {
282+
// Known currency or FX occurred: build currency-based labels normally
283+
originalUnitString = buildOriginalUnitString(
284+
effectiveCurrency,
285+
originalScale,
301286
);
302-
normalizedFullUnit = buildFullUnitString(
303-
options.toCurrency || effectiveCurrency,
304-
"ones",
305-
options.toTimeScale,
306-
) || normalizedUnitString;
307-
}
287+
// For monetary units, only preserve original time scale if it was explicitly in the unit string
288+
// (not just in metadata periodicity). This prevents adding "per month" to stock indicators.
289+
const hasTimeInUnit = !!parsed.timeScale;
290+
291+
// Use target time scale unless conversion was explicitly blocked
292+
// Check if time conversion was blocked (not just "no conversion needed")
293+
const timeWasBlocked = explain.periodicity?.adjusted === false &&
294+
explain.periodicity?.description?.includes("blocked");
295+
const effectiveTargetTime: TimeScale | undefined = timeWasBlocked
296+
? (hasTimeInUnit && originalTimeScale ? originalTimeScale : undefined)
297+
: (options.toTimeScale ||
298+
(hasTimeInUnit && originalTimeScale ? originalTimeScale : undefined));
299+
300+
// Choose label currency:
301+
// - If FX occurred, use target currency
302+
// - Else, use the known effective currency
303+
const labelCurrency = didFX
304+
? options.toCurrency
305+
: (effectiveCurrency || undefined);
308306

309-
// Indicators with skipTimeInUnit: omit per-time in unit strings
310-
// This includes: stock, balance, capacity, price, percentage, ratio, rate, index, etc.
311-
if (skipTimeInUnitString) {
312307
normalizedUnitString = buildNormalizedUnitString(
313-
options.toCurrency || effectiveCurrency,
308+
labelCurrency,
314309
targetScale,
315-
undefined,
310+
effectiveTargetTime,
316311
);
312+
313+
// Build full unit strings with time periods
317314
originalFullUnit = buildFullUnitString(
318315
effectiveCurrency,
319316
originalScale,
320-
undefined,
317+
originalTimeScale || undefined,
321318
);
322319
normalizedFullUnit = buildFullUnitString(
323-
options.toCurrency || effectiveCurrency,
320+
labelCurrency,
324321
targetScale,
325-
undefined,
322+
effectiveTargetTime,
326323
) || normalizedUnitString;
327324
}
325+
326+
// Per-capita: avoid adding scale label like 'millions' to currency units
327+
if (isPerCapita) {
328+
const perCapitaCurrency = didFX
329+
? options.toCurrency
330+
: (effectiveCurrency || undefined);
331+
if (perCapitaCurrency) {
332+
normalizedUnitString = buildNormalizedUnitString(
333+
perCapitaCurrency,
334+
"ones",
335+
options.toTimeScale,
336+
);
337+
normalizedFullUnit = buildFullUnitString(
338+
perCapitaCurrency,
339+
"ones",
340+
options.toTimeScale,
341+
) || normalizedUnitString;
342+
} else {
343+
// If currency is unknown, keep the base label without currency
344+
const base = originalUnit;
345+
normalizedUnitString = options.toTimeScale
346+
? `${base} per ${options.toTimeScale}`
347+
: base;
348+
normalizedFullUnit = normalizedUnitString;
349+
}
350+
}
351+
352+
// Indicators with skipTimeInUnit: omit per-time in unit strings
353+
// This includes: stock, balance, capacity, price, percentage, ratio, rate, index, etc.
354+
if (skipTimeInUnitString) {
355+
if (hasKnownCurrency || didFX) {
356+
const labelCurrency = didFX
357+
? options.toCurrency
358+
: (effectiveCurrency || undefined);
359+
normalizedUnitString = buildNormalizedUnitString(
360+
labelCurrency,
361+
targetScale,
362+
undefined,
363+
);
364+
originalFullUnit = buildFullUnitString(
365+
effectiveCurrency,
366+
originalScale,
367+
undefined,
368+
);
369+
normalizedFullUnit = buildFullUnitString(
370+
labelCurrency,
371+
targetScale,
372+
undefined,
373+
) || normalizedUnitString;
374+
} else {
375+
// Placeholder currency: keep base without per-time
376+
const base = originalUnit;
377+
normalizedUnitString = base;
378+
originalFullUnit = base;
379+
normalizedFullUnit = base;
380+
}
381+
}
328382
}
329383

330384
explain.units = {
@@ -619,11 +673,13 @@ export function buildExplainMetadata(
619673
// 🆕 Separate component fields for easy frontend access
620674
if (
621675
!isNonCurrencyCategory && !isNonCurrencyDomain &&
622-
(effectiveCurrency || options.toCurrency)
676+
(hasKnownCurrency || didFX)
623677
) {
624678
explain.currency = {
625-
original: effectiveCurrency,
626-
normalized: options.toCurrency || effectiveCurrency || "USD",
679+
original: hasKnownCurrency ? effectiveCurrency : undefined,
680+
normalized: didFX
681+
? options.toCurrency
682+
: (hasKnownCurrency ? effectiveCurrency : undefined),
627683
};
628684
}
629685

0 commit comments

Comments
 (0)