Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions packages/econify/src/batch/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,9 +640,24 @@ function processItem<T extends BatchItem>(
const scale = targetMagnitude ?? "ones";
normalizedUnit = scale === "ones" ? "ones" : titleCase(scale);
} else {
// Choose label currency for units:
// - If FX would occur (known currency, target set, fx available and different), use target currency
// - Else, use known effective currency (if any)
const effectiveCurrencyKnown = (effectiveCurrency &&
effectiveCurrency !== "UNKNOWN")
? effectiveCurrency
: undefined;
const willConvert = !shouldSkipCurrency &&
!!effectiveCurrencyKnown &&
!!options.toCurrency &&
!!options.fx &&
effectiveCurrencyKnown !== options.toCurrency;
const labelCurrency = willConvert
? options.toCurrency
: effectiveCurrencyKnown;
normalizedUnit = buildNormalizedUnit(
item.unit,
options.toCurrency,
labelCurrency,
targetMagnitude,
options.toTimeScale,
indicatorType,
Expand Down Expand Up @@ -716,7 +731,27 @@ function buildNormalizedUnit(
): string {
const parsed = parseUnit(original);

const cur = (currency || parsed.currency)?.toUpperCase();
// Special-case placeholder currencies (e.g., "National currency"): preserve original label,
// add magnitude if any, and append time dimension when appropriate.
if (parsed.currency === "UNKNOWN") {
const mag = magnitude ?? parsed.scale ?? getScale(original);
const ts = timeScale ?? parsed.timeScale;
const parts: string[] = [original];
if (mag && mag !== "ones") parts.push(String(mag));
let out = parts.join(" ");
const shouldIncludeTime = ts &&
allowsTimeConversion(indicatorType, temporalAggregation);
if (shouldIncludeTime) {
out = `${out}${out ? " " : ""}per ${ts}`;
}
return out || original;
}

// Sanitize provided currency: ignore placeholder values
const provided = currency && currency.toUpperCase() !== "UNKNOWN"
? currency
: undefined;
const cur = (provided || parsed.currency)?.toUpperCase();
// Fallback to detect scale from unit text when parser misses singular forms (e.g., "Thousand")
const mag = magnitude ?? parsed.scale ?? getScale(original);
const ts = timeScale ?? parsed.timeScale;
Expand Down
182 changes: 121 additions & 61 deletions packages/econify/src/normalization/explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export function buildExplainMetadata(

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

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

// FX information
if (
effectiveCurrency && options.toCurrency && options.fx &&
hasKnownCurrency && options.toCurrency && options.fx &&
effectiveCurrency !== options.toCurrency
) {
const rate = options.fx.rates[effectiveCurrency];
Expand All @@ -79,6 +81,9 @@ export function buildExplainMetadata(
}
}

// Determine if FX conversion actually occurred (based on presence of explain.fx)
const didFX = !!explain.fx;

// Magnitude information - only provide when scaling actually occurs
const originalScale = effectiveScale || getScale(originalUnit);
const targetScale = options.toMagnitude || originalScale;
Expand Down Expand Up @@ -257,74 +262,123 @@ export function buildExplainMetadata(
}
} else {
// Monetary/currency-based units (default behavior)
originalUnitString = buildOriginalUnitString(
effectiveCurrency,
originalScale,
);
// For monetary units, only preserve original time scale if it was explicitly in the unit string
// (not just in metadata periodicity). This prevents adding "per month" to stock indicators.
const hasTimeInUnit = !!parsed.timeScale;

// Use target time scale unless conversion was explicitly blocked
// Check if time conversion was blocked (not just "no conversion needed")
const timeWasBlocked = explain.periodicity?.adjusted === false &&
explain.periodicity?.description?.includes("blocked");
const effectiveTargetTime: TimeScale | undefined = timeWasBlocked
? (hasTimeInUnit && originalTimeScale ? originalTimeScale : undefined)
: (options.toTimeScale ||
(hasTimeInUnit && originalTimeScale ? originalTimeScale : undefined));

normalizedUnitString = buildNormalizedUnitString(
options.toCurrency || effectiveCurrency,
targetScale,
effectiveTargetTime,
);

// Build full unit strings with time periods
originalFullUnit = buildFullUnitString(
effectiveCurrency,
originalScale,
originalTimeScale || undefined,
);
normalizedFullUnit = buildFullUnitString(
options.toCurrency || effectiveCurrency,
targetScale,
effectiveTargetTime,
) || normalizedUnitString;

// Per-capita: avoid adding scale label like 'millions' to currency units
if (isPerCapita) {
normalizedUnitString = buildNormalizedUnitString(
options.toCurrency || effectiveCurrency,
"ones",
options.toTimeScale,
// Special handling for placeholder currency (e.g., "National currency") with no FX:
// Present units using the original label without injecting a target currency.
if (!hasKnownCurrency && !didFX) {
const base = originalUnit;
// For placeholder currencies, reflect target time scale if provided
if (options.toTimeScale) {
originalUnitString = base;
normalizedUnitString = `${base} per ${options.toTimeScale}`;
originalFullUnit = originalUnitString;
normalizedFullUnit = normalizedUnitString;
} else {
originalUnitString = base;
normalizedUnitString = base;
originalFullUnit = base;
normalizedFullUnit = base;
}
} else {
// Known currency or FX occurred: build currency-based labels normally
originalUnitString = buildOriginalUnitString(
effectiveCurrency,
originalScale,
);
normalizedFullUnit = buildFullUnitString(
options.toCurrency || effectiveCurrency,
"ones",
options.toTimeScale,
) || normalizedUnitString;
}
// For monetary units, only preserve original time scale if it was explicitly in the unit string
// (not just in metadata periodicity). This prevents adding "per month" to stock indicators.
const hasTimeInUnit = !!parsed.timeScale;

// Use target time scale unless conversion was explicitly blocked
// Check if time conversion was blocked (not just "no conversion needed")
const timeWasBlocked = explain.periodicity?.adjusted === false &&
explain.periodicity?.description?.includes("blocked");
const effectiveTargetTime: TimeScale | undefined = timeWasBlocked
? (hasTimeInUnit && originalTimeScale ? originalTimeScale : undefined)
: (options.toTimeScale ||
(hasTimeInUnit && originalTimeScale ? originalTimeScale : undefined));

// Choose label currency:
// - If FX occurred, use target currency
// - Else, use the known effective currency
const labelCurrency = didFX
? options.toCurrency
: (effectiveCurrency || undefined);

// Indicators with skipTimeInUnit: omit per-time in unit strings
// This includes: stock, balance, capacity, price, percentage, ratio, rate, index, etc.
if (skipTimeInUnitString) {
normalizedUnitString = buildNormalizedUnitString(
options.toCurrency || effectiveCurrency,
labelCurrency,
targetScale,
undefined,
effectiveTargetTime,
);

// Build full unit strings with time periods
originalFullUnit = buildFullUnitString(
effectiveCurrency,
originalScale,
undefined,
originalTimeScale || undefined,
);
normalizedFullUnit = buildFullUnitString(
options.toCurrency || effectiveCurrency,
labelCurrency,
targetScale,
undefined,
effectiveTargetTime,
) || normalizedUnitString;
}

// Per-capita: avoid adding scale label like 'millions' to currency units
if (isPerCapita) {
const perCapitaCurrency = didFX
? options.toCurrency
: (effectiveCurrency || undefined);
if (perCapitaCurrency) {
normalizedUnitString = buildNormalizedUnitString(
perCapitaCurrency,
"ones",
options.toTimeScale,
);
normalizedFullUnit = buildFullUnitString(
perCapitaCurrency,
"ones",
options.toTimeScale,
) || normalizedUnitString;
} else {
// If currency is unknown, keep the base label without currency
const base = originalUnit;
normalizedUnitString = options.toTimeScale
? `${base} per ${options.toTimeScale}`
: base;
normalizedFullUnit = normalizedUnitString;
}
}

// Indicators with skipTimeInUnit: omit per-time in unit strings
// This includes: stock, balance, capacity, price, percentage, ratio, rate, index, etc.
if (skipTimeInUnitString) {
if (hasKnownCurrency || didFX) {
const labelCurrency = didFX
? options.toCurrency
: (effectiveCurrency || undefined);
normalizedUnitString = buildNormalizedUnitString(
labelCurrency,
targetScale,
undefined,
);
originalFullUnit = buildFullUnitString(
effectiveCurrency,
originalScale,
undefined,
);
normalizedFullUnit = buildFullUnitString(
labelCurrency,
targetScale,
undefined,
) || normalizedUnitString;
} else {
// Placeholder currency: keep base without per-time
const base = originalUnit;
normalizedUnitString = base;
originalFullUnit = base;
normalizedFullUnit = base;
}
}
}

explain.units = {
Expand Down Expand Up @@ -619,12 +673,18 @@ export function buildExplainMetadata(
// 🆕 Separate component fields for easy frontend access
if (
!isNonCurrencyCategory && !isNonCurrencyDomain &&
(effectiveCurrency || options.toCurrency)
(hasKnownCurrency || didFX)
) {
explain.currency = {
original: effectiveCurrency,
normalized: options.toCurrency || effectiveCurrency || "USD",
};
const normalizedCurrency = didFX
? (options.toCurrency || "USD")
: (hasKnownCurrency ? effectiveCurrency : undefined);

if (normalizedCurrency) {
explain.currency = {
original: hasKnownCurrency ? effectiveCurrency : undefined,
normalized: normalizedCurrency,
};
}
}

if (originalScale || targetScale) {
Expand Down
Loading