diff --git a/src/components/FundingProgress.tsx b/src/components/FundingProgress.tsx index 5c4f946..a5ac098 100644 --- a/src/components/FundingProgress.tsx +++ b/src/components/FundingProgress.tsx @@ -3,12 +3,26 @@ import { useEffect, useState } from "react"; import { formatAmount } from "@stellar-split/sdk"; +/** + * A milestone can be expressed either as a percentage (0–100) + * or as an absolute amount (bigint). When expressed as an amount, + * it is converted to a percentage relative to `total`. + */ +export type MilestoneInput = + | { type: "percent"; value: number; label?: string } + | { type: "amount"; value: bigint; label?: string }; + interface Props { funded: bigint; total: bigint; token?: string; /** compact hides the text label */ compact?: boolean; + /** + * Optional milestone markers. Each entry renders a tick on the bar. + * Accepts percentages or absolute amounts (converted to % internally). + */ + milestones?: MilestoneInput[]; } function getBarColor(pct: number): string { @@ -19,10 +33,17 @@ function getBarColor(pct: number): string { } /** - * FundingProgress — animated horizontal bar with colour transitions. + * FundingProgress — animated horizontal bar with colour transitions + * and optional milestone tick marks. * Animates from 0 → actual value on first render (600 ms). */ -export default function FundingProgress({ funded, total, token = "USDC", compact = false }: Props) { +export default function FundingProgress({ + funded, + total, + token = "USDC", + compact = false, + milestones, +}: Props) { const rawPct = total === 0n ? 0 : Number((funded * 100n) / total); const clamped = Math.min(100, Math.max(0, rawPct)); @@ -36,23 +57,74 @@ export default function FundingProgress({ funded, total, token = "USDC", compact const label = `${formatAmount(funded)} ${token} of ${formatAmount(total)} ${token} funded (${clamped}%)`; + /** Resolve milestones to normalised percentage values (0–100). */ + const resolvedMilestones: Array<{ pct: number; label?: string }> = + (milestones ?? []) + .map((m) => { + if (m.type === "percent") { + return { pct: Math.min(100, Math.max(0, m.value)), label: m.label }; + } + // amount — convert to percentage + const pct = + total === 0n ? 0 : Number((m.value * 100n) / total); + return { pct: Math.min(100, Math.max(0, pct)), label: m.label }; + }) + // Filter out 0% and 100% edge markers + .filter((m) => m.pct > 0 && m.pct < 100); + return (
{!compact && (

{label}

)} -
+ {/* + * The wrapper is `relative` with `overflow-visible` so tick labels + * can render above/below without being clipped. + */} +
+ role="progressbar" + aria-valuenow={clamped} + aria-valuemin={0} + aria-valuemax={100} + aria-label={label} + className="w-full bg-gray-700 rounded-full h-2 overflow-hidden" + > +
+
+ + {/* Milestone tick marks */} + {resolvedMilestones.map((m) => { + const reached = clamped >= m.pct; + return ( +
+ {/* Tick line */} + + ); + })}
); diff --git a/src/components/PayoutScheduler.tsx b/src/components/PayoutScheduler.tsx index c592add..807eb60 100644 --- a/src/components/PayoutScheduler.tsx +++ b/src/components/PayoutScheduler.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; interface Props { invoiceId: string; @@ -38,6 +38,46 @@ export default function PayoutScheduler({ invoiceId, vestingCliff, publicKey }: const cliffPassed = Math.floor(Date.now() / 1000) >= vestingCliff; + // Resolve the browser timezone abbreviation once (e.g. "WAT", "EST") + const tzAbbr = useMemo(() => { + try { + const ianaZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + // Extract the abbreviation from a formatted date string + const sample = new Intl.DateTimeFormat(undefined, { + timeZoneName: "short", + hour: "numeric", + timeZone: ianaZone, + }).formatToParts(new Date()); + return sample.find((p) => p.type === "timeZoneName")?.value ?? ianaZone; + } catch { + return "local"; + } + }, []); + + /** + * Formats a datetime-local string (YYYY-MM-DDTHH:MM) to show: + * - Local time with timezone abbreviation: "3:00 PM WAT" + * - UTC equivalent below: "2:00 PM UTC" + */ + const formatWithTz = (datetimeLocal: string) => { + if (!datetimeLocal) return { local: "", utc: "" }; + const d = new Date(datetimeLocal); + if (Number.isNaN(d.getTime())) return { local: datetimeLocal, utc: "" }; + + const localStr = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(d); + + const utcStr = new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "UTC", + }).format(d); + + return { local: `${localStr} ${tzAbbr}`, utc: `${utcStr} UTC` }; + }; + useEffect(() => { const existing = loadSchedules().find((s) => s.invoiceId === invoiceId); if (existing) { setDate(existing.date); setSaved(true); } @@ -77,9 +117,23 @@ export default function PayoutScheduler({ invoiceId, vestingCliff, publicKey }: {cliffPassed ? (

Vesting cliff passed — you can claim now.

) : ( -

- Cliff on {new Date(vestingCliff * 1000).toLocaleDateString()}. Schedule a reminder. -

+
+

+ Cliff on{" "} + {new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format( + new Date(vestingCliff * 1000) + )}{" "} + {tzAbbr}. Schedule a reminder. +

+

+ {new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "UTC", + }).format(new Date(vestingCliff * 1000))}{" "} + UTC +

+
)} {!saved ? ( @@ -101,9 +155,17 @@ export default function PayoutScheduler({ invoiceId, vestingCliff, publicKey }: ) : (
-

- Scheduled: {new Date(date).toLocaleString()} -

+
+

+ Scheduled:{" "} + {formatWithTz(date).local} +

+ {formatWithTz(date).utc && ( +

+ {formatWithTz(date).utc} +

+ )} +
+
{/* Desktop table */}