fix: address audit findings — nil pointers, timezone bug, dead code, tests, README
- Fix nil pointer dereference in clock.go (guard GetOrCreateSettings and GetOrCreateRPGStats errors before accessing fields) - Fix timezone bug in handleSetBreakMsg (was using system time.Now() instead of user's configured timezone) - Remove dead code: sendExportMonthPicker (unused wrapper) - Simplify xpForWorkSeconds (xpPerSecond=1, inline to just return sec) - Extract computeStreakFromDates as pure testable function from recalcAggregates; add 6 tests covering empty, current, gaps, no-today, long streak, longest-not-current edge cases - Update README with full feature documentation: RPG, League, Salary, Burnout, Work Types, Inline History Editing, all missing commands
This commit is contained in:
78
README.md
78
README.md
@@ -1,6 +1,6 @@
|
|||||||
# WorkTime Bot
|
# WorkTime Bot
|
||||||
|
|
||||||
A single-user Telegram time-tracking bot with **multi-calendar support** (Gregorian, Jalali/Persian, Hijri). Clock in/out, toggle remote/onsite, mark day off, get daily reports at a configurable time, browse history by month, and export monthly `.xlsx` reports.
|
A Telegram time-tracking bot with **RPG progression system**, **WorkTime League**, **salary estimation**, **burnout assessment**, and **multi-calendar support** (Gregorian, Jalali/Persian, Hijri). Clock in/out, track work types, mark day off, get daily reports, browse and edit history by month, and export monthly `.xlsx` reports.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ docker compose up -d
|
|||||||
|
|
||||||
## Calendar Support
|
## Calendar Support
|
||||||
|
|
||||||
Users can choose between three calendars via `/settings`:
|
Users can choose between three calendars via Settings:
|
||||||
|
|
||||||
- **Gregorian** — standard international calendar
|
- **Gregorian** — standard international calendar
|
||||||
- **Jalali** (Persian/Solar Hijri) — official calendar of Iran and Afghanistan
|
- **Jalali** (Persian/Solar Hijri) — official calendar of Iran and Afghanistan
|
||||||
@@ -34,6 +34,41 @@ Users can choose between three calendars via `/settings`:
|
|||||||
|
|
||||||
The history view, export month picker, and date displays all adapt to the user's chosen calendar. The `/export` and `/edit` commands accept dates in the user's calendar. Internally, all dates are stored as Gregorian.
|
The history view, export month picker, and date displays all adapt to the user's chosen calendar. The `/export` and `/edit` commands accept dates in the user's calendar. Internally, all dates are stored as Gregorian.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### RPG Progression System
|
||||||
|
|
||||||
|
Every second of tracked work earns XP. Level up as you accumulate XP with a quadratic curve. Maintain consecutive workdays to build a streak for bonus XP.
|
||||||
|
|
||||||
|
| Feature | Details |
|
||||||
|
| ------------ | --------------------------------------------------------------------------------------------------------- |
|
||||||
|
| XP rate | 1 XP per second worked |
|
||||||
|
| Level curve | `totalXP(n) = n * (n + 1) * 50` (Level 10 = 5,500 XP, Level 27 = 37,800 XP) |
|
||||||
|
| Streak bonus | `streak × 50` XP per session, capped at 1,000 XP |
|
||||||
|
| Achievements | 12 achievements for milestones (early bird, night owl, streaks, hours, levels, overtime, weekend warrior) |
|
||||||
|
|
||||||
|
Use `/rpg` to view your stats and achievements. Enable/disable via Settings or `/rpgtoggle`.
|
||||||
|
|
||||||
|
### WorkTime League
|
||||||
|
|
||||||
|
Compete with other users on the leaderboard. Sortable by XP, Level, Streak, or Hours. Opt in via Settings or `/leaguetoggle`. Users without a display name appear as "Anonymous".
|
||||||
|
|
||||||
|
### Salary Estimation
|
||||||
|
|
||||||
|
Estimate monthly earnings based on tracked work time. Configure your hourly rate with `/setrate <amount>` and currency symbol with `/setcurrency <symbol> <code>`. View the estimate with `/salary`.
|
||||||
|
|
||||||
|
### Burnout Assessment
|
||||||
|
|
||||||
|
Get a 0-100 burnout score based on consecutive workdays, long hours, late-night sessions, break ratio, and work volume trends. Use `/burnout`.
|
||||||
|
|
||||||
|
### Inline History Editing
|
||||||
|
|
||||||
|
Browse past days with the History button, then tap any event to edit its time, type, work type, or note. Add missing events or delete incorrect ones. Changes are reflected immediately in XP, streaks, and totals.
|
||||||
|
|
||||||
|
### Work Types
|
||||||
|
|
||||||
|
Categorize your work sessions (e.g., Remote, Onsite, Meeting). The current work type is assigned to new clock-ins. Change via the inline menu or `/worktype`.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
**Persistent reply keyboard** at the bottom of the chat: `Clock In` / `Clock Out`
|
**Persistent reply keyboard** at the bottom of the chat: `Clock In` / `Clock Out`
|
||||||
@@ -46,9 +81,13 @@ The history view, export month picker, and date displays all adapt to the user's
|
|||||||
| Report | Today's work & break summary |
|
| Report | Today's work & break summary |
|
||||||
| Export | Pick a month → download `.xlsx` |
|
| Export | Pick a month → download `.xlsx` |
|
||||||
| History | Browse past days in calendar view |
|
| History | Browse past days in calendar view |
|
||||||
| Toggle Remote/Onsite | Switch work type |
|
| Work Type | Switch work type (Remote/Onsite) |
|
||||||
| Day Off | Mark today as day off |
|
| Day Off | Mark today as day off |
|
||||||
| Settings | Timezone, accent, calendar type |
|
| RPG / Achievements | View RPG stats and achievements |
|
||||||
|
| League | View leaderboard |
|
||||||
|
| Salary | View monthly salary estimate |
|
||||||
|
| Burnout | View burnout assessment |
|
||||||
|
| Settings | Timezone, calendar, accent, more |
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
@@ -57,11 +96,11 @@ The history view, export month picker, and date displays all adapt to the user's
|
|||||||
| `/start` | Show inline menu |
|
| `/start` | Show inline menu |
|
||||||
| `/clockin` | Record clock-in |
|
| `/clockin` | Record clock-in |
|
||||||
| `/clockout` | Record clock-out |
|
| `/clockout` | Record clock-out |
|
||||||
| `/remote` | Toggle remote/onsite |
|
| `/worktype` | Change current work type |
|
||||||
| `/report` | Today's work & break summary |
|
| `/report` | Today's work & break summary |
|
||||||
|
| `/dayoff` | Toggle today as day off |
|
||||||
| `/export [YYYY-MM]` | Export monthly report (optional `YYYY-MM` in your calendar) |
|
| `/export [YYYY-MM]` | Export monthly report (optional `YYYY-MM` in your calendar) |
|
||||||
| `/summary [YYYY-MM]` | Show monthly summary (optional `YYYY-MM`) |
|
| `/summary [YYYY-MM]` | Show monthly summary (optional `YYYY-MM`) |
|
||||||
| `/dayoff` | Toggle today as day off |
|
|
||||||
| `/edit YYYY-MM-DD` | View/edit events for a specific date |
|
| `/edit YYYY-MM-DD` | View/edit events for a specific date |
|
||||||
| `/note [DATE] HH:MM <text>` | Set note for an event at a specific time |
|
| `/note [DATE] HH:MM <text>` | Set note for an event at a specific time |
|
||||||
| `/settimezone <IANA>` | Set timezone (e.g. `Asia/Tehran`) |
|
| `/settimezone <IANA>` | Set timezone (e.g. `Asia/Tehran`) |
|
||||||
@@ -69,7 +108,15 @@ The history view, export month picker, and date displays all adapt to the user's
|
|||||||
| `/setaccent` | Select export accent color |
|
| `/setaccent` | Select export accent color |
|
||||||
| `/setreporttime HH:MM` | Set daily auto-report time |
|
| `/setreporttime HH:MM` | Set daily auto-report time |
|
||||||
| `/reporttoggle` | Enable/disable daily auto-report |
|
| `/reporttoggle` | Enable/disable daily auto-report |
|
||||||
| `/settings` | Open settings menu |
|
| `/rpg` | View RPG stats |
|
||||||
|
| `/rpgtoggle` | Enable/disable RPG system |
|
||||||
|
| `/league` | View WorkTime League leaderboard |
|
||||||
|
| `/leaguetoggle` | Opt in/out of the league |
|
||||||
|
| `/salary` | View monthly salary estimate |
|
||||||
|
| `/setrate <amount>` | Set hourly rate for salary estimation |
|
||||||
|
| `/setcurrency <sym> <code>` | Set currency (e.g. `$ USD`, `T Toman`, `€ EUR`) |
|
||||||
|
| `/burnout` | View burnout assessment |
|
||||||
|
| `/setusername <name>` | Set display name for the league |
|
||||||
|
|
||||||
## Daily Report
|
## Daily Report
|
||||||
|
|
||||||
@@ -102,13 +149,24 @@ Four color themes are available in settings: **Ocean**, **Beach**, **Rose**, **C
|
|||||||
├── internal/
|
├── internal/
|
||||||
│ ├── bot/
|
│ ├── bot/
|
||||||
│ │ ├── handlers.go Message/callback routing, views, menu builders
|
│ │ ├── handlers.go Message/callback routing, views, menu builders
|
||||||
|
│ │ ├── clock.go Clock in/out, day off, report
|
||||||
│ │ ├── totals.go Break/work computation state machine
|
│ │ ├── totals.go Break/work computation state machine
|
||||||
|
│ │ ├── calendar.go History view and inline event editing
|
||||||
│ │ ├── export.go Excel (.xlsx) report generation
|
│ │ ├── export.go Excel (.xlsx) report generation
|
||||||
│ │ └── dateutil.go Gregorian/Jalali/Hijri conversions & calendars
|
│ │ ├── dateutil.go Gregorian/Jalali/Hijri conversions & calendars
|
||||||
|
│ │ ├── rpg.go XP, levels, streaks, achievements
|
||||||
|
│ │ ├── league.go WorkTime League leaderboard
|
||||||
|
│ │ ├── salary.go Salary estimation, rate/currency commands
|
||||||
|
│ │ ├── burnout.go Burnout assessment model
|
||||||
|
│ │ ├── settings.go Settings menu with inline pickers
|
||||||
|
│ │ ├── summary.go Monthly summary
|
||||||
|
│ │ ├── report.go Daily report builder
|
||||||
|
│ │ └── sanitize.go Input sanitization helpers
|
||||||
│ └── db/
|
│ └── db/
|
||||||
│ ├── store.go SQLite store
|
│ ├── store.go SQLite store (data access layer)
|
||||||
│ └── migrations/ Goose-managed SQL migrations
|
│ └── migrations/ Goose-managed SQL migrations
|
||||||
│ └── 001_init.sql Initial schema
|
│ ├── 001_init.sql Initial schema
|
||||||
|
│ └── 002_features.sql RPG, achievements, user_settings
|
||||||
├── .gitea/workflows/
|
├── .gitea/workflows/
|
||||||
│ └── ci.yml Gitea Actions CI
|
│ └── ci.yml Gitea Actions CI
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
|
|||||||
@@ -64,24 +64,26 @@ func (h *Handler) clockOut(chatID int64) (string, error) {
|
|||||||
text := fmt.Sprintf("clocked out at %s", now.Format(TimeLayout))
|
text := fmt.Sprintf("clocked out at %s", now.Format(TimeLayout))
|
||||||
|
|
||||||
// Award XP and update streaks if RPG enabled
|
// Award XP and update streaks if RPG enabled
|
||||||
st, _ := h.DB.GetOrCreateSettings(user.ID)
|
st, err := h.DB.GetOrCreateSettings(user.ID)
|
||||||
if st.RPGEnabled {
|
if err == nil && st.RPGEnabled {
|
||||||
sessionWork := now.Unix() - last.OccurredAt
|
sessionWork := now.Unix() - last.OccurredAt
|
||||||
today := now.Format(DateLayout)
|
today := now.Format(DateLayout)
|
||||||
isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday
|
isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday
|
||||||
|
|
||||||
stats, _ := h.DB.GetOrCreateRPGStats(user.ID)
|
stats, err := h.DB.GetOrCreateRPGStats(user.ID)
|
||||||
h.updateStreak(stats, user.ID, today)
|
if err == nil {
|
||||||
xp, leveledUp, newLevel, _ := h.computeAndAwardXP(stats, user.ID, sessionWork, stats.CurrentStreak, today)
|
h.updateStreak(stats, user.ID, today)
|
||||||
totalHours := float64(stats.TotalWorkSeconds) / secondsPerHour
|
xp, leveledUp, newLevel, _ := h.computeAndAwardXP(stats, user.ID, sessionWork, stats.CurrentStreak, today)
|
||||||
unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend, totalHours)
|
totalHours := float64(stats.TotalWorkSeconds) / secondsPerHour
|
||||||
|
unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend, totalHours)
|
||||||
|
|
||||||
text += fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel)
|
text += fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel)
|
||||||
if leveledUp {
|
if leveledUp {
|
||||||
text += "\nLevel up!"
|
text += "\nLevel up!"
|
||||||
}
|
}
|
||||||
for _, a := range unlocked {
|
for _, a := range unlocked {
|
||||||
text += fmt.Sprintf("\nAchievement: %s", a)
|
text += fmt.Sprintf("\nAchievement: %s", a)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -388,11 +388,6 @@ func (h *Handler) exportCallback(ctx context.Context, chatID int64, msgID int, c
|
|||||||
h.editExportMonthPicker(ctx, chatID, msgID, cy, cm, user)
|
h.editExportMonthPicker(ctx, chatID, msgID, cy, cm, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendExportMonthPicker sends the export month picker as a new message.
|
|
||||||
func (h *Handler) sendExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, month int, user *db.User) {
|
|
||||||
h.editExportMonthPicker(ctx, chatID, msgID, year, month, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
// editExportMonthPicker renders a year-based month picker with 12 month buttons.
|
// editExportMonthPicker renders a year-based month picker with 12 month buttons.
|
||||||
func (h *Handler) editExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, _ int, user *db.User) {
|
func (h *Handler) editExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, _ int, user *db.User) {
|
||||||
months := gregMonthNames
|
months := gregMonthNames
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ import (
|
|||||||
"worktimeBot/internal/db"
|
"worktimeBot/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// XP earned per second of work.
|
|
||||||
const xpPerSecond int64 = 1
|
|
||||||
|
|
||||||
// xpForLevel returns the total XP required to reach level n.
|
// xpForLevel returns the total XP required to reach level n.
|
||||||
// Quadratic curve: totalXP(n) = n * (n + 1) * xpCurveMultiplier
|
// Quadratic curve: totalXP(n) = n * (n + 1) * xpCurveMultiplier
|
||||||
// Level 1 = 100 XP, Level 10 = 5500 XP, Level 50 = 127500 XP
|
// Level 1 = 100 XP, Level 10 = 5500 XP, Level 50 = 127500 XP
|
||||||
@@ -34,7 +31,7 @@ func levelForXP(xp int64) int {
|
|||||||
// xpForWorkSeconds calculates XP earned for a given amount of work.
|
// xpForWorkSeconds calculates XP earned for a given amount of work.
|
||||||
// Base: 1 XP per second.
|
// Base: 1 XP per second.
|
||||||
func xpForWorkSeconds(sec int64) int64 {
|
func xpForWorkSeconds(sec int64) int64 {
|
||||||
return sec / xpPerSecond
|
return sec
|
||||||
}
|
}
|
||||||
|
|
||||||
// streakBonus returns bonus XP for maintaining a streak.
|
// streakBonus returns bonus XP for maintaining a streak.
|
||||||
@@ -47,6 +44,54 @@ func streakBonus(streak int) int64 {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// computeStreakFromDates computes current and longest streak from a sorted list of date strings.
|
||||||
|
// today is the reference date for the current streak.
|
||||||
|
func computeStreakFromDates(dates []string, today string) (current, longest int) {
|
||||||
|
if len(dates) == 0 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
dateSet := make(map[string]struct{}, len(dates))
|
||||||
|
for _, d := range dates {
|
||||||
|
dateSet[d] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current streak: count consecutive days backwards from today
|
||||||
|
if _, ok := dateSet[today]; ok {
|
||||||
|
current = 1
|
||||||
|
for i := 1; ; i++ {
|
||||||
|
t, _ := time.Parse(DateLayout, today)
|
||||||
|
prev := t.AddDate(0, 0, -i).Format(DateLayout)
|
||||||
|
if _, ok := dateSet[prev]; ok {
|
||||||
|
current++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longest streak: find longest consecutive run in sorted dates
|
||||||
|
longest = 0
|
||||||
|
run := 1
|
||||||
|
for i := 1; i < len(dates); i++ {
|
||||||
|
prev, _ := time.Parse(DateLayout, dates[i-1])
|
||||||
|
curr, _ := time.Parse(DateLayout, dates[i])
|
||||||
|
if curr.Sub(prev).Hours() == 24 {
|
||||||
|
run++
|
||||||
|
} else {
|
||||||
|
if run > longest {
|
||||||
|
longest = run
|
||||||
|
}
|
||||||
|
run = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if run > longest {
|
||||||
|
longest = run
|
||||||
|
}
|
||||||
|
|
||||||
|
return current, longest
|
||||||
|
}
|
||||||
|
|
||||||
// computeAndAwardXP calculates XP for a work session and persists it.
|
// computeAndAwardXP calculates XP for a work session and persists it.
|
||||||
// stats must already have the streak updated before calling.
|
// stats must already have the streak updated before calling.
|
||||||
// Returns the new total XP, whether the user leveled up, and the new level.
|
// Returns the new total XP, whether the user leveled up, and the new level.
|
||||||
@@ -135,45 +180,9 @@ func (h *Handler) recalcAggregates(userID int64, loc *time.Location) {
|
|||||||
|
|
||||||
// Recompute streak from scratch
|
// Recompute streak from scratch
|
||||||
dates, err := h.DB.GetDatesWithEvents(userID)
|
dates, err := h.DB.GetDatesWithEvents(userID)
|
||||||
if err == nil && len(dates) > 0 {
|
today := time.Now().In(loc).Format(DateLayout)
|
||||||
dateSet := make(map[string]struct{}, len(dates))
|
if err == nil {
|
||||||
for _, d := range dates {
|
stats.CurrentStreak, stats.LongestStreak = computeStreakFromDates(dates, today)
|
||||||
dateSet[d] = struct{}{}
|
|
||||||
}
|
|
||||||
today := time.Now().In(loc).Format(DateLayout)
|
|
||||||
|
|
||||||
// Current streak
|
|
||||||
stats.CurrentStreak = 0
|
|
||||||
if _, ok := dateSet[today]; ok {
|
|
||||||
stats.CurrentStreak = 1
|
|
||||||
for i := 1; ; i++ {
|
|
||||||
prev := time.Now().In(loc).AddDate(0, 0, -i).Format(DateLayout)
|
|
||||||
if _, ok := dateSet[prev]; ok {
|
|
||||||
stats.CurrentStreak++
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Longest streak
|
|
||||||
stats.LongestStreak = 0
|
|
||||||
run := 1
|
|
||||||
for i := 1; i < len(dates); i++ {
|
|
||||||
prev, _ := time.Parse(DateLayout, dates[i-1])
|
|
||||||
curr, _ := time.Parse(DateLayout, dates[i])
|
|
||||||
if curr.Sub(prev).Hours() == 24 {
|
|
||||||
run++
|
|
||||||
} else {
|
|
||||||
if run > stats.LongestStreak {
|
|
||||||
stats.LongestStreak = run
|
|
||||||
}
|
|
||||||
run = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if run > stats.LongestStreak {
|
|
||||||
stats.LongestStreak = run
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
stats.CurrentStreak = 0
|
stats.CurrentStreak = 0
|
||||||
stats.LongestStreak = 0
|
stats.LongestStreak = 0
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package bot
|
package bot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -225,3 +226,72 @@ func TestComputeTrendFromAvgs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestComputeStreakFromDates_Empty(t *testing.T) {
|
||||||
|
cur, longest := computeStreakFromDates(nil, "2026-06-25")
|
||||||
|
if cur != 0 || longest != 0 {
|
||||||
|
t.Errorf("empty dates: got (%d, %d), want (0, 0)", cur, longest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeStreakFromDates_CurrentOnly(t *testing.T) {
|
||||||
|
dates := []string{"2026-06-25", "2026-06-26"}
|
||||||
|
cur, longest := computeStreakFromDates(dates, "2026-06-26")
|
||||||
|
if cur != 2 {
|
||||||
|
t.Errorf("current streak = %d, want 2", cur)
|
||||||
|
}
|
||||||
|
if longest != 2 {
|
||||||
|
t.Errorf("longest streak = %d, want 2", longest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeStreakFromDates_GapBreaksStreak(t *testing.T) {
|
||||||
|
dates := []string{"2026-06-23", "2026-06-25", "2026-06-26"}
|
||||||
|
cur, longest := computeStreakFromDates(dates, "2026-06-26")
|
||||||
|
if cur != 2 {
|
||||||
|
t.Errorf("current streak = %d, want 2 (today and yesterday)", cur)
|
||||||
|
}
|
||||||
|
if longest != 2 {
|
||||||
|
t.Errorf("longest streak = %d, want 2", longest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeStreakFromDates_NoEventsToday(t *testing.T) {
|
||||||
|
dates := []string{"2026-06-24", "2026-06-25"}
|
||||||
|
cur, longest := computeStreakFromDates(dates, "2026-06-26")
|
||||||
|
if cur != 0 {
|
||||||
|
t.Errorf("current streak = %d, want 0 (no events today)", cur)
|
||||||
|
}
|
||||||
|
if longest != 2 {
|
||||||
|
t.Errorf("longest streak = %d, want 2", longest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeStreakFromDates_LongStreak(t *testing.T) {
|
||||||
|
dates := []string{}
|
||||||
|
for d := 1; d <= 10; d++ {
|
||||||
|
dates = append(dates, fmt.Sprintf("2026-06-%02d", d))
|
||||||
|
}
|
||||||
|
cur, longest := computeStreakFromDates(dates, "2026-06-10")
|
||||||
|
if cur != 10 {
|
||||||
|
t.Errorf("current streak = %d, want 10", cur)
|
||||||
|
}
|
||||||
|
if longest != 10 {
|
||||||
|
t.Errorf("longest streak = %d, want 10", longest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeStreakFromDates_LongestNotCurrent(t *testing.T) {
|
||||||
|
dates := []string{}
|
||||||
|
for d := 1; d <= 5; d++ {
|
||||||
|
dates = append(dates, fmt.Sprintf("2026-06-%02d", d))
|
||||||
|
}
|
||||||
|
dates = append(dates, "2026-06-09", "2026-06-10")
|
||||||
|
cur, longest := computeStreakFromDates(dates, "2026-06-10")
|
||||||
|
if cur != 2 {
|
||||||
|
t.Errorf("current streak = %d, want 2", cur)
|
||||||
|
}
|
||||||
|
if longest != 5 {
|
||||||
|
t.Errorf("longest streak = %d, want 5", longest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,7 +146,8 @@ func (h *Handler) handleSetBreakMsg(ctx context.Context, msg *models.Message) {
|
|||||||
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
|
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := time.Now()
|
loc := loadLocation(user.Timezone)
|
||||||
|
now := time.Now().In(loc)
|
||||||
date := now.Format(DateLayout)
|
date := now.Format(DateLayout)
|
||||||
day, err := h.DB.GetOrCreateDay(user.ID, date)
|
day, err := h.DB.GetOrCreateDay(user.ID, date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user