@@ -4,6 +4,209 @@ defmodule Plausible.Stats.TimeTest do
44 import Plausible.Stats.Time
55 alias Plausible.Stats.DateTimeRange
66
7+ @ now DateTime . utc_now ( :second )
8+
9+ describe "partial_time_labels/2" do
10+ test "returns today as partial_time_label for time:day when today is still incomplete" do
11+ now = ~U[ 2023-03-01 14:00:00Z]
12+
13+ assert partial_time_labels ( [ "2023-03-01" ] , % {
14+ dimensions: [ "time:day" ] ,
15+ utc_time_range: DateTimeRange . new! ( ~U[ 2023-03-01 00:00:00Z] , now ) ,
16+ now: now ,
17+ timezone: "UTC"
18+ } ) == [ "2023-03-01" ]
19+ end
20+
21+ test "time_label of today is not partial when it's 23:59:59" do
22+ now = ~U[ 2023-03-01 23:59:59Z]
23+
24+ assert partial_time_labels ( [ "2023-03-01" ] , % {
25+ dimensions: [ "time:day" ] ,
26+ utc_time_range: DateTimeRange . new! ( ~U[ 2023-03-01 00:00:00Z] , now ) ,
27+ now: now ,
28+ timezone: "UTC"
29+ } ) == [ ]
30+ end
31+
32+ test "returns current hour as partial time label when it's incomplete" do
33+ now = ~U[ 2023-03-01 12:30:00Z]
34+
35+ assert partial_time_labels ( [ "2023-03-01 12:00:00" ] , % {
36+ dimensions: [ "time:hour" ] ,
37+ utc_time_range: DateTimeRange . new! ( ~U[ 2023-03-01 12:00:00Z] , now ) ,
38+ now: now ,
39+ timezone: "UTC"
40+ } ) == [ "2023-03-01 12:00:00" ]
41+ end
42+
43+ test "current hour is not partial when query.now is the last second of the hour" do
44+ now = ~U[ 2023-03-01 12:59:59Z]
45+
46+ assert partial_time_labels ( [ "2023-03-01 12:00:00" ] , % {
47+ dimensions: [ "time:hour" ] ,
48+ utc_time_range: DateTimeRange . new! ( ~U[ 2023-03-01 12:00:00Z] , now ) ,
49+ now: now ,
50+ timezone: "UTC"
51+ } ) == [ ]
52+ end
53+
54+ test "returns current minute as partial time label when it's incomplete" do
55+ now = ~U[ 2023-03-01 12:30:30Z]
56+
57+ assert partial_time_labels ( [ "2023-03-01 12:30:00" ] , % {
58+ dimensions: [ "time:minute" ] ,
59+ utc_time_range: DateTimeRange . new! ( ~U[ 2023-03-01 12:30:00Z] , now ) ,
60+ now: now ,
61+ timezone: "UTC"
62+ } ) == [ "2023-03-01 12:30:00" ]
63+ end
64+
65+ test "current minute is not partial when query.now is the last second of the minute" do
66+ now = ~U[ 2023-03-01 12:30:59Z]
67+
68+ assert partial_time_labels ( [ "2023-03-01 12:30:00" ] , % {
69+ dimensions: [ "time:minute" ] ,
70+ utc_time_range: DateTimeRange . new! ( ~U[ 2023-03-01 12:30:00Z] , now ) ,
71+ now: now ,
72+ timezone: "UTC"
73+ } ) == [ ]
74+ end
75+
76+ test "first bucket is partial when query range starts mid-bucket (e.g. last 24h)" do
77+ # time:day: range starts at 12:30, so the first day only has half a day of data
78+ now = ~U[ 2023-03-02 12:30:00Z]
79+
80+ assert partial_time_labels ( [ "2023-03-01" , "2023-03-02" ] , % {
81+ dimensions: [ "time:day" ] ,
82+ utc_time_range: DateTimeRange . new! ( ~U[ 2023-03-01 12:30:00Z] , now ) ,
83+ now: now ,
84+ timezone: "UTC"
85+ } ) == [ "2023-03-01" , "2023-03-02" ]
86+
87+ # time:hour: range starts at 12:30, so the first hour only has 30 minutes of data
88+ now = ~U[ 2023-03-01 13:30:00Z]
89+
90+ assert partial_time_labels ( [ "2023-03-01 12:00:00" , "2023-03-01 13:00:00" ] , % {
91+ dimensions: [ "time:hour" ] ,
92+ utc_time_range: DateTimeRange . new! ( ~U[ 2023-03-01 12:30:00Z] , now ) ,
93+ now: now ,
94+ timezone: "UTC"
95+ } ) == [ "2023-03-01 12:00:00" , "2023-03-01 13:00:00" ]
96+ end
97+
98+ test "handles timezone with non-whole-hour UTC offset (IST, UTC+05:30)" do
99+ # 13:30 UTC = 19:00 IST (range starts exactly on the hour, so first bucket is not partial)
100+ # 14:00 UTC = 19:30 IST, so the 19:00 IST hour is still in progress
101+ now = ~U[ 2023-03-01 14:00:00Z]
102+
103+ assert partial_time_labels ( [ "2023-03-01 19:00:00" ] , % {
104+ dimensions: [ "time:hour" ] ,
105+ utc_time_range: DateTimeRange . new! ( ~U[ 2023-03-01 13:30:00Z] , now ) ,
106+ now: now ,
107+ timezone: "Asia/Kolkata"
108+ } ) == [ "2023-03-01 19:00:00" ]
109+
110+ # 14:30 UTC = 20:00 IST, so the 19:00 IST hour is now complete
111+ now = ~U[ 2023-03-01 14:30:00Z]
112+
113+ assert partial_time_labels ( [ "2023-03-01 19:00:00" ] , % {
114+ dimensions: [ "time:hour" ] ,
115+ utc_time_range: DateTimeRange . new! ( ~U[ 2023-03-01 13:30:00Z] , now ) ,
116+ now: now ,
117+ timezone: "Asia/Kolkata"
118+ } ) == [ ]
119+ end
120+
121+ test "handles DST transition (America/New_York, UTC-04:00 -> UTC-05:00)" do
122+ # Clocks fall back 02:00 -> 01:00, so 01:xx occurs twice.
123+ # 05:00 UTC = 01:00 EDT (first occurrence, UTC-4)
124+ # 06:00 UTC = 01:00 EST (second occurrence, UTC-5)
125+ now = ~U[ 2026-11-01 06:30:00Z]
126+
127+ # 06:30 UTC = 01:30 EST
128+ assert partial_time_labels ( [ "2026-11-01 01:00:00" ] , % {
129+ dimensions: [ "time:hour" ] ,
130+ utc_time_range: DateTimeRange . new! ( ~U[ 2026-11-01 06:00:00Z] , now ) ,
131+ now: now ,
132+ timezone: "America/New_York"
133+ } ) == [ "2026-11-01 01:00:00" ]
134+
135+ # 06:59:59 UTC = 01:59:59 EST
136+ now = ~U[ 2026-11-01 06:59:59Z]
137+
138+ assert partial_time_labels ( [ "2026-11-01 01:00:00" ] , % {
139+ dimensions: [ "time:hour" ] ,
140+ utc_time_range: DateTimeRange . new! ( ~U[ 2026-11-01 06:00:00Z] , now ) ,
141+ now: now ,
142+ timezone: "America/New_York"
143+ } ) == [ ]
144+ end
145+
146+ test "first month bucket is partial if date range start is one second after actual month start" do
147+ assert partial_time_labels ( [ "2023-03-01" ] , % {
148+ dimensions: [ "time:month" ] ,
149+ utc_time_range:
150+ DateTimeRange . new! ( ~U[ 2023-03-01 00:00:01Z] , ~U[ 2023-03-31 23:59:59Z] ) ,
151+ now: @ now ,
152+ timezone: "UTC"
153+ } ) == [ "2023-03-01" ]
154+ end
155+
156+ test "last month bucket is partial if date range end is one second before actual month end" do
157+ assert partial_time_labels ( [ "2023-03-01" ] , % {
158+ dimensions: [ "time:month" ] ,
159+ utc_time_range:
160+ DateTimeRange . new! ( ~U[ 2023-03-01 00:00:00Z] , ~U[ 2023-03-31 23:59:58Z] ) ,
161+ now: @ now ,
162+ timezone: "UTC"
163+ } ) == [ "2023-03-01" ]
164+ end
165+
166+ test "a month bucket is not partial if date range starts and ends exactly at month start/end" do
167+ assert partial_time_labels ( [ "2023-03-01" ] , % {
168+ dimensions: [ "time:month" ] ,
169+ utc_time_range:
170+ DateTimeRange . new! ( ~U[ 2023-03-01 00:00:00Z] , ~U[ 2023-03-31 23:59:59Z] ) ,
171+ now: @ now ,
172+ timezone: "UTC"
173+ } ) == [ ]
174+ end
175+
176+ test "first week bucket is partial if date range start is one second after actual week start" do
177+ # Week of 2023-03-06 (Mon) to 2023-03-12 (Sun)
178+ assert partial_time_labels ( [ "2023-03-06" ] , % {
179+ dimensions: [ "time:week" ] ,
180+ utc_time_range:
181+ DateTimeRange . new! ( ~U[ 2023-03-06 00:00:01Z] , ~U[ 2023-03-12 23:59:59Z] ) ,
182+ now: @ now ,
183+ timezone: "UTC"
184+ } ) == [ "2023-03-06" ]
185+ end
186+
187+ test "last week bucket is partial if date range end is one second before actual week end" do
188+ # Week of 2023-03-06 (Mon) to 2023-03-12 (Sun)
189+ assert partial_time_labels ( [ "2023-03-06" ] , % {
190+ dimensions: [ "time:week" ] ,
191+ utc_time_range:
192+ DateTimeRange . new! ( ~U[ 2023-03-06 00:00:00Z] , ~U[ 2023-03-12 23:59:58Z] ) ,
193+ now: @ now ,
194+ timezone: "UTC"
195+ } ) == [ "2023-03-06" ]
196+ end
197+
198+ test "a week bucket is not partial if date range starts and ends exactly at week start/end" do
199+ # Week of 2023-03-06 (Mon) to 2023-03-12 (Sun)
200+ assert partial_time_labels ( [ "2023-03-06" ] , % {
201+ dimensions: [ "time:week" ] ,
202+ utc_time_range:
203+ DateTimeRange . new! ( ~U[ 2023-03-06 00:00:00Z] , ~U[ 2023-03-12 23:59:59Z] ) ,
204+ now: @ now ,
205+ timezone: "UTC"
206+ } ) == [ ]
207+ end
208+ end
209+
7210 describe "time_labels/1" do
8211 test "with time:month dimension" do
9212 assert time_labels ( % {
0 commit comments