Skip to content

Commit d2a6fc3

Browse files
Merge pull request #23 from matejsemancik/feature/stats-ahead-behind
Stats: ahead/behind indicators
2 parents d7cf85e + 4e121b0 commit d2a6fc3

File tree

7 files changed

+252
-18
lines changed

7 files changed

+252
-18
lines changed

GEMINI.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Gemini Guidelines
2+
3+
This file contains guidelines for the Gemini AI assistant to follow when working on this project.
4+
5+
## Architecture
6+
7+
This project follows a Kotlin Multiplatform (KMP) architecture with a `shared` module for the core business logic and a `desktopApp` module for the desktop-specific implementation. The architecture is based on a variant of Clean Architecture, with a clear separation of concerns between the data, domain, and presentation layers.
8+
9+
### Key Technologies
10+
11+
* **Kotlin Multiplatform:** For sharing code between different platforms.
12+
* **Jetpack Compose for Desktop:** For building the UI.
13+
* **Koin:** For dependency injection.
14+
* **Kotlin Coroutines:** For asynchronous programming.
15+
* **Ktorfit:** For type-safe HTTP clients.
16+
* **Room:** For local data persistence.
17+
18+
### Modules
19+
20+
* **`shared`:** Contains the platform-independent code.
21+
* **`commonMain`:** The main source set for the shared module.
22+
* **`data`:** The data layer, including repositories, network services, and the database.
23+
* **`feature`:** The presentation layer, with each feature in its own package.
24+
* **`design`:** The design system, including colors, typography, and custom UI components.
25+
* **`desktopApp`:** Contains the desktop-specific implementation.

shared/src/commonMain/composeResources/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,7 @@
5858
<string name="stats_today">Today: </string>
5959
<string name="stats_weekly">This week: </string>
6060
<string name="stats_period">Current period: </string>
61+
<string name="stats_ahead">+%1$s ahead</string>
62+
<string name="stats_behind">-%1$s behind</string>
63+
<string name="stats_caught_up">Done for today</string>
6164
</resources>

shared/src/commonMain/kotlin/dev/matsem/bpm/data/repo/WorklogRepo.kt

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,8 @@ internal class WorklogRepoImpl(
133133
start..end
134134
}
135135

136-
val workSchedule = tempoApiManager.getUserSchedule(dateRange = currentApprovalPeriod.dateRange)
136+
val workSchedule =
137+
tempoApiManager.getUserSchedule(dateRange = currentApprovalPeriod.dateRange)
137138
val allWorklogs = tempoApiManager.getAllWorklogs(
138139
jiraAccountId = user.accountId,
139140
dateRange = currentApprovalPeriod.dateRange,
@@ -143,24 +144,33 @@ internal class WorklogRepoImpl(
143144
type = WorkStats.Type.Today,
144145
dateRange = dateNow..dateNow,
145146
workSchedule = workSchedule,
146-
worklogs = allWorklogs
147+
worklogs = allWorklogs,
148+
dateNow = dateNow,
147149
)
148150

149151
val thisWeekWorkStats = getStatsForDates(
150152
type = WorkStats.Type.ThisWeek,
151153
dateRange = currentWeek,
152154
workSchedule = workSchedule,
153-
worklogs = allWorklogs
155+
worklogs = allWorklogs,
156+
dateNow = dateNow,
154157
)
155158

156159
val currentPeriodWorkStats = getStatsForDates(
157160
type = WorkStats.Type.CurrentPeriod,
158161
dateRange = currentApprovalPeriod.dateRange,
159162
workSchedule = workSchedule,
160-
worklogs = allWorklogs
163+
worklogs = allWorklogs,
164+
dateNow = dateNow,
161165
)
162166

163-
workStats.update { listOf(todayWorkStats, thisWeekWorkStats, currentPeriodWorkStats) }
167+
workStats.update {
168+
listOf(
169+
currentPeriodWorkStats,
170+
thisWeekWorkStats,
171+
todayWorkStats
172+
)
173+
}
164174
}
165175

166176
override fun getWorkStats(): Flow<List<WorkStats>> = workStats.asStateFlow()
@@ -179,6 +189,7 @@ internal class WorklogRepoImpl(
179189
dateRange: ClosedRange<LocalDate>,
180190
workSchedule: List<DaySchedule>,
181191
worklogs: List<Worklog>,
192+
dateNow: LocalDate,
182193
): WorkStats {
183194
val requiredDuration = workSchedule
184195
.filter { it.date in dateRange }
@@ -190,6 +201,13 @@ internal class WorklogRepoImpl(
190201
.sumOf { it.timeSpentSeconds }
191202
.seconds
192203

193-
return WorkStats(type, dateRange, requiredDuration, trackedDuration)
204+
val requiredUntilToday = workSchedule
205+
.filter { it.date in dateRange && it.date <= dateNow }
206+
.sumOf { it.requiredSeconds }
207+
.seconds
208+
209+
val trackingDelta = trackedDuration - requiredUntilToday
210+
211+
return WorkStats(type, dateRange, requiredDuration, trackedDuration, trackingDelta)
194212
}
195213
}

shared/src/commonMain/kotlin/dev/matsem/bpm/data/repo/model/WorkStats.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,31 @@ import kotlin.time.Duration.Companion.seconds
1111
* @property dateRange The range of dates the statistics cover.
1212
* @property requiredDuration The total required duration of work for the specified time range.
1313
* @property trackedDuration The total duration of work that was tracked within the specified time range.
14+
* @property trackingDelta The duration by which the tracked work is ahead (positive) or behind (negative) the required work by end of today.
1415
*/
1516
data class WorkStats(
1617
val type: Type,
1718
val dateRange: ClosedRange<LocalDate>,
1819
val requiredDuration: Duration,
1920
val trackedDuration: Duration,
21+
val trackingDelta: Duration,
2022
) {
23+
/**
24+
* Represents the type of work statistics.
25+
*/
2126
enum class Type {
27+
/** Statistics for today. */
2228
Today,
29+
/** Statistics for the current week. */
2330
ThisWeek,
31+
/** Statistics for the current work period. */
2432
CurrentPeriod
2533
}
2634

35+
/**
36+
* The percentage of tracked work towards the required work, coerced between 0f and 1f.
37+
* Returns 0f if the required duration is zero.
38+
*/
2739
val percent: Float
2840
get() {
2941
if (requiredDuration.inWholeSeconds == 0L) {
@@ -32,3 +44,4 @@ data class WorkStats(
3244
return (trackedDuration.inWholeSeconds / requiredDuration.inWholeSeconds.toFloat()).coerceIn(0f..1f)
3345
}
3446
}
47+

shared/src/commonMain/kotlin/dev/matsem/bpm/design/theme/BpmColors.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ import androidx.compose.ui.graphics.Color
55
data class BpmColors(
66
val favourite: Color,
77
val success: Color,
8+
val negativeTimeDelta: Color,
89
)
910

1011
fun lightBpmColors() = BpmColors(
1112
favourite = Color(0xfff2b21d),
1213
success = Color(0xff31994e),
14+
negativeTimeDelta = Color(0xff9c6843),
1315
)
1416

1517
fun darkBpmColors() = BpmColors(
1618
favourite = Color(0xffd19917),
1719
success = Color(0xff52cc73),
20+
negativeTimeDelta = Color(0xffc99977),
1821
)

shared/src/commonMain/kotlin/dev/matsem/bpm/feature/stats/ui/StatsWidgetUi.kt

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import androidx.compose.animation.slideInVertically
88
import androidx.compose.animation.slideOutVertically
99
import androidx.compose.animation.togetherWith
1010
import androidx.compose.foundation.clickable
11+
import androidx.compose.foundation.layout.Arrangement
1112
import androidx.compose.foundation.layout.Column
1213
import androidx.compose.foundation.layout.PaddingValues
14+
import androidx.compose.foundation.layout.Row
1315
import androidx.compose.foundation.layout.fillMaxWidth
1416
import androidx.compose.foundation.layout.padding
1517
import androidx.compose.material3.LinearProgressIndicator
@@ -27,17 +29,22 @@ import androidx.compose.ui.unit.dp
2729
import androidx.lifecycle.compose.LifecycleResumeEffect
2830
import androidx.lifecycle.compose.collectAsStateWithLifecycle
2931
import bpm_tracker.shared.generated.resources.Res
32+
import bpm_tracker.shared.generated.resources.stats_ahead
33+
import bpm_tracker.shared.generated.resources.stats_behind
34+
import bpm_tracker.shared.generated.resources.stats_caught_up
3035
import bpm_tracker.shared.generated.resources.stats_period
3136
import bpm_tracker.shared.generated.resources.stats_today
3237
import bpm_tracker.shared.generated.resources.stats_weekly
3338
import dev.matsem.bpm.data.repo.model.WorkStats
39+
import dev.matsem.bpm.data.repo.model.WorkStats.Type
3440
import dev.matsem.bpm.design.theme.BpmTheme
3541
import dev.matsem.bpm.design.theme.Grid
3642
import dev.matsem.bpm.design.tooling.VerticalSpacer
3743
import dev.matsem.bpm.feature.stats.presentation.StatsWidget
3844
import dev.matsem.bpm.feature.tracker.formatting.DurationFormatter.formatForWorkStats
3945
import org.jetbrains.compose.resources.stringResource
4046
import org.koin.compose.koinInject
47+
import kotlin.time.Duration
4148

4249
@Composable
4350
fun StatsWidgetUi(
@@ -71,11 +78,23 @@ fun StatsWidgetUi(
7178
)
7279
)
7380
Column(Modifier.padding(contentPadding)) {
74-
Text(
75-
text = workStats.title,
76-
style = BpmTheme.typography.bodySmall,
77-
color = BpmTheme.colorScheme.onSurface,
78-
)
81+
Row(
82+
horizontalArrangement = Arrangement.SpaceBetween,
83+
modifier = Modifier.fillMaxWidth()
84+
) {
85+
Text(
86+
text = workStats.title,
87+
style = BpmTheme.typography.bodySmall,
88+
color = BpmTheme.colorScheme.onSurface,
89+
)
90+
workStats.aheadOrBehindText?.let {
91+
Text(
92+
text = it,
93+
style = BpmTheme.typography.bodySmall,
94+
color = BpmTheme.colorScheme.onSurface,
95+
)
96+
}
97+
}
7998
VerticalSpacer(Grid.d1)
8099
LinearProgressIndicator(
81100
color = when (workStats.percent) {
@@ -90,13 +109,39 @@ fun StatsWidgetUi(
90109
}
91110
}
92111

112+
private val WorkStats.aheadOrBehindText: AnnotatedString?
113+
@Composable
114+
get() {
115+
if (type != Type.CurrentPeriod) {
116+
return null
117+
}
118+
119+
val textRes = when {
120+
trackingDelta > Duration.ZERO -> Res.string.stats_ahead
121+
trackingDelta < Duration.ZERO -> Res.string.stats_behind
122+
else -> Res.string.stats_caught_up
123+
}
124+
125+
val style = when {
126+
trackingDelta >= Duration.ZERO -> SpanStyle(color = BpmTheme.customColorScheme.success)
127+
else -> SpanStyle(color = BpmTheme.customColorScheme.negativeTimeDelta)
128+
}
129+
val text = stringResource(textRes, trackingDelta.absoluteValue.formatForWorkStats())
130+
131+
return buildAnnotatedString {
132+
withStyle(style) {
133+
append(text)
134+
}
135+
}
136+
}
137+
93138
private val WorkStats.title: AnnotatedString
94139
@Composable
95140
get() {
96141
val title = when (this.type) {
97-
WorkStats.Type.Today -> stringResource(Res.string.stats_today)
98-
WorkStats.Type.ThisWeek -> stringResource(Res.string.stats_weekly)
99-
WorkStats.Type.CurrentPeriod -> stringResource(Res.string.stats_period)
142+
Type.Today -> stringResource(Res.string.stats_today)
143+
Type.ThisWeek -> stringResource(Res.string.stats_weekly)
144+
Type.CurrentPeriod -> stringResource(Res.string.stats_period)
100145
}
101146
return buildAnnotatedString {
102147
append(title)

0 commit comments

Comments
 (0)