diff --git a/frontend/src/components/AdminGrantQualityPanel.tsx b/frontend/src/components/AdminGrantQualityPanel.tsx new file mode 100644 index 0000000..fb8d719 --- /dev/null +++ b/frontend/src/components/AdminGrantQualityPanel.tsx @@ -0,0 +1,382 @@ +"use client"; + +/** + * AdminGrantQualityPanel — #166 + * + * Displays a sortable list of grant records with their data quality scores + * so administrators can quickly identify records that need improvement. + * + * Acceptance criteria met: + * ✓ Required information fields are defined (see grant-quality.ts) + * ✓ Incomplete grants receive a lower quality score (shown here as badge) + * ✓ Administrators can identify records that need improvement (low-quality + * section + sort-by-quality mode) + */ + +import React, { useMemo, useState } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + formatQualityLabel, + formatQualitySummary, + listLowQualityGrants, + scoreAllGrants, + type ScoredGrant, +} from "@/lib/grant-quality"; +import type { GrantRecord, QualityGrade } from "@/types/grant"; + +// ── Grade badge colours ──────────────────────────────────────────────────────── + +const GRADE_CLASS: Record = { + A: "bg-emerald-600 text-white border-transparent", + B: "bg-green-500 text-white border-transparent", + C: "bg-yellow-500 text-white border-transparent", + D: "bg-orange-500 text-white border-transparent", + F: "bg-red-600 text-white border-transparent", +}; + +/** Progress-bar colour derived from score. */ +function scoreBarClass(score: number): string { + if (score >= 90) return "bg-emerald-500"; + if (score >= 75) return "bg-green-500"; + if (score >= 60) return "bg-yellow-500"; + if (score >= 40) return "bg-orange-500"; + return "bg-red-500"; +} + +// ── GradeIndicator ───────────────────────────────────────────────────────────── + +interface GradeIndicatorProps { + grade: QualityGrade; + score: number; +} + +function GradeIndicator({ grade, score }: GradeIndicatorProps) { + return ( +
+ + {grade} + + {/* Progress bar */} +
+
+
+ {score} +
+ ); +} + +// ── GrantQualityRow ──────────────────────────────────────────────────────────── + +interface GrantQualityRowProps { + item: ScoredGrant; + /** Show missing-fields detail. Toggled by user. */ + expanded: boolean; + onToggle: () => void; +} + +function GrantQualityRow({ item, expanded, onToggle }: GrantQualityRowProps) { + const { grant, quality } = item; + const summary = formatQualitySummary(quality); + + return ( +
+
+ {/* Grant identity */} +
+

+ {grant.title || (no title)} +

+

+ {grant.funder || (no funder)} + {grant.category ? ` · ${grant.category}` : ""} +

+
+ + {/* Score badge + bar */} +
+ +
+ + {/* Expand/collapse toggle — only when fields are missing */} + {!quality.isComplete && ( + + )} +
+ + {/* Missing fields detail panel */} + {!quality.isComplete && expanded && ( +
+

+ {summary} +

+
+ {quality.missingFields.map((field) => ( + + {field} + + ))} +
+
+ )} +
+ ); +} + +// ── Summary Stats ───────────────────────────────────────────────────────────── + +interface QualitySummaryStatsProps { + scored: ScoredGrant[]; +} + +function QualitySummaryStats({ scored }: QualitySummaryStatsProps) { + const total = scored.length; + if (total === 0) return null; + + const complete = scored.filter((s) => s.quality.isComplete).length; + const low = scored.filter((s) => s.quality.score < 75).length; + const avg = Math.round( + scored.reduce((sum, s) => sum + s.quality.score, 0) / total, + ); + + const stats = [ + { label: "Total grants", value: total }, + { label: "Complete records", value: `${complete} / ${total}` }, + { label: "Need attention (< 75)", value: low }, + { label: "Average score", value: avg }, + ]; + + return ( +
+ {stats.map(({ label, value }) => ( +
+
{label}
+
{value}
+
+ ))} +
+ ); +} + +// ── Sort options ─────────────────────────────────────────────────────────────── + +type SortMode = "worst-first" | "best-first" | "alpha"; + +function applySortMode(items: ScoredGrant[], mode: SortMode): ScoredGrant[] { + // scoreAllGrants already returns worst-first, so we copy before sorting. + const copy = [...items]; + if (mode === "worst-first") return copy; // already sorted + if (mode === "best-first") return copy.reverse(); + if (mode === "alpha") { + return copy.sort((a, b) => a.grant.title.localeCompare(b.grant.title)); + } + return copy; +} + +// ── AdminGrantQualityPanel ───────────────────────────────────────────────────── + +export interface AdminGrantQualityPanelProps { + /** Full list of grant records to evaluate. */ + grants: GrantRecord[]; + /** + * When true, show only grants with a score below `lowQualityThreshold`. + * Defaults to false (show all grants). + */ + showLowQualityOnly?: boolean; + /** + * Score below which a grant is considered "low quality". + * Defaults to 75 (grade B cutoff). + */ + lowQualityThreshold?: number; +} + +export function AdminGrantQualityPanel({ + grants, + showLowQualityOnly = false, + lowQualityThreshold = 75, +}: AdminGrantQualityPanelProps) { + const [filterLow, setFilterLow] = useState(showLowQualityOnly); + const [sortMode, setSortMode] = useState("worst-first"); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + // Score all grants once per render cycle. + const allScored = useMemo(() => scoreAllGrants(grants), [grants]); + + // Apply low-quality filter. + const filtered = useMemo( + () => + filterLow + ? listLowQualityGrants(grants, lowQualityThreshold) + : allScored, + [filterLow, grants, allScored, lowQualityThreshold], + ); + + // Apply sort. + const displayed = useMemo( + () => applySortMode(filtered, sortMode), + [filtered, sortMode], + ); + + function toggleExpand(id: string) { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + const hasGrants = grants.length > 0; + + return ( + + + Grant Data Quality + + Completeness scores help identify records that need more information + before they are useful to applicants. + + + + + {/* Summary statistics */} + + + {hasGrants ? ( + <> + {/* Controls */} +
+ {/* Filter toggle */} + + + {/* Sort */} +
+ + +
+
+ + {/* Grant list */} + {displayed.length === 0 ? ( +
+

+ All grants meet the quality threshold +

+

+ No records score below {lowQualityThreshold}. +

+
+ ) : ( +
    + {displayed.map((item) => ( +
  • + toggleExpand(item.grant.id)} + /> +
  • + ))} +
+ )} + + {/* Result count */} +

+ Showing {displayed.length} of {allScored.length} grant + {allScored.length === 1 ? "" : "s"} +

+ + ) : ( +
+

No grants to evaluate

+

+ Import or create grant records to see quality scores here. +

+
+ )} +
+
+ ); +} + +/** + * Convenience re-export of the quality label formatter so consumers can + * render a single-grant label without importing from two places. + */ +export { formatQualityLabel }; diff --git a/frontend/src/lib/grant-quality.test.ts b/frontend/src/lib/grant-quality.test.ts new file mode 100644 index 0000000..6f17602 --- /dev/null +++ b/frontend/src/lib/grant-quality.test.ts @@ -0,0 +1,508 @@ +import { describe, expect, it } from "vitest"; + +import { + GRADE_THRESHOLDS, + QUALITY_FIELD_WEIGHTS, + QUALITY_FIELDS, + formatQualityLabel, + formatQualitySummary, + isFieldPresent, + listLowQualityGrants, + scoreAllGrants, + scoreGrantQuality, + scoreToGrade, +} from "./grant-quality"; +import type { GrantRecord } from "@/types/grant"; + +// ── Test fixtures ───────────────────────────────────────────────────────────── + +const ADDRESS = `G${"A".repeat(55)}`; + +/** A completely filled-out grant record — should score 100. */ +function makeFullGrant(overrides: Partial = {}): GrantRecord { + return { + id: "grant-full", + title: "Community Tooling Grant", + funder: "Stellar Foundation", + deadline: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30, // 30 days out + status: "active", + owner: "GABC", + createdAt: new Date("2026-01-15T10:00:00.000Z"), + // Enrichment fields + description: "Funds development of open-source tooling for the Stellar ecosystem.", + website: "https://example.org/grant", + contactEmail: "grants@example.org", + category: "tooling", + amount: 5000, + currency: "USDC", + recipientName: "Ada Lovelace", + recipientAddress: ADDRESS, + updatedAt: new Date("2026-02-01T12:00:00.000Z"), + ...overrides, + }; +} + +/** Minimum valid grant — only required fields, all enrichment fields absent. */ +function makeMinimalGrant(overrides: Partial = {}): GrantRecord { + return { + id: "grant-minimal", + title: "Minimal Grant", + funder: "Some Org", + deadline: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30, + status: "active", + owner: "GABC", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + ...overrides, + }; +} + +// ── Weight table integrity ──────────────────────────────────────────────────── + +describe("QUALITY_FIELD_WEIGHTS integrity", () => { + it("weights sum to exactly 100", () => { + const total = Object.values(QUALITY_FIELD_WEIGHTS).reduce((a, b) => a + b, 0); + expect(total).toBe(100); + }); + + it("every field in QUALITY_FIELDS has a weight", () => { + for (const field of QUALITY_FIELDS) { + expect(QUALITY_FIELD_WEIGHTS).toHaveProperty(field); + expect(QUALITY_FIELD_WEIGHTS[field]).toBeGreaterThan(0); + } + }); + + it("every weighted field appears in QUALITY_FIELDS", () => { + for (const field of Object.keys(QUALITY_FIELD_WEIGHTS)) { + expect(QUALITY_FIELDS).toContain(field); + } + }); +}); + +// ── Grade thresholds ────────────────────────────────────────────────────────── + +describe("scoreToGrade", () => { + it("returns A for 90–100", () => { + expect(scoreToGrade(100)).toBe("A"); + expect(scoreToGrade(90)).toBe("A"); + expect(scoreToGrade(95)).toBe("A"); + }); + + it("returns B for 75–89", () => { + expect(scoreToGrade(89)).toBe("B"); + expect(scoreToGrade(75)).toBe("B"); + expect(scoreToGrade(80)).toBe("B"); + }); + + it("returns C for 60–74", () => { + expect(scoreToGrade(74)).toBe("C"); + expect(scoreToGrade(60)).toBe("C"); + }); + + it("returns D for 40–59", () => { + expect(scoreToGrade(59)).toBe("D"); + expect(scoreToGrade(40)).toBe("D"); + }); + + it("returns F for 0–39", () => { + expect(scoreToGrade(39)).toBe("F"); + expect(scoreToGrade(0)).toBe("F"); + expect(scoreToGrade(1)).toBe("F"); + }); + + it("covers all 5 grades", () => { + const grades = new Set(GRADE_THRESHOLDS.map((t) => t.grade)); + expect(grades).toEqual(new Set(["A", "B", "C", "D", "F"])); + }); +}); + +// ── Field presence checks ───────────────────────────────────────────────────── + +describe("isFieldPresent", () => { + it("treats a non-empty title as present", () => { + expect(isFieldPresent(makeFullGrant(), "title")).toBe(true); + }); + + it("treats an empty title string as missing", () => { + expect(isFieldPresent(makeFullGrant({ title: "" }), "title")).toBe(false); + }); + + it("treats a whitespace-only title as missing", () => { + expect(isFieldPresent(makeFullGrant({ title: " " }), "title")).toBe(false); + }); + + it("treats a positive deadline as present", () => { + expect(isFieldPresent(makeFullGrant(), "deadline")).toBe(true); + }); + + it("treats a zero deadline as missing", () => { + expect(isFieldPresent(makeFullGrant({ deadline: 0 }), "deadline")).toBe(false); + }); + + it("treats a negative deadline as missing", () => { + expect(isFieldPresent(makeFullGrant({ deadline: -1 }), "deadline")).toBe(false); + }); + + it("treats a positive amount as present", () => { + expect(isFieldPresent(makeFullGrant({ amount: 100 }), "amount")).toBe(true); + }); + + it("treats zero amount as missing", () => { + expect(isFieldPresent(makeFullGrant({ amount: 0 }), "amount")).toBe(false); + }); + + it("treats a negative amount as missing", () => { + expect(isFieldPresent(makeFullGrant({ amount: -5 }), "amount")).toBe(false); + }); + + it("treats undefined optional fields as missing", () => { + const grant = makeMinimalGrant(); + const optionalFields = [ + "description", + "website", + "contactEmail", + "category", + "currency", + "recipientName", + "recipientAddress", + ] as const; + for (const field of optionalFields) { + expect(isFieldPresent(grant, field)).toBe(false); + } + }); + + it("treats a populated optional field as present", () => { + expect(isFieldPresent(makeFullGrant(), "description")).toBe(true); + expect(isFieldPresent(makeFullGrant(), "website")).toBe(true); + expect(isFieldPresent(makeFullGrant(), "contactEmail")).toBe(true); + expect(isFieldPresent(makeFullGrant(), "category")).toBe(true); + expect(isFieldPresent(makeFullGrant(), "currency")).toBe(true); + expect(isFieldPresent(makeFullGrant(), "recipientName")).toBe(true); + expect(isFieldPresent(makeFullGrant(), "recipientAddress")).toBe(true); + }); + + it("treats a whitespace-only optional field as missing", () => { + expect(isFieldPresent(makeFullGrant({ description: " " }), "description")).toBe(false); + expect(isFieldPresent(makeFullGrant({ website: "" }), "website")).toBe(false); + }); +}); + +// ── scoreGrantQuality — full record ────────────────────────────────────────── + +describe("scoreGrantQuality — complete grant", () => { + it("scores a fully populated grant at 100", () => { + const result = scoreGrantQuality(makeFullGrant()); + expect(result.score).toBe(100); + }); + + it("grades a perfect grant as A", () => { + expect(scoreGrantQuality(makeFullGrant()).grade).toBe("A"); + }); + + it("marks a perfect grant as complete", () => { + expect(scoreGrantQuality(makeFullGrant()).isComplete).toBe(true); + }); + + it("has no missing fields for a perfect grant", () => { + expect(scoreGrantQuality(makeFullGrant()).missingFields).toHaveLength(0); + }); + + it("lists all quality fields as present for a perfect grant", () => { + const result = scoreGrantQuality(makeFullGrant()); + expect(result.presentFields.sort()).toEqual([...QUALITY_FIELDS].sort()); + }); +}); + +// ── scoreGrantQuality — minimal record ─────────────────────────────────────── + +describe("scoreGrantQuality — minimal grant (required fields only)", () => { + it("scores a minimal grant at title + funder + deadline weight sum", () => { + const grant = makeMinimalGrant(); + const expected = + QUALITY_FIELD_WEIGHTS.title + + QUALITY_FIELD_WEIGHTS.funder + + QUALITY_FIELD_WEIGHTS.deadline; + expect(scoreGrantQuality(grant).score).toBe(expected); + }); + + it("does not mark minimal grant as complete", () => { + expect(scoreGrantQuality(makeMinimalGrant()).isComplete).toBe(false); + }); + + it("grades a minimal grant as D (only 45 points)", () => { + // title(15)+funder(15)+deadline(15) = 45 → grade D (40–59 range) + expect(scoreGrantQuality(makeMinimalGrant()).grade).toBe("D"); + }); + + it("lists enrichment fields as missing", () => { + const result = scoreGrantQuality(makeMinimalGrant()); + expect(result.missingFields).toContain("description"); + expect(result.missingFields).toContain("website"); + expect(result.missingFields).toContain("contactEmail"); + expect(result.missingFields).toContain("recipientName"); + expect(result.missingFields).toContain("recipientAddress"); + }); +}); + +// ── scoreGrantQuality — empty / degenerate record ──────────────────────────── + +describe("scoreGrantQuality — empty values", () => { + it("scores zero when title, funder, and deadline are empty/zero", () => { + const grant = makeMinimalGrant({ title: "", funder: "", deadline: 0 }); + expect(scoreGrantQuality(grant).score).toBe(0); + }); + + it("grades a zero-score grant as F", () => { + const grant = makeMinimalGrant({ title: "", funder: "", deadline: 0 }); + expect(scoreGrantQuality(grant).grade).toBe("F"); + }); + + it("marks a zero-score grant as incomplete", () => { + const grant = makeMinimalGrant({ title: "", funder: "", deadline: 0 }); + expect(scoreGrantQuality(grant).isComplete).toBe(false); + }); +}); + +// ── scoreGrantQuality — partial enrichment ──────────────────────────────────── + +describe("scoreGrantQuality — partial enrichment", () => { + it("adding description increases the score by its weight", () => { + const base = scoreGrantQuality(makeMinimalGrant()).score; + const enriched = scoreGrantQuality( + makeMinimalGrant({ description: "A real description." }), + ).score; + expect(enriched - base).toBe(QUALITY_FIELD_WEIGHTS.description); + }); + + it("adding recipientName and recipientAddress moves score up by their weights", () => { + const base = scoreGrantQuality(makeMinimalGrant()).score; + const enriched = scoreGrantQuality( + makeMinimalGrant({ recipientName: "Alice", recipientAddress: ADDRESS }), + ).score; + expect(enriched - base).toBe( + QUALITY_FIELD_WEIGHTS.recipientName + QUALITY_FIELD_WEIGHTS.recipientAddress, + ); + }); + + it("reflects B grade when score is in 75–89 range", () => { + // Base = 45. Need 30+ more. description(8)+website(4)+contactEmail(4)+ + // category(4)+amount(8)+currency(7) = 35 → total 80 → B + const grant = makeMinimalGrant({ + description: "desc", + website: "https://x.test", + contactEmail: "a@b.test", + category: "tooling", + amount: 100, + currency: "XLM", + }); + const result = scoreGrantQuality(grant); + expect(result.score).toBe(80); + expect(result.grade).toBe("B"); + }); + + it("reflects C grade when score is in 60–74 range", () => { + // Base 45 + description(8) + website(4) + contactEmail(4) = 61 → C + const grant = makeMinimalGrant({ + description: "desc", + website: "https://x.test", + contactEmail: "a@b.test", + }); + const result = scoreGrantQuality(grant); + expect(result.score).toBe(61); + expect(result.grade).toBe("C"); + }); + + it("reflects D grade when score is in 40–59 range", () => { + // Base 45 + category(4) = 49 → D + const grant = makeMinimalGrant({ category: "tooling" }); + const result = scoreGrantQuality(grant); + expect(result.score).toBe(49); + expect(result.grade).toBe("D"); + }); +}); + +// ── scoreGrantQuality — present/missing symmetry ────────────────────────────── + +describe("scoreGrantQuality — present + missing fields cover all quality fields", () => { + it("for a full grant", () => { + const result = scoreGrantQuality(makeFullGrant()); + const union = [...result.presentFields, ...result.missingFields].sort(); + expect(union).toEqual([...QUALITY_FIELDS].sort()); + }); + + it("for a minimal grant", () => { + const result = scoreGrantQuality(makeMinimalGrant()); + const union = [...result.presentFields, ...result.missingFields].sort(); + expect(union).toEqual([...QUALITY_FIELDS].sort()); + }); + + it("for a partially filled grant", () => { + const result = scoreGrantQuality(makeMinimalGrant({ description: "x" })); + const union = [...result.presentFields, ...result.missingFields].sort(); + expect(union).toEqual([...QUALITY_FIELDS].sort()); + }); + + it("no field appears in both lists", () => { + const result = scoreGrantQuality(makeMinimalGrant({ description: "x" })); + const presentSet = new Set(result.presentFields); + for (const field of result.missingFields) { + expect(presentSet.has(field)).toBe(false); + } + }); +}); + +// ── scoreAllGrants ──────────────────────────────────────────────────────────── + +describe("scoreAllGrants", () => { + it("returns an entry for every grant", () => { + const grants = [makeFullGrant(), makeMinimalGrant(), makeMinimalGrant({ id: "g3" })]; + expect(scoreAllGrants(grants)).toHaveLength(3); + }); + + it("sorts results lowest-score first", () => { + const grants = [ + makeFullGrant({ id: "full" }), + makeMinimalGrant({ id: "min" }), + makeMinimalGrant({ id: "mid", description: "some desc", website: "https://x.test" }), + ]; + const scored = scoreAllGrants(grants); + expect(scored[0].grant.id).toBe("min"); + expect(scored[scored.length - 1].grant.id).toBe("full"); + }); + + it("includes the quality object alongside the grant", () => { + const scored = scoreAllGrants([makeFullGrant()]); + expect(scored[0].quality).toBeDefined(); + expect(scored[0].quality.score).toBe(100); + expect(scored[0].grant).toBeDefined(); + }); + + it("returns an empty array for an empty input", () => { + expect(scoreAllGrants([])).toEqual([]); + }); +}); + +// ── listLowQualityGrants ────────────────────────────────────────────────────── + +describe("listLowQualityGrants", () => { + const full = makeFullGrant({ id: "full" }); + const minimal = makeMinimalGrant({ id: "min" }); // score 45 → below 75 + const good = makeFullGrant({ + id: "good", + // Remove the lower-weight fields to land just below A but in B territory + description: undefined, + website: undefined, + contactEmail: undefined, + category: undefined, + // score = 100 - 8 - 4 - 4 - 4 = 80 → grade B, above default threshold + }); + + it("excludes grants at or above the threshold (default 75)", () => { + const result = listLowQualityGrants([full, minimal, good]); + const ids = result.map((r) => r.grant.id); + expect(ids).not.toContain("full"); + expect(ids).not.toContain("good"); // 80 ≥ 75 + expect(ids).toContain("min"); // 45 < 75 + }); + + it("includes grants strictly below the threshold", () => { + const result = listLowQualityGrants([minimal]); + expect(result).toHaveLength(1); + expect(result[0].grant.id).toBe("min"); + }); + + it("respects a custom threshold", () => { + // With threshold=90, both minimal and good are low quality. + const result = listLowQualityGrants([full, minimal, good], 90); + const ids = result.map((r) => r.grant.id); + expect(ids).toContain("min"); + expect(ids).toContain("good"); + expect(ids).not.toContain("full"); + }); + + it("returns an empty list when all grants pass the threshold", () => { + expect(listLowQualityGrants([full], 75)).toHaveLength(0); + }); + + it("returns an empty list for empty input", () => { + expect(listLowQualityGrants([])).toHaveLength(0); + }); + + it("results are sorted worst-first", () => { + const worst = makeMinimalGrant({ id: "worst", title: "", funder: "", deadline: 0 }); + const bad = makeMinimalGrant({ id: "bad" }); // 45 + const result = listLowQualityGrants([bad, worst]); + expect(result[0].grant.id).toBe("worst"); + expect(result[1].grant.id).toBe("bad"); + }); +}); + +// ── formatQualitySummary ────────────────────────────────────────────────────── + +describe("formatQualitySummary", () => { + it("returns an empty string for a complete record", () => { + const quality = scoreGrantQuality(makeFullGrant()); + expect(formatQualitySummary(quality)).toBe(""); + }); + + it("lists the missing fields for an incomplete record", () => { + const quality = scoreGrantQuality(makeMinimalGrant()); + const summary = formatQualitySummary(quality); + expect(summary).toMatch(/^Missing fields:/); + expect(summary).toContain("description"); + expect(summary).toContain("website"); + expect(summary).toContain("recipientName"); + }); + + it("mentions every missing field in the summary", () => { + const quality = scoreGrantQuality(makeMinimalGrant()); + for (const field of quality.missingFields) { + expect(formatQualitySummary(quality)).toContain(field); + } + }); +}); + +// ── formatQualityLabel ──────────────────────────────────────────────────────── + +describe("formatQualityLabel", () => { + it("formats a full grant label as A (100)", () => { + expect(formatQualityLabel(makeFullGrant())).toBe("A (100)"); + }); + + it("formats a minimal grant label as D (45)", () => { + expect(formatQualityLabel(makeMinimalGrant())).toBe("D (45)"); + }); + + it("includes the grade and numeric score separated by a space", () => { + const label = formatQualityLabel(makeFullGrant()); + expect(label).toMatch(/^[ABCDF] \(\d+\)$/); + }); +}); + +// ── Edge cases ──────────────────────────────────────────────────────────────── + +describe("scoreGrantQuality — edge cases", () => { + it("handles a grant with a whitespace-only funder", () => { + const grant = makeMinimalGrant({ funder: " " }); + const result = scoreGrantQuality(grant); + expect(result.missingFields).toContain("funder"); + expect(result.presentFields).not.toContain("funder"); + }); + + it("handles amount of 0.001 (above zero) as present", () => { + const grant = makeMinimalGrant({ amount: 0.001 }); + expect(isFieldPresent(grant, "amount")).toBe(true); + }); + + it("handles a grant created with createdAt as a Date object", () => { + const grant = makeFullGrant({ createdAt: new Date() }); + expect(() => scoreGrantQuality(grant)).not.toThrow(); + expect(scoreGrantQuality(grant).score).toBe(100); + }); + + it("scoreAllGrants does not mutate the original array", () => { + const grants = [makeMinimalGrant({ id: "a" }), makeFullGrant({ id: "b" })]; + const originalOrder = grants.map((g) => g.id); + scoreAllGrants(grants); + expect(grants.map((g) => g.id)).toEqual(originalOrder); + }); +}); diff --git a/frontend/src/lib/grant-quality.ts b/frontend/src/lib/grant-quality.ts new file mode 100644 index 0000000..f045bd0 --- /dev/null +++ b/frontend/src/lib/grant-quality.ts @@ -0,0 +1,228 @@ +/** + * Grant Data Quality Score — #166 + * + * Evaluates the completeness of a GrantRecord and produces a 0–100 numeric + * score plus a letter grade. The score helps administrators identify records + * that need more information before being useful to applicants. + * + * Design decisions: + * + * • Weighted fields — not all fields are equally important. `title`, + * `funder`, and `deadline` are core identifiers and carry the most + * weight. Enrichment fields like `description`, `website`, and + * `contactEmail` add progressively more value but are less critical. + * + * • Required vs. optional — `title`, `funder`, and `deadline` are always + * present in a valid GrantRecord (they are required by the type). They + * are still scored because their *values* can be empty strings / 0, + * which counts as missing for quality purposes. + * + * • Pure functions — no side effects, no store access. Callers decide + * what to do with the results. + * + * • Admin helpers — `scoreAllGrants` and `listLowQualityGrants` let an + * admin surface and sort records by completeness in one call. + */ + +import type { + GrantQualityScore, + GrantRecord, + QualityField, + QualityGrade, +} from "@/types/grant"; + +// ── Field weights ───────────────────────────────────────────────────────────── + +/** + * Weight assigned to each quality field. + * + * Total of all weights = 100, so the numeric score equals the sum of weights + * for present fields and is directly interpretable as a percentage. + * + * Breakdown rationale: + * Core identity (title + funder + deadline) = 45 pts + * Recipient info (name + address) = 20 pts + * Financial info (amount + currency) = 15 pts + * Discoverability (description + website) = 12 pts + * Contact / classification = 8 pts + */ +export const QUALITY_FIELD_WEIGHTS: Record = { + title: 15, + funder: 15, + deadline: 15, + recipientName: 10, + recipientAddress: 10, + amount: 8, + currency: 7, + description: 8, + website: 4, + contactEmail: 4, + category: 4, +}; + +/** All fields that are assessed, in a stable order for display. */ +export const QUALITY_FIELDS: readonly QualityField[] = [ + "title", + "funder", + "deadline", + "description", + "website", + "contactEmail", + "category", + "amount", + "currency", + "recipientName", + "recipientAddress", +]; + +// Compile-time check: weights sum to 100. +const _weightSum = Object.values(QUALITY_FIELD_WEIGHTS).reduce((a, b) => a + b, 0); +if (_weightSum !== 100) { + throw new Error( + `QUALITY_FIELD_WEIGHTS must sum to 100, but got ${_weightSum}. Fix the weight table.`, + ); +} + +// ── Grade thresholds ────────────────────────────────────────────────────────── + +/** Maps inclusive lower bounds to a letter grade. Evaluated from highest down. */ +export const GRADE_THRESHOLDS: { min: number; grade: QualityGrade }[] = [ + { min: 90, grade: "A" }, + { min: 75, grade: "B" }, + { min: 60, grade: "C" }, + { min: 40, grade: "D" }, + { min: 0, grade: "F" }, +]; + +export function scoreToGrade(score: number): QualityGrade { + for (const { min, grade } of GRADE_THRESHOLDS) { + if (score >= min) return grade; + } + return "F"; +} + +// ── Field presence check ────────────────────────────────────────────────────── + +/** + * Returns `true` when a field value is considered "present" for scoring + * purposes. + * + * • `title` / `funder` — non-empty string after trimming + * • `deadline` — positive non-zero number + * • `amount` — positive non-zero number + * • everything else — truthy after trimming (for strings) or simply truthy + */ +export function isFieldPresent(grant: GrantRecord, field: QualityField): boolean { + switch (field) { + case "title": + case "funder": + return typeof grant[field] === "string" && grant[field].trim().length > 0; + + case "deadline": + return typeof grant.deadline === "number" && grant.deadline > 0; + + case "amount": + return typeof grant.amount === "number" && grant.amount > 0; + + case "description": + case "website": + case "contactEmail": + case "category": + case "currency": + case "recipientName": + case "recipientAddress": + return ( + typeof grant[field] === "string" && + (grant[field] as string).trim().length > 0 + ); + } +} + +// ── Core scoring function ───────────────────────────────────────────────────── + +/** + * Compute a data quality score for one grant record. + * + * @returns A `GrantQualityScore` with the numeric score, letter grade, + * lists of present and missing fields, and a boolean shorthand. + */ +export function scoreGrantQuality(grant: GrantRecord): GrantQualityScore { + const presentFields: QualityField[] = []; + const missingFields: QualityField[] = []; + let score = 0; + + for (const field of QUALITY_FIELDS) { + if (isFieldPresent(grant, field)) { + presentFields.push(field); + score += QUALITY_FIELD_WEIGHTS[field]; + } else { + missingFields.push(field); + } + } + + // Clamp to [0, 100] to guard against future weight-sum drift. + const clampedScore = Math.min(100, Math.max(0, score)); + + return { + score: clampedScore, + grade: scoreToGrade(clampedScore), + presentFields, + missingFields, + isComplete: missingFields.length === 0, + }; +} + +// ── Admin helpers ───────────────────────────────────────────────────────────── + +export interface ScoredGrant { + grant: GrantRecord; + quality: GrantQualityScore; +} + +/** + * Score every grant in the list and return grant+quality pairs, sorted + * from lowest to highest quality score so the worst records appear first. + * + * Admins can immediately see which records need attention without having + * to sort the list themselves. + */ +export function scoreAllGrants(grants: readonly GrantRecord[]): ScoredGrant[] { + return grants + .map((grant) => ({ grant, quality: scoreGrantQuality(grant) })) + .sort((a, b) => a.quality.score - b.quality.score); +} + +/** + * Return only grants whose quality score falls below `threshold` (default + * 75 — grade C and below). Results are sorted worst-first. + * + * Use this to build admin dashboards that focus attention on the records + * that need improvement. + */ +export function listLowQualityGrants( + grants: readonly GrantRecord[], + threshold = 75, +): ScoredGrant[] { + return scoreAllGrants(grants).filter(({ quality }) => quality.score < threshold); +} + +/** + * Return a human-readable summary of what is missing from a grant record. + * + * Empty string when the record is complete. + * + * Example: "Missing fields: description, website, contactEmail" + */ +export function formatQualitySummary(quality: GrantQualityScore): string { + if (quality.isComplete) return ""; + return `Missing fields: ${quality.missingFields.join(", ")}`; +} + +/** + * Convenience function: score a single grant and return a short label + * suitable for display in a table, e.g. "B (85)". + */ +export function formatQualityLabel(grant: GrantRecord): string { + const { score, grade } = scoreGrantQuality(grant); + return `${grade} (${score})`; +} diff --git a/frontend/src/types/grant.ts b/frontend/src/types/grant.ts index ec74373..876c628 100644 --- a/frontend/src/types/grant.ts +++ b/frontend/src/types/grant.ts @@ -3,6 +3,10 @@ export type GrantStatus = "saved" | "active" | "expired"; /** * A grant the user has saved (bookmarked) or activated (applied for / * is tracking). `deadline` is a Unix timestamp in seconds. + * + * Enrichment fields are optional — records may be partial. The quality + * scoring system uses their presence/absence to produce a completeness + * score that helps admins prioritise which records need attention. */ export interface GrantRecord { id: string; @@ -15,7 +19,75 @@ export interface GrantRecord { status: GrantStatus; /** Wallet address of the user who saved/activated the grant. */ owner: string; - createdAt: string; + createdAt: string | Date; + + // ── Enrichment fields (optional) ───────────────────────────────────────── + /** Human-readable description of what the grant funds. */ + description?: string; + /** URL of the grant's official page. */ + website?: string; + /** Public contact e-mail for enquiries. */ + contactEmail?: string; + /** Thematic category, e.g. "tooling", "education", "infrastructure". */ + category?: string; + /** Grant award amount. */ + amount?: number; + /** ISO 4217 currency code for the award, e.g. "USDC", "XLM". */ + currency?: string; + /** Name of the recipient or applying organisation. */ + recipientName?: string; + /** Stellar public key of the recipient. */ + recipientAddress?: string; + /** Timestamp of the last update to this record. */ + updatedAt?: string | Date; + + // ── Restricted fields (never exported) ─────────────────────────────────── + applicantEmail?: string; + reviewerNotes?: string; + internalScore?: number; + kycReference?: string; + bankAccountNumber?: string; +} + +// ── Quality Score types ─────────────────────────────────────────────────────── + +/** + * Letter grade derived from a numeric quality score (0–100). + * + * - A 90–100 Excellent — all important fields present + * - B 75–89 Good + * - C 60–74 Acceptable + * - D 40–59 Poor — notable gaps + * - F 0–39 Incomplete — needs significant attention + */ +export type QualityGrade = "A" | "B" | "C" | "D" | "F"; + +/** The name of a field that contributes to the quality score. */ +export type QualityField = + | "title" + | "funder" + | "deadline" + | "description" + | "website" + | "contactEmail" + | "category" + | "amount" + | "currency" + | "recipientName" + | "recipientAddress"; + +/** Result of evaluating one grant's data completeness. */ +export interface GrantQualityScore { + /** 0–100 numeric completeness score. */ + score: number; + /** Letter grade derived from score. */ + grade: QualityGrade; + /** Fields that are present and contribute positively. */ + presentFields: QualityField[]; + /** Fields that are missing and would improve the score. */ + missingFields: QualityField[]; + /** True when every quality field is present. */ + isComplete: boolean; } /** @@ -37,3 +109,12 @@ export const DEFAULT_REMINDER_CONFIG: ReminderConfig = { 6 * 60 * 60, ], }; + +// ── Export authorization types ──────────────────────────────────────────────── + +export type ExportRole = "admin" | "grant_manager" | "reviewer" | "contributor"; + +export interface ExportRequester { + id: string; + role: ExportRole; +}