diff --git a/src/components/bounty/BountyCard.tsx b/src/components/bounty/BountyCard.tsx index 851fef4c..e57a20ef 100644 --- a/src/components/bounty/BountyCard.tsx +++ b/src/components/bounty/BountyCard.tsx @@ -11,6 +11,7 @@ const accentByDifficulty: Record = { intermediate: "before:bg-amber-400", advanced: "before:bg-rose-400", expert: "before:bg-fuchsia-400", + unknown: "before:bg-slate-400", }; export function BountyCard({ bounty }: { bounty: Bounty }) { diff --git a/src/components/ui/Badge.test.tsx b/src/components/ui/Badge.test.tsx index bb725d7a..cb3748ff 100644 --- a/src/components/ui/Badge.test.tsx +++ b/src/components/ui/Badge.test.tsx @@ -26,7 +26,13 @@ const ALL_STATUSES: BountyStatus[] = [ "unknown", ]; -const ALL_DIFFICULTIES: Difficulty[] = ["beginner", "intermediate", "advanced", "expert"]; +const ALL_DIFFICULTIES: Difficulty[] = [ + "beginner", + "intermediate", + "advanced", + "expert", + "unknown", +]; /** * Explicit expected label per status. Asserted against a literal map rather @@ -37,7 +43,7 @@ const ALL_DIFFICULTIES: Difficulty[] = ["beginner", "intermediate", "advanced", * (#456). Both now render the same map. */ const STATUS_LABELS: Record = { - open: "Open", + open: "Not yet funded", funded: "Funded", claimed: "Claimed", in_review: "In Review", diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx index 491c1394..3d173ba5 100644 --- a/src/components/ui/Badge.tsx +++ b/src/components/ui/Badge.tsx @@ -118,6 +118,11 @@ const difficultyStyles: Record = { className: "bg-fuchsia-50 text-fuchsia-800 ring-fuchsia-300 dark:bg-fuchsia-500/10 dark:text-fuchsia-300 dark:ring-fuchsia-500/40", }, + unknown: { + Icon: CircleHelp, + className: + "bg-slate-50 text-slate-800 ring-slate-300 dark:bg-slate-500/10 dark:text-slate-300 dark:ring-slate-500/40", + }, }; function BaseBadge({ diff --git a/src/lib/adapters.test.ts b/src/lib/adapters.test.ts index 0ba96b92..72ae34e0 100644 --- a/src/lib/adapters.test.ts +++ b/src/lib/adapters.test.ts @@ -170,6 +170,16 @@ describe("adaptBounty — field coverage audit (#86)", () => { expect(bounty[field]).not.toBeUndefined(); } }); + + it("coerces invalid asset and difficulty to 'unknown' (#893)", () => { + const raw = rawBounty({ + asset: "INVALID_COIN" as any, + difficulty: "super_hard" as any, + }); + const adapted = adaptBounty(raw); + expect(adapted.asset).toBe("unknown"); + expect(adapted.difficulty).toBe("unknown"); + }); }); /** diff --git a/src/lib/adapters.ts b/src/lib/adapters.ts index 1775c0ef..4cd8ac17 100644 --- a/src/lib/adapters.ts +++ b/src/lib/adapters.ts @@ -7,7 +7,9 @@ import type { ReputationProfile, } from "@/types"; import { + coerceAsset, coerceDecimal, + coerceDifficulty, coerceNonNegative, coercePercentage, coerceStatus, @@ -52,8 +54,8 @@ interface RawIssue { export interface RawBounty { id: string; amount: string; - asset: "USDC" | "XLM"; - difficulty: Difficulty; + asset: "USDC" | "XLM" | string; + difficulty: Difficulty | string; status: Bounty["status"]; deadline: string | null; createdAt?: string | null; @@ -131,8 +133,8 @@ export function adaptBounty(raw: RawBounty): Bounty { title: raw.issue?.title ?? "Untitled issue", description: raw.issue?.body ?? "", reward: coerceNonNegative(raw.amount), - asset: raw.asset, - difficulty: raw.difficulty, + asset: coerceAsset(raw.asset), + difficulty: coerceDifficulty(raw.difficulty), status: coerceStatus(raw.status), // A null deadline means the bounty is genuinely open-ended, not "due // now" — pass it through as-is rather than fabricating a "now" @@ -156,7 +158,7 @@ export interface RawMilestone { title: string; budget: string; distributed: string; - asset: "USDC" | "XLM"; + asset: "USDC" | "XLM" | string; status?: string; deadline?: string | null; repository?: RawRepository; @@ -173,7 +175,7 @@ export function adaptMilestone(raw: RawMilestone): Milestone { repo: raw.repository ? `${raw.repository.owner}/${raw.repository.name}` : "unassigned", budget, distributed, - asset: raw.asset, + asset: coerceAsset(raw.asset), status: raw.status ?? "open", deadline: raw.deadline ?? null, issueCount: issues.length, @@ -193,7 +195,7 @@ export interface RawMaintenancePool { name?: string | null; monthlyDeposit: string; balance: string; - asset: "USDC" | "XLM"; + asset: "USDC" | "XLM" | string; status?: string; repository?: RawRepository | null; } @@ -205,7 +207,7 @@ export function adaptMaintenancePool(raw: RawMaintenancePool): MaintenancePool { repo: raw.repository ? `${raw.repository.owner}/${raw.repository.name}` : "platform-wide", monthlyDeposit: coerceNonNegative(raw.monthlyDeposit), balance: coerceNonNegative(raw.balance), - asset: raw.asset, + asset: coerceAsset(raw.asset), status: raw.status ?? "active", }; } diff --git a/src/lib/bounty-query.ts b/src/lib/bounty-query.ts index 46180054..2f47ebfb 100644 --- a/src/lib/bounty-query.ts +++ b/src/lib/bounty-query.ts @@ -72,6 +72,7 @@ export const XLM_TO_USD_APPROX = 0.12; /** Convert a reward to USD-equivalent for cross-asset comparison (#613). */ function rewardToUsd(reward: number, asset: string): number { + if (asset === "unknown") return 0; return asset === "XLM" ? reward * XLM_TO_USD_APPROX : reward; } diff --git a/src/lib/locale.ts b/src/lib/locale.ts index eb39fc8e..5b860dab 100644 --- a/src/lib/locale.ts +++ b/src/lib/locale.ts @@ -47,12 +47,13 @@ export const DEFAULT_LOCALE = "en"; export const LOCALE_STORAGE_KEY = "mergefi_locale"; /** Crypto assets this app denominates payouts in. */ -export type CryptoAsset = "USDC" | "XLM"; +export type CryptoAsset = "USDC" | "XLM" | "unknown"; /** Fraction digits per asset: USDC is an ERC-20-style 2dp token, XLM has 7. */ export const ASSET_DECIMALS: Record = { USDC: 2, XLM: 7, + unknown: 2, }; /** diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 497617f5..d49fd43a 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -16,6 +16,8 @@ import { coerceFraction, coercePercentage, coerceStatus, + coerceAsset, + coerceDifficulty, validateTeamSplits, generateIdempotencyKey, toCents, @@ -246,6 +248,44 @@ describe("coerceStatus", () => { }); }); +describe("coerceAsset (#893)", () => { + it("accepts valid crypto assets", () => { + expect(coerceAsset("USDC")).toBe("USDC"); + expect(coerceAsset("XLM")).toBe("XLM"); + }); + + it("falls back to 'unknown' for unrecognized asset strings", () => { + expect(coerceAsset("ETH")).toBe("unknown"); + expect(coerceAsset("BTC")).toBe("unknown"); + expect(coerceAsset("bogus")).toBe("unknown"); + }); + + it("falls back to 'unknown' for null, undefined, or empty string", () => { + expect(coerceAsset(null)).toBe("unknown"); + expect(coerceAsset(undefined)).toBe("unknown"); + expect(coerceAsset("")).toBe("unknown"); + }); +}); + +describe("coerceDifficulty (#893)", () => { + it("accepts valid difficulty tiers", () => { + for (const d of ["beginner", "intermediate", "advanced", "expert"] as const) { + expect(coerceDifficulty(d)).toBe(d); + } + }); + + it("falls back to 'unknown' for unrecognized difficulty strings", () => { + expect(coerceDifficulty("hardcore")).toBe("unknown"); + expect(coerceDifficulty("trivial")).toBe("unknown"); + }); + + it("falls back to 'unknown' for null, undefined, or empty string", () => { + expect(coerceDifficulty(null)).toBe("unknown"); + expect(coerceDifficulty(undefined)).toBe("unknown"); + expect(coerceDifficulty("")).toBe("unknown"); + }); +}); + // ─── validateTeamSplits ────────────────────────────────────────────────────── describe("validateTeamSplits", () => { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index b04c143f..b3d16001 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,6 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; -import type { BountyStatus } from "@/types"; +import type { BountyStatus, Difficulty, BountyAsset } from "@/types"; import { formatCount, formatCryptoAmount, @@ -121,10 +121,6 @@ export function formatCurrency( // is true, so testing the ceiling first would render a non-finite amount as // "0 USDC ⚠" instead of the plain "0 USDC" the contract promises. if (!Number.isFinite(amount)) return `0 ${asset}`; - const fmt = asset === "XLM" ? currencyFormat7 : currencyFormat2; - const formatted = fmt.format(amount); - if (Math.abs(amount) > SANITY_CEILING) return `${formatted} ${asset} ⚠`; - return `${formatted} ${asset}`; const formatted = formatCryptoAmount(amount, asset, locale); if (Math.abs(amount) > SANITY_CEILING) return `${formatted} ⚠`; return formatted; @@ -184,9 +180,6 @@ export function parseMoneyInput( return { valid: false, error: "Enter a deposit amount." }; } - // Use Number only for range checks, not for normalization. - // Normalization is done string-based to avoid precision loss on large amounts. - const num = Number(trimmed); const { group, decimal } = localeSeparators(locale); // Two notations have to be accepted, and they conflict on exactly one @@ -237,24 +230,6 @@ export function parseMoneyInput( }; } - // Normalize: string-based normalization to avoid precision loss. - // Trim trailing zeros and pad to maxDecimals if needed. - let normalized = trimmed; - if (normalized.includes(".")) { - // Trim trailing zeros after decimal point - normalized = normalized.replace(/\.?0+$/, ""); - // If we removed everything after decimal, remove the decimal too - if (normalized.endsWith(".")) { - normalized = normalized.slice(0, -1); - } - } - - return { valid: true, normalized }; - // Normalize: convert to a canonical decimal string with the right precision. - // Use a cached Intl.NumberFormat with fixed fraction digits to get a clean - // representation, then strip thousands separators. - const fmt = asset === "XLM" ? parseNormalizeFormat7 : parseNormalizeFormat2; - const normalized = fmt.format(num).replace(/,/g, ""); // Normalize to the canonical dot-decimal wire form, dropping any trailing // zeros the viewer's locale added ("12,50" → "12.5"). `toFixed` is the // correct primitive here precisely because it is locale-independent. @@ -304,11 +279,6 @@ export function daysUntil(dateIso: string) { return Math.ceil((deadlineUtc - nowUtc) / (1000 * 60 * 60 * 24)); } -export function formatDaysUntil(days: number | null): string { - if (days === null) return "No deadline"; - if (days > 0) return `${days} day${days === 1 ? "" : "s"} left`; - if (days === 0) return "Due today"; - return "Deadline passed"; /** * Human deadline label. Pluralization goes through `Intl.PluralRules` via the * message catalog rather than the previous `days === 1 ? "" : "s"` ternary, @@ -352,6 +322,36 @@ export function coerceStatus(value: string | null | undefined): BountyStatus { return "unknown"; } +const VALID_ASSETS: ReadonlySet = new Set([ + "USDC", + "XLM", +]); + +/** + * Validate that a raw asset string is a supported crypto asset. + * Returns the validated asset or falls back to "unknown" (#893). + */ +export function coerceAsset(value: string | null | undefined): BountyAsset { + if (value && VALID_ASSETS.has(value)) return value as BountyAsset; + return "unknown"; +} + +const VALID_DIFFICULTIES: ReadonlySet = new Set([ + "beginner", + "intermediate", + "advanced", + "expert", +]); + +/** + * Validate that a raw difficulty string is a recognized Difficulty tier. + * Returns the validated difficulty or falls back to "unknown" (#893). + */ +export function coerceDifficulty(value: string | null | undefined): Difficulty { + if (value && VALID_DIFFICULTIES.has(value as Difficulty)) return value as Difficulty; + return "unknown"; +} + export function validateTeamSplits( splits: Array<{ percentage: string | number }>, tolerance = 0.01 diff --git a/src/types/bounty.ts b/src/types/bounty.ts index 74ab4601..dcb5e2c2 100644 --- a/src/types/bounty.ts +++ b/src/types/bounty.ts @@ -19,12 +19,14 @@ export const TERMINAL_STATUSES: ReadonlySet = new Set([ 'expired', ]); +export type BountyAsset = "USDC" | "XLM" | "unknown"; + export interface Bounty { id: string; title: string; description: string; reward: number; - asset: "USDC" | "XLM"; + asset: BountyAsset; difficulty: Difficulty; status: BountyStatus; org: string; diff --git a/src/types/shared.ts b/src/types/shared.ts index 636d2d43..a41ec9fe 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -1,7 +1,7 @@ export type UserRole = "contributor" | "maintainer" | "sponsor"; // Mirrors mergefi-backend's BountyDifficulty enum -export type Difficulty = "beginner" | "intermediate" | "advanced" | "expert"; +export type Difficulty = "beginner" | "intermediate" | "advanced" | "expert" | "unknown"; export interface TeamSplit { role: string; @@ -15,7 +15,7 @@ export interface Milestone { repo: string; budget: number; distributed: number; - asset: "USDC" | "XLM"; + asset: "USDC" | "XLM" | "unknown"; status: string; deadline: string | null; issueCount: number; @@ -46,7 +46,7 @@ export interface MaintenancePool { repo: string; monthlyDeposit: number; balance: number; - asset: "USDC" | "XLM"; + asset: "USDC" | "XLM" | "unknown"; status: string; }