[JavaScript Toy Project] Expense Tracker | 가계부 앱 '작심소비' 토이프로젝트

이은진·2020년 11월 15일
60

수입과 지출을 관리할 수 있는 가계부 앱 '작심소비'를 기획해 보았습니다. 아이폰 X 사이즈로 작업하였고 실제로 출시할 앱이라고 생각하면서 UI/UX 기획과 디자인 및 코딩을 진행했습니다. 수입과 지출을 입력하면 내역을 한눈에 보고 거래 내역을 합산해 주는 간단한 기능을 가지고 있습니다. 지금까지 공부한 바닐라JS, CSS의 대부분의 내용을 적용하였습니다.

Contents

  1. 앱 UI/UX 소개
  2. 주요 기능들
    2-1. 수입, 지출 계산하기
    2-2. 필터링하기
    2-3. 날짜별로 정렬하기
    2-4. 로컬스토리지 저장
  3. 디테일
    3-1. 디자인 theme
    3-2. 입력칸 자동으로 이동하기
    3-3. 버튼 클릭해서 창 열고 닫기
    3-4. 마우스 오버 시 버튼 보이기
    3-5. 폼 입력값 자르고 붙이기
  4. 개선할 점

1. Expense Tracker UI/UX

메인 화면에서 본인의 한 달 소비 내역을 한눈에 볼 수 있습니다. 거래 내역은 날짜와 내용, 금액으로 이루어져 있고 수입만 보기, 지출만 보기가 가능합니다. 아래 버튼을 눌러 새로운 거래 내역을 추가할 수 있습니다. 등록과 동시에 날짜순으로 정렬된 결과를 확인할 수 있습니다. 수입은 초록색으로, 지출은 빨간색으로 표시하여 사용자가 지출 금액을 보고 경각심을 갖도록 유도했습니다. 불필요한 소비를 막고자 하는 가계부의 목적에 부합하도록 했습니다.

2. Main Features

2-1. Calculating incomes and expenses

들어온 돈(수입) 합계, 나간 돈(지출) 합계, 둘의 총합에 해당하는 DOM을 선택한 후, innerText 메서드를 이용해 간단히 값을 넣어 줬습니다. 거래 금액이 음수 또는 양수이기 때문에 array.filter()array.reduce() 메서드를 활용해 수입은 양수의 총합, 지출은 음수의 총합으로 계산해 주었습니다.

const balance = document.getElementById('balance')
const money_plus = document.getElementById('money-plus')
const money_minus = document.getElementById('money-minus')

const updateValues = () => {
  const amounts = transactions.map((transaction) => transaction.amount)
  const total = amounts
  	.reduce((acc, item) => (acc += item), 0)
  const income = amounts
    .filter((item) => item > 0)
    .reduce((acc, item) => (acc += item), 0)
  const expense = amounts
    .filter((item) => item < 0)
    .reduce((acc, item) => (acc += item), 0)

  balance.innerText = `${numberWithCommas(total)}`
  money_plus.innerText = `${numberWithCommas(income)}`
  money_minus.innerText = `-${numberWithCommas(Math.abs(expense))}`
}

2-2. Filtering by amount

작업 중 수입과 지출만을 보여주는 기능을 새로 추가하게 돼서 addTransactionDOM() 이외에 renderTransactions() 함수를 추가로 만들었습니다. DOM에 추가하는 트랜잭션들 조건대로 필터하느냐 그렇지 않느냐의 차이입니다.
수입 또는 지출을 눌러 필터된 값이 화면에 보이는 상태에서 새로 폼을 제출하면, 그 기능이 선택된 상태에서도 전체 거래 내역이 목록으로 뜨는 현상이 있었습니다. 이를 해결하기 위해 해당 결과 리스트의 innerHTML 값을 먼저 초기화시켜준 후, filter() 메서드로 incomeTransactions, expenseTransactions를 만들어서 각 조건에 따로따로 넣어 주었습니다.

const sort = document.getElementById('history-sort')
const sort_all = document.getElementById('history-sort__all')
const sort_plus = document.getElementById('history-sort__plus')
const sort_minus = document.getElementById('history-sort__minus')

//Add transactions to DOM list
const addTransactionDOM = (transaction) => {
  const sign = transaction.amount < 0 ? '-' : '+'
  const item = document.createElement('li')
  item.classList.add(transaction.amount < 0 ? 'minus' : 'plus')
  item.innerHTML = ''
  item.innerHTML = `
    <button id="delete-btn" class="delete-btn"token interpolation">${transaction.id})">삭제</button>
    <div class="transaction-list">
      <div class="transaction-list-info">
        <div class="transaction-date"> ${transaction.date.slice(0,4)}. ${transaction.date.slice(4,6)}. ${transaction.date.slice(6,8)}</div>
        <div class="transaction-text"> ${transaction.text} </div>
      </div>
      <span class="transaction-amount">${sign}${numberWithCommas(
      Math.abs(transaction.amount))}</span>
    </div> `
  list.appendChild(item)
}

//Rendering transactions conditionally
const renderTransactions = () => {
  if (sort_plus.classList.contains('sort')) {
    list.innerHTML = ''
    const incomeTransactions = transactions
    	.filter((transaction) => transaction.amount > 0)
    incomeTransactions
      	.forEach((transaction) => {addTransactionDOM(transaction)})
  } else if (sort_minus.classList.contains('sort')) {
    list.innerHTML = ''
    const expenseTransactions = transactions
    	.filter((transaction) => transaction.amount < 0)
    expenseTransactions
      	.forEach((transaction) => {addTransactionDOM(transaction)})
  } 
}

//Selecting menu
sort.addEventListener('click', (e) => {
  if (e.target === sort_plus) {
    sort_all.classList.remove('sort')
    sort_plus.classList.add('sort')
    sort_minus.classList.remove('sort')
    init()
  } else if (e.target === sort_minus) {
    sort_all.classList.remove('sort')
    sort_plus.classList.remove('sort')
    sort_minus.classList.add('sort')
    init()
  } else if (e.target === sort_all) {
    sort_all.classList.add('sort')
    sort_plus.classList.remove('sort')
    sort_minus.classList.remove('sort')
    init()
  }
})

2-3. Sorting by dates


폼으로 날짜, 내용, 금액 데이터를 전달하는 함수 addTransaction을 만들어서 transaction 배열 안으로 각 값을 넣어 주었습니다. 나중에 삭제할 수 있도록 Math.random() 메서드로 랜덤한 ID값을 만들어서 추가했습니다. 원래 금액 input 값으로 양수와 음수를 모두 쓰도록 했는데, 사용자가 직접 '-'를 치는 것이 불편할 거라고 판단하여 radio 선택지를 추가해 선택한 값에 따라 양수와 음수를 판단해 넣어주는 것으로 바꿨습니다. 폼을 제출하면 입력한 값이 모두 초기화되고 바로 로컬스토리지에 저장되도록 했습니다.

//Add transaction
const addTransaction = (e) => {
  e.preventDefault()
  //Generate random ID
  const generateID = () => Math.floor(Math.random() * 100000000)

  if (text.value.trim() === '' || amount.value.trim() === '') {
    alert('내역과 금액을 모두 입력해주세요.')
  } else {
    const sign = radio[0].checked === true ? '+' : '-'
    const transaction = {
      id: generateID(),
      text: text.value,
      amount: +`${sign}${amount.value}`,
      date: +`${yearInput.value}${monthInput.value}${dayInput.value}`
    }

    transactions.push(transaction) //add the transaction into the transactions array
    updateValues() // update the calculated transaction
    
    radio[0].checked = false
    radio[1].checked = false
    yearInput.value = ''
    monthInput.value = ''
    dayInput.value = ''
    text.value = ''
    amount.value = ''
    
    updateLocalStorage()
    init()
  }
}

거래 내역을 등록한 순서가 아니라 거래한 날짜 순서로 목록을 자동으로 정렬해서 보여주는 것이 좋을 것 같아 sortByDate() 함수를 새로 만들었습니다. 폼 제출 때마다, 새로고침할 때마다 이 함수가 실행됩니다.
초기에는 폼이 제출된 당시에는 거래내역 리스트 가장 하단에 날짜 상관 없이 추가되는 문제가 있었습니다. 이를 해결하기 위해 sortByDate() 함수를 폼 제출 시 실행되는 init() 함수 안에 제일 처음으로 넣어주었습니다. transactions.forEach()의 결과로 화면에 출력이 되는데 그 전에 먼저 transaction을 날짜 순서대로 나열해 주니 바로 해결됐습니다.


//Sort by date
const sortByDate = () => {
  return transactions.sort((a, b) => {
    if (a.date > b.date) {
      return 1
    } else if (a.date < b.date) {
      return -1
    } else {
      return 0
    }
  })
}

//Init app
const init = () => {
  sortByDate()
  list.innerHTML = ''
  transactions.forEach(addTransactionDOM)
  updateValues()
  renderTransactions()
  yearInput.value = ''
  monthInput.value = ''
  dayInput.value = ''
  radio[0].checked = false
  radio[1].checked = false
}

form.addEventListener('submit', addTransaction)

init()

2-4. Saving data in local storage


updateLocalstorage() 함수를 만들어서 transaction이라는 이름으로 로컬스토리지에 제출한 데이터가 저장되도록 했습니다. 폼을 제출할 때에도, 내역을 삭제할 때도 이 함수가 호출되어 화면상에 보이는 데이터와 저장된 데이터가 sync 되도록 했습니다.

//Getting data from local storage
const localStorageTransactions = JSON.parse(localStorage.getItem('transactions'))
let transactions =
  localStorage.getItem('transactions') !== null ? localStorageTransactions : []

//Update local storage transactions
const updateLocalStorage = () => {
  localStorage.setItem('transactions', JSON.stringify(transactions))
}

//Remove transaction by ID
const removeTransaction = (id) => {
  transactions = transactions.filter((transaction) => transaction.id !== id)
  init()
  updateLocalStorage()
}

3. Details

3-1. CSS design

뉴모피즘 디자인에 활용하는 css의 box-shadow 속성값을 활용하여 디바이스에 목업을 한 느낌을 주었습니다. container 안에 똑같은 사이즈의 inner-container를 만들어서 각각 그림자를 다른 방향에서 적용합니다.

.container {
  width: 375px;
  
  border-radius: 35px;
  box-shadow: 8px 8px 40px rgba(143, 143, 150, 0.55),
   	      -10px -10px 30px rgb(255, 255, 255, .8);

  margin: 30px auto;
}

.inner-container {
  width: 100%;
  height: 100%;

  background: #f5f5f6;

  border-radius: 35px;
  box-shadow: inset -12px -12px 16px rgb(174, 174, 192, 0.32),
   	      inset 14px 14px 12px rgba(255, 255, 255);

  padding: 0 22px 40px 22px;

  display: flex;
  flex-direction: column;
  align-items: flex-start;

  overflow: hidden;

}

3-2. Moving text focus to next input field

폼의 입력창이 많으면 사용자가 쉽게 지루함을 느낍니다. 이 앱의 경우 입력창이 많지는 않지만 사용자 편의를 고려해서, 날짜의 경우 이전 입력창에 다 입력하면 그 다음 창으로 자동으로 넘어가도록 만들어서 불필요한 손가락 움직임을 줄였습니다. 년, 월, 일 입력창에 각각 몇 자리까지 입력을 하면 넘어가는지 조건을 정하여 focus() 메서드로 다음 입력을 유도했습니다.

const yearInput = document.getElementById('year')
const monthInput = document.getElementById('month')
const dayInput = document.getElementById('day')

yearInput.addEventListener('keyup', () => {
  if (yearInput.value.length === 4) {
    monthInput.focus()
  }
})
monthInput.addEventListener('keyup', () => {
  if (monthInput.value.length === 2) {
    dayInput.focus()
  }
})

3-3. Event Listener for opening and closing element

DOM 엘리먼트의 클래스를 추가하여 새로운 거래 입력창을 열고 닫을 수 있도록 했습니다.

const new_transaction_container = document.getElementById('new-transaction-container')
const newBtn = document.getElementById('new-transaction-btn')
const backBtn = document.getElementById('back-btn')

newBtn.addEventListener('click', () => {
  new_transaction_container.classList.add('show')
})
backBtn.addEventListener('click', () => {
  new_transaction_container.classList.remove('show')
})

3-4. Hover to show deleting icon

마우스 오버 시 위에 있는 요소가 왼쪽으로 움직여서 숨어 있는 삭제 버튼을 클릭할 수 있도록 했습니다.

.list li {
  width: 100%;
  height: 64px;

  display: flex;
  justify-content: center;
  align-items: center;

  margin: 2px 0;

  position: relative;
}

.transaction-list {
  width: 100%;
  height: 64px;

  background: #fbfbfb;

  border-radius: 10px;
  border: 1px solid var(--border-color);

  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 14px;

  position: absolute;
}

.delete-btn {
  min-width: 51px;
  height: 51px;
  cursor: pointer;

  background: none;
  border: 0;
  border-radius: 5px;

  color: var(--red-color);
  font-size: 15px;
  font-weight: 500;

  margin-left: 5px;

  display: flex;
  justify-content: center;
  align-items: center;

  position: absolute;
  top: 0;
  right: 0;
  transform: translate(-4px, 7px);
}

.minus:hover .transaction-list,
.plus:hover .transaction-list{
  transform: translateX(-56px);
  transition: all ease-in-out .3s;
}

3-5. Managing input data

폼으로 전달된 값을 HTML 리스트 형태로 출력하는 코드입니다. 날짜 값의 경우 8자리 값으로 받아서 slice() 메서드로 잘라 필요한 형태로 만들어 주었습니다. Math.abs() 메서드를 이용하여 양수나 음수를 모두 양수로 바꾸어준 후, 기호를 따로 붙여 주었습니다.

const sign = transaction.amount < 0 ? '-' : '+'
const item = document.createElement('li')
item.classList.add(transaction.amount < 0 ? 'minus' : 'plus')
item.innerHTML = ''
item.innerHTML = `
  <button id="delete-btn" class="delete-btn"token interpolation">${transaction.id})">삭제</button>
  <div class="transaction-list">
    <div class="transaction-list-info">
        <div class="transaction-date"> 
${transaction.date.slice(0,4)}. ${transaction.date.slice(4,6)}. ${transaction.date.slice(6,8)}</div>
        <div class="transaction-text"> ${transaction.text} </div>
    </div>
    <span class="transaction-amount">${sign}${numberWithCommas(Math.abs(transaction.amount))}</span>
  </div>`
list.appendChild(item)

4. Things To Improve

4-1. Inefficient codes

코드를 작성하다가 방법을 잘 몰라 비효율적으로 작성된 부분이 몇 군데 있습니다. 특히 전체 내역과 수입 내역, 지출 내역 메뉴를 선택했을 때 클래스를 추가하고 삭제하는 방법을 아래와 같이 작성했는데, 의도대로 잘 돌아가기는 하지만 메뉴가 이것보다 많을 때는 이 방법을 쓰면 안될 것 같다고 생각했습니다..

sort.addEventListener('click', (e) => {
  if (e.target === sort_plus) {
    sort_all.classList.remove('sort')
    sort_plus.classList.add('sort')
    sort_minus.classList.remove('sort')
    init()
  } else if (e.target === sort_minus) {
    sort_all.classList.remove('sort')
    sort_plus.classList.remove('sort')
    sort_minus.classList.add('sort')
    init()
  } else if (e.target === sort_all) {
    sort_all.classList.add('sort')
    sort_plus.classList.remove('sort')
    sort_minus.classList.remove('sort')
    init()
  }
})

4-2. Transition doesn't work on hover off

메뉴에 마우스를 올리면 삭제 버튼이 나오는 부분에서, 마우스를 떼었을 때 트랜지션 효과가 나오지 않습니다. 부모에 속성을 주면 된다고 하는데 여전히 안 돼서 고민끝에 도무지 안 돼서 그냥 두었습니다.

/* 
.minus, .plus {
  transition: all ease-in-out .3s;
} */

.minus:hover .transaction-list,
.plus:hover .transaction-list{
  transform: translateX(-56px);
  transition: all ease-in-out .3s;
}
profile
빵굽는 프론트엔드 개발자

23개의 댓글

comment-user-thumbnail
2020년 11월 16일

제가 본 토이프로젝트 중 제일 멋있어요! 멋지십니다

1개의 답글
comment-user-thumbnail
2020년 11월 18일

잘 봤습니다 !

1개의 답글
comment-user-thumbnail
2020년 11월 19일

디자인 까지 잘하시네요 0_0

1개의 답글
comment-user-thumbnail
2020년 11월 24일

디자인 & UI이 정말 멋지고 기능도 너무 좋아보여요! 👍

1개의 답글
comment-user-thumbnail
2020년 11월 25일

🙀 깔끔하게 잘 만들었네요!! 혹시 소스코드 따로 올린곳이 있나요?

1개의 답글
comment-user-thumbnail
2020년 11월 30일

안녕하세요!
너무 멋진 프로젝트 소개글 잘읽었습니다!
리액트 네이티브 토이프로젝트로 가계부를 만드려고 하는데 eunjin님의
토이프로젝트 메인 디자인을조금 커스텀해서 사용하려는데 괜찮을까요?ㅠㅠ
eunjin님의 토이플로젝트를 보고 만들어 보고싶은 가계부의 기능들이 생각나 이렇게 댓글 남깁니다!

1개의 답글
comment-user-thumbnail
2020년 12월 7일

은진님 역시 능력자...!

1개의 답글
comment-user-thumbnail
2020년 12월 9일

디자인 너무 이쁘네요 !! 근데 궁금한게 로컬 db면 데이터가 유저가 캐쉬 날리지 않는 이상 계속 남아 있나요>?

1개의 답글
comment-user-thumbnail
2020년 12월 10일

우연히 들어왔다가 여러가지 토이프로젝트들을 보면서 정말 감탄하고 갑니다.
너무 대단하시네요!

1개의 답글