Skip to content

Commit 5a22b9b

Browse files
committed
fix: improve statusbar and use the user's timezone
1 parent 49f6757 commit 5a22b9b

File tree

4 files changed

+145
-68
lines changed

4 files changed

+145
-68
lines changed

rustytime/src/handlers/api/user.rs

Lines changed: 72 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
use aide::NoApi;
2+
use axum::Json;
23
use axum::extract::ConnectInfo;
3-
use axum::extract::{Json, Path, State};
4+
use axum::extract::{Path, State};
45
use axum::http::StatusCode;
56
use axum::response::{IntoResponse, Response};
67
use chrono::Utc;
78
use diesel::prelude::*;
89
use ipnetwork::IpNetwork;
9-
use serde_json::json;
10+
use schemars::JsonSchema;
11+
use serde::Serialize;
1012
use std::net::IpAddr;
1113
use std::net::SocketAddr;
1214

@@ -16,15 +18,47 @@ use crate::models::heartbeat::*;
1618
use crate::models::project::get_or_create_project_id;
1719
use crate::schema::heartbeats;
1820
use crate::state::AppState;
19-
use crate::utils::auth::{get_user_id_from_api_key, get_valid_api_key};
21+
use crate::utils::auth::{get_user_from_api_key, get_user_id_from_api_key, get_valid_api_key};
2022
use crate::utils::extractors::DbConnection;
2123
use crate::utils::http::extract_client_ip_from_headers;
22-
use crate::utils::time::{TimeFormat, human_readable_duration};
24+
use crate::utils::time::{
25+
TimeFormat, get_day_end_utc, get_day_start_utc, get_today_in_timezone, human_readable_duration,
26+
parse_timezone,
27+
};
2328
use std::collections::{HashMap, hash_map};
2429

2530
const MAX_HEARTBEATS_PER_REQUEST: usize = 100;
2631
const HEARTBEAT_INSERT_BATCH_SIZE: usize = 1_000; // avoids hitting Postgres' 65k parameter limit
2732

33+
#[derive(Serialize, JsonSchema)]
34+
pub struct StatusBarResponse {
35+
data: StatusBarResponseData,
36+
}
37+
38+
#[derive(Serialize, JsonSchema)]
39+
pub struct StatusBarResponseData {
40+
grand_total: StatusBarResponseDataGrandTotal,
41+
range: StatusBarResponseDataRange,
42+
}
43+
44+
#[derive(Serialize, JsonSchema)]
45+
pub struct StatusBarResponseDataGrandTotal {
46+
decimal: String,
47+
digital: String,
48+
hours: i64,
49+
minutes: i64,
50+
human_readable: String,
51+
total_seconds: i64,
52+
}
53+
54+
#[derive(Serialize, JsonSchema)]
55+
pub struct StatusBarResponseDataRange {
56+
date: String,
57+
end: String,
58+
start: String,
59+
timezone: String,
60+
}
61+
2862
/// Process heartbeat request and store in the database
2963
async fn process_heartbeat_request(
3064
app_state: &AppState,
@@ -132,14 +166,15 @@ pub async fn create_heartbeats(
132166
/// Handler to get today's status bar data
133167
pub async fn get_statusbar_today(
134168
State(app_state): State<AppState>,
135-
Path(id): Path<String>,
136169
NoApi(DbConnection(mut conn)): NoApi<DbConnection>,
170+
Path(id): Path<String>,
137171
headers: axum::http::HeaderMap,
138172
uri: axum::http::Uri,
139-
) -> Result<Json<serde_json::Value>, Response> {
140-
let user_id: i32 = if id != "current" {
173+
) -> Result<Json<StatusBarResponse>, Response> {
174+
let (user_id, timezone) = if id != "current" {
175+
// not implemented
141176
match id.parse::<i32>() {
142-
Ok(id) => id,
177+
Ok(id) => (id, None),
143178
Err(_) => return Err((StatusCode::BAD_REQUEST, "Bad request").into_response()),
144179
}
145180
} else {
@@ -149,22 +184,20 @@ pub async fn get_statusbar_today(
149184
None => return Err((StatusCode::BAD_REQUEST, "Bad request").into_response()),
150185
};
151186

152-
let user_result = get_user_id_from_api_key(&app_state.db_pool, &api_key).await;
153-
match user_result {
154-
Some(id) => id,
187+
let user_result = get_user_from_api_key(&app_state.db_pool, &api_key).await;
188+
let user = match user_result {
189+
Some(user) => user,
155190
None => return Err((StatusCode::BAD_REQUEST, "Bad request").into_response()),
156-
}
191+
};
192+
193+
(user.id, Some(user.timezone))
157194
};
158195

159-
// calculate today's date range
160-
let today = Utc::now().date_naive();
161-
let start_of_day = today.and_hms_opt(0, 0, 0).unwrap().and_utc();
162-
let end_of_day = today
163-
.succ_opt()
164-
.unwrap_or(today)
165-
.and_hms_opt(0, 0, 0)
166-
.unwrap()
167-
.and_utc();
196+
// calculate today's date range in user's timezone
197+
let user_tz = parse_timezone(timezone.as_deref().unwrap_or("UTC"));
198+
let today = get_today_in_timezone(user_tz);
199+
let start_of_day = get_day_start_utc(today, user_tz);
200+
let end_of_day = get_day_end_utc(today, user_tz);
168201

169202
match Heartbeat::get_user_duration_seconds(
170203
&mut conn,
@@ -181,18 +214,24 @@ pub async fn get_statusbar_today(
181214
Ok(total_seconds) => {
182215
let time_obj = human_readable_duration(total_seconds, TimeFormat::HourMinute);
183216

184-
Ok(Json(json!({
185-
"data": {
186-
"grand_total": {
187-
"decimal": format!("{:.2}", total_seconds as f64 / 3600.0),
188-
"digital": format!("{:02}:{:02}", time_obj.hours, time_obj.minutes),
189-
"hours": time_obj.hours,
190-
"minutes": time_obj.minutes,
191-
"text": time_obj.human_readable,
192-
"total_seconds": total_seconds
193-
}
194-
}
195-
})))
217+
Ok(Json(StatusBarResponse {
218+
data: StatusBarResponseData {
219+
grand_total: StatusBarResponseDataGrandTotal {
220+
decimal: format!("{:.2}", total_seconds as f64 / 3600.0),
221+
digital: format!("{:02}:{:02}", time_obj.hours, time_obj.minutes),
222+
hours: time_obj.hours,
223+
minutes: time_obj.minutes,
224+
human_readable: time_obj.human_readable,
225+
total_seconds,
226+
},
227+
range: StatusBarResponseDataRange {
228+
date: today.format("%Y-%m-%d").to_string(),
229+
start: start_of_day.to_rfc3339(),
230+
end: end_of_day.to_rfc3339(),
231+
timezone: timezone.unwrap_or_else(|| "UTC".to_string()),
232+
},
233+
},
234+
}))
196235
}
197236
Err(err) => {
198237
eprintln!("❌ Error calculating duration: {}", err);

rustytime/src/models/heartbeat/mod.rs

Lines changed: 13 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use axum::http::HeaderMap;
2-
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Utc};
2+
use chrono::{DateTime, Utc};
33
use chrono_tz::Tz;
44
use diesel::dsl::sql;
55
use diesel::prelude::*;
@@ -12,7 +12,10 @@ use std::fmt;
1212

1313
use crate::schema::heartbeats::{self};
1414
use crate::utils::http::parse_user_agent;
15-
use crate::utils::time::{TimeFormat, human_readable_duration};
15+
use crate::utils::time::{
16+
TimeFormat, get_day_start_utc, get_month_start_date, get_week_start_date,
17+
human_readable_duration, parse_timezone,
18+
};
1619

1720
diesel::define_sql_function! {
1821
/// Calculate user duration with filters
@@ -858,7 +861,7 @@ impl Heartbeat {
858861
range: TimeRange,
859862
user_timezone: &str,
860863
) -> QueryResult<i64> {
861-
let tz: Tz = user_timezone.parse().unwrap_or(chrono_tz::UTC);
864+
let tz = parse_timezone(user_timezone);
862865
let now = Utc::now();
863866

864867
match Self::start_boundary_utc(range, tz, now) {
@@ -897,40 +900,16 @@ impl Heartbeat {
897900
return None;
898901
}
899902

900-
let now_local = now_utc.with_timezone(&tz);
903+
let today = now_utc.with_timezone(&tz).date_naive();
901904

902-
let start_local_naive: NaiveDateTime = match range {
903-
TimeRange::Day => {
904-
// Today
905-
now_local.date_naive().and_hms_opt(0, 0, 0).unwrap()
906-
}
907-
TimeRange::Week => {
908-
// This week
909-
let days_from_monday = now_local.weekday().num_days_from_monday() as i64;
910-
let week_start_date =
911-
now_local.date_naive() - chrono::Duration::days(days_from_monday);
912-
week_start_date.and_hms_opt(0, 0, 0).unwrap()
913-
}
914-
TimeRange::Month => {
915-
// This month
916-
let y = now_local.year();
917-
let m = now_local.month();
918-
NaiveDate::from_ymd_opt(y, m, 1)
919-
.unwrap()
920-
.and_hms_opt(0, 0, 0)
921-
.unwrap()
922-
}
905+
let start_date = match range {
906+
TimeRange::Day => today,
907+
TimeRange::Week => get_week_start_date(today),
908+
TimeRange::Month => get_month_start_date(today),
923909
TimeRange::All => unreachable!(),
924910
};
925911

926-
// timezones are weird
927-
let start_local = tz
928-
.from_local_datetime(&start_local_naive)
929-
.earliest()
930-
.or_else(|| tz.from_local_datetime(&start_local_naive).latest())
931-
.unwrap_or(now_local);
932-
933-
Some(start_local.with_timezone(&Utc))
912+
Some(get_day_start_utc(start_date, tz))
934913
}
935914

936915
/// Get dashboard stats filtered by time range (day, week, month, all)
@@ -940,7 +919,7 @@ impl Heartbeat {
940919
range: TimeRange,
941920
user_timezone: &str,
942921
) -> QueryResult<DashboardStats> {
943-
let tz: Tz = user_timezone.parse().unwrap_or(chrono_tz::UTC);
922+
let tz = parse_timezone(user_timezone);
944923
let now = Utc::now();
945924

946925
let filtered_rows: Vec<DashboardMetricRow> = match Self::start_boundary_utc(range, tz, now)

rustytime/src/utils/auth/mod.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use diesel::prelude::*;
44
use serde::Deserialize;
55

66
use crate::db::connection::DbPool;
7+
use crate::models::user::User;
78
use crate::schema::users::dsl;
89

910
/// Try to get API key from the "Authorization" header
@@ -74,5 +75,16 @@ pub async fn get_user_id_from_api_key(pool: &DbPool, api_key_value: &str) -> Opt
7475
Some(user_id)
7576
}
7677

78+
/// Get user from the API key
79+
pub async fn get_user_from_api_key(pool: &DbPool, api_key_value: &str) -> Option<User> {
80+
let api_key_uuid = uuid::Uuid::parse_str(api_key_value).ok()?;
81+
let mut conn = pool.get().ok()?;
82+
let user: User = dsl::users
83+
.filter(dsl::api_key.eq(api_key_uuid))
84+
.first(&mut conn)
85+
.ok()?;
86+
Some(user)
87+
}
88+
7789
#[cfg(test)]
7890
mod tests;

rustytime/src/utils/time/mod.rs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use chrono::{DateTime, Datelike, Duration, NaiveDate, SecondsFormat, Utc};
1+
use chrono::{
2+
DateTime, Datelike, Duration, NaiveDate, NaiveDateTime, SecondsFormat, TimeZone, Utc,
3+
};
4+
use chrono_tz::Tz;
25

36
#[allow(dead_code)]
47
#[derive(Debug)]
@@ -138,5 +141,49 @@ pub fn get_week_start(date: NaiveDate) -> NaiveDate {
138141
date - chrono::Duration::days(weekday as i64)
139142
}
140143

144+
/// Parse a timezone string, returning the Tz or UTC as fallback
145+
#[inline(always)]
146+
pub fn parse_timezone(tz_str: &str) -> Tz {
147+
tz_str.parse().unwrap_or(chrono_tz::UTC)
148+
}
149+
150+
/// Convert a naive datetime in a timezone to UTC, handling DST transitions
151+
pub fn local_datetime_to_utc(naive_dt: NaiveDateTime, tz: Tz) -> DateTime<Utc> {
152+
tz.from_local_datetime(&naive_dt)
153+
.earliest()
154+
.or_else(|| tz.from_local_datetime(&naive_dt).latest())
155+
.map(|dt| dt.with_timezone(&Utc))
156+
.unwrap_or_else(Utc::now)
157+
}
158+
159+
/// Get the start of day (00:00:00) in the user's timezone, converted to UTC
160+
pub fn get_day_start_utc(date: NaiveDate, tz: Tz) -> DateTime<Utc> {
161+
let naive_dt = date.and_hms_opt(0, 0, 0).unwrap();
162+
local_datetime_to_utc(naive_dt, tz)
163+
}
164+
165+
/// Get the end of day (start of next day) in the user's timezone, converted to UTC
166+
pub fn get_day_end_utc(date: NaiveDate, tz: Tz) -> DateTime<Utc> {
167+
let tomorrow = date.succ_opt().unwrap_or(date);
168+
let naive_dt = tomorrow.and_hms_opt(0, 0, 0).unwrap();
169+
local_datetime_to_utc(naive_dt, tz)
170+
}
171+
172+
/// Get today's date in the user's timezone
173+
pub fn get_today_in_timezone(tz: Tz) -> NaiveDate {
174+
Utc::now().with_timezone(&tz).date_naive()
175+
}
176+
177+
/// Get the start of the week (Monday) for a given date
178+
pub fn get_week_start_date(date: NaiveDate) -> NaiveDate {
179+
let days_from_monday = date.weekday().num_days_from_monday() as i64;
180+
date - chrono::Duration::days(days_from_monday)
181+
}
182+
183+
/// Get the start of the month for a given date
184+
pub fn get_month_start_date(date: NaiveDate) -> NaiveDate {
185+
NaiveDate::from_ymd_opt(date.year(), date.month(), 1).unwrap()
186+
}
187+
141188
#[cfg(test)]
142189
mod tests;

0 commit comments

Comments
 (0)