Yōten: A social platform for tracking the essential points of your language learning yoten.app
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: streaks calculated on client to avoid timezone parsing issues on server

Signed-off-by: brookjeynes <me@brookjeynes.dev>

author
brookjeynes
date (May 31, 2026, 11:28 AM +1000) commit e13f52bf parent 4b5d2899 change-id oyxynstx
+57 -53
+21 -36
internal/db/stats.go
··· 102 102 return activeDays, nil 103 103 } 104 104 105 - func GetCurrentStreak(e Execer, did string) (int, error) { 105 + type StreakData struct { 106 + Latest string 107 + Count int 108 + } 109 + 110 + func GetStreakData(e Execer, did string) (StreakData, error) { 106 111 query := ` 107 112 select distinct date(date) 108 113 from study_sessions ··· 111 116 ` 112 117 rows, err := e.Query(query, did) 113 118 if err != nil { 114 - return 0, fmt.Errorf("failed to query for streak dates: %w", err) 119 + return StreakData{}, fmt.Errorf("failed to query for study dates: %w", err) 115 120 } 116 121 defer rows.Close() 117 122 118 - var studyDates []time.Time 123 + var latest string 124 + var count int 125 + var prev time.Time 126 + 119 127 for rows.Next() { 120 128 var dateStr string 121 129 if err := rows.Scan(&dateStr); err != nil { 122 - return 0, fmt.Errorf("failed to scan streak date: %w", err) 130 + return StreakData{}, fmt.Errorf("failed to scan study date: %w", err) 123 131 } 124 - parsedDate, err := time.Parse("2006-01-02", dateStr) 125 - if err != nil { 126 - return 0, fmt.Errorf("failed to parse streak date string '%s': %w", dateStr, err) 132 + curr, _ := time.Parse("2006-01-02", dateStr) 133 + if count == 0 { 134 + latest = dateStr 135 + } else if prev.Sub(curr) != 24*time.Hour { 136 + break 127 137 } 128 - studyDates = append(studyDates, parsedDate) 138 + prev = curr 139 + count++ 129 140 } 130 141 if err = rows.Err(); err != nil { 131 - return 0, fmt.Errorf("error iterating streak date rows: %w", err) 142 + return StreakData{}, fmt.Errorf("error iterating study date rows: %w", err) 132 143 } 133 144 134 - if len(studyDates) == 0 { 135 - return 0, nil 136 - } 137 - 138 - // TODO: This should be calculated in users timezone. 139 - now := time.Now().UTC() 140 - today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) 141 - yesterday := today.AddDate(0, 0, -1) 142 - 143 - mostRecentStudyDay := studyDates[0] 144 - if !mostRecentStudyDay.Equal(today) && !mostRecentStudyDay.Equal(yesterday) { 145 - return 0, nil 146 - } 147 - 148 - streak := 1 149 - for i := 1; i < len(studyDates); i++ { 150 - previousDay := studyDates[i-1] 151 - currentDay := studyDates[i] 152 - 153 - if previousDay.AddDate(0, 0, -1).Equal(currentDay) { 154 - streak++ 155 - } else { 156 - break 157 - } 158 - } 159 - 160 - return streak, nil 145 + return StreakData{Latest: latest, Count: count}, nil 161 146 } 162 147 163 148 func GetTimePerData(e Execer, did string, period TimePeriod) (ChartsData, error) {
+4 -4
internal/server/handlers/profile.go
··· 89 89 var totalStudySessions int64 90 90 var followers int 91 91 var following int 92 - var streak int 92 + var streak db.StreakData 93 93 var followStatus domain.FollowStatus 94 94 95 95 g, _ := errgroup.WithContext(r.Context()) ··· 120 120 l.Error("failed to get follow stats", "err", err) 121 121 } 122 122 123 - streak, _ = db.GetCurrentStreak(h.Db, profileDID) 123 + streak, _ = db.GetStreakData(h.Db, profileDID) 124 124 if err != nil { 125 - l.Error("failed to get streak", "err", err) 125 + l.Error("failed to get streak data", "err", err) 126 126 } 127 127 128 128 if user != nil { ··· 178 178 Following: following, 179 179 TotalStudyTime: totalStudyTime, 180 180 TotalStudySessions: totalStudySessions, 181 - Streak: streak, 181 + StreakData: streak, 182 182 }).Render(r.Context(), w) 183 183 } 184 184
+3 -3
internal/server/handlers/stats.go
··· 62 62 l.Error("failed to get total active days", "err", err) 63 63 } 64 64 65 - streak, err := db.GetCurrentStreak(h.Db, user.DID) 65 + streakData, err := db.GetStreakData(h.Db, user.DID) 66 66 if err != nil { 67 - l.Error("failed to get streak", "err", err) 67 + l.Error("failed to get streak data", "err", err) 68 68 } 69 69 70 70 heatmap, err := db.GetHeatmapData(h.Db, user.DID) ··· 88 88 TotalStudyTime: totalStudyTime, 89 89 TotalStudySessions: totalStudySessions, 90 90 TotalActiveDays: totalActiveDays, 91 - Streak: streak, 91 + StreakData: streakData, 92 92 HeatmapData: heatmap, 93 93 InputOutputPercentage: inputOutputPercentage, 94 94 LanguageSummaryChartSegments: languageChartSegments,
+2 -1
internal/server/ui/views/profile/profile.go
··· 3 3 import ( 4 4 "time" 5 5 6 + "yoten.app/internal/db" 6 7 "yoten.app/internal/domain" 7 8 ) 8 9 ··· 16 17 Following int 17 18 TotalStudyTime time.Duration 18 19 TotalStudySessions int64 19 - Streak int 20 + StreakData db.StreakData 20 21 }
+5 -7
internal/server/ui/views/profile/profile.templ
··· 45 45 </p> 46 46 } else { 47 47 <div 48 - title={ fmt.Sprintf("%d day study streak", params.Streak) } 49 - if params.Streak > 0 { 50 - class={ streakClass + "pill-streak" } 51 - } else { 52 - class={ streakClass + "pill-muted" } 53 - } 48 + x-data={ fmt.Sprintf("streakData('%s', %d)", params.StreakData.Latest, params.StreakData.Count) } 49 + :title="`${streak} day study streak`" 50 + :class={ fmt.Sprintf("streak > 0 ? '%s' : '%s'", streakClass+"pill-streak", streakClass+"pill-muted") } 54 51 > 55 52 <i class="w-4 h-4" data-lucide="flame"></i> 56 - <span>{ params.Streak }</span> 53 + <span x-text="streak"></span> 57 54 </div> 58 55 } 59 56 </div> ··· 199 196 </div> 200 197 </div> 201 198 </div> 199 + <script src="/static/js/streak.js"></script> 202 200 } 203 201 }
+1 -1
internal/server/ui/views/stats/stats.go
··· 13 13 TotalStudyTime time.Duration 14 14 TotalStudySessions int64 15 15 TotalActiveDays int 16 - Streak int 16 + StreakData db.StreakData 17 17 HeatmapData db.HeatmapData 18 18 InputOutputPercentage int 19 19 LanguageSummaryChartSegments []db.DonutChartSegment
+8 -1
internal/server/ui/views/stats/stats.templ
··· 1 1 package stats 2 2 3 3 import ( 4 + "fmt" 5 + 4 6 "yoten.app/internal/server/ui" 5 7 "yoten.app/internal/server/ui/components/donut-chart" 6 8 "yoten.app/internal/server/ui/components/header" ··· 43 45 </div> 44 46 </div> 45 47 <div class="card text-center"> 46 - <p class="text-2xl font-bold">{ params.Streak }</p> 48 + <p 49 + class="text-2xl font-bold" 50 + x-data={ fmt.Sprintf("streakData('%s', %d)", params.StreakData.Latest, params.StreakData.Count) } 51 + x-text="streak" 52 + ></p> 47 53 <div class="text-xs text-text-muted flex items-center justify-center gap-1 mt-1"> 48 54 <i class="w-4 h-4" data-lucide="trending-up"></i> 49 55 <p class="text-xs">Streak</p> ··· 140 146 </div> 141 147 </div> 142 148 </div> 149 + <script src="/static/js/streak.js"></script> 143 150 } 144 151 }
+13
static/streak.js
··· 1 + function streakData(latest, count) { 2 + return { 3 + streak: 0, 4 + init() { 5 + const d = new Date(); 6 + const fmt = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; 7 + const today = fmt(d); 8 + d.setDate(d.getDate() - 1); 9 + const yesterday = fmt(d); 10 + this.streak = (latest === today || latest === yesterday) ? count : 0; 11 + } 12 + } 13 + }