diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/Page.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/Page.kt index b3ef296..5c7dcc0 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/Page.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/Page.kt @@ -9,4 +9,9 @@ interface Page { interface AuthenticatedPage : Page { val user: UserResponse +} + +object NotFoundPage : Page { + override val title: String = "404 Not Found" + override val error: String? = null } \ No newline at end of file diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/WebRoutes.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/WebRoutes.kt index d84996c..bcb417c 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/WebRoutes.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/WebRoutes.kt @@ -1,6 +1,7 @@ package com.wbrawner.twigs.web import com.wbrawner.twigs.model.CookieSession +import com.wbrawner.twigs.service.HttpException import com.wbrawner.twigs.service.budget.BudgetService import com.wbrawner.twigs.service.category.CategoryService import com.wbrawner.twigs.service.transaction.TransactionService @@ -24,7 +25,13 @@ fun Application.webRoutes( staticResources("/", "static") get("/") { call.sessions.get(CookieSession::class) - ?.let { userService.session(it.token) } + ?.let { + try { + userService.session(it.token) + } catch (e: HttpException) { + null + } + } ?.let { session -> application.environment.log.info("Session found!") budgetService.budgetsForUser(session.userId) diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetPages.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetPages.kt index 4609fdc..70165e1 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetPages.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetPages.kt @@ -2,12 +2,11 @@ package com.wbrawner.twigs.web.budget import com.wbrawner.twigs.service.budget.BudgetResponse import com.wbrawner.twigs.service.category.CategoryResponse -import com.wbrawner.twigs.service.transaction.BalanceResponse import com.wbrawner.twigs.service.user.UserResponse import com.wbrawner.twigs.web.AuthenticatedPage data class BudgetListPage( - val budgets: List, + val budgets: List, override val user: UserResponse, override val error: String? = null ) : AuthenticatedPage { @@ -15,17 +14,26 @@ data class BudgetListPage( } data class BudgetDetailsPage( + val budgets: List, val budget: BudgetResponse, - val balance: BalanceResponse, - val categories: List, - val archivedCategories: List, - val transactionCount: Long, + val balances: BudgetBalances, + val incomeCategories: List, + val expenseCategories: List, + val archivedIncomeCategories: List, + val archivedExpenseCategories: List, + val transactionCount: String, override val user: UserResponse, override val error: String? = null ) : AuthenticatedPage { override val title: String = "Budgets" - data class CategoryWithBalanceResponse(val category: CategoryResponse, val balance: BalanceResponse) + data class CategoryWithBalanceResponse( + val category: CategoryResponse, + val amountLabel: String, + val balance: Long, + val balanceLabel: String, + val remainingAmountLabel: String, + ) } data class BudgetFormPage( diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetWebRoutes.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetWebRoutes.kt index 50a8fa2..3ccbd35 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetWebRoutes.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetWebRoutes.kt @@ -8,10 +8,10 @@ import com.wbrawner.twigs.service.budget.BudgetResponse import com.wbrawner.twigs.service.budget.BudgetService import com.wbrawner.twigs.service.category.CategoryService import com.wbrawner.twigs.service.requireSession -import com.wbrawner.twigs.service.transaction.BalanceResponse import com.wbrawner.twigs.service.transaction.TransactionService import com.wbrawner.twigs.service.user.UserService import com.wbrawner.twigs.toInstantOrNull +import com.wbrawner.twigs.web.NotFoundPage import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE import io.ktor.http.* import io.ktor.server.application.* @@ -21,6 +21,11 @@ import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.util.* +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.NumberFormat +import java.util.* +import kotlin.math.abs fun Application.budgetWebRoutes( budgetService: BudgetService, @@ -33,7 +38,7 @@ fun Application.budgetWebRoutes( route("/budgets") { get { val user = userService.user(requireSession().userId) - val budgets = budgetService.budgetsForUser(user.id) + val budgets = budgetService.budgetsForUser(user.id).map { it.toBudgetListItem() } call.respond(MustacheContent("budgets.mustache", BudgetListPage(budgets, user))) } @@ -84,33 +89,73 @@ fun Application.budgetWebRoutes( } route("/{id}") { - get { val user = userService.user(requireSession().userId) - val budget = budgetService.budget(budgetId = call.parameters.getOrFail("id"), userId = user.id) - val balance = BalanceResponse(transactionService.sum(budgetId = budget.id, userId = user.id)) + val budgetId = call.parameters.getOrFail("id") + val budgets = budgetService.budgetsForUser(userId = user.id).toMutableList() + val budget = budgets.firstOrNull { it.id == budgetId } + ?: run { + call.respond(MustacheContent("404.mustache", NotFoundPage)) + return@get + } + val numberFormat = NumberFormat.getCurrencyInstance(Locale.US) val categories = categoryService.categories(budgetIds = listOf(budget.id), userId = user.id) .map { category -> + val categoryBalance = + abs(transactionService.sum(categoryId = category.id, userId = user.id)) BudgetDetailsPage.CategoryWithBalanceResponse( - category, - BalanceResponse(transactionService.sum(categoryId = category.id, userId = user.id)) + category = category, + amountLabel = category.amount.toCurrencyString(numberFormat), + balance = categoryBalance, + balanceLabel = categoryBalance.toCurrencyString(numberFormat), + remainingAmountLabel = (category.amount - categoryBalance).toCurrencyString( + numberFormat + ) ) } - // TODO: Add a count method so we don't have to do this + .toMutableSet() + val incomeCategories = categories.extractIf { !it.category.expense && !it.category.archived } + val archivedIncomeCategories = + categories.extractIf { !it.category.expense && it.category.archived } + val expenseCategories = categories.extractIf { it.category.expense && !it.category.archived } + val archivedExpenseCategories = + categories.extractIf { it.category.expense && it.category.archived } val transactions = transactionService.transactions( budgetIds = listOf(budget.id), from = call.parameters["from"]?.toInstantOrNull() ?: firstOfMonth, to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth, userId = user.id ) + // TODO: Allow user-configurable locale + val budgetBalance = transactionService.sum(budgetId = budget.id, userId = user.id) + .toCurrencyString(numberFormat) + val expectedIncome = incomeCategories.sumOf { it.category.amount } + val actualIncome = transactions.sumOf { if (it.expense == false) it.amount ?: 0L else 0L } + val expectedExpenses = expenseCategories.sumOf { it.category.amount } + val actualExpenses = transactions.sumOf { if (it.expense == true) it.amount ?: 0L else 0L } + val balances = BudgetBalances( + cashFlow = budgetBalance, + expectedIncome = expectedIncome, + expectedIncomeLabel = expectedIncome.toCurrencyString(numberFormat), + actualIncome = actualIncome, + actualIncomeLabel = actualIncome.toCurrencyString(numberFormat), + expectedExpenses = expectedExpenses, + expectedExpensesLabel = expectedExpenses.toCurrencyString(numberFormat), + actualExpenses = actualExpenses, + actualExpensesLabel = actualExpenses.toCurrencyString(numberFormat), + ) call.respond( MustacheContent( "budget-details.mustache", BudgetDetailsPage( + budgets = budgets.map { it.toBudgetListItem(budgetId) }.sortedBy { it.name }, budget = budget, - balance = balance, - categories = categories.filter { !it.category.archived }, - archivedCategories = categories.filter { it.category.archived }, - transactionCount = transactions.size.toLong(), + balances = balances, + incomeCategories = incomeCategories, + archivedIncomeCategories = archivedIncomeCategories, + expenseCategories = expenseCategories, + archivedExpenseCategories = archivedExpenseCategories, + transactionCount = NumberFormat.getNumberInstance(Locale.US) + .format(transactions.size), user = user ) ) @@ -138,8 +183,48 @@ fun Application.budgetWebRoutes( } } +data class BudgetBalances( + val cashFlow: String, + val expectedIncome: Long, + val expectedIncomeLabel: String, + val actualIncome: Long, + val actualIncomeLabel: String, + val expectedExpenses: Long, + val expectedExpensesLabel: String, + val actualExpenses: Long, + val actualExpensesLabel: String, +) { + val maxProgressBarValue: Long = maxOf(expectedExpenses, expectedIncome, actualIncome, actualExpenses) +} + +data class BudgetListItem(val id: String, val name: String, val description: String, val selected: Boolean) + +private fun BudgetResponse.toBudgetListItem(selectedId: String? = null) = BudgetListItem( + id = id, + name = name.orEmpty(), + description = description.orEmpty(), + selected = id == selectedId +) + private fun Parameters.toBudgetRequest() = BudgetRequest( name = get("name"), description = get("description"), users = setOf() // TODO: Enable adding users at budget creation ) + +private fun MutableCollection.extractIf(predicate: (T) -> Boolean): List { + val extracted = mutableListOf() + val iterator = iterator() + while (iterator.hasNext()) { + val item = iterator.next() + if (predicate(item)) { + extracted.add(item) + iterator.remove() + } + } + return extracted +} + +private fun Long.toCurrencyString(formatter: NumberFormat): String = formatter.format( + this.toBigDecimal().divide(BigDecimal(100), 2, RoundingMode.HALF_UP) +) \ No newline at end of file diff --git a/web/src/main/resources/static/style.css b/web/src/main/resources/static/style.css index 75d9290..8ce3f75 100644 --- a/web/src/main/resources/static/style.css +++ b/web/src/main/resources/static/style.css @@ -1,3 +1,7 @@ +* { + transition: 0.25s ease; +} + html, body { margin: 0; padding: 0; @@ -30,15 +34,75 @@ html, body { body { background-image: linear-gradient(var(--background-color-primary), var(--background-color-secondary)); + background-attachment: fixed; height: 100vh; width: 100vw; } +h1, h2, h3, h4, h5, h6, p, ul { + margin: 0; + padding: 0; +} + +h1, h2, h3, h4, h5, h6, p, summary { + padding: 0.5rem; +} + #app { + box-sizing: border-box; + display: flex; + flex-direction: row; height: 100%; width: 100%; } +main { + height: 100%; + overflow-y: auto; + flex-grow: 1; +} + +.sidebar { + height: 100%; + width: 100%; + overflow-y: auto; + max-width: 300px; +} + +.stacked-label { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +@media all and (min-width: 1200px) { + .columns { + display: flex; + flex-direction: row; + } + + .columns > * { + flex-grow: 1; + } +} + +.column { + display: flex; + flex-direction: column; +} + +.row { + display: flex; + flex-direction: row; + justify-content: space-evenly; +} + +.card { + background: var(--background-color-primary); + border-radius: var(--border-radius); +} + .button { border: 1px solid var(--color-accent); border-radius: var(--border-radius); @@ -75,6 +139,7 @@ body { .logo { background-image: var(--logo); background-size: contain; + background-repeat: no-repeat; height: 200px; width: 200px; } @@ -103,6 +168,34 @@ a { color: var(--color-accent); } +.list-item { + list-style: none; +} + +.list-item > a { + display: flex; + flex-direction: column; + text-decoration: none; + border-radius: var(--border-radius); + padding: 0.5rem; +} + +.list-item > a:hover { + background-color: rgba(0, 0, 0, 0.2); +} + +.body-large { + font-size: 1.1rem; +} + +.body-medium { + font-size: 1rem; +} + +.body-small { + font-size: 0.9rem; +} + @media all and (max-width: 400px) { button { width: 100%; diff --git a/web/src/main/resources/templates/404.mustache b/web/src/main/resources/templates/404.mustache new file mode 100644 index 0000000..7925d36 --- /dev/null +++ b/web/src/main/resources/templates/404.mustache @@ -0,0 +1,8 @@ +{{> partials/head }} +
+
+ +

Looks like you got a little lost in the woods. Take a few steps back and try again!

+
+
+{{>partials/foot}} \ No newline at end of file diff --git a/web/src/main/resources/templates/budget-details.mustache b/web/src/main/resources/templates/budget-details.mustache index 86dac7d..42a7ec9 100644 --- a/web/src/main/resources/templates/budget-details.mustache +++ b/web/src/main/resources/templates/budget-details.mustache @@ -1,14 +1,65 @@ - - {{> partials/head }} -
-
-
- {{>partials/budget-list}} + {{>partials/sidebar}} +
+

{{budget.name}}

+

{{budget.description}}

+
+
+

Month

+

TODO

+
+
+

Cash Flow

+

{{balances.cashFlow}}

+
+
+

Transactions

+

{{transactionCount}}

+
-
+
+

Income

+
+

{{balances.actualIncomeLabel}} earned of {{balances.expectedIncomeLabel}} expected

+ {{balances.actualIncomeLabel}} +
+
    + {{#incomeCategories}} + {{>partials/category-list}} + {{/incomeCategories}} +
+
+ Archived +
    + {{#archivedIncomeCategories}} + {{>partials/category-list}} + {{/archivedIncomeCategories}} +
+
+

Expenses

+
+

{{balances.actualExpensesLabel}} spent of {{balances.expectedExpensesLabel}} expected

+ {{balances.actualExpensesLabel}} +
+
    + {{#expenseCategories}} + {{>partials/category-list}} + {{/expenseCategories}} +
+
+ Archived +
    + {{#archivedExpenseCategories}} + {{>partials/category-list}} + {{/archivedExpenseCategories}} +
+
+
+
-{{>partials/foot}} - - \ No newline at end of file +{{>partials/foot}} \ No newline at end of file diff --git a/web/src/main/resources/templates/budget-form.mustache b/web/src/main/resources/templates/budget-form.mustache index 67c2056..5dc031c 100644 --- a/web/src/main/resources/templates/budget-form.mustache +++ b/web/src/main/resources/templates/budget-form.mustache @@ -1,7 +1,4 @@ - - {{> partials/head }} -

{{title}}

@@ -18,5 +15,3 @@
{{>partials/foot}} - - \ No newline at end of file diff --git a/web/src/main/resources/templates/budgets.mustache b/web/src/main/resources/templates/budgets.mustache index 86dac7d..f973d29 100644 --- a/web/src/main/resources/templates/budgets.mustache +++ b/web/src/main/resources/templates/budgets.mustache @@ -1,7 +1,4 @@ - - {{> partials/head }} -
@@ -10,5 +7,3 @@
{{>partials/foot}} - - \ No newline at end of file diff --git a/web/src/main/resources/templates/index.mustache b/web/src/main/resources/templates/index.mustache index af7cb9b..8ea31d9 100644 --- a/web/src/main/resources/templates/index.mustache +++ b/web/src/main/resources/templates/index.mustache @@ -1,7 +1,4 @@ - - {{> partials/head }} -
@@ -11,6 +8,4 @@
-{{>partials/foot}} - - \ No newline at end of file +{{>partials/foot}} \ No newline at end of file diff --git a/web/src/main/resources/templates/login.mustache b/web/src/main/resources/templates/login.mustache index db92e57..4a2bc6e 100644 --- a/web/src/main/resources/templates/login.mustache +++ b/web/src/main/resources/templates/login.mustache @@ -1,7 +1,4 @@ - - {{> partials/head }} -

{{title}}

@@ -20,6 +17,4 @@ Create an account
-{{>partials/foot}} - - \ No newline at end of file +{{>partials/foot}} \ No newline at end of file diff --git a/web/src/main/resources/templates/partials/budget-list.mustache b/web/src/main/resources/templates/partials/budget-list.mustache index 3a749f2..ba86e32 100644 --- a/web/src/main/resources/templates/partials/budget-list.mustache +++ b/web/src/main/resources/templates/partials/budget-list.mustache @@ -1,8 +1,10 @@ diff --git a/web/src/main/resources/templates/partials/category-list.mustache b/web/src/main/resources/templates/partials/category-list.mustache new file mode 100644 index 0000000..0af4144 --- /dev/null +++ b/web/src/main/resources/templates/partials/category-list.mustache @@ -0,0 +1,9 @@ +
  • + +
    + {{category.title}} + {{remainingAmountLabel}} remaining +
    + {{balanceLabel}} +
    +
  • \ No newline at end of file diff --git a/web/src/main/resources/templates/partials/foot.mustache b/web/src/main/resources/templates/partials/foot.mustache index 5219243..72e9ad8 100644 --- a/web/src/main/resources/templates/partials/foot.mustache +++ b/web/src/main/resources/templates/partials/foot.mustache @@ -1 +1,3 @@ + + \ No newline at end of file diff --git a/web/src/main/resources/templates/partials/head.mustache b/web/src/main/resources/templates/partials/head.mustache index f502138..fe43970 100644 --- a/web/src/main/resources/templates/partials/head.mustache +++ b/web/src/main/resources/templates/partials/head.mustache @@ -1,13 +1,18 @@ - -{{ title }} - - - - - - - - - - - + + + + + {{ title }} + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/main/resources/templates/partials/sidebar.mustache b/web/src/main/resources/templates/partials/sidebar.mustache new file mode 100644 index 0000000..c1bfa43 --- /dev/null +++ b/web/src/main/resources/templates/partials/sidebar.mustache @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/web/src/main/resources/templates/register.mustache b/web/src/main/resources/templates/register.mustache index 1b62416..cec4e95 100644 --- a/web/src/main/resources/templates/register.mustache +++ b/web/src/main/resources/templates/register.mustache @@ -1,7 +1,4 @@ - - {{> partials/head }} -

    {{title}}

    @@ -23,6 +20,4 @@ Login
    -{{>partials/foot}} - - \ No newline at end of file +{{>partials/foot}} \ No newline at end of file