diff --git a/PROJECT_SPEC.md b/PROJECT_SPEC.md deleted file mode 100644 index df5cfe3..0000000 --- a/PROJECT_SPEC.md +++ /dev/null @@ -1,664 +0,0 @@ -# Accounting Dashboard Project Spec - -## 1. Goal - -Build a wallet-gated accounting dashboard for RaidGuild that links onchain treasury activity, manually entered raid accounting activity, DAO proposal data, RIPs, and eventually bank CSV imports into clean quarterly and annual financial reports. - -The first implementation target is a complete Q1 2026 export. - -The most important reporting output is taxable revenue. RaidGuild taxes are based on gross revenue, not profit, so the dashboard must clearly and accurately report full client payments as taxable revenue. - -The app should also provide a P&L summary for internal records, track expenses and revenue sources, support raid/project accounting, and produce accountant-friendly exports. - -## 2. Non-Goals For V1 - -- Invoice tracking. -- Accrual accounting. -- Storing uploaded bank CSV files permanently. -- Storing sensitive contractor tax documents. -- Mobile-specific workflows beyond responsive web layout. -- Hardcoding treasury addresses, DAO addresses, RPC URLs, API keys, or real financial data in the public repo. - -## 3. Core Accounting Rules - -The app uses cash-basis accounting only. Money counts when it actually moves. - -For raids: - -- Full client payment is taxable revenue. -- Spoils are always 10% of gross raid revenue. -- The remaining 90% is the expected subcontractor/team payout pool. -- Subcontractor payouts are expenses for P&L and raid accounting. -- Spoils are expected to land in a treasury or old side-vault and can be tracked as their own treasury inflow. - -Example: - -- Client pays $10,000. -- Taxable revenue: $10,000. -- Expected spoils: $1,000. -- Expected subcontractor pool: $9,000. - -Stablecoin valuation: - -- USDC, xDAI, and wxDAI are treated as 1:1 USD. - -Volatile asset valuation: - -- wETH is valued in USD at transaction time. -- CoinGecko is the preferred historical pricing source. - -## 4. Supported Assets And Chains - -Automated treasury scanning in V1 starts on Gnosis only. - -Supported Gnosis assets: - -- USDC -- xDAI -- wxDAI -- wETH - -Manual transaction lookup should support: - -- Gnosis -- Arbitrum -- Optimism -- Ethereum mainnet -- Base - -## 5. Users And Permissions - -Authentication is wallet-based using SIWE. - -Read access: - -- Any wallet with at least 100 current RaidGuild DAO shares. - -Admin/write access: - -- Wallets wearing the Angry Dwarf Hat, checked through Hats Protocol data. - -Cleric access: - -- Wallets assigned the Cleric role by an Angry Dwarf admin can manually add raid revenue, raid payouts, and spoils links. -- Clerics are narrower than Angry Dwarf admins. -- Clerics should not be able to manage quarter publishing, bank imports, side-vault configuration, global classifications, or unrelated entity/address book data unless separately granted admin access. -- Cleric roles are stored in the database and can be granted or revoked by Angry Dwarf admins. -- Cleric role changes should be audit logged. - -Configuration: - -- DAO contract address, share threshold, Hats IDs, Safe addresses, RPC URLs, and API keys must be environment-configured. -- No sensitive values should be committed to source control. - -Member permissions: - -- View current treasury balance homepage. -- View published/locked quarters. -- Generate/export reports for published quarters. -- View full audit/change history in an uncluttered history area. - -Cleric permissions: - -- Add manual raid revenue entries. -- Add manual raid payout entries. -- Link spoils inflows to raids. -- Use transaction lookup for Gnosis, Arbitrum, Optimism, Ethereum mainnet, and Base. -- Create flagged unverified manual entries when no transaction hash exists. -- View raids and related clients/subcontractors needed for their accounting workflow. - -Angry Dwarf admin permissions: - -- Fetch/import data. -- Classify transactions. -- Create and edit clients, raids, providers, subcontractors, RIPs, and address book records. -- Grant and revoke Cleric roles. -- Add side-vaults. -- Import bank CSVs. -- Manage quarter workflow states. -- Lock, publish, reopen, and republish quarters. - -## 6. Quarter Workflow - -Quarter data is prepared by Angry Dwarf admins before members can inspect or export it. - -Quarter statuses: - -- Draft: admins are importing and classifying data. -- Ready for Review: admins believe the quarter is complete. -- Published/Locked: members can view/export; data is read-only. -- Reopened: a previously published quarter has been reopened for correction. - -Published quarters should have: - -- Published timestamp. -- Last updated timestamp. -- Status history. -- Audit history. -- User-facing export label based on date, such as "Q1 2026, last published April 12, 2026". - -Reopening a quarter: - -- Requires Angry Dwarf admin access. -- Records who reopened it, when, and why. -- Makes the quarter editable again. -- Should visibly mark the quarter as reopened until republished. - -## 7. Homepage - -The homepage should always be visible to members who pass wallet access control, even before any quarter is published. - -Homepage content: - -- Current total treasury balance in USD, starting with onchain Gnosis accounts and later including the latest imported bank balance snapshot. -- Asset breakdown across included balance sources. -- Account breakdown by Treasury, side-vault, and eventually bank balance source. -- Published quarter cards. -- Export buttons for published quarters. - -The homepage should not include a recent activity feed in V1. - -Balance refresh behavior: - -- The homepage serves the latest cached treasury balance immediately. -- Any authenticated member may trigger a background refresh when cached balance data is missing or older than one hour. -- Stale data should remain readable and dated, with clear sync timing and subtle refreshing/updated UI states. - -## 8. Data Sources - -### Main Safe - -The first automated onchain source is the main RaidGuild Safe on Gnosis. - -The main Safe address is environment-configured. - -The app should fetch current balances for USDC, xDAI, wxDAI, and wETH through the configured Gnosis RPC. Stable assets are valued 1:1 with USD. wETH uses CoinGecko pricing when available, with the API key optional. - -Later main Safe ingestion should fetch: - -- Native transfers. -- ERC-20 transfers. -- Safe transaction metadata where useful. -- Transaction hashes and timestamps. - -### Side-Vaults - -Side-vaults come after main Safe support. - -Side-vault records are Angry Dwarf admin-managed Gnosis Safe/multisig entries. - -Fields: - -- Name. -- Address. -- Gnosis chain. -- Notes. -- Whether DAO-controlled or independent multisig. -- Active/archived status. - -Side-vault behavior: - -- Queried alongside the main treasury. -- Included in homepage balances and later reports. -- Labeled by source account. -- Some side-vaults are controlled by DAO proposals. -- Some are separate multisigs and will not have DAO proposal links. - -Onchain balance syncing is Gnosis-only for V1. Future bank imports should add the latest imported bank balance snapshot to the homepage total without changing the onchain account model. - -### DAO Proposals - -The app should fetch DAO proposals that moved money during execution. - -Proposal linking rule: - -- Proposal execution transaction hash should match the treasury transaction hash. -- This link should be inferred automatically. - -The proposals page should show proposal-associated expenses and other money-moving proposal activity. - -### Manual Raid Activity - -Treasury scanning is not enough to capture all raid activity. - -Common workflow: - -- A Cleric/account manager may receive raid revenue outside the tracked treasury accounts. -- The Cleric may pay the team/subcontractors separately. -- Only the 10% spoils may later reach the treasury or old side-vault. - -The app therefore needs manual accounting flows that can still be verified by tx hash where possible. - -Manual raid revenue flow: - -- Cleric or admin pastes a transaction hash and selects or infers chain. -- App fetches transaction details and native/ERC-20 transfers. -- Cleric or admin selects relevant transfer(s). -- Cleric or admin links revenue to client and raid. -- Cleric or admin can split one payment across multiple raids, though this is rare. -- If no tx hash exists, Cleric or admin can create an unverified manual entry that is clearly flagged. - -Manual raid payout flow: - -- Cleric or admin pastes a transaction hash and selects or infers chain. -- App fetches transaction details and transfer lines. -- Cleric or admin selects relevant transfer(s). -- Cleric or admin allocates payout lines to raid and subcontractors. -- One transaction can pay multiple subcontractors. -- If no tx hash exists, Cleric or admin can create an unverified manual entry that is clearly flagged. - -Spoils linking flow: - -- Cleric or admin links a spoils inflow to a raid. -- Spoils inflow usually appears as a treasury or old side-vault transaction. -- The app should compare linked spoils against the expected 10% of gross raid revenue. -- Underpaid, overpaid, or missing spoils should be visible in raid accounting views and exports. - -Manual entry verification states: - -- Verified: backed by fetched onchain transaction/transfer. -- Unverified: hand-entered and clearly flagged in dashboard and exports. - -### Bank CSVs - -Bank CSV support comes later in V1. - -Rules: - -- Upload CSV through dashboard. -- Parse into normalized bank transaction rows. -- Discard the original uploaded file after import. -- Store only necessary imported fields. -- Encrypt sensitive stored fields. -- Support duplicate detection across imports. - -## 9. Domain Objects - -### Member - -Represents a DAO member or wallet with relevant access/accounting activity. - -Fields may include: - -- Wallet address. -- Current share count. -- Access role derived from DAO shares and Hats Protocol. -- Membership dues/join activity. -- Ragequit activity. - -### Client - -Fields: - -- Name. -- Optional website. -- Optional wallet/address list. -- Optional notes. -- Associated raids/projects. - -### Raid - -Raid means project/client engagement. - -Fields: - -- Client. -- Raid/project name. -- Optional notes. -- Gross revenue allocations. -- Expected 10% spoils. -- Expected 90% subcontractor pool. -- Actual subcontractor payouts. -- Remaining/unpaid payout balance. - -### Subcontractor - -Lightweight non-sensitive address book record. - -Fields: - -- Display name. -- Wallet address. -- Optional notes. -- Optional member marker. - -No legal identity fields or tax documents in V1. - -### Provider - -Service provider profile for non-raid expenses. - -Fields: - -- Provider name. -- Wallet/address list. -- Category, such as software, legal, accounting, or ops. -- Optional notes. - -### Treasury Account - -Represents main Safe or side-vault. - -Fields: - -- Name. -- Address. -- Chain. -- Account type. -- DAO-controlled flag. -- Notes. - -### Proposal - -Only money-moving proposals are in scope for V1 proposal pages. - -Fields: - -- Proposal ID. -- Title. -- Status. -- Execution transaction hash. -- Processed/executed date. -- Linked treasury transaction(s). -- Amounts and assets moved. - -### RIP - -RIPs are paid through proposals or side-vault transactions and usually have an external ticket link. - -Fields: - -- Title. -- External link, such as a GitHub issue. -- Optional notes. -- Status. -- Linked proposal or transaction(s). -- Linked subcontractor/payee records. -- Total amount paid. - -RIP page requirements: - -- List RIPs. -- Show total paid. -- Link payees/subcontractors. -- Rank most expensive and least expensive RIPs. - -### Transaction / Ledger Entry - -The system should distinguish observed transactions from manual accounting events. - -Observed transaction sources: - -- Main Safe. -- Side-vaults. -- DAO proposal executions. -- Bank CSV imports. - -Manual accounting event sources: - -- Manual raid revenue entries. -- Manual raid payout entries. -- Unverified hand-entered entries. - -Starter classification categories: - -- Raid revenue. -- Subcontractor payout. -- Provider expense. -- Member dues. -- Ragequit. -- Transfer between treasury accounts. -- Uncategorized. - -## 10. Reporting And Export - -First target: - -- Complete Q1 2026 export. - -Primary format: - -- XLSX workbook. - -Q1 definition: - -- Calendar Q1: January 1 through March 31, 2026. - -Workbook tabs: - -- Summary. -- Taxable Revenue. -- P&L. -- Raid Revenue & Spoils. -- Subcontractor Payouts. -- Provider Expenses. -- Proposals. -- RIPs. -- Membership Activity. -- Full Ledger. - -Exports should include: - -- Source transaction hashes where available. -- Source account. -- Chain. -- Asset. -- USD value. -- Classification. -- Linked client/raid/provider/subcontractor/RIP/proposal where applicable. -- Verification flag. -- Quarter status. -- Last published/updated date. - -Membership activity should also be available as its own report/export: - -- Member dues / joins. -- Ragequits. -- Transaction hashes. -- Wallet/member. -- Asset and USD value. -- Date/quarter/year. - -Future published quarter reports should support a lightweight analysis assistant: - -- Members can open a chat box on a published quarter report page and ask analysis questions or request visual summaries of that quarter's report data. -- Example prompts: - - "What were our top 5 raids?" - - "Please chart the balance for the quarter." - - "Please show a pie chart of all clients by revenue." -- The assistant should answer analysis questions inline and, when useful, update the page UI by adding or replacing chart widgets inside the published quarter report view. -- Chart generation should be scoped to already-published, member-visible quarter data. -- The assistant should not expose draft data, unpublished classifications, audit-only metadata, decrypted private notes, or raw sensitive records. -- Supported responses should start with concise text answers and simple tables, plus line/bar/pie charts for visual prompts, backed by deterministic report queries rather than arbitrary database access. -- Generated answers and chart widgets should show enough provenance for members to understand the underlying report slice, such as quarter, metric, grouping, and last published date. - -Security and implementation constraints: - -- A `ReportQueryValidator` should enforce an allowlist of deterministic report queries, validate query parameters against a schema, and scope every query to the published quarter report view. -- The assistant should only read published-only datasets and member-visible report slices. -- A `PromptGuard` layer should mitigate prompt injection with instruction sanitization, a strict system prompt, and tokenized input checks before any query or chart plan is accepted. -- The chat API should enforce rate limits and per-user throttling. -- A `ChartRendererSandbox` should render generated widgets with DOM sanitization, CSP-compatible output, resource/time limits, and image-only outputs where practical. -- Generated queries, answers, and widgets should be logged for auditability without storing decrypted private notes or raw sensitive records. -- Every answer and chart widget should include provenance fields: quarter, metric, grouping, and last published date. - -Future published accounting data should also be available through a paid machine-access API: - -- Add an x402-compatible endpoint that lets external services request published accounting data for a configured price. -- Requests must satisfy both access requirements: - - Payment is completed or verified through the x402 flow. - - The request is signed by an EVM wallet that currently qualifies as a RaidGuild member. -- The endpoint should only return published, member-visible accounting data. -- The endpoint should not expose draft data, reopened-but-unpublished changes, audit-only metadata, decrypted private notes, raw bank memo data, or admin-only records. -- Supported response shapes should start with deterministic report slices, such as quarter summary, taxable revenue, client revenue, raid revenue, provider expenses, and full ledger rows that match the published export. -- Responses should include provenance fields such as quarter, published timestamp, source transaction hash where available, and report/export version. -- Pricing, accepted settlement asset/network, x402 facilitator/provider, replay protection, request expiry, and rate limits should be finalized during implementation. - -## 11. Security And Privacy - -The repo is public. - -Never commit: - -- Safe addresses. -- DAO contract addresses. -- Hat IDs. -- RPC URLs. -- API keys. -- Bank CSV samples. -- Real transaction classification data. -- Real client/provider/subcontractor data. - -Use environment variables or secret manager values for all sensitive configuration. - -Database: - -- Hosted Postgres using Neon. -- App-level authorization based on wallet session, DAO shares, and Hats admin status. -- Audit logs for sensitive actions and quarter workflow changes. - -Application-layer encryption: - -- Sensitive fields should be encrypted before storage, in addition to provider-level database encryption. -- Encryption key should come from environment/secret manager. - -Fields to encrypt: - -- Bank memo/description. -- Notes. -- Client/provider/subcontractor optional notes. -- Private external links. -- Entity display names if relationships are considered sensitive. - -Fields that do not require app-layer encryption by default: - -- Public transaction hashes. -- Dates. -- Amounts. -- Token symbols. -- Public addresses. - -## 12. Tech Stack - -Core app: - -- Next.js. -- TypeScript. -- Tailwind CSS. -- shadcn/ui customized to RaidGuild brand guidelines. - -Auth and EVM: - -- SIWE for wallet login. -- viem/wagmi for EVM reads and wallet integration. - -Database: - -- Neon Postgres. -- Drizzle ORM. - -Hosting: - -- Vercel. -- Target subdomain: accounting.raidguild.org. - -Data integrations: - -- Safe/Gnosis APIs plus RPC fallback. -- DAOhaus/Moloch DAO reads. -- Hats Protocol data for Angry Dwarf admin checks. -- CoinGecko historical pricing for wETH. - -Exports: - -- Server-side XLSX generation. - -## 13. PR-Sized Implementation Plan - -Each step should be its own PR. - -1. Project scaffold - - Next.js, TypeScript, Tailwind, shadcn/ui, linting, env conventions, base layout. - -2. Database foundation - - Neon/Drizzle setup, migrations, core tables, encrypted-field utility, audit-log table. - -3. Wallet auth + permissions - - SIWE login, session handling, DAO share read gate, Hats admin gate, database-managed Cleric role gate. - -4. Member homepage - - Current treasury balance UI with mocked/configured data shape, asset/account breakdown. - -5. Main Safe balance ingestion - - Gnosis RPC integration, cached balances from env-configured Treasury. - -6. Side-vault admin - - Admin-managed Gnosis side-vault CRUD using treasury account records, including active/archived state and DAO-controlled labeling. - -7. Multi-account balance sync - - Sync Treasury plus active side-vault balances, include them in the homepage total, account breakdown, and aggregated asset breakdown. - -8. Quarter workspace model - - Q1 workspace, statuses, draft/review/published/reopened flow, quarter audit history. - -9. Core entity management - - Clients, raids, providers, subcontractors, address book CRUD with admin-only writes. - -10. Main Safe and side-vault transaction ingestion - - Import Gnosis native/ERC-20 transfers, Safe metadata where useful, tx hashes, and timestamps from tracked onchain accounts. - -11. Transaction classification - - Classify imported transactions, link to entities, categories, notes, verification/source metadata. - -12. Raid accounting views - - Raid revenue, 10% spoils calculation, subcontractor payout summaries, unpaid/remaining views. - -13. DAO proposal linking - - Fetch money-moving proposals, link by execution tx hash, proposal expense page. - -14. Membership activity reports - - Member dues/joins and ragequit classification/report pages. - -15. Manual transaction lookup - - Paste tx hash, support Gnosis/Arbitrum/Optimism/Ethereum/Base, fetch native/ERC-20 transfers. - -16. Manual raid revenue flow - - Cleric/admin flow to select transfer(s), allocate revenue to client/raid, support split revenue and flagged unverified entries. - -17. Manual raid payout flow - - Cleric/admin flow to select transfer(s), allocate payout lines to raid/subcontractors, support split payouts and flagged unverified entries. - -18. Spoils linking flow - - Cleric/admin flow to link spoils inflows to raids and compare actual spoils against expected 10%. - -19. RIP tracking - - RIP CRUD, external links, linked tx/proposals/payees, most/least expensive views. - -20. Bank CSV import - - Upload, parse, normalize rows, discard original file, encrypted sensitive fields, duplicate detection. - -21. Bank transaction classification - - Classify imported bank rows, link to quarter/entities/reports. - -22. XLSX export - - Q1 workbook with all required tabs, source hashes, verification flags, last published/updated date. - -23. Publish/export polish - - Member-facing published quarter view, export buttons, read-only locked state, final UI/security pass. - -24. Published report analysis assistant - - Add a member-facing chat box to published quarter reports that can answer scoped analysis questions and create chart widgets from published report data, such as top raids, quarter balance charts, or client revenue pie charts. - -25. x402 published accounting data endpoint - - Add a paid machine-access endpoint for published accounting data that requires x402 payment plus an EVM signature from a current member address. - -## 14. Open Questions - -- Exact DAOhaus/Moloch contracts and read methods for shares, joins, ragequits, and proposal data. -- Exact Hats Protocol source and Hat ID for Angry Dwarf admin access. -- Whether CoinGecko free tier is sufficient for historical pricing volume. -- Exact bank CSV formats to support first. -- Whether side-vaults are all Gnosis in V1 or some side-vault scanning needs other chains. -- How much entity naming should be encrypted versus visible as normal app data. -- Whether published quarter exports should include internal audit history or only source/verification fields. -- x402 endpoint pricing, accepted settlement asset/network, facilitator/provider, request expiry, replay protection, and rate limits. diff --git a/README.md b/README.md index e5ca7e2..862917a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,43 @@ Wallet-gated accounting dashboard for RaidGuild treasury reporting. -The project plan and product specification live in [PROJECT_SPEC.md](./PROJECT_SPEC.md). +## Product Scope + +RaidGuild Accounting links treasury holdings, manually entered accounting +activity, DAO proposal data, RIPs, and imported reporting data into member-facing +quarterly and annual financial reports. + +The app uses cash-basis accounting: money counts when it moves. Taxable raid +revenue is gross client revenue, with spoils tracked as 10% of gross raid +revenue and subcontractor payouts tracked as expenses for P&L and raid +accounting. + +Treasury portfolio views are read-only and focus on current holdings, asset +allocation, account breakdowns, sync freshness, and stablecoin rebalancing +guidance. Stablecoin allocation targets 25% stables and 75% other assets, with a +5 percentage point tolerance. + +## Architecture + +- Next.js App Router renders the wallet-gated dashboard. +- Drizzle ORM models accounting records, treasury accounts, balance snapshots, + quarter workflow state, audit records, and entity/address book records. +- Treasury balance snapshots are synced from environment-configured accounts and + cached for member-visible reads. +- Published quarter reports and exports are member-visible. Draft, + ready-for-review, reopened, and administrative accounting workflows remain + restricted to the appropriate roles. +- Sensitive configuration and real financial data are environment-driven. The + public repo must not hardcode treasury addresses, DAO addresses, RPC URLs, API + keys, bank data, or classified accounting records. + +## Permissions + +- Member access is wallet-gated through SIWE and current RaidGuild DAO shares. +- Admin/write access is derived from Angry Dwarf Hat ownership. +- Cleric access is database-managed by admins and is limited to raid-oriented + accounting workflows. +- Portfolio and published report views are read-only for authorized users. ## Getting Started diff --git a/src/app/admin/providers/page.tsx b/src/app/admin/providers/page.tsx index 927996f..c16bacc 100644 --- a/src/app/admin/providers/page.tsx +++ b/src/app/admin/providers/page.tsx @@ -13,6 +13,7 @@ import { and, eq, inArray, sql } from "drizzle-orm"; import { AppHeader } from "@/components/app-header"; import { CopyableAddress } from "@/components/copyable-address"; +import { DashboardBackLink } from "@/components/dashboard-back-link"; import { Button } from "@/components/ui/button"; import { getDb } from "@/db"; import { ledgerEntries } from "@/db/schema"; @@ -638,6 +639,8 @@ export default async function ProvidersPage({
+ +
+ + {canManage && !q1Exists ? (
diff --git a/src/app/admin/treasury-accounts/page.tsx b/src/app/admin/treasury-accounts/page.tsx index d91bd9c..640e0df 100644 --- a/src/app/admin/treasury-accounts/page.tsx +++ b/src/app/admin/treasury-accounts/page.tsx @@ -15,6 +15,7 @@ import type { ReactNode } from "react"; import { AppHeader } from "@/components/app-header"; import { CopyableAddress } from "@/components/copyable-address"; +import { DashboardBackLink } from "@/components/dashboard-back-link"; import { Button } from "@/components/ui/button"; import { getAuthSession, serializeSession } from "@/lib/auth/session"; import { getTreasuryBalanceSnapshot } from "@/lib/treasury/balances"; @@ -605,6 +606,8 @@ export default async function TreasuryAccountsPage({
+ + * { + min-width: 0; + } + .type-label { @apply text-xs font-semibold tracking-normal; } @@ -254,7 +258,7 @@ } .mobile-card-table tr { - @apply grid gap-2 rounded-md border border-border bg-background p-3 md:table-row md:rounded-none md:border-0 md:bg-transparent md:p-0; + @apply grid gap-1.5 rounded-md border border-border bg-background p-3 md:table-row md:rounded-none md:border-0 md:bg-transparent md:p-0; } .mobile-card-table tbody tr { @@ -271,7 +275,7 @@ } .mobile-card-table td { - @apply grid min-w-0 grid-cols-[7.5rem_minmax(0,1fr)] gap-3 py-0 text-right md:table-cell md:text-left; + @apply grid min-w-0 grid-cols-[6.25rem_minmax(0,1fr)] gap-2 py-0 text-right leading-snug md:table-cell md:text-left md:leading-normal; overflow-wrap: anywhere; } @@ -293,6 +297,14 @@ @apply sr-only; } + .mobile-card-table td.p-0 { + @apply md:p-0; + } + + .mobile-card-table td > a.block { + @apply px-0 py-0 md:px-4 md:py-3; + } + .mobile-card-table-lg { @apply w-full text-left text-sm; } @@ -306,7 +318,7 @@ } .mobile-card-table-lg tr { - @apply grid gap-2 rounded-md border border-border bg-background p-3 lg:table-row lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0; + @apply grid gap-1.5 rounded-md border border-border bg-background p-3 lg:table-row lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0; } .mobile-card-table-lg tbody tr { @@ -323,7 +335,7 @@ } .mobile-card-table-lg td { - @apply grid min-w-0 grid-cols-[7.5rem_minmax(0,1fr)] gap-3 py-0 text-right lg:table-cell lg:text-left; + @apply grid min-w-0 grid-cols-[6.25rem_minmax(0,1fr)] gap-2 py-0 text-right leading-snug lg:table-cell lg:text-left lg:leading-normal; overflow-wrap: anywhere; } @@ -349,4 +361,17 @@ .mobile-card-table-lg td[data-full="true"]::before { @apply sr-only; } + + .mobile-card-table-lg td.p-0 { + @apply lg:p-0; + } + + .mobile-card-table-lg td > a.block { + @apply px-0 py-0 lg:px-4 lg:py-3; + } + + .portfolio-asset-table td, + .portfolio-asset-table th { + @apply lg:py-2; + } } diff --git a/src/app/membership/page.tsx b/src/app/membership/page.tsx index 8da4100..f132288 100644 --- a/src/app/membership/page.tsx +++ b/src/app/membership/page.tsx @@ -8,6 +8,7 @@ import Link from "next/link"; import { AppHeader } from "@/components/app-header"; import { CopyableAddress } from "@/components/copyable-address"; +import { DashboardBackLink } from "@/components/dashboard-back-link"; import { grantClericRole, revokeClericRole } from "@/app/membership/actions"; import { getAuthSession, serializeSession } from "@/lib/auth/session"; import { listClericRoles, type ClericRoleRow } from "@/lib/cleric-roles"; @@ -482,6 +483,8 @@ export default async function MembershipPage({
+ + {canManageClerics ? ( STABLE_SYMBOLS.has(asset.symbol)) + .reduce((total, asset) => total + Number(asset.usdValue), 0); + const safeTotalUsd = Number.isFinite(totalUsd) ? totalUsd : 0; + const safeStableUsd = Number.isFinite(stableUsd) ? stableUsd : 0; + const otherUsd = Math.max(safeTotalUsd - safeStableUsd, 0); + const stablePercent = + safeTotalUsd > 0 ? (safeStableUsd / safeTotalUsd) * 100 : 0; + const targetStableUsd = safeTotalUsd * (TARGET_STABLE_PERCENT / 100); + const lowerBound = TARGET_STABLE_PERCENT - STABLE_TOLERANCE_PERCENT; + const upperBound = TARGET_STABLE_PERCENT + STABLE_TOLERANCE_PERCENT; + const isUnavailable = safeTotalUsd === 0; + const isOutOfRange = + !isUnavailable && + (stablePercent < lowerBound || stablePercent > upperBound); + const rebalanceAmountUsd = Math.abs(safeStableUsd - targetStableUsd); + let rebalanceCopy = + "Stable allocation is within tolerance. No rebalance is suggested."; + + if (isUnavailable) { + rebalanceCopy = "Portfolio value is unavailable until balances sync."; + } else if (stablePercent > upperBound) { + rebalanceCopy = `Buy ${formatCurrency( + rebalanceAmountUsd, + )} of wETH with stables to return to ${TARGET_STABLE_PERCENT}% stables.`; + } else if (stablePercent < lowerBound) { + rebalanceCopy = `Sell ${formatCurrency( + rebalanceAmountUsd, + )} of wETH into stables to return to ${TARGET_STABLE_PERCENT}% stables.`; + } + + return { + isUnavailable, + isOutOfRange, + lowerBound, + otherUsd, + rebalanceAmountUsd, + rebalanceCopy, + stablePercent, + stableUsd: safeStableUsd, + targetStableUsd, + totalUsd: safeTotalUsd, + upperBound, + }; +} + +function getAllocationRows( + snapshot: TreasuryBalanceSnapshot, +): AllocationRow[] { + const totalUsd = Number(snapshot.totalUsd); + const safeTotalUsd = Number.isFinite(totalUsd) ? totalUsd : 0; + + return snapshot.assets + .map((asset, index) => { + const usdValue = Number(asset.usdValue); + const safeUsdValue = Number.isFinite(usdValue) ? usdValue : 0; + + return { + asset, + color: CHART_COLORS[index % CHART_COLORS.length], + percent: safeTotalUsd > 0 ? (safeUsdValue / safeTotalUsd) * 100 : 0, + usdValue: safeUsdValue, + }; + }) + .sort((left, right) => right.usdValue - left.usdValue); +} + +function getAccountRows(snapshot: TreasuryBalanceSnapshot): AccountRow[] { + const totalUsd = Number(snapshot.totalUsd); + const safeTotalUsd = Number.isFinite(totalUsd) ? totalUsd : 0; + + return snapshot.accounts + .map((account) => { + const totalUsdNumber = Number(account.totalUsd); + const safeAccountUsd = Number.isFinite(totalUsdNumber) + ? totalUsdNumber + : 0; + + return { + ...account, + percent: safeTotalUsd > 0 ? (safeAccountUsd / safeTotalUsd) * 100 : 0, + totalUsdNumber: safeAccountUsd, + }; + }) + .sort((left, right) => right.totalUsdNumber - left.totalUsdNumber); +} + +async function getSnapshot() { + try { + return await getTreasuryBalanceSnapshot(); + } catch (error) { + console.error("Failed to load portfolio balance snapshot", error); + return createFailedTreasuryBalanceSnapshot( + "Treasury balance cache unavailable", + ); + } +} + +function AccessRequired({ + session, +}: { + session: ReturnType; +}) { + return ( +
+ +
+
+

Portfolio

+

+ Member access required +

+
+
+
+ ); +} + +function AllocationFlag({ summary }: { summary: AllocationSummary }) { + const Icon = + summary.isUnavailable || summary.isOutOfRange + ? AlertTriangle + : CheckCircle2; + + return ( +
+
+
+ + +
+

+ Stable Allocation +

+

+ {summary.isUnavailable + ? "Allocation unavailable" + : `${formatPercent(summary.stablePercent)}% stables`} +

+

+ {summary.isUnavailable + ? summary.rebalanceCopy + : `Target is ${TARGET_STABLE_PERCENT}% stables with an acceptable ${summary.lowerBound}% - ${summary.upperBound}% range.`} +

+
+
+ + {summary.isUnavailable + ? "Unavailable" + : summary.isOutOfRange + ? "Rebalance flagged" + : "Within tolerance"} + +
+
+ ); +} + +function MetricCard({ + icon: Icon, + label, + value, +}: { + icon: LucideIcon; + label: string; + value: string; +}) { + return ( +
+
+
+

{label}

+

{value}

+
+ + +
+
+ ); +} + +function RebalanceCard({ summary }: { summary: AllocationSummary }) { + return ( +
+
+
+ + +
+

+ Rebalance Guidance +

+

+ {summary.rebalanceCopy} +

+
+
+ + {summary.isOutOfRange + ? formatCurrency(summary.rebalanceAmountUsd) + : "No action suggested"} + +
+
+ ); +} + +function AssetTable({ rows }: { rows: AllocationRow[] }) { + return ( +
+ + + + + + + + + + + + + {rows.map((row) => { + const isStable = STABLE_SYMBOLS.has(row.asset.symbol); + + return ( + + + + + + + + + ); + })} + +
AssetBucketChainBalanceUSD ValueAllocation
+ Asset: +

{row.asset.symbol}

+

+ {row.asset.name} +

+
+ Bucket: + + {isStable ? "Stable" : "Other"} + + + Chain: + {row.asset.chainIds.map(getChainName).join(", ")} + + Balance: + {formatNumber(row.asset.balance)} {row.asset.symbol} + + USD Value: + {formatCurrency(row.usdValue)} + + Allocation: + {formatPercent(row.percent)}% +
+
+ ); +} + +function formatAddress(address: string) { + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +function AccountBreakdown({ rows }: { rows: AccountRow[] }) { + return ( +
+
+
+

Accounts

+

Included holdings

+
+
+ +
+ {rows.map((account) => ( +
+
+
+

{account.name}

+

+ {getChainName(account.chainId)} +

+
+
+

+ {formatCurrency(account.totalUsdNumber)} +

+

+ {formatPercent(account.percent)}% +

+
+
+

+ {account.address ? formatAddress(account.address) : "No address"} +

+
+ ))} +
+
+ ); +} + +export default async function PortfolioPage() { + const session = serializeSession(await getAuthSession()); + + if (!session.authenticated || !session.permissions?.canAccess) { + return ; + } + + const snapshot = await getSnapshot(); + const summary = getAllocationSummary(snapshot); + const rows = getAllocationRows(snapshot); + const accountRows = getAccountRows(snapshot); + + return ( +
+ + +
+ + +
+
+

Portfolio

+

Treasury portfolio

+

+ Track the treasury mix against the 25% stablecoin target. +

+
+
+ + + Synced {formatTimestamp(snapshot.syncedAt)} + +
+
+ + + +
+ + + + +
+ + + ({ + color: row.color, + name: row.asset.name, + percent: row.percent, + symbol: row.asset.symbol, + usdValue: row.usdValue, + }))} + /> + +
+
+

+ Tracked Holdings +

+

Asset details

+
+ +
+ + +
+
+ ); +} diff --git a/src/app/proposals/page.tsx b/src/app/proposals/page.tsx index 118b18c..6932531 100644 --- a/src/app/proposals/page.tsx +++ b/src/app/proposals/page.tsx @@ -2,6 +2,7 @@ import { ArrowLeft, ExternalLink } from "lucide-react"; import Link from "next/link"; import { AppHeader } from "@/components/app-header"; +import { DashboardBackLink } from "@/components/dashboard-back-link"; import { getAuthSession, serializeSession } from "@/lib/auth/session"; import { listProposalActivity, @@ -190,8 +191,10 @@ export default async function ProposalsPage() {
-
-
+
+ + +

Synced Proposal Links diff --git a/src/app/raids/page.tsx b/src/app/raids/page.tsx index d41e260..495de68 100644 --- a/src/app/raids/page.tsx +++ b/src/app/raids/page.tsx @@ -19,6 +19,7 @@ import { and, desc, eq, inArray } from "drizzle-orm"; import { AppHeader } from "@/components/app-header"; import { CopyableAddress } from "@/components/copyable-address"; +import { DashboardBackLink } from "@/components/dashboard-back-link"; import { Button } from "@/components/ui/button"; import { getDb } from "@/db"; import { ledgerEntries, quarters } from "@/db/schema"; @@ -1728,6 +1729,8 @@ export default async function RaidsPage({

+ + {canManageRaidAccounting ? : null}
- - - Dashboard - +
diff --git a/src/app/rips/page.tsx b/src/app/rips/page.tsx index 632396e..d3735a3 100644 --- a/src/app/rips/page.tsx +++ b/src/app/rips/page.tsx @@ -12,6 +12,7 @@ import type { ReactNode } from "react"; import { createRip, deleteRip, updateRip } from "@/app/rips/actions"; import { AppHeader } from "@/components/app-header"; +import { DashboardBackLink } from "@/components/dashboard-back-link"; import { Button } from "@/components/ui/button"; import { getAuthSession, serializeSession } from "@/lib/auth/session"; import { listRipsWithTotals, type RipView } from "@/lib/rips"; @@ -362,8 +363,10 @@ export default async function RipsPage({
-
-
+
+ + +

Member-created records @@ -378,32 +381,32 @@ export default async function RipsPage({

{query.created === "1" ? ( -
+
RIP added.
) : null} {query.updated === "1" ? ( -
+
RIP updated.
) : null} {query.deleted === "1" ? ( -
+
RIP deleted.
) : null} {query.error === "invalid" ? ( -
+
Add a title and a valid RIP URL.
) : null} {query.error === "linked" ? ( -
+
RIPs with linked ledger entries cannot be deleted.
) : null} {query.error === "missing" ? ( -
+
RIP not found.
) : null} diff --git a/src/components/app-header.tsx b/src/components/app-header.tsx index f69d083..271076d 100644 --- a/src/components/app-header.tsx +++ b/src/components/app-header.tsx @@ -37,6 +37,7 @@ const accountingLinks: NavLink[] = [ ]; const daoLinks: NavLink[] = [ + { href: "/portfolio", label: "Portfolio" }, { href: "/proposals", label: "Proposals" }, { href: "/membership", label: "Membership" }, ]; diff --git a/src/components/dashboard-back-link.tsx b/src/components/dashboard-back-link.tsx new file mode 100644 index 0000000..2df07e1 --- /dev/null +++ b/src/components/dashboard-back-link.tsx @@ -0,0 +1,14 @@ +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; + +export function DashboardBackLink() { + return ( + + + Dashboard + + ); +} diff --git a/src/components/portfolio/asset-allocation-chart.tsx b/src/components/portfolio/asset-allocation-chart.tsx new file mode 100644 index 0000000..358956a --- /dev/null +++ b/src/components/portfolio/asset-allocation-chart.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { useState } from "react"; +import { PieChart } from "lucide-react"; + +import { formatCurrency, formatPercent } from "@/lib/portfolio/formatters"; + +export type AssetAllocationChartRow = { + color: string; + name: string; + percent: number; + symbol: string; + usdValue: number; +}; + +type TooltipState = { + row: AssetAllocationChartRow; + x: number; + y: number; +}; + +function getPiePoint({ + angle, + radius, +}: { + angle: number; + radius: number; +}) { + const radians = ((angle - 90) * Math.PI) / 180; + + return { + x: 100 + radius * Math.cos(radians), + y: 100 + radius * Math.sin(radians), + }; +} + +function formatCoordinate(value: number) { + return Number(value.toFixed(6)).toString(); +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function getPieSlicePath({ + endAngle, + startAngle, +}: { + endAngle: number; + startAngle: number; +}) { + const start = getPiePoint({ angle: startAngle, radius: 92 }); + const end = getPiePoint({ angle: endAngle, radius: 92 }); + const largeArcFlag = endAngle - startAngle > 180 ? 1 : 0; + + return [ + "M 100 100", + `L ${formatCoordinate(start.x)} ${formatCoordinate(start.y)}`, + `A 92 92 0 ${largeArcFlag} 1 ${formatCoordinate( + end.x, + )} ${formatCoordinate(end.y)}`, + "Z", + ].join(" "); +} + +function getAssetTooltip(row: AssetAllocationChartRow) { + return `${row.symbol}: ${formatPercent(row.percent)}%, ${formatCurrency( + row.usdValue, + )}`; +} + +function getTooltipPosition({ + endAngle, + startAngle, +}: { + endAngle: number; + startAngle: number; +}) { + const point = getPiePoint({ + angle: startAngle + (endAngle - startAngle) / 2, + radius: 70, + }); + + return { + x: clamp(point.x, 34, 166), + y: clamp(point.y, 28, 172), + }; +} + +export function AssetAllocationChart({ + rows, +}: { + rows: AssetAllocationChartRow[]; +}) { + const [tooltip, setTooltip] = useState(null); + const visibleRows = rows.filter((row) => row.usdValue > 0); + const slices = visibleRows.reduce<{ + cursor: number; + items: { + endAngle: number; + row: AssetAllocationChartRow; + startAngle: number; + }[]; + }>( + (accumulator, row) => { + const startAngle = accumulator.cursor; + const endAngle = startAngle + row.percent * 3.6; + + return { + cursor: endAngle, + items: [...accumulator.items, { endAngle, row, startAngle }], + }; + }, + { cursor: 0, items: [] }, + ).items; + + return ( +
+
+
+

Asset Allocation

+

Treasury mix

+
+
+ +
+
+ + {visibleRows.length > 0 ? ( + slices.map(({ endAngle, row, startAngle }) => { + const nextTooltip = getTooltipPosition({ + endAngle, + startAngle, + }); + const sharedProps = { + "aria-label": getAssetTooltip(row), + className: + "outline-none transition-opacity hover:opacity-80 focus-visible:opacity-80", + fill: row.color, + onBlur: () => setTooltip(null), + onFocus: () => setTooltip({ row, ...nextTooltip }), + onPointerEnter: () => setTooltip({ row, ...nextTooltip }), + onPointerLeave: () => setTooltip(null), + stroke: "var(--card)", + strokeWidth: 2, + tabIndex: 0, + }; + + if (row.percent >= 99.99) { + return ( + + ); + } + + return ( + + ); + }) + ) : ( + + No portfolio allocation available + + )} + + + + {tooltip ? ( +
+

{tooltip.row.symbol}

+

{formatPercent(tooltip.row.percent)}%

+

{formatCurrency(tooltip.row.usdValue)}

+
+ ) : null} +
+ +
+ {visibleRows.length > 0 ? ( + visibleRows.map((row) => ( +
+
+ )) + ) : ( +

+ No allocation data yet. +

+ )} +
+
+
+ ); +} diff --git a/src/components/treasury/treasury-dashboard.tsx b/src/components/treasury/treasury-dashboard.tsx index 5825c3e..5dd5242 100644 --- a/src/components/treasury/treasury-dashboard.tsx +++ b/src/components/treasury/treasury-dashboard.tsx @@ -1,6 +1,6 @@ "use client"; -import { Coins, FileSpreadsheet, WalletCards } from "lucide-react"; +import { Coins, FileSpreadsheet, PieChart, WalletCards } from "lucide-react"; import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; @@ -250,6 +250,13 @@ export function TreasuryDashboard({ Updated just now

) : null} + + + View Portfolio +
{ expect(errors).toEqual([]); }); + + test("portfolio has no overflow at 350px", async ({ page }) => { + const errors = collectConsoleErrors(page); + + await page.setViewportSize({ height: 900, width: 350 }); + await setRole(page, "member"); + await page.goto("/portfolio"); + + await expect(page.getByRole("heading", { name: /treasury portfolio/i })).toBeVisible(); + await expectNoNextErrorOverlay(page); + await expectNoHorizontalOverflow(page); + + await page.screenshot({ + fullPage: true, + path: test.info().outputPath("edge-350-portfolio.png"), + }); + + expect(errors).toEqual([]); + }); }); test.describe("responsive modals", () => {