Skip to content
This repository has been archived by the owner on May 5, 2023. It is now read-only.

Add console app and refactor #6

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import Dependencies.kotlinXSerializationJson
import Dependencies.postgresql

plugins {
kotlin("jvm") version "1.8.10"
`java-library`
kotlin("jvm") version "1.8.21"
application
}

java { toolchain { languageVersion.set(JavaLanguageVersion.of(8)) } }
java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }

repositories {
mavenCentral()
Expand All @@ -25,7 +25,11 @@ tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
}

kotlin {
jvmToolchain(11)
jvmToolchain(17)
}

application {
mainClass.set("com.agilogy.timetracking.driveradapters.console.ConsoleAppKt")
}

dependencies {
Expand Down
15 changes: 10 additions & 5 deletions app/src/main/kotlin/com/agilogy/db/sql/Sql.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,18 @@ object Sql {
}
}

suspend fun <A> DataSource.sqlTransaction(isolationLevel: TransactionIsolationLevel, f: context(Connection) () -> A): A =
suspend fun <A> DataSource.transaction(isolationLevel: TransactionIsolationLevel, f: context(Connection) () -> A): A =
withContext(Dispatchers.IO) {
connection.use {
with(it) {
autoCommit = false
transactionIsolation = isolationLevel.value
f(this).also { commit() }
val previousAutoCommit = it.autoCommit
try {
with(it) {
autoCommit = false
transactionIsolation = isolationLevel.value
f(this).also { commit() }
}
}finally{
it.autoCommit = previousAutoCommit
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package com.agilogy.timetracking.domain
package com.agilogy.time

import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.YearMonth
import java.time.ZoneId
import java.time.ZoneOffset
import kotlin.time.Duration

fun LocalDateTime.toLocalInstant() = atZone(ZoneOffset.systemDefault()).toInstant()
fun LocalDateTime.toLocalInstant() = atZone(ZoneId.systemDefault()).toInstant()
fun LocalDate.toLocalInstant() = atTime(0, 0).toLocalInstant()

fun ClosedRange<LocalDate>.toInstantRange(): ClosedRange<Instant> =
Expand All @@ -21,4 +24,14 @@ infix fun ClosedRange<Instant>.intersection(other: ClosedRange<Instant>): Closed
fun <A : Comparable<A>> max(a: A, b: A) = if (a > b) a else b
fun <A : Comparable<A>> min(a: A, b: A) = if (a <= b) a else b

fun Iterable<Duration>.sum(): Duration = fold(Duration.ZERO) { acc, d -> acc + d }
fun Iterable<Duration>.sum(): Duration = fold(Duration.ZERO) { acc, d -> acc + d }

fun YearMonth.toInstantRange(): ClosedRange<Instant> =
atDay(1).atStartOfDay().atZone(ZoneOffset.systemDefault()).toInstant()..
atEndOfMonth().atTime(23, 59, 59).atZone(ZoneOffset.systemDefault()).toInstant()

fun YearMonth.toLocalDateRange(): ClosedRange<LocalDate> =
atDay(1)..atEndOfMonth()

fun Instant.localTime(): LocalTime = atZone(ZoneId.systemDefault()).toLocalTime()
fun Instant.localDate(): LocalDate = atZone(ZoneId.systemDefault()).toLocalDate()
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ package com.agilogy.timetracking.domain

import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime

interface TimeEntriesRepository {

suspend fun saveTimeEntries(timeEntries: List<TimeEntry>)
suspend fun getHoursByDeveloperAndProject(range: ClosedRange<Instant>): Map<DeveloperProject, Hours>
suspend fun getHoursByDeveloperAndProject(range: ClosedRange<Instant>): Map<Pair<Developer, Project>, Hours>
suspend fun getDeveloperHoursByProjectAndDate(
developer: String,
developer: Developer,
dateRange: ClosedRange<LocalDate>,
): List<Triple<LocalDate, String, Hours>>
): List<Triple<LocalDate, Project, Hours>>

suspend fun listTimeEntries(timeRange: ClosedRange<Instant>, developer: Developer?): List<TimeEntry>
}
13 changes: 11 additions & 2 deletions app/src/main/kotlin/com/agilogy/timetracking/domain/TimeEntry.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
package com.agilogy.timetracking.domain

import com.agilogy.time.localDate
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import kotlin.time.Duration
import kotlin.time.toKotlinDuration

data class TimeEntry(val developer: String, val project: String, val range: ClosedRange<Instant>) {
data class TimeEntry(val developer: Developer, val project: Project, val range: ClosedRange<Instant>) {
val duration: Duration = java.time.Duration.between(range.start, range.endInclusive.plusNanos(1)).toKotlinDuration()
val localDate: LocalDate by lazy { range.start.atZone(ZoneOffset.systemDefault()).toLocalDate() }
val localDate: LocalDate by lazy { range.start.localDate() }
}



@JvmInline
value class Developer(val name: String)

@JvmInline
value class Project(val name: String)
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
package com.agilogy.timetracking.domain

import arrow.core.Tuple4
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime

interface TimeTrackingApp {

suspend fun saveTimeEntries(developer: String, timeEntries: List<DeveloperTimeEntry>)
suspend fun getDeveloperHours(range: ClosedRange<Instant>): Map<DeveloperProject, Hours>
suspend fun saveTimeEntries(developer: Developer, timeEntries: List<Pair<Project, ClosedRange<Instant>>>)
suspend fun getDeveloperHours(range: ClosedRange<Instant>): Map<Pair<Developer, Project>, Hours>
suspend fun getDeveloperHoursByProjectAndDate(developer: Developer, dateRange: ClosedRange<LocalDate>):
List<Triple<LocalDate, Project, Hours>>

suspend fun listTimeEntries(dateRange: ClosedRange<LocalDate>, developer: Developer?):
List<Tuple4<Developer, Project, LocalDate, ClosedRange<LocalTime>>>
}

@JvmInline
value class Hours(val value: Int)
data class DeveloperProject(val developer: String, val project: String)
data class DeveloperTimeEntry(val project: String, val range: ClosedRange<Instant>)

Original file line number Diff line number Diff line change
@@ -1,13 +1,44 @@
package com.agilogy.timetracking.domain

import arrow.core.Tuple4
import com.agilogy.time.localDate
import com.agilogy.time.localTime
import com.agilogy.time.toInstantRange
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime

class TimeTrackingAppPrd(private val timeEntriesRepository: TimeEntriesRepository) : TimeTrackingApp {

override suspend fun saveTimeEntries(developer: String, timeEntries: List<DeveloperTimeEntry>) {
timeEntriesRepository.saveTimeEntries(timeEntries.map { TimeEntry(developer, it.project, it.range) })
override suspend fun saveTimeEntries(developer: Developer, timeEntries: List<Pair<Project, ClosedRange<Instant>>>) {
timeEntriesRepository.saveTimeEntries(timeEntries.map { TimeEntry(developer, it.first, it.second) })
}

override suspend fun getDeveloperHours(range: ClosedRange<Instant>): Map<DeveloperProject, Hours> =
timeEntriesRepository.getHoursByDeveloperAndProject(range)
override suspend fun getDeveloperHours(range: ClosedRange<Instant>): Map<Pair<Developer, Project>, Hours> =
timeEntriesRepository.getHoursByDeveloperAndProject(range)

override suspend fun getDeveloperHoursByProjectAndDate(developer: Developer, dateRange: ClosedRange<LocalDate>):
List<Triple<LocalDate, Project, Hours>> =
timeEntriesRepository.getDeveloperHoursByProjectAndDate(developer, dateRange)

override suspend fun listTimeEntries(dateRange: ClosedRange<LocalDate>, developer: Developer?):
List<Tuple4<Developer, Project, LocalDate, ClosedRange<LocalTime>>> {
val timeEntries = timeEntriesRepository.listTimeEntries(dateRange.toInstantRange(), developer)
return timeEntries.flatMap { timeEntry ->
fun row(date: LocalDate, range: ClosedRange<LocalTime>) =
Tuple4(timeEntry.developer, timeEntry.project, date, range)

val res = if (timeEntry.range.endInclusive.localDate() != timeEntry.localDate) {
listOf(
row(timeEntry.localDate, timeEntry.range.start.localTime()..LocalTime.of(23, 59, 59)),
row(timeEntry.localDate.plusDays(1), LocalTime.of(0, 0)..timeEntry.range.endInclusive.localTime())
)
} else {
listOf(
row(timeEntry.localDate, timeEntry.range.start.localTime()..timeEntry.range.endInclusive.localTime())
)
}
res
}
}
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
package com.agilogy.timetracking.drivenadapters

import com.agilogy.timetracking.domain.DeveloperProject
import com.agilogy.timetracking.domain.Hours
import com.agilogy.timetracking.domain.TimeEntriesRepository
import com.agilogy.timetracking.domain.TimeEntry
import com.agilogy.db.sql.ResultSetView
import com.agilogy.db.sql.Sql.batchUpdate
import com.agilogy.db.sql.Sql.select
import com.agilogy.db.sql.Sql.sql
import com.agilogy.db.sql.SqlParameter
import com.agilogy.db.sql.param
import com.agilogy.timetracking.domain.toInstantRange
import com.agilogy.time.toInstantRange
import com.agilogy.timetracking.domain.Developer
import com.agilogy.timetracking.domain.Hours
import com.agilogy.timetracking.domain.Project
import com.agilogy.timetracking.domain.TimeEntriesRepository
import com.agilogy.timetracking.domain.TimeEntry
import java.time.Instant
import java.time.LocalDate
import javax.sql.DataSource
import kotlin.math.ceil

class PostgresTimeEntriesRepository(private val dataSource: DataSource) : TimeEntriesRepository {

private val Developer.param: SqlParameter get() = name.param
private val Project.param: SqlParameter get() = name.param
private fun ResultSetView.developer(columnIndex: Int): Developer? = string(columnIndex)?.let { Developer(it) }
private fun ResultSetView.project(columnIndex: Int): Project? = string(columnIndex)?.let { Project(it) }

companion object {
val dbMigrations = listOf(
"""create table time_entries(
Expand All @@ -30,34 +39,48 @@ class PostgresTimeEntriesRepository(private val dataSource: DataSource) : TimeEn
override suspend fun saveTimeEntries(timeEntries: List<TimeEntry>) = dataSource.sql {
val sql = """insert into time_entries(developer, project, start, "end") values (?, ?, ?, ?)"""
batchUpdate(sql) {
timeEntries.forEach { addBatch(it.developer.param, it.project.param, it.range.start.param, it.range.endInclusive.param) }
timeEntries.forEach {
addBatch(
it.developer.param, it.project.param, it.range.start.param, it.range.endInclusive
.param
)
}
}
Unit
}

override suspend fun getHoursByDeveloperAndProject(range: ClosedRange<Instant>): Map<DeveloperProject, Hours> = dataSource.sql {
val sql = """select developer, project, extract(EPOCH from sum("end" - start))
override suspend fun getHoursByDeveloperAndProject(range: ClosedRange<Instant>): Map<Pair<Developer, Project>, Hours> = dataSource.sql {
val sql = """select developer, project, extract(EPOCH from sum(least("end", ?) - greatest(start, ?)))
|from time_entries
|where "end" > ? and start < ?
|group by developer, project""".trimMargin()
select(sql, range.start.param, range.endInclusive.param) {
DeveloperProject(it.string(1)!!, it.string(2)!!) to Hours((it.long(3)!! / 3_600).toInt())
}
}.toMap()
select(sql, range.endInclusive.param, range.start.param, range.start.param, range.endInclusive.param) {
(it.developer(1)!! to it.project(2)!!) to Hours(ceil(it.long(3)!! / 3_600.0).toInt())
}.toMap().filterValues { it.value > 0 }
}

override suspend fun getDeveloperHoursByProjectAndDate(
developer: String,
dateRange: ClosedRange<LocalDate>,
): List<Triple<LocalDate, String, Hours>> = dataSource.sql {
developer: Developer,
dateRange: ClosedRange<LocalDate>
): List<Triple<LocalDate, Project, Hours>> = dataSource.sql {
val instantRange = dateRange.toInstantRange()
val sql = """select date(start at time zone 'CEST'), project, extract(EPOCH from sum("end" - start))
|from time_entries
|where "start" > ? and start < ?
|where "start" > ? and start < ? and developer = ?
|group by date(start at time zone 'CEST'), project
|order by date(start at time zone 'CEST'), project
|""".trimMargin()
select(sql, instantRange.start.param, instantRange.endInclusive.param) {
Triple(LocalDate.parse(it.string(1)!!), it.string(2)!!, Hours((it.long(3)!! / 3_600).toInt()))
select(sql, instantRange.start.param, instantRange.endInclusive.param, developer.param) {
Triple(LocalDate.parse(it.string(1)!!), it.project(2)!!, Hours((it.long(3)!! / 3_600).toInt()))
}
}

override suspend fun listTimeEntries(timeRange: ClosedRange<Instant>, developer: Developer?): List<TimeEntry> = dataSource.sql {
val sql = """select developer, project, start, "end"
|from time_entries
|where "end" > ? and start < ?""".trimMargin()
select(sql, timeRange.start.param, timeRange.endInclusive.param) {
TimeEntry(it.developer(1)!!, it.project(2)!!, it.timestamp(3)!!..it.timestamp(4)!!)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.agilogy.timetracking.driveradapters.console

import arrow.core.raise.Raise
import com.agilogy.timetracking.domain.Developer
import com.agilogy.timetracking.domain.Project
import java.time.Instant
import java.time.YearMonth

data class ArgsParseError(val message: String)

class ArgsParser {

context(Raise<ArgsParseError>)
fun parse(args: Array<String>): Command {
fun arg(index: Int): String = if (index < args.size) args[index] else ""

return if (args.isEmpty()) Help
else if (arg(0) == "report") when (args.size - 1) {
0 -> GlobalReport(YearMonth.now())
1 -> GlobalReport(parseMonth(arg(1)))
2 -> DeveloperReport(parseMonth(arg(1)), Developer(arg(2)))
else -> raise(ArgsParseError("Invalid number of arguments for command ${arg(0)}"))
} else if (arg(0) == "list") {
ListTimeEntries(parseMonth(arg(1)), args.getOrElse(2) { null }?.let { Developer(it) })
} else if (arg(0) == "add") {
AddTimeEntry(Developer(arg(1)), Project(arg(2)),
parseInstant(arg(3))..parseInstant(arg(4))
)
} else raise(ArgsParseError("Unknown command ${arg(0)}"))
}

context(Raise<ArgsParseError>)
private fun parseMonth(value: String): YearMonth = parse("month", value) { YearMonth.parse(it) }

context(Raise<ArgsParseError>)
private fun parseInstant(value: String): Instant = parse("instant", value) { Instant.parse(it) }

context(Raise<ArgsParseError>)
private fun <A> parse(type: String, value: String, parse: (String) -> A): A =
runCatching { parse(value) }.getOrElse { raise(ArgsParseError("Invalid $type $value")) }

fun help(): String =
"""
Usage: timetracking <command> [options]

Commands:
add <developer> <project> <start> <end> Adds a new time entry
developer: developer name
project: project name
start: start time in the format yyyy-MM-dd HH:mm
end: end time in the format yyyy-MM-dd HH:mm or just HH:mm
list <month> [<developer?] List the time entries for the given month and developer
month: month in the format yyyy-MM
developer: developer name
report [<month>] Show the global time tracking report for the given month
month: month in the format yyyy-MM, defaults to current month
report <month> <developer> Show the time tracking report for the given developer and month
month: month in the format yyyy-MM
developer: developer name
""".trimIndent()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.agilogy.timetracking.driveradapters.console

import com.agilogy.timetracking.domain.Developer
import com.agilogy.timetracking.domain.Project
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.YearMonth

sealed interface Command

object Help: Command
data class GlobalReport(val yearMonth: YearMonth): Command
data class DeveloperReport(val yearMonth: YearMonth, val developer: Developer): Command
data class ListTimeEntries(val yearMonth: YearMonth, val developer: Developer?): Command
data class AddTimeEntry(val developer: Developer, val project: Project, val range: ClosedRange<Instant>): Command
Loading