From 1ac6fa9300137edee061a8164e6b8aa7d3ce9d09 Mon Sep 17 00:00:00 2001 From: knytcomics-ui Date: Mon, 31 Aug 2026 09:09:50 +0000 Subject: [PATCH] feat: draft conflict detection, rate caching/alerts, collab count badge - useOfflineDraftAutosave: check server updatedAt on reconnect and call onConflict instead of silently overwriting a newer server draft - useXlmUsdcRate: add alertThreshold/onAlert to notify on large rate moves - useFiatRate: add configurable ttl and isStale to avoid using overly stale cached rates - CollaborationCursors: show an "N online" badge in the top-right corner Closes: #684 Closes: #683 Closes: #682 Closes: #673 --- src/app/api/invoices/drafts/route.ts | 17 +++ src/components/CollaborationCursors.tsx | 133 +++++++++++++----------- src/hooks/useFiatRate.ts | 30 ++++-- src/hooks/useOfflineDraftAutosave.ts | 23 +++- src/hooks/useXlmUsdcRate.ts | 24 ++++- 5 files changed, 155 insertions(+), 72 deletions(-) diff --git a/src/app/api/invoices/drafts/route.ts b/src/app/api/invoices/drafts/route.ts index 57732c8d..e6d267a1 100644 --- a/src/app/api/invoices/drafts/route.ts +++ b/src/app/api/invoices/drafts/route.ts @@ -30,3 +30,20 @@ export async function POST(request: NextRequest) { return NextResponse.json({ received: true, draftId }); } + +/** + * Reports the server's last-known `updatedAt` for a draft, so the client can + * detect a conflict before overwriting it. There's no database in this + * project, so there's never a stored server version — the client treats a + * missing `updatedAt` as "no conflict" and syncs normally. + */ +export async function GET(request: NextRequest) { + const draftId = request.nextUrl.searchParams.get("draftId"); + const userId = request.nextUrl.searchParams.get("userId"); + + if (!draftId || !userId) { + return NextResponse.json({ error: "draftId and userId are required" }, { status: 400 }); + } + + return NextResponse.json({ updatedAt: null }); +} diff --git a/src/components/CollaborationCursors.tsx b/src/components/CollaborationCursors.tsx index e1a76627..03359c37 100644 --- a/src/components/CollaborationCursors.tsx +++ b/src/components/CollaborationCursors.tsx @@ -87,72 +87,81 @@ export default function CollaborationCursors({ invoiceId, currentAddress }: Prop if (others.length === 0) return null; return ( -
- - - {!collapsed && ( - +
+ + + {!collapsed && ( +
+ {/* Track line */} +
+ + {others.map((cursor) => { + const topPct = Math.min((cursor.scrollY / pageHeight) * 100, 94); + const color = colorForAddress(cursor.address); + return ( +
+ ); + })} +
+ )} + + {!collapsed && ( +
+ {others.slice(0, 4).map((cursor) => (
- ); - })} -
- )} - - {!collapsed && ( -
- {others.slice(0, 4).map((cursor) => ( -
- ))} - {others.length > 4 && ( - - +{others.length - 4} - - )} -
- )} -
+ ))} + {others.length > 4 && ( + + +{others.length - 4} + + )} +
+ )} +
+ ); } diff --git a/src/hooks/useFiatRate.ts b/src/hooks/useFiatRate.ts index edd7fdc9..c0f26a73 100644 --- a/src/hooks/useFiatRate.ts +++ b/src/hooks/useFiatRate.ts @@ -21,6 +21,8 @@ interface RateState { loading: boolean; /** Set when the last fetch failed and no usable rate is available. */ error: string | null; + /** Timestamp (ms) of the last successful fetch, used to derive `isStale`. */ + lastFetchedAt: number | null; } interface FiatRateContextValue extends RateState { @@ -29,6 +31,8 @@ interface FiatRateContextValue extends RateState { currency: FiatCurrency; } +const DEFAULT_TTL_SECONDS = 60; + const FiatRateContext = createContext(null); /** @@ -43,6 +47,7 @@ export function FiatRateProvider({ children }: { children: React.ReactNode }) { rates: null, loading: true, error: null, + lastFetchedAt: null, }); const mounted = useRef(true); @@ -59,12 +64,12 @@ export function FiatRateProvider({ children }: { children: React.ReactNode }) { if (!body.rates) throw new Error("Malformed rate response"); if (mounted.current) { - setState({ rates: body.rates, loading: false, error: null }); + setState({ rates: body.rates, loading: false, error: null, lastFetchedAt: Date.now() }); } } catch (error) { if (controller.signal.aborted || !mounted.current) return; const message = error instanceof Error ? error.message : String(error); - setState({ rates: null, loading: false, error: message }); + setState((prev) => ({ rates: null, loading: false, error: message, lastFetchedAt: prev.lastFetchedAt })); } }; @@ -96,15 +101,26 @@ export function FiatRateProvider({ children }: { children: React.ReactNode }) { /** * Read the current fiat rate for the user's preferred currency. * - * Returns a neutral loading state outside a provider so a component tree that - * has not mounted the provider degrades instead of throwing. + * `ttl` (seconds, default 60) controls how long the cached rate is considered + * fresh; once exceeded, `isStale` is true while the last-known rate is still + * returned. Returns a neutral loading state outside a provider so a component + * tree that has not mounted the provider degrades instead of throwing. */ -export function useFiatRate(): FiatRateContextValue { +export function useFiatRate(ttl: number = DEFAULT_TTL_SECONDS): FiatRateContextValue & { isStale: boolean } { const ctx = useContext(FiatRateContext); if (!ctx) { - return { rates: null, rate: null, loading: false, error: "Rate unavailable", currency: "USD" }; + return { + rates: null, + rate: null, + loading: false, + error: "Rate unavailable", + currency: "USD", + lastFetchedAt: null, + isStale: false, + }; } - return ctx; + const isStale = ctx.lastFetchedAt !== null && Date.now() - ctx.lastFetchedAt > ttl * 1000; + return { ...ctx, isStale }; } export default useFiatRate; diff --git a/src/hooks/useOfflineDraftAutosave.ts b/src/hooks/useOfflineDraftAutosave.ts index 3004a630..932a67ba 100644 --- a/src/hooks/useOfflineDraftAutosave.ts +++ b/src/hooks/useOfflineDraftAutosave.ts @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { deleteDraft, getDraft, putDraft, type DraftFormData } from "@/lib/offlineDraftDB"; +import { deleteDraft, getDraft, putDraft, type DraftFormData, type StoredDraft } from "@/lib/offlineDraftDB"; import { apiFetch } from "@/lib/apiClient"; const AUTOSAVE_INTERVAL_MS = 5_000; @@ -21,7 +21,8 @@ export interface UseOfflineDraftAutosaveResult { export function useOfflineDraftAutosave( userId: string, draftId: string, - data: DraftFormData + data: DraftFormData, + onConflict?: (local: StoredDraft, server: { updatedAt: number }) => void ): UseOfflineDraftAutosaveResult { const [isOffline, setIsOffline] = useState( () => typeof navigator !== "undefined" && !navigator.onLine @@ -29,6 +30,8 @@ export function useOfflineDraftAutosave( const [lastSavedAt, setLastSavedAt] = useState(null); const dataRef = useRef(data); dataRef.current = data; + const onConflictRef = useRef(onConflict); + onConflictRef.current = onConflict; const flush = useCallback(async () => { if (!userId || !draftId) return; @@ -36,6 +39,22 @@ export function useOfflineDraftAutosave( const draft = await getDraft(userId, draftId); if (!draft) return; + try { + const serverRes = await apiFetch( + `/api/invoices/drafts?${new URLSearchParams({ userId, draftId })}` + ); + if (serverRes.ok) { + const serverBody = await serverRes.json().catch(() => null); + const serverUpdatedAt = serverBody?.updatedAt; + if (typeof serverUpdatedAt === "number" && serverUpdatedAt > draft.updatedAt) { + onConflictRef.current?.(draft, { updatedAt: serverUpdatedAt }); + return; + } + } + } catch { + // Can't reach the server to check for a conflict — fall through and sync as before. + } + try { const res = await apiFetch("/api/invoices/drafts", { method: "POST", diff --git a/src/hooks/useXlmUsdcRate.ts b/src/hooks/useXlmUsdcRate.ts index ed4410d6..ee6af139 100644 --- a/src/hooks/useXlmUsdcRate.ts +++ b/src/hooks/useXlmUsdcRate.ts @@ -4,6 +4,13 @@ import { useState, useEffect, useRef } from "react"; const REFRESH_MS = 60_000; +export interface UseXlmUsdcRateOptions { + /** Percentage move (e.g. 5 for 5%) from the rate at hook initialisation that triggers onAlert. */ + alertThreshold?: number; + /** Called at most once per polling interval when the rate crosses alertThreshold. */ + onAlert?: (direction: "up" | "down", changePercent: number) => void; +} + /** * useXlmUsdcRate * @@ -13,12 +20,16 @@ const REFRESH_MS = 60_000; * * Returns null while loading or when the rate is unavailable. */ -export function useXlmUsdcRate(): number | null { +export function useXlmUsdcRate(options?: UseXlmUsdcRateOptions): number | null { const [rate, setRate] = useState(null); const mounted = useRef(true); + const baseRateRef = useRef(null); + const optionsRef = useRef(options); + optionsRef.current = options; useEffect(() => { mounted.current = true; + baseRateRef.current = null; const load = async () => { try { @@ -32,6 +43,17 @@ export function useXlmUsdcRate(): number | null { const data = await res.json(); const xlmUsd: number | undefined = data?.stellar?.usd; if (typeof xlmUsd === "number" && xlmUsd > 0 && mounted.current) { + if (baseRateRef.current === null) { + baseRateRef.current = xlmUsd; + } else { + const { alertThreshold, onAlert } = optionsRef.current ?? {}; + if (alertThreshold && onAlert) { + const changePercent = ((xlmUsd - baseRateRef.current) / baseRateRef.current) * 100; + if (Math.abs(changePercent) >= alertThreshold) { + onAlert(changePercent > 0 ? "up" : "down", Math.abs(changePercent)); + } + } + } setRate(xlmUsd); } } catch {