diff --git a/packages/tx-ui1/Dockerfile b/packages/tx-ui1/Dockerfile
new file mode 100644
index 0000000..89e0521
--- /dev/null
+++ b/packages/tx-ui1/Dockerfile
@@ -0,0 +1,8 @@
+FROM node:18-alpine
+WORKDIR /usr/src/app
+COPY package*.json ./
+RUN yarn install --production --frozen-lockfile
+COPY . .
+EXPOSE 8080
+ENV NODE_ENV=production
+CMD ["node", "server/server.js"]
\ No newline at end of file
diff --git a/packages/tx-ui1/package.json b/packages/tx-ui1/package.json
new file mode 100644
index 0000000..182f2a4
--- /dev/null
+++ b/packages/tx-ui1/package.json
@@ -0,0 +1,33 @@
+{
+ "scripts": {
+ "start": "node server/server.js"
+ },
+ "dependencies": {},
+ "type": "module",
+ "devDependencies": {
+ "@agoric/eslint-config": "^0.3.3",
+ "@agoric/eslint-plugin": "^0.2.3",
+ "@endo/eslint-config": "^0.5.1",
+ "@types/better-sqlite3": "^7.6.1",
+ "@types/node": "^14.14.7",
+ "@typescript-eslint/parser": "^4.21.0",
+ "eslint": "^7.24.0",
+ "eslint-config-airbnb-base": "^14.2.1",
+ "eslint-config-prettier": "^8.1.0",
+ "eslint-plugin-import": "^2.22.1",
+ "eslint-plugin-jsdoc": "^32.3.0",
+ "eslint-plugin-prettier": "^3.3.1",
+ "fuse.js": "^6.6.2",
+ "typescript": "^4.8.4"
+ },
+ "eslintConfig": {
+ "extends": [
+ "@agoric"
+ ]
+ },
+ "prettier": {
+ "trailingComma": "all",
+ "arrowParens": "avoid",
+ "singleQuote": true
+ }
+}
diff --git a/packages/tx-ui1/server/server.js b/packages/tx-ui1/server/server.js
new file mode 100644
index 0000000..c2ea57f
--- /dev/null
+++ b/packages/tx-ui1/server/server.js
@@ -0,0 +1,38 @@
+// @ts-check
+
+const pages = {
+ // package-relative
+ '/': { path: './ui/index.html', type: 'text/html' },
+ '/txUi.js': { path: './ui/txUi.js', type: 'application/javascript' },
+ '/report.css': { path: './ui/report.css', type: 'text/css' },
+ '/txs': { path: './data/split_detail.json', type: 'application/json' },
+};
+
+/**
+ * @param {object} io
+ * @param {typeof import('http')} io.http
+ * @param {typeof import('fs')} io.fs TODO: narrow to least authority
+ */
+const main = async ({ http, fs }) => {
+ const srv = http.createServer((request, response) => {
+ const { url } = request;
+ console.log('@@request for', url);
+ const page = pages[url];
+ if (!page) {
+ response.writeHead(404, { 'content-type': 'text/plain' });
+ response.end();
+ return;
+ }
+ fs.promises.readFile(page.path, 'utf8').then(body => {
+ response.writeHead(200, { 'content-type': page.type });
+ response.write(body);
+ response.end();
+ });
+ });
+ srv.listen(8080);
+};
+
+(async () =>
+ main({ http: await import('http'), fs: await import('fs') }))().catch(err =>
+ console.error(err),
+);
diff --git a/packages/tx-ui1/ui/index.html b/packages/tx-ui1/ui/index.html
new file mode 100644
index 0000000..a25c9bd
--- /dev/null
+++ b/packages/tx-ui1/ui/index.html
@@ -0,0 +1,23 @@
+
+ Transaction Search
+
+
+
+
+
+
+
diff --git a/packages/tx-ui1/ui/report.css b/packages/tx-ui1/ui/report.css
new file mode 100644
index 0000000..43a1e83
--- /dev/null
+++ b/packages/tx-ui1/ui/report.css
@@ -0,0 +1,49 @@
+.report {
+ border-collapse: collapse;
+ font-family: sans-serif;
+}
+th,
+td {
+ border: 1px solid black;
+ padding: 4px;
+}
+.report tr:nth-child(odd) {
+ background-color: #fff;
+}
+
+.report tr:nth-child(even) {
+ background-color: #eee;
+}
+
+.report td.amount {
+ text-align: right;
+}
+.report td.amount:before {
+ content: '$';
+}
+
+.report td.status {
+ text-align: center;
+}
+.report td.status span {
+ visibility: hidden;
+}
+.report td.uncleared:before {
+ content: '?';
+}
+.report td.cleared:before {
+ content: '✓';
+}
+.report td.recurring:before {
+ content: '♲';
+ color: green;
+}
+.report td.recurring_suggested:before {
+ content: '♲?';
+ color: grey;
+}
+
+.report td.uncleared {
+ font-style: italic;
+ color: red;
+}
diff --git a/packages/tx-ui1/ui/txUi.js b/packages/tx-ui1/ui/txUi.js
new file mode 100644
index 0000000..d05df94
--- /dev/null
+++ b/packages/tx-ui1/ui/txUi.js
@@ -0,0 +1,132 @@
+// @ts-check
+import {
+ html,
+ useEffect,
+ useState,
+} from 'https://unpkg.com/htm/preact/standalone.module.js';
+import Fuse from 'https://cdn.jsdelivr.net/npm/fuse.js@6.6.2/dist/fuse.esm.js';
+
+console.log('@@txUi module');
+
+/**
+ * @param {object} io
+ * @param { typeof document } io.document
+ * @param { typeof fetch } io.fetch
+ * @param { typeof localStorage } io.localStorage
+ * @param { typeof import('fuse.js').default } io.Fuse
+ */
+export const main = ({ fetch, document, localStorage, Fuse }) => {
+ console.log('@@main');
+ document.addEventListener('DOMContentLoaded', ev => {
+ console.log('content ready@@');
+
+ fetch('/txs').then(resp => {
+ if (resp.status !== 200) {
+ throw resp;
+ }
+ resp.text().then(txt => {
+ console.log('got text@@', txt.slice(0, 20));
+ const lines = txt.split('\n').filter(line => line > '');
+ console.log(lines[0]);
+ });
+ });
+ });
+};
+
+const opts = {
+ keys: [
+ 'date',
+ 'num',
+ 'description',
+ { name: 'memo', getFn: tx => tx.splits.map(s => s.memo).join(' ') },
+ { name: 'value', getFn: tx => tx.splits.map(s => `${s.value}`).join(' ') },
+ ],
+ threshold: 0.3,
+ minMatchCharLength: 3,
+};
+
+export const TxRegister = () => {
+ const [items, setItems] = useState([]);
+ const [fuse, setFuse] = useState(new Fuse([], opts));
+ const [pattern, setPattern] = useState('');
+ const [matches, setMatches] = useState([]);
+
+ console.warn('AMBIENT: window.localStorage');
+ navigator.storage.estimate().then(est => console.log(est));
+ const { localStorage } = window;
+
+ useEffect(() => {
+ const found = localStorage.getItem('/txs');
+ if (!found) return;
+ const lines = found.split('\n').filter(line => line > '');
+ setItems(lines);
+ });
+
+ const load = async () => {
+ const resp = await fetch('/txs');
+ if (resp.status !== 200) {
+ throw resp;
+ }
+ const txt = await resp.text();
+ console.log('got text@@', txt.length, txt.slice(0, 20));
+ const lines = txt.split('\n').filter(line => line > '');
+ console.log(lines[0]);
+ const txs = lines.map(line => JSON.parse(line));
+ setItems(txs);
+ setFuse(new Fuse(txs, opts));
+ localStorage.setItem('/txs', txt);
+ };
+
+ const search = () => {
+ console.log('@@search', pattern);
+ const hits = fuse.search(pattern);
+ setMatches(hits);
+ };
+
+ return html`
+
+
+
+ Date |
+ Num |
+ Description |
+ Code |
+ Account |
+
+ Amount |
+
+
+ ${matches.map(match => {
+ const tx = match.item;
+ return html`
+
+ ${tx.date} |
+ ${tx.num} |
+ ${tx.description} |
+
+ ${tx.splits.map(s => {
+ return html`
+
+ |
+ ${s.memo} |
+ ${s.code} |
+ ${s.account} |
+ ${s.value} |
+
+ `;
+ })}
+ `;
+ })}
+
+
+ `;
+};