diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8dbf0b4..fac2a56 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() @@ -25,7 +25,11 @@ tasks.withType { } kotlin { - jvmToolchain(11) + jvmToolchain(17) +} + +application { + mainClass.set("com.agilogy.timetracking.driveradapters.console.ConsoleAppKt") } dependencies { diff --git a/app/src/main/kotlin/com/agilogy/db/sql/Sql.kt b/app/src/main/kotlin/com/agilogy/db/sql/Sql.kt index 22d8cc2..c405834 100644 --- a/app/src/main/kotlin/com/agilogy/db/sql/Sql.kt +++ b/app/src/main/kotlin/com/agilogy/db/sql/Sql.kt @@ -31,13 +31,18 @@ object Sql { } } - suspend fun DataSource.sqlTransaction(isolationLevel: TransactionIsolationLevel, f: context(Connection) () -> A): A = + suspend fun 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 } } } diff --git a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeUtils.kt b/app/src/main/kotlin/com/agilogy/time/TimeUtils.kt similarity index 53% rename from app/src/main/kotlin/com/agilogy/timetracking/domain/TimeUtils.kt rename to app/src/main/kotlin/com/agilogy/time/TimeUtils.kt index b191c84..ba3c96f 100644 --- a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeUtils.kt +++ b/app/src/main/kotlin/com/agilogy/time/TimeUtils.kt @@ -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.toInstantRange(): ClosedRange = @@ -21,4 +24,14 @@ infix fun ClosedRange.intersection(other: ClosedRange): Closed fun > max(a: A, b: A) = if (a > b) a else b fun > min(a: A, b: A) = if (a <= b) a else b -fun Iterable.sum(): Duration = fold(Duration.ZERO) { acc, d -> acc + d } \ No newline at end of file +fun Iterable.sum(): Duration = fold(Duration.ZERO) { acc, d -> acc + d } + +fun YearMonth.toInstantRange(): ClosedRange = + atDay(1).atStartOfDay().atZone(ZoneOffset.systemDefault()).toInstant().. + atEndOfMonth().atTime(23, 59, 59).atZone(ZoneOffset.systemDefault()).toInstant() + +fun YearMonth.toLocalDateRange(): ClosedRange = + atDay(1)..atEndOfMonth() + +fun Instant.localTime(): LocalTime = atZone(ZoneId.systemDefault()).toLocalTime() +fun Instant.localDate(): LocalDate = atZone(ZoneId.systemDefault()).toLocalDate() \ No newline at end of file diff --git a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeEntriesRepository.kt b/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeEntriesRepository.kt index e5110eb..a93b8dc 100644 --- a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeEntriesRepository.kt +++ b/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeEntriesRepository.kt @@ -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) - suspend fun getHoursByDeveloperAndProject(range: ClosedRange): Map + suspend fun getHoursByDeveloperAndProject(range: ClosedRange): Map, Hours> suspend fun getDeveloperHoursByProjectAndDate( - developer: String, + developer: Developer, dateRange: ClosedRange, - ): List> + ): List> + + suspend fun listTimeEntries(timeRange: ClosedRange, developer: Developer?): List } diff --git a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeEntry.kt b/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeEntry.kt index 35e90bb..f0db1da 100644 --- a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeEntry.kt +++ b/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeEntry.kt @@ -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) { +data class TimeEntry(val developer: Developer, val project: Project, val range: ClosedRange) { 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) \ No newline at end of file diff --git a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeTrackingApp.kt b/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeTrackingApp.kt index 7d9b08a..22125cb 100644 --- a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeTrackingApp.kt +++ b/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeTrackingApp.kt @@ -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) - suspend fun getDeveloperHours(range: ClosedRange): Map + suspend fun saveTimeEntries(developer: Developer, timeEntries: List>>) + suspend fun getDeveloperHours(range: ClosedRange): Map, Hours> + suspend fun getDeveloperHoursByProjectAndDate(developer: Developer, dateRange: ClosedRange): + List> + + suspend fun listTimeEntries(dateRange: ClosedRange, developer: Developer?): + List>> } @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) + diff --git a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppPrd.kt b/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppPrd.kt index 7696926..f58245c 100644 --- a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppPrd.kt +++ b/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppPrd.kt @@ -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) { - timeEntriesRepository.saveTimeEntries(timeEntries.map { TimeEntry(developer, it.project, it.range) }) + override suspend fun saveTimeEntries(developer: Developer, timeEntries: List>>) { + timeEntriesRepository.saveTimeEntries(timeEntries.map { TimeEntry(developer, it.first, it.second) }) } - override suspend fun getDeveloperHours(range: ClosedRange): Map = - timeEntriesRepository.getHoursByDeveloperAndProject(range) + override suspend fun getDeveloperHours(range: ClosedRange): Map, Hours> = + timeEntriesRepository.getHoursByDeveloperAndProject(range) + + override suspend fun getDeveloperHoursByProjectAndDate(developer: Developer, dateRange: ClosedRange): + List> = + timeEntriesRepository.getDeveloperHoursByProjectAndDate(developer, dateRange) + + override suspend fun listTimeEntries(dateRange: ClosedRange, developer: Developer?): + List>> { + val timeEntries = timeEntriesRepository.listTimeEntries(dateRange.toInstantRange(), developer) + return timeEntries.flatMap { timeEntry -> + fun row(date: LocalDate, range: ClosedRange) = + 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 + } + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/agilogy/timetracking/drivenadapters/PostgresTimeEntriesRepository.kt b/app/src/main/kotlin/com/agilogy/timetracking/drivenadapters/PostgresTimeEntriesRepository.kt index d999741..1644c9e 100644 --- a/app/src/main/kotlin/com/agilogy/timetracking/drivenadapters/PostgresTimeEntriesRepository.kt +++ b/app/src/main/kotlin/com/agilogy/timetracking/drivenadapters/PostgresTimeEntriesRepository.kt @@ -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( @@ -30,34 +39,48 @@ class PostgresTimeEntriesRepository(private val dataSource: DataSource) : TimeEn override suspend fun saveTimeEntries(timeEntries: List) = 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): Map = dataSource.sql { - val sql = """select developer, project, extract(EPOCH from sum("end" - start)) + override suspend fun getHoursByDeveloperAndProject(range: ClosedRange): Map, 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, - ): List> = dataSource.sql { + developer: Developer, + dateRange: ClosedRange + ): List> = 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, developer: Developer?): List = 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)!!) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/ArgsParser.kt b/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/ArgsParser.kt new file mode 100644 index 0000000..3a96345 --- /dev/null +++ b/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/ArgsParser.kt @@ -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) + fun parse(args: Array): 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) + private fun parseMonth(value: String): YearMonth = parse("month", value) { YearMonth.parse(it) } + + context(Raise) + private fun parseInstant(value: String): Instant = parse("instant", value) { Instant.parse(it) } + + context(Raise) + private fun parse(type: String, value: String, parse: (String) -> A): A = + runCatching { parse(value) }.getOrElse { raise(ArgsParseError("Invalid $type $value")) } + + fun help(): String = + """ + Usage: timetracking [options] + + Commands: + add 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 [] Show the global time tracking report for the given month + month: month in the format yyyy-MM, defaults to current month + report Show the time tracking report for the given developer and month + month: month in the format yyyy-MM + developer: developer name + """.trimIndent() +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/Command.kt b/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/Command.kt new file mode 100644 index 0000000..f1419e2 --- /dev/null +++ b/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/Command.kt @@ -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): Command \ No newline at end of file diff --git a/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/Console.kt b/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/Console.kt new file mode 100644 index 0000000..f23ed29 --- /dev/null +++ b/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/Console.kt @@ -0,0 +1,55 @@ +package com.agilogy.timetracking.driveradapters.console + +import arrow.core.Tuple4 +import com.agilogy.timetracking.domain.Developer +import com.agilogy.timetracking.domain.Hours +import com.agilogy.timetracking.domain.Project +import java.time.LocalDate +import java.time.LocalTime +import kotlin.math.max + +class Console { + + fun printHelp(message: String): Unit = println(message) + + fun print(report: Map, Hours>) { + val table = table( + report.map { (dp, hours) -> listOf(dp.first.name, dp.second.name, hours.value.toString()) }, + "Developer", "Project", "Hours" + ) + println(table) + } + + fun print(report: List>) { + val table = table( + report.map { (date, project, hours) -> listOf(date.toString(), project.name, hours.value.toString()) }, + "Date", "Project", "Hours" + ) + println(table) + } + + fun table(data: List>, vararg columns: String): String { + val columnLengths = columns.mapIndexed { i, header -> max(data.maxOfOrNull { it[i].length } ?: 0, header.length) } + val separators = columnLengths.map { "-" * it } + return listOf(columns.toList(), separators, * data.toTypedArray()).joinToString(separator = "\n") { row -> + row.zip(columnLengths).joinToString(" ") { (value, length) -> value.padEnd(length) } + } + } + + private operator fun String.times(n: Int): String = repeat(n) + + fun printTimeEntries(listTimeEntries: List>>): Unit = println( + table( + listTimeEntries.map { (developer, project, date, range) -> + listOf( + developer.name, + project.name, + date.toString(), + range.start.toString(), + range.endInclusive.toString() + ) + }, + "Developer", "Project", "Date", "Start", "End" + ) + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/ConsoleApp.kt b/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/ConsoleApp.kt new file mode 100644 index 0000000..5292165 --- /dev/null +++ b/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/ConsoleApp.kt @@ -0,0 +1,59 @@ +package com.agilogy.timetracking.driveradapters.console + +import arrow.core.raise.effect +import arrow.core.raise.fold +import com.agilogy.db.hikari.HikariCp +import com.agilogy.time.toInstantRange +import com.agilogy.time.toLocalDateRange +import com.agilogy.timetracking.domain.TimeTrackingApp +import com.agilogy.timetracking.domain.TimeTrackingAppPrd +import com.agilogy.timetracking.drivenadapters.PostgresTimeEntriesRepository +import kotlinx.coroutines.runBlocking +import java.time.ZoneOffset + +fun main(args: Array): Unit = runBlocking { + HikariCp.dataSource("jdbc:postgresql://localhost/test", "postgres", "postgres").use { dataSource -> + val timeEntriesRepository = PostgresTimeEntriesRepository(dataSource) + val timeTrackingApp = TimeTrackingAppPrd(timeEntriesRepository) + println("Your current zone id is ${ZoneOffset.systemDefault()}") + ConsoleApp(ArgsParser(), timeTrackingApp, Console()).main(args) + } +} + +class ConsoleApp( + private val argsParser: ArgsParser, + private val timeTrackingApp: TimeTrackingApp, + private val console: Console, +) { + + suspend fun main(args: Array) = + effect { runCommand(argsParser.parse(args)) }.fold( + { + println(it.message) + runCommand(Help) + }, + { + println("Command executed successfully") + } + ) + + private suspend fun runCommand(cmd: Command) { + when (cmd) { + is GlobalReport -> { + val report = timeTrackingApp.getDeveloperHours(cmd.yearMonth.toInstantRange()) + console.print(report) + } + + is DeveloperReport -> { + val report = timeTrackingApp.getDeveloperHoursByProjectAndDate(cmd.developer, cmd.yearMonth.toLocalDateRange()) + console.print(report) + } + + Help -> console.printHelp(argsParser.help()) + is ListTimeEntries -> + console.printTimeEntries(timeTrackingApp.listTimeEntries(cmd.yearMonth.toLocalDateRange(), cmd.developer)) + + is AddTimeEntry -> timeTrackingApp.saveTimeEntries(cmd.developer, listOf(cmd.project to cmd.range)) + } + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt b/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt index 49757ce..fe4caa1 100644 --- a/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt +++ b/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt @@ -10,68 +10,30 @@ class TimeTrackingAppTest : FunSpec() { val now = Instant.now() val hours = 1 val start = now.minusSeconds(hours * 3600L) - val developer = "John" - val project = "Acme Inc." + val developer = Developer("John") + val project = Project("Acme Inc.") test("Save time entries") { val timeEntriesRepository = InMemoryTimeEntriesRepository() val app = TimeTrackingAppPrd(timeEntriesRepository) - val developerTimeEntries = listOf(DeveloperTimeEntry(project, start..now)) + val developerTimeEntries = listOf(project to start..now) app.saveTimeEntries(developer, developerTimeEntries) val expected = listOf(TimeEntry(developer, project, start..now)) assertEquals(expected, timeEntriesRepository.getState()) - } test("Get hours per developer") { val timeEntriesRepository = InMemoryTimeEntriesRepository(listOf(TimeEntry(developer, project, start..now))) val app = TimeTrackingAppPrd(timeEntriesRepository) val result = app.getDeveloperHours(start..now) - val expected = mapOf(DeveloperProject(developer, project) to Hours(hours)) + val expected = mapOf((developer to project) to Hours(hours)) assertEquals(expected, result) } - xtest("Get hours per developer when range is inside the developer hours") { - val timeEntriesRepository = InMemoryTimeEntriesRepository(listOf(TimeEntry(developer, project, start..now))) - val app = TimeTrackingAppPrd(timeEntriesRepository) - val result = app.getDeveloperHours(start.plusSeconds(900)..now.minusSeconds(900)) - val expected = mapOf(DeveloperProject(developer, project) to Hours(1)) - assertEquals(expected, result) - } - test("Get hours per developer when range is bigger than the developer hours") { - val timeEntriesRepository = InMemoryTimeEntriesRepository(listOf(TimeEntry(developer, project, start..now))) - val app = TimeTrackingAppPrd(timeEntriesRepository) - val result = app.getDeveloperHours(start.minusSeconds(7200L)..now.plusSeconds(7200L)) - val expected = mapOf(DeveloperProject(developer, project) to Hours(1)) - assertEquals(expected, result) - } - xtest("Get hours per developer when range is outside the developer hours") { - val timeEntriesRepository = InMemoryTimeEntriesRepository(listOf(TimeEntry(developer, project, start..now))) - val app = TimeTrackingAppPrd(timeEntriesRepository) - val resultLeft = app.getDeveloperHours(start.minusSeconds(3600L)..now.minusSeconds(7200L)) - val resultRight = app.getDeveloperHours(start.plusSeconds(7200L)..now.plusSeconds(3600L)) - val expected = mapOf(DeveloperProject(developer, project) to Hours(0)) - assertEquals(expected, resultLeft) - assertEquals(expected, resultRight) - } - xtest("Get hours per developer when range makes no sense") { - val timeEntriesRepository = InMemoryTimeEntriesRepository(listOf(TimeEntry(developer, project, start..now))) - val app = TimeTrackingAppPrd(timeEntriesRepository) - val resultOutside = app.getDeveloperHours(start.plusSeconds(7200L)..now.minusSeconds(7200L)) - val resultInside = app.getDeveloperHours(start.plusSeconds(2700L)..now.minusSeconds(2700L)) - val expected = emptyMap() - assertEquals(expected, resultOutside) - assertEquals(expected, resultInside) - } - xtest("Get hours per developer when only one part of the range is inside") { - val timeEntriesRepository = InMemoryTimeEntriesRepository(listOf(TimeEntry(developer, project, start..now))) - val app = TimeTrackingAppPrd(timeEntriesRepository) - val resultStartInsideEndOutside = app.getDeveloperHours(start.plusSeconds(1600L)..now.plusSeconds(1600L)) - val resultStartOutsideEndInside = app.getDeveloperHours(start.minusSeconds(1600L)..now.minusSeconds(1600L)) - val expected = mapOf(DeveloperProject(developer, project) to Hours(1)) - assertEquals(expected, resultStartInsideEndOutside) - assertEquals(expected, resultStartOutsideEndInside) - } + // TODO: Test the other methods of the app + + // TODO: Specially test the logic in listTimeEntries + } } \ No newline at end of file diff --git a/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/InMemoryTimeEntriesRepository.kt b/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/InMemoryTimeEntriesRepository.kt index cda061a..e1a0b11 100644 --- a/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/InMemoryTimeEntriesRepository.kt +++ b/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/InMemoryTimeEntriesRepository.kt @@ -1,14 +1,16 @@ package com.agilogy.timetracking.drivenadapters -import com.agilogy.timetracking.domain.DeveloperProject +import com.agilogy.time.intersection +import com.agilogy.time.sum +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 com.agilogy.timetracking.domain.intersection -import com.agilogy.timetracking.domain.sum -import com.agilogy.timetracking.domain.toInstantRange import java.time.Instant import java.time.LocalDate +import kotlin.math.ceil import kotlin.math.roundToInt class InMemoryTimeEntriesRepository(initialState: List = emptyList()) : TimeEntriesRepository { @@ -23,19 +25,19 @@ class InMemoryTimeEntriesRepository(initialState: List = emptyList()) state.addAll(timeEntries) } - override suspend fun getHoursByDeveloperAndProject(range: ClosedRange): Map = + override suspend fun getHoursByDeveloperAndProject(range: ClosedRange): Map, Hours> = state.filterIsIn(range) - .groupBy({ DeveloperProject(it.developer, it.project) }) { it.duration } - .mapValues { Hours(it.value.sum().inWholeHours.toInt()) } + .groupBy({ it.developer to it.project }) { it.duration } + .mapValues { Hours(ceil(it.value.sum().inWholeSeconds / 3_600.0).toInt()) } private fun List.filterIsIn(range: ClosedRange) = mapNotNull { timeEntry -> range.intersection(timeEntry.range)?.let { timeEntry.copy(range = it) } } override suspend fun getDeveloperHoursByProjectAndDate( - developer: String, - dateRange: ClosedRange, - ): List> = + developer: Developer, + dateRange: ClosedRange + ): List> = state .filter { it.developer == developer } .filterIsIn(dateRange.toInstantRange()) @@ -43,5 +45,10 @@ class InMemoryTimeEntriesRepository(initialState: List = emptyList()) .mapValues { Hours(((it.value.sum().inWholeSeconds) / 3600.0).roundToInt()) } .map { (k, v) -> Triple(k.first, k.second, v) } + override suspend fun listTimeEntries(timeRange: ClosedRange, developer: Developer?): List = + state + .filter { timeEntry -> developer?.let { it == timeEntry.developer } ?: true } + .filterIsIn(timeRange) + fun getState(): List = state.toList() } diff --git a/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt b/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt index e4b1942..439a720 100644 --- a/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt +++ b/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt @@ -1,14 +1,10 @@ package com.agilogy.timetracking.drivenadapters -import arrow.fx.coroutines.use import com.agilogy.db.hikari.HikariCp import com.agilogy.db.postgresql.PostgreSql +import com.agilogy.db.sql.Sql import com.agilogy.db.sql.Sql.sql -import com.agilogy.db.sql.Sql.update -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.timetracking.domain.* import io.kotest.core.spec.style.FunSpec import io.kotest.core.test.TestScope import org.junit.jupiter.api.Assertions.assertEquals @@ -17,18 +13,33 @@ import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime +import java.time.Month +import java.time.ZoneId import java.time.ZoneOffset import javax.sql.DataSource class TimeEntriesRepositoryTest : FunSpec() { private suspend fun withTestDataSource(database: String? = "test", f: suspend (DataSource) -> A) = - HikariCp.dataSource("jdbc:postgresql://localhost/${database ?: ""}", "postgres", "postgres").use { - dataSource -> f(dataSource) + HikariCp.dataSource("jdbc:postgresql://localhost:5432/${database ?: ""}", "postgres", "postgres").use { dataSource -> + f(dataSource) } - private suspend fun withPostgresTestRepo(f: suspend (TimeEntriesRepository) -> A) = - withTestDataSource { f(PostgresTimeEntriesRepository(it)) } + private suspend fun withPostgresTestRepo(f: suspend (TimeEntriesRepository) -> A) { + withTestDataSource(null) { dataSource -> + kotlin.runCatching { dataSource.sql { Sql.update("create database test") } } + .recoverIf(Unit) { it is PSQLException && it.sqlState == PostgreSql.DuplicateDatabase }.getOrThrow() + } + + withTestDataSource { dataSource -> + println("Recreating table time_entries") + kotlin.runCatching { dataSource.sql { Sql.update("drop table time_entries") } } + .recoverIf(Unit) { it is PSQLException && it.sqlState == PostgreSql.UndefinedTable }.getOrThrow() + PostgresTimeEntriesRepository.dbMigrations.forEach { dbMigration -> dataSource.sql { Sql.update(dbMigration) } } + f(PostgresTimeEntriesRepository(dataSource)) + } + + } private suspend fun withInMemoryTestRepo(f: suspend (TimeEntriesRepository) -> A) = f(InMemoryTimeEntriesRepository()) @@ -38,7 +49,9 @@ class TimeEntriesRepositoryTest : FunSpec() { private fun LocalDateTime.toLocalInstant() = atZone(ZoneOffset.systemDefault()).toInstant() private fun LocalDate.toLocalInstant() = atTime(0, 0).toLocalInstant() - private fun date(day: Int): LocalDate = LocalDate.of(2013, 2, day) + val today = LocalDate.of(2023, Month.APRIL, 1) + fun at(hour: Int, minute: Int = 0) = today.atTime(hour, minute).atZone(ZoneId.systemDefault()).toInstant() + private fun date(day: Int): LocalDate = LocalDate.of(2023, Month.APRIL, day) private fun timePeriod(day: Int, hourFrom: Int, hours: Int): ClosedRange { val from = date(day).atTime(LocalTime.of(hourFrom, 0)).toLocalInstant() return (from..from.plusSeconds(3600L * hours)) @@ -46,19 +59,19 @@ class TimeEntriesRepositoryTest : FunSpec() { init { - beforeTest { - withTestDataSource(null) { dataSource -> - kotlin.runCatching { dataSource.sql { update("create database test") } } - .recoverIf(Unit) { it is PSQLException && it.sqlState == PostgreSql.DuplicateDatabase }.getOrThrow() - } - withTestDataSource { dataSource -> - kotlin.runCatching { dataSource.sql { update("drop table time_entries") } } - .recoverIf(Unit) { it is PSQLException && it.sqlState == PostgreSql.UndefinedTable }.getOrThrow() - PostgresTimeEntriesRepository.dbMigrations.forEach { dbMigration -> dataSource.sql { update(dbMigration) } } - } + // TODO: Migrate tests using now and start to use at(hour, minute) instead + val now = Instant.now() + val hours = 1 + val start = now.minusSeconds(hours * 3600L) - } + val developer = Developer("John") + val project = Project("Acme Inc.") + + val d1 = Developer("d1") + val d2 = Developer("d2") + val p = Project("p") + val p2 = Project("p2") fun test(name: String, test: suspend TestScope.(TimeEntriesRepository) -> Unit) { context(name) { @@ -71,42 +84,102 @@ class TimeEntriesRepositoryTest : FunSpec() { } } + @Suppress("UNUSED_PARAMETER") + fun xtest(name: String, test: suspend TestScope.(TimeEntriesRepository) -> Unit) { + super.xtest(name) {} + } + test("getHoursByDeveloperAndProject") { repo -> val testDay = date(1) repo.saveTimeEntries( listOf( - TimeEntry("d1", "p", timePeriod(1, 9, 4)), - TimeEntry("d1", "p", timePeriod(1, 14, 3)), - TimeEntry("d2", "p", timePeriod(1, 10, 3)), + TimeEntry(d1, p, timePeriod(1, 9, 4)), + TimeEntry(d1, p, timePeriod(1, 14, 3)), + TimeEntry(d2, p, timePeriod(1, 10, 3)), ) ) assertEquals( mapOf( - DeveloperProject("d1", "p") to Hours(7), - DeveloperProject("d2", "p") to Hours(3), + Pair(d1, p) to Hours(7), + Pair(d2, p) to Hours(3), ), repo.getHoursByDeveloperAndProject(testDay.toLocalInstant()..testDay.plusDays(1).toLocalInstant()) ) } + test("Get hours per developer") { repo -> + repo.saveTimeEntries(listOf(TimeEntry(developer, project, start..now))) + val result = repo.getHoursByDeveloperAndProject(start..now) + val expected = mapOf((developer to project) to Hours(hours)) + assertEquals(expected, result) + } + + test("Get hours per developer when range is bigger than the developer hours") { repo -> + repo.saveTimeEntries(listOf(TimeEntry(developer, project, start..now))) + val result = repo.getHoursByDeveloperAndProject(start.minusSeconds(7200L)..now.plusSeconds(7200L)) + val expected = mapOf((developer to project) to Hours(1)) + assertEquals(expected, result) + } + + test("Get hours per developer when range makes no sense") { repo -> + repo.saveTimeEntries(listOf(TimeEntry(developer, project, start..now))) + val resultOutside = repo.getHoursByDeveloperAndProject(start.plusSeconds(7200L)..now.minusSeconds(7200L)) + val resultInside = repo.getHoursByDeveloperAndProject(start.plusSeconds(2700L)..now.minusSeconds(2700L)) + val expected = emptyMap, Hours>() + assertEquals(expected, resultOutside) + assertEquals(expected, resultInside) + } + + test("getHoursByDeveloperAndProject returns the hours in the interval") { repo -> + repo.saveTimeEntries(listOf(TimeEntry(developer, project, at(9)..at(13)))) + val actual = repo.getHoursByDeveloperAndProject(at(10)..at(12)) + val expected = mapOf((developer to project) to Hours(2)) + assertEquals(expected, actual) + } + + test("getHoursByDeveloperAndProject rounds properly up") { repo -> + repo.saveTimeEntries(listOf(TimeEntry(developer, project, at(10)..at(10, 30)))) + val result = repo.getHoursByDeveloperAndProject(at(10)..at(11)) + val expected = mapOf((developer to project) to Hours(1)) + assertEquals(expected, result) + } + + test("Get hours per developer when range is outside the developer hours") { repo -> + repo.saveTimeEntries(listOf(TimeEntry(developer, project, start..now))) + val resultLeft = repo.getHoursByDeveloperAndProject(start.minusSeconds(3600L)..now.minusSeconds(7200L)) + val resultRight = repo.getHoursByDeveloperAndProject(start.plusSeconds(7200L)..now.plusSeconds(3600L)) + val expected = emptyMap, Hours>() + assertEquals(expected, resultLeft) + assertEquals(expected, resultRight) + } + + test("Get hours per developer when only one part of the range is inside") { repo -> + repo.saveTimeEntries(listOf(TimeEntry(developer, project, start..now))) + val resultStartInsideEndOutside = repo.getHoursByDeveloperAndProject(start.plusSeconds(1600L)..now.plusSeconds(1600L)) + val resultStartOutsideEndInside = repo.getHoursByDeveloperAndProject(start.minusSeconds(1600L)..now.minusSeconds(1600L)) + val expected = mapOf((developer to project) to Hours(1)) + assertEquals(expected, resultStartInsideEndOutside) + assertEquals(expected, resultStartOutsideEndInside) + } + test("getDeveloperHoursByProjectAndDate") { repo -> repo.saveTimeEntries( listOf( - TimeEntry("d1", "p", timePeriod(1, 9, 1)), - TimeEntry("d1", "p", timePeriod(1, 11, 2)), - TimeEntry("d1", "p2", timePeriod(1, 14, 4)), - TimeEntry("d1", "p", timePeriod(2, 8, 6)), + TimeEntry(d1, p, timePeriod(1, 9, 1)), + TimeEntry(d1, p, timePeriod(1, 11, 2)), + TimeEntry(d1, p2, timePeriod(1, 14, 4)), + TimeEntry(d1, p, timePeriod(2, 8, 6)), ) ) assertEquals( listOf( - Triple(date(1), "p", Hours(3)), - Triple(date(1), "p2", Hours(4)), - Triple(date(2), "p", Hours(6)) + Triple(date(1), p, Hours(3)), + Triple(date(1), p2, Hours(4)), + Triple(date(2), p, Hours(6)) ), - repo.getDeveloperHoursByProjectAndDate("d1", date(1)..date(2)) + repo.getDeveloperHoursByProjectAndDate(d1, date(1)..date(2)) ) } diff --git a/app/src/test/kotlin/com/agilogy/timetracking/driveradapters/ConsoleAppTest.kt b/app/src/test/kotlin/com/agilogy/timetracking/driveradapters/ConsoleAppTest.kt new file mode 100644 index 0000000..02687cc --- /dev/null +++ b/app/src/test/kotlin/com/agilogy/timetracking/driveradapters/ConsoleAppTest.kt @@ -0,0 +1,9 @@ +package com.agilogy.timetracking.driveradapters + +import io.kotest.core.spec.style.FunSpec + +class ConsoleAppTest: FunSpec() { + init{ + // TODO: Test ConsoleApp + } +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index b94ae7c..16b6ec5 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -1,7 +1,7 @@ object Dependencies { val arrowKt = "io.arrow-kt" - val arrowVersion = "2.0.0-SNAPSHOT" + val arrowVersion = "1.1.6-alpha.57" val arrowCore = "$arrowKt:arrow-core:$arrowVersion" val arrowFxCoroutines = "$arrowKt:arrow-fx-coroutines:$arrowVersion" val arrowFxStm = "$arrowKt:arrow-fx-stm:$arrowVersion"