aboutsummaryrefslogtreecommitdiff
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/aio/build.gradle.kts19
-rw-r--r--server/aio/src/main/kotlin/moe/nea/ledger/server/aio/AIO.kt20
-rw-r--r--server/analysis/build.gradle.kts16
-rw-r--r--server/analysis/src/main/kotlin/moe/nea/ledger/analysis/Analysis.kt9
-rw-r--r--server/analysis/src/main/kotlin/moe/nea/ledger/analysis/AnalysisFilter.kt26
-rw-r--r--server/analysis/src/main/kotlin/moe/nea/ledger/analysis/AnalysisResult.kt8
-rw-r--r--server/analysis/src/main/kotlin/moe/nea/ledger/analysis/CoinsSpentOnAuctions.kt49
-rw-r--r--server/analysis/src/main/kotlin/moe/nea/ledger/analysis/Visualization.kt30
-rw-r--r--server/core/build.gradle.kts24
-rw-r--r--server/core/src/main/kotlin/moe/nea/ledger/server/core/Application.kt53
-rw-r--r--server/core/src/main/kotlin/moe/nea/ledger/server/core/api/BaseApi.kt188
-rw-r--r--server/core/test-requests/test-hello.http5
-rw-r--r--server/frontend/.gitignore2
-rw-r--r--server/frontend/build.gradle.kts17
-rw-r--r--server/frontend/index.html16
-rw-r--r--server/frontend/package.json37
-rw-r--r--server/frontend/pnpm-lock.yaml1920
-rw-r--r--server/frontend/src/Analysis.tsx84
-rw-r--r--server/frontend/src/App.module.css0
-rw-r--r--server/frontend/src/App.tsx22
-rw-r--r--server/frontend/src/Test.tsx31
-rw-r--r--server/frontend/src/api-schema.d.ts236
-rw-r--r--server/frontend/src/api.ts13
-rw-r--r--server/frontend/src/index.css13
-rw-r--r--server/frontend/src/index.tsx23
-rw-r--r--server/frontend/tsconfig.json19
-rw-r--r--server/frontend/vite.config.ts17
-rw-r--r--server/swagger/build.gradle.kts17
-rw-r--r--server/swagger/src/main/kotlin/moe/nea/ledger/server/core/api/OpenApiModel.kt117
-rw-r--r--server/swagger/src/main/kotlin/moe/nea/ledger/server/core/api/docs.kt323
30 files changed, 3333 insertions, 21 deletions
diff --git a/server/aio/build.gradle.kts b/server/aio/build.gradle.kts
new file mode 100644
index 0000000..22819f0
--- /dev/null
+++ b/server/aio/build.gradle.kts
@@ -0,0 +1,19 @@
+plugins {
+ kotlin("jvm")
+ application
+}
+
+dependencies {
+ declareKtorVersion()
+ implementation(project(":server:core"))
+ implementation(project(":server:frontend"))
+}
+
+java {
+ toolchain.languageVersion.set(JavaLanguageVersion.of(21))
+}
+
+application {
+ mainClass.set("moe.nea.ledger.server.core.ApplicationKt")
+}
+
diff --git a/server/aio/src/main/kotlin/moe/nea/ledger/server/aio/AIO.kt b/server/aio/src/main/kotlin/moe/nea/ledger/server/aio/AIO.kt
new file mode 100644
index 0000000..e59301f
--- /dev/null
+++ b/server/aio/src/main/kotlin/moe/nea/ledger/server/aio/AIO.kt
@@ -0,0 +1,20 @@
+package moe.nea.ledger.server.aio
+
+import io.ktor.server.application.Application
+import io.ktor.server.http.content.singlePageApplication
+import io.ktor.server.routing.Routing
+import moe.nea.ledger.server.core.AIOProvider
+
+
+class AIO : AIOProvider {
+ override fun Routing.installExtraRouting() {
+ singlePageApplication {
+ useResources = true
+ filesPath = "ledger-web-dist"
+ defaultPage = "index.html"
+ }
+ }
+
+ override fun Application.module() {
+ }
+} \ No newline at end of file
diff --git a/server/analysis/build.gradle.kts b/server/analysis/build.gradle.kts
new file mode 100644
index 0000000..d5d48a0
--- /dev/null
+++ b/server/analysis/build.gradle.kts
@@ -0,0 +1,16 @@
+plugins {
+ kotlin("jvm")
+ kotlin("plugin.serialization")
+ id("com.google.devtools.ksp")
+}
+
+dependencies {
+ api("org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.0")
+ ksp("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
+ implementation("com.google.auto.service:auto-service-annotations:1.1.1")
+ implementation(project(":database:impl"))
+}
+
+java {
+ toolchain.languageVersion.set(JavaLanguageVersion.of(21))
+}
diff --git a/server/analysis/src/main/kotlin/moe/nea/ledger/analysis/Analysis.kt b/server/analysis/src/main/kotlin/moe/nea/ledger/analysis/Analysis.kt
new file mode 100644
index 0000000..abcf8ed
--- /dev/null
+++ b/server/analysis/src/main/kotlin/moe/nea/ledger/analysis/Analysis.kt
@@ -0,0 +1,9 @@
+package moe.nea.ledger.analysis
+
+import java.sql.Connection
+
+interface Analysis {
+ val id: String
+ val name: String
+ fun perform(database: Connection, filter: AnalysisFilter): AnalysisResult
+} \ No newline at end of file
diff --git a/server/analysis/src/main/kotlin/moe/nea/ledger/analysis/AnalysisFilter.kt b/server/analysis/src/main/kotlin/moe/nea/ledger/analysis/AnalysisFilter.kt
new file mode 100644
index 0000000..10d9b9c
--- /dev/null
+++ b/server/analysis/src/main/kotlin/moe/nea/ledger/analysis/AnalysisFilter.kt
@@ -0,0 +1,26 @@
+package moe.nea.ledger.analysis
+
+import moe.nea.ledger.database.DBLogEntry
+import moe.nea.ledger.database.Query
+import moe.nea.ledger.database.columns.DBUlid
+import moe.nea.ledger.database.sql.Clause
+import moe.nea.ledger.utils.ULIDWrapper
+import java.time.Instant
+import java.time.ZoneId
+import java.util.UUID
+
+interface AnalysisFilter {
+ fun applyTo(query: Query) {
+ query.where(Clause { column(DBLogEntry.transactionId) ge value(DBUlid, ULIDWrapper.lowerBound(startWindow)) })
+ .where(Clause { column(DBLogEntry.transactionId) le value(DBUlid, ULIDWrapper.upperBound(endWindow)) })
+//TODO: .where(Clause { column(DBLogEntry.profileId) inList profiles })
+ }
+
+ fun timeZone(): ZoneId {
+ return ZoneId.systemDefault()
+ }
+
+ val startWindow: Instant
+ val endWindow: Instant
+ val profiles: List<UUID>
+}
diff --git a/server/analysis/src/main/kotlin/moe/nea/ledger/analysis/AnalysisResult.kt b/server/analysis/src/main/kotlin/moe/nea/ledger/analysis/AnalysisResult.kt
new file mode 100644
index 0000000..4ad47f7
--- /dev/null
+++ b/server/analysis/src/main/kotlin/moe/nea/ledger/analysis/AnalysisResult.kt
@@ -0,0 +1,8 @@
+package moe.nea.ledger.analysis
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class AnalysisResult(
+ val visualizations: List<Visualization>
+) \ No newline at end of file
diff --git a/server/analysis/src/main/kotlin/moe/nea/ledger/analysis/CoinsSpentOnAuctions.kt b/server/analysis/src/main/kotlin/moe/nea/ledger/analysis/CoinsSpentOnAuctions.kt
new file mode 100644
index 0000000..d1ce52b
--- /dev/null
+++ b/server/analysis/src/main/kotlin/moe/nea/ledger/analysis/CoinsSpentOnAuctions.kt
@@ -0,0 +1,49 @@
+package moe.nea.ledger.analysis
+
+import com.google.auto.service.AutoService
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.ItemId
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.database.DBItemEntry
+import moe.nea.ledger.database.DBLogEntry
+import moe.nea.ledger.database.sql.Clause
+import java.sql.Connection
+import java.time.LocalDate
+
+@AutoService(Analysis::class)
+class CoinsSpentOnAuctions : Analysis {
+ override val name: String
+ get() = "Shopping Costs"
+ override val id: String
+ get() = "coins-spent-on-auctions"
+
+ override fun perform(database: Connection, filter: AnalysisFilter): AnalysisResult {
+ val query = DBLogEntry.from(database)
+ .join(DBItemEntry, Clause { column(DBItemEntry.transactionId) eq column(DBLogEntry.transactionId) })
+ .where(Clause { column(DBItemEntry.itemId) eq ItemId.COINS })
+ .where(Clause { column(DBItemEntry.mode) eq ItemChange.ChangeDirection.LOST })
+ .where(Clause { column(DBLogEntry.type) eq TransactionType.AUCTION_BOUGHT })
+ .select(DBItemEntry.size, DBLogEntry.transactionId)
+ filter.applyTo(query)
+ val spentThatDay = mutableMapOf<LocalDate, Double>()
+ for (resultRow in query) {
+ val timestamp = resultRow[DBLogEntry.transactionId].getTimestamp()
+ val damage = resultRow[DBItemEntry.size]
+ val localZone = filter.timeZone()
+ val localDate = timestamp.atZone(localZone).toLocalDate()
+ spentThatDay.merge(localDate, damage) { a, b -> a + b }
+ }
+ return AnalysisResult(
+ listOf(
+ Visualization(
+ "Coins spent on auctions",
+ xLabel = "Time",
+ yLabel = "Coins Spent that day",
+ dataPoints = spentThatDay.entries.map { (k, v) ->
+ DataPoint(k.atTime(12, 0).atZone(filter.timeZone()).toInstant(), v)
+ }
+ )
+ )
+ )
+ }
+} \ No newline at end of file
diff --git a/server/analysis/src/main/kotlin/moe/nea/ledger/analysis/Visualization.kt b/server/analysis/src/main/kotlin/moe/nea/ledger/analysis/Visualization.kt
new file mode 100644
index 0000000..d0c0d56
--- /dev/null
+++ b/server/analysis/src/main/kotlin/moe/nea/ledger/analysis/Visualization.kt
@@ -0,0 +1,30 @@
+package moe.nea.ledger.analysis
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import java.time.Instant
+
+@Serializable
+data class Visualization(
+ val label: String,
+ val xLabel: String,
+ val yLabel: String,
+ val dataPoints: List<DataPoint>
+)
+
+@Serializable
+data class DataPoint(
+ val time: @Serializable(InstantSerializer::class) Instant,
+ val value: Double,
+)
+
+object InstantSerializer : KSerializer<Instant> {
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("java.time.Instant", PrimitiveKind.LONG)
+ override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeLong(value.toEpochMilli())
+ override fun deserialize(decoder: Decoder): Instant = Instant.ofEpochMilli(decoder.decodeLong())
+} \ No newline at end of file
diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts
index 87f613a..b2a3222 100644
--- a/server/core/build.gradle.kts
+++ b/server/core/build.gradle.kts
@@ -2,19 +2,22 @@ plugins {
kotlin("jvm")
kotlin("plugin.serialization")
application
+ id("com.github.gmazzo.buildconfig")
}
-val ktor_version = "3.0.3"
dependencies {
- implementation(platform("io.ktor:ktor-bom:$ktor_version"))
- implementation("io.ktor:ktor-server-netty")
- implementation("io.ktor:ktor-server-status-pages")
- implementation("io.ktor:ktor-server-content-negotiation")
- implementation("io.ktor:ktor-server-openapi")
- implementation("io.ktor:ktor-serialization-kotlinx-json")
- implementation("io.ktor:ktor-server-compression")
- implementation(project(":database:impl"))
+ declareKtorVersion()
+ api("io.ktor:ktor-server-netty")
+ api("io.ktor:ktor-server-status-pages")
+ api("io.ktor:ktor-server-content-negotiation")
+ api("io.ktor:ktor-serialization-kotlinx-json")
+ api("io.ktor:ktor-server-compression")
+ api("io.ktor:ktor-server-cors")
+ api("sh.ondr:kotlin-json-schema:0.1.1")
+ api(project(":server:analysis"))
+ api(project(":database:impl"))
+ api(project(":server:swagger"))
runtimeOnly("ch.qos.logback:logback-classic:1.5.16")
runtimeOnly("org.xerial:sqlite-jdbc:3.45.3.0")
@@ -29,3 +32,6 @@ application {
"-Dledger.databasefolder=${project(":mod").file("run/money-ledger").absoluteFile}")
mainClass.set("moe.nea.ledger.server.core.ApplicationKt")
}
+buildConfig {
+ packageName("moe.nea.ledger.gen")
+}
diff --git a/server/core/src/main/kotlin/moe/nea/ledger/server/core/Application.kt b/server/core/src/main/kotlin/moe/nea/ledger/server/core/Application.kt
index 0ea6ed3..23b2a6a 100644
--- a/server/core/src/main/kotlin/moe/nea/ledger/server/core/Application.kt
+++ b/server/core/src/main/kotlin/moe/nea/ledger/server/core/Application.kt
@@ -6,29 +6,76 @@ import io.ktor.server.application.install
import io.ktor.server.netty.EngineMain
import io.ktor.server.plugins.compression.Compression
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.server.plugins.cors.routing.CORS
+import io.ktor.server.response.respondRedirect
+import io.ktor.server.routing.Routing
+import io.ktor.server.routing.get
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
+import kotlinx.serialization.json.Json
import moe.nea.ledger.database.Database
+import moe.nea.ledger.gen.BuildConfig
+import moe.nea.ledger.server.core.api.Documentation
+import moe.nea.ledger.server.core.api.Info
+import moe.nea.ledger.server.core.api.Server
import moe.nea.ledger.server.core.api.apiRouting
+import moe.nea.ledger.server.core.api.openApiDocsJson
+import moe.nea.ledger.server.core.api.openApiUi
+import moe.nea.ledger.server.core.api.setApiRoot
import java.io.File
fun main(args: Array<String>) {
EngineMain.main(args)
}
+interface AIOProvider {
+ fun Routing.installExtraRouting()
+ fun Application.module()
+}
fun Application.module() {
+ val aio = runCatching {
+ Class.forName("moe.nea.ledger.server.aio.AIO")
+ .newInstance() as AIOProvider
+ }.getOrNull()
+ aio?.run { module() }
install(Compression)
+ install(Documentation) {
+ info = Info(
+ "Ledger Analysis Server",
+ "Your local API for loading ledger data",
+ BuildConfig.VERSION
+ )
+ servers.add(
+ Server("http://localhost:8080/api", "Your Local Server")
+ )
+ }
install(ContentNegotiation) {
- json()
+ json(Json {
+ this.explicitNulls = false
+ this.encodeDefaults = true
+ })
// cbor()
}
- val database = Database(File(System.getProperty("ledger.databasefolder")))
+ install(CORS) {
+ anyHost()
+ }
+ val database = Database(File(System.getProperty("ledger.databasefolder",
+ "/home/nea/.local/share/PrismLauncher/instances/Skyblock/.minecraft/money-ledger")))
database.loadAndUpgrade()
routing {
route("/api") {
- this.apiRouting(database)
+ setApiRoot()
+ get { call.respondRedirect("/openapi/") }
+ apiRouting(database)
+ }
+ route("/api.json") {
+ openApiDocsJson()
+ }
+ route("/openapi") {
+ openApiUi("/api.json")
}
+ aio?.run { installExtraRouting() }
}
}
diff --git a/server/core/src/main/kotlin/moe/nea/ledger/server/core/api/BaseApi.kt b/server/core/src/main/kotlin/moe/nea/ledger/server/core/api/BaseApi.kt
index 264f74b..3240a65 100644
--- a/server/core/src/main/kotlin/moe/nea/ledger/server/core/api/BaseApi.kt
+++ b/server/core/src/main/kotlin/moe/nea/ledger/server/core/api/BaseApi.kt
@@ -1,18 +1,49 @@
package moe.nea.ledger.server.core.api
+import io.ktor.http.Url
+import io.ktor.http.toURI
import io.ktor.server.response.respond
-import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
-import io.ktor.server.routing.Routing
import io.ktor.server.routing.get
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.async
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.decodeFromStream
+import moe.nea.ledger.ItemChange
+import moe.nea.ledger.TransactionType
+import moe.nea.ledger.analysis.Analysis
+import moe.nea.ledger.analysis.AnalysisFilter
+import moe.nea.ledger.analysis.AnalysisResult
+import moe.nea.ledger.database.DBItemEntry
import moe.nea.ledger.database.DBLogEntry
import moe.nea.ledger.database.Database
+import moe.nea.ledger.database.sql.Clause
import moe.nea.ledger.server.core.Profile
+import moe.nea.ledger.utils.ULIDWrapper
+import java.time.Instant
+import java.util.ServiceLoader
+import java.util.UUID
fun Route.apiRouting(database: Database) {
- get("/") {
- call.respondText("K")
+ val allOfferedAnalysisServices: Map<String, Analysis> = run {
+ val serviceLoader = ServiceLoader.load(Analysis::class.java, environment.classLoader)
+ val map = mutableMapOf<String, Analysis>()
+ serviceLoader.forEach {
+ map[it.id] = it
+ }
+ map
}
+
get("/profiles") {
val profiles = DBLogEntry.from(database.connection)
.select(DBLogEntry.playerId, DBLogEntry.profileId)
@@ -21,5 +52,154 @@ fun Route.apiRouting(database: Database) {
Profile(it[DBLogEntry.playerId], it[DBLogEntry.profileId])
}
call.respond(profiles)
+ }.docs {
+ summary = "List all profiles and players known to ledger"
+ operationId = "listProfiles"
+ tag(Tags.PROFILE)
+ respondsOk {
+ schema<List<Profile>>()
+ }
+ }
+ @OptIn(DelicateCoroutinesApi::class)
+ val itemNames = GlobalScope.async {
+ val itemNamesUrl =
+ Url("https://github.com/nea89o/ledger-auxiliary-data/raw/refs/heads/master/data/item_names.json")
+ Json.decodeFromStream<Map<String, String>>(itemNamesUrl.toURI().toURL().openStream())
+ }
+ get("/analysis/execute") {
+ val analysis = allOfferedAnalysisServices[call.queryParameters["analysis"]] ?: TODO()
+ val start = call.queryParameters["tStart"]?.toLongOrNull()?.let { Instant.ofEpochMilli(it) } ?: TODO()
+ val end = call.queryParameters["tEnd"]?.toLongOrNull()?.let { Instant.ofEpochMilli(it) } ?: TODO()
+ val analysisResult = withContext(Dispatchers.IO) {
+ analysis.perform(
+ database.connection,
+ object : AnalysisFilter {
+ override val startWindow: Instant
+ get() = start
+ override val endWindow: Instant
+ get() = end
+ override val profiles: List<UUID>
+ get() = listOf()
+ }
+ )
+ }
+ call.respond(analysisResult)
+ }.docs {
+ summary = "Execute an analysis on a given timeframe"
+ operationId = "executeAnalysis"
+ queryParameter<String>("analysis", description = "An analysis id obtained from getAnalysis")
+ queryParameter<Long>("tStart", description = "The start of the timeframe to analyze")
+ queryParameter<Long>("tEnd",
+ description = "The end of the timeframe to analyze. Make sure to use the end of the day if you want the entire day included.")
+ tag(Tags.DATA)
+ respondsOk {
+ schema<AnalysisResult>()
+ }
+ }
+ get("/analysis/list") {
+ call.respond(allOfferedAnalysisServices.values.map {
+ AnalysisListing(it.name, it.id)
+ })
+ }.docs {
+ summary = "List all installed analysis"
+ operationId = "getAnalysis"
+ tag(Tags.DATA)
+ respondsOk {
+ schema<List<AnalysisListing>>()
+ }
+ }
+ get("/item") {
+ val itemIds = call.queryParameters.getAll("itemId")?.toSet() ?: emptySet()
+ val itemNameMap = itemNames.await()
+ call.respond(itemIds.associateWith { itemNameMap[it] })
+ }.docs {
+ summary = "Get item names for item ids"
+ operationId = "getItemNames"
+ tag(Tags.HYPIXEL)
+ queryParameter<List<String>>("itemId")
+ respondsOk {
+ schema<Map<String, String?>>()
+ }
+ }
+ get("/entries") {
+ val logs = mutableMapOf<ULIDWrapper, LogEntry>()
+ val items = mutableMapOf<ULIDWrapper, MutableList<SerializableItemChange>>()
+ withContext(Dispatchers.IO) {
+ DBLogEntry.from(database.connection)
+ .join(DBItemEntry, Clause { column(DBItemEntry.transactionId) eq column(DBLogEntry.transactionId) })
+ .select(DBLogEntry.profileId,
+ DBLogEntry.playerId,
+ DBLogEntry.transactionId,
+ DBLogEntry.type,
+ DBItemEntry.mode,
+ DBItemEntry.itemId,
+ DBItemEntry.size)
+ .forEach { row ->
+ logs.getOrPut(row[DBLogEntry.transactionId]) {
+ LogEntry(row[DBLogEntry.type],
+ row[DBLogEntry.transactionId],
+ listOf())
+ }
+ items.getOrPut(row[DBLogEntry.transactionId]) { mutableListOf() }
+ .add(SerializableItemChange(
+ row[DBItemEntry.itemId].string,
+ row[DBItemEntry.mode],
+ row[DBItemEntry.size],
+ ))
+ }
+ }
+ val compiled = logs.values.map { it.copy(items = items[it.id]!!) }
+ call.respond(compiled)
+ }.docs {
+ summary = "Get all log entries"
+ operationId = "getLogEntries"
+ tag(Tags.DATA)
+ respondsOk {
+ schema<List<LogEntry>>()
+ }
+ }
+}
+
+@Serializable
+data class AnalysisListing(
+ val name: String,
+ val id: String,
+)
+
+@Serializable
+data class LogEntry(
+ val type: TransactionType,
+ val id: @Serializable(ULIDSerializer::class) ULIDWrapper,
+ val items: List<SerializableItemChange>,
+)
+
+@Serializable
+data class SerializableItemChange(
+ val itemId: String,
+ val direction: ItemChange.ChangeDirection,
+ val amount: Double,
+)
+
+object ULIDSerializer : KSerializer<ULIDWrapper> {
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ULID", PrimitiveKind.STRING)
+
+ override fun deserialize(decoder: Decoder): ULIDWrapper {
+ return ULIDWrapper(decoder.decodeString())
+ }
+
+ override fun serialize(encoder: Encoder, value: ULIDWrapper) {
+ encoder.encodeString(value.wrapped)
}
}
+
+enum class Tags : IntoTag {
+ PROFILE,
+ HYPIXEL,
+ MANAGEMENT,
+ DATA,
+ ;
+
+ override fun intoTag(): String {
+ return name
+ }
+} \ No newline at end of file
diff --git a/server/core/test-requests/test-hello.http b/server/core/test-requests/test-hello.http
deleted file mode 100644
index 3ddf352..0000000
--- a/server/core/test-requests/test-hello.http
+++ /dev/null
@@ -1,5 +0,0 @@
-### GET request to example server
-GET localhost:8080/
-
-### GET profiles
-GET localhost:8080/api/profiles \ No newline at end of file
diff --git a/server/frontend/.gitignore b/server/frontend/.gitignore
new file mode 100644
index 0000000..76add87
--- /dev/null
+++ b/server/frontend/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+dist \ No newline at end of file
diff --git a/server/frontend/build.gradle.kts b/server/frontend/build.gradle.kts
new file mode 100644
index 0000000..72fe6ea
--- /dev/null
+++ b/server/frontend/build.gradle.kts
@@ -0,0 +1,17 @@
+import com.github.gradle.node.pnpm.task.PnpmTask
+
+plugins {
+ id("com.github.node-gradle.node") version "7.1.0"
+ `java-library`
+}
+
+val webDist by tasks.register("webDist", PnpmTask::class) {
+ dependsOn(tasks.pnpmInstall)
+ args.addAll("build")
+ outputs.dir("dist")
+}
+tasks.jar {
+ from(webDist) {
+ into("ledger-web-dist/")
+ }
+}
diff --git a/server/frontend/index.html b/server/frontend/index.html
new file mode 100644
index 0000000..48c59fc
--- /dev/null
+++ b/server/frontend/index.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <meta name="theme-color" content="#000000" />
+ <link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.ico" />
+ <title>Solid App</title>
+ </head>
+ <body>
+ <noscript>You need to enable JavaScript to run this app.</noscript>
+ <div id="root"></div>
+
+ <script src="/src/index.tsx" type="module"></script>
+ </body>
+</html>
diff --git a/server/frontend/package.json b/server/frontend/package.json
new file mode 100644
index 0000000..a8a8880
--- /dev/null
+++ b/server/frontend/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "ledger-frontend",
+ "version": "0.0.0",
+ "description": "",
+ "type": "module",
+ "scripts": {
+ "start": "vite",
+ "dev": "vite",
+ "build": "vite build",
+ "serve": "vite preview",
+ "test:ts": "tsc --noEmit",
+ "genApi": "openapi-typescript http://localhost:8080/api.json -o src/api-schema.d.ts"
+ },
+ "license": "MIT",
+ "devDependencies": {
+ "openapi-typescript": "^7.5.2",
+ "solid-devtools": "^0.33.0",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.0",
+ "vite-plugin-solid": "^2.11.0"
+ },
+ "dependencies": {
+ "@solidjs/router": "^0.15.3",
+ "apexcharts": "^4.3.0",
+ "moment": "^2.30.1",
+ "openapi-fetch": "^0.13.4",
+ "solid-apexcharts": "^0.4.0",
+ "solid-js": "^1.9.3"
+ },
+ "devEngines": {
+ "packageManager": {
+ "name": "pnpm",
+ "onFail": "error"
+ }
+ },
+ "packageManager": "pnpm@^9.3.0"
+}
diff --git a/server/frontend/pnpm-lock.yaml b/server/frontend/pnpm-lock.yaml
new file mode 100644
index 0000000..6483404
--- /dev/null
+++ b/server/frontend/pnpm-lock.yaml
@@ -0,0 +1,1920 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@solidjs/router':
+ specifier: ^0.15.3
+ version: 0.15.3(solid-js@1.9.4)
+ apexcharts:
+ specifier: ^4.3.0
+ version: 4.3.0
+ moment:
+ specifier: ^2.30.1
+ version: 2.30.1
+ openapi-fetch:
+ specifier: ^0.13.4
+ version: 0.13.4
+ solid-apexcharts:
+ specifier: ^0.4.0
+ version: 0.4.0(apexcharts@4.3.0)(solid-js@1.9.4)
+ solid-js:
+ specifier: ^1.9.3
+ version: 1.9.4
+ devDependencies:
+ openapi-typescript:
+ specifier: ^7.5.2
+ version: 7.5.2(typescript@5.7.3)
+ solid-devtools:
+ specifier: ^0.33.0
+ version: 0.33.0(solid-js@1.9.4)(vite@6.0.7(sass@1.83.4))
+ typescript:
+ specifier: ^5.7.2
+ version: 5.7.3
+ vite:
+ specifier: ^6.0.0
+ version: 6.0.7(sass@1.83.4)
+ vite-plugin-solid:
+ specifier: ^2.11.0
+ version: 2.11.0(solid-js@1.9.4)(vite@6.0.7(sass@1.83.4))
+
+packages:
+
+ '@ampproject/remapping@2.3.0':
+ resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
+ engines: {node: '>=6.0.0'}
+
+ '@babel/code-frame@7.26.2':
+ resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.26.5':
+ resolution: {integrity: sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.26.0':
+ resolution: {integrity: sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.26.5':
+ resolution: {integrity: sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.26.5':
+ resolution: {integrity: sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.18.6':
+ resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.25.9':
+ resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.26.0':
+ resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-plugin-utils@7.26.5':
+ resolution: {integrity: sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.25.9':
+ resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.25.9':
+ resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-option@7.25.9':
+ resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.26.0':
+ resolution: {integrity: sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.26.5':
+ resolution: {integrity: sha512-SRJ4jYmXRqV1/Xc+TIVG84WjHBXKlxO9sHQnA2Pf12QQEAp1LOh6kDzNHXcUnbH1QI0FDoPPVOt+vyUDucxpaw==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/plugin-syntax-jsx@7.25.9':
+ resolution: {integrity: sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437