// import { h } from "preact"; import { decryptDataWithStoredPassword, getDate, getDay, getEncryptionPassword, getMonth, getYear, MakeNewDate, } from "../helpers.ts"; import { Income } from "../services/financhalServices.ts"; interface Obligation { id: string; user_id: string | null; description: string | null; amount: number | null; frequency: string | null; due_date: number | null; encrypted_data: string | null; created_on: number | null; updated_on: number | null; } interface FinancialCalendarProps { obligations: Obligation[]; incomes: Income[]; yearNum: number; monthNum: number; oldBalance?: string; oldDate?: string; userId?: string; } export function getDaysInBaseMonth(year: number, month: number) { return new Date(year, month + 1, 0).getDate(); } import { useEffect, useReducer } from "preact/hooks"; // Consolidated state interface for better data synchronization interface FinancialCalendarState { processedObligations: Obligation[]; processedIncomes: Income[]; formDate: string; formBalance: number; formYear: number; formMonth: number; showListView: boolean; listViewType: "income" | "obligation"; selectedDay: number; selectedDayData: { incomes: Income[]; obligations: Obligation[]; }; } type FinancialCalendarAction = | { type: "SET_PROCESSED_OBLIGATIONS"; payload: Obligation[] } | { type: "SET_PROCESSED_INCOMES"; payload: Income[] } | { type: "SET_FORM_DATE"; payload: string } | { type: "SET_FORM_BALANCE"; payload: number } | { type: "SET_FORM_YEAR"; payload: number } | { type: "SET_FORM_MONTH"; payload: number } | { type: "SET_FORM_DATE_BALANCE"; payload: { date: string; balance: number }; } | { type: "SET_FORM_YEAR_MONTH"; payload: { year: number; month: number } } | { type: "SHOW_LIST_VIEW"; payload: { day: number; data: { incomes: Income[]; obligations: Obligation[] }; type: "income" | "obligation"; }; } | { type: "HIDE_LIST_VIEW" } | { type: "RESET_FORM_TO_PROPS"; payload: { year: number; month: number; date?: string; balance?: string }; }; // Initial state function function getInitialState( props: FinancialCalendarProps, ): FinancialCalendarState { const thisDay = new Date(); const todayStr = thisDay.toISOString().slice(0, 10); return { processedObligations: props.obligations, processedIncomes: props.incomes, formDate: props.oldDate || todayStr, formBalance: props.oldBalance ? parseFloat(props.oldBalance) : 0, formYear: props.yearNum, formMonth: props.monthNum, showListView: false, listViewType: "income", selectedDay: 0, selectedDayData: { incomes: [], obligations: [] }, }; } // Reducer function for state management function financialCalendarReducer( state: FinancialCalendarState, action: FinancialCalendarAction, ): FinancialCalendarState { switch (action.type) { case "SET_PROCESSED_OBLIGATIONS": return { ...state, processedObligations: action.payload }; case "SET_PROCESSED_INCOMES": return { ...state, processedIncomes: action.payload }; case "SET_FORM_DATE": return { ...state, formDate: action.payload }; case "SET_FORM_BALANCE": return { ...state, formBalance: action.payload }; case "SET_FORM_YEAR": return { ...state, formYear: action.payload }; case "SET_FORM_MONTH": return { ...state, formMonth: action.payload }; case "SET_FORM_DATE_BALANCE": return { ...state, formDate: action.payload.date, formBalance: action.payload.balance, }; case "SET_FORM_YEAR_MONTH": return { ...state, formYear: action.payload.year, formMonth: action.payload.month, }; case "SHOW_LIST_VIEW": return { ...state, showListView: true, selectedDay: action.payload.day, selectedDayData: action.payload.data, listViewType: action.payload.type, }; case "HIDE_LIST_VIEW": return { ...state, showListView: false, }; case "RESET_FORM_TO_PROPS": { const thisDay = new Date(); const todayStr = thisDay.toISOString().slice(0, 10); return { ...state, formYear: action.payload.year, formMonth: action.payload.month, formDate: action.payload.date || todayStr, formBalance: action.payload.balance ? parseFloat(action.payload.balance) : 0, }; } default: return state; } } export default function FinancialCalendar( { obligations, incomes, yearNum, monthNum, oldBalance, oldDate, userId }: FinancialCalendarProps, ) { // Theme classes are defined in styles.css and respond to theme mode + media. // Consolidated state management with useReducer const [state, dispatch] = useReducer( financialCalendarReducer, { obligations, incomes, yearNum, monthNum, oldBalance, oldDate, userId }, getInitialState, ); // Process obligations for decryption on component mount and when data changes useEffect(() => { if (!userId) { dispatch({ type: "SET_PROCESSED_OBLIGATIONS", payload: obligations }); return; } const processObligations = async () => { const encryptionPassword = getEncryptionPassword(userId); if (!encryptionPassword) { // No encryption key available, use obligations as-is dispatch({ type: "SET_PROCESSED_OBLIGATIONS", payload: obligations }); return; } const updatedObligations: Obligation[] = []; 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); } } updatedObligations.push(processedObligation); } dispatch({ type: "SET_PROCESSED_OBLIGATIONS", payload: updatedObligations, }); }; processObligations(); }, [obligations, userId]); // Process incomes for decryption on component mount and when data changes useEffect(() => { if (!userId) { dispatch({ type: "SET_PROCESSED_INCOMES", payload: incomes }); return; } const processIncomes = async () => { const encryptionPassword = getEncryptionPassword(userId); if (!encryptionPassword) { // No encryption key available, use incomes as-is dispatch({ type: "SET_PROCESSED_INCOMES", payload: incomes }); return; } const updatedIncomes: Income[] = []; for (const income of incomes) { const processedIncome = { ...income }; if (income.encrypted_data) { // Try to decrypt existing encrypted data try { const decryptedData = await decryptDataWithStoredPassword( userId, income.encrypted_data, ); if (decryptedData) { const parsed = JSON.parse(decryptedData); if (parsed.amount !== undefined) { processedIncome.amount = parsed.amount; } if (parsed.source !== undefined) { processedIncome.source = parsed.source; } } } catch (error) { console.error("Failed to decrypt income data:", error); } } updatedIncomes.push(processedIncome); } dispatch({ type: "SET_PROCESSED_INCOMES", payload: updatedIncomes }); }; processIncomes(); }, [incomes, userId]); // Remove old form state variables - now managed in consolidated state const baseMonth = monthNum - 1; const daysInThisMonth = getDaysInBaseMonth(yearNum, baseMonth); // Map days to markers const dayPositiveMarkers: Record = {}; const dayNegativeMarkers: Record = {}; for (let day = 1; day <= daysInThisMonth; day++) { dayPositiveMarkers[day] = 0; dayNegativeMarkers[day] = 0; } state.processedObligations.forEach((ob) => { if (!ob.due_date) return; const d = MakeNewDate(ob.due_date); if (getYear(d) === yearNum && getMonth(d) === baseMonth) { const day = getDate(d); dayNegativeMarkers[day]++; } }); state.processedIncomes.forEach((inc) => { if (!inc.received_date) return; const d = MakeNewDate(inc.received_date); if (getYear(d) === yearNum && getMonth(d) === baseMonth) { const day = getDate(d); dayPositiveMarkers[day]++; } }); // Helper: Get data for a specific day function getDayData( day: number, ): { incomes: Income[]; obligations: Obligation[] } { const dayIncomes: Income[] = []; const dayObligations: Obligation[] = []; state.processedIncomes.forEach((inc) => { if (!inc.received_date) return; const d = MakeNewDate(inc.received_date); if ( getYear(d) === yearNum && getMonth(d) === baseMonth && getDate(d) === day ) { dayIncomes.push(inc); } }); state.processedObligations.forEach((ob) => { if (!ob.due_date) return; const d = MakeNewDate(ob.due_date); if ( getYear(d) === yearNum && getMonth(d) === baseMonth && getDate(d) === day ) { dayObligations.push(ob); } }); return { incomes: dayIncomes, obligations: dayObligations }; } // Helper: Compute running balance for each day after the set balance date function getDailyBalances(): { dailyBalances: Record; incomesByDay: Record; obligationsByDay: Record; } { const start = MakeNewDate(state.formDate); const startYear = getYear(start); const startMonth = getMonth(start); const startDay = getDate(start); const startMonthYear = startYear * 12 + startMonth; const endMonthYear = yearNum * 12 + baseMonth; let runningBalance = 0; const dailyBalances: Record = {}; const incomesByDay: Record = {}; const obligationsByDay: Record = {}; // console.log("startMonthYear", startMonthYear); // console.log("endMonthYear", endMonthYear); let numberNegative = 0; let numberPositive = 0; let maxYear = 0; let minYear = 9999; if (startMonthYear < endMonthYear) { state.processedIncomes.forEach((inc) => { if (!inc.received_date) return; const d = MakeNewDate(inc.received_date); const thisYear = getYear(d); const thisMonth = getMonth(d); const thisDay = getDate(d); const thisMonthYear = thisYear * 12 + thisMonth; if (thisMonthYear < endMonthYear) { if ( thisYear > startYear || (thisYear === startYear && thisMonth > startMonth) || (thisYear === startYear && thisMonth === startMonth && thisDay >= startDay) ) { runningBalance += inc.amount || 0; numberPositive++; if (thisYear > maxYear) maxYear = thisYear; if (thisYear < minYear) minYear = thisYear; } } }); state.processedObligations.forEach((ob) => { if (!ob.due_date) return; const d = MakeNewDate(ob.due_date); const thisYear = getYear(d); const thisMonth = getMonth(d); const thisDay = getDate(d); const thisMonthYear = thisYear * 12 + thisMonth; if (thisMonthYear < endMonthYear) { if ( thisYear > startYear || (thisYear === startYear && thisMonth > startMonth) || (thisYear === startYear && thisMonth === startMonth && thisDay >= startDay) ) { runningBalance -= ob.amount || 0; numberNegative++; if (thisYear > maxYear) maxYear = thisYear; if (thisYear < minYear) minYear = thisYear; } } }); } // console.log("runningBalance", runningBalance); // console.log("numberPositive", numberPositive); // console.log("numberNegative", numberNegative); // console.log("maxYear", maxYear); // console.log("minYear", minYear); if (startMonthYear <= endMonthYear) { runningBalance += state.formBalance || 0; const sDay = (startMonthYear < endMonthYear) ? 1 : startDay; for (let d = 1; d <= daysInThisMonth; d++) { incomesByDay[d] = []; obligationsByDay[d] = []; } state.processedIncomes.forEach((inc) => { if (!inc.received_date) return; const d = MakeNewDate(inc.received_date); const thisYear = getYear(d); const thisMonth = getMonth(d); const thisDay = getDate(d); const thisMonthYear = thisYear * 12 + thisMonth; if (thisMonthYear === endMonthYear) { incomesByDay[thisDay].push(inc.amount || 0); } }); state.processedObligations.forEach((ob) => { if (!ob.due_date) return; const d = MakeNewDate(ob.due_date); const thisYear = getYear(d); const thisMonth = getMonth(d); const thisDay = getDate(d); const thisMonthYear = thisYear * 12 + thisMonth; if (thisMonthYear === endMonthYear) { obligationsByDay[thisDay].push(ob.amount || 0); } }); // Calculate running balance for current month for (let d = sDay; d <= daysInThisMonth; d++) { // Add incomes if (incomesByDay[d]) { for (const amt of incomesByDay[d]) runningBalance += amt; } // Subtract obligations if (obligationsByDay[d]) { for (const amt of obligationsByDay[d]) runningBalance -= amt; } dailyBalances[d] = runningBalance; } } return { dailyBalances, incomesByDay, obligationsByDay }; } // Render calendar grid const first = MakeNewDate( yearNum + "-" + (monthNum < 10 ? "0" + monthNum : monthNum) + "-" + "01", ); // 0=Sun const firstDay = getDay(first); let weeks: (string | number)[][] = []; let week: (string | number)[] = Array(firstDay).fill(""); for (let day = 1; day <= daysInThisMonth; day++) { week.push(day); if (week.length === 7) { weeks.push(week); week = []; } } if (week.length) { while (week.length < 7) week.push(""); weeks.push(week); } if (weeks.length < 5) { weeks = [Array(7).fill(""), ...weeks]; } if (weeks.length < 6) { weeks.push(Array(7).fill("")); } // Month names for display const monthNames = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; return ( <> {/* Financial Balance Form */}
dispatch({ type: "SET_FORM_BALANCE", payload: (e.target as HTMLInputElement).valueAsNumber, })} class="px-2 py-1 text-center border rounded theme-calendar-input" /> dispatch({ type: "SET_FORM_DATE", payload: (e.target as HTMLInputElement).value, })} class="px-2 py-1 border rounded theme-calendar-input" />
{monthNames[baseMonth]} {yearNum}
{weeks.map((week, i) => ( {(() => { // Compute daily balances for this render const { dailyBalances, incomesByDay, obligationsByDay } = getDailyBalances(); // Calculate total change for each day const getDayChange = (day: number) => { const dayIncomes = incomesByDay[day] || []; const dayObligations = obligationsByDay[day] || []; const totalIncome = dayIncomes.reduce( (sum, amount) => sum + amount, 0, ); const totalObligations = dayObligations.reduce( (sum, amount) => sum + amount, 0, ); return totalIncome - totalObligations; }; // Get today's date for highlighting current day const today = new Date(); const todayDay = today.getDate(); const todayMonth = today.getMonth(); const todayYear = today.getFullYear(); const isCurrentMonth = yearNum === todayYear && baseMonth === todayMonth; return week.map((d, j) => ( )); })()} ))}
Sun Mon Tue Wed Thu Fri Sat
{d ? (
{dayNegativeMarkers[d as number] > 0 && ( )}
{d}
{dayPositiveMarkers[d as number] > 0 && ( )}
{(() => { const dayChange = getDayChange(d as number); const hasValues = (incomesByDay[d as number] && incomesByDay[d as number].length > 0) || (obligationsByDay[d as number] && obligationsByDay[d as number].length > 0); return hasValues && dayChange !== 0 ? (
= 0 ? "calendar-change-positive" : "calendar-change-negative" }`} > {dayChange >= 0 ? "+" : ""}${dayChange .toFixed(2)}
) : (
); })()} {dailyBalances[d as number] !== undefined && (
= 0 ? "calendar-balance-positive" : "calendar-balance-negative") + " font-mono mt-1"} > ${dailyBalances[d as number].toFixed(2)}
)}
) : null}
{/* List View */} {state.showListView ? (

{state.listViewType === "income" ? "Incomes" : "Obligations"} {" "} for Day {state.selectedDay}

{state.listViewType === "income" ? (
{state.selectedDayData.incomes.length === 0 ? (
No incomes for this day
) : (
{state.selectedDayData.incomes.map(( income, index, ) => (
{income.source || "Income"}
Frequency: {income.frequency || "One-time"}
+${income.amount?.toFixed(2) || "0.00"}
))}
)}
) : (
{state.selectedDayData.obligations.length === 0 ? (
No obligations for this day
) : (
{state.selectedDayData.obligations.map(( obligation, index, ) => (
{obligation.description || "Obligation"}
Frequency:{" "} {obligation.frequency || "One-time"}
-${obligation.amount?.toFixed(2) || "0.00"}
))}
)}
)}
) : ( // Placeholder div to prevent layout shift
)}
); }