Compare commits

...

3 commits

15 changed files with 580 additions and 76 deletions

View file

@ -260,7 +260,7 @@ fun Application.moduleWithDependencies(
recurringTransactionRoutes(recurringTransactionService) recurringTransactionRoutes(recurringTransactionService)
transactionRoutes(transactionService) transactionRoutes(transactionService)
userRoutes(userService) userRoutes(userService)
webRoutes(budgetService, categoryService, transactionService, userService) webRoutes(budgetService, categoryService, recurringTransactionService, transactionService, userService)
launch { launch {
while (currentCoroutineContext().isActive) { while (currentCoroutineContext().isActive) {
jobs.forEach { it.run() } jobs.forEach { it.run() }

View file

@ -1,5 +1,3 @@
version: "3.3"
services: services:
twigs: twigs:
build: . build: .

View file

@ -25,6 +25,7 @@ data class RecurringTransactionResponse(
val frequency: String, val frequency: String,
val start: String, val start: String,
val finish: String?, val finish: String?,
val lastRun: String?,
val amount: Long?, val amount: Long?,
val expense: Boolean?, val expense: Boolean?,
val budgetId: String, val budgetId: String,
@ -39,6 +40,7 @@ fun RecurringTransaction.asResponse(): RecurringTransactionResponse = RecurringT
frequency = frequency.toString(), frequency = frequency.toString(),
start = start.truncatedTo(ChronoUnit.SECONDS).toString(), start = start.truncatedTo(ChronoUnit.SECONDS).toString(),
finish = finish?.truncatedTo(ChronoUnit.SECONDS)?.toString(), finish = finish?.truncatedTo(ChronoUnit.SECONDS)?.toString(),
lastRun = lastRun?.truncatedTo(ChronoUnit.SECONDS)?.toString(),
amount = amount, amount = amount,
expense = expense, expense = expense,
budgetId = budgetId, budgetId = budgetId,

View file

@ -4,10 +4,12 @@ import com.wbrawner.twigs.model.CookieSession
import com.wbrawner.twigs.service.HttpException 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.recurringtransaction.RecurringTransactionService
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.web.budget.budgetWebRoutes import com.wbrawner.twigs.web.budget.budgetWebRoutes
import com.wbrawner.twigs.web.category.categoryWebRoutes import com.wbrawner.twigs.web.category.categoryWebRoutes
import com.wbrawner.twigs.web.recurring.recurringTransactionWebRoutes
import com.wbrawner.twigs.web.transaction.transactionWebRoutes import com.wbrawner.twigs.web.transaction.transactionWebRoutes
import com.wbrawner.twigs.web.user.userWebRoutes import com.wbrawner.twigs.web.user.userWebRoutes
import io.ktor.server.application.* import io.ktor.server.application.*
@ -20,6 +22,7 @@ import io.ktor.server.sessions.*
fun Application.webRoutes( fun Application.webRoutes(
budgetService: BudgetService, budgetService: BudgetService,
categoryService: CategoryService, categoryService: CategoryService,
recurringTransactionService: RecurringTransactionService,
transactionService: TransactionService, transactionService: TransactionService,
userService: UserService userService: UserService
) { ) {
@ -47,6 +50,13 @@ fun Application.webRoutes(
} }
budgetWebRoutes(budgetService, categoryService, transactionService, userService) budgetWebRoutes(budgetService, categoryService, transactionService, userService)
categoryWebRoutes(budgetService, categoryService, transactionService, userService) categoryWebRoutes(budgetService, categoryService, transactionService, userService)
recurringTransactionWebRoutes(
budgetService,
categoryService,
recurringTransactionService,
transactionService,
userService
)
transactionWebRoutes(budgetService, categoryService, transactionService, userService) transactionWebRoutes(budgetService, categoryService, transactionService, userService)
userWebRoutes(userService) userWebRoutes(userService)
} }

View file

@ -1,15 +1,29 @@
package com.wbrawner.twigs.web package com.wbrawner.twigs.web
import com.wbrawner.twigs.endOfMonth
import com.wbrawner.twigs.firstOfMonth
import com.wbrawner.twigs.model.Frequency
import com.wbrawner.twigs.service.recurringtransaction.RecurringTransactionResponse
import com.wbrawner.twigs.service.transaction.TransactionResponse
import com.wbrawner.twigs.toInstant
import com.wbrawner.twigs.toInstantOrNull
import com.wbrawner.twigs.web.recurring.toListItem
import com.wbrawner.twigs.web.transaction.toListItem
import io.ktor.http.* import io.ktor.http.*
import io.ktor.server.util.*
import io.ktor.util.date.*
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
import java.text.DateFormat import java.text.DateFormat
import java.text.DecimalFormat import java.text.DecimalFormat
import java.text.NumberFormat import java.text.NumberFormat
import java.time.Instant
import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
import java.util.* import java.util.*
val currencyFormat = NumberFormat.getCurrencyInstance(Locale.US) val currencyFormat: NumberFormat = NumberFormat.getCurrencyInstance(Locale.US)
val decimalFormat = DecimalFormat.getNumberInstance(Locale.US).apply { val decimalFormat: NumberFormat = DecimalFormat.getNumberInstance(Locale.US).apply {
with(this as DecimalFormat) { with(this as DecimalFormat) {
decimalFormatSymbols = decimalFormatSymbols.apply { decimalFormatSymbols = decimalFormatSymbols.apply {
currencySymbol = "" currencySymbol = ""
@ -17,8 +31,7 @@ val decimalFormat = DecimalFormat.getNumberInstance(Locale.US).apply {
} }
} }
} }
val shortDateFormat = DateFormat.getDateInstance(DateFormat.SHORT, Locale.US) val shortDateFormat: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT, Locale.US)
fun Parameters.getAmount() = decimalFormat.parse(get("amount")) fun Parameters.getAmount() = decimalFormat.parse(get("amount"))
?.toDouble() ?.toDouble()
@ -30,4 +43,77 @@ fun Parameters.getAmount() = decimalFormat.parse(get("amount"))
fun Long?.toDecimalString(): String { fun Long?.toDecimalString(): String {
if (this == null) return "" if (this == null) return ""
return decimalFormat.format(toBigDecimal().divide(BigDecimal(100), 2, RoundingMode.HALF_UP)) return decimalFormat.format(toBigDecimal().divide(BigDecimal(100), 2, RoundingMode.HALF_UP))
}
fun Instant.toHtmlInputString() = truncatedTo(ChronoUnit.MINUTES).toString().substringBefore(":00Z")
data class ListGroup<T>(val label: String, val items: List<T>)
fun List<TransactionResponse>.groupByDate() =
groupBy {
it.date.toInstant().truncatedTo(ChronoUnit.DAYS)
}
.entries
.sortedByDescending { it.key }
.map { (date, transactions) ->
ListGroup(
shortDateFormat.format(date.toGMTDate().toJvmDate()),
transactions.map { it.toListItem(currencyFormat) })
}
val RecurringTransactionResponse.isThisMonth: Boolean
get() {
if (isExpired) {
return false
}
// TODO: Check user's timezone for this
return when (val frequencyObj = Frequency.parse(frequency)) {
is Frequency.Daily -> true
is Frequency.Weekly -> frequencyObj.count < 5
|| (lastRun ?: start).toInstant()
.plus(frequencyObj.count.toLong(), ChronoUnit.WEEKS)
.run {
isAfter(firstOfMonth) && isBefore(endOfMonth)
}
is Frequency.Monthly -> frequencyObj.count < 2
|| (lastRun ?: start).toInstant()
.plus(frequencyObj.count.toLong(), ChronoUnit.MONTHS)
.run {
isAfter(firstOfMonth) && isBefore(endOfMonth)
}
is Frequency.Yearly -> ZonedDateTime.now().month == frequencyObj.dayOfYear.month
}
}
val RecurringTransactionResponse.isExpired: Boolean
get() = finish?.toInstantOrNull()?.let { firstOfMonth > it } == true
fun List<RecurringTransactionResponse>.groupByOccurrence() =
groupBy {
when {
it.isThisMonth -> RecurringTransactionOccurrence.THIS_MONTH
!it.isExpired -> RecurringTransactionOccurrence.FUTURE
else -> RecurringTransactionOccurrence.EXPIRED
}
}
.entries
.sortedBy { (k, _) -> k.ordinal }
.map { (occurrence, transactions) ->
// TODO: I18n
ListGroup(occurrence.niceName, transactions.sortedBy { it.title }.map { it.toListItem(currencyFormat) })
}
enum class RecurringTransactionOccurrence {
THIS_MONTH,
FUTURE,
EXPIRED;
val niceName = name.split("_")
.joinToString(" ") { word ->
word.lowercase()
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
}
} }

View file

@ -2,18 +2,17 @@ package com.wbrawner.twigs.web.category
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.TransactionResponse
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
import com.wbrawner.twigs.web.BudgetListItem import com.wbrawner.twigs.web.BudgetListItem
import com.wbrawner.twigs.web.budget.toCurrencyString import com.wbrawner.twigs.web.ListGroup
import java.text.NumberFormat import com.wbrawner.twigs.web.transaction.TransactionListItem
data class CategoryDetailsPage( data class CategoryDetailsPage(
val category: CategoryWithBalanceResponse, val category: CategoryWithBalanceResponse,
val budget: BudgetResponse, val budget: BudgetResponse,
val transactionCount: String, val transactionCount: String,
val transactions: List<Map.Entry<String, List<TransactionListItem>>>, val transactions: List<ListGroup<TransactionListItem>>,
override val budgets: List<BudgetListItem>, override val budgets: List<BudgetListItem>,
override val user: UserResponse, override val user: UserResponse,
override val error: String? = null override val error: String? = null
@ -21,24 +20,6 @@ data class CategoryDetailsPage(
override val title: String = category.category.title override val title: String = category.category.title
} }
data class TransactionListItem(
val id: String,
val title: String,
val description: String,
val budgetId: String,
val expenseClass: String,
val amountLabel: String
)
fun TransactionResponse.toListItem(numberFormat: NumberFormat) = TransactionListItem(
id,
title.orEmpty(),
description.orEmpty(),
budgetId,
if (expense != false) "expense" else "income",
(amount ?: 0L).toCurrencyString(numberFormat)
)
data class CategoryFormPage( data class CategoryFormPage(
val category: CategoryResponse, val category: CategoryResponse,
val amountLabel: String, val amountLabel: String,
@ -64,4 +45,19 @@ data class CategoryWithBalanceResponse(
val balance: Long, val balance: Long,
val balanceLabel: String, val balanceLabel: String,
val remainingAmountLabel: String, val remainingAmountLabel: String,
) )
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) = CategoryOption(id, title, id == selectedCategoryId)

View file

@ -10,7 +10,6 @@ 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.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.toInstant
import com.wbrawner.twigs.toInstantOrNull import com.wbrawner.twigs.toInstantOrNull
import com.wbrawner.twigs.web.* import com.wbrawner.twigs.web.*
import com.wbrawner.twigs.web.budget.toCurrencyString import com.wbrawner.twigs.web.budget.toCurrencyString
@ -23,7 +22,6 @@ 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 io.ktor.util.date.*
import java.text.NumberFormat import java.text.NumberFormat
import java.util.* import java.util.*
import kotlin.math.abs import kotlin.math.abs
@ -132,12 +130,6 @@ fun Application.categoryWebRoutes(
) )
val transactionCount = NumberFormat.getNumberInstance(Locale.US) val transactionCount = NumberFormat.getNumberInstance(Locale.US)
.format(transactions.size) .format(transactions.size)
val transactionsByDate = transactions.groupBy {
shortDateFormat.format(it.date.toInstant().toGMTDate().toJvmDate())
}
.mapValues { (_, transactions) -> transactions.map { it.toListItem(currencyFormat) } }
.entries
.sortedByDescending { it.key }
val budgets = budgetService.budgetsForUser(user.id) val budgets = budgetService.budgetsForUser(user.id)
val budgetId = call.parameters.getOrFail("budgetId") val budgetId = call.parameters.getOrFail("budgetId")
val budget = budgets.first { it.id == budgetId } val budget = budgets.first { it.id == budgetId }
@ -145,7 +137,7 @@ fun Application.categoryWebRoutes(
MustacheContent( MustacheContent(
"category-details.mustache", CategoryDetailsPage( "category-details.mustache", CategoryDetailsPage(
category = categoryWithBalance, category = categoryWithBalance,
transactions = transactionsByDate, transactions = transactions.groupByDate(),
transactionCount = transactionCount, transactionCount = transactionCount,
budgets = budgets.map { it.toBudgetListItem(budgetId) }, budgets = budgets.map { it.toBudgetListItem(budgetId) },
budget = budget, budget = budget,

View file

@ -0,0 +1,62 @@
package com.wbrawner.twigs.web.recurring
import com.wbrawner.twigs.service.budget.BudgetResponse
import com.wbrawner.twigs.service.category.CategoryResponse
import com.wbrawner.twigs.service.recurringtransaction.RecurringTransactionResponse
import com.wbrawner.twigs.service.user.UserResponse
import com.wbrawner.twigs.web.AuthenticatedPage
import com.wbrawner.twigs.web.BudgetListItem
import com.wbrawner.twigs.web.ListGroup
import com.wbrawner.twigs.web.budget.toCurrencyString
import com.wbrawner.twigs.web.category.CategoryOption
import com.wbrawner.twigs.web.transaction.TransactionListItem
import java.text.NumberFormat
data class RecurringTransactionListPage(
val budget: BudgetResponse,
val transactions: List<ListGroup<TransactionListItem>>,
override val budgets: List<BudgetListItem>,
override val user: UserResponse,
override val error: String? = null
) : AuthenticatedPage {
override val title: String = "Recurring Transactions"
}
fun RecurringTransactionResponse.toListItem(numberFormat: NumberFormat) = TransactionListItem(
id = id,
title = title.orEmpty(),
description = description.orEmpty(),
budgetId = budgetId,
expenseClass = if (expense != false) "expense" else "income",
amountLabel = (amount ?: 0L).toCurrencyString(numberFormat)
)
data class RecurringTransactionDetailsPage(
val transaction: RecurringTransactionResponse,
val category: CategoryResponse?,
val budget: BudgetResponse,
val amountLabel: String,
val dateLabel: String,
val createdBy: UserResponse,
override val budgets: List<BudgetListItem>,
override val user: UserResponse,
override val error: String? = null
) : AuthenticatedPage {
override val title: String = transaction.title.orEmpty()
}
data class RecurringTransactionFormPage(
val transaction: RecurringTransactionResponse,
val amountLabel: String,
val budget: BudgetResponse,
val categoryOptions: List<CategoryOption>,
override val budgets: List<BudgetListItem>,
override val user: UserResponse,
override val error: String? = null
) : AuthenticatedPage {
override val title: String = if (transaction.id.isBlank()) {
"New Recurring Transaction"
} else {
"Edit Recurring Transaction"
}
}

View file

@ -0,0 +1,300 @@
package com.wbrawner.twigs.web.recurring
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.recurringtransaction.RecurringTransactionService
import com.wbrawner.twigs.service.requireSession
import com.wbrawner.twigs.service.transaction.TransactionRequest
import com.wbrawner.twigs.service.transaction.TransactionService
import com.wbrawner.twigs.service.user.UserService
import com.wbrawner.twigs.toInstant
import com.wbrawner.twigs.web.*
import com.wbrawner.twigs.web.budget.toCurrencyString
import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.mustache.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.util.*
import java.time.ZoneOffset.UTC
fun Application.recurringTransactionWebRoutes(
budgetService: BudgetService,
categoryService: CategoryService,
recurringTransactionService: RecurringTransactionService,
transactionService: TransactionService,
userService: UserService
) {
routing {
authenticate(TWIGS_SESSION_COOKIE) {
route("/budgets/{budgetId}/recurring") {
get {
val user = userService.user(requireSession().userId)
val budgetId = call.parameters.getOrFail("budgetId")
val budgets = budgetService.budgetsForUser(user.id)
val transactions = recurringTransactionService.recurringTransactions(
budgetId = budgetId,
userId = user.id
)
call.respond(
MustacheContent(
"recurring-transactions.mustache",
RecurringTransactionListPage(
budgets = budgets.map { it.toBudgetListItem(budgetId) },
budget = budgets.first { it.id == budgetId },
transactions = transactions.groupByOccurrence(),
user = user
)
)
)
}
route("/new") {
// get {
// val user = userService.user(requireSession().userId)
// val budgetId = call.parameters.getOrFail("budgetId")
// val budgets = budgetService.budgetsForUser(user.id)
// val budget = budgets.first { it.id == budgetId }
// val categoryId = call.request.queryParameters["categoryId"]
// val transaction = TransactionResponse(
// id = "",
// title = "",
// description = "",
// amount = 0,
// budgetId = budgetId,
// expense = true,
// date = Instant.now().toHtmlInputString(),
// categoryId = categoryId,
// createdBy = user.id
// )
// call.respond(
// MustacheContent(
// "transaction-form.mustache",
// TransactionFormPage(
// transaction = transaction,
// amountLabel = 0L.toDecimalString(),
// budget = budget,
// categoryOptions = categoryOptions(
// transaction = transaction,
// categoryService = categoryService,
// budgetId = budgetId,
// user = user
// ),
// budgets = budgets.map { it.toBudgetListItem(budgetId) },
// user = user
// )
// )
// )
// }
//
// post {
// val user = userService.user(requireSession().userId)
// val urlBudgetId = call.parameters.getOrFail("budgetId")
// val budgets = budgetService.budgetsForUser(user.id)
// val budget = budgets.first { it.id == urlBudgetId }
// 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, user.id)
// call.respondRedirect("/budgets/${transaction.budgetId}/transactions/${transaction.id}")
// } 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(
// status = e.statusCode,
// MustacheContent(
// "transaction-form.mustache",
// TransactionFormPage(
// transaction = transaction,
// amountLabel = call.parameters["amount"].orEmpty(),
// budget = budget,
// categoryOptions = categoryOptions(
// transaction,
// categoryService,
// urlBudgetId,
// user
// ),
// budgets = budgets.map { it.toBudgetListItem(urlBudgetId) },
// user = user,
// error = e.message
// )
// )
// )
// }
// }
}
route("/{id}") {
get {
val user = userService.user(requireSession().userId)
val transactionId = call.parameters.getOrFail("id")
val budgetId = call.parameters.getOrFail("budgetId")
// TODO: Allow user-configurable locale
try {
val transaction = recurringTransactionService.recurringTransaction(
recurringTransactionId = transactionId,
userId = user.id
)
check(transaction.budgetId == budgetId) {
// TODO: redirect instead of error?
"Attempted to fetch transaction from wrong budget"
}
val category = transaction.categoryId?.let {
categoryService.category(categoryId = it, userId = user.id)
}
val budgets = budgetService.budgetsForUser(user.id)
val budget = budgets.first { it.id == budgetId }
val transactionInstant = transaction.start.toInstant()
val transactionOffset = transactionInstant.atOffset(UTC)
val startLabel = shortDateFormat.format(transactionOffset)
call.respond(
MustacheContent(
"recurring-transaction-details.mustache", RecurringTransactionDetailsPage(
transaction = transaction,
category = category,
budget = budget,
budgets = budgets.map { it.toBudgetListItem(budgetId) },
amountLabel = transaction.amount?.toCurrencyString(currencyFormat).orEmpty(),
dateLabel = startLabel,
createdBy = userService.user(transaction.createdBy),
user = user
)
)
)
} catch (e: HttpException) {
call.respond(
status = e.statusCode,
MustacheContent("404.mustache", NotFoundPage)
)
}
}
// route("/edit") {
// get {
// val user = userService.user(requireSession().userId)
// val budgetId = call.parameters.getOrFail("budgetId")
// val budgets = budgetService.budgetsForUser(user.id)
// val budget = budgets.first { it.id == 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 = budget,
// categoryOptions = categoryOptions(transaction, categoryService, budgetId, user),
// budgets = budgets.map { it.toBudgetListItem(budgetId) },
// user = user
// )
// )
// )
// }
//
// post {
// val user = userService.user(requireSession().userId)
// val transactionId = call.parameters.getOrFail("id")
// val urlBudgetId = call.parameters.getOrFail("budgetId")
// val budgets = budgetService.budgetsForUser(user.id)
// val budget = budgets.first { it.id == urlBudgetId }
// 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 = budget,
// categoryOptions = categoryOptions(
// transaction,
// categoryService,
// urlBudgetId,
// user
// ),
// budgets = budgets.map { it.toBudgetListItem(urlBudgetId) },
// user = user,
// error = e.message
// )
// )
// )
// }
// }
// }
route("/delete") {
post {
val user = userService.user(requireSession().userId)
val transactionId = call.parameters.getOrFail("id")
val urlBudgetId = call.parameters.getOrFail("budgetId")
transactionService.delete(transactionId = transactionId, userId = user.id)
call.respondRedirect("/budgets/${urlBudgetId}")
}
}
}
}
}
}
}
private fun Parameters.toRecurringTransactionRequest() = TransactionRequest(
title = get("title"),
description = get("description"),
amount = getAmount(),
expense = false,
date = get("date"),
categoryId = get("categoryId"),
budgetId = get("budgetId"),
)

View file

@ -6,11 +6,14 @@ import com.wbrawner.twigs.service.transaction.TransactionResponse
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
import com.wbrawner.twigs.web.BudgetListItem import com.wbrawner.twigs.web.BudgetListItem
import com.wbrawner.twigs.web.category.TransactionListItem import com.wbrawner.twigs.web.ListGroup
import com.wbrawner.twigs.web.budget.toCurrencyString
import com.wbrawner.twigs.web.category.CategoryOption
import java.text.NumberFormat
data class TransactionListPage( data class TransactionListPage(
val budget: BudgetResponse, val budget: BudgetResponse,
val transactions: List<Map.Entry<String, List<TransactionListItem>>>, val transactions: List<ListGroup<TransactionListItem>>,
override val budgets: List<BudgetListItem>, override val budgets: List<BudgetListItem>,
override val user: UserResponse, override val user: UserResponse,
override val error: String? = null override val error: String? = null
@ -47,20 +50,22 @@ 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) = data class TransactionListItem(
TransactionFormPage.CategoryOption(id, title, id == selectedCategoryId) val id: String,
val title: String,
val description: String,
val budgetId: String,
val expenseClass: String,
val amountLabel: String
)
fun TransactionResponse.toListItem(numberFormat: NumberFormat) = TransactionListItem(
id,
title.orEmpty(),
description.orEmpty(),
budgetId,
if (expense != false) "expense" else "income",
(amount ?: 0L).toCurrencyString(numberFormat)
)

View file

@ -15,7 +15,8 @@ import com.wbrawner.twigs.toInstant
import com.wbrawner.twigs.toInstantOrNull import com.wbrawner.twigs.toInstantOrNull
import com.wbrawner.twigs.web.* import com.wbrawner.twigs.web.*
import com.wbrawner.twigs.web.budget.toCurrencyString import com.wbrawner.twigs.web.budget.toCurrencyString
import com.wbrawner.twigs.web.category.toListItem import com.wbrawner.twigs.web.category.CategoryOption
import com.wbrawner.twigs.web.category.asOption
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.*
@ -25,11 +26,9 @@ 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 io.ktor.util.date.*
import java.time.Instant import java.time.Instant
import java.time.ZoneOffset.UTC import java.time.ZoneOffset.UTC
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
fun Application.transactionWebRoutes( fun Application.transactionWebRoutes(
budgetService: BudgetService, budgetService: BudgetService,
@ -50,19 +49,13 @@ fun Application.transactionWebRoutes(
to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth, to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth,
userId = user.id userId = user.id
) )
val transactionsByDate = transactions.groupBy {
shortDateFormat.format(it.date.toInstant().toGMTDate().toJvmDate())
}
.mapValues { (_, transactions) -> transactions.map { it.toListItem(currencyFormat) } }
.entries
.sortedByDescending { it.key }
call.respond( call.respond(
MustacheContent( MustacheContent(
"budget-transactions.mustache", "budget-transactions.mustache",
TransactionListPage( TransactionListPage(
budgets = budgets.map { it.toBudgetListItem(budgetId) }, budgets = budgets.map { it.toBudgetListItem(budgetId) },
budget = budgets.first { it.id == budgetId }, budget = budgets.first { it.id == budgetId },
transactions = transactionsByDate, transactions = transactions.groupByDate(),
user = user user = user
) )
) )
@ -312,16 +305,16 @@ private suspend fun categoryOptions(
categoryService: CategoryService, categoryService: CategoryService,
budgetId: String, budgetId: String,
user: UserResponse user: UserResponse
): List<TransactionFormPage.CategoryOption> { ): List<CategoryOption> {
val selectedCategoryId = transaction.categoryId.orEmpty() val selectedCategoryId = transaction.categoryId.orEmpty()
val categoryOptions = listOf( val categoryOptions = listOf(
TransactionFormPage.CategoryOption( CategoryOption(
"", "",
"Select a category", "Select a category",
isSelected = transaction.categoryId.isNullOrBlank(), isSelected = transaction.categoryId.isNullOrBlank(),
isDisabled = true isDisabled = true
), ),
TransactionFormPage.CategoryOption("income", "Income", isDisabled = true), CategoryOption("income", "Income", isDisabled = true),
) )
.plus( .plus(
categoryService.categories( categoryService.categories(
@ -334,7 +327,7 @@ private suspend fun categoryOptions(
} }
) )
.plus( .plus(
TransactionFormPage.CategoryOption("expense", "Expense", isDisabled = true), CategoryOption("expense", "Expense", isDisabled = true),
) )
.plus( .plus(
categoryService.categories( categoryService.categories(
@ -358,5 +351,3 @@ private fun Parameters.toTransactionRequest() = TransactionRequest(
categoryId = get("categoryId"), categoryId = get("categoryId"),
budgetId = get("budgetId"), budgetId = get("budgetId"),
) )
private fun Instant.toHtmlInputString() = truncatedTo(ChronoUnit.MINUTES).toString().substringBefore(":00Z")

View file

@ -5,4 +5,19 @@ for (let i = 0; i < forms.length; i++) {
form.onsubmit = () => { form.onsubmit = () => {
form.querySelector('input[type="submit"]').disabled = true form.querySelector('input[type="submit"]').disabled = true
} }
} }
const sidebar = document.querySelector('#sidebar')
document.querySelector('#hamburger').onclick = (e) => {
e.preventDefault()
sidebar.style.transform = 'translateX(0)'
}
document.addEventListener('click', (e) => {
const style = window.getComputedStyle(sidebar)
const matrix = new DOMMatrixReadOnly(style.getPropertyValue("transform"))
if (matrix.m41 === 0) {
sidebar.style.transform = 'translateX(-100%)'
}
})

View file

@ -0,0 +1,18 @@
<li class="list-item">
<h5>
{{label}}
</h5>
</li>
{{#items}}
<li class="list-item">
<a href="/budgets/{{budgetId}}/recurring/{{id}}">
<div class="row" style="justify-content: space-between">
<div class="column">
<span class="body-large">{{title}}</span>
<span class="body-small">{{description}}</span>
</div>
<span class="body-medium {{expenseClass}}">{{amountLabel}}</span>
</div>
</a>
</li>
{{/items}}

View file

@ -1,9 +1,9 @@
<li class="list-item"> <li class="list-item">
<h5> <h5>
{{key}} {{label}}
</h5> </h5>
</li> </li>
{{#value}} {{#items}}
<li class="list-item"> <li class="list-item">
<a href="/budgets/{{budgetId}}/transactions/{{id}}"> <a href="/budgets/{{budgetId}}/transactions/{{id}}">
<div class="row" style="justify-content: space-between"> <div class="row" style="justify-content: space-between">
@ -15,4 +15,4 @@
</div> </div>
</a> </a>
</li> </li>
{{/value}} {{/items}}

View file

@ -0,0 +1,29 @@
{{> partials/head }}
<div id="app">
{{>partials/sidebar}}
<main>
<div class="column">
<header class="row">
<a id="hamburger" href="#sidebar">☰</a>
<h1>{{title}}</h1>
<div class="row">
<a href="/budgets/{{budget.id}}/recurring/new"
class="button button-secondary">
<!-- TODO: Hide text on small widths -->
<span aria-description="New Recurring Transaction">+</span> <span
aria-hidden="true">New Recurring Transaction</span>
</a>
</div>
</header>
</div>
<div class="card">
<!-- TODO: Add a search bar to filter transactions by name/description -->
<ul>
{{#transactions}}
{{>partials/recurring-transaction-list}}
{{/transactions}}
</ul>
</div>
</main>
</div>
{{>partials/foot}}