Skip to content

Commit 1ea2829

Browse files
authored
Merge pull request #573 from yusufsharon/fix/414-timezone-achievement-date-tracking
fix: use UTC day boundaries for achievement date tracking (#414)
2 parents ffb0691 + ae8a6ce commit 1ea2829

4 files changed

Lines changed: 27 additions & 18 deletions

File tree

contracts/analytics/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,10 @@ impl Analytics {
375375
require_initialized(&env)?;
376376
session.student.require_auth();
377377

378+
// Issue #414: validate that start_time is a plausible UTC epoch second so
379+
// that achievement earned_date and streak calculations are timezone-safe.
380+
validate_utc_timestamp(session.start_time).map_err(|_| AnalyticsError::InvalidTimestamp)?;
381+
378382
if AnalyticsStorage::has_session(&env, &session.session_id) {
379383
return Err(AnalyticsError::SessionAlreadyExists);
380384
}

contracts/analytics/src/reports.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::{
77
LearningSession, OptionalSessionType, ProgressReport, ReportPeriod,
88
},
99
};
10+
use shared::timestamp_utils::{utc_day_start, SECS_PER_DAY};
1011
use soroban_sdk::{Address, Env, Symbol, Vec};
1112

1213
/// Report generation and time-based analytics
@@ -69,7 +70,7 @@ impl ReportGenerator {
6970
}
7071

7172
// Calculate average daily time
72-
let report_days = ((end_date - start_date) / 86400) + 1; // +1 to include both start and end days
73+
let report_days = ((end_date - start_date) / SECS_PER_DAY) + 1; // +1 to include both start and end days
7374
let average_daily_time = total_time.checked_div(report_days).unwrap_or(0);
7475

7576
// Calculate consistency score
@@ -124,8 +125,8 @@ impl ReportGenerator {
124125
course_id: &Symbol,
125126
date: u64,
126127
) -> Result<AggregatedMetrics, AnalyticsError> {
127-
let day_start = (date / 86400) * 86400; // Start of day
128-
let day_end = day_start + 86400; // End of day
128+
let day_start = utc_day_start(date);
129+
let day_end = day_start + SECS_PER_DAY;
129130

130131
let students = AnalyticsStorage::get_course_students(env, course_id);
131132
let mut active_students = 0u32;
@@ -345,13 +346,13 @@ impl ReportGenerator {
345346
return 0;
346347
}
347348

348-
let total_days = ((end_date - start_date) / 86400) + 1;
349+
let total_days = ((end_date - start_date) / SECS_PER_DAY) + 1;
349350
let mut active_days: Vec<u64> = Vec::new(env);
350351

351-
// Count unique active days
352+
// Count unique active UTC days
352353
for i in 0..sessions.len() {
353354
let session = sessions.get(i).unwrap();
354-
let day = session.start_time / 86400;
355+
let day = session.start_time / SECS_PER_DAY;
355356

356357
let mut day_exists = false;
357358
for j in 0..active_days.len() {

contracts/gamification/src/achievements.rs

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use crate::types::{
88
ActivityType, AdaptiveDifficulty, ChallengeDifficulty, GamificationKey, GamificationProfile,
99
UserAchievement,
1010
};
11+
use shared::timestamp_utils::utc_day_index;
1112

1213
/// First 25 IDs are reserved for milestone achievements seeded at init.
1314
const MILESTONE_RESERVE: u64 = 25;
@@ -615,22 +616,24 @@ impl AchievementManager {
615616
now: u64,
616617
config: &crate::types::GamificationConfig,
617618
) -> u32 {
618-
let one_day = 86_400u64;
619-
let two_days = one_day * 2;
620-
621619
if profile.current_streak == 0 {
622620
// Very first activity ever recorded for this user
623621
profile.current_streak = 1;
624622
profile.max_streak = 1;
625623
return 0;
626624
}
627625

628-
let elapsed = now.saturating_sub(profile.last_activity);
629-
630-
if elapsed < one_day {
631-
// Same day — streak already counted, no change, no bonus
632-
} else if elapsed <= two_days {
633-
// Consecutive day — extend streak
626+
// Compare UTC day indices so that a user active at 23:00 UTC and again
627+
// at 01:00 UTC the next day (elapsed = 7 200 s < 86 400 s) is correctly
628+
// treated as a new day rather than the same day.
629+
let last_day = utc_day_index(profile.last_activity);
630+
let now_day = utc_day_index(now);
631+
let day_diff = now_day.saturating_sub(last_day);
632+
633+
if day_diff == 0 {
634+
// Same UTC day — streak already counted, no change, no bonus
635+
} else if day_diff == 1 {
636+
// Consecutive UTC day — extend streak
634637
profile.current_streak += 1;
635638
if profile.current_streak > profile.max_streak {
636639
profile.max_streak = profile.current_streak;
@@ -648,7 +651,7 @@ impl AchievementManager {
648651
);
649652
}
650653
} else {
651-
// Streak broken
654+
// Streak broken (gap of 2+ UTC days)
652655
profile.current_streak = 1;
653656
}
654657

contracts/gamification/src/social.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::errors::Error;
44
use crate::events::GamificationEvents;
55
use crate::storage::GamificationStorage;
66
use crate::types::{GamificationKey, PeerEndorsement, PeerRecognition, RecognitionType};
7+
use shared::timestamp_utils::utc_day_index;
78
use shared::validation::{CoreValidator, ValidationConfig};
89

910
pub struct SocialManager;
@@ -32,9 +33,9 @@ impl SocialManager {
3233

3334
let config = GamificationStorage::get_config(env);
3435

35-
// Rate-limit: max N endorsements given per day (day = 86400s bucket)
36+
// Rate-limit: max N endorsements given per UTC day
3637
let now = env.ledger().timestamp();
37-
let day_bucket = now / 86_400;
38+
let day_bucket = utc_day_index(now);
3839
let rate_key = GamificationKey::EndorserDailyCount(endorser.clone(), day_bucket);
3940
let given_today: u32 = env.storage().persistent().get(&rate_key).unwrap_or(0u32);
4041
if given_today >= config.max_endorsements_per_day {

0 commit comments

Comments
 (0)