diff --git a/client/src/app.js b/client/src/app.js
index b27463aa..66ea08e7 100644
--- a/client/src/app.js
+++ b/client/src/app.js
@@ -224,6 +224,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
// Single TX
, tx$ = reply('tx').merge(goTx$.mapTo(null)).startWith(null)
+ , txBlock$ = reply('tx-block').merge(goTx$.mapTo(null)).startWith(null)
// Currently collapsed tx/block ("details")
, openTx$ = togTx$.startWith(null).scan((prev, txid) => prev == txid ? null : txid)
@@ -348,7 +349,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
, newBlockEntries$, newTxEntries$
, goBlock$, block$, blockStatus$, blockTxs$, nextBlockTxs$, prevBlockTxs$, openBlock$
, mempool$, mempoolRecent$, feeEst$, bitcoinMarketChart$
- , tx$, txAnalysis$, openTx$
+ , tx$, txBlock$, txAnalysis$, openTx$
, goAddr$, addr$, addrTxs$, addrQR$
, assetMap$, assetList$, goAssetList$, goAsset$, asset$, assetTxs$, unblinded$
, isReady$, loading$, page$, view$, title$
@@ -376,6 +377,10 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
// fetch single tx (including confirmation status)
, goTx$.map(txid => ({ category: 'tx', method: 'GET', path: `/tx/${txid}` }))
+ // fetch the block containing a confirmed tx
+ , tx$.filter(tx => tx && tx.status && tx.status.confirmed && tx.status.block_hash)
+ .map(tx => ({ category: 'tx-block', method: 'GET', path: `/block/${tx.status.block_hash}` }))
+
// fetch address and its txs
, goAddr$.flatMap(d => [{ category: 'address', method: 'GET', path: `/address/${d.addr}` }
, d.last_txids.length
@@ -499,7 +504,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
dbg({ goBlocks$, goBlock$, goTx$, goAddr$, togTx$, page$, lang$, vdom$, moreBlocks$
, openTx$, openBlock$, updateQuery$
- , state$, view$, block$, blockTxs$, blocks$, tx$, txAnalysis$, spends$, addr$
+ , state$, view$, block$, blockTxs$, blocks$, tx$, txBlock$, txAnalysis$, spends$, addr$
, tipHeight$, error$, loading$
, goSearch$, searchResult$, copy$, store$, navto$, scanning$, scan$
, assetMap$, goAssetList$, assetList$
diff --git a/client/src/components/block-grid.js b/client/src/components/block-grid.js
new file mode 100644
index 00000000..dc6ee8b9
--- /dev/null
+++ b/client/src/components/block-grid.js
@@ -0,0 +1,88 @@
+import { maxBlockWeight } from "../const";
+
+const GRID_LENGTH = 15;
+
+const drawBlockGrid = (canvas, blockWeight) => {
+ if (
+ typeof HTMLCanvasElement === "undefined" ||
+ !(canvas instanceof HTMLCanvasElement)
+ ) {
+ return;
+ }
+
+ const ctx = canvas.getContext("2d");
+ const rect = canvas.getBoundingClientRect();
+ const dpr = window.devicePixelRatio || 1;
+ const width = rect.width;
+ const height = rect.height;
+ const gap = 2;
+ const cellWidth = (width - gap * (GRID_LENGTH - 1)) / GRID_LENGTH;
+ const cellHeight = (height - gap * (GRID_LENGTH - 1)) / GRID_LENGTH;
+ const fillRatio = Number.isFinite(blockWeight)
+ ? Math.min(Math.max(blockWeight / maxBlockWeight, 0), 1)
+ : 0;
+ const filledCells = Math.round(fillRatio * GRID_LENGTH * GRID_LENGTH);
+ const styles = window.getComputedStyle(canvas);
+ const emptyColor =
+ styles.getPropertyValue("--block-grid-empty-color").trim() || "#141414";
+ const filledColor = styles
+ .getPropertyValue("--block-grid-filled-color")
+ .trim() || "#FA8A00";
+
+ canvas.width = Math.max(1, Math.round(width * dpr));
+ canvas.height = Math.max(1, Math.round(height * dpr));
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, width, height);
+
+ for (let index = 0; index < GRID_LENGTH * GRID_LENGTH; index += 1) {
+ const row = Math.floor(index / GRID_LENGTH);
+ const column = index % GRID_LENGTH;
+
+ ctx.fillStyle = index < filledCells ? filledColor : emptyColor;
+ ctx.fillRect(
+ column * (cellWidth + gap),
+ row * (cellHeight + gap),
+ cellWidth,
+ cellHeight,
+ );
+ }
+};
+
+const drawGrid = (vnode, blockWeight) => {
+ const draw = () => drawBlockGrid(vnode.elm, blockWeight);
+
+ if (typeof window !== "undefined" && window.requestAnimationFrame) {
+ window.requestAnimationFrame(draw);
+ return;
+ }
+
+ draw();
+};
+
+export const BlockGrid = ({ blockWeight } = {}) => {
+ const hasBlockWeight = Number.isFinite(blockWeight);
+ const percentage = hasBlockWeight
+ ? Math.min(
+ Math.max(
+ Math.round((blockWeight / maxBlockWeight) * 10_000) / 100,
+ 0,
+ ),
+ 100,
+ )
+ : 0;
+
+ return (
+
+
+
+ );
+};
diff --git a/client/src/components/elapsed-time.js b/client/src/components/elapsed-time.js
index 7b16df5c..407fca9e 100644
--- a/client/src/components/elapsed-time.js
+++ b/client/src/components/elapsed-time.js
@@ -1,4 +1,7 @@
const UPDATE_INTERVAL_MS = 60 * 1000;
+const MINUTES_PER_DAY = 24 * 60;
+const MINUTES_PER_YEAR = 365 * MINUTES_PER_DAY;
+const MINUTES_PER_MONTH = MINUTES_PER_YEAR / 12;
const formatElapsedTime = (timestamp) => {
const fromDate =
@@ -7,10 +10,18 @@ const formatElapsedTime = (timestamp) => {
0,
Math.floor((new Date() - fromDate) / UPDATE_INTERVAL_MS),
);
+ const years = Math.floor(diffMinutes / MINUTES_PER_YEAR);
+ const minutesAfterYears = diffMinutes % MINUTES_PER_YEAR;
+ const months = Math.floor(minutesAfterYears / MINUTES_PER_MONTH);
+ const minutesAfterMonths = Math.floor(
+ minutesAfterYears - months * MINUTES_PER_MONTH,
+ );
const units = [
- ["d", Math.floor(diffMinutes / (24 * 60))],
- ["h", Math.floor((diffMinutes % (24 * 60)) / 60)],
- ["m", diffMinutes % 60],
+ ["y", years],
+ ["mo", months],
+ ["d", Math.floor(minutesAfterMonths / MINUTES_PER_DAY)],
+ ["h", Math.floor((minutesAfterMonths % MINUTES_PER_DAY) / 60)],
+ ["m", minutesAfterMonths % 60],
];
const parts = units
.filter(([_, value]) => value > 0)
diff --git a/client/src/components/icons.js b/client/src/components/icons.js
index e57a616a..d5486491 100644
--- a/client/src/components/icons.js
+++ b/client/src/components/icons.js
@@ -16,12 +16,28 @@ export const ArrowsInSimpleIcon = ({ className } = {}) =>
+export const PrivacyAnalysisArrowIcon = ({ className } = {}) =>
+
+
export const CopyIcon = ({ className } = {}) =>
+export const PlusIcon = ({ className } = {}) =>
+
+
+export const MinusIcon = ({ className } = {}) =>
+
+
export const ClockIcon = ({ className } = {}) =>