Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8c97f71
chore: initialize npm and install jest
Apr 21, 2026
c1cdedd
feat: add English and Chinese language switcher
Apr 25, 2026
64ed9e1
feat: add three-language selector
Apr 25, 2026
d113b09
add budget.test for test
0902young Apr 27, 2026
dc910ef
ci: add GitHub Actions workflow for Jest and Codecov
0902young Apr 27, 2026
d1dcb5b
ci: trigger codecov upload
0902young Apr 27, 2026
cdc523a
Update ci.yml
0902young Apr 27, 2026
bc68e83
Update README.md
0902young Apr 27, 2026
191721d
Merge branch 'master' into improve-tests
0902young May 1, 2026
695e50e
fix: clean package.json remove conflict markers
0902young May 1, 2026
8baf979
Merge pull request #1 from tonglynn/improve-tests
0902young May 1, 2026
7597e17
Merge branch 'master' into add-i18n
zhihuangzheng22-spec May 1, 2026
d90a3da
Merge pull request #2 from tonglynn/add-i18n
zhihuangzheng22-spec May 1, 2026
fb4360b
Update budget.js
0902young May 2, 2026
f6e0938
Update budget.js
0902young May 2, 2026
581c5bb
Merge branch 'master' into improve-tests
0902young May 2, 2026
2f66d5a
Merge pull request #4 from tonglynn/improve-tests
0902young May 2, 2026
c2181f8
Update budget.test.js
0902young May 2, 2026
89cd9d8
Merge pull request #5 from tonglynn/improve-tests
0902young May 2, 2026
ff59ed8
fix: add budget entry input validation
May 2, 2026
b2fe424
Merge branch 'master' into fix-budget-entry-validation
zhihuangzheng22-spec May 2, 2026
b8a52ee
Merge pull request #6 from tonglynn/fix-budget-entry-validation
zhihuangzheng22-spec May 2, 2026
cd48df8
fix: add CSP COOP XFO security headers
May 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
The diff you're trying to view is too large. We only load the first 3000 changed files.
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: CI – Test & Coverage

on:
push:
branches: ["**"]
pull_request:
branches: [main, master]

jobs:
test:
name: Jest + Codecov
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"

- name: Install dependencies
run: npm install

- name: Run tests with coverage
run: npm test -- --coverage --coverageReporters=lcov --coverageReporters=text

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
flags: unittests
name: budget-app-coverage
fail_ci_if_error: false
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
coverage/
.DS_Store
Thumbs.db
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[![codecov](https://codecov.io/gh/tonglynn/Budget-app/graph/badge.svg?token=B1EX1SFYTG)](https://codecov.io/gh/tonglynn/Budget-app)
# Budget-App-JavaScript

Welcome to the Budget App! This project is the result of following a comprehensive YouTube tutorial that guides you through building a budget management application from scratch. With this app, you can efficiently track your income, expenses, and overall budget, gaining better control of your financial situation.
Expand Down
201 changes: 189 additions & 12 deletions budget.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ const expenseBtn = document.querySelector(".first-tab");
const incomeBtn = document.querySelector(".second-tab");
const allBtn = document.querySelector(".third-tab");

//LANGUAGE SELECT
const langSelect = document.getElementById("lang-select");

//INPUT BTS
const addExpense = document.querySelector(".add-expense");
const expenseTitle = document.getElementById("expense-title-input");
Expand All @@ -23,17 +26,61 @@ const addIncome = document.querySelector(".add-income");
const incomeTitle = document.getElementById("income-title-input");
const incomeAmount = document.getElementById("income-amount-input");

// I18N TRANSLATIONS
const translations = {
en: {
languageLabel: "Language",
balance: "Balance",
income: "Income",
outcome: "Outcome",
dashboard: "Dashboard",
expenses: "Expenses",
all: "All",
titlePlaceholder: "title",
titleRequired: "Please enter a title.",
amountPositiveRequired: "Please enter a positive amount.",
},
zh: {
languageLabel: "语言选择",
balance: "余额",
income: "收入",
outcome: "支出",
dashboard: "总览",
expenses: "支出",
all: "全部",
titlePlaceholder: "标题",
titleRequired: "请输入标题。",
amountPositiveRequired: "请输入大于 0 的金额。",
},
ja: {
languageLabel: "言語選択",
balance: "残高",
income: "収入",
outcome: "支出",
dashboard: "概要",
expenses: "支出",
all: "すべて",
titlePlaceholder: "タイトル",
titleRequired: "タイトルを入力してください。",
amountPositiveRequired: "0より大きい金額を入力してください。",
},
};

//VARIABLES
let ENTRY_LIST;
let balance = 0,
income = 0,
outcome = 0;

let currentLanguage = localStorage.getItem("language") || "en";

const DELETE = "delete",
EDIT = "edit";

// LOOK IF THERE IS DATA IN LOCAL STORAGE
ENTRY_LIST = JSON.parse(localStorage.getItem("entry_list")) || [];
updateUI();
applyLanguage(currentLanguage);

//EVENT LISTENERS
expenseBtn.addEventListener("click", function () {
Expand All @@ -42,12 +89,14 @@ expenseBtn.addEventListener("click", function () {
active(expenseBtn);
inactive([incomeBtn, allBtn]);
});

incomeBtn.addEventListener("click", function () {
show(incomeEl);
hide([expenseEl, allEl]);
active(incomeBtn);
inactive([expenseBtn, allBtn]);
});

allBtn.addEventListener("click", function () {
show(allEl);
hide([incomeEl, expenseEl]);
Expand All @@ -56,31 +105,39 @@ allBtn.addEventListener("click", function () {
});

addExpense.addEventListener("click", function () {
// CHECK IF ONE OF THE INPUT IS EMPTY => EXIT
if (!expenseTitle.value || !expenseAmount.value) return;
const validExpense = validateEntryInput(expenseTitle, expenseAmount);

if (!validExpense.isValid) {
showValidationMessage(validExpense.messageKey);
return;
}

// ADD INPUTs TO ENTRY_LIST
let expense = {
type: "expense",
title: expenseTitle.value,
amount: +expenseAmount.value,
title: validExpense.title,
amount: validExpense.amount,
};

ENTRY_LIST.push(expense);

updateUI();
clearInput([expenseTitle, expenseAmount]);
});

addIncome.addEventListener("click", function () {
// CHECK IF ONE OF THE INPUT IS EMPTY => EXIT
if (!incomeTitle.value || !incomeAmount.value) return;
const validIncome = validateEntryInput(incomeTitle, incomeAmount);

if (!validIncome.isValid) {
showValidationMessage(validIncome.messageKey);
return;
}

// ADD INPUTs TO ENTRY_LIST
let income = {
type: "income",
title: incomeTitle.value,
amount: +incomeAmount.value,
title: validIncome.title,
amount: validIncome.amount,
};

ENTRY_LIST.push(income);

updateUI();
Expand All @@ -91,7 +148,82 @@ incomeList.addEventListener("click", deleteOrEdit);
expenseList.addEventListener("click", deleteOrEdit);
allList.addEventListener("click", deleteOrEdit);

// HELEPER FUNCS
if (langSelect) {
langSelect.addEventListener("change", function () {
applyLanguage(langSelect.value);
});
}

// I18N FUNCTION
function applyLanguage(language) {
if (!translations[language]) {
language = "en";
}

currentLanguage = language;
localStorage.setItem("language", language);

if (language === "zh") {
document.documentElement.lang = "zh-CN";
} else if (language === "ja") {
document.documentElement.lang = "ja";
} else {
document.documentElement.lang = "en";
}

document.querySelectorAll("[data-i18n]").forEach((element) => {
const key = element.getAttribute("data-i18n");

if (translations[language][key]) {
element.textContent = translations[language][key];
}
});

document.querySelectorAll("[data-i18n-placeholder]").forEach((element) => {
const key = element.getAttribute("data-i18n-placeholder");

if (translations[language][key]) {
element.setAttribute("placeholder", translations[language][key]);
}
});

if (langSelect) {
langSelect.value = language;
langSelect.setAttribute("aria-label", translations[language].languageLabel);
}
}

// HELPER FUNCS
function validateEntryInput(titleInput, amountInput) {
const title = titleInput.value.trim();
const amount = Number(amountInput.value);

if (!title) {
return {
isValid: false,
messageKey: "titleRequired",
};
}

if (!Number.isFinite(amount) || amount <= 0) {
return {
isValid: false,
messageKey: "amountPositiveRequired",
};
}

return {
isValid: true,
title: title,
amount: amount,
};
}

function showValidationMessage(messageKey) {
const messages = translations[currentLanguage] || translations.en;
alert(messages[messageKey] || translations.en[messageKey]);
}

function deleteOrEdit(event) {
const targetBtn = event.target;
const entry = targetBtn.parentNode;
Expand All @@ -118,6 +250,7 @@ function editEntry(entry) {
expenseTitle.value = ENTRY.title;
expenseAmount.value = ENTRY.amount;
}

deleteEntry(entry);
}

Expand All @@ -141,18 +274,33 @@ function updateUI() {
} else if (entry.type == "income") {
showEntry(incomeList, entry.type, entry.title, entry.amount, index);
}

showEntry(allList, entry.type, entry.title, entry.amount, index);
});

updateChart(income, outcome);
localStorage.setItem("entry_list", JSON.stringify(ENTRY_LIST));
}

// HELPER FUNC: Escape special HTML characters to prevent XSS attacks
function escapeHTML(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

function showEntry(list, type, title, amount, id) {
const safeTitle = escapeHTML(title);
const safeAmount = escapeHTML(amount);
const entry = `<li id="${id}" class="${type}">
<div class="entry">${title} : $${amount}</div>
<div class="entry">${safeTitle} : $${safeAmount}</div>
<div id="edit"></div>
<div id="delete"></div>
</li>`;

const position = "afterbegin";
list.insertAdjacentHTML(position, entry);
}
Expand All @@ -165,17 +313,20 @@ function clearElement(elements) {

function calculateTotal(type, list) {
let sum = 0;

list.forEach((entry) => {
if (entry.type == type) {
sum += entry.amount;
}
});

return sum;
}

function calculateBalance(income, outcome) {
return income - outcome;
}

function clearInput(inputs) {
inputs.forEach((input) => {
input.value = "";
Expand All @@ -195,8 +346,34 @@ function hide(elements) {
function active(element) {
element.classList.add("focus");
}

function inactive(elements) {
elements.forEach((element) => {
element.classList.remove("focus");
});
}

// ── Exports for testing ──
if (typeof module !== "undefined") {
module.exports = {
calculateTotal,
calculateBalance,
show,
hide,
active,
inactive,
clearElement,
clearInput,
showEntry,
deleteEntry,
editEntry,
validateEntryInput,
escapeHTML,
get ENTRY_LIST() {
return ENTRY_LIST;
},
set ENTRY_LIST(v) {
ENTRY_LIST = v;
},
};
}
Loading