Skip to content

Commit dd8f2d6

Browse files
committed
fix partial time labels
1 parent c41641d commit dd8f2d6

3 files changed

Lines changed: 269 additions & 20 deletions

File tree

lib/plausible/stats/time.ex

Lines changed: 64 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -121,32 +121,77 @@ defmodule Plausible.Stats.Time do
121121
end
122122

123123
def partial_time_labels(time_labels, query) do
124-
case time_dimension(query) do
125-
"time:week" ->
126-
date_range = Query.date_range(query)
127-
partial_labels(time_labels, date_range, &Date.beginning_of_week/1, &Date.end_of_week/1)
124+
time_dimension = time_dimension(query)
128125

129-
"time:month" ->
130-
date_range = Query.date_range(query)
131-
partial_labels(time_labels, date_range, &Date.beginning_of_month/1, &Date.end_of_month/1)
126+
range_start = to_naive_in_tz!(query.utc_time_range.first, query.timezone)
127+
range_end = to_naive_in_tz!(query.utc_time_range.last, query.timezone)
128+
now = to_naive_in_tz!(query.now, query.timezone)
132129

133-
_ ->
134-
[]
130+
cutoff = if NaiveDateTime.before?(now, range_end), do: now, else: range_end
131+
132+
first_bucket = List.first(time_labels)
133+
last_bucket = List.last(time_labels)
134+
135+
first_partial? =
136+
case bucket_start(first_bucket, time_dimension) do
137+
nil -> false
138+
start -> NaiveDateTime.after?(range_start, start)
139+
end
140+
141+
last_partial? =
142+
case bucket_end(last_bucket, time_dimension) do
143+
nil -> false
144+
bucket_end -> NaiveDateTime.after?(bucket_end, cutoff)
145+
end
146+
147+
[
148+
if(first_partial?, do: first_bucket),
149+
if(last_partial?, do: last_bucket)
150+
]
151+
|> Enum.uniq()
152+
|> Enum.reject(&is_nil/1)
153+
end
154+
155+
defp bucket_start(label, "time:week") do
156+
case Date.from_iso8601(label) do
157+
{:ok, date} -> NaiveDateTime.new!(Date.beginning_of_week(date), ~T[00:00:00])
158+
_ -> nil
135159
end
136160
end
137161

138-
defp partial_labels(time_labels, date_range, start_fn, end_fn) do
139-
Enum.filter(time_labels, fn label ->
140-
case Date.from_iso8601(label) do
141-
{:ok, date} ->
142-
start_in_range = Enum.member?(date_range, start_fn.(date))
143-
end_in_range = Enum.member?(date_range, end_fn.(date))
144-
not start_in_range or not end_in_range
162+
defp bucket_start(label, _time_dimension) do
163+
case Date.from_iso8601(label) do
164+
{:ok, date} ->
165+
NaiveDateTime.new!(date, ~T[00:00:00])
145166

146-
_ ->
147-
false
167+
_ ->
168+
case NaiveDateTime.from_iso8601(label) do
169+
{:ok, naive_datetime} -> naive_datetime
170+
_ -> nil
171+
end
172+
end
173+
end
174+
175+
defp bucket_end(label, time_dimension) do
176+
shift_unit =
177+
case time_dimension do
178+
"time:month" -> :month
179+
"time:week" -> :week
180+
"time:day" -> :day
181+
"time:hour" -> :hour
182+
"time:minute" -> :minute
148183
end
149-
end)
184+
185+
case bucket_start(label, time_dimension) do
186+
nil -> nil
187+
start -> NaiveDateTime.shift(start, [{shift_unit, 1}, {:second, -1}])
188+
end
189+
end
190+
191+
defp to_naive_in_tz!(utc_datetime, timezone) do
192+
utc_datetime
193+
|> DateTime.shift_zone!(timezone)
194+
|> DateTime.to_naive()
150195
end
151196

152197
def present_index(time_labels, query) do

test/plausible/stats/time_test.exs

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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(%{

test/plausible_web/controllers/api/stats_controller/main_graph_test.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1500,13 +1500,14 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
15001500
assert response["meta"]["partial_time_labels"] == []
15011501
end
15021502

1503-
test "partial_time_labels is an empty list when interval is not week nor month", %{
1503+
test "partial_time_labels is an empty list for time:day when all days are complete", %{
15041504
conn: conn,
15051505
site: site
15061506
} do
15071507
response =
15081508
do_query(conn, site, %{
15091509
"date_range" => "28d",
1510+
"relative_date" => "2021-01-28",
15101511
"metrics" => ["visitors"],
15111512
"dimensions" => ["time:day"],
15121513
"include" => %{"time_labels" => true, "partial_time_labels" => true}

0 commit comments

Comments
 (0)