1061 lines
37 KiB
TypeScript
1061 lines
37 KiB
TypeScript
// import { h } from "preact";
|
|
|
|
import {
|
|
decryptDataWithStoredPassword,
|
|
getDate,
|
|
getDay,
|
|
getEncryptionPassword,
|
|
getMonth,
|
|
getYear,
|
|
MakeNewDate,
|
|
setEncryptionPassword,
|
|
} 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 () => {
|
|
let encryptionPassword = getEncryptionPassword(userId);
|
|
const hasEncrypted = obligations.some((o) => o.encrypted_data) ||
|
|
incomes.some((i) => i.encrypted_data);
|
|
|
|
// If no local PIN but we have encrypted data, try to fetch PIN from server (e.g. new device or calendar opened first)
|
|
if (!encryptionPassword && hasEncrypted) {
|
|
try {
|
|
const res = await fetch("/api/sync-pin/get", {
|
|
credentials: "include",
|
|
cache: "no-store",
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data.pin && setEncryptionPassword(userId, data.pin)) {
|
|
encryptionPassword = data.pin;
|
|
}
|
|
}
|
|
} catch (_e) {
|
|
// sync-pin/get fetch failed
|
|
}
|
|
}
|
|
|
|
if (!encryptionPassword) {
|
|
dispatch({ type: "SET_PROCESSED_OBLIGATIONS", payload: obligations });
|
|
return;
|
|
}
|
|
|
|
const updatedObligations: Obligation[] = [];
|
|
let decryptedCount = 0;
|
|
|
|
for (const obligation of obligations) {
|
|
const processedObligation = { ...obligation };
|
|
|
|
if (obligation.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;
|
|
}
|
|
decryptedCount++;
|
|
}
|
|
} catch {
|
|
// decrypt failed for this obligation
|
|
}
|
|
}
|
|
|
|
updatedObligations.push(processedObligation);
|
|
}
|
|
|
|
dispatch({
|
|
type: "SET_PROCESSED_OBLIGATIONS",
|
|
payload: updatedObligations,
|
|
});
|
|
};
|
|
processObligations();
|
|
}, [obligations, incomes, 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 () => {
|
|
let encryptionPassword = getEncryptionPassword(userId);
|
|
const hasEncrypted = obligations.some((o) => o.encrypted_data) ||
|
|
incomes.some((i) => i.encrypted_data);
|
|
|
|
if (!encryptionPassword && hasEncrypted) {
|
|
try {
|
|
const res = await fetch("/api/sync-pin/get", {
|
|
credentials: "include",
|
|
cache: "no-store",
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data.pin && setEncryptionPassword(userId, data.pin)) {
|
|
encryptionPassword = data.pin;
|
|
}
|
|
}
|
|
} catch (_e) {
|
|
// sync-pin/get fetch failed
|
|
}
|
|
}
|
|
|
|
if (!encryptionPassword) {
|
|
dispatch({ type: "SET_PROCESSED_INCOMES", payload: incomes });
|
|
return;
|
|
}
|
|
|
|
const updatedIncomes: Income[] = [];
|
|
let decryptedCount = 0;
|
|
|
|
for (const income of incomes) {
|
|
const processedIncome = { ...income };
|
|
|
|
if (income.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;
|
|
}
|
|
decryptedCount++;
|
|
}
|
|
} catch {
|
|
// decrypt failed for this income
|
|
}
|
|
}
|
|
|
|
updatedIncomes.push(processedIncome);
|
|
}
|
|
|
|
dispatch({ type: "SET_PROCESSED_INCOMES", payload: updatedIncomes });
|
|
};
|
|
|
|
processIncomes();
|
|
}, [incomes, obligations, 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<number, number> = {};
|
|
const dayNegativeMarkers: Record<number, number> = {};
|
|
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<number, number>;
|
|
incomesByDay: Record<number, number[]>;
|
|
obligationsByDay: Record<number, number[]>;
|
|
} {
|
|
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<number, number> = {};
|
|
const incomesByDay: Record<number, number[]> = {};
|
|
const obligationsByDay: Record<number, number[]> = {};
|
|
// 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 */}
|
|
<div class="flex justify-center mt-14 mb-4">
|
|
<div class="w-[560px] p-4 border rounded-lg theme-calendar-form">
|
|
<div class="flex items-center gap-2">
|
|
<label
|
|
htmlFor="balance"
|
|
class="font-bold whitespace-nowrap theme-calendar-label"
|
|
>
|
|
Balance
|
|
</label>
|
|
<input
|
|
id="balance"
|
|
type="number"
|
|
step="0.01"
|
|
required
|
|
value={state.formBalance}
|
|
onInput={(e) =>
|
|
dispatch({
|
|
type: "SET_FORM_BALANCE",
|
|
payload: (e.target as HTMLInputElement).valueAsNumber,
|
|
})}
|
|
class="px-2 py-1 text-center border rounded theme-calendar-input"
|
|
/>
|
|
<label
|
|
htmlFor="date"
|
|
class="font-bold whitespace-nowrap theme-calendar-label"
|
|
>
|
|
Date
|
|
</label>
|
|
<input
|
|
id="date"
|
|
type="date"
|
|
required
|
|
value={state.formDate}
|
|
onInput={(e) =>
|
|
dispatch({
|
|
type: "SET_FORM_DATE",
|
|
payload: (e.target as HTMLInputElement).value,
|
|
})}
|
|
class="px-2 py-1 border rounded theme-calendar-input"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
console.log("Set button clicked!");
|
|
console.log("Form data:", {
|
|
balance: state.formBalance,
|
|
date: state.formDate,
|
|
year: state.formYear,
|
|
month: state.formMonth,
|
|
});
|
|
|
|
try {
|
|
console.log("Making fetch request...");
|
|
const response = await fetch(
|
|
"/api/update/financial-balance",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
balance: state.formBalance,
|
|
date: state.formDate,
|
|
year: state.formYear,
|
|
month: state.formMonth,
|
|
}),
|
|
credentials: "include",
|
|
},
|
|
);
|
|
|
|
console.log("Response status:", response.status);
|
|
console.log("Response ok:", response.ok);
|
|
|
|
const responseData = await response.json();
|
|
console.log("Response data:", responseData);
|
|
|
|
if (!response.ok) {
|
|
console.error("Failed to update balance:", responseData);
|
|
alert(
|
|
`Error: ${
|
|
responseData.error || "Failed to update balance"
|
|
}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
console.log("Balance updated successfully!");
|
|
alert("Balance updated successfully!");
|
|
globalThis.location.reload();
|
|
} catch (error) {
|
|
console.error("Error updating balance:", error);
|
|
const errorMessage = error instanceof Error
|
|
? error.message
|
|
: "Unknown error";
|
|
alert(`Error: ${errorMessage}`);
|
|
}
|
|
}}
|
|
class="calendar-set-button"
|
|
>
|
|
Set
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<form
|
|
method="GET"
|
|
action={`/calendar/financial-calendar/${state.formYear}/${state.formMonth}?`}
|
|
>
|
|
<button
|
|
type="submit"
|
|
aria-label="Previous Month"
|
|
onClick={(_e) => {
|
|
//e.preventDefault(); // Prevent form submission
|
|
let newMonth = monthNum;
|
|
let newYear = yearNum;
|
|
if (newMonth === 1) {
|
|
if (newYear !== 1900) {
|
|
newYear = newYear - 1;
|
|
newMonth = 12;
|
|
}
|
|
} else {
|
|
newMonth = newMonth - 1;
|
|
}
|
|
console.log("newMonth", newMonth);
|
|
console.log("newYear", newYear);
|
|
dispatch({
|
|
type: "SET_FORM_YEAR_MONTH",
|
|
payload: { year: newYear, month: newMonth },
|
|
});
|
|
// // Navigate after state update
|
|
// setTimeout(() => {
|
|
// //globalThis.location.href = `/financial-calendar/${newYear}/${newMonth}`;
|
|
// }, 100);
|
|
}}
|
|
class="theme-calendar-nav-button calendar-nav-button calendar-nav-button--prev"
|
|
>
|
|
<svg
|
|
width="32"
|
|
height="32"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<polyline points="15 18 9 12 15 6" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
aria-label="Next Month"
|
|
onClick={(_e) => {
|
|
//e.preventDefault(); // Prevent form submission
|
|
let newMonth = monthNum;
|
|
let newYear = yearNum;
|
|
if (newMonth === 12) {
|
|
if (newYear !== 2100) {
|
|
newYear = newYear + 1;
|
|
newMonth = 1;
|
|
}
|
|
} else {
|
|
newMonth = newMonth + 1;
|
|
}
|
|
console.log("newMonth", newMonth);
|
|
console.log("newYear", newYear);
|
|
dispatch({
|
|
type: "SET_FORM_YEAR_MONTH",
|
|
payload: { year: newYear, month: newMonth },
|
|
});
|
|
// Navigate after state update
|
|
// setTimeout(() => {
|
|
// //globalThis.location.href = `/financial-calendar/${newYear}/${newMonth}`;
|
|
// }, 100);
|
|
}}
|
|
class="theme-calendar-nav-button calendar-nav-button calendar-nav-button--next"
|
|
>
|
|
<svg
|
|
width="32"
|
|
height="32"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<polyline points="9 6 15 12 9 18" />
|
|
</svg>
|
|
</button>
|
|
</form>
|
|
<div class="w-full text-center text-3xl font-bold mb-0 theme-calendar-title">
|
|
{monthNames[baseMonth]} {yearNum}
|
|
</div>
|
|
<div id="calendar" class="flex justify-center">
|
|
<table class="max-w-[500px] border-collapse text-center theme-calendar-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="p-1">Sun</th>
|
|
<th class="p-1">Mon</th>
|
|
<th class="p-1">Tue</th>
|
|
<th class="p-1">Wed</th>
|
|
<th class="p-1">Thu</th>
|
|
<th class="p-1">Fri</th>
|
|
<th class="p-1">Sat</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{weeks.map((week, i) => (
|
|
<tr key={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) => (
|
|
<td
|
|
key={j}
|
|
class={`p-1 h-[85px] align-top theme-calendar-cell ${
|
|
isCurrentMonth && d === todayDay
|
|
? "theme-calendar-today rounded"
|
|
: "border"
|
|
}`}
|
|
>
|
|
{d
|
|
? (
|
|
<div>
|
|
<div
|
|
id="day-markers"
|
|
class="relative w-[72px] h-8 flex items-center justify-center"
|
|
>
|
|
{dayNegativeMarkers[d as number] > 0 && (
|
|
<button
|
|
id="button-negative"
|
|
type="button"
|
|
class="absolute left-0 w-6 h-6 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center"
|
|
onClick={() => {
|
|
const dayData = getDayData(d as number);
|
|
dispatch({
|
|
type: "SHOW_LIST_VIEW",
|
|
payload: {
|
|
day: d as number,
|
|
data: dayData,
|
|
type: "obligation",
|
|
},
|
|
});
|
|
// Scroll to list view after a brief delay to ensure it's rendered
|
|
setTimeout(() => {
|
|
const listView = document.getElementById(
|
|
"list-view",
|
|
);
|
|
if (listView) {
|
|
listView.scrollIntoView({
|
|
behavior: "smooth",
|
|
block: "start",
|
|
});
|
|
}
|
|
}, 100);
|
|
}}
|
|
>
|
|
-{dayNegativeMarkers[d as number]}
|
|
</button>
|
|
)}
|
|
<div
|
|
id="day-number"
|
|
class="w-full font-bold theme-calendar-day"
|
|
>
|
|
{d}
|
|
</div>
|
|
{dayPositiveMarkers[d as number] > 0 && (
|
|
<button
|
|
id="button-positive"
|
|
type="button"
|
|
class="absolute right-0 w-6 h-6 rounded-full bg-green-500 text-white text-xs font-bold flex items-center justify-center"
|
|
onClick={() => {
|
|
const dayData = getDayData(d as number);
|
|
dispatch({
|
|
type: "SHOW_LIST_VIEW",
|
|
payload: {
|
|
day: d as number,
|
|
data: dayData,
|
|
type: "income",
|
|
},
|
|
});
|
|
// Scroll to list view after a brief delay to ensure it's rendered
|
|
setTimeout(() => {
|
|
const listView = document.getElementById(
|
|
"list-view",
|
|
);
|
|
if (listView) {
|
|
listView.scrollIntoView({
|
|
behavior: "smooth",
|
|
block: "start",
|
|
});
|
|
}
|
|
}, 100);
|
|
}}
|
|
>
|
|
+{dayPositiveMarkers[d as number]}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{(() => {
|
|
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
|
|
? (
|
|
<div
|
|
id="day-change"
|
|
class={`w-full h-[16px] text-xs font-mono ${
|
|
dayChange >= 0
|
|
? "calendar-change-positive"
|
|
: "calendar-change-negative"
|
|
}`}
|
|
>
|
|
{dayChange >= 0 ? "+" : ""}${dayChange
|
|
.toFixed(2)}
|
|
</div>
|
|
)
|
|
: (
|
|
<div
|
|
id="day-change"
|
|
class={`w-full h-[16px] text-xs font-mono`}
|
|
>
|
|
</div>
|
|
);
|
|
})()}
|
|
{dailyBalances[d as number] !== undefined && (
|
|
<div
|
|
id="daily-balance"
|
|
class={"w-full text-sm " +
|
|
(dailyBalances[d as number] >= 0
|
|
? "calendar-balance-positive"
|
|
: "calendar-balance-negative") +
|
|
" font-mono mt-1"}
|
|
>
|
|
${dailyBalances[d as number].toFixed(2)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
: null}
|
|
</td>
|
|
));
|
|
})()}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div id="list-view" class="w-[500px] mx-auto">
|
|
{/* List View */}
|
|
{state.showListView
|
|
? (
|
|
<div class="mt-4 mx-auto">
|
|
<div class="flex justify-between w-[450px] mx-auto items-center mb-2">
|
|
<h3 class="text-xl font-bold theme-calendar-title">
|
|
{state.listViewType === "income" ? "Incomes" : "Obligations"}
|
|
{" "}
|
|
for Day {state.selectedDay}
|
|
</h3>
|
|
<button
|
|
type="button"
|
|
onClick={() => dispatch({ type: "HIDE_LIST_VIEW" })}
|
|
class="px-3 py-1 rounded theme-calendar-close-button"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
|
|
<div class="border rounded-lg theme-calendar-list-container">
|
|
{state.listViewType === "income"
|
|
? (
|
|
<div>
|
|
{state.selectedDayData.incomes.length === 0
|
|
? (
|
|
<div class="p-4 text-center theme-calendar-list-empty">
|
|
No incomes for this day
|
|
</div>
|
|
)
|
|
: (
|
|
<div class="divide-y divide-inherit">
|
|
{state.selectedDayData.incomes.map((
|
|
income,
|
|
index,
|
|
) => (
|
|
<div
|
|
key={income.id || index}
|
|
class="p-4 flex justify-between items-center"
|
|
>
|
|
<div>
|
|
<div class="font-medium calendar-income-text">
|
|
{income.source || "Income"}
|
|
</div>
|
|
<div class="text-sm theme-calendar-list-frequency">
|
|
Frequency: {income.frequency || "One-time"}
|
|
</div>
|
|
</div>
|
|
<div class="text-lg font-bold calendar-income-text">
|
|
+${income.amount?.toFixed(2) || "0.00"}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
: (
|
|
<div>
|
|
{state.selectedDayData.obligations.length === 0
|
|
? (
|
|
<div class="p-4 text-center theme-calendar-list-empty">
|
|
No obligations for this day
|
|
</div>
|
|
)
|
|
: (
|
|
<div class="divide-y divide-inherit">
|
|
{state.selectedDayData.obligations.map((
|
|
obligation,
|
|
index,
|
|
) => (
|
|
<div
|
|
key={obligation.id || index}
|
|
class="p-4 flex justify-between items-center"
|
|
>
|
|
<div>
|
|
<div class="font-medium calendar-obligation-text">
|
|
{obligation.description || "Obligation"}
|
|
</div>
|
|
<div class="text-sm theme-calendar-list-frequency">
|
|
Frequency:{" "}
|
|
{obligation.frequency || "One-time"}
|
|
</div>
|
|
</div>
|
|
<div class="text-lg font-bold calendar-obligation-text">
|
|
-${obligation.amount?.toFixed(2) || "0.00"}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
: (
|
|
// Placeholder div to prevent layout shift
|
|
<div class="mt-4 mx-auto">
|
|
<div class="w-[450px] mx-auto mb-2 calendar-placeholder-title">
|
|
</div>
|
|
<div class="bg-transparent border-transparent rounded-lg calendar-placeholder-body">
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div aria-hidden="true" class="h-28"></div>
|
|
<div class="fixed bottom-4 inset-x-0 flex justify-center z-40 pointer-events-none">
|
|
<div class="flex gap-4 w-11/12 max-w-2xl pointer-events-auto">
|
|
<a
|
|
href="/calendar/budget-obligations?modal=off"
|
|
class="flex-1 calendar-action-link calendar-action-link--obligation"
|
|
>
|
|
Manage Obligations
|
|
</a>
|
|
<a
|
|
href="/calendar/every-income?modal=off"
|
|
class="flex-1 calendar-action-link calendar-action-link--income"
|
|
>
|
|
View Incomes
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|