More work on budget details page

This commit is contained in:
William Brawner 2024-04-05 20:27:19 -06:00
parent 7e49dbeb31
commit 978de59d36
17 changed files with 328 additions and 74 deletions

View file

@ -9,4 +9,9 @@ interface Page {
interface AuthenticatedPage : Page { interface AuthenticatedPage : Page {
val user: UserResponse val user: UserResponse
}
object NotFoundPage : Page {
override val title: String = "404 Not Found"
override val error: String? = null
} }

View file

@ -1,6 +1,7 @@
package com.wbrawner.twigs.web package com.wbrawner.twigs.web
import com.wbrawner.twigs.model.CookieSession import com.wbrawner.twigs.model.CookieSession
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.service.budget.BudgetService import com.wbrawner.twigs.service.budget.BudgetService
import com.wbrawner.twigs.service.category.CategoryService import com.wbrawner.twigs.service.category.CategoryService
import com.wbrawner.twigs.service.transaction.TransactionService import com.wbrawner.twigs.service.transaction.TransactionService
@ -24,7 +25,13 @@ fun Application.webRoutes(
staticResources("/", "static") staticResources("/", "static")
get("/") { get("/") {
call.sessions.get(CookieSession::class) call.sessions.get(CookieSession::class)
?.let { userService.session(it.token) } ?.let {
try {
userService.session(it.token)
} catch (e: HttpException) {
null
}
}
?.let { session -> ?.let { session ->
application.environment.log.info("Session found!") application.environment.log.info("Session found!")
budgetService.budgetsForUser(session.userId) budgetService.budgetsForUser(session.userId)

View file

@ -2,12 +2,11 @@ package com.wbrawner.twigs.web.budget
import com.wbrawner.twigs.service.budget.BudgetResponse import com.wbrawner.twigs.service.budget.BudgetResponse
import com.wbrawner.twigs.service.category.CategoryResponse 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.service.user.UserResponse
import com.wbrawner.twigs.web.AuthenticatedPage import com.wbrawner.twigs.web.AuthenticatedPage
data class BudgetListPage( data class BudgetListPage(
val budgets: List<BudgetResponse>, val budgets: List<BudgetListItem>,
override val user: UserResponse, override val user: UserResponse,
override val error: String? = null override val error: String? = null
) : AuthenticatedPage { ) : AuthenticatedPage {
@ -15,17 +14,26 @@ data class BudgetListPage(
} }
data class BudgetDetailsPage( data class BudgetDetailsPage(
val budgets: List<BudgetListItem>,
val budget: BudgetResponse, val budget: BudgetResponse,
val balance: BalanceResponse, val balances: BudgetBalances,
val categories: List<CategoryWithBalanceResponse>, val incomeCategories: List<CategoryWithBalanceResponse>,
val archivedCategories: List<CategoryWithBalanceResponse>, val expenseCategories: List<CategoryWithBalanceResponse>,
val transactionCount: Long, val archivedIncomeCategories: List<CategoryWithBalanceResponse>,
val archivedExpenseCategories: List<CategoryWithBalanceResponse>,
val transactionCount: String,
override val user: UserResponse, override val user: UserResponse,
override val error: String? = null override val error: String? = null
) : AuthenticatedPage { ) : AuthenticatedPage {
override val title: String = "Budgets" 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( data class BudgetFormPage(

View file

@ -8,10 +8,10 @@ import com.wbrawner.twigs.service.budget.BudgetResponse
import com.wbrawner.twigs.service.budget.BudgetService import com.wbrawner.twigs.service.budget.BudgetService
import com.wbrawner.twigs.service.category.CategoryService import com.wbrawner.twigs.service.category.CategoryService
import com.wbrawner.twigs.service.requireSession 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.transaction.TransactionService
import com.wbrawner.twigs.service.user.UserService import com.wbrawner.twigs.service.user.UserService
import com.wbrawner.twigs.toInstantOrNull import com.wbrawner.twigs.toInstantOrNull
import com.wbrawner.twigs.web.NotFoundPage
import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
import io.ktor.http.* import io.ktor.http.*
import io.ktor.server.application.* import io.ktor.server.application.*
@ -21,6 +21,11 @@ import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.util.* 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( fun Application.budgetWebRoutes(
budgetService: BudgetService, budgetService: BudgetService,
@ -33,7 +38,7 @@ fun Application.budgetWebRoutes(
route("/budgets") { route("/budgets") {
get { get {
val user = userService.user(requireSession().userId) 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))) call.respond(MustacheContent("budgets.mustache", BudgetListPage(budgets, user)))
} }
@ -84,33 +89,73 @@ fun Application.budgetWebRoutes(
} }
route("/{id}") { route("/{id}") {
get { get {
val user = userService.user(requireSession().userId) val user = userService.user(requireSession().userId)
val budget = budgetService.budget(budgetId = call.parameters.getOrFail("id"), userId = user.id) val budgetId = call.parameters.getOrFail("id")
val balance = BalanceResponse(transactionService.sum(budgetId = budget.id, userId = user.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) val categories = categoryService.categories(budgetIds = listOf(budget.id), userId = user.id)
.map { category -> .map { category ->
val categoryBalance =
abs(transactionService.sum(categoryId = category.id, userId = user.id))
BudgetDetailsPage.CategoryWithBalanceResponse( BudgetDetailsPage.CategoryWithBalanceResponse(
category, category = category,
BalanceResponse(transactionService.sum(categoryId = category.id, userId = user.id)) 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( val transactions = transactionService.transactions(
budgetIds = listOf(budget.id), budgetIds = listOf(budget.id),
from = call.parameters["from"]?.toInstantOrNull() ?: firstOfMonth, from = call.parameters["from"]?.toInstantOrNull() ?: firstOfMonth,
to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth, to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth,
userId = user.id 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( call.respond(
MustacheContent( MustacheContent(
"budget-details.mustache", BudgetDetailsPage( "budget-details.mustache", BudgetDetailsPage(
budgets = budgets.map { it.toBudgetListItem(budgetId) }.sortedBy { it.name },
budget = budget, budget = budget,
balance = balance, balances = balances,
categories = categories.filter { !it.category.archived }, incomeCategories = incomeCategories,
archivedCategories = categories.filter { it.category.archived }, archivedIncomeCategories = archivedIncomeCategories,
transactionCount = transactions.size.toLong(), expenseCategories = expenseCategories,
archivedExpenseCategories = archivedExpenseCategories,
transactionCount = NumberFormat.getNumberInstance(Locale.US)
.format(transactions.size),
user = user 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( private fun Parameters.toBudgetRequest() = BudgetRequest(
name = get("name"), name = get("name"),
description = get("description"), description = get("description"),
users = setOf() // TODO: Enable adding users at budget creation users = setOf() // TODO: Enable adding users at budget creation
) )
private fun <T> MutableCollection<T>.extractIf(predicate: (T) -> Boolean): List<T> {
val extracted = mutableListOf<T>()
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)
)

View file

@ -1,3 +1,7 @@
* {
transition: 0.25s ease;
}
html, body { html, body {
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -30,15 +34,75 @@ html, body {
body { body {
background-image: linear-gradient(var(--background-color-primary), var(--background-color-secondary)); background-image: linear-gradient(var(--background-color-primary), var(--background-color-secondary));
background-attachment: fixed;
height: 100vh; height: 100vh;
width: 100vw; 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 { #app {
box-sizing: border-box;
display: flex;
flex-direction: row;
height: 100%; height: 100%;
width: 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 { .button {
border: 1px solid var(--color-accent); border: 1px solid var(--color-accent);
border-radius: var(--border-radius); border-radius: var(--border-radius);
@ -75,6 +139,7 @@ body {
.logo { .logo {
background-image: var(--logo); background-image: var(--logo);
background-size: contain; background-size: contain;
background-repeat: no-repeat;
height: 200px; height: 200px;
width: 200px; width: 200px;
} }
@ -103,6 +168,34 @@ a {
color: var(--color-accent); 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) { @media all and (max-width: 400px) {
button { button {
width: 100%; width: 100%;

View file

@ -0,0 +1,8 @@
{{> partials/head }}
<div id="app">
<div class="center">
<div class="logo"></div>
<p>Looks like you got a little lost in the woods. Take a few steps back and try again!</p>
</div>
</div>
{{>partials/foot}}

View file

@ -1,14 +1,65 @@
<!DOCTYPE html>
<html>
{{> partials/head }} {{> partials/head }}
<body>
<div id="app"> <div id="app">
<div class="center"> {{>partials/sidebar}}
<div class="flex-full-width"> <main>
{{>partials/budget-list}} <h1>{{budget.name}}</h1>
<p>{{budget.description}}</p>
<div class="row">
<div class="stacked-label">
<p class="body-small">Month</p>
<p class="body-large">TODO</p>
</div>
<div class="stacked-label">
<p class="body-small">Cash Flow</p>
<p class="body-large">{{balances.cashFlow}}</p>
</div>
<div class="stacked-label">
<p class="body-small">Transactions</p>
<p class="body-large">{{transactionCount}}</p>
</div>
</div> </div>
</div> <div class="card">
<h3>Income</h3>
<div class="column">
<p>{{balances.actualIncomeLabel}} earned of {{balances.expectedIncomeLabel}} expected</p>
<progress style="margin: 0 0.5rem;"
value="{{balances.actualIncome}}"
max="{{balances.maxProgressBarValue}}">{{balances.actualIncomeLabel}}</progress>
</div>
<ul>
{{#incomeCategories}}
{{>partials/category-list}}
{{/incomeCategories}}
</ul>
<details>
<summary>Archived</summary>
<ul>
{{#archivedIncomeCategories}}
{{>partials/category-list}}
{{/archivedIncomeCategories}}
</ul>
</details>
<h3>Expenses</h3>
<div class="column">
<p>{{balances.actualExpensesLabel}} spent of {{balances.expectedExpensesLabel}} expected</p>
<progress style="margin: 0 0.5rem;"
value="{{balances.actualExpenses}}"
max="{{balances.maxProgressBarValue}}">{{balances.actualExpensesLabel}}</progress>
</div>
<ul>
{{#expenseCategories}}
{{>partials/category-list}}
{{/expenseCategories}}
</ul>
<details>
<summary>Archived</summary>
<ul>
{{#archivedExpenseCategories}}
{{>partials/category-list}}
{{/archivedExpenseCategories}}
</ul>
</details>
</div>
</main>
</div> </div>
{{>partials/foot}} {{>partials/foot}}
</body>
</html>

View file

@ -1,7 +1,4 @@
<!DOCTYPE html>
<html>
{{> partials/head }} {{> partials/head }}
<body>
<div id="app"> <div id="app">
<h1>{{title}}</h1> <h1>{{title}}</h1>
<div class="center"> <div class="center">
@ -18,5 +15,3 @@
</div> </div>
</div> </div>
{{>partials/foot}} {{>partials/foot}}
</body>
</html>

View file

@ -1,7 +1,4 @@
<!DOCTYPE html>
<html>
{{> partials/head }} {{> partials/head }}
<body>
<div id="app"> <div id="app">
<div class="center"> <div class="center">
<div class="flex-full-width"> <div class="flex-full-width">
@ -10,5 +7,3 @@
</div> </div>
</div> </div>
{{>partials/foot}} {{>partials/foot}}
</body>
</html>

View file

@ -1,7 +1,4 @@
<!DOCTYPE html>
<html>
{{> partials/head }} {{> partials/head }}
<body>
<div id="app"> <div id="app">
<div class="center"> <div class="center">
<div class="logo"></div> <div class="logo"></div>
@ -11,6 +8,4 @@
</div> </div>
</div> </div>
</div> </div>
{{>partials/foot}} {{>partials/foot}}
</body>
</html>

View file

@ -1,7 +1,4 @@
<!DOCTYPE html>
<html>
{{> partials/head }} {{> partials/head }}
<body>
<div id="app"> <div id="app">
<h1>{{title}}</h1> <h1>{{title}}</h1>
<div class="center"> <div class="center">
@ -20,6 +17,4 @@
<a href="/register" style="padding: var(--input-padding)">Create an account</a> <a href="/register" style="padding: var(--input-padding)">Create an account</a>
</div> </div>
</div> </div>
{{>partials/foot}} {{>partials/foot}}
</body>
</html>

View file

@ -1,8 +1,10 @@
<ul> <ul>
{{#budgets}} {{#budgets}}
<li> <li class="list-item">
<span class="budget-name">{{name}}</span> <a class="{{#selected}}selected{{/selected}}" href="/budgets/{{id}}">
<span class="budget-description">{{description}}</span> <span class="body-large">{{name}}</span>
<span class="body-small">{{description}}</span>
</a>
</li> </li>
{{/budgets}} {{/budgets}}
</ul> </ul>

View file

@ -0,0 +1,9 @@
<li class="list-item">
<a href="/categories/{{category.id}}">
<div class="row" style="justify-content: space-between">
<span class="body-large">{{category.title}}</span>
<span class="body-small">{{remainingAmountLabel}} remaining</span>
</div>
<progress value="{{balance}}" max="{{category.amount}}">{{balanceLabel}}</progress>
</a>
</li>

View file

@ -1 +1,3 @@
<script type="text/javascript" async src="/js/index.js"></script> <script type="text/javascript" async src="/js/index.js"></script>
</body>
</html>

View file

@ -1,13 +1,18 @@
<link rel="stylesheet" href="/style.css"/> <!DOCTYPE html>
<title>{{ title }}</title> <html lang="en-US"> <!-- TODO: Localization -->
<meta charset="utf-8"> <head>
<base href="/"> <link rel="stylesheet" href="/style.css"/>
<meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{ title }}</title>
<link rel="apple-touch-icon" sizes="180x180" href="/icons/touch-icon.png"> <meta charset="utf-8">
<link rel="icon" type="image/png" sizes="96x96" href="/icons/favicon-96x96.png"> <base href="/">
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png"> <link rel="apple-touch-icon" sizes="180x180" href="/icons/touch-icon.png">
<meta name="apple-mobile-web-app-title" content="Twigs"> <link rel="icon" type="image/png" sizes="96x96" href="/icons/favicon-96x96.png">
<meta name="application-name" content="Twigs"> <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png">
<meta name="theme-color" content="#FFFFFF"> <link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png">
<link rel="manifest" href="manifest.json"> <meta name="apple-mobile-web-app-title" content="Twigs">
<meta name="application-name" content="Twigs">
<meta name="theme-color" content="#FFFFFF">
<link rel="manifest" href="manifest.json">
</head>
<body>

View file

@ -0,0 +1,4 @@
<aside class="sidebar">
<div class="logo" style="height: 100px; width: 100px;"></div>
{{>budget-list}}
</aside>

View file

@ -1,7 +1,4 @@
<!DOCTYPE html>
<html>
{{> partials/head }} {{> partials/head }}
<body>
<div id="app"> <div id="app">
<h1>{{title}}</h1> <h1>{{title}}</h1>
<div class="center"> <div class="center">
@ -23,6 +20,4 @@
<a href="/login" style="padding: var(--input-padding)">Login</a> <a href="/login" style="padding: var(--input-padding)">Login</a>
</div> </div>
</div> </div>
{{>partials/foot}} {{>partials/foot}}
</body>
</html>