More progress on transaction editing, also removed some debug logging for hikari and set the docker compose file to use postgres

This commit is contained in:
William Brawner 2024-04-16 21:14:35 -06:00
parent 64e7cb9d52
commit 59aa48817a
12 changed files with 326 additions and 113 deletions

View file

@ -1,7 +1,6 @@
package com.wbrawner.twigs.server package com.wbrawner.twigs.server
import at.favre.lib.crypto.bcrypt.BCrypt import at.favre.lib.crypto.bcrypt.BCrypt
import ch.qos.logback.classic.Level
import com.github.mustachejava.DefaultMustacheFactory import com.github.mustachejava.DefaultMustacheFactory
import com.wbrawner.twigs.* import com.wbrawner.twigs.*
import com.wbrawner.twigs.db.* import com.wbrawner.twigs.db.*
@ -38,7 +37,6 @@ import io.ktor.server.response.*
import io.ktor.server.sessions.* import io.ktor.server.sessions.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
fun main() { fun main() {
@ -72,7 +70,6 @@ fun Application.module() {
throw RuntimeException("Unsupported DB type: $dbType") throw RuntimeException("Unsupported DB type: $dbType")
} }
} }
(LoggerFactory.getLogger("com.zaxxer.hikari") as ch.qos.logback.classic.Logger).level = Level.DEBUG
HikariDataSource(HikariConfig().apply { HikariDataSource(HikariConfig().apply {
setJdbcUrl(jdbcUrl) setJdbcUrl(jdbcUrl)
username = dbUser username = dbUser

View file

@ -9,6 +9,7 @@ services:
- db - db
environment: environment:
TWIGS_DB_HOST: db TWIGS_DB_HOST: db
TWIGS_DB_TYPE: postgresql
networks: networks:
- twigs - twigs

View file

@ -31,6 +31,7 @@ fun Application.webRoutes(
try { try {
userService.session(it.token) userService.session(it.token)
} catch (e: HttpException) { } catch (e: HttpException) {
application.environment.log.debug("Failed to retrieve session for user", e)
null null
} }
} }

View file

@ -24,8 +24,7 @@ data class TransactionFormPage(
val transaction: TransactionResponse, val transaction: TransactionResponse,
val amountLabel: String, val amountLabel: String,
val budget: BudgetResponse, val budget: BudgetResponse,
val incomeCategories: List<CategoryResponse>, val categoryOptions: List<CategoryOption>,
val expenseCategories: List<CategoryResponse>,
override val user: UserResponse, override val user: UserResponse,
override val error: String? = null override val error: String? = null
) : AuthenticatedPage { ) : AuthenticatedPage {
@ -34,4 +33,20 @@ data class TransactionFormPage(
} else { } else {
"Edit Transaction" "Edit Transaction"
} }
data class CategoryOption(
val id: String,
val title: String,
val isSelected: Boolean = false,
val isDisabled: Boolean = false
) {
val selected: String
get() = if (isSelected) "selected" else ""
val disabled: String
get() = if (isDisabled) "disabled" else ""
}
} }
fun CategoryResponse.asOption(selectedCategoryId: String) =
TransactionFormPage.CategoryOption(id, title, id == selectedCategoryId)

View file

@ -7,6 +7,7 @@ import com.wbrawner.twigs.service.requireSession
import com.wbrawner.twigs.service.transaction.TransactionRequest import com.wbrawner.twigs.service.transaction.TransactionRequest
import com.wbrawner.twigs.service.transaction.TransactionResponse import com.wbrawner.twigs.service.transaction.TransactionResponse
import com.wbrawner.twigs.service.transaction.TransactionService import com.wbrawner.twigs.service.transaction.TransactionService
import com.wbrawner.twigs.service.user.UserResponse
import com.wbrawner.twigs.service.user.UserService import com.wbrawner.twigs.service.user.UserService
import com.wbrawner.twigs.toInstant import com.wbrawner.twigs.toInstant
import com.wbrawner.twigs.web.NotFoundPage import com.wbrawner.twigs.web.NotFoundPage
@ -21,6 +22,7 @@ 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.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat import java.text.DecimalFormat
import java.text.NumberFormat import java.text.NumberFormat
import java.time.Instant import java.time.Instant
@ -36,6 +38,7 @@ private val decimalFormat = DecimalFormat.getNumberInstance(Locale.US).apply {
with(this as DecimalFormat) { with(this as DecimalFormat) {
decimalFormatSymbols = decimalFormatSymbols.apply { decimalFormatSymbols = decimalFormatSymbols.apply {
currencySymbol = "" currencySymbol = ""
isGroupingUsed = false
} }
} }
} }
@ -59,35 +62,25 @@ fun Application.transactionWebRoutes(
get { get {
val user = userService.user(requireSession().userId) val user = userService.user(requireSession().userId)
val budgetId = call.parameters.getOrFail("budgetId") val budgetId = call.parameters.getOrFail("budgetId")
val transaction = TransactionResponse(
id = "",
title = "",
description = "",
amount = 0,
budgetId = budgetId,
expense = true,
date = Instant.now().toHtmlInputString(),
categoryId = null,
createdBy = user.id
)
call.respond( call.respond(
MustacheContent( MustacheContent(
"transaction-form.mustache", "transaction-form.mustache",
TransactionFormPage( TransactionFormPage(
TransactionResponse( transaction = transaction,
id = "",
title = "",
description = "",
amount = 0,
budgetId = budgetId,
expense = true,
date = Instant.now().toHtmlInputString(),
categoryId = null,
createdBy = user.id
),
amountLabel = currencyFormat.format(0L), amountLabel = currencyFormat.format(0L),
budget = budgetService.budget(budgetId = budgetId, userId = user.id), budget = budgetService.budget(budgetId = budgetId, userId = user.id),
incomeCategories = categoryService.categories( categoryOptions = categoryOptions(transaction, categoryService, budgetId, user),
budgetIds = listOf(budgetId),
userId = user.id,
expense = false,
archived = false
),
expenseCategories = categoryService.categories(
budgetIds = listOf(budgetId),
userId = user.id,
expense = true,
archived = false
),
user = user user = user
) )
) )
@ -101,7 +94,7 @@ fun Application.transactionWebRoutes(
val request = call.receiveParameters().toTransactionRequest() val request = call.receiveParameters().toTransactionRequest()
.run { .run {
copy( copy(
date = date + 'Z', date = "$date:00Z",
expense = categoryService.category( expense = categoryService.category(
categoryId = requireNotNull(categoryId), categoryId = requireNotNull(categoryId),
userId = user.id userId = user.id
@ -112,35 +105,30 @@ fun Application.transactionWebRoutes(
val transaction = transactionService.save(request, user.id) val transaction = transactionService.save(request, user.id)
call.respondRedirect("/budgets/${transaction.budgetId}/transactions/${transaction.id}") call.respondRedirect("/budgets/${transaction.budgetId}/transactions/${transaction.id}")
} catch (e: HttpException) { } catch (e: HttpException) {
val transaction = TransactionResponse(
id = "",
title = call.parameters["title"],
description = call.parameters["description"],
amount = 0L,
budgetId = urlBudgetId,
expense = call.parameters["expense"]?.toBoolean() ?: true,
date = call.parameters["date"].orEmpty(),
categoryId = call.parameters["categoryId"],
createdBy = user.id
)
call.respond( call.respond(
status = e.statusCode, status = e.statusCode,
MustacheContent( MustacheContent(
"transaction-form.mustache", "transaction-form.mustache",
TransactionFormPage( TransactionFormPage(
TransactionResponse( transaction = transaction,
id = "",
title = call.parameters["title"],
description = call.parameters["description"],
amount = 0L,
budgetId = urlBudgetId,
expense = call.parameters["expense"]?.toBoolean() ?: true,
date = call.parameters["date"].orEmpty(),
categoryId = call.parameters["categoryId"],
createdBy = user.id
),
amountLabel = call.parameters["amount"].orEmpty(), amountLabel = call.parameters["amount"].orEmpty(),
budget = budgetService.budget(budgetId = urlBudgetId, userId = user.id), budget = budgetService.budget(budgetId = urlBudgetId, userId = user.id),
incomeCategories = categoryService.categories( categoryOptions = categoryOptions(
budgetIds = listOf(urlBudgetId), transaction,
userId = user.id, categoryService,
expense = false, urlBudgetId,
archived = false user
),
expenseCategories = categoryService.categories(
budgetIds = listOf(urlBudgetId),
userId = user.id,
expense = true,
archived = false
), ),
user = user, user = user,
error = e.message error = e.message
@ -197,8 +185,82 @@ fun Application.transactionWebRoutes(
} }
} }
post { route("/edit") {
get {
val user = userService.user(requireSession().userId)
val budgetId = call.parameters.getOrFail("budgetId")
val transaction = transactionService.transaction(
transactionId = call.parameters.getOrFail("id"),
userId = user.id
)
call.respond(
MustacheContent(
"transaction-form.mustache",
TransactionFormPage(
transaction = transaction.copy(
date = transaction.date.toInstant().toHtmlInputString()
),
amountLabel = transaction.amount.toDecimalString(),
budget = budgetService.budget(budgetId = budgetId, userId = user.id),
categoryOptions = categoryOptions(transaction, categoryService, budgetId, user),
user = user
)
)
)
}
post {
val user = userService.user(requireSession().userId)
val transactionId = call.parameters.getOrFail("id")
val urlBudgetId = call.parameters.getOrFail("budgetId")
try {
val request = call.receiveParameters().toTransactionRequest()
.run {
copy(
date = "$date:00Z",
expense = categoryService.category(
categoryId = requireNotNull(categoryId),
userId = user.id
).expense,
budgetId = urlBudgetId
)
}
val transaction =
transactionService.save(request, userId = user.id, transactionId = transactionId)
call.respondRedirect("/budgets/${transaction.budgetId}/transactions/${transaction.id}")
} catch (e: HttpException) {
val transaction = TransactionResponse(
id = transactionId,
title = call.parameters["title"],
description = call.parameters["description"],
amount = 0L,
budgetId = urlBudgetId,
expense = call.parameters["expense"]?.toBoolean() ?: true,
date = call.parameters["date"].orEmpty(),
categoryId = call.parameters["categoryId"],
createdBy = user.id
)
call.respond(
status = e.statusCode,
MustacheContent(
"transaction-form.mustache",
TransactionFormPage(
transaction = transaction,
amountLabel = call.parameters["amount"].orEmpty(),
budget = budgetService.budget(budgetId = urlBudgetId, userId = user.id),
categoryOptions = categoryOptions(
transaction,
categoryService,
urlBudgetId,
user
),
user = user,
error = e.message
)
)
)
}
}
} }
} }
} }
@ -206,6 +268,48 @@ fun Application.transactionWebRoutes(
} }
} }
private suspend fun categoryOptions(
transaction: TransactionResponse,
categoryService: CategoryService,
budgetId: String,
user: UserResponse
): List<TransactionFormPage.CategoryOption> {
val selectedCategoryId = transaction.categoryId.orEmpty()
val categoryOptions = listOf(
TransactionFormPage.CategoryOption(
"",
"Select a category",
isSelected = transaction.categoryId.isNullOrBlank(),
isDisabled = true
),
TransactionFormPage.CategoryOption("income", "Income", isDisabled = true),
)
.plus(
categoryService.categories(
budgetIds = listOf(budgetId),
userId = user.id,
expense = false,
archived = false
).map { category ->
category.asOption(selectedCategoryId)
}
)
.plus(
TransactionFormPage.CategoryOption("expense", "Expense", isDisabled = true),
)
.plus(
categoryService.categories(
budgetIds = listOf(budgetId),
userId = user.id,
expense = true,
archived = false
).map { category ->
category.asOption(selectedCategoryId)
}
)
return categoryOptions
}
private fun Parameters.toTransactionRequest() = TransactionRequest( private fun Parameters.toTransactionRequest() = TransactionRequest(
title = get("title"), title = get("title"),
description = get("description"), description = get("description"),
@ -216,4 +320,9 @@ private fun Parameters.toTransactionRequest() = TransactionRequest(
budgetId = get("budgetId"), budgetId = get("budgetId"),
) )
private fun Instant.toHtmlInputString() = truncatedTo(ChronoUnit.SECONDS).toString().substringBefore('Z') private fun Instant.toHtmlInputString() = truncatedTo(ChronoUnit.MINUTES).toString().substringBefore(":00Z")
private fun Long?.toDecimalString(): String {
if (this == null) return ""
return decimalFormat.format(toBigDecimal().divide(BigDecimal(100), 2, RoundingMode.HALF_UP))
}

View file

@ -71,6 +71,11 @@ main {
max-width: 300px; max-width: 300px;
} }
#hamburger {
color: var(--color-on-background);
text-decoration: none;
}
.stacked-label { .stacked-label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -100,9 +105,19 @@ main {
justify-content: space-evenly; justify-content: space-evenly;
} }
header.row {
justify-content: space-between;
align-items: center;
}
.card header .button {
margin: 0.5rem;
}
.card { .card {
background: var(--background-color-primary); background: var(--background-color-primary);
border-radius: var(--border-radius); border-radius: var(--border-radius);
margin-bottom: 1rem;
} }
.button { .button {
@ -146,12 +161,15 @@ main {
width: 200px; width: 200px;
} }
.center form {
width: 100%;
max-width: 1200px;
}
form { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
width: 100%;
max-width: 1200px;
} }
input, select, textarea { input, select, textarea {
@ -201,6 +219,30 @@ a {
font-size: 0.9rem; font-size: 0.9rem;
} }
@media all and (max-width: 600px) {
.hide-small {
display: none;
}
}
@media all and (max-width: 900px) {
#sidebar {
background-color: var(--background-color-primary);
position: fixed;
transform: translateX(-100%);
}
#sidebar:target {
transform: translateX(0);
}
}
@media all and (min-width: 900px) {
#hamburger {
display: none;
}
}
@media all and (max-width: 400px) { @media all and (max-width: 400px) {
button { button {
width: 100%; width: 100%;

View file

@ -2,30 +2,60 @@
<div id="app"> <div id="app">
{{>partials/sidebar}} {{>partials/sidebar}}
<main> <main>
<h1>{{budget.name}}</h1> <header class="row">
<a id="hamburger" href="#sidebar">☰</a>
<h1>{{title}}</h1>
<a href="/budgets/{{budget.id}}/transactions/new"
class="button button-secondary">
<!-- TODO: Hide text on small widths -->
<span aria-description="New Transaction">+</span> <span class="hide-small" aria-hidden="true">New Transaction</span>
</a>
</header>
<p>{{budget.description}}</p> <p>{{budget.description}}</p>
<div class="row"> <div class="card">
<div class="stacked-label"> <div class="row">
<p class="body-small">Month</p> <div class="stacked-label">
<p class="body-large">{{monthAndYear}}</p> <p class="body-small">Month</p>
</div> <p class="body-large">{{monthAndYear}}</p>
<div class="stacked-label"> </div>
<p class="body-small">Cash Flow</p> <div class="stacked-label">
<p class="body-large">{{balances.cashFlow}}</p> <p class="body-small">Cash Flow</p>
</div> <p class="body-large">{{balances.cashFlow}}</p>
<div class="stacked-label"> </div>
<p class="body-small">Transactions</p> <div class="stacked-label">
<p class="body-large">{{transactionCount}}</p> <p class="body-small">Transactions</p>
<p class="body-large">{{transactionCount}}</p>
</div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h3>Income</h3>
<div class="column"> <div class="column">
<p>{{balances.actualIncomeLabel}} earned of {{balances.expectedIncomeLabel}} expected</p> <p>Expected income: {{balances.expectedIncomeLabel}}</p>
<progress style="margin: 0 0.5rem;"
value="{{balances.expectedIncome}}"
max="{{balances.maxProgressBarValue}}">{{balances.expectedIncomeLabel}}</progress>
<p>Actual Income: {{balances.actualIncomeLabel}}</p>
<progress style="margin: 0 0.5rem;" <progress style="margin: 0 0.5rem;"
value="{{balances.actualIncome}}" value="{{balances.actualIncome}}"
max="{{balances.maxProgressBarValue}}">{{balances.actualIncomeLabel}}</progress> max="{{balances.maxProgressBarValue}}">{{balances.actualIncomeLabel}}</progress>
<p>Expected expenses: {{balances.expectedExpensesLabel}}</p>
<progress style="margin: 0 0.5rem;"
value="{{balances.expectedExpenses}}"
max="{{balances.maxProgressBarValue}}">{{balances.expectedExpensesLabel}}</progress>
<p>Actual Income: {{balances.actualIncomeLabel}}</p>
<progress style="margin: 0 0.5rem;"
value="{{balances.actualExpenses}}"
max="{{balances.maxProgressBarValue}}">{{balances.actualExpensesLabel}}</progress>
</div> </div>
</div>
<div class="card">
<header class="row">
<h3>Income</h3>
<a href="/budgets/{{budget.id}}/categories/new?expense=false" class="button button-secondary">
<!-- TODO: Hide text on small widths -->
<span aria-description="New Category">+</span> <span class="hide-small" aria-hidden="true">New Category</span>
</a>
</header>
<ul> <ul>
{{#incomeCategories}} {{#incomeCategories}}
{{>partials/category-list}} {{>partials/category-list}}
@ -39,13 +69,15 @@
{{/archivedIncomeCategories}} {{/archivedIncomeCategories}}
</ul> </ul>
</details> </details>
<h3>Expenses</h3> </div>
<div class="column"> <div class="card">
<p>{{balances.actualExpensesLabel}} spent of {{balances.expectedExpensesLabel}} expected</p> <header class="row">
<progress style="margin: 0 0.5rem;" <h3>Expenses</h3>
value="{{balances.actualExpenses}}" <a href="/budgets/{{budget.id}}/categories/new?expense=true" class="button button-secondary">
max="{{balances.maxProgressBarValue}}">{{balances.actualExpensesLabel}}</progress> <!-- TODO: Hide text on small widths -->
</div> <span aria-description="New Category">+</span> <span class="hide-small" aria-hidden="true">New Category</span>
</a>
</header>
<ul> <ul>
{{#expenseCategories}} {{#expenseCategories}}
{{>partials/category-list}} {{>partials/category-list}}

View file

@ -3,8 +3,18 @@
{{>partials/sidebar}} {{>partials/sidebar}}
<main> <main>
<div class="column"> <div class="column">
<h1>{{category.category.title}}</h1> <header class="row">
<a id="hamburger" href="#sidebar">☰</a>
<h1>{{title}}</h1>
<a href="/budgets/{{budget.id}}/transactions/new?categoryId={{category.category.id}}"
class="button button-secondary">
<!-- TODO: Hide text on small widths -->
<span aria-description="New Transaction">+</span> <span aria-hidden="true">New Transaction</span>
</a>
</header>
<p>{{category.category.description}}</p> <p>{{category.category.description}}</p>
</div>
<div class="card">
<div class="row"> <div class="row">
<div class="stacked-label"> <div class="stacked-label">
<p class="body-small">Budgeted</p> <p class="body-small">Budgeted</p>
@ -22,8 +32,8 @@
<progress value="{{category.balance}}" <progress value="{{category.balance}}"
max="{{category.category.amount}}">{{category.balanceLabel}}</progress> max="{{category.category.amount}}">{{category.balanceLabel}}</progress>
</div> </div>
<!-- TODO: Add a search bar to filter transactions by name/description -->
<div class="card"> <div class="card">
<!-- TODO: Add a search bar to filter transactions by name/description -->
<h3>Transactions</h3> <h3>Transactions</h3>
<ul> <ul>
{{#transactions}} {{#transactions}}

View file

@ -7,6 +7,11 @@
</a> </a>
</li> </li>
{{/budgets}} {{/budgets}}
<li class="list-item">
<a href="/budgets/new">
<span class="body-medium">+ New Budget</span>
</a>
</li>
</ul> </ul>
{{^budgets}} {{^budgets}}
<p style="text-align: center;">Welcome to Twigs! It looks like you haven't created any budgets yet. Budgets are the <p style="text-align: center;">Welcome to Twigs! It looks like you haven't created any budgets yet. Budgets are the

View file

@ -1,4 +1,4 @@
<aside class="sidebar"> <aside id="sidebar" class="sidebar">
<div class="logo" style="height: 100px; width: 100px;"></div> <div class="logo" style="height: 100px; width: 100px;"></div>
<ul> <ul>
<li class="list-item"> <li class="list-item">

View file

@ -2,7 +2,14 @@
<div id="app"> <div id="app">
{{>partials/sidebar}} {{>partials/sidebar}}
<main> <main>
<h1>{{title}}</h1> <header class="row">
<a id="hamburger" href="#sidebar">☰</a>
<h1>{{title}}</h1>
<a href="/budgets/{{budget.id}}/transactions/{{transaction.id}}/edit"
class="button button-secondary">
Edit
</a>
</header>
<div class="card"> <div class="card">
<div class="row"> <div class="row">
<span class="body-large">{{dateLabel}}</span> <span class="body-large">{{dateLabel}}</span>

View file

@ -2,40 +2,34 @@
<div id="app"> <div id="app">
<main> <main>
<h1>{{title}}</h1> <h1>{{title}}</h1>
<div class="center"> {{#error }}
{{#error }} <p class="error">{{error}}</p>
<p class="error">{{error}}</p> {{/error}}
{{/error}} <form method="post">
<form method="post"> <label for="title">Name</label>
<label for="title">Name</label> <input id="title" type="text" name="title" value="{{ transaction.title }}" required/>
<input id="title" type="text" name="title" value="{{ transaction.title }}" required/> <label for="description">Description</label>
<label for="description">Description</label> <textarea id="description" name="description">{{ transaction.description }}</textarea>
<textarea id="description" name="description">{{ transaction.description }}</textarea> <label for="date">Date</label>
<label for="date">Date</label> <input id="date" type="datetime-local" name="date" value="{{ transaction.date }}" required/>
<input id="date" type="datetime-local" name="date" value="{{ transaction.date }}" required/> <label for="amount">Amount</label>
<label for="amount">Amount</label> <input id="amount" type="number" name="amount" value="{{ amountLabel }}" step="0.01" required/>
<input id="amount" type="number" name="amount" value="{{ amountLabel }}" required/> <label for="categoryId">Category</label>
<label for="categoryId">Category</label> <select id="categoryId" name="categoryId" required>
<select id="categoryId" name="categoryId" required> {{#categoryOptions}}
<option selected disabled>Select a category</option> <option value="{{id}}" {{selected}} {{disabled}}>{{title}}</option>
<option disabled>Income</option> {{/categoryOptions}}
{{#incomeCategories}} </select>
<option value="{{id}}">{{title}}</option> <input id="submit" type="submit" class="button button-primary" value="Save"/>
{{/incomeCategories}} </form>
<option disabled>Expense</option>
{{#expenseCategories}}
<option value="{{id}}">{{title}}</option>
{{/expenseCategories}}
</select>
<input id="submit" type="submit" class="button button-primary" value="Save"/>
</form>
</div>
</main> </main>
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
const dateField = document.querySelector('#date') if (window.location.pathname.endsWith("new")) {
let localDateTime = dateField.valueAsDate const dateField = document.querySelector('#date')
const localTimeMs = localDateTime.setMinutes(localDateTime.getMinutes() - localDateTime.getTimezoneOffset()) let localDateTime = dateField.valueAsDate
dateField.value = new Date(localTimeMs).toISOString().slice(0, -1) const localTimeMs = localDateTime.setMinutes(localDateTime.getMinutes() - localDateTime.getTimezoneOffset())
dateField.value = new Date(localTimeMs).toISOString().slice(0, -1)
}
</script> </script>
{{>partials/foot}} {{>partials/foot}}