import { decryptDataWithStoredPassword, encryptDataWithStoredPassword, getEncryptionPassword, MakeNewDate, } from "../helpers.ts"; import { Obligation } from "../services/financhalServices.ts"; import { useEffect, useRef, useState } from "preact/hooks"; import { PopAddObligationDialog } from "./dialogs/PopAddObligationDialog.tsx"; interface Props { obligations: Obligation[]; userId?: string; openModal?: boolean; } export default function ObligationList( { obligations, userId, openModal = false }: Props, ) { // Theme classes are defined in styles.css and respond to theme mode + media. const [_popAddObligation, popupAddObligationDialog] = PopAddObligationDialog({ userId, defaultOpen: openModal, }); const [processedObligations, setProcessedObligations] = useState< Obligation[] >(obligations); const [_isProcessing, setIsProcessing] = useState(false); const [_error, setError] = useState(null); const [isOpeningModal, setIsOpeningModal] = useState(openModal); const processingRef = useRef(false); // Process obligations on component mount and when obligations or userId change useEffect(() => { setTimeout(() => { const lockRoute = localStorage.getItem("lock-route"); const url = new URL(globalThis.location.href); const currentRoute = url.pathname + url.search; if (lockRoute && lockRoute === currentRoute) { localStorage.removeItem("lock-route"); } }, 100); // Prevent concurrent processing if (processingRef.current) { return; } if (!userId) { setProcessedObligations(obligations); setError(null); return; } const processObligations = async () => { processingRef.current = true; setIsProcessing(true); setError(null); try { const encryptionPassword = getEncryptionPassword(userId); if (!encryptionPassword) { // No encryption key available, use obligations as-is setProcessedObligations(obligations); return; } const updatedObligations: Obligation[] = []; let hasEncryptionUpdates = false; for (const obligation of obligations) { const processedObligation = { ...obligation }; if (obligation.encrypted_data) { // Try to decrypt existing encrypted data try { const decryptedData = await decryptDataWithStoredPassword( userId, obligation.encrypted_data, ); if (decryptedData) { const parsed = JSON.parse(decryptedData); if (parsed.amount !== undefined) { processedObligation.amount = parsed.amount; } if (parsed.description !== undefined) { processedObligation.description = parsed.description; } } } catch (error) { console.error("Failed to decrypt obligation data:", error); setError("Failed to decrypt some obligation data"); } } else if ( obligation.amount !== null || obligation.description !== null ) { // No encrypted data but has plaintext data - encrypt it and clear plaintext try { const dataToEncrypt = { amount: obligation.amount, description: obligation.description, }; const encryptedData = await encryptDataWithStoredPassword( userId, JSON.stringify(dataToEncrypt), ); if (encryptedData) { // Update the obligation in the database const response = await fetch("/api/update/obligation", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ obligation_id: obligation.id, encrypted_data: encryptedData, description: null, // Clear plaintext description amount: null, // Clear plaintext amount }), }); if (response.ok) { // Update local state processedObligation.encrypted_data = encryptedData; processedObligation.description = obligation.description; // Keep for display processedObligation.amount = obligation.amount; // Keep for display hasEncryptionUpdates = true; } else { console.error("Failed to update obligation encryption"); setError("Failed to encrypt some obligation data"); } } } catch (error) { console.error("Failed to encrypt obligation data:", error); setError("Failed to encrypt some obligation data"); } } updatedObligations.push(processedObligation); } setProcessedObligations(updatedObligations); if (hasEncryptionUpdates) { console.log("Successfully encrypted plaintext obligation data"); } } catch (error) { console.error("Unexpected error during obligation processing:", error); setError("An unexpected error occurred while processing obligations"); setProcessedObligations(obligations); // Fallback to original data } finally { setIsProcessing(false); processingRef.current = false; } }; processObligations(); // Cleanup function to handle component unmount return () => { processingRef.current = false; }; }, []); async function handleExportObligations() { const exportData = processedObligations.map((ob) => ({ id: ob.id, description: ob.description ?? "", amount: ob.amount ?? 0, frequency: ob.frequency ?? "", due_date: ob.due_date ? new Date(ob.due_date).toISOString().split("T")[0] : null, })); const json = JSON.stringify(exportData, null, 2); const blob = new Blob([json], { type: "application/json" }); const suggestedName = `budget-obligations-${ new Date().toISOString().split("T")[0] }.json`; const showSave = (globalThis as { showSaveFilePicker?: unknown }) .showSaveFilePicker; if (typeof showSave === "function") { try { const handle = await (showSave as (opts: { suggestedName: string; types: { description: string; accept: Record }[]; }) => Promise)({ suggestedName, types: [{ description: "JSON file", accept: { "application/json": [".json"] }, }], }); const writable = await handle.createWritable(); await writable.write(blob); await writable.close(); } catch (err) { if ((err as Error).name !== "AbortError") { console.error("Export failed:", err); fallbackDownload(blob, suggestedName); } } } else { fallbackDownload(blob, suggestedName); } } function fallbackDownload(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } async function handleImportObligations() { let file: File; const showOpen = (globalThis as { showOpenFilePicker?: unknown }) .showOpenFilePicker; if (typeof showOpen === "function") { try { const [handle] = await (showOpen as (opts: { types: { description: string; accept: Record }[]; multiple: boolean; }) => Promise)({ types: [{ description: "JSON file", accept: { "application/json": [".json"] }, }], multiple: false, }); file = await handle.getFile(); } catch (err) { if ((err as Error).name !== "AbortError") { console.error("Import file picker failed:", err); } return; } } else { const chosen = await new Promise((resolve) => { const input = document.createElement("input"); input.type = "file"; input.accept = "application/json,.json"; input.onchange = () => resolve(input.files?.[0] ?? null); input.click(); }); if (!chosen) return; file = chosen; } try { const text = await file.text(); const data = JSON.parse(text) as unknown; const list = Array.isArray(data) ? data : (data as { obligations?: unknown[] })?.obligations; if (!Array.isArray(list)) { alert("Invalid file: expected a JSON array or { obligations: [] }"); return; } const res = await fetch("/api/import/obligations", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(list), }); if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err?.error ?? "Import failed"); return; } const result = await res.json() as { added: number; skipped: number }; if (result.added > 0 || result.skipped > 0) { globalThis.location.reload(); } } catch (e) { console.error("Import failed:", e); alert("Invalid JSON or import failed."); } } return ( <> {popupAddObligationDialog}

Your Obligations

    {processedObligations.map((ob) => (
  • { // Set sender input to current page const form = e.currentTarget; form.sender.value = location.pathname; // Add confirmation for delete if ( !confirm( `Are you sure you want to delete "${ ob.description || "this obligation" }"?`, ) ) { e.preventDefault(); return false; } }} >
    {ob.description || ""}
    Amount: ${ob.amount?.toFixed(2) || "0.00"}
    Frequency: {ob.frequency}
    Due: {ob.due_date ? MakeNewDate(ob.due_date).toISOString().split("T")[0] : ""}
  • ))}
); }