Skip to content

Commit f1cb898

Browse files
committed
fix daily distribution with records on dst change with start of day shift 2h
1 parent 59450bc commit f1cb898

2 files changed

Lines changed: 185 additions & 6 deletions

File tree

core/common/src/main/java/com/example/util/simpletimetracker/core/extension/CalendarExtensions.kt

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,30 @@ fun Calendar.setWeekToFirstDay() {
2626
}
2727

2828
fun Calendar.shift(shift: Long): Calendar {
29-
// Example
30-
// shift 6h
29+
if (shift == 0L) return this
30+
// Used for start of day shift on dst change.
31+
// Example, shift 6h:
3132
// before 2023-03-26T00:00+01:00[Europe/Amsterdam] DST_OFFSET = 0
3233
// after 2023-03-26T07:00+02:00[Europe/Amsterdam] DST_OFFSET = 3600000
33-
// need to compensate one hour.
34-
val dstOffsetBefore = get(Calendar.DST_OFFSET)
34+
// need to compensate one hour to preserve correct start of day.
35+
var dstOffsetBefore = get(Calendar.DST_OFFSET)
3536
timeInMillis += shift
36-
val dstOffsetAfter = get(Calendar.DST_OFFSET)
37-
timeInMillis += (dstOffsetBefore - dstOffsetAfter)
37+
var dstOffsetAfter = get(Calendar.DST_OFFSET)
38+
39+
// Compensate.
40+
val dstChange = dstOffsetBefore - dstOffsetAfter
41+
dstOffsetBefore = dstOffsetAfter
42+
timeInMillis += dstChange
43+
dstOffsetAfter = get(Calendar.DST_OFFSET)
44+
45+
// If dst fix causes another dst change - rollback,
46+
// it means timestamp after shift falls right on the dst change period,
47+
// for example 30 March 2025 Germany 02:00 -> 03:00,
48+
// otherwise 00:00 + 2h would be 01:00 after compensation,
49+
// and 01:00 - 2h would be different date.
50+
if (dstOffsetBefore != dstOffsetAfter) {
51+
timeInMillis -= dstChange
52+
}
3853
return this
3954
}
4055

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package com.example.util.simpletimetracker.core.mapper
2+
3+
import com.example.util.simpletimetracker.core.extension.shift
4+
import com.example.util.simpletimetracker.domain.extension.orZero
5+
import org.junit.Assert.assertEquals
6+
import org.junit.Rule
7+
import org.junit.Test
8+
import org.junit.rules.TestWatcher
9+
import org.junit.runner.Description
10+
import org.junit.runner.RunWith
11+
import org.junit.runners.Parameterized
12+
import java.util.Calendar
13+
import java.util.TimeZone
14+
import java.util.concurrent.TimeUnit
15+
16+
@RunWith(Parameterized::class)
17+
class CalendarShiftTest(
18+
private val input: Pair<Calendar, String>,
19+
private val output: String,
20+
) {
21+
22+
@Rule
23+
@JvmField
24+
var timeZoneRule = TimeZoneRule()
25+
26+
@Test
27+
fun map() {
28+
val expected = output
29+
val shift = input.second.split(":").let {
30+
val negative = it.first().startsWith("-")
31+
val sign = if (negative) -1 else 1
32+
it.first().toIntOrNull().orZero() * hourInMs +
33+
sign * it.last().toIntOrNull().orZero() * minInMs
34+
}
35+
val actual = input.first.shift(shift).let {
36+
"${it.get(Calendar.HOUR_OF_DAY)}:${it.get(Calendar.MINUTE)}"
37+
}
38+
39+
assertEquals(
40+
"Test failed for params $input",
41+
expected,
42+
actual,
43+
)
44+
}
45+
46+
companion object {
47+
private val minInMs = TimeUnit.MINUTES.toMillis(1)
48+
private val hourInMs = TimeUnit.HOURS.toMillis(1)
49+
50+
// Day of dst forward change in Germany.
51+
private fun getDstForward(time: String): Calendar {
52+
val hour = time.split(":").first().toIntOrNull().orZero()
53+
val min = time.split(":").last().toIntOrNull().orZero()
54+
return Calendar.getInstance(TimeZone.getTimeZone("Europe/Berlin")).apply {
55+
timeInMillis = 0
56+
set(Calendar.YEAR, 2025)
57+
set(Calendar.MONTH, 2)
58+
set(Calendar.HOUR_OF_DAY, hour)
59+
set(Calendar.MINUTE, min)
60+
set(Calendar.DAY_OF_MONTH, 30)
61+
}
62+
}
63+
64+
// Day of dst forward change in Germany.
65+
private fun getDstBackward(time: String): Calendar {
66+
val hour = time.split(":").first().toIntOrNull().orZero()
67+
val min = time.split(":").last().toIntOrNull().orZero()
68+
return Calendar.getInstance(TimeZone.getTimeZone("Europe/Berlin")).apply {
69+
timeInMillis = 0
70+
set(Calendar.YEAR, 2025)
71+
set(Calendar.MONTH, 9)
72+
set(Calendar.HOUR_OF_DAY, hour)
73+
set(Calendar.MINUTE, min)
74+
set(Calendar.DAY_OF_MONTH, 26)
75+
}
76+
}
77+
78+
@JvmStatic
79+
@Parameterized.Parameters
80+
fun data() = listOf(
81+
// Forward dst change.
82+
arrayOf(getDstForward("0:0") to "0:0", "0:0"),
83+
arrayOf(getDstForward("0:0") to "1:0", "1:0"),
84+
arrayOf(getDstForward("0:0") to "1:59", "1:59"),
85+
arrayOf(getDstForward("0:0") to "2:0", "3:0"),
86+
arrayOf(getDstForward("0:0") to "2:1", "3:1"),
87+
arrayOf(getDstForward("0:0") to "2:30", "3:30"),
88+
arrayOf(getDstForward("0:0") to "2:59", "3:59"),
89+
arrayOf(getDstForward("0:0") to "3:0", "3:0"),
90+
arrayOf(getDstForward("0:0") to "3:1", "3:1"),
91+
arrayOf(getDstForward("0:0") to "4:0", "4:0"),
92+
arrayOf(getDstForward("0:0") to "5:0", "5:0"),
93+
94+
arrayOf(getDstForward("5:0") to "0:0", "5:0"),
95+
arrayOf(getDstForward("5:0") to "-1:0", "4:0"),
96+
arrayOf(getDstForward("5:0") to "-1:59", "3:1"),
97+
arrayOf(getDstForward("5:0") to "-2:0", "3:0"),
98+
arrayOf(getDstForward("5:0") to "-2:1", "1:59"),
99+
arrayOf(getDstForward("5:0") to "-2:30", "1:30"),
100+
arrayOf(getDstForward("5:0") to "-2:59", "1:1"),
101+
arrayOf(getDstForward("5:0") to "-3:0", "1:0"),
102+
arrayOf(getDstForward("5:0") to "-3:1", "1:59"),
103+
arrayOf(getDstForward("5:0") to "-4:0", "1:0"),
104+
arrayOf(getDstForward("5:0") to "-5:0", "0:0"),
105+
106+
arrayOf(getDstForward("0:0") to "2:0", "3:0"),
107+
arrayOf(getDstForward("0:0") to "3:0", "3:0"),
108+
arrayOf(getDstForward("1:0") to "1:0", "3:0"),
109+
arrayOf(getDstForward("1:0") to "2:0", "3:0"),
110+
arrayOf(getDstForward("2:0") to "1:0", "4:0"),
111+
arrayOf(getDstForward("2:0") to "2:0", "5:0"),
112+
arrayOf(getDstForward("3:0") to "1:0", "4:0"),
113+
arrayOf(getDstForward("3:0") to "2:0", "5:0"),
114+
arrayOf(getDstForward("3:0") to "-1:0", "1:0"),
115+
arrayOf(getDstForward("3:0") to "-2:0", "1:0"),
116+
arrayOf(getDstForward("3:0") to "-3:0", "0:0"),
117+
arrayOf(getDstForward("2:0") to "-1:0", "1:0"),
118+
arrayOf(getDstForward("2:0") to "-2:0", "1:0"),
119+
arrayOf(getDstForward("1:0") to "-1:0", "0:0"),
120+
121+
// Backward dst change.
122+
arrayOf(getDstBackward("0:0") to "0:0", "0:0"),
123+
arrayOf(getDstBackward("0:0") to "1:0", "1:0"),
124+
arrayOf(getDstBackward("0:0") to "2:0", "2:0"),
125+
arrayOf(getDstBackward("0:0") to "3:0", "3:0"),
126+
arrayOf(getDstBackward("0:0") to "4:0", "4:0"),
127+
arrayOf(getDstBackward("0:0") to "5:0", "5:0"),
128+
129+
arrayOf(getDstBackward("5:0") to "0:0", "5:0"),
130+
arrayOf(getDstBackward("5:0") to "-1:0", "4:0"),
131+
arrayOf(getDstBackward("5:0") to "-2:0", "3:0"),
132+
arrayOf(getDstBackward("5:0") to "-3:0", "2:0"),
133+
arrayOf(getDstBackward("5:0") to "-4:0", "1:0"),
134+
arrayOf(getDstBackward("5:0") to "-5:0", "0:0"),
135+
136+
arrayOf(getDstBackward("0:0") to "2:0", "2:0"),
137+
arrayOf(getDstBackward("0:0") to "3:0", "3:0"),
138+
arrayOf(getDstBackward("1:0") to "1:0", "2:0"),
139+
arrayOf(getDstBackward("1:0") to "2:0", "3:0"),
140+
arrayOf(getDstBackward("2:0") to "1:0", "3:0"),
141+
arrayOf(getDstBackward("2:0") to "2:0", "4:0"),
142+
arrayOf(getDstBackward("3:0") to "1:0", "4:0"),
143+
arrayOf(getDstBackward("3:0") to "2:0", "5:0"),
144+
arrayOf(getDstBackward("3:0") to "-1:0", "2:0"),
145+
arrayOf(getDstBackward("3:0") to "-2:0", "1:0"),
146+
arrayOf(getDstBackward("3:0") to "-3:0", "0:0"),
147+
arrayOf(getDstBackward("2:0") to "-1:0", "1:0"),
148+
arrayOf(getDstBackward("2:0") to "-2:0", "0:0"),
149+
arrayOf(getDstBackward("1:0") to "-1:0", "0:0"),
150+
)
151+
}
152+
153+
class TimeZoneRule : TestWatcher() {
154+
private val origDefault: TimeZone = TimeZone.getDefault()
155+
156+
override fun starting(description: Description?) {
157+
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Berlin"))
158+
}
159+
160+
override fun finished(description: Description?) {
161+
TimeZone.setDefault(origDefault)
162+
}
163+
}
164+
}

0 commit comments

Comments
 (0)