From 39724f89c7b35afa4e54b01644ba4826f1b5f6ed Mon Sep 17 00:00:00 2001 From: unclehowell Date: Sat, 23 May 2026 21:47:31 -0300 Subject: [PATCH] docs(dcc): focus README on technical app details --- static/dcc/App.tsx | 390 +++++++++++++++---------------------------- static/dcc/README.md | 58 ++++++- 2 files changed, 192 insertions(+), 256 deletions(-) diff --git a/static/dcc/App.tsx b/static/dcc/App.tsx index 2e6536fc1e..db0ff9a5f9 100644 --- a/static/dcc/App.tsx +++ b/static/dcc/App.tsx @@ -1,282 +1,164 @@ -import React, { useState, useEffect, useMemo } from 'react'; -import { - PlusIcon, - ArrowPathIcon, - WalletIcon, - Cog6ToothIcon, - InformationCircleIcon, - ShieldCheckIcon, - XMarkIcon, - LockClosedIcon -} from '@heroicons/react/24/outline'; - -import { - Transaction, - TransactionType, - TransactionStatus, - UserSettings, - Payload, - LedgerStats -} from './types'; - -import { - getTransactions, - saveTransactions, - getSettings, - saveSettings, - generateWalletUid -} from './utils/storage'; +import React, { useEffect, useMemo, useState } from 'react'; + +type ContactDebt = { + id: string; + name: string; + email: string; + theyOweMeGbp: number; + iOweThemGbp: number; +}; -import { - getPayloadFromUrl, - clearUrlHash, - generateDccUrl -} from './utils/crypto'; +type Edge = { from: string; to: string; amount: number }; -import { fetchExchangeRates, ExchangeRates } from './services/exchangeRateService'; +const STORAGE_KEY = 'dcc-circle-cache-v1'; -// Components -import Dashboard from './components/Dashboard'; -import TransactionList from './components/TransactionList'; -import TransactionForm from './components/TransactionForm'; -import LinkProcessor from './components/LinkProcessor'; -import ImportTool from './components/ImportTool'; -import LandingPage from './components/LandingPage'; +const cleanMoney = (value: number) => { + if (Number.isNaN(value)) return 0; + return Math.max(0, Math.round(value * 100) / 100); +}; const App: React.FC = () => { - const [view, setView] = useState<'landing' | 'app'>('landing'); - const [transactions, setTransactions] = useState([]); - const [settings, setSettings] = useState(getSettings()); - const [activeTab, setActiveTab] = useState<'balances' | 'history' | 'settings' | 'import'>('balances'); - const [incomingPayload, setIncomingPayload] = useState(null); - const [showForm, setShowForm] = useState(false); - const [isProcessing, setIsProcessing] = useState(false); - const [rates, setRates] = useState({ USD: 1 }); - const [showPrivacyNotice, setShowPrivacyNotice] = useState(true); + const [rows, setRows] = useState([]); + const [form, setForm] = useState({ name: '', email: '', theyOweMeGbp: '', iOweThemGbp: '' }); + const [importText, setImportText] = useState(''); useEffect(() => { - setTransactions(getTransactions()); - - const payload = getPayloadFromUrl(); - if (payload) { - setIncomingPayload(payload); - setView('app'); // Auto-switch to app if link detected + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + try { + const parsed = JSON.parse(raw) as ContactDebt[]; + if (Array.isArray(parsed)) setRows(parsed); + } catch { + // ignore invalid cache } - - // Migration/Ensure UID exists - if (!settings.walletUid) { - const newSettings = { ...settings, walletUid: generateWalletUid() }; - setSettings(newSettings); - saveSettings(newSettings); - } - - // Initial rates fetch - fetchExchangeRates().then(setRates); }, []); useEffect(() => { - saveTransactions(transactions); - }, [transactions]); - - useEffect(() => { - saveSettings(settings); - }, [settings]); + localStorage.setItem(STORAGE_KEY, JSON.stringify(rows)); + }, [rows]); - const stats: LedgerStats = useMemo(() => { - const balances: Record = {}; + const exportedBase64 = useMemo(() => btoa(unescape(encodeURIComponent(JSON.stringify(rows)))), [rows]); - transactions.forEach(t => { - const cur = t.currency || 'USD'; - if (!balances[cur]) { - balances[cur] = { confirmedNet: 0, proposedInbound: 0, proposedOutbound: 0 }; - } - - if (t.status === TransactionStatus.CONFIRMED) { - if (t.type === TransactionType.UOM) { - balances[cur].confirmedNet += t.amount; - } else { - balances[cur].confirmedNet -= t.amount; - } - } else if (t.status === TransactionStatus.PENDING) { - if (t.type === TransactionType.UOM) { - balances[cur].proposedInbound += t.amount; - } else { - balances[cur].proposedOutbound += t.amount; + const edges = useMemo(() => { + const result: Edge[] = []; + for (const row of rows) { + if (row.iOweThemGbp > 0) result.push({ from: 'me', to: row.email.toLowerCase(), amount: row.iOweThemGbp }); + if (row.theyOweMeGbp > 0) result.push({ from: row.email.toLowerCase(), to: 'me', amount: row.theyOweMeGbp }); + } + return result; + }, [rows]); + + const circleSuggestions = useMemo(() => { + const suggestions: Array<{ a: string; b: string; c: string; cancellable: number }> = []; + const byFrom = new Map(); + edges.forEach(e => byFrom.set(e.from, [...(byFrom.get(e.from) || []), e])); + + const meOut = byFrom.get('me') || []; + for (const e1 of meOut) { + const b = e1.to; + const bOut = byFrom.get(b) || []; + for (const e2 of bOut) { + const c = e2.to; + if (c === 'me' || c === b) continue; + const cOut = byFrom.get(c) || []; + const e3 = cOut.find(x => x.to === 'me'); + if (!e3) continue; + const cancellable = cleanMoney(Math.min(e1.amount, e2.amount, e3.amount)); + if (cancellable > 0) { + suggestions.push({ a: b, b: c, c: 'me', cancellable }); } } - }); - - return { balances }; - }, [transactions]); - - const handleAddTransaction = (newTx: Transaction) => { - setTransactions(prev => [newTx, ...prev]); - setShowForm(false); - }; - - const handleUpdateStatus = (id: string, status: TransactionStatus, counterpartyUid?: string) => { - setTransactions(prev => prev.map(t => - t.id === id ? { - ...t, - status, - updatedAt: Date.now(), - counterpartyWalletUid: counterpartyUid || t.counterpartyWalletUid - } : t - )); - }; - - const handleDeleteTransaction = (id: string) => { - if (confirm("Delete this record permanently?")) { - setTransactions(prev => prev.filter(t => t.id !== id)); } - }; - const handleAcceptIncoming = async (payload: Payload) => { - setIsProcessing(true); + const unique = new Map(); + suggestions.forEach(s => { + const key = [s.a, s.b].sort().join('|'); + const prev = unique.get(key); + if (!prev || s.cancellable > prev.cancellable) unique.set(key, s); + }); + return [...unique.values()].sort((x, y) => y.cancellable - x.cancellable); + }, [edges]); - const newTx: Transaction = { + const addRow = () => { + if (!form.name.trim() || !form.email.trim()) return; + const newRow: ContactDebt = { id: crypto.randomUUID(), - type: payload.data.type === TransactionType.IOU ? TransactionType.UOM : TransactionType.IOU, - amount: payload.data.amount || 0, - currency: payload.data.currency || 'USD', - counterpartyEmail: payload.senderEmail, - counterpartyWalletUid: payload.senderWalletUid, - status: TransactionStatus.CONFIRMED, - createdAt: Date.now(), - updatedAt: Date.now(), - originDate: payload.data.originDate || new Date().toISOString().split('T')[0], - reference: payload.data.reference || '', - isOriginator: false, - relatedTransactionId: payload.data.id - }; - - setTransactions(prev => [newTx, ...prev]); - - const responsePayload: Payload = { - type: 'RESPONSE', - data: { ...newTx, status: TransactionStatus.CONFIRMED }, - senderEmail: settings.email, - senderWalletUid: settings.walletUid, - timestamp: Date.now() + name: form.name.trim(), + email: form.email.trim().toLowerCase(), + theyOweMeGbp: cleanMoney(Number(form.theyOweMeGbp || 0)), + iOweThemGbp: cleanMoney(Number(form.iOweThemGbp || 0)) }; + setRows(prev => [newRow, ...prev]); + setForm({ name: '', email: '', theyOweMeGbp: '', iOweThemGbp: '' }); + }; - const confirmUrl = generateDccUrl(responsePayload); - - // Clean email template using array + join - const emailBody = [ - "DCC Acceptance Confirmation", - "", - "Hi,", - "", - `I have accepted your ${newTx.type} request for ${newTx.amount} ${newTx.currency}`, - `Reference: ${newTx.reference || '(none)'}`, - `Original date: ${newTx.originDate}`, - "", - "My confirmation link (please open in browser to record the mutual agreement):", - confirmUrl, - "", - "Best regards,", - settings.email || 'DCC User' - ].join('\n'); - - const subject = `DCC Accepted: ${newTx.type} - Ref: ${newTx.reference || 'No ref'}`; - - const gmailUrl = `https://mail.google.com/mail/u/0/?view=cm&fs=1&to=${encodeURIComponent(payload.senderEmail)}&su=${encodeURIComponent(subject)}&body=${encodeURIComponent(emailBody)}&bcc=dcc@datro.xyz`; - - window.open(gmailUrl, '_blank'); - - setIncomingPayload(null); - clearUrlHash(); - setIsProcessing(false); + const importData = () => { + try { + const decoded = decodeURIComponent(escape(atob(importText.trim()))); + const parsed = JSON.parse(decoded) as ContactDebt[]; + if (!Array.isArray(parsed)) throw new Error('Invalid format'); + setRows(parsed.map(item => ({ ...item, id: item.id || crypto.randomUUID() }))); + setImportText(''); + alert('Imported successfully'); + } catch { + alert('Import failed. Please check the base64 string.'); + } }; return ( -
- {/* Landing Page */} - {view === 'landing' && ( - setView('app')} /> - )} - - {/* Main App View */} - {view === 'app' && ( - <> - {/* Header / Navigation */} -
-
-

DCC Ledger

- -
-
- - {/* Main Content */} -
- {activeTab === 'balances' && ( - setShowForm(true)} - /> - )} - - {activeTab === 'history' && ( - - )} - - {activeTab === 'settings' && ( -
-

Settings

- {/* Add your settings form here */} -
- )} - - {activeTab === 'import' && } -
- - {/* Footer */} -
- © 2025 Debt Cancellation Circle • Local-first • Peer-to-peer -
- - {/* Transaction Form Modal */} - {showForm && ( - setShowForm(false)} - onSubmit={handleAddTransaction} - userSettings={settings} - /> +
+
+

DCC — Debt Cancellation Circle

+

Track debts in GBP, cached in your browser. Export/import as base64 to share by email.

+ +
+ setForm({ ...form, name: e.target.value })} /> + setForm({ ...form, email: e.target.value })} /> + setForm({ ...form, theyOweMeGbp: e.target.value })} /> + setForm({ ...form, iOweThemGbp: e.target.value })} /> + +
+ +
+

People & Debts

+
+ + + + {rows.map(r => ( + + + + + ))} + +
NameEmailThey owe meI owe them
{r.name}{r.email}£{r.theyOweMeGbp.toFixed(2)}£{r.iOweThemGbp.toFixed(2)}
+
+
+ +
+

Debt Cancellation Circles (3-party)

+ {circleSuggestions.length === 0 ? ( +

No cancellable circles found yet.

+ ) : ( +
    + {circleSuggestions.map((c, i) => ( +
  • + You owe {c.a}, {c.a} owes {c.b}, and {c.b} owes you. Maximum cancellable amount: £{c.cancellable.toFixed(2)}. +
  • + ))} +
)} - - )} +
+ +
+

Export / Import

+