From d8b89bc8d230c892c6d953e2f51a65c6e7979137 Mon Sep 17 00:00:00 2001 From: William Brawner Date: Sat, 20 Apr 2024 21:38:39 -0600 Subject: [PATCH] Implement transaction list page and ensure consistency with budget list in sidebar --- .../kotlin/com/wbrawner/twigs/web/Page.kt | 11 ++++ .../kotlin/com/wbrawner/twigs/web/WebUtils.kt | 3 + .../wbrawner/twigs/web/budget/BudgetPages.kt | 6 +- .../twigs/web/budget/BudgetWebRoutes.kt | 33 +++++------ .../twigs/web/category/CategoryPages.kt | 4 +- .../twigs/web/category/CategoryWebRoutes.kt | 48 +++++++++------ .../twigs/web/transaction/TransactionPages.kt | 16 ++++- .../web/transaction/TransactionWebRoutes.kt | 59 +++++++++++++++---- .../templates/budget-transactions.mustache | 29 +++++++++ 9 files changed, 158 insertions(+), 51 deletions(-) create mode 100644 web/src/main/resources/templates/budget-transactions.mustache 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 5c7dcc0..93b5c23 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/Page.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/Page.kt @@ -1,5 +1,6 @@ package com.wbrawner.twigs.web +import com.wbrawner.twigs.service.budget.BudgetResponse import com.wbrawner.twigs.service.user.UserResponse interface Page { @@ -9,8 +10,18 @@ interface Page { interface AuthenticatedPage : Page { val user: UserResponse + val budgets: List } +data class BudgetListItem(val id: String, val name: String, val description: String, val selected: Boolean) + +fun BudgetResponse.toBudgetListItem(selectedId: String? = null) = BudgetListItem( + id = id, + name = name.orEmpty(), + description = description.orEmpty(), + selected = id == selectedId +) + object NotFoundPage : Page { override val title: String = "404 Not Found" override val error: String? = null diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/WebUtils.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/WebUtils.kt index 06c1d2e..ebb834c 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/WebUtils.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/WebUtils.kt @@ -3,6 +3,7 @@ package com.wbrawner.twigs.web import io.ktor.http.* import java.math.BigDecimal import java.math.RoundingMode +import java.text.DateFormat import java.text.DecimalFormat import java.text.NumberFormat import java.util.* @@ -16,6 +17,8 @@ val decimalFormat = DecimalFormat.getNumberInstance(Locale.US).apply { } } } +val shortDateFormat = DateFormat.getDateInstance(DateFormat.SHORT, Locale.US) + fun Parameters.getAmount() = decimalFormat.parse(get("amount")) ?.toDouble() 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 5bb75c7..37de4cb 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 @@ -3,10 +3,11 @@ package com.wbrawner.twigs.web.budget import com.wbrawner.twigs.service.budget.BudgetResponse import com.wbrawner.twigs.service.user.UserResponse import com.wbrawner.twigs.web.AuthenticatedPage +import com.wbrawner.twigs.web.BudgetListItem import com.wbrawner.twigs.web.category.CategoryWithBalanceResponse data class BudgetListPage( - val budgets: List, + override val budgets: List, override val user: UserResponse, override val error: String? = null ) : AuthenticatedPage { @@ -14,7 +15,6 @@ data class BudgetListPage( } data class BudgetDetailsPage( - val budgets: List, val budget: BudgetResponse, val balances: BudgetBalances, val incomeCategories: List, @@ -23,6 +23,7 @@ data class BudgetDetailsPage( val archivedExpenseCategories: List, val transactionCount: String, val monthAndYear: String, + override val budgets: List, override val user: UserResponse, override val error: String? = null ) : AuthenticatedPage { @@ -31,6 +32,7 @@ data class BudgetDetailsPage( data class BudgetFormPage( val budget: BudgetResponse, + override val budgets: List, override val user: UserResponse, override val error: String? = null ) : AuthenticatedPage { 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 3bb1c69..f3d8030 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 @@ -13,6 +13,7 @@ import com.wbrawner.twigs.service.user.UserService import com.wbrawner.twigs.toInstantOrNull import com.wbrawner.twigs.web.NotFoundPage import com.wbrawner.twigs.web.category.CategoryWithBalanceResponse +import com.wbrawner.twigs.web.toBudgetListItem import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE import io.ktor.http.* import io.ktor.server.application.* @@ -52,13 +53,14 @@ fun Application.budgetWebRoutes( MustacheContent( "budget-form.mustache", BudgetFormPage( - BudgetResponse( + budget = BudgetResponse( id = "", name = "", description = "", users = listOf() ), - user + budgets = budgetService.budgetsForUser(user.id).map { it.toBudgetListItem() }, + user = user ) ) ) @@ -76,14 +78,15 @@ fun Application.budgetWebRoutes( MustacheContent( "budget-form.mustache", BudgetFormPage( - BudgetResponse( + budget = BudgetResponse( id = "", name = call.parameters["name"].orEmpty(), description = call.parameters["description"].orEmpty(), users = listOf() ), - user, - e.message + budgets = budgetService.budgetsForUser(user.id).map { it.toBudgetListItem() }, + user = user, + error = e.message ) ) ) @@ -150,16 +153,16 @@ fun Application.budgetWebRoutes( call.respond( MustacheContent( "budget-details.mustache", BudgetDetailsPage( - budgets = budgets.map { it.toBudgetListItem(budgetId) }.sortedBy { it.name }, budget = budget, balances = balances, incomeCategories = incomeCategories, - archivedIncomeCategories = archivedIncomeCategories, expenseCategories = expenseCategories, + archivedIncomeCategories = archivedIncomeCategories, archivedExpenseCategories = archivedExpenseCategories, transactionCount = NumberFormat.getNumberInstance(Locale.US) .format(transactions.size), monthAndYear = YearMonth.now().format(DateTimeFormatter.ofPattern("MMMM yyyy")), + budgets = budgets.map { it.toBudgetListItem(budgetId) }.sortedBy { it.name }, user = user ) ) @@ -176,7 +179,12 @@ fun Application.budgetWebRoutes( call.respond( MustacheContent( "budget-form.mustache", - BudgetFormPage(budget, user) + BudgetFormPage( + budget = budget, + budgets = budgetService.budgetsForUser(user.id) + .map { it.toBudgetListItem(budget.id) }, + user = user + ) ) ) } @@ -210,15 +218,6 @@ data class BudgetBalances( 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"), diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/category/CategoryPages.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/category/CategoryPages.kt index fb99183..ddaf160 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/category/CategoryPages.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/category/CategoryPages.kt @@ -5,15 +5,16 @@ 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.web.AuthenticatedPage +import com.wbrawner.twigs.web.BudgetListItem import com.wbrawner.twigs.web.budget.toCurrencyString import java.text.NumberFormat data class CategoryDetailsPage( val category: CategoryWithBalanceResponse, val budget: BudgetResponse, - val budgets: List, val transactionCount: String, val transactions: List>>, + override val budgets: List, override val user: UserResponse, override val error: String? = null ) : AuthenticatedPage { @@ -42,6 +43,7 @@ data class CategoryFormPage( val category: CategoryResponse, val amountLabel: String, val budget: BudgetResponse, + override val budgets: List, override val user: UserResponse, override val error: String? = null ) : AuthenticatedPage { diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/category/CategoryWebRoutes.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/category/CategoryWebRoutes.kt index 3ae90a1..2ad6548 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/category/CategoryWebRoutes.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/category/CategoryWebRoutes.kt @@ -12,10 +12,8 @@ import com.wbrawner.twigs.service.transaction.TransactionService import com.wbrawner.twigs.service.user.UserService import com.wbrawner.twigs.toInstant import com.wbrawner.twigs.toInstantOrNull -import com.wbrawner.twigs.web.NotFoundPage +import com.wbrawner.twigs.web.* import com.wbrawner.twigs.web.budget.toCurrencyString -import com.wbrawner.twigs.web.getAmount -import com.wbrawner.twigs.web.toDecimalString import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE import io.ktor.http.* import io.ktor.server.application.* @@ -26,7 +24,6 @@ import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.util.* import io.ktor.util.date.* -import java.text.DateFormat import java.text.NumberFormat import java.util.* import kotlin.math.abs @@ -49,6 +46,8 @@ fun Application.categoryWebRoutes( 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 } call.respond( MustacheContent( "category-form.mustache", @@ -63,7 +62,8 @@ fun Application.categoryWebRoutes( archived = false, ), amountLabel = 0L.toDecimalString(), - budget = budgetService.budget(budgetId = budgetId, userId = user.id), + budget = budget, + budgets = budgets.map { it.toBudgetListItem(budgetId) }, user = user ) ) @@ -73,6 +73,8 @@ fun Application.categoryWebRoutes( post { 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 } try { val request = call.receiveParameters().toCategoryRequest(budgetId) val category = categoryService.save(request, user.id) @@ -93,7 +95,8 @@ fun Application.categoryWebRoutes( budgetId = budgetId ), amountLabel = call.parameters["amount"]?.toLongOrNull().toDecimalString(), - budget = budgetService.budget(budgetId = budgetId, userId = user.id), + budget = budget, + budgets = budgets.map { it.toBudgetListItem(budgetId) }, user = user, error = e.message ) @@ -107,18 +110,18 @@ fun Application.categoryWebRoutes( get { val user = userService.user(requireSession().userId) val categoryId = call.parameters.getOrFail("id") - // TODO: Allow user-configurable locale - val numberFormat = NumberFormat.getCurrencyInstance(Locale.US) try { val category = categoryService.category(categoryId = categoryId, userId = user.id) val categoryBalance = abs(transactionService.sum(categoryId = category.id, userId = user.id)) val categoryWithBalance = CategoryWithBalanceResponse( category = category, - amountLabel = category.amount.toCurrencyString(numberFormat), + amountLabel = category.amount.toCurrencyString(currencyFormat), balance = categoryBalance, - balanceLabel = categoryBalance.toCurrencyString(numberFormat), - remainingAmountLabel = (category.amount - categoryBalance).toCurrencyString(numberFormat) + balanceLabel = categoryBalance.toCurrencyString(currencyFormat), + remainingAmountLabel = (category.amount - categoryBalance).toCurrencyString( + currencyFormat + ) ) val transactions = transactionService.transactions( budgetIds = listOf(category.budgetId), @@ -129,13 +132,12 @@ fun Application.categoryWebRoutes( ) val transactionCount = NumberFormat.getNumberInstance(Locale.US) .format(transactions.size) - val dateFormat = DateFormat.getDateInstance(DateFormat.SHORT, Locale.US) val transactionsByDate = transactions.groupBy { - dateFormat.format(it.date.toInstant().toGMTDate().toJvmDate()) + shortDateFormat.format(it.date.toInstant().toGMTDate().toJvmDate()) } - .mapValues { (_, transactions) -> transactions.map { it.toListItem(numberFormat) } } + .mapValues { (_, transactions) -> transactions.map { it.toListItem(currencyFormat) } } .entries - .sortedBy { it.key } + .sortedByDescending { it.key } val budgets = budgetService.budgetsForUser(user.id) val budgetId = call.parameters.getOrFail("budgetId") val budget = budgets.first { it.id == budgetId } @@ -145,7 +147,7 @@ fun Application.categoryWebRoutes( category = categoryWithBalance, transactions = transactionsByDate, transactionCount = transactionCount, - budgets = budgets, + budgets = budgets.map { it.toBudgetListItem(budgetId) }, budget = budget, user = user ) @@ -167,11 +169,17 @@ fun Application.categoryWebRoutes( userId = user.id ) val budgetId = call.parameters.getOrFail("budgetId") - val budget = budgetService.budget(budgetId = budgetId, userId = user.id) + val budgets = budgetService.budgetsForUser(user.id) call.respond( MustacheContent( "category-form.mustache", - CategoryFormPage(category, category.amount.toDecimalString(), budget, user) + CategoryFormPage( + category = category, + amountLabel = category.amount.toDecimalString(), + budget = budgets.first { it.id == budgetId }, + budgets = budgets.map { it.toBudgetListItem(budgetId) }, + user = user + ) ) ) } @@ -179,6 +187,7 @@ fun Application.categoryWebRoutes( post { val user = userService.user(requireSession().userId) val budgetId = call.parameters.getOrFail("budgetId") + val budgets = budgetService.budgetsForUser(user.id) val categoryId = call.parameters.getOrFail("id") try { val request = call.receiveParameters().toCategoryRequest(budgetId) @@ -200,7 +209,8 @@ fun Application.categoryWebRoutes( budgetId = budgetId ), amountLabel = call.parameters["amount"]?.toLongOrNull().toDecimalString(), - budget = budgetService.budget(budgetId = budgetId, userId = user.id), + budget = budgets.first { it.id == budgetId }, + budgets = budgets.map { it.toBudgetListItem(budgetId) }, user = user, error = e.message ) diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/transaction/TransactionPages.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/transaction/TransactionPages.kt index ca617e8..879941a 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/transaction/TransactionPages.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/transaction/TransactionPages.kt @@ -5,6 +5,19 @@ 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.web.AuthenticatedPage +import com.wbrawner.twigs.web.BudgetListItem +import com.wbrawner.twigs.web.category.TransactionListItem + +data class TransactionListPage( + val budget: BudgetResponse, + val transactions: List>>, + override val budgets: List, + override val user: UserResponse, + override val error: String? = null +) : AuthenticatedPage { + override val title: String = "Transactions" +} + data class TransactionDetailsPage( val transaction: TransactionResponse, @@ -12,8 +25,8 @@ data class TransactionDetailsPage( val budget: BudgetResponse, val amountLabel: String, val dateLabel: String, - val budgets: List, val createdBy: UserResponse, + override val budgets: List, override val user: UserResponse, override val error: String? = null ) : AuthenticatedPage { @@ -25,6 +38,7 @@ data class TransactionFormPage( val amountLabel: String, val budget: BudgetResponse, val categoryOptions: List, + override val budgets: List, override val user: UserResponse, override val error: String? = null ) : AuthenticatedPage { diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/transaction/TransactionWebRoutes.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/transaction/TransactionWebRoutes.kt index 0cbd83c..de6aed4 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/transaction/TransactionWebRoutes.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/transaction/TransactionWebRoutes.kt @@ -1,5 +1,7 @@ package com.wbrawner.twigs.web.transaction +import com.wbrawner.twigs.endOfMonth +import com.wbrawner.twigs.firstOfMonth import com.wbrawner.twigs.service.HttpException import com.wbrawner.twigs.service.budget.BudgetService import com.wbrawner.twigs.service.category.CategoryService @@ -10,11 +12,10 @@ 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.toInstant -import com.wbrawner.twigs.web.NotFoundPage +import com.wbrawner.twigs.toInstantOrNull +import com.wbrawner.twigs.web.* import com.wbrawner.twigs.web.budget.toCurrencyString -import com.wbrawner.twigs.web.currencyFormat -import com.wbrawner.twigs.web.getAmount -import com.wbrawner.twigs.web.toDecimalString +import com.wbrawner.twigs.web.category.toListItem import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE import io.ktor.http.* import io.ktor.server.application.* @@ -24,6 +25,7 @@ import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.util.* +import io.ktor.util.date.* import java.time.Instant import java.time.ZoneOffset.UTC import java.time.format.DateTimeFormatter @@ -39,15 +41,40 @@ fun Application.transactionWebRoutes( authenticate(TWIGS_SESSION_COOKIE) { route("/budgets/{budgetId}/transactions") { get { - // TODO: Show transaction list here + val user = userService.user(requireSession().userId) val budgetId = call.parameters.getOrFail("budgetId") - call.respondRedirect("/budgets/$budgetId") + val budgets = budgetService.budgetsForUser(user.id) + val transactions = transactionService.transactions( + budgetIds = listOf(budgetId), + from = call.parameters["from"]?.toInstantOrNull() ?: firstOfMonth, + to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth, + 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( + MustacheContent( + "budget-transactions.mustache", + TransactionListPage( + budgets = budgets.map { it.toBudgetListItem(budgetId) }, + budget = budgets.first { it.id == budgetId }, + transactions = transactionsByDate, + 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 = "", @@ -66,13 +93,14 @@ fun Application.transactionWebRoutes( TransactionFormPage( transaction = transaction, amountLabel = 0L.toDecimalString(), - budget = budgetService.budget(budgetId = budgetId, userId = user.id), + budget = budget, categoryOptions = categoryOptions( transaction = transaction, categoryService = categoryService, budgetId = budgetId, user = user ), + budgets = budgets.map { it.toBudgetListItem(budgetId) }, user = user ) ) @@ -82,6 +110,8 @@ fun Application.transactionWebRoutes( 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 { @@ -115,13 +145,14 @@ fun Application.transactionWebRoutes( TransactionFormPage( transaction = transaction, amountLabel = call.parameters["amount"].orEmpty(), - budget = budgetService.budget(budgetId = urlBudgetId, userId = user.id), + budget = budget, categoryOptions = categoryOptions( transaction, categoryService, urlBudgetId, user ), + budgets = budgets.map { it.toBudgetListItem(urlBudgetId) }, user = user, error = e.message ) @@ -161,7 +192,7 @@ fun Application.transactionWebRoutes( transaction = transaction, category = category, budget = budget, - budgets = budgets, + budgets = budgets.map { it.toBudgetListItem(budgetId) }, amountLabel = transaction.amount?.toCurrencyString(currencyFormat).orEmpty(), dateLabel = dateLabel, createdBy = userService.user(transaction.createdBy), @@ -181,6 +212,8 @@ fun Application.transactionWebRoutes( 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 @@ -193,8 +226,9 @@ fun Application.transactionWebRoutes( date = transaction.date.toInstant().toHtmlInputString() ), amountLabel = transaction.amount.toDecimalString(), - budget = budgetService.budget(budgetId = budgetId, userId = user.id), + budget = budget, categoryOptions = categoryOptions(transaction, categoryService, budgetId, user), + budgets = budgets.map { it.toBudgetListItem(budgetId) }, user = user ) ) @@ -205,6 +239,8 @@ fun Application.transactionWebRoutes( 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 { @@ -239,13 +275,14 @@ fun Application.transactionWebRoutes( TransactionFormPage( transaction = transaction, amountLabel = call.parameters["amount"].orEmpty(), - budget = budgetService.budget(budgetId = urlBudgetId, userId = user.id), + budget = budget, categoryOptions = categoryOptions( transaction, categoryService, urlBudgetId, user ), + budgets = budgets.map { it.toBudgetListItem(urlBudgetId) }, user = user, error = e.message ) diff --git a/web/src/main/resources/templates/budget-transactions.mustache b/web/src/main/resources/templates/budget-transactions.mustache new file mode 100644 index 0000000..d29b921 --- /dev/null +++ b/web/src/main/resources/templates/budget-transactions.mustache @@ -0,0 +1,29 @@ +{{> partials/head }} +
+ {{>partials/sidebar}} +
+
+
+ +

{{title}}

+ +
+
+
+ +
    + {{#transactions}} + {{>partials/transaction-list}} + {{/transactions}} +
+
+
+
+{{>partials/foot}} \ No newline at end of file