package timewarrior import ( "fmt" "log/slog" "math" "strconv" "strings" "time" ) const ( dtformat = "20060102T150405Z" ) type Intervals []*Interval type Interval struct { ID int `json:"-"` Start string `json:"start,omitempty"` End string `json:"end,omitempty"` Tags []string `json:"tags,omitempty"` IsGap bool `json:"-"` // True if this represents an untracked time gap } func NewInterval() *Interval { return &Interval{ Tags: make([]string, 0), } } // NewGapInterval creates a new gap interval representing untracked time. // start and end are the times between which the gap occurred. func NewGapInterval(start, end time.Time) *Interval { return &Interval{ ID: -1, // Gap intervals have no real ID Start: start.UTC().Format(dtformat), End: end.UTC().Format(dtformat), Tags: make([]string, 0), IsGap: true, } } func (i *Interval) GetString(field string) string { // Special handling for gap intervals if i.IsGap { switch field { case "duration": return i.GetDuration() case "gap_display": return fmt.Sprintf("--- Untracked: %s ---", i.GetDuration()) default: return "" } } switch field { case "id": return strconv.Itoa(i.ID) case "start": return formatDate(i.Start, "formatted") case "start_time": return formatDate(i.Start, "time") case "end": if i.End == "" { return "now" } return formatDate(i.End, "formatted") case "end_time": if i.End == "" { return "now" } return formatDate(i.End, "time") case "weekday": return formatDate(i.Start, "weekday") case "tags": if len(i.Tags) == 0 { return "" } return strings.Join(i.Tags, " ") case "duration": return i.GetDuration() case "active": if i.End == "" { return "●" } return "" default: slog.Error(fmt.Sprintf("Field not implemented: %s", field)) return "" } } func (i *Interval) GetDuration() string { start, err := time.Parse(dtformat, i.Start) if err != nil { slog.Error("Failed to parse start time:", err) return "" } var end time.Time if i.End == "" { end = time.Now() } else { end, err = time.Parse(dtformat, i.End) if err != nil { slog.Error("Failed to parse end time:", err) return "" } } duration := end.Sub(start) return formatDuration(duration) } func (i *Interval) GetStartTime() time.Time { dt, err := time.Parse(dtformat, i.Start) if err != nil { slog.Error("Failed to parse time:", err) return time.Time{} } return dt } func (i *Interval) GetEndTime() time.Time { if i.End == "" { return time.Now() } dt, err := time.Parse(dtformat, i.End) if err != nil { slog.Error("Failed to parse time:", err) return time.Time{} } return dt } func (i *Interval) HasTag(tag string) bool { for _, t := range i.Tags { if t == tag { return true } } return false } func (i *Interval) AddTag(tag string) { if !i.HasTag(tag) { i.Tags = append(i.Tags, tag) } } func (i *Interval) RemoveTag(tag string) { for idx, t := range i.Tags { if t == tag { i.Tags = append(i.Tags[:idx], i.Tags[idx+1:]...) return } } } func (i *Interval) IsActive() bool { return i.End == "" } func formatDate(date string, format string) string { if date == "" { return "" } dt, err := time.Parse(dtformat, date) if err != nil { slog.Error("Failed to parse time:", err) return "" } dt = dt.Local() switch format { case "formatted", "": return dt.Format("2006-01-02 15:04") case "time": return dt.Format("15:04") case "date": return dt.Format("2006-01-02") case "weekday": return dt.Format("Mon") case "iso": return dt.Format("2006-01-02T150405Z") case "epoch": return strconv.FormatInt(dt.Unix(), 10) case "age": return parseDurationVague(time.Since(dt)) case "relative": return parseDurationVague(time.Until(dt)) default: slog.Error(fmt.Sprintf("Date format not implemented: %s", format)) return "" } } func formatDuration(d time.Duration) string { hours := int(d.Hours()) minutes := int(d.Minutes()) % 60 seconds := int(d.Seconds()) % 60 return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds) } func parseDurationVague(d time.Duration) string { dur := d.Round(time.Second).Abs() days := dur.Hours() / 24 var formatted string if dur >= time.Hour*24*365 { formatted = fmt.Sprintf("%.1fy", days/365) } else if dur >= time.Hour*24*90 { formatted = strconv.Itoa(int(math.Round(days/30))) + "mo" } else if dur >= time.Hour*24*7 { formatted = strconv.Itoa(int(math.Round(days/7))) + "w" } else if dur >= time.Hour*24 { formatted = strconv.Itoa(int(days)) + "d" } else if dur >= time.Hour { formatted = strconv.Itoa(int(dur.Round(time.Hour).Hours())) + "h" } else if dur >= time.Minute { formatted = strconv.Itoa(int(dur.Round(time.Minute).Minutes())) + "min" } else if dur >= time.Second { formatted = strconv.Itoa(int(dur.Round(time.Second).Seconds())) + "s" } if d < 0 { formatted = "-" + formatted } return formatted } var ( dateFormats = []string{ "2006-01-02", "2006-01-02T15:04", "20060102T150405Z", } specialDateFormats = []string{ "", "now", "today", "yesterday", "tomorrow", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", "mon", "tue", "wed", "thu", "fri", "sat", "sun", } ) func ValidateDate(s string) error { for _, f := range dateFormats { if _, err := time.Parse(f, s); err == nil { return nil } } for _, f := range specialDateFormats { if s == f { return nil } } return fmt.Errorf("invalid date") } func ValidateDuration(s string) error { // TODO: implement proper duration validation // Should accept formats like: 1h, 30m, 1h30m, etc. return nil } // Summary represents time tracking summary data type Summary struct { Range string TotalTime time.Duration ByTag map[string]time.Duration } func (s *Summary) GetTotalString() string { return formatDuration(s.TotalTime) } func (s *Summary) GetTagTime(tag string) string { if duration, ok := s.ByTag[tag]; ok { return formatDuration(duration) } return "0:00" }