From 029ad95455ca4eede2bfc0c3b46b822e5bbaba89 Mon Sep 17 00:00:00 2001 From: Gautam Prajapati Date: Wed, 11 Mar 2026 22:22:45 +0530 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20Add=20Carbon=20Atlas=20=E2=80=94?= =?UTF-8?q?=20business-friendly=20multi-policy=20indexer=20frontend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carbon Atlas is a Next.js dashboard that translates Guardian's on-chain Verifiable Credentials into business-friendly views for carbon market stakeholders. Currently supports the Gold Standard MECD 431 policy (testnet) with: - Per-policy dashboards with emission reduction stats, charts, and tables - Trust chain explorer tracing issuances through the full VC relationship graph - Dedicated VC-type renderers for monitoring reports, verification reports, projects, device MRV data, and VVB registrations - Searchable device data table (3,254 metered cooking devices) - Server-side auth proxy (API tokens never exposed to client) - Hedera proof links to HashScan for every document - Document verification by consensus timestamp Tech stack: Next.js 16 (App Router), React 19, TanStack Query, shadcn/ui, Tailwind CSS 4, Vitest Multi-policy support (VM0033/Verra mainnet) planned as next phase. Signed-off-by: Gautam Prajapati --- carbon-atlas/.env.example | 5 + carbon-atlas/.gitignore | 45 + carbon-atlas/CLAUDE.md | 77 + carbon-atlas/CONTRIBUTING.md | 131 + carbon-atlas/DEPLOYMENT.md | 201 + carbon-atlas/LICENSE | 21 + carbon-atlas/README.md | 81 + carbon-atlas/__tests__/format.test.ts | 82 + carbon-atlas/__tests__/trust-chain.test.ts | 124 + carbon-atlas/__tests__/vc-documents.test.ts | 194 + carbon-atlas/app/analytics/page.tsx | 30 + carbon-atlas/app/api/proxy/[...path]/route.ts | 34 + carbon-atlas/app/dashboard/data.json | 614 + carbon-atlas/app/dashboard/page.tsx | 19 + .../app/dashboard/recent-issuances.tsx | 97 + carbon-atlas/app/devices/[messageId]/page.tsx | 70 + .../app/documents/[messageId]/page.tsx | 67 + carbon-atlas/app/globals.css | 174 + .../app/issuances/[messageId]/page.tsx | 68 + carbon-atlas/app/issuances/page.tsx | 144 + carbon-atlas/app/layout.tsx | 50 + carbon-atlas/app/not-found.tsx | 16 + carbon-atlas/app/page.tsx | 5 + .../app/projects/[messageId]/page.tsx | 72 + carbon-atlas/app/projects/page.tsx | 127 + carbon-atlas/app/verify/page.tsx | 57 + carbon-atlas/components.json | 25 + carbon-atlas/components/app-sidebar.tsx | 111 + .../components/chart-area-interactive.tsx | 291 + carbon-atlas/components/dashboard-charts.tsx | 162 + carbon-atlas/components/dashboard-layout.tsx | 27 + carbon-atlas/components/data-table.tsx | 807 + carbon-atlas/components/device-map.tsx | 111 + carbon-atlas/components/nav-documents.tsx | 92 + carbon-atlas/components/nav-main.tsx | 51 + carbon-atlas/components/nav-secondary.tsx | 42 + carbon-atlas/components/nav-user.tsx | 110 + carbon-atlas/components/section-cards.tsx | 96 + .../components/shared/DeviceDataTable.tsx | 219 + .../components/shared/FieldDisplay.tsx | 43 + .../components/shared/HederaProofBadge.tsx | 30 + .../shared/ProjectDeveloperBadge.tsx | 34 + carbon-atlas/components/site-header.tsx | 127 + .../components/trust-chain/ChainStep.tsx | 111 + .../components/trust-chain/TrustChainView.tsx | 56 + carbon-atlas/components/ui/avatar.tsx | 109 + carbon-atlas/components/ui/badge.tsx | 48 + carbon-atlas/components/ui/breadcrumb.tsx | 109 + carbon-atlas/components/ui/button-group.tsx | 83 + carbon-atlas/components/ui/button.tsx | 64 + carbon-atlas/components/ui/card.tsx | 92 + carbon-atlas/components/ui/chart.tsx | 357 + carbon-atlas/components/ui/checkbox.tsx | 32 + carbon-atlas/components/ui/command.tsx | 184 + carbon-atlas/components/ui/dialog.tsx | 158 + carbon-atlas/components/ui/drawer.tsx | 135 + carbon-atlas/components/ui/dropdown-menu.tsx | 257 + carbon-atlas/components/ui/input-group.tsx | 170 + carbon-atlas/components/ui/input.tsx | 21 + carbon-atlas/components/ui/label.tsx | 24 + carbon-atlas/components/ui/map.tsx | 1544 ++ .../components/ui/place-autocomplete.tsx | 393 + carbon-atlas/components/ui/select.tsx | 190 + carbon-atlas/components/ui/separator.tsx | 28 + carbon-atlas/components/ui/sheet.tsx | 143 + carbon-atlas/components/ui/sidebar.tsx | 726 + carbon-atlas/components/ui/skeleton.tsx | 13 + carbon-atlas/components/ui/sonner.tsx | 40 + carbon-atlas/components/ui/spinner.tsx | 16 + carbon-atlas/components/ui/table.tsx | 116 + carbon-atlas/components/ui/tabs.tsx | 91 + carbon-atlas/components/ui/textarea.tsx | 18 + carbon-atlas/components/ui/toggle-group.tsx | 83 + carbon-atlas/components/ui/toggle.tsx | 47 + carbon-atlas/components/ui/tooltip.tsx | 57 + .../components/vc-views/DeviceDataView.tsx | 43 + .../components/vc-views/GenericVCView.tsx | 39 + .../vc-views/MonitoringReportView.tsx | 188 + .../components/vc-views/ProjectView.tsx | 189 + .../components/vc-views/VCRenderer.tsx | 69 + carbon-atlas/components/vc-views/VVBView.tsx | 35 + .../vc-views/ValidationReportView.tsx | 35 + .../vc-views/VerificationReportView.tsx | 76 + carbon-atlas/eslint.config.mjs | 18 + carbon-atlas/hooks/use-mobile.ts | 19 + carbon-atlas/hooks/useDashboardStats.ts | 156 + carbon-atlas/hooks/usePolicyVcDocuments.ts | 41 + carbon-atlas/hooks/useVcDocument.ts | 14 + carbon-atlas/lib/api/client.ts | 30 + carbon-atlas/lib/api/vc-documents.ts | 85 + carbon-atlas/lib/types/indexer.ts | 97 + carbon-atlas/lib/utils.ts | 6 + carbon-atlas/lib/utils/format.ts | 49 + carbon-atlas/lib/utils/hedera.ts | 11 + carbon-atlas/lib/utils/trust-chain.ts | 153 + carbon-atlas/next.config.ts | 7 + carbon-atlas/package-lock.json | 13657 ++++++++++++++++ carbon-atlas/package.json | 63 + carbon-atlas/postcss.config.mjs | 7 + carbon-atlas/providers/QueryProvider.tsx | 39 + carbon-atlas/public/atec-dark.png | Bin 0 -> 343526 bytes carbon-atlas/public/atec-light.png | Bin 0 -> 216025 bytes carbon-atlas/public/cmhq-logo-dark.png | Bin 0 -> 34052 bytes carbon-atlas/public/cmhq-logo-light.png | Bin 0 -> 41178 bytes carbon-atlas/public/file.svg | 1 + carbon-atlas/public/globe.svg | 1 + carbon-atlas/public/logo-dark.png | Bin 0 -> 75642 bytes carbon-atlas/public/logo-light.png | Bin 0 -> 81247 bytes carbon-atlas/public/next.svg | 1 + carbon-atlas/public/screenshots/dashboard.png | Bin 0 -> 631151 bytes .../public/screenshots/device-search.png | Bin 0 -> 394763 bytes carbon-atlas/public/screenshots/issuances.png | Bin 0 -> 248207 bytes .../public/screenshots/monitoring-report.png | Bin 0 -> 397785 bytes carbon-atlas/public/screenshots/projects.png | Bin 0 -> 266482 bytes .../public/screenshots/trust-chain.png | Bin 0 -> 353574 bytes carbon-atlas/public/screenshots/verify.png | Bin 0 -> 231030 bytes carbon-atlas/public/vercel.svg | 1 + carbon-atlas/public/window.svg | 1 + carbon-atlas/tsconfig.json | 34 + carbon-atlas/vitest.config.mts | 14 + 120 files changed, 25677 insertions(+) create mode 100644 carbon-atlas/.env.example create mode 100644 carbon-atlas/.gitignore create mode 100644 carbon-atlas/CLAUDE.md create mode 100644 carbon-atlas/CONTRIBUTING.md create mode 100644 carbon-atlas/DEPLOYMENT.md create mode 100644 carbon-atlas/LICENSE create mode 100644 carbon-atlas/README.md create mode 100644 carbon-atlas/__tests__/format.test.ts create mode 100644 carbon-atlas/__tests__/trust-chain.test.ts create mode 100644 carbon-atlas/__tests__/vc-documents.test.ts create mode 100644 carbon-atlas/app/analytics/page.tsx create mode 100644 carbon-atlas/app/api/proxy/[...path]/route.ts create mode 100644 carbon-atlas/app/dashboard/data.json create mode 100644 carbon-atlas/app/dashboard/page.tsx create mode 100644 carbon-atlas/app/dashboard/recent-issuances.tsx create mode 100644 carbon-atlas/app/devices/[messageId]/page.tsx create mode 100644 carbon-atlas/app/documents/[messageId]/page.tsx create mode 100644 carbon-atlas/app/globals.css create mode 100644 carbon-atlas/app/issuances/[messageId]/page.tsx create mode 100644 carbon-atlas/app/issuances/page.tsx create mode 100644 carbon-atlas/app/layout.tsx create mode 100644 carbon-atlas/app/not-found.tsx create mode 100644 carbon-atlas/app/page.tsx create mode 100644 carbon-atlas/app/projects/[messageId]/page.tsx create mode 100644 carbon-atlas/app/projects/page.tsx create mode 100644 carbon-atlas/app/verify/page.tsx create mode 100644 carbon-atlas/components.json create mode 100644 carbon-atlas/components/app-sidebar.tsx create mode 100644 carbon-atlas/components/chart-area-interactive.tsx create mode 100644 carbon-atlas/components/dashboard-charts.tsx create mode 100644 carbon-atlas/components/dashboard-layout.tsx create mode 100644 carbon-atlas/components/data-table.tsx create mode 100644 carbon-atlas/components/device-map.tsx create mode 100644 carbon-atlas/components/nav-documents.tsx create mode 100644 carbon-atlas/components/nav-main.tsx create mode 100644 carbon-atlas/components/nav-secondary.tsx create mode 100644 carbon-atlas/components/nav-user.tsx create mode 100644 carbon-atlas/components/section-cards.tsx create mode 100644 carbon-atlas/components/shared/DeviceDataTable.tsx create mode 100644 carbon-atlas/components/shared/FieldDisplay.tsx create mode 100644 carbon-atlas/components/shared/HederaProofBadge.tsx create mode 100644 carbon-atlas/components/shared/ProjectDeveloperBadge.tsx create mode 100644 carbon-atlas/components/site-header.tsx create mode 100644 carbon-atlas/components/trust-chain/ChainStep.tsx create mode 100644 carbon-atlas/components/trust-chain/TrustChainView.tsx create mode 100644 carbon-atlas/components/ui/avatar.tsx create mode 100644 carbon-atlas/components/ui/badge.tsx create mode 100644 carbon-atlas/components/ui/breadcrumb.tsx create mode 100644 carbon-atlas/components/ui/button-group.tsx create mode 100644 carbon-atlas/components/ui/button.tsx create mode 100644 carbon-atlas/components/ui/card.tsx create mode 100644 carbon-atlas/components/ui/chart.tsx create mode 100644 carbon-atlas/components/ui/checkbox.tsx create mode 100644 carbon-atlas/components/ui/command.tsx create mode 100644 carbon-atlas/components/ui/dialog.tsx create mode 100644 carbon-atlas/components/ui/drawer.tsx create mode 100644 carbon-atlas/components/ui/dropdown-menu.tsx create mode 100644 carbon-atlas/components/ui/input-group.tsx create mode 100644 carbon-atlas/components/ui/input.tsx create mode 100644 carbon-atlas/components/ui/label.tsx create mode 100644 carbon-atlas/components/ui/map.tsx create mode 100644 carbon-atlas/components/ui/place-autocomplete.tsx create mode 100644 carbon-atlas/components/ui/select.tsx create mode 100644 carbon-atlas/components/ui/separator.tsx create mode 100644 carbon-atlas/components/ui/sheet.tsx create mode 100644 carbon-atlas/components/ui/sidebar.tsx create mode 100644 carbon-atlas/components/ui/skeleton.tsx create mode 100644 carbon-atlas/components/ui/sonner.tsx create mode 100644 carbon-atlas/components/ui/spinner.tsx create mode 100644 carbon-atlas/components/ui/table.tsx create mode 100644 carbon-atlas/components/ui/tabs.tsx create mode 100644 carbon-atlas/components/ui/textarea.tsx create mode 100644 carbon-atlas/components/ui/toggle-group.tsx create mode 100644 carbon-atlas/components/ui/toggle.tsx create mode 100644 carbon-atlas/components/ui/tooltip.tsx create mode 100644 carbon-atlas/components/vc-views/DeviceDataView.tsx create mode 100644 carbon-atlas/components/vc-views/GenericVCView.tsx create mode 100644 carbon-atlas/components/vc-views/MonitoringReportView.tsx create mode 100644 carbon-atlas/components/vc-views/ProjectView.tsx create mode 100644 carbon-atlas/components/vc-views/VCRenderer.tsx create mode 100644 carbon-atlas/components/vc-views/VVBView.tsx create mode 100644 carbon-atlas/components/vc-views/ValidationReportView.tsx create mode 100644 carbon-atlas/components/vc-views/VerificationReportView.tsx create mode 100644 carbon-atlas/eslint.config.mjs create mode 100644 carbon-atlas/hooks/use-mobile.ts create mode 100644 carbon-atlas/hooks/useDashboardStats.ts create mode 100644 carbon-atlas/hooks/usePolicyVcDocuments.ts create mode 100644 carbon-atlas/hooks/useVcDocument.ts create mode 100644 carbon-atlas/lib/api/client.ts create mode 100644 carbon-atlas/lib/api/vc-documents.ts create mode 100644 carbon-atlas/lib/types/indexer.ts create mode 100644 carbon-atlas/lib/utils.ts create mode 100644 carbon-atlas/lib/utils/format.ts create mode 100644 carbon-atlas/lib/utils/hedera.ts create mode 100644 carbon-atlas/lib/utils/trust-chain.ts create mode 100644 carbon-atlas/next.config.ts create mode 100644 carbon-atlas/package-lock.json create mode 100644 carbon-atlas/package.json create mode 100644 carbon-atlas/postcss.config.mjs create mode 100644 carbon-atlas/providers/QueryProvider.tsx create mode 100644 carbon-atlas/public/atec-dark.png create mode 100644 carbon-atlas/public/atec-light.png create mode 100644 carbon-atlas/public/cmhq-logo-dark.png create mode 100644 carbon-atlas/public/cmhq-logo-light.png create mode 100644 carbon-atlas/public/file.svg create mode 100644 carbon-atlas/public/globe.svg create mode 100644 carbon-atlas/public/logo-dark.png create mode 100644 carbon-atlas/public/logo-light.png create mode 100644 carbon-atlas/public/next.svg create mode 100644 carbon-atlas/public/screenshots/dashboard.png create mode 100644 carbon-atlas/public/screenshots/device-search.png create mode 100644 carbon-atlas/public/screenshots/issuances.png create mode 100644 carbon-atlas/public/screenshots/monitoring-report.png create mode 100644 carbon-atlas/public/screenshots/projects.png create mode 100644 carbon-atlas/public/screenshots/trust-chain.png create mode 100644 carbon-atlas/public/screenshots/verify.png create mode 100644 carbon-atlas/public/vercel.svg create mode 100644 carbon-atlas/public/window.svg create mode 100644 carbon-atlas/tsconfig.json create mode 100644 carbon-atlas/vitest.config.mts diff --git a/carbon-atlas/.env.example b/carbon-atlas/.env.example new file mode 100644 index 0000000000..331841c686 --- /dev/null +++ b/carbon-atlas/.env.example @@ -0,0 +1,5 @@ +NEXT_PUBLIC_POLICY_HEDERA_ID=1767599197.624837133 +NEXT_PUBLIC_POLICY_MONGO_ID=695b6ae90ea0fa317cdc01c4 +NEXT_PUBLIC_HEDERA_NETWORK=testnet +INDEXER_API_URL=https://indexer.guardianservice.app/api/v1/testnet +INDEXER_API_TOKEN=your_bearer_token_here diff --git a/carbon-atlas/.gitignore b/carbon-atlas/.gitignore new file mode 100644 index 0000000000..35477d02af --- /dev/null +++ b/carbon-atlas/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* +!.env.example + +# internal claude context (not for public repo) +.claude/ + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/carbon-atlas/CLAUDE.md b/carbon-atlas/CLAUDE.md new file mode 100644 index 0000000000..90634286c0 --- /dev/null +++ b/carbon-atlas/CLAUDE.md @@ -0,0 +1,77 @@ +# CLAUDE.md — MECD Indexer + +## Project Overview + +Public-facing Next.js dashboard for exploring carbon credit issuances from the [Gold Standard MECD 431](https://globalgoals.goldstandard.org/431_ee_ics_methodology-for-metered-measured-energy-cooking-devices/) methodology — Metered & Measured Energy Cooking Devices. The methodology is digitized on [Hedera Guardian](https://github.com/hashgraph/guardian), an open-source MRV platform using Hedera Hashgraph DLT. + +**Stack:** Next.js 16 (App Router) | React 19 | TanStack Query | shadcn/ui | Tailwind CSS 4 | Vitest + +## Architecture + +``` +Guardian Indexer API + → /api/proxy/[...path] (server-side auth proxy) + → TanStack Query (client-side caching) + → React components +``` + +- **Auth proxy:** `app/api/proxy/[...path]/route.ts` injects a Bearer JWT server-side so the token never reaches the client bundle. +- **Caching:** TanStack Query with 15 min staleTime, 1 hr gcTime. All policy VCs are fetched once and filtered client-side. +- **Theming:** `next-themes` with system default, dark/light toggle in header. + +## Key Files + +| File | Purpose | +|---|---| +| `app/api/proxy/[...path]/route.ts` | Auth proxy — injects Bearer token server-side | +| `lib/api/vc-documents.ts` | API client — getVcDocuments, getAllPolicyVcs, parseCredentialSubject | +| `lib/utils/trust-chain.ts` | buildChain(), ENTITY_TYPE_CONFIG, getProjectDevelopers | +| `hooks/usePolicyVcDocuments.ts` | TanStack Query hooks with client-side filtering/pagination | +| `hooks/useDashboardStats.ts` | Aggregates real tCO₂e and device counts from VC details | +| `components/vc-views/VCRenderer.tsx` | Routes entity types to specific renderers | +| `components/trust-chain/TrustChainView.tsx` | Trust chain visualization | +| `components/section-cards.tsx` | Dashboard stat cards with live data | + +## Entity Types + +| Entity Type | Description | +|---|---| +| `approved_report` | Verified monitoring report (carbon credit issuance) | +| `report` | Calculated monitoring report | +| `verification_report` | VVB verification report | +| `validation_report` | VVB validation report | +| `daily_mrv_report` | Aggregated device MRV data | +| `approved_project` | Validated project | +| `project` | Calculated project (auto-completed fields) | +| `project_form` | Raw Project Design Document submission | +| `approved_vvb` | Approved Validation & Verification Body | +| `vvb` | VVB registration | + +## Development + +```bash +npm install +cp .env.example .env.local # Add Guardian Indexer API token +npm run dev # http://localhost:3000 +npm test # Vitest +npm run build # Type-check + production build +``` + +### Environment Variables + +See `.env.example` for the full list. The `INDEXER_API_TOKEN` is a Bearer JWT for the Guardian Indexer API — must be set server-side only. + +## Testing + +Tests are in `__tests__/` and use Vitest with `environment: "node"` (not jsdom — the tests are pure logic, no DOM needed). + +```bash +npm test # Run all tests +npm run test:watch # Watch mode +``` + +## Branding + +- **CarbonMarketsHQ:** `public/cmhq-logo-dark.png`, `public/cmhq-logo-light.png` — sidebar footer +- **ATEC Global:** `public/atec-dark.png`, `public/atec-light.png` — project developer badge +- All logos have dark/light variants for theme support diff --git a/carbon-atlas/CONTRIBUTING.md b/carbon-atlas/CONTRIBUTING.md new file mode 100644 index 0000000000..64580ccf33 --- /dev/null +++ b/carbon-atlas/CONTRIBUTING.md @@ -0,0 +1,131 @@ +# Contributing to MECD Indexer + +Thanks for your interest in contributing! This project is a public dashboard for exploring carbon credit issuances from the Gold Standard MECD 431 methodology on Hedera Guardian. + +## Getting Started + +### Prerequisites + +- **Node.js** 20+ +- **npm** 10+ +- A **Guardian Indexer API token** (Bearer JWT) — reach out to the maintainers or generate one from the [Guardian Indexer](https://indexer.guardianservice.app) + +### Setup + +```bash +git clone https://github.com/gautamp8/mecd-indexer.git +cd mecd-indexer +npm install +cp .env.example .env.local +``` + +Edit `.env.local` and add your API token: + +``` +INDEXER_API_TOKEN=your_bearer_jwt_here +INDEXER_API_URL=https://indexer.guardianservice.app/api/v1/testnet +NEXT_PUBLIC_POLICY_HEDERA_ID=1767599197.624837133 +NEXT_PUBLIC_HEDERA_NETWORK=testnet +``` + +Start the dev server: + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000). + +## Architecture Overview + +``` +Guardian Indexer API + -> /api/proxy/[...path] (server-side auth proxy, injects Bearer token) + -> TanStack Query (client-side caching, 15 min stale / 1 hr gc) + -> React components +``` + +- **Auth proxy** (`app/api/proxy/[...path]/route.ts`): All API calls go through this server-side proxy so the JWT token never reaches the client bundle. +- **Data fetching** (`lib/api/vc-documents.ts`): Fetches all policy VCs in one call, filters client-side by entity type (the indexer API ignores `entityType` filter params). +- **VC renderers** (`components/vc-views/`): Each entity type has a dedicated renderer. `VCRenderer.tsx` dispatches based on `entityType`. +- **Trust chain** (`lib/utils/trust-chain.ts`): Traverses VC relationships depth-first from a root document. + +## Project Structure + +``` +app/ # Next.js App Router pages + api/proxy/ # Auth proxy to Guardian Indexer API + dashboard/ # Overview with stats and recent issuances + issuances/ # Issuance list + trust chain detail + projects/ # Project list + detail +components/ + vc-views/ # Entity-type-specific VC renderers + trust-chain/ # Trust chain visualization + shared/ # Reusable components (DeviceDataTable, HederaProofBadge, etc.) + ui/ # shadcn/ui components +hooks/ # TanStack Query hooks +lib/ + api/ # API client (fetchProxy, vc-documents) + types/ # TypeScript interfaces + utils/ # Formatting, Hedera URLs, trust chain logic +__tests__/ # Vitest tests +``` + +## Development Guidelines + +### Code Style + +- **TypeScript** — all code is typed. Run `npm run build` to type-check. +- **Tailwind CSS 4** — utility-first styling. Use `cn()` from `lib/utils.ts` for conditional classes. +- **shadcn/ui** — all UI components come from shadcn. Add new ones with `npx shadcn@latest add `. +- **No CSS modules or styled-components** — Tailwind only. + +### Adding a New VC Renderer + +1. Create a new component in `components/vc-views/` (e.g., `NewEntityView.tsx`) +2. The component receives `cs` (parsed credential subject) and `rawDocuments` as props +3. Use the `get(obj, "dotted.path")` helper pattern for nested field access (see existing renderers) +4. Add a case to the switch in `components/vc-views/VCRenderer.tsx` + +### Testing + +Tests use Vitest with `environment: "node"` (pure logic, no DOM). + +```bash +npm test # Run all tests +npm run test:watch # Watch mode +``` + +Add tests in the `__tests__/` directory. Focus on: +- Data transformation logic (trust chain building, VC parsing) +- API client behavior (pagination, filtering) + +### Key API Quirks + +These are documented in detail in `CLAUDE.md` but the critical ones: + +1. **`options.entityType` filter is ignored by the API** — all filtering must be done client-side +2. **Individual VC lookup uses `consensusTimestamp`** as the path param, not the MongoDB `id` +3. **`documents[]` in list responses** contain MongoDB ref IDs (useless). Only the detail endpoint returns full VC JSON. + +## Making Changes + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/your-feature` +3. Make your changes +4. Run tests: `npm test` +5. Run type check: `npm run build` +6. Commit with a clear message describing what and why +7. Push and open a pull request + +## Reporting Issues + +Open an issue on [GitHub](https://github.com/gautamp8/mecd-indexer/issues) with: +- What you expected to happen +- What actually happened +- Steps to reproduce +- Browser/OS if relevant + +## License + +MIT — see [LICENSE](LICENSE) for details. diff --git a/carbon-atlas/DEPLOYMENT.md b/carbon-atlas/DEPLOYMENT.md new file mode 100644 index 0000000000..a4052bbac5 --- /dev/null +++ b/carbon-atlas/DEPLOYMENT.md @@ -0,0 +1,201 @@ +# Deployment Guide + +This guide covers deploying the MECD Indexer dashboard to various hosting platforms. + +## Prerequisites + +- Node.js 20+ +- A **Guardian Indexer API token** (Bearer JWT) +- The Guardian Indexer API must be accessible from your deployment environment + +## Environment Variables + +| Variable | Required | Description | +|---|---|---| +| `INDEXER_API_URL` | Yes | Guardian Indexer API base URL (e.g., `https://indexer.guardianservice.app/api/v1/testnet`) | +| `INDEXER_API_TOKEN` | Yes | Bearer JWT for the Guardian Indexer API. **Server-side only** — never expose to client. | +| `NEXT_PUBLIC_POLICY_HEDERA_ID` | Yes | Hedera topic ID for the policy (e.g., `1767599197.624837133`) | +| `NEXT_PUBLIC_POLICY_MONGO_ID` | No | MongoDB ID for the policy (not currently used in queries) | +| `NEXT_PUBLIC_HEDERA_NETWORK` | Yes | `testnet` or `mainnet` — used for Hedera explorer links | + +**Important:** `INDEXER_API_TOKEN` and `INDEXER_API_URL` are server-side only. They are used by the auth proxy route (`app/api/proxy/[...path]/route.ts`) and must never be prefixed with `NEXT_PUBLIC_`. + +## Option 1: Vercel (Recommended) + +Vercel is the native hosting platform for Next.js. + +### Steps + +1. Push your code to GitHub/GitLab/Bitbucket + +2. Import the repository on [vercel.com/new](https://vercel.com/new) + +3. Add environment variables in the Vercel dashboard: + - Go to **Settings > Environment Variables** + - Add all variables from the table above + - Ensure `INDEXER_API_TOKEN` and `INDEXER_API_URL` are **not** exposed to the client (Vercel handles this automatically for non-`NEXT_PUBLIC_` vars) + +4. Deploy — Vercel auto-detects Next.js and configures the build + +### Vercel-Specific Notes + +- The auth proxy route runs as a Vercel Serverless Function +- Server-side caching (`next: { revalidate: 600 }`) works with Vercel's ISR +- `Cache-Control: s-maxage=600, stale-while-revalidate=3600` is respected by Vercel's CDN + +## Option 2: Docker + +### Dockerfile + +Create a `Dockerfile` in the project root: + +```dockerfile +FROM node:20-alpine AS base + +# Install dependencies +FROM base AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +# Build +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +# Production +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +CMD ["node", "server.js"] +``` + +For standalone output, add to `next.config.ts`: + +```ts +const nextConfig: NextConfig = { + output: "standalone", +}; +``` + +### Build and Run + +```bash +docker build -t mecd-indexer . +docker run -p 3000:3000 \ + -e INDEXER_API_URL=https://indexer.guardianservice.app/api/v1/testnet \ + -e INDEXER_API_TOKEN=your_token_here \ + -e NEXT_PUBLIC_POLICY_HEDERA_ID=1767599197.624837133 \ + -e NEXT_PUBLIC_HEDERA_NETWORK=testnet \ + mecd-indexer +``` + +## Option 3: Node.js Server + +Build and run directly with Node.js: + +```bash +npm install +npm run build +npm start +``` + +The app starts on port 3000 by default. Set `PORT` env var to change it. + +### With PM2 (Process Manager) + +```bash +npm install -g pm2 +npm run build +pm2 start npm --name "mecd-indexer" -- start +pm2 save +pm2 startup # Auto-start on reboot +``` + +### Behind Nginx (Reverse Proxy) + +```nginx +server { + listen 80; + server_name your-domain.com; + + location / { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } +} +``` + +## Option 4: Cloudflare Pages + +Next.js on Cloudflare Pages requires the `@cloudflare/next-on-pages` adapter. + +1. Install: `npm install @cloudflare/next-on-pages` +2. Add to `package.json` scripts: `"pages:build": "npx @cloudflare/next-on-pages"` +3. Deploy via Cloudflare dashboard or Wrangler CLI +4. Add environment variables in the Cloudflare dashboard + +**Note:** Cloudflare Pages has [some limitations](https://developers.cloudflare.com/pages/framework-guides/nextjs/) with Next.js features. The auth proxy route should work as an Edge Function, but test thoroughly. + +## Option 5: AWS (Amplify or EC2) + +### AWS Amplify + +1. Connect your GitHub repo in the [Amplify Console](https://console.aws.amazon.com/amplify/) +2. Amplify auto-detects Next.js +3. Add environment variables in **App settings > Environment variables** +4. Deploy + +### AWS EC2 + +Use the Node.js Server approach (Option 3) on an EC2 instance with PM2 and Nginx. + +## Connecting to a Different Guardian Indexer + +To point the dashboard at a different Guardian Indexer instance or policy: + +1. **Change the API URL:** Set `INDEXER_API_URL` to your Guardian Indexer's base URL (e.g., `https://your-indexer.example.com/api/v1/testnet`) + +2. **Get an API token:** Obtain a Bearer JWT from your Guardian Indexer instance + +3. **Find your policy's Hedera topic ID:** This is the `analytics.policyId` value in your policy's VC documents. You can find it on [HashScan](https://hashscan.io/) by looking at your policy's Hedera Consensus Service topic. + +4. **Set the network:** `testnet` or `mainnet` depending on where your Guardian instance runs + +5. **Entity types:** The current dashboard supports the MECD 431 entity types. If your policy uses different entity types, you'll need to update `lib/utils/trust-chain.ts` (entity type config) and add VC renderers in `components/vc-views/`. + +## Monitoring and Health Checks + +The app exposes no dedicated health endpoint, but you can check: + +- **`GET /`** — returns 200 if the app is running (redirects to `/dashboard`) +- **`GET /api/proxy/entities/vc-documents?pageSize=1`** — returns 200 if the API proxy and Guardian Indexer connection are working + +## Troubleshooting + +| Problem | Solution | +|---|---| +| Blank page, no data | Check `INDEXER_API_TOKEN` is set and valid (JWTs expire) | +| API proxy returns 401 | Token expired — get a new JWT from the Guardian Indexer | +| API proxy returns 500 | Check `INDEXER_API_URL` is correct and the Guardian Indexer is reachable | +| Build fails with type errors | Run `npm install` first, ensure Node.js 20+ | +| `NEXT_PUBLIC_` vars not working | These are baked in at build time — rebuild after changing them | diff --git a/carbon-atlas/LICENSE b/carbon-atlas/LICENSE new file mode 100644 index 0000000000..0de8199fab --- /dev/null +++ b/carbon-atlas/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Gautam Prajapati + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/carbon-atlas/README.md b/carbon-atlas/README.md new file mode 100644 index 0000000000..45d6e3ad0f --- /dev/null +++ b/carbon-atlas/README.md @@ -0,0 +1,81 @@ +# MECD Indexer + +Public dashboard for the [Gold Standard MECD 431](https://globalgoals.goldstandard.org/431_ee_ics_methodology-for-metered-measured-energy-cooking-devices/) methodology — Metered & Measured Energy Cooking Devices. + +This indexer provides a transparent, read-only view of carbon credit issuances from a digitized MECD methodology running on [Hedera Guardian](https://github.com/hashgraph/guardian). + +## Screenshots + +### Dashboard +![Dashboard](public/screenshots/dashboard.png) + +### Issuances +![Issuances](public/screenshots/issuances.png) + +### Projects +![Projects](public/screenshots/projects.png) + +### Trust Chain +![Trust Chain](public/screenshots/trust-chain.png) + +### Monitoring Report +![Monitoring Report](public/screenshots/monitoring-report.png) + +### Device ID Search +![Device ID Search](public/screenshots/device-search.png) + +### Verify Document +![Verify](public/screenshots/verify.png) + +## Features + +- **Trust Chain Explorer** — Trace any issuance back to its project origin through the full Verifiable Credential chain +- **VC-Type Renderers** — Dedicated views for monitoring reports, verification reports, projects, device MRV data, and VVB registrations +- **Device Data Table** — Browse 3,254 metered cooking device records with search, sort, and pagination +- **Hedera Proof Links** — Every document links to its on-chain Hedera Consensus Service message +- **API Proxy** — Server-side auth proxy to the Guardian Indexer API (tokens never exposed to client) + +## Tech Stack + +Next.js 16 | React 19 | TanStack Query | shadcn/ui | Tailwind CSS 4 | Vitest + +## Setup + +```bash +npm install +cp .env.example .env.local # Add your Guardian Indexer API token +npm run dev # http://localhost:3000 +``` + +## Testing + +```bash +npm test # Run all tests +npm run test:watch # Watch mode +``` + +## Project Structure + +``` +app/ # Next.js App Router pages + api/proxy/ # Auth proxy to Guardian Indexer + dashboard/ # Overview with stats and recent issuances + issuances/ # Issuance list + trust chain detail + projects/ # Project list + detail +components/ + vc-views/ # Entity-type-specific VC renderers + trust-chain/ # Trust chain visualization + shared/ # Reusable components (DeviceDataTable, HederaProofBadge) +lib/ + api/ # API client and data fetching + types/ # TypeScript DTOs + utils/ # Formatting, Hedera URLs, trust chain logic +``` + +## License + +MIT + +--- + +Built by [CarbonMarketsHQ](https://carbonmarketshq.com) diff --git a/carbon-atlas/__tests__/format.test.ts b/carbon-atlas/__tests__/format.test.ts new file mode 100644 index 0000000000..05bda56ca1 --- /dev/null +++ b/carbon-atlas/__tests__/format.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest" +import { + formatTimestamp, + formatTimestampFull, + formatTCO2e, + shortenDid, + formatKWh, +} from "@/lib/utils/format" + +describe("formatTimestamp", () => { + it("converts Hedera consensus timestamp to readable date", () => { + // 1767599197 = ~2025-12-05 in UTC + const result = formatTimestamp("1767599197.624837133") + expect(result).toMatch(/\w+ \d+, \d{4}/) + }) + + it("returns dash for empty input", () => { + expect(formatTimestamp("")).toBe("—") + }) + + it("returns raw string for non-numeric input", () => { + expect(formatTimestamp("not-a-timestamp")).toBe("not-a-timestamp") + }) +}) + +describe("formatTimestampFull", () => { + it("includes time component", () => { + const result = formatTimestampFull("1767599197.624837133") + // Should contain hour:minute in addition to date + expect(result).toMatch(/\d{1,2}:\d{2}/) + }) + + it("returns dash for empty input", () => { + expect(formatTimestampFull("")).toBe("—") + }) +}) + +describe("formatTCO2e", () => { + it("formats a number with tCO2e suffix", () => { + expect(formatTCO2e(123.456)).toMatch(/123\.46 tCO/) + }) + + it("returns dash for undefined", () => { + expect(formatTCO2e(undefined)).toBe("—") + }) + + it("returns dash for null", () => { + expect(formatTCO2e(null)).toBe("—") + }) + + it("returns dash for NaN", () => { + expect(formatTCO2e(NaN)).toBe("—") + }) +}) + +describe("shortenDid", () => { + it("shortens a long DID", () => { + const did = "did:hedera:testnet:En4cNG6kwvQ8aufrYeP3SqkHKRmGDG8BU3q4U6kXSfkF_0.0.7561092" + const result = shortenDid(did) + expect(result.length).toBeLessThan(did.length) + expect(result).toContain("…") + expect(result.startsWith("did:hedera:testn")).toBe(true) + }) + + it("returns full string for short DID", () => { + expect(shortenDid("did:short")).toBe("did:short") + }) + + it("returns dash for undefined", () => { + expect(shortenDid(undefined)).toBe("—") + }) +}) + +describe("formatKWh", () => { + it("formats kWh value", () => { + expect(formatKWh(1234.5)).toMatch(/1,234\.5 kWh/) + }) + + it("returns dash for undefined", () => { + expect(formatKWh(undefined)).toBe("—") + }) +}) diff --git a/carbon-atlas/__tests__/trust-chain.test.ts b/carbon-atlas/__tests__/trust-chain.test.ts new file mode 100644 index 0000000000..f9f3418dcc --- /dev/null +++ b/carbon-atlas/__tests__/trust-chain.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest" +import { buildChain, getProjectDevelopers, ENTITY_TYPE_CONFIG } from "@/lib/utils/trust-chain" +import type { VCListItem } from "@/lib/types/indexer" + +// Mirrors the real 10-VC dataset from the MECD policy +function makeVc( + consensusTimestamp: string, + entityType: string, + relationships: string[] = [] +): VCListItem { + return { + id: `mongo_${consensusTimestamp}`, + consensusTimestamp, + topicId: "0.0.7561138", + options: { + entityType: entityType as VCListItem["options"]["entityType"], + relationships, + documentStatus: "NEW", + issuer: `did:hedera:testnet:issuer_${entityType}`, + }, + analytics: { + policyId: "1767599197.624837133", + schemaId: "schema_1", + schemaName: "Test", + }, + files: [], + } +} + +// Real relationship graph from the MECD policy +const vvb = makeVc("1767599469.174809683", "vvb", ["1767599430.841141131"]) +const approvedVvb = makeVc("1767599618.917681000", "approved_vvb", ["1767599469.174809683"]) +const projectForm = makeVc("1767599555.977638000", "project_form", ["1767599482.842853323"]) +const project = makeVc("1767599565.954854000", "project", ["1767599555.977638000", "1767599482.842853323"]) +const approvedProject = makeVc("1767599639.104282237", "approved_project", ["1767599565.954854000", "1767599618.917681000"]) +const validationReport = makeVc("1767599673.705876458", "validation_report", ["1767599639.104282237", "1767599430.841141131"]) +const dailyMrv = makeVc("1767599794.276501000", "daily_mrv_report", ["1767599565.954854000"]) +const report = makeVc("1767600603.136402856", "report", ["1767599794.276501000", "1767599482.842853323"]) +const approvedReport = makeVc("1767600748.312578844", "approved_report", ["1767600603.136402856"]) +const verificationReport = makeVc("1767601105.625149000", "verification_report", ["1767599430.841141131"]) + +const allVcs: VCListItem[] = [ + dailyMrv, approvedVvb, approvedProject, verificationReport, + approvedReport, validationReport, vvb, projectForm, project, report, +] + +describe("buildChain", () => { + it("traverses from approved_report root through relationships", () => { + const chain = buildChain(allVcs, "1767600748.312578844") + expect(chain.length).toBeGreaterThan(0) + // Root should be the approved_report + expect(chain[0].entityType).toBe("approved_report") + expect(chain[0].depth).toBe(0) + }) + + it("includes report, daily_mrv_report in chain from approved_report", () => { + const chain = buildChain(allVcs, "1767600748.312578844") + const types = chain.map((n) => n.entityType) + expect(types).toContain("report") + expect(types).toContain("daily_mrv_report") + }) + + it("follows nested relationships to project data", () => { + const chain = buildChain(allVcs, "1767600748.312578844") + const types = chain.map((n) => n.entityType) + // approved_report -> report -> daily_mrv_report -> project + expect(types).toContain("project") + }) + + it("returns empty array for unknown root", () => { + const chain = buildChain(allVcs, "9999999999.000000000") + expect(chain).toEqual([]) + }) + + it("does not revisit nodes (prevents cycles)", () => { + const chain = buildChain(allVcs, "1767600748.312578844") + const timestamps = chain.map((n) => n.vc.consensusTimestamp) + expect(new Set(timestamps).size).toBe(timestamps.length) + }) + + it("each node has a valid config from ENTITY_TYPE_CONFIG", () => { + const chain = buildChain(allVcs, "1767600748.312578844") + for (const node of chain) { + expect(ENTITY_TYPE_CONFIG[node.entityType]).toBeDefined() + expect(node.config.label).toBeTruthy() + expect(node.config.color).toBeTruthy() + } + }) + + it("handles root with no relationships gracefully", () => { + const isolated = makeVc("9999999999.111111000", "vvb", []) + const chain = buildChain([...allVcs, isolated], "9999999999.111111000") + expect(chain).toHaveLength(1) + expect(chain[0].entityType).toBe("vvb") + }) +}) + +describe("getProjectDevelopers", () => { + it("extracts issuers from project_form VCs", () => { + const devs = getProjectDevelopers(allVcs) + expect(devs.length).toBeGreaterThan(0) + expect(devs[0]).toContain("did:hedera:testnet:issuer_project_form") + }) + + it("returns empty array when no project_form VCs exist", () => { + const nonProjectVcs = allVcs.filter( + (vc) => vc.options.entityType !== "project_form" + ) + expect(getProjectDevelopers(nonProjectVcs)).toEqual([]) + }) +}) + +describe("ENTITY_TYPE_CONFIG", () => { + it("covers all 10 entity types", () => { + const expected = [ + "approved_report", "verification_report", "report", "daily_mrv_report", + "approved_project", "validation_report", "project_form", "project", + "approved_vvb", "vvb", + ] + for (const et of expected) { + expect(ENTITY_TYPE_CONFIG[et as keyof typeof ENTITY_TYPE_CONFIG]).toBeDefined() + } + }) +}) diff --git a/carbon-atlas/__tests__/vc-documents.test.ts b/carbon-atlas/__tests__/vc-documents.test.ts new file mode 100644 index 0000000000..f9326e61fe --- /dev/null +++ b/carbon-atlas/__tests__/vc-documents.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" + +// Mock fetch globally before importing modules +const mockFetch = vi.fn() +vi.stubGlobal("fetch", mockFetch) + +import { getAllPolicyVcs, parseCredentialSubject } from "@/lib/api/vc-documents" +import type { VCDetail, VCListItem } from "@/lib/types/indexer" + +function makeListItem( + consensusTimestamp: string, + entityType: string +): VCListItem { + return { + id: `mongo_${consensusTimestamp}`, + consensusTimestamp, + topicId: "0.0.7561138", + options: { + entityType: entityType as VCListItem["options"]["entityType"], + relationships: [], + documentStatus: "NEW", + issuer: "did:hedera:testnet:test", + }, + analytics: { + policyId: "1767599197.624837133", + schemaId: "s1", + schemaName: "Test", + }, + files: [], + } +} + +const sampleItems: VCListItem[] = [ + makeListItem("1.000", "approved_report"), + makeListItem("2.000", "project"), + makeListItem("3.000", "daily_mrv_report"), + makeListItem("4.000", "approved_report"), + makeListItem("5.000", "vvb"), +] + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("getAllPolicyVcs", () => { + it("fetches all items and returns unfiltered when no entityType", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + items: sampleItems, + total: 5, + pageIndex: 0, + pageSize: 100, + }), + }) + + const result = await getAllPolicyVcs() + expect(result).toHaveLength(5) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it("filters client-side by entityType", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + items: sampleItems, + total: 5, + pageIndex: 0, + pageSize: 100, + }), + }) + + const result = await getAllPolicyVcs("approved_report") + expect(result).toHaveLength(2) + expect(result.every((v) => v.options.entityType === "approved_report")).toBe(true) + }) + + it("returns empty when entityType has no matches", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + items: sampleItems, + total: 5, + pageIndex: 0, + pageSize: 100, + }), + }) + + const result = await getAllPolicyVcs("validation_report") + expect(result).toHaveLength(0) + }) + + it("paginates when total exceeds page size", async () => { + // Simulate 150 items across 2 pages (PAGE_SIZE=100 in the real code) + const page1 = Array.from({ length: 100 }, (_, i) => + makeListItem(`${i}.000`, i % 2 === 0 ? "approved_report" : "project") + ) + const page2 = Array.from({ length: 50 }, (_, i) => + makeListItem(`${100 + i}.000`, "daily_mrv_report") + ) + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ items: page1, total: 150, pageIndex: 0, pageSize: 100 }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ items: page2, total: 150, pageIndex: 1, pageSize: 100 }), + }) + + const result = await getAllPolicyVcs() + expect(result).toHaveLength(150) + expect(mockFetch).toHaveBeenCalledTimes(2) + }) +}) + +describe("parseCredentialSubject", () => { + it("parses a valid VC detail with stringified document", () => { + const vcDetail: VCDetail = { + id: "1767600748.312578844", + item: { + id: "mongo_1", + consensusTimestamp: "1767600748.312578844", + topicId: "0.0.7561138", + options: { + entityType: "approved_report", + relationships: [], + documentStatus: "NEW", + issuer: "did:test", + }, + analytics: { policyId: "p1", schemaId: "s1", schemaName: "Test" }, + files: [], + documents: [ + JSON.stringify({ + credentialSubject: [ + { ER_y: 42.5, type: "approved_report" }, + ], + }), + ], + }, + history: [], + } + + const cs = parseCredentialSubject<{ ER_y: number; type: string }>(vcDetail) + expect(cs).not.toBeNull() + expect(cs!.ER_y).toBe(42.5) + expect(cs!.type).toBe("approved_report") + }) + + it("returns null for empty documents", () => { + const vcDetail: VCDetail = { + id: "1", + item: { + id: "m1", + consensusTimestamp: "1.0", + topicId: "t1", + options: { + entityType: "vvb", + relationships: [], + documentStatus: "NEW", + issuer: "did:test", + }, + analytics: { policyId: "p1", schemaId: "s1", schemaName: "Test" }, + files: [], + documents: [], + }, + history: [], + } + expect(parseCredentialSubject(vcDetail)).toBeNull() + }) + + it("returns null for malformed JSON", () => { + const vcDetail: VCDetail = { + id: "1", + item: { + id: "m1", + consensusTimestamp: "1.0", + topicId: "t1", + options: { + entityType: "vvb", + relationships: [], + documentStatus: "NEW", + issuer: "did:test", + }, + analytics: { policyId: "p1", schemaId: "s1", schemaName: "Test" }, + files: [], + documents: ["not valid json {{{"], + }, + history: [], + } + expect(parseCredentialSubject(vcDetail)).toBeNull() + }) +}) diff --git a/carbon-atlas/app/analytics/page.tsx b/carbon-atlas/app/analytics/page.tsx new file mode 100644 index 0000000000..b1950592ea --- /dev/null +++ b/carbon-atlas/app/analytics/page.tsx @@ -0,0 +1,30 @@ +import { DashboardLayout } from "@/components/dashboard-layout" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" + +export default function AnalyticsPage() { + return ( + +
+
+

Analytics

+

+ Emission reduction trends and issuance history +

+
+ + + Coming Soon + + tCO₂e over time charts, per monitoring period and project developer breakdown + + + +
+ Analytics charts — future feature +
+
+
+
+
+ ) +} diff --git a/carbon-atlas/app/api/proxy/[...path]/route.ts b/carbon-atlas/app/api/proxy/[...path]/route.ts new file mode 100644 index 0000000000..4fe7741a61 --- /dev/null +++ b/carbon-atlas/app/api/proxy/[...path]/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server" + +const BASE_URL = process.env.INDEXER_API_URL! +const TOKEN = process.env.INDEXER_API_TOKEN! + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> } +) { + const { path } = await params + const pathStr = path.join("/") + + // Forward all query params + const searchParams = request.nextUrl.searchParams.toString() + const upstreamUrl = `${BASE_URL}/${pathStr}${searchParams ? `?${searchParams}` : ""}` + + const res = await fetch(upstreamUrl, { + headers: { + Authorization: `Bearer ${TOKEN}`, + "Content-Type": "application/json", + }, + next: { revalidate: 600 }, // 10 min server-side cache + }) + + const data = await res.json() + + return NextResponse.json(data, { + status: res.status, + headers: { + // 10 min fresh, serve stale for up to 1 hr while revalidating + "Cache-Control": "s-maxage=600, stale-while-revalidate=3600", + }, + }) +} diff --git a/carbon-atlas/app/dashboard/data.json b/carbon-atlas/app/dashboard/data.json new file mode 100644 index 0000000000..ec0873641b --- /dev/null +++ b/carbon-atlas/app/dashboard/data.json @@ -0,0 +1,614 @@ +[ + { + "id": 1, + "header": "Cover page", + "type": "Cover page", + "status": "In Process", + "target": "18", + "limit": "5", + "reviewer": "Eddie Lake" + }, + { + "id": 2, + "header": "Table of contents", + "type": "Table of contents", + "status": "Done", + "target": "29", + "limit": "24", + "reviewer": "Eddie Lake" + }, + { + "id": 3, + "header": "Executive summary", + "type": "Narrative", + "status": "Done", + "target": "10", + "limit": "13", + "reviewer": "Eddie Lake" + }, + { + "id": 4, + "header": "Technical approach", + "type": "Narrative", + "status": "Done", + "target": "27", + "limit": "23", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 5, + "header": "Design", + "type": "Narrative", + "status": "In Process", + "target": "2", + "limit": "16", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 6, + "header": "Capabilities", + "type": "Narrative", + "status": "In Process", + "target": "20", + "limit": "8", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 7, + "header": "Integration with existing systems", + "type": "Narrative", + "status": "In Process", + "target": "19", + "limit": "21", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 8, + "header": "Innovation and Advantages", + "type": "Narrative", + "status": "Done", + "target": "25", + "limit": "26", + "reviewer": "Assign reviewer" + }, + { + "id": 9, + "header": "Overview of EMR's Innovative Solutions", + "type": "Technical content", + "status": "Done", + "target": "7", + "limit": "23", + "reviewer": "Assign reviewer" + }, + { + "id": 10, + "header": "Advanced Algorithms and Machine Learning", + "type": "Narrative", + "status": "Done", + "target": "30", + "limit": "28", + "reviewer": "Assign reviewer" + }, + { + "id": 11, + "header": "Adaptive Communication Protocols", + "type": "Narrative", + "status": "Done", + "target": "9", + "limit": "31", + "reviewer": "Assign reviewer" + }, + { + "id": 12, + "header": "Advantages Over Current Technologies", + "type": "Narrative", + "status": "Done", + "target": "12", + "limit": "0", + "reviewer": "Assign reviewer" + }, + { + "id": 13, + "header": "Past Performance", + "type": "Narrative", + "status": "Done", + "target": "22", + "limit": "33", + "reviewer": "Assign reviewer" + }, + { + "id": 14, + "header": "Customer Feedback and Satisfaction Levels", + "type": "Narrative", + "status": "Done", + "target": "15", + "limit": "34", + "reviewer": "Assign reviewer" + }, + { + "id": 15, + "header": "Implementation Challenges and Solutions", + "type": "Narrative", + "status": "Done", + "target": "3", + "limit": "35", + "reviewer": "Assign reviewer" + }, + { + "id": 16, + "header": "Security Measures and Data Protection Policies", + "type": "Narrative", + "status": "In Process", + "target": "6", + "limit": "36", + "reviewer": "Assign reviewer" + }, + { + "id": 17, + "header": "Scalability and Future Proofing", + "type": "Narrative", + "status": "Done", + "target": "4", + "limit": "37", + "reviewer": "Assign reviewer" + }, + { + "id": 18, + "header": "Cost-Benefit Analysis", + "type": "Plain language", + "status": "Done", + "target": "14", + "limit": "38", + "reviewer": "Assign reviewer" + }, + { + "id": 19, + "header": "User Training and Onboarding Experience", + "type": "Narrative", + "status": "Done", + "target": "17", + "limit": "39", + "reviewer": "Assign reviewer" + }, + { + "id": 20, + "header": "Future Development Roadmap", + "type": "Narrative", + "status": "Done", + "target": "11", + "limit": "40", + "reviewer": "Assign reviewer" + }, + { + "id": 21, + "header": "System Architecture Overview", + "type": "Technical content", + "status": "In Process", + "target": "24", + "limit": "18", + "reviewer": "Maya Johnson" + }, + { + "id": 22, + "header": "Risk Management Plan", + "type": "Narrative", + "status": "Done", + "target": "15", + "limit": "22", + "reviewer": "Carlos Rodriguez" + }, + { + "id": 23, + "header": "Compliance Documentation", + "type": "Legal", + "status": "In Process", + "target": "31", + "limit": "27", + "reviewer": "Sarah Chen" + }, + { + "id": 24, + "header": "API Documentation", + "type": "Technical content", + "status": "Done", + "target": "8", + "limit": "12", + "reviewer": "Raj Patel" + }, + { + "id": 25, + "header": "User Interface Mockups", + "type": "Visual", + "status": "In Process", + "target": "19", + "limit": "25", + "reviewer": "Leila Ahmadi" + }, + { + "id": 26, + "header": "Database Schema", + "type": "Technical content", + "status": "Done", + "target": "22", + "limit": "20", + "reviewer": "Thomas Wilson" + }, + { + "id": 27, + "header": "Testing Methodology", + "type": "Technical content", + "status": "In Process", + "target": "17", + "limit": "14", + "reviewer": "Assign reviewer" + }, + { + "id": 28, + "header": "Deployment Strategy", + "type": "Narrative", + "status": "Done", + "target": "26", + "limit": "30", + "reviewer": "Eddie Lake" + }, + { + "id": 29, + "header": "Budget Breakdown", + "type": "Financial", + "status": "In Process", + "target": "13", + "limit": "16", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 30, + "header": "Market Analysis", + "type": "Research", + "status": "Done", + "target": "29", + "limit": "32", + "reviewer": "Sophia Martinez" + }, + { + "id": 31, + "header": "Competitor Comparison", + "type": "Research", + "status": "In Process", + "target": "21", + "limit": "19", + "reviewer": "Assign reviewer" + }, + { + "id": 32, + "header": "Maintenance Plan", + "type": "Technical content", + "status": "Done", + "target": "16", + "limit": "23", + "reviewer": "Alex Thompson" + }, + { + "id": 33, + "header": "User Personas", + "type": "Research", + "status": "In Process", + "target": "27", + "limit": "24", + "reviewer": "Nina Patel" + }, + { + "id": 34, + "header": "Accessibility Compliance", + "type": "Legal", + "status": "Done", + "target": "18", + "limit": "21", + "reviewer": "Assign reviewer" + }, + { + "id": 35, + "header": "Performance Metrics", + "type": "Technical content", + "status": "In Process", + "target": "23", + "limit": "26", + "reviewer": "David Kim" + }, + { + "id": 36, + "header": "Disaster Recovery Plan", + "type": "Technical content", + "status": "Done", + "target": "14", + "limit": "17", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 37, + "header": "Third-party Integrations", + "type": "Technical content", + "status": "In Process", + "target": "25", + "limit": "28", + "reviewer": "Eddie Lake" + }, + { + "id": 38, + "header": "User Feedback Summary", + "type": "Research", + "status": "Done", + "target": "20", + "limit": "15", + "reviewer": "Assign reviewer" + }, + { + "id": 39, + "header": "Localization Strategy", + "type": "Narrative", + "status": "In Process", + "target": "12", + "limit": "19", + "reviewer": "Maria Garcia" + }, + { + "id": 40, + "header": "Mobile Compatibility", + "type": "Technical content", + "status": "Done", + "target": "28", + "limit": "31", + "reviewer": "James Wilson" + }, + { + "id": 41, + "header": "Data Migration Plan", + "type": "Technical content", + "status": "In Process", + "target": "19", + "limit": "22", + "reviewer": "Assign reviewer" + }, + { + "id": 42, + "header": "Quality Assurance Protocols", + "type": "Technical content", + "status": "Done", + "target": "30", + "limit": "33", + "reviewer": "Priya Singh" + }, + { + "id": 43, + "header": "Stakeholder Analysis", + "type": "Research", + "status": "In Process", + "target": "11", + "limit": "14", + "reviewer": "Eddie Lake" + }, + { + "id": 44, + "header": "Environmental Impact Assessment", + "type": "Research", + "status": "Done", + "target": "24", + "limit": "27", + "reviewer": "Assign reviewer" + }, + { + "id": 45, + "header": "Intellectual Property Rights", + "type": "Legal", + "status": "In Process", + "target": "17", + "limit": "20", + "reviewer": "Sarah Johnson" + }, + { + "id": 46, + "header": "Customer Support Framework", + "type": "Narrative", + "status": "Done", + "target": "22", + "limit": "25", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 47, + "header": "Version Control Strategy", + "type": "Technical content", + "status": "In Process", + "target": "15", + "limit": "18", + "reviewer": "Assign reviewer" + }, + { + "id": 48, + "header": "Continuous Integration Pipeline", + "type": "Technical content", + "status": "Done", + "target": "26", + "limit": "29", + "reviewer": "Michael Chen" + }, + { + "id": 49, + "header": "Regulatory Compliance", + "type": "Legal", + "status": "In Process", + "target": "13", + "limit": "16", + "reviewer": "Assign reviewer" + }, + { + "id": 50, + "header": "User Authentication System", + "type": "Technical content", + "status": "Done", + "target": "28", + "limit": "31", + "reviewer": "Eddie Lake" + }, + { + "id": 51, + "header": "Data Analytics Framework", + "type": "Technical content", + "status": "In Process", + "target": "21", + "limit": "24", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 52, + "header": "Cloud Infrastructure", + "type": "Technical content", + "status": "Done", + "target": "16", + "limit": "19", + "reviewer": "Assign reviewer" + }, + { + "id": 53, + "header": "Network Security Measures", + "type": "Technical content", + "status": "In Process", + "target": "29", + "limit": "32", + "reviewer": "Lisa Wong" + }, + { + "id": 54, + "header": "Project Timeline", + "type": "Planning", + "status": "Done", + "target": "14", + "limit": "17", + "reviewer": "Eddie Lake" + }, + { + "id": 55, + "header": "Resource Allocation", + "type": "Planning", + "status": "In Process", + "target": "27", + "limit": "30", + "reviewer": "Assign reviewer" + }, + { + "id": 56, + "header": "Team Structure and Roles", + "type": "Planning", + "status": "Done", + "target": "20", + "limit": "23", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 57, + "header": "Communication Protocols", + "type": "Planning", + "status": "In Process", + "target": "15", + "limit": "18", + "reviewer": "Assign reviewer" + }, + { + "id": 58, + "header": "Success Metrics", + "type": "Planning", + "status": "Done", + "target": "30", + "limit": "33", + "reviewer": "Eddie Lake" + }, + { + "id": 59, + "header": "Internationalization Support", + "type": "Technical content", + "status": "In Process", + "target": "23", + "limit": "26", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 60, + "header": "Backup and Recovery Procedures", + "type": "Technical content", + "status": "Done", + "target": "18", + "limit": "21", + "reviewer": "Assign reviewer" + }, + { + "id": 61, + "header": "Monitoring and Alerting System", + "type": "Technical content", + "status": "In Process", + "target": "25", + "limit": "28", + "reviewer": "Daniel Park" + }, + { + "id": 62, + "header": "Code Review Guidelines", + "type": "Technical content", + "status": "Done", + "target": "12", + "limit": "15", + "reviewer": "Eddie Lake" + }, + { + "id": 63, + "header": "Documentation Standards", + "type": "Technical content", + "status": "In Process", + "target": "27", + "limit": "30", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 64, + "header": "Release Management Process", + "type": "Planning", + "status": "Done", + "target": "22", + "limit": "25", + "reviewer": "Assign reviewer" + }, + { + "id": 65, + "header": "Feature Prioritization Matrix", + "type": "Planning", + "status": "In Process", + "target": "19", + "limit": "22", + "reviewer": "Emma Davis" + }, + { + "id": 66, + "header": "Technical Debt Assessment", + "type": "Technical content", + "status": "Done", + "target": "24", + "limit": "27", + "reviewer": "Eddie Lake" + }, + { + "id": 67, + "header": "Capacity Planning", + "type": "Planning", + "status": "In Process", + "target": "21", + "limit": "24", + "reviewer": "Jamik Tashpulatov" + }, + { + "id": 68, + "header": "Service Level Agreements", + "type": "Legal", + "status": "Done", + "target": "26", + "limit": "29", + "reviewer": "Assign reviewer" + } +] diff --git a/carbon-atlas/app/dashboard/page.tsx b/carbon-atlas/app/dashboard/page.tsx new file mode 100644 index 0000000000..19a0419c7b --- /dev/null +++ b/carbon-atlas/app/dashboard/page.tsx @@ -0,0 +1,19 @@ +import * as React from "react" +import { DashboardLayout } from "@/components/dashboard-layout" +import { SectionCards } from "@/components/section-cards" +import { DashboardCharts } from "@/components/dashboard-charts" +import { RecentIssuancesTable } from "./recent-issuances" + +export default function Page() { + return ( + +
+ + +
+ +
+
+
+ ) +} diff --git a/carbon-atlas/app/dashboard/recent-issuances.tsx b/carbon-atlas/app/dashboard/recent-issuances.tsx new file mode 100644 index 0000000000..987711e323 --- /dev/null +++ b/carbon-atlas/app/dashboard/recent-issuances.tsx @@ -0,0 +1,97 @@ +"use client" + +import * as React from "react" +import Link from "next/link" +import { IconExternalLink, IconLoader } from "@tabler/icons-react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { usePolicyVcDocuments } from "@/hooks/usePolicyVcDocuments" +import { formatTimestamp, shortenDid } from "@/lib/utils/format" + +export function RecentIssuancesTable() { + const { data, isLoading, error } = usePolicyVcDocuments("approved_report", 0, 10) + + return ( + + +
+ Recent Issuances + Latest verified monitoring reports +
+ +
+ + {isLoading && ( +
+ + Loading issuances… +
+ )} + {error && ( +

+ Error: {error.message} +

+ )} + {data && ( +
+ + + + Date + Issuer + Status + Actions + + + + {data.items.map((item) => ( + + + {formatTimestamp(item.consensusTimestamp)} + + + {shortenDid(item.options?.issuer)} + + + + {item.options?.documentStatus ?? "Verified"} + + + + + + + ))} + +
+
+ )} +
+
+ ) +} diff --git a/carbon-atlas/app/devices/[messageId]/page.tsx b/carbon-atlas/app/devices/[messageId]/page.tsx new file mode 100644 index 0000000000..3e7e3859e3 --- /dev/null +++ b/carbon-atlas/app/devices/[messageId]/page.tsx @@ -0,0 +1,70 @@ +"use client" + +import * as React from "react" +import { useParams } from "next/navigation" +import Link from "next/link" +import { IconArrowLeft, IconLoader } from "@tabler/icons-react" +import { Button } from "@/components/ui/button" +import { DashboardLayout } from "@/components/dashboard-layout" +import { DeviceDataView } from "@/components/vc-views/DeviceDataView" +import { HederaProofBadge } from "@/components/shared/HederaProofBadge" +import { useVcDocument } from "@/hooks/useVcDocument" +import { parseCredentialSubject } from "@/lib/api/vc-documents" +import { formatTimestamp } from "@/lib/utils/format" + +export default function DevicesPage() { + const params = useParams<{ messageId: string }>() + const vcId = params.messageId + + const { data: vcDetail, isLoading, error } = useVcDocument(vcId) + const cs = vcDetail ? parseCredentialSubject(vcDetail) : null + + return ( + +
+
+ + {vcDetail && ( + + )} +
+ +
+

Device MRV Data

+

+ Daily metered energy data per cooking device + {vcDetail + ? ` · ${formatTimestamp(vcDetail.item.consensusTimestamp)}` + : ""} +

+

+ {vcId} +

+
+ + {isLoading && ( +
+ + Loading device data… +
+ )} + {error && ( +

Error: {error.message}

+ )} + {vcDetail && cs && ( + } + rawDocuments={vcDetail.item.documents} + /> + )} +
+
+ ) +} diff --git a/carbon-atlas/app/documents/[messageId]/page.tsx b/carbon-atlas/app/documents/[messageId]/page.tsx new file mode 100644 index 0000000000..ee2f388332 --- /dev/null +++ b/carbon-atlas/app/documents/[messageId]/page.tsx @@ -0,0 +1,67 @@ +"use client" + +import * as React from "react" +import { useParams } from "next/navigation" +import Link from "next/link" +import { IconArrowLeft, IconLoader } from "@tabler/icons-react" +import { Button } from "@/components/ui/button" +import { DashboardLayout } from "@/components/dashboard-layout" +import { VCRenderer } from "@/components/vc-views/VCRenderer" +import { HederaProofBadge } from "@/components/shared/HederaProofBadge" +import { useVcDocument } from "@/hooks/useVcDocument" +import { ENTITY_TYPE_CONFIG } from "@/lib/utils/trust-chain" +import { formatTimestamp } from "@/lib/utils/format" +import type { EntityType } from "@/lib/types/indexer" + +export default function DocumentDetailPage() { + const params = useParams<{ messageId: string }>() + const vcId = params.messageId + + const { data: vcDetail, isLoading, error } = useVcDocument(vcId) + + const entityType = vcDetail?.item.options?.entityType as EntityType | undefined + const config = entityType ? ENTITY_TYPE_CONFIG[entityType] : null + + return ( + +
+
+ + {vcDetail && ( + + )} +
+ +
+

+ {config?.label ?? "VC Document"} +

+

+ {vcDetail + ? `${entityType} · ${formatTimestamp(vcDetail.item.consensusTimestamp)}` + : ""} +

+

+ {vcId} +

+
+ + {isLoading && ( +
+ + Loading… +
+ )} + {error && ( +

Error: {error.message}

+ )} + {vcDetail && } +
+
+ ) +} diff --git a/carbon-atlas/app/globals.css b/carbon-atlas/app/globals.css new file mode 100644 index 0000000000..268f20f70d --- /dev/null +++ b/carbon-atlas/app/globals.css @@ -0,0 +1,174 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.47 0.1 173); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.95 0.02 173); + --accent-foreground: oklch(0.35 0.08 173); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.47 0.1 173); + --chart-1: oklch(0.47 0.1 173); + --chart-2: oklch(0.60 0.12 175); + --chart-3: oklch(0.55 0.08 200); + --chart-4: oklch(0.70 0.13 165); + --chart-5: oklch(0.40 0.07 185); + --sidebar: oklch(0.98 0.005 173); + --sidebar-foreground: oklch(0.25 0.04 173); + --sidebar-primary: oklch(0.47 0.1 173); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.95 0.02 173); + --sidebar-accent-foreground: oklch(0.25 0.04 173); + --sidebar-border: oklch(0.90 0.02 173); + --sidebar-ring: oklch(0.47 0.1 173); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.70 0.13 173); + --primary-foreground: oklch(0.145 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.25 0.03 173); + --accent-foreground: oklch(0.75 0.12 173); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.70 0.13 173); + --chart-1: oklch(0.70 0.13 173); + --chart-2: oklch(0.60 0.12 175); + --chart-3: oklch(0.55 0.10 185); + --chart-4: oklch(0.50 0.08 195); + --chart-5: oklch(0.80 0.10 165); + --sidebar: oklch(0.17 0.01 173); + --sidebar-foreground: oklch(0.90 0.02 173); + --sidebar-primary: oklch(0.70 0.13 173); + --sidebar-primary-foreground: oklch(0.145 0 0); + --sidebar-accent: oklch(0.22 0.02 173); + --sidebar-accent-foreground: oklch(0.90 0.02 173); + --sidebar-border: oklch(0.25 0.02 173); + --sidebar-ring: oklch(0.70 0.13 173); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + .leaflet-container { + @apply !bg-card !font-[inherit]; + } + .leaflet-container a { + @apply !text-inherit; + } + .leaflet-div-icon { + @apply !bg-transparent !border-none; + } + .leaflet-popup-content-wrapper, .leaflet-popup-content, .leaflet-popup-content p { + @apply ![all:unset]; + } + .leaflet-popup { + @apply !animate-none; + } + .leaflet-popup-close-button { + @apply ring-offset-background focus:ring-ring bg-secondary rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:outline-hidden; + } + .leaflet-tooltip, .leaflet-draw-tooltip { + @apply !bg-foreground !text-background !animate-none !rounded-md !border-none !p-0 !px-3 !py-1.5 !shadow-none; + } + .leaflet-draw-tooltip:before { + @apply bg-foreground !top-1/2 !right-0.5 size-2.5 translate-x-1/2 -translate-y-1/2 rotate-45 rounded-[2px] !border-none; + } + .leaflet-error-draw-tooltip { + @apply !bg-destructive !text-white; + } + .leaflet-error-draw-tooltip:before { + @apply bg-destructive; + } + .leaflet-draw-tooltip-subtext { + @apply !text-background; + } + .leaflet-popup-tip-container, .leaflet-tooltip-top:before, .leaflet-tooltip-bottom:before, .leaflet-tooltip-left:before, .leaflet-tooltip-right:before { + @apply hidden; + } + .leaflet-control-attribution { + @apply !bg-muted rounded-md !px-[4px] !py-[2px] text-[10px] !leading-none !text-inherit; + } + .leaflet-draw-guide-dash { + @apply rounded-full; + } + .leaflet-edit-marker-selected { + @apply !border-transparent !bg-transparent; + } + .marker-cluster div { + @apply font-[inherit]; + } +} \ No newline at end of file diff --git a/carbon-atlas/app/issuances/[messageId]/page.tsx b/carbon-atlas/app/issuances/[messageId]/page.tsx new file mode 100644 index 0000000000..9d2555611f --- /dev/null +++ b/carbon-atlas/app/issuances/[messageId]/page.tsx @@ -0,0 +1,68 @@ +"use client" + +import * as React from "react" +import { useParams } from "next/navigation" +import Link from "next/link" +import { IconArrowLeft, IconLoader } from "@tabler/icons-react" +import { Button } from "@/components/ui/button" +import { DashboardLayout } from "@/components/dashboard-layout" +import { TrustChainView } from "@/components/trust-chain/TrustChainView" +import { HederaProofBadge } from "@/components/shared/HederaProofBadge" +import { useVcDocument } from "@/hooks/useVcDocument" +import { formatTimestamp } from "@/lib/utils/format" +import { ProjectDeveloperBadge } from "@/components/shared/ProjectDeveloperBadge" + +export default function IssuanceDetailPage() { + const params = useParams<{ messageId: string }>() + const vcId = params.messageId + + const { data: vcDetail, isLoading, error } = useVcDocument(vcId) + + return ( + +
+
+ + {vcDetail && ( + + )} +
+ +
+
+

Trust Chain

+

+ Verifiable audit trail from approved monitoring report through to project origin + {vcDetail + ? ` · ${formatTimestamp(vcDetail.item.consensusTimestamp)}` + : ""} +

+

+ {vcId} +

+
+ +
+ + {isLoading && ( +
+ + Loading… +
+ )} + {error && ( +

Error: {error.message}

+ )} + + +
+
+ ) +} diff --git a/carbon-atlas/app/issuances/page.tsx b/carbon-atlas/app/issuances/page.tsx new file mode 100644 index 0000000000..f93773fc3f --- /dev/null +++ b/carbon-atlas/app/issuances/page.tsx @@ -0,0 +1,144 @@ +"use client" + +import * as React from "react" +import Link from "next/link" +import { + IconChevronLeft, + IconChevronRight, + IconExternalLink, + IconLoader, +} from "@tabler/icons-react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { DashboardLayout } from "@/components/dashboard-layout" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { usePolicyVcDocuments } from "@/hooks/usePolicyVcDocuments" +import { formatTimestamp, shortenDid } from "@/lib/utils/format" +import { HederaProofBadge } from "@/components/shared/HederaProofBadge" + +export default function IssuancesPage() { + const [pageIndex, setPageIndex] = React.useState(0) + const PAGE_SIZE = 25 + + const { data, isLoading, error } = usePolicyVcDocuments( + "approved_report", + pageIndex, + PAGE_SIZE + ) + + const totalPages = data ? Math.ceil(data.total / PAGE_SIZE) : 0 + + return ( + +
+
+
+

Issuances

+

+ Verified monitoring reports — each represents a carbon credit issuance + {data ? ` (${data.total} total)` : ""} +

+
+
+ + {isLoading && ( +
+ + Loading issuances… +
+ )} + + {error && ( +

Error: {error.message}

+ )} + + {data && ( +
+
+ + + + Date + Document ID + Issuer + Status + Hedera + Actions + + + + {data.items.map((item) => ( + + + {formatTimestamp(item.consensusTimestamp)} + + + {item.consensusTimestamp} + + + {shortenDid(item.options?.issuer)} + + + + {item.options?.documentStatus ?? "Verified"} + + + + + + + + + + ))} + +
+
+ + {/* Pagination */} +
+ + Page {pageIndex + 1} of {totalPages || "…"} + +
+ + +
+
+
+ )} +
+
+ ) +} diff --git a/carbon-atlas/app/layout.tsx b/carbon-atlas/app/layout.tsx new file mode 100644 index 0000000000..e9d0e95e2f --- /dev/null +++ b/carbon-atlas/app/layout.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from "next" +import { Geist, Geist_Mono } from "next/font/google" +import { ThemeProvider } from "next-themes" +import "./globals.css" +import { QueryProvider } from "@/providers/QueryProvider" + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}) + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}) + +export const metadata: Metadata = { + title: "MECD Indexer", + description: + "Public dashboard for Gold Standard MECD 431 — Metered & Measured Energy Cooking Devices carbon credit issuances on Hedera Guardian", + icons: { + icon: [ + { url: "/logo-light.png", media: "(prefers-color-scheme: light)" }, + { url: "/logo-dark.png", media: "(prefers-color-scheme: dark)" }, + ], + }, +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + + {children} + + + + ) +} diff --git a/carbon-atlas/app/not-found.tsx b/carbon-atlas/app/not-found.tsx new file mode 100644 index 0000000000..09232d3677 --- /dev/null +++ b/carbon-atlas/app/not-found.tsx @@ -0,0 +1,16 @@ +import Link from "next/link" + +export default function NotFound() { + return ( +
+

404

+

Page not found

+ + Go back home + +
+ ) +} diff --git a/carbon-atlas/app/page.tsx b/carbon-atlas/app/page.tsx new file mode 100644 index 0000000000..4d94006935 --- /dev/null +++ b/carbon-atlas/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation" + +export default function Home() { + redirect("/dashboard") +} diff --git a/carbon-atlas/app/projects/[messageId]/page.tsx b/carbon-atlas/app/projects/[messageId]/page.tsx new file mode 100644 index 0000000000..f5e984bd19 --- /dev/null +++ b/carbon-atlas/app/projects/[messageId]/page.tsx @@ -0,0 +1,72 @@ +"use client" + +import * as React from "react" +import { useParams } from "next/navigation" +import Link from "next/link" +import { IconArrowLeft, IconLoader } from "@tabler/icons-react" +import { Button } from "@/components/ui/button" +import { DashboardLayout } from "@/components/dashboard-layout" +import { VCRenderer } from "@/components/vc-views/VCRenderer" +import { HederaProofBadge } from "@/components/shared/HederaProofBadge" +import { useVcDocument } from "@/hooks/useVcDocument" +import { ENTITY_TYPE_CONFIG } from "@/lib/utils/trust-chain" +import { formatTimestamp } from "@/lib/utils/format" +import { ProjectDeveloperBadge } from "@/components/shared/ProjectDeveloperBadge" + +export default function ProjectDetailPage() { + const params = useParams<{ messageId: string }>() + const vcId = params.messageId + + const { data: vcDetail, isLoading, error } = useVcDocument(vcId) + + const entityType = vcDetail?.item.options?.entityType + const config = entityType ? ENTITY_TYPE_CONFIG[entityType] : null + + return ( + +
+
+ + {vcDetail && ( + + )} +
+ +
+
+

+ {config?.label ?? "Project Document"} +

+

+ {vcDetail + ? formatTimestamp(vcDetail.item.consensusTimestamp) + : ""} +

+

+ {vcId} +

+
+ +
+ + {isLoading && ( +
+ + Loading… +
+ )} + {error && ( +

Error: {error.message}

+ )} + {vcDetail && } +
+
+ ) +} diff --git a/carbon-atlas/app/projects/page.tsx b/carbon-atlas/app/projects/page.tsx new file mode 100644 index 0000000000..e8337e752d --- /dev/null +++ b/carbon-atlas/app/projects/page.tsx @@ -0,0 +1,127 @@ +"use client" + +import * as React from "react" +import Link from "next/link" +import { IconExternalLink, IconLoader } from "@tabler/icons-react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { DashboardLayout } from "@/components/dashboard-layout" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { usePolicyVcDocuments } from "@/hooks/usePolicyVcDocuments" +import { formatTimestamp, shortenDid } from "@/lib/utils/format" +import { HederaProofBadge } from "@/components/shared/HederaProofBadge" +import { ProjectDeveloperBadge } from "@/components/shared/ProjectDeveloperBadge" + +export default function ProjectsPage() { + const { data: forms, isLoading: loadingForms } = usePolicyVcDocuments( + "project_form", + 0, + 50 + ) + const { data: approved, isLoading: loadingApproved } = usePolicyVcDocuments( + "approved_project", + 0, + 50 + ) + + const isLoading = loadingForms || loadingApproved + const allItems = [ + ...(approved?.items ?? []), + ...(forms?.items ?? []), + ] + + return ( + +
+
+
+

Projects

+

+ Project Design Documents and validated project VCs +

+
+ +
+ + {isLoading && ( +
+ + Loading projects… +
+ )} + + {!isLoading && allItems.length > 0 && ( +
+ + + + Date + Type + Issuer + Status + Hedera + Actions + + + + {allItems.map((item) => ( + + + {formatTimestamp(item.consensusTimestamp)} + + + + {item.options?.entityType === "approved_project" + ? "Validated" + : "PDD"} + + + + {shortenDid(item.options?.issuer)} + + + + {item.options?.documentStatus ?? "—"} + + + + + + + + + + ))} + +
+
+ )} + + {!isLoading && allItems.length === 0 && ( +

No project documents found.

+ )} +
+
+ ) +} diff --git a/carbon-atlas/app/verify/page.tsx b/carbon-atlas/app/verify/page.tsx new file mode 100644 index 0000000000..944e0d5935 --- /dev/null +++ b/carbon-atlas/app/verify/page.tsx @@ -0,0 +1,57 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { IconSearch } from "@tabler/icons-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { DashboardLayout } from "@/components/dashboard-layout" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" + +export default function VerifyPage() { + const router = useRouter() + const [vcId, setVcId] = React.useState("") + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + const trimmed = vcId.trim() + if (!trimmed) return + router.push(`/documents/${encodeURIComponent(trimmed)}`) + } + + return ( + +
+
+

Verify Document

+

+ Paste a VC document ID to jump directly to its trust chain or detail view +

+
+ + + + Look up a VC + + Enter the MongoDB document ID of any VC in this policy + + + +
+ setVcId(e.target.value)} + className="font-mono" + /> + +
+
+
+
+
+ ) +} diff --git a/carbon-atlas/components.json b/carbon-atlas/components.json new file mode 100644 index 0000000000..d0b82f273e --- /dev/null +++ b/carbon-atlas/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": { + "@shadcn-map": "http://shadcn-map.vercel.app/r/{name}.json" + } +} diff --git a/carbon-atlas/components/app-sidebar.tsx b/carbon-atlas/components/app-sidebar.tsx new file mode 100644 index 0000000000..d4fe7bc580 --- /dev/null +++ b/carbon-atlas/components/app-sidebar.tsx @@ -0,0 +1,111 @@ +"use client" + +import * as React from "react" +import Image from "next/image" +import { useTheme } from "next-themes" +import { + IconChartBar, + IconDashboard, + IconExternalLink, + IconMail, + IconList, + IconSearch, + IconServer, + IconSitemap, +} from "@tabler/icons-react" + +import { NavMain } from "@/components/nav-main" +import { NavSecondary } from "@/components/nav-secondary" +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" + +const data = { + navMain: [ + { title: "Dashboard", url: "/dashboard", icon: IconDashboard }, + { title: "Issuances", url: "/issuances", icon: IconList }, + { title: "Projects", url: "/projects", icon: IconSitemap }, + { title: "Analytics", url: "/analytics", icon: IconChartBar }, + { title: "Verify", url: "/verify", icon: IconSearch }, + ], + navSecondary: [ + { + title: "Methodology", + url: "https://globalgoals.goldstandard.org/431_ee_ics_methodology-for-metered-measured-energy-cooking-devices/", + icon: IconExternalLink, + }, + { + title: "Guardian", + url: "https://github.com/hashgraph/guardian", + icon: IconServer, + }, + { + title: "Hedera Policy", + url: "https://indexer.guardianservice.app/policies/1767599197.624837133", + icon: IconExternalLink, + }, + { title: "Contact", url: "mailto:gautam@carbonmarketshq.com", icon: IconMail }, + ], +} + +export function AppSidebar({ ...props }: React.ComponentProps) { + const { resolvedTheme } = useTheme() + const [mounted, setMounted] = React.useState(false) + React.useEffect(() => setMounted(true), []) + + const logo = mounted && resolvedTheme === "dark" + ? "/logo-dark.png" + : "/logo-light.png" + + const cmhqLogo = mounted && resolvedTheme === "dark" + ? "/cmhq-logo-dark.png" + : "/cmhq-logo-light.png" + + return ( + + + + + + + MECD + MECD Indexer + + + + + + + + + + +
+ Built by + + CarbonMarketsHQ + +
+
+
+ ) +} diff --git a/carbon-atlas/components/chart-area-interactive.tsx b/carbon-atlas/components/chart-area-interactive.tsx new file mode 100644 index 0000000000..fff1956304 --- /dev/null +++ b/carbon-atlas/components/chart-area-interactive.tsx @@ -0,0 +1,291 @@ +"use client" + +import * as React from "react" +import { Area, AreaChart, CartesianGrid, XAxis } from "recharts" + +import { useIsMobile } from "@/hooks/use-mobile" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + ToggleGroup, + ToggleGroupItem, +} from "@/components/ui/toggle-group" + +export const description = "An interactive area chart" + +const chartData = [ + { date: "2024-04-01", desktop: 222, mobile: 150 }, + { date: "2024-04-02", desktop: 97, mobile: 180 }, + { date: "2024-04-03", desktop: 167, mobile: 120 }, + { date: "2024-04-04", desktop: 242, mobile: 260 }, + { date: "2024-04-05", desktop: 373, mobile: 290 }, + { date: "2024-04-06", desktop: 301, mobile: 340 }, + { date: "2024-04-07", desktop: 245, mobile: 180 }, + { date: "2024-04-08", desktop: 409, mobile: 320 }, + { date: "2024-04-09", desktop: 59, mobile: 110 }, + { date: "2024-04-10", desktop: 261, mobile: 190 }, + { date: "2024-04-11", desktop: 327, mobile: 350 }, + { date: "2024-04-12", desktop: 292, mobile: 210 }, + { date: "2024-04-13", desktop: 342, mobile: 380 }, + { date: "2024-04-14", desktop: 137, mobile: 220 }, + { date: "2024-04-15", desktop: 120, mobile: 170 }, + { date: "2024-04-16", desktop: 138, mobile: 190 }, + { date: "2024-04-17", desktop: 446, mobile: 360 }, + { date: "2024-04-18", desktop: 364, mobile: 410 }, + { date: "2024-04-19", desktop: 243, mobile: 180 }, + { date: "2024-04-20", desktop: 89, mobile: 150 }, + { date: "2024-04-21", desktop: 137, mobile: 200 }, + { date: "2024-04-22", desktop: 224, mobile: 170 }, + { date: "2024-04-23", desktop: 138, mobile: 230 }, + { date: "2024-04-24", desktop: 387, mobile: 290 }, + { date: "2024-04-25", desktop: 215, mobile: 250 }, + { date: "2024-04-26", desktop: 75, mobile: 130 }, + { date: "2024-04-27", desktop: 383, mobile: 420 }, + { date: "2024-04-28", desktop: 122, mobile: 180 }, + { date: "2024-04-29", desktop: 315, mobile: 240 }, + { date: "2024-04-30", desktop: 454, mobile: 380 }, + { date: "2024-05-01", desktop: 165, mobile: 220 }, + { date: "2024-05-02", desktop: 293, mobile: 310 }, + { date: "2024-05-03", desktop: 247, mobile: 190 }, + { date: "2024-05-04", desktop: 385, mobile: 420 }, + { date: "2024-05-05", desktop: 481, mobile: 390 }, + { date: "2024-05-06", desktop: 498, mobile: 520 }, + { date: "2024-05-07", desktop: 388, mobile: 300 }, + { date: "2024-05-08", desktop: 149, mobile: 210 }, + { date: "2024-05-09", desktop: 227, mobile: 180 }, + { date: "2024-05-10", desktop: 293, mobile: 330 }, + { date: "2024-05-11", desktop: 335, mobile: 270 }, + { date: "2024-05-12", desktop: 197, mobile: 240 }, + { date: "2024-05-13", desktop: 197, mobile: 160 }, + { date: "2024-05-14", desktop: 448, mobile: 490 }, + { date: "2024-05-15", desktop: 473, mobile: 380 }, + { date: "2024-05-16", desktop: 338, mobile: 400 }, + { date: "2024-05-17", desktop: 499, mobile: 420 }, + { date: "2024-05-18", desktop: 315, mobile: 350 }, + { date: "2024-05-19", desktop: 235, mobile: 180 }, + { date: "2024-05-20", desktop: 177, mobile: 230 }, + { date: "2024-05-21", desktop: 82, mobile: 140 }, + { date: "2024-05-22", desktop: 81, mobile: 120 }, + { date: "2024-05-23", desktop: 252, mobile: 290 }, + { date: "2024-05-24", desktop: 294, mobile: 220 }, + { date: "2024-05-25", desktop: 201, mobile: 250 }, + { date: "2024-05-26", desktop: 213, mobile: 170 }, + { date: "2024-05-27", desktop: 420, mobile: 460 }, + { date: "2024-05-28", desktop: 233, mobile: 190 }, + { date: "2024-05-29", desktop: 78, mobile: 130 }, + { date: "2024-05-30", desktop: 340, mobile: 280 }, + { date: "2024-05-31", desktop: 178, mobile: 230 }, + { date: "2024-06-01", desktop: 178, mobile: 200 }, + { date: "2024-06-02", desktop: 470, mobile: 410 }, + { date: "2024-06-03", desktop: 103, mobile: 160 }, + { date: "2024-06-04", desktop: 439, mobile: 380 }, + { date: "2024-06-05", desktop: 88, mobile: 140 }, + { date: "2024-06-06", desktop: 294, mobile: 250 }, + { date: "2024-06-07", desktop: 323, mobile: 370 }, + { date: "2024-06-08", desktop: 385, mobile: 320 }, + { date: "2024-06-09", desktop: 438, mobile: 480 }, + { date: "2024-06-10", desktop: 155, mobile: 200 }, + { date: "2024-06-11", desktop: 92, mobile: 150 }, + { date: "2024-06-12", desktop: 492, mobile: 420 }, + { date: "2024-06-13", desktop: 81, mobile: 130 }, + { date: "2024-06-14", desktop: 426, mobile: 380 }, + { date: "2024-06-15", desktop: 307, mobile: 350 }, + { date: "2024-06-16", desktop: 371, mobile: 310 }, + { date: "2024-06-17", desktop: 475, mobile: 520 }, + { date: "2024-06-18", desktop: 107, mobile: 170 }, + { date: "2024-06-19", desktop: 341, mobile: 290 }, + { date: "2024-06-20", desktop: 408, mobile: 450 }, + { date: "2024-06-21", desktop: 169, mobile: 210 }, + { date: "2024-06-22", desktop: 317, mobile: 270 }, + { date: "2024-06-23", desktop: 480, mobile: 530 }, + { date: "2024-06-24", desktop: 132, mobile: 180 }, + { date: "2024-06-25", desktop: 141, mobile: 190 }, + { date: "2024-06-26", desktop: 434, mobile: 380 }, + { date: "2024-06-27", desktop: 448, mobile: 490 }, + { date: "2024-06-28", desktop: 149, mobile: 200 }, + { date: "2024-06-29", desktop: 103, mobile: 160 }, + { date: "2024-06-30", desktop: 446, mobile: 400 }, +] + +const chartConfig = { + visitors: { + label: "Visitors", + }, + desktop: { + label: "Desktop", + color: "var(--primary)", + }, + mobile: { + label: "Mobile", + color: "var(--primary)", + }, +} satisfies ChartConfig + +export function ChartAreaInteractive() { + const isMobile = useIsMobile() + const [timeRange, setTimeRange] = React.useState("90d") + + React.useEffect(() => { + if (isMobile) { + setTimeRange("7d") + } + }, [isMobile]) + + const filteredData = chartData.filter((item) => { + const date = new Date(item.date) + const referenceDate = new Date("2024-06-30") + let daysToSubtract = 90 + if (timeRange === "30d") { + daysToSubtract = 30 + } else if (timeRange === "7d") { + daysToSubtract = 7 + } + const startDate = new Date(referenceDate) + startDate.setDate(startDate.getDate() - daysToSubtract) + return date >= startDate + }) + + return ( + + + Total Visitors + + + Total for the last 3 months + + Last 3 months + + + + Last 3 months + Last 30 days + Last 7 days + + + + + + + + + + + + + + + + + + + { + const date = new Date(value) + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + }} + /> + { + return new Date(value).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + }} + indicator="dot" + /> + } + /> + + + + + + + ) +} diff --git a/carbon-atlas/components/dashboard-charts.tsx b/carbon-atlas/components/dashboard-charts.tsx new file mode 100644 index 0000000000..9f31cf683a --- /dev/null +++ b/carbon-atlas/components/dashboard-charts.tsx @@ -0,0 +1,162 @@ +"use client" + +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts" +import { IconLoader } from "@tabler/icons-react" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart" +import { DeviceMap } from "@/components/device-map" +import { useDashboardStats, type IssuanceDataPoint } from "@/hooks/useDashboardStats" + +const chartConfig = { + ery: { + label: "Emission Reductions (tCO₂e)", + color: "var(--chart-1)", + }, +} satisfies ChartConfig + +const START_YEAR = 2021 + +/** + * Build yearly bars from START_YEAR through current year + 2. + * Years without issuances show as empty bars, giving historical + * context and room for future issuances. + */ +function buildTimelineData(raw: IssuanceDataPoint[]) { + const currentYear = new Date().getFullYear() + const endYear = Math.max(currentYear + 1, START_YEAR + 5) + + const points: { year: number; label: string; ery: number }[] = [] + + for (let y = START_YEAR; y <= endYear; y++) { + const yearIssuances = raw.filter( + (d) => new Date(d.date).getFullYear() === y + ) + const yearEry = yearIssuances.reduce((sum, d) => sum + d.ery, 0) + points.push({ + year: y, + label: String(y), + ery: yearEry, + }) + } + + return points +} + +export function DashboardCharts() { + const { chartData, isLoading } = useDashboardStats() + + if (isLoading) { + return ( +
+ {[0, 1].map((i) => ( + + + + + + ))} +
+ ) + } + + return ( +
+ + +
+ ) +} + +function IssuanceChart({ data }: { data: IssuanceDataPoint[] }) { + const timelineData = buildTimelineData(data) + + return ( + + + Emission Reductions Over Time + tCO₂e issued per year from verified monitoring reports + + + + + + + + + + + + + + v >= 1000 ? `${(v / 1000).toFixed(1)}k` : v.toLocaleString() + } + /> + { + const item = payload?.[0]?.payload + return item ? `Year ${item.label}` : "" + }} + formatter={(value) => { + const n = Number(value) + if (n === 0) { + return ( + + No issuances + + ) + } + return ( + + {n.toLocaleString("en-US", { maximumFractionDigits: 2 })} tCO₂e + + ) + }} + indicator="dot" + /> + } + /> + + + + + + ) +} diff --git a/carbon-atlas/components/dashboard-layout.tsx b/carbon-atlas/components/dashboard-layout.tsx new file mode 100644 index 0000000000..77714e1aa6 --- /dev/null +++ b/carbon-atlas/components/dashboard-layout.tsx @@ -0,0 +1,27 @@ +import * as React from "react" +import { AppSidebar } from "@/components/app-sidebar" +import { SiteHeader } from "@/components/site-header" +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" + +export function DashboardLayout({ children }: { children: React.ReactNode }) { + return ( + + + + +
+
+ {children} +
+
+
+
+ ) +} diff --git a/carbon-atlas/components/data-table.tsx b/carbon-atlas/components/data-table.tsx new file mode 100644 index 0000000000..1d977f8230 --- /dev/null +++ b/carbon-atlas/components/data-table.tsx @@ -0,0 +1,807 @@ +"use client" + +import * as React from "react" +import { + closestCenter, + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, + type UniqueIdentifier, +} from "@dnd-kit/core" +import { restrictToVerticalAxis } from "@dnd-kit/modifiers" +import { + arrayMove, + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { + IconChevronDown, + IconChevronLeft, + IconChevronRight, + IconChevronsLeft, + IconChevronsRight, + IconCircleCheckFilled, + IconDotsVertical, + IconGripVertical, + IconLayoutColumns, + IconLoader, + IconPlus, + IconTrendingUp, +} from "@tabler/icons-react" +import { + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type ColumnFiltersState, + type Row, + type SortingState, + type VisibilityState, +} from "@tanstack/react-table" +import { Area, AreaChart, CartesianGrid, XAxis } from "recharts" +import { toast } from "sonner" +import { z } from "zod" + +import { useIsMobile } from "@/hooks/use-mobile" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart" +import { Checkbox } from "@/components/ui/checkbox" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs" + +export const schema = z.object({ + id: z.number(), + header: z.string(), + type: z.string(), + status: z.string(), + target: z.string(), + limit: z.string(), + reviewer: z.string(), +}) + +// Create a separate component for the drag handle +function DragHandle({ id }: { id: number }) { + const { attributes, listeners } = useSortable({ + id, + }) + + return ( + + ) +} + +const columns: ColumnDef>[] = [ + { + id: "drag", + header: () => null, + cell: ({ row }) => , + }, + { + id: "select", + header: ({ table }) => ( +
+ table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(!!value)} + aria-label="Select row" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "header", + header: "Header", + cell: ({ row }) => { + return + }, + enableHiding: false, + }, + { + accessorKey: "type", + header: "Section Type", + cell: ({ row }) => ( +
+ + {row.original.type} + +
+ ), + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => ( + + {row.original.status === "Done" ? ( + + ) : ( + + )} + {row.original.status} + + ), + }, + { + accessorKey: "target", + header: () =>
Target
, + cell: ({ row }) => ( +
{ + e.preventDefault() + toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), { + loading: `Saving ${row.original.header}`, + success: "Done", + error: "Error", + }) + }} + > + + +
+ ), + }, + { + accessorKey: "limit", + header: () =>
Limit
, + cell: ({ row }) => ( +
{ + e.preventDefault() + toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), { + loading: `Saving ${row.original.header}`, + success: "Done", + error: "Error", + }) + }} + > + + +
+ ), + }, + { + accessorKey: "reviewer", + header: "Reviewer", + cell: ({ row }) => { + const isAssigned = row.original.reviewer !== "Assign reviewer" + + if (isAssigned) { + return row.original.reviewer + } + + return ( + <> + + + + ) + }, + }, + { + id: "actions", + cell: () => ( + + + + + + Edit + Make a copy + Favorite + + Delete + + + ), + }, +] + +function DraggableRow({ row }: { row: Row> }) { + const { transform, transition, setNodeRef, isDragging } = useSortable({ + id: row.original.id, + }) + + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) +} + +export function DataTable({ + data: initialData, +}: { + data: z.infer[] +}) { + const [data, setData] = React.useState(() => initialData) + const [rowSelection, setRowSelection] = React.useState({}) + const [columnVisibility, setColumnVisibility] = + React.useState({}) + const [columnFilters, setColumnFilters] = React.useState( + [] + ) + const [sorting, setSorting] = React.useState([]) + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }) + const sortableId = React.useId() + const sensors = useSensors( + useSensor(MouseSensor, {}), + useSensor(TouchSensor, {}), + useSensor(KeyboardSensor, {}) + ) + + const dataIds = React.useMemo( + () => data?.map(({ id }) => id) || [], + [data] + ) + + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + pagination, + }, + getRowId: (row) => row.id.toString(), + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + }) + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event + if (active && over && active.id !== over.id) { + setData((data) => { + const oldIndex = dataIds.indexOf(active.id) + const newIndex = dataIds.indexOf(over.id) + return arrayMove(data, oldIndex, newIndex) + }) + } + } + + return ( + +
+ + + + Outline + + Past Performance 3 + + + Key Personnel 2 + + Focus Documents + +
+ + + + + + {table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== "undefined" && + column.getCanHide() + ) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ) + })} + + + +
+
+ +
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + + {table.getRowModel().rows.map((row) => ( + + ))} + + ) : ( + + + No results. + + + )} + +
+
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+ + +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+
+ +
+
+ +
+
+ +
+
+
+ ) +} + +const chartData = [ + { month: "January", desktop: 186, mobile: 80 }, + { month: "February", desktop: 305, mobile: 200 }, + { month: "March", desktop: 237, mobile: 120 }, + { month: "April", desktop: 73, mobile: 190 }, + { month: "May", desktop: 209, mobile: 130 }, + { month: "June", desktop: 214, mobile: 140 }, +] + +const chartConfig = { + desktop: { + label: "Desktop", + color: "var(--primary)", + }, + mobile: { + label: "Mobile", + color: "var(--primary)", + }, +} satisfies ChartConfig + +function TableCellViewer({ item }: { item: z.infer }) { + const isMobile = useIsMobile() + + return ( + + + + + + + {item.header} + + Showing total visitors for the last 6 months + + +
+ {!isMobile && ( + <> + + + + value.slice(0, 3)} + hide + /> + } + /> + + + + + +
+
+ Trending up by 5.2% this month{" "} + +
+
+ Showing total visitors for the last 6 months. This is just + some random text to test the layout. It spans multiple lines + and should wrap around. +
+
+ + + )} +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + + + + + +
+
+ ) +} diff --git a/carbon-atlas/components/device-map.tsx b/carbon-atlas/components/device-map.tsx new file mode 100644 index 0000000000..6cf76a2ecf --- /dev/null +++ b/carbon-atlas/components/device-map.tsx @@ -0,0 +1,111 @@ +"use client" + +import { useMemo } from "react" +import { + Map, + MapControlContainer, + MapMarker, + MapTileLayer, +} from "@/components/ui/map" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import type { LatLngExpression } from "leaflet" +import { useDashboardStats } from "@/hooks/useDashboardStats" + +// Known project deployment locations with approximate coordinates +// These are derived from PDD host country fields in the Guardian VC data +const PROJECT_LOCATIONS: { + id: string + country: string + coordinates: LatLngExpression + label: string +}[] = [ + { + id: "bangladesh-vpa02", + country: "Bangladesh", + coordinates: [23.685, 90.356], + label: "VPA02 — Bangladesh", + }, +] + +// Map center: zoomed to show South/Southeast Asia +const MAP_CENTER: LatLngExpression = [23.685, 90.356] +const MAP_ZOOM = 6 + +function PulsingDot({ count }: { count: number | null }) { + return ( +
+ + + + {count !== null && count > 0 && ( + + {count.toLocaleString()} + + )} +
+ ) +} + +export function DeviceMap() { + const { totalDevices, isLoading } = useDashboardStats() + + const locations = useMemo( + () => + PROJECT_LOCATIONS.map((loc) => ({ + ...loc, + devices: totalDevices, + })), + [totalDevices] + ) + + return ( + + + Device Locations + + Active dMRV cooking devices by deployment region + + + +
+ + + {locations.map((location) => ( + + } + iconAnchor={[16, 16]} + /> + ))} + +

Deployment Sites

+ {locations.map((loc) => ( +

+ + + + + {loc.label} +

+ ))} +
+
+
+
+
+ ) +} diff --git a/carbon-atlas/components/nav-documents.tsx b/carbon-atlas/components/nav-documents.tsx new file mode 100644 index 0000000000..b551e71971 --- /dev/null +++ b/carbon-atlas/components/nav-documents.tsx @@ -0,0 +1,92 @@ +"use client" + +import { + IconDots, + IconFolder, + IconShare3, + IconTrash, + type Icon, +} from "@tabler/icons-react" + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar" + +export function NavDocuments({ + items, +}: { + items: { + name: string + url: string + icon: Icon + }[] +}) { + const { isMobile } = useSidebar() + + return ( + + Documents + + {items.map((item) => ( + + + + + {item.name} + + + + + + + More + + + + + + Open + + + + Share + + + + + Delete + + + + + ))} + + + + More + + + + + ) +} diff --git a/carbon-atlas/components/nav-main.tsx b/carbon-atlas/components/nav-main.tsx new file mode 100644 index 0000000000..c650022a13 --- /dev/null +++ b/carbon-atlas/components/nav-main.tsx @@ -0,0 +1,51 @@ +"use client" + +import Link from "next/link" +import { usePathname } from "next/navigation" +import { type Icon } from "@tabler/icons-react" + +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" + +export function NavMain({ + items, +}: { + items: { + title: string + url: string + icon?: Icon + }[] +}) { + const pathname = usePathname() + + return ( + + + + {items.map((item) => ( + + + + {item.icon && } + {item.title} + + + + ))} + + + + ) +} diff --git a/carbon-atlas/components/nav-secondary.tsx b/carbon-atlas/components/nav-secondary.tsx new file mode 100644 index 0000000000..3f3636f1a1 --- /dev/null +++ b/carbon-atlas/components/nav-secondary.tsx @@ -0,0 +1,42 @@ +"use client" + +import * as React from "react" +import { type Icon } from "@tabler/icons-react" + +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" + +export function NavSecondary({ + items, + ...props +}: { + items: { + title: string + url: string + icon: Icon + }[] +} & React.ComponentPropsWithoutRef) { + return ( + + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + + ) +} diff --git a/carbon-atlas/components/nav-user.tsx b/carbon-atlas/components/nav-user.tsx new file mode 100644 index 0000000000..7c49dc71ad --- /dev/null +++ b/carbon-atlas/components/nav-user.tsx @@ -0,0 +1,110 @@ +"use client" + +import { + IconCreditCard, + IconDotsVertical, + IconLogout, + IconNotification, + IconUserCircle, +} from "@tabler/icons-react" + +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@/components/ui/avatar" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar" + +export function NavUser({ + user, +}: { + user: { + name: string + email: string + avatar: string + } +}) { + const { isMobile } = useSidebar() + + return ( + + + + + + + + CN + +
+ {user.name} + + {user.email} + +
+ +
+
+ + +
+ + + CN + +
+ {user.name} + + {user.email} + +
+
+
+ + + + + Account + + + + Billing + + + + Notifications + + + + + + Log out + +
+
+
+
+ ) +} diff --git a/carbon-atlas/components/section-cards.tsx b/carbon-atlas/components/section-cards.tsx new file mode 100644 index 0000000000..1e743220b5 --- /dev/null +++ b/carbon-atlas/components/section-cards.tsx @@ -0,0 +1,96 @@ +"use client" + +import { + IconCertificate, + IconDevices, + IconLeaf, + IconLoader, + IconSitemap, +} from "@tabler/icons-react" +import { + Card, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { useDashboardStats } from "@/hooks/useDashboardStats" + +export function SectionCards() { + const { + issuanceCount, + projectCount, + totalERy, + totalDevices, + isLoading, + } = useDashboardStats() + + const loadingEl = + + const formatERy = (val: number) => + val.toLocaleString("en-US", { maximumFractionDigits: 2 }) + + return ( +
+ + + + + Verified Issuances + + + {isLoading ? loadingEl : issuanceCount} + + + +
Approved monitoring reports on Hedera
+
+
+ + + + + + Emission Reductions + + + {isLoading ? loadingEl : totalERy !== null ? formatERy(totalERy) : "—"} + + + +
Total tCO₂e from verified issuances
+
+
+ + + + + + Active Projects + + + {isLoading ? loadingEl : projectCount} + + + +
Validated project VCs on Hedera
+
+
+ + + + + + Monitored Devices + + + {isLoading ? loadingEl : totalDevices !== null ? totalDevices.toLocaleString() : "—"} + + + +
Cooking devices with metered energy data
+
+
+
+ ) +} diff --git a/carbon-atlas/components/shared/DeviceDataTable.tsx b/carbon-atlas/components/shared/DeviceDataTable.tsx new file mode 100644 index 0000000000..ec5443f19c --- /dev/null +++ b/carbon-atlas/components/shared/DeviceDataTable.tsx @@ -0,0 +1,219 @@ +"use client" + +import * as React from "react" +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type SortingState, +} from "@tanstack/react-table" +import { + IconChevronLeft, + IconChevronRight, + IconChevronsLeft, + IconChevronsRight, + IconSortAscending, + IconSortDescending, +} from "@tabler/icons-react" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import type { DeviceRecord } from "@/lib/types/indexer" +import { formatKWh } from "@/lib/utils/format" + +const columns: ColumnDef[] = [ + { + accessorKey: "device_id", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.device_id} + ), + }, + { + accessorKey: "date_from", + header: "Period Start", + cell: ({ row }) => ( + {row.original.date_from} + ), + }, + { + accessorKey: "date_to", + header: "Period End", + cell: ({ row }) => {row.original.date_to}, + }, + { + accessorKey: "eg_p_d_y", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {formatKWh(row.original.eg_p_d_y)} +
+ ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => ( + 0 + ? "text-green-700 border-green-300 bg-green-50" + : "text-muted-foreground" + } + > + {(row.original.eg_p_d_y ?? 0) > 0 ? "Active" : "Inactive"} + + ), + }, +] + +export function DeviceDataTable({ devices }: { devices: DeviceRecord[] }) { + const [globalFilter, setGlobalFilter] = React.useState("") + const [sorting, setSorting] = React.useState([]) + + const table = useReactTable({ + data: devices, + columns, + state: { globalFilter, sorting }, + onGlobalFilterChange: setGlobalFilter, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + initialState: { pagination: { pageSize: 50 } }, + globalFilterFn: (row, _columnId, filterValue) => { + return String(row.original.device_id).includes(filterValue) + }, + }) + + const activeCount = devices.filter((d) => (d.eg_p_d_y ?? 0) > 0).length + const totalEnergy = devices.reduce((sum, d) => sum + (d.eg_p_d_y ?? 0), 0) + + return ( +
+ {/* Stats bar */} +
+
+
Total Records
+
{devices.length.toLocaleString()}
+
+
+
Active Devices
+
{activeCount.toLocaleString()}
+
+
+
Total Energy
+
{totalEnergy.toLocaleString(undefined, { maximumFractionDigits: 0 })} kWh
+
+
+ + {/* Search */} + setGlobalFilter(e.target.value)} + className="max-w-sm" + /> + + {/* Table */} +
+ + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((h) => ( + + {h.isPlaceholder + ? null + : flexRender(h.column.columnDef.header, h.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No records found. + + + )} + +
+
+ + {/* Pagination */} +
+
+ {table.getFilteredRowModel().rows.length.toLocaleString()} of {devices.length.toLocaleString()} records +
+
+ + Page {table.getState().pagination.pageIndex + 1} / {table.getPageCount()} + + + + + +
+
+
+ ) +} diff --git a/carbon-atlas/components/shared/FieldDisplay.tsx b/carbon-atlas/components/shared/FieldDisplay.tsx new file mode 100644 index 0000000000..f3152c023c --- /dev/null +++ b/carbon-atlas/components/shared/FieldDisplay.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +interface FieldDisplayProps { + label: string + value: React.ReactNode + className?: string +} + +export function FieldDisplay({ label, value, className }: FieldDisplayProps) { + return ( +
+ + {label} + + + {value ?? } + +
+ ) +} + +interface FieldGridProps { + fields: { label: string; value: React.ReactNode }[] + cols?: 2 | 3 | 4 + className?: string +} + +export function FieldGrid({ fields, cols = 2, className }: FieldGridProps) { + const colClass = { + 2: "grid-cols-2", + 3: "grid-cols-2 sm:grid-cols-3", + 4: "grid-cols-2 sm:grid-cols-4", + }[cols] + + return ( +
+ {fields.map((f) => ( + + ))} +
+ ) +} diff --git a/carbon-atlas/components/shared/HederaProofBadge.tsx b/carbon-atlas/components/shared/HederaProofBadge.tsx new file mode 100644 index 0000000000..66a22a5513 --- /dev/null +++ b/carbon-atlas/components/shared/HederaProofBadge.tsx @@ -0,0 +1,30 @@ +import { IconExternalLink, IconShieldCheck } from "@tabler/icons-react" +import { Badge } from "@/components/ui/badge" +import { hederaExplorerUrl } from "@/lib/utils/hedera" +import { formatTimestamp } from "@/lib/utils/format" + +interface HederaProofBadgeProps { + consensusTimestamp: string + className?: string +} + +export function HederaProofBadge({ consensusTimestamp, className }: HederaProofBadgeProps) { + const url = hederaExplorerUrl(consensusTimestamp) + return ( + + + + Hedera · {formatTimestamp(consensusTimestamp)} + + + + ) +} diff --git a/carbon-atlas/components/shared/ProjectDeveloperBadge.tsx b/carbon-atlas/components/shared/ProjectDeveloperBadge.tsx new file mode 100644 index 0000000000..29cb7706f3 --- /dev/null +++ b/carbon-atlas/components/shared/ProjectDeveloperBadge.tsx @@ -0,0 +1,34 @@ +"use client" + +import * as React from "react" +import Image from "next/image" +import { useTheme } from "next-themes" + +export function ProjectDeveloperBadge({ className }: { className?: string }) { + const { resolvedTheme } = useTheme() + const [mounted, setMounted] = React.useState(false) + React.useEffect(() => setMounted(true), []) + + const logo = mounted && resolvedTheme === "dark" + ? "/atec-dark.png" + : "/atec-light.png" + + return ( +
+ Project Developer + + ATEC Global + +
+ ) +} diff --git a/carbon-atlas/components/site-header.tsx b/carbon-atlas/components/site-header.tsx new file mode 100644 index 0000000000..5d7f13d6e8 --- /dev/null +++ b/carbon-atlas/components/site-header.tsx @@ -0,0 +1,127 @@ +"use client" + +import * as React from "react" +import { useTheme } from "next-themes" +import { + IconBrandGithub, + IconChevronDown, + IconMoon, + IconSun, +} from "@tabler/icons-react" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Separator } from "@/components/ui/separator" +import { SidebarTrigger } from "@/components/ui/sidebar" + +const NETWORKS = { + testnet: { + label: "Testnet", + topicUrl: "https://hashscan.io/testnet/topic/1767599197.624837133", + }, + mainnet: { + label: "Mainnet", + topicUrl: "", + }, +} as const + +type NetworkId = keyof typeof NETWORKS + +function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme() + const [mounted, setMounted] = React.useState(false) + React.useEffect(() => setMounted(true), []) + + return ( + + ) +} + +function NetworkSelector() { + const [network, setNetwork] = React.useState("testnet") + + function handleChange(value: string) { + const id = value as NetworkId + setNetwork(id) + const url = NETWORKS[id].topicUrl + if (url) { + window.open(url, "_blank", "noopener,noreferrer") + } + } + + return ( + + + + + + + + Testnet + + + Mainnet (coming soon) + + + + + ) +} + +export function SiteHeader() { + return ( +
+
+ + +
+

+ Methodology for Metered & Measured Energy Cooking Devices +

+

+ Gold Standard MECD 431 v1.2 — ICVCM CCP-approved methodology +

+
+
+ + + +
+
+
+ ) +} diff --git a/carbon-atlas/components/trust-chain/ChainStep.tsx b/carbon-atlas/components/trust-chain/ChainStep.tsx new file mode 100644 index 0000000000..71e9572836 --- /dev/null +++ b/carbon-atlas/components/trust-chain/ChainStep.tsx @@ -0,0 +1,111 @@ +"use client" + +import * as React from "react" +import { + IconChevronDown, + IconChevronRight, + IconLoader, +} from "@tabler/icons-react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { HederaProofBadge } from "@/components/shared/HederaProofBadge" +import { VCRenderer } from "@/components/vc-views/VCRenderer" +import { useVcDocument } from "@/hooks/useVcDocument" +import type { ChainNode } from "@/lib/utils/trust-chain" +import { formatTimestamp } from "@/lib/utils/format" +import { cn } from "@/lib/utils" + +const colorMap: Record = { + teal: "border-teal-300 bg-teal-50 text-teal-800", + blue: "border-blue-300 bg-blue-50 text-blue-800", + indigo: "border-indigo-300 bg-indigo-50 text-indigo-800", + violet: "border-violet-300 bg-violet-50 text-violet-800", + green: "border-green-300 bg-green-50 text-green-800", + amber: "border-amber-300 bg-amber-50 text-amber-800", + orange: "border-orange-300 bg-orange-50 text-orange-800", + sky: "border-sky-300 bg-sky-50 text-sky-800", + slate: "border-slate-300 bg-slate-50 text-slate-800", +} + +interface ChainStepProps { + node: ChainNode + index: number + total: number +} + +export function ChainStep({ node, index, total }: ChainStepProps) { + const [expanded, setExpanded] = React.useState(false) + const [fetched, setFetched] = React.useState(false) + + // Use consensusTimestamp — that's what the indexer API expects as path param + const { data: vcDetail, isLoading } = useVcDocument( + fetched ? node.vc.consensusTimestamp : undefined + ) + + function handleExpand() { + if (!expanded && !fetched) setFetched(true) + setExpanded((v) => !v) + } + + const badgeClass = + colorMap[node.config.color] ?? "border-slate-300 bg-slate-50 text-slate-800" + const isLast = index === total - 1 + + return ( +
+ {/* Vertical connector line */} +
+
+ {!isLast &&
} +
+ + {/* Content */} +
+
+ {/* Step header */} + + + {/* Expanded content */} + {expanded && ( +
+ {isLoading ? ( +
+ + Loading VC detail… +
+ ) : vcDetail ? ( + + ) : ( +

Failed to load VC detail.

+ )} +
+ )} +
+
+
+ ) +} diff --git a/carbon-atlas/components/trust-chain/TrustChainView.tsx b/carbon-atlas/components/trust-chain/TrustChainView.tsx new file mode 100644 index 0000000000..c26c33b9fc --- /dev/null +++ b/carbon-atlas/components/trust-chain/TrustChainView.tsx @@ -0,0 +1,56 @@ +"use client" + +import * as React from "react" +import { IconLoader } from "@tabler/icons-react" +import { ChainStep } from "./ChainStep" +import { useAllPolicyVcs } from "@/hooks/usePolicyVcDocuments" +import { buildChain } from "@/lib/utils/trust-chain" + +interface TrustChainViewProps { + rootVcId: string +} + +export function TrustChainView({ rootVcId }: TrustChainViewProps) { + const { data: allVcs, isLoading, error } = useAllPolicyVcs() + + const chain = React.useMemo(() => { + if (!allVcs) return [] + return buildChain(allVcs, rootVcId) + }, [allVcs, rootVcId]) + + if (isLoading) { + return ( +
+ + Loading trust chain… +
+ ) + } + + if (error) { + return ( +

+ Error loading trust chain: {error.message} +

+ ) + } + + if (chain.length === 0) { + return ( +

+ No chain nodes found for this issuance. +

+ ) + } + + return ( +
+

+ {chain.length} nodes in chain · Click any step to expand VC details +

+ {chain.map((node, i) => ( + + ))} +
+ ) +} diff --git a/carbon-atlas/components/ui/avatar.tsx b/carbon-atlas/components/ui/avatar.tsx new file mode 100644 index 0000000000..1ac15704f7 --- /dev/null +++ b/carbon-atlas/components/ui/avatar.tsx @@ -0,0 +1,109 @@ +"use client" + +import * as React from "react" +import { Avatar as AvatarPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarBadge, + AvatarGroup, + AvatarGroupCount, +} diff --git a/carbon-atlas/components/ui/badge.tsx b/carbon-atlas/components/ui/badge.tsx new file mode 100644 index 0000000000..beb56ed1cb --- /dev/null +++ b/carbon-atlas/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + link: "text-primary underline-offset-4 [a&]:hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/carbon-atlas/components/ui/breadcrumb.tsx b/carbon-atlas/components/ui/breadcrumb.tsx new file mode 100644 index 0000000000..542e76202a --- /dev/null +++ b/carbon-atlas/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react" +import { ChevronRight, MoreHorizontal } from "lucide-react" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return