diff --git a/src/__tests__/AdvancedPaymentMethodPicker.test.tsx b/src/__tests__/AdvancedPaymentMethodPicker.test.tsx new file mode 100644 index 00000000..05d947ae --- /dev/null +++ b/src/__tests__/AdvancedPaymentMethodPicker.test.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import AdvancedPaymentMethodPicker, { DEFAULT_PAYMENT_METHODS } from "@/components/AdvancedPaymentMethodPicker"; + +describe("AdvancedPaymentMethodPicker", () => { + it("selects a method on click", () => { + const onChange = vi.fn(); + render(); + expect(screen.getByRole("radio", { name: /Freighter wallet/ })).toHaveAttribute("aria-checked", "true"); + fireEvent.click(screen.getByRole("radio", { name: /Batch payment/ })); + expect(onChange).toHaveBeenCalledWith("batch"); + }); + + it("skips disabled methods with arrow keys", () => { + const onChange = vi.fn(); + const methods = DEFAULT_PAYMENT_METHODS.map((m) => + m.id === "batch" ? { ...m, disabled: true, disabledReason: "Unavailable" } : m + ); + render(); + expect(screen.getByText("Unavailable")).toBeInTheDocument(); + fireEvent.keyDown(screen.getByRole("radio", { name: /Freighter wallet/ }), { key: "ArrowDown" }); + expect(onChange).toHaveBeenCalledWith("crossChain"); + }); +}); diff --git a/src/__tests__/InvoiceCalendar.test.tsx b/src/__tests__/InvoiceCalendar.test.tsx new file mode 100644 index 00000000..794fbd3b --- /dev/null +++ b/src/__tests__/InvoiceCalendar.test.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import InvoiceCalendar from "@/components/InvoiceCalendar"; +import { buildMonthGrid, groupByDeadline } from "@/lib/invoiceCalendar"; + +const ts = (y: number, m: number, d: number) => Math.floor(new Date(y, m, d, 12).getTime() / 1000); +const invoices = [ + { id: "1", deadline: ts(2026, 8, 15), status: "Pending" }, + { id: "2", deadline: ts(2026, 8, 15), status: "Released" }, + { id: "3", deadline: 0, status: "Pending" }, +]; + +describe("invoiceCalendar lib", () => { + it("groups by deadline day and skips missing deadlines", () => { + const grouped = groupByDeadline(invoices); + expect(grouped.get("2026-09-15")?.map((i) => i.id)).toEqual(["1", "2"]); + expect(grouped.size).toBe(1); + }); + + it("builds a 42-day grid starting on Sunday", () => { + const grid = buildMonthGrid(2026, 8, invoices); + expect(grid).toHaveLength(42); + expect(grid[0].date.getDay()).toBe(0); + expect(grid.find((d) => d.key === "2026-09-15")?.invoices).toHaveLength(2); + }); +}); + +describe("InvoiceCalendar", () => { + it("shows invoices for a selected day and navigates months", () => { + render(); + expect(screen.getByText(/September 2026/)).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText(/Sep 15 2026, 2 invoices due/)); + expect(screen.getByTestId("calendar-day-detail")).toHaveTextContent("Invoice #1"); + fireEvent.click(screen.getByLabelText("Next month")); + expect(screen.getByText(/October 2026/)).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/earningsForecast.test.ts b/src/__tests__/earningsForecast.test.ts new file mode 100644 index 00000000..9a10100b --- /dev/null +++ b/src/__tests__/earningsForecast.test.ts @@ -0,0 +1,26 @@ +import { forecastEarnings, totalForecast } from "@/lib/earningsForecast"; + +describe("forecastEarnings", () => { + it("projects a linear trend", () => { + const history = [10, 20, 30].map((v, i) => ({ label: `m${i}`, value: v })); + const points = forecastEarnings(history, 2, ["a", "b"]); + expect(points).toHaveLength(5); + expect(points[2].forecast).toBe(30); + expect(points[3]).toMatchObject({ label: "a", forecast: 40, low: 40, high: 40 }); + expect(points[4].forecast).toBe(50); + expect(totalForecast(points)).toBe(90); + }); + + it("never projects negative earnings and widens the band", () => { + const history = [10, 30, 20, 40].map((v, i) => ({ label: `m${i}`, value: v })); + const points = forecastEarnings(history, 6); + const future = points.filter((p) => p.actual === undefined); + future.forEach((p) => expect(p.low!).toBeGreaterThanOrEqual(0)); + expect(future[5].high! - future[5].forecast!).toBeGreaterThan(future[0].high! - future[0].forecast!); + }); + + it("returns history unchanged when empty or no horizon", () => { + expect(forecastEarnings([], 3)).toEqual([]); + expect(forecastEarnings([{ label: "x", value: 1 }], 0)).toEqual([{ label: "x", actual: 1 }]); + }); +}); diff --git a/src/__tests__/sharePermissions.test.ts b/src/__tests__/sharePermissions.test.ts new file mode 100644 index 00000000..c249c656 --- /dev/null +++ b/src/__tests__/sharePermissions.test.ts @@ -0,0 +1,33 @@ +import { can, revokeGrant, upsertGrant, type ShareGrant } from "@/lib/sharePermissions"; + +const OWNER = "GOWNER"; +const EDITOR = "GEDITOR"; +const base: ShareGrant[] = [ + { address: OWNER, role: "owner", grantedAt: 0 }, + { address: EDITOR, role: "editor", grantedAt: 0 }, +]; + +describe("sharePermissions", () => { + it("resolves role permissions with allow/deny overrides", () => { + expect(can(base, EDITOR, "edit")).toBe(true); + expect(can(base, EDITOR, "cancel")).toBe(false); + const g = upsertGrant(base, OWNER, { address: EDITOR, role: "editor", allow: ["cancel"], deny: ["edit"] }); + expect(can(g, EDITOR, "cancel")).toBe(true); + expect(can(g, EDITOR, "edit")).toBe(false); + expect(can(g, "GUNKNOWN", "view")).toBe(false); + }); + + it("revokes access after expiry", () => { + const g = upsertGrant(base, OWNER, { address: "GV", role: "viewer", expiresAt: 1000 }, 0); + expect(can(g, "GV", "view", 999)).toBe(true); + expect(can(g, "GV", "view", 1000)).toBe(false); + }); + + it("enforces who can manage access", () => { + expect(() => upsertGrant(base, EDITOR, { address: "GX", role: "viewer" })).toThrow(); + expect(() => revokeGrant(base, OWNER, OWNER)).toThrow(); + const admin = upsertGrant(base, OWNER, { address: "GA", role: "editor", allow: ["manageAccess"] }); + expect(() => upsertGrant(admin, "GA", { address: "GX", role: "owner" })).toThrow(/higher/); + expect(revokeGrant(base, OWNER, EDITOR)).toHaveLength(1); + }); +}); diff --git a/src/app/dashboard/calendar/page.tsx b/src/app/dashboard/calendar/page.tsx new file mode 100644 index 00000000..fbbbd615 --- /dev/null +++ b/src/app/dashboard/calendar/page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { Invoice } from "@stellar-split/sdk"; +import { splitClient } from "@/lib/stellar"; +import { getFreighterPublicKey } from "@/lib/freighter"; +import InvoiceCalendar from "@/components/InvoiceCalendar"; + +export default function InvoiceCalendarPage() { + const [invoices, setInvoices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function load() { + try { + const pk = await getFreighterPublicKey().catch(() => null); + if (!pk) { + setError("Connect your wallet to view your invoice calendar."); + return; + } + // as any: getInvoicesByCreator is not yet declared in the published @stellar-split/sdk types + const result: Invoice[] = (await (splitClient as any).getInvoicesByCreator(pk)) ?? []; + setInvoices(result); + } catch (err) { + setError(String(err)); + } finally { + setLoading(false); + } + } + load(); + }, []); + + return ( +
+

Invoice Calendar

+

Your invoices laid out by deadline.

+ {loading &&
} + {error &&

{error}

} + {!loading && !error && } +
+ ); +} diff --git a/src/app/revenue/page.tsx b/src/app/revenue/page.tsx index 933ce37b..6d126ea1 100644 --- a/src/app/revenue/page.tsx +++ b/src/app/revenue/page.tsx @@ -14,6 +14,7 @@ import { ResponsiveContainer, } from "recharts"; import type { Invoice } from "@stellar-split/sdk"; +import EarningsForecastChart from "@/components/EarningsForecastChart"; /** Convert a bigint USDC amount (7 decimals) to a JS number for charting. */ function toUsdc(amount: bigint): number { @@ -191,6 +192,10 @@ export default function RevenuePage() { + ({ label: d.month, value: d.usdc }))} + /> + {/* Invoice breakdown table */}

Invoice Breakdown

diff --git a/src/app/settings/sharing/page.tsx b/src/app/settings/sharing/page.tsx new file mode 100644 index 00000000..663cc6d0 --- /dev/null +++ b/src/app/settings/sharing/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getFreighterPublicKey } from "@/lib/freighter"; +import SharePermissionsPanel from "@/components/SharePermissionsPanel"; + +export default function SharingSettingsPage() { + const [address, setAddress] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + getFreighterPublicKey() + .then((pk) => (pk ? setAddress(pk) : setError("Connect your wallet to manage shared access."))) + .catch(() => setError("Connect your wallet to manage shared access.")); + }, []); + + return ( +
+

Sharing & permissions

+

+ Control who can view, comment on, edit, or manage your invoices. +

+ {error &&

{error}

} + {address && } +
+ ); +} diff --git a/src/components/AdvancedPaymentMethodPicker.tsx b/src/components/AdvancedPaymentMethodPicker.tsx new file mode 100644 index 00000000..5a4e256b --- /dev/null +++ b/src/components/AdvancedPaymentMethodPicker.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useRef } from "react"; + +export type PaymentMethodId = "freighter" | "batch" | "crossChain" | "confidential"; + +export interface PaymentMethodOption { + id: PaymentMethodId; + label: string; + description: string; + /** Estimated network fee, e.g. "~0.00001 XLM". */ + fee: string; + /** Estimated settlement time, e.g. "~5s". */ + eta: string; + recommended?: boolean; + disabled?: boolean; + disabledReason?: string; +} + +export const DEFAULT_PAYMENT_METHODS: PaymentMethodOption[] = [ + { + id: "freighter", + label: "Freighter wallet", + description: "Pay directly in USDC from your connected Stellar wallet.", + fee: "~0.00001 XLM", + eta: "~5s", + recommended: true, + }, + { + id: "batch", + label: "Batch payment", + description: "Queue this payment with others and settle them in one transaction.", + fee: "Shared", + eta: "On submit", + }, + { + id: "crossChain", + label: "Cross-chain bridge", + description: "Pay from another chain; funds are bridged to Stellar USDC.", + fee: "Bridge fee", + eta: "~2–10 min", + }, + { + id: "confidential", + label: "Confidential payment", + description: "Hide the contributed amount from other participants.", + fee: "~0.0001 XLM", + eta: "~10s", + }, +]; + +interface Props { + value: PaymentMethodId | null; + onChange: (id: PaymentMethodId) => void; + methods?: PaymentMethodOption[]; +} + +export default function AdvancedPaymentMethodPicker({ + value, + onChange, + methods = DEFAULT_PAYMENT_METHODS, +}: Props) { + const refs = useRef<(HTMLButtonElement | null)[]>([]); + const enabled = methods.map((m, i) => (m.disabled ? -1 : i)).filter((i) => i >= 0); + const focusIndex = Math.max( + methods.findIndex((m) => m.id === value && !m.disabled), + enabled[0] ?? 0 + ); + + const onKeyDown = (e: React.KeyboardEvent, index: number) => { + const dir = + e.key === "ArrowDown" || e.key === "ArrowRight" ? 1 : e.key === "ArrowUp" || e.key === "ArrowLeft" ? -1 : 0; + if (!dir || enabled.length === 0) return; + e.preventDefault(); + const pos = enabled.indexOf(index); + const next = enabled[(pos + dir + enabled.length) % enabled.length]; + refs.current[next]?.focus(); + onChange(methods[next].id); + }; + + return ( +
+ {methods.map((m, i) => { + const selected = value === m.id; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/EarningsForecastChart.tsx b/src/components/EarningsForecastChart.tsx new file mode 100644 index 00000000..49f723f3 --- /dev/null +++ b/src/components/EarningsForecastChart.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + ComposedChart, + Area, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { forecastEarnings, totalForecast, type EarningsPoint } from "@/lib/earningsForecast"; + +interface Props { + history: EarningsPoint[]; +} + +const HORIZONS = [3, 6, 12] as const; + +function nextMonthLabels(count: number): string[] { + const now = new Date(); + return Array.from({ length: count }, (_, i) => + new Date(now.getFullYear(), now.getMonth() + i + 1, 1).toLocaleDateString(undefined, { + month: "short", + year: "2-digit", + }) + ); +} + +export default function EarningsForecastChart({ history }: Props) { + const [horizon, setHorizon] = useState(3); + + const data = useMemo( + () => + forecastEarnings(history, horizon, nextMonthLabels(horizon)).map((p) => ({ + ...p, + band: p.low !== undefined && p.high !== undefined ? [p.low, p.high] : undefined, + })), + [history, horizon] + ); + const total = useMemo(() => totalForecast(data), [data]); + const hasData = history.some((h) => h.value > 0); + + return ( +
+
+

+ Earnings Forecast +

+
+ {HORIZONS.map((h) => ( + + ))} +
+
+ + {!hasData ? ( +

Not enough earnings history to forecast yet.

+ ) : ( + <> +

+ Projected next {horizon} months:{" "} + + {total} USDC + +

+
+ + + + + + + + + + + +
+

+ Linear trend over your last {history.length} months; shaded area shows the expected range. +

+ + )} +
+ ); +} diff --git a/src/components/InvoiceCalendar.tsx b/src/components/InvoiceCalendar.tsx new file mode 100644 index 00000000..93c39d52 --- /dev/null +++ b/src/components/InvoiceCalendar.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { buildMonthGrid, dateKey, type CalendarInvoice } from "@/lib/invoiceCalendar"; + +interface Props { + invoices: CalendarInvoice[]; + /** Initial month to display; defaults to the current month. */ + initialDate?: Date; +} + +const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +const STATUS_COLORS: Record = { + Pending: "bg-amber-500/20 text-amber-300", + Funded: "bg-blue-500/20 text-blue-300", + Released: "bg-emerald-500/20 text-emerald-300", + Cancelled: "bg-gray-500/20 text-gray-400", + Expired: "bg-red-500/20 text-red-300", +}; + +export default function InvoiceCalendar({ invoices, initialDate }: Props) { + const [cursor, setCursor] = useState(() => { + const d = initialDate ?? new Date(); + return { year: d.getFullYear(), month: d.getMonth() }; + }); + const [selected, setSelected] = useState(null); + + const days = useMemo( + () => buildMonthGrid(cursor.year, cursor.month, invoices), + [cursor, invoices] + ); + const todayKey = dateKey(new Date()); + const selectedDay = days.find((d) => d.key === selected); + const title = new Date(cursor.year, cursor.month, 1).toLocaleDateString(undefined, { + month: "long", + year: "numeric", + }); + + const shift = (delta: number) => { + setSelected(null); + setCursor(({ year, month }) => { + const d = new Date(year, month + delta, 1); + return { year: d.getFullYear(), month: d.getMonth() }; + }); + }; + + return ( +
+
+ +

{title}

+ +
+ +
+ {WEEKDAYS.map((d) => ( +
+ {d[0]} + {d} +
+ ))} + {days.map((day) => ( + + ))} +
+ + {selectedDay && ( +
+

Due {selectedDay.date.toDateString()}

+ {selectedDay.invoices.length === 0 ? ( +

No invoices due on this day.

+ ) : ( +
    + {selectedDay.invoices.map((inv) => ( +
  • + + Invoice #{inv.id} + + + {inv.status} + +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/SharePermissionsPanel.tsx b/src/components/SharePermissionsPanel.tsx new file mode 100644 index 00000000..61130978 --- /dev/null +++ b/src/components/SharePermissionsPanel.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + ROLE_PERMISSIONS, + ROLE_RANK, + effectivePermissions, + isExpired, + loadGrants, + revokeGrant, + saveGrants, + upsertGrant, + type SharePermission, + type ShareGrant, + type ShareRole, +} from "@/lib/sharePermissions"; + +interface Props { + resourceId: string; + /** Address of the current user. Seeded as owner when no grants exist yet. */ + currentAddress: string; +} + +const ROLES: ShareRole[] = ["viewer", "commenter", "editor", "owner"]; +const PERMISSIONS: SharePermission[] = ["view", "comment", "edit", "cancel", "manageAccess"]; +const STELLAR_ADDRESS = /^G[A-Z2-7]{55}$/; + +function short(addr: string) { + return `${addr.slice(0, 6)}…${addr.slice(-4)}`; +} + +export default function SharePermissionsPanel({ resourceId, currentAddress }: Props) { + const [grants, setGrants] = useState([]); + const [address, setAddress] = useState(""); + const [role, setRole] = useState("viewer"); + const [expiryDays, setExpiryDays] = useState(""); + const [error, setError] = useState(null); + + useEffect(() => { + const stored = loadGrants(resourceId); + setGrants( + stored.length > 0 + ? stored + : [{ address: currentAddress, role: "owner", grantedAt: Date.now() }] + ); + }, [resourceId, currentAddress]); + + const apply = (fn: () => ShareGrant[]) => { + try { + const next = fn(); + setGrants(next); + saveGrants(resourceId, next); + setError(null); + return true; + } catch (err) { + setError((err as Error).message); + return false; + } + }; + + const actorRole = grants.find((g) => g.address === currentAddress)?.role; + const canManage = grants.some( + (g) => g.address === currentAddress && effectivePermissions(g).has("manageAccess") + ); + const assignable = ROLES.filter((r) => actorRole && ROLE_RANK[r] <= ROLE_RANK[actorRole]); + + const onAdd = (e: React.FormEvent) => { + e.preventDefault(); + const addr = address.trim(); + if (!STELLAR_ADDRESS.test(addr)) { + setError("Enter a valid Stellar address."); + return; + } + const days = Number(expiryDays); + const expiresAt = expiryDays && days > 0 ? Date.now() + days * 86_400_000 : undefined; + if (apply(() => upsertGrant(grants, currentAddress, { address: addr, role, expiresAt }))) { + setAddress(""); + setExpiryDays(""); + } + }; + + const toggleOverride = (grant: ShareGrant, perm: SharePermission) => { + const inRole = ROLE_PERMISSIONS[grant.role].includes(perm); + const has = effectivePermissions(grant).has(perm); + const allow = new Set(grant.allow ?? []); + const deny = new Set(grant.deny ?? []); + if (has) { + allow.delete(perm); + if (inRole) deny.add(perm); + } else { + deny.delete(perm); + if (!inRole) allow.add(perm); + } + apply(() => + upsertGrant(grants, currentAddress, { ...grant, allow: [...allow], deny: [...deny] }) + ); + }; + + return ( +
+

+ Shared access +

+ + {error && ( +

+ {error} +

+ )} + + {canManage && ( +
+ + setAddress(e.target.value)} + placeholder="G… collaborator address" + className="flex-1 min-w-0 rounded-md bg-gray-800 px-3 py-2 text-sm" + /> + + + + setExpiryDays(e.target.value)} + placeholder="Expires (days)" + className="sm:w-32 rounded-md bg-gray-800 px-3 py-2 text-sm" + /> + +
+ )} + +
    + {grants.map((grant) => { + const perms = effectivePermissions(grant); + const locked = !canManage || grant.role === "owner"; + return ( +
  • +
    + + {short(grant.address)} + {grant.address === currentAddress && (you)} + +
    + {isExpired(grant) ? ( + Expired + ) : grant.expiresAt ? ( + + Until {new Date(grant.expiresAt).toLocaleDateString()} + + ) : null} + {locked ? ( + {grant.role} + ) : ( + + )} + {!locked && ( + + )} +
    +
    +
    + {PERMISSIONS.map((p) => ( + + ))} +
    +
  • + ); + })} +
+
+ ); +} diff --git a/src/lib/earningsForecast.ts b/src/lib/earningsForecast.ts new file mode 100644 index 00000000..d4040ccb --- /dev/null +++ b/src/lib/earningsForecast.ts @@ -0,0 +1,73 @@ +export interface EarningsPoint { + /** Display label for the period (e.g. "Jan 25"). */ + label: string; + /** Earnings for the period in USDC. */ + value: number; +} + +export interface ForecastPoint { + label: string; + /** Historical value; undefined for projected periods. */ + actual?: number; + /** Projected value; also set on the last actual point so the lines connect. */ + forecast?: number; + /** Lower / upper bound of the confidence band for projected periods. */ + low?: number; + high?: number; +} + +/** + * Forecast future earnings with an ordinary least-squares linear trend over + * the historical series. The confidence band is ±1 residual standard deviation, + * widening with distance from the last actual point. Values never go below 0. + */ +export function forecastEarnings( + history: EarningsPoint[], + periods: number, + futureLabels: string[] = [] +): ForecastPoint[] { + const points: ForecastPoint[] = history.map((h) => ({ label: h.label, actual: h.value })); + const n = history.length; + if (n === 0 || periods <= 0) return points; + + const xs = history.map((_, i) => i); + const ys = history.map((h) => h.value); + const meanX = xs.reduce((a, b) => a + b, 0) / n; + const meanY = ys.reduce((a, b) => a + b, 0) / n; + let num = 0; + let den = 0; + for (let i = 0; i < n; i++) { + num += (xs[i] - meanX) * (ys[i] - meanY); + den += (xs[i] - meanX) ** 2; + } + const slope = den === 0 ? 0 : num / den; + const intercept = meanY - slope * meanX; + + const residualVar = + n > 2 ? ys.reduce((s, y, i) => s + (y - (intercept + slope * i)) ** 2, 0) / (n - 2) : 0; + const sd = Math.sqrt(residualVar); + + const round = (v: number) => Math.round(Math.max(0, v) * 100) / 100; + points[n - 1].forecast = points[n - 1].actual; + + for (let k = 1; k <= periods; k++) { + const x = n - 1 + k; + const value = intercept + slope * x; + const spread = sd * Math.sqrt(k); + points.push({ + label: futureLabels[k - 1] ?? `+${k}`, + forecast: round(value), + low: round(value - spread), + high: round(value + spread), + }); + } + return points; +} + +/** Sum of projected values across the forecast horizon. */ +export function totalForecast(points: ForecastPoint[]): number { + const total = points + .filter((p) => p.actual === undefined) + .reduce((s, p) => s + (p.forecast ?? 0), 0); + return Math.round(total * 100) / 100; +} diff --git a/src/lib/invoiceCalendar.ts b/src/lib/invoiceCalendar.ts new file mode 100644 index 00000000..153dbc70 --- /dev/null +++ b/src/lib/invoiceCalendar.ts @@ -0,0 +1,47 @@ +export interface CalendarInvoice { + id: string; + /** Unix timestamp deadline in seconds; 0 means no deadline. */ + deadline: number; + status: string; +} + +export interface CalendarDay { + /** Local date key in YYYY-MM-DD form. */ + key: string; + date: Date; + inMonth: boolean; + invoices: T[]; +} + +export function dateKey(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +/** Group invoices by the local calendar day of their deadline, skipping invoices without one. */ +export function groupByDeadline(invoices: T[]): Map { + const map = new Map(); + for (const inv of invoices) { + if (!inv.deadline) continue; + const key = dateKey(new Date(inv.deadline * 1000)); + const list = map.get(key); + if (list) list.push(inv); + else map.set(key, [inv]); + } + return map; +} + +/** Build a 6-week (42 day) Sunday-first grid covering the given month. */ +export function buildMonthGrid( + year: number, + month: number, + invoices: T[] +): CalendarDay[] { + const grouped = groupByDeadline(invoices); + const first = new Date(year, month, 1); + const start = new Date(year, month, 1 - first.getDay()); + return Array.from({ length: 42 }, (_, i) => { + const date = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i); + const key = dateKey(date); + return { key, date, inMonth: date.getMonth() === month, invoices: grouped.get(key) ?? [] }; + }); +} diff --git a/src/lib/sharePermissions.ts b/src/lib/sharePermissions.ts new file mode 100644 index 00000000..3f60d58a --- /dev/null +++ b/src/lib/sharePermissions.ts @@ -0,0 +1,117 @@ +export type ShareRole = "owner" | "editor" | "commenter" | "viewer"; + +export type SharePermission = + | "view" + | "comment" + | "edit" + | "cancel" + | "manageAccess"; + +export interface ShareGrant { + /** Stellar address of the collaborator. */ + address: string; + role: ShareRole; + /** Optional per-grant overrides that add or remove permissions from the role. */ + allow?: SharePermission[]; + deny?: SharePermission[]; + /** Unix ms expiry; access is revoked after this time. */ + expiresAt?: number; + grantedAt: number; +} + +export const ROLE_PERMISSIONS: Record = { + owner: ["view", "comment", "edit", "cancel", "manageAccess"], + editor: ["view", "comment", "edit"], + commenter: ["view", "comment"], + viewer: ["view"], +}; + +export const ROLE_RANK: Record = { + viewer: 0, + commenter: 1, + editor: 2, + owner: 3, +}; + +export function isExpired(grant: ShareGrant, now = Date.now()): boolean { + return grant.expiresAt !== undefined && grant.expiresAt <= now; +} + +/** Resolve the effective permissions for a grant (role + allow − deny). Expired grants have none. */ +export function effectivePermissions(grant: ShareGrant, now = Date.now()): Set { + if (isExpired(grant, now)) return new Set(); + const perms = new Set([...ROLE_PERMISSIONS[grant.role], ...(grant.allow ?? [])]); + for (const p of grant.deny ?? []) perms.delete(p); + return perms; +} + +export function can( + grants: ShareGrant[], + address: string, + permission: SharePermission, + now = Date.now() +): boolean { + const grant = grants.find((g) => g.address === address); + return grant ? effectivePermissions(grant, now).has(permission) : false; +} + +/** + * Add or update a grant. Only actors with `manageAccess` may change grants, and + * nobody may assign a role above their own or modify the owner's grant. + */ +export function upsertGrant( + grants: ShareGrant[], + actor: string, + next: Omit, + now = Date.now() +): ShareGrant[] { + const actorGrant = grants.find((g) => g.address === actor); + if (!actorGrant || !can(grants, actor, "manageAccess", now)) { + throw new Error("You do not have permission to manage access."); + } + if (ROLE_RANK[next.role] > ROLE_RANK[actorGrant.role]) { + throw new Error("Cannot grant a role higher than your own."); + } + const existing = grants.find((g) => g.address === next.address); + if (existing?.role === "owner" && next.address !== actor) { + throw new Error("The owner's access cannot be changed."); + } + const grant: ShareGrant = { ...next, grantedAt: existing?.grantedAt ?? now }; + return existing + ? grants.map((g) => (g.address === next.address ? grant : g)) + : [...grants, grant]; +} + +export function revokeGrant( + grants: ShareGrant[], + actor: string, + address: string, + now = Date.now() +): ShareGrant[] { + if (!can(grants, actor, "manageAccess", now)) { + throw new Error("You do not have permission to manage access."); + } + if (grants.find((g) => g.address === address)?.role === "owner") { + throw new Error("The owner's access cannot be revoked."); + } + return grants.filter((g) => g.address !== address); +} + +const storageKey = (resourceId: string) => `split:share-grants:${resourceId}`; + +export function loadGrants(resourceId: string): ShareGrant[] { + try { + const raw = localStorage.getItem(storageKey(resourceId)); + return raw ? (JSON.parse(raw) as ShareGrant[]) : []; + } catch { + return []; + } +} + +export function saveGrants(resourceId: string, grants: ShareGrant[]): void { + try { + localStorage.setItem(storageKey(resourceId), JSON.stringify(grants)); + } catch { + // storage unavailable; grants stay in memory only + } +}