this repo has no description
1
fork

Configure Feed

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

✨ Implement time spent calculation with Wakatime

+310 -7
+1 -1
Justfile
··· 3 3 REMOVE_UNUSED_MESSAGES=1 ENV=static ./tmp/main 4 4 5 5 dev: 6 - MAIL_PASSWORD=$(rbw get mail.ewen.works) ENV=development air 6 + WAKATIME_API_KEY=$(rbw get 'wakatime api key') MAIL_PASSWORD=$(rbw get mail.ewen.works) ENV=development air 7 7 8 8 start: 9 9 just build
+3
i18n/fr.po
··· 411 411 msgid "thumbnails" 412 412 msgstr "miniatures" 413 413 414 + msgid "Time spent" 415 + msgstr "Temps passé" 416 + 414 417 msgid "typeface" 415 418 msgstr "police de caractères" 416 419
+9
main.go
··· 225 225 var tags []shared.Tag 226 226 loadDataFile("tags.yaml", &tags) 227 227 228 + for i := range technologies { 229 + err := technologies[i].CalculateTimeSpent() 230 + if err != nil { 231 + color.Yellow("[!!] While calculating time spent on %s: %s", technologies[i].Name, err) 232 + } else if technologies[i].TimeSpent.Seconds() > 0 { 233 + fmt.Printf("[ ] Time spent with %s is %s, via wakatime\n", technologies[i].Name, technologies[i].TimeSpent) 234 + } 235 + } 236 + 228 237 var wg sync.WaitGroup 229 238 wg.Add(len(locales)) 230 239
+4
pages/filtered.templ
··· 17 17 templ TechnologyPage(tech shared.Technology, db ortfodb.Database, locale string) { 18 18 @components.IntroWith("", tech.Name, false) { 19 19 <i18n>{ tech.Description }</i18n> 20 + if tech.TimeSpent.Seconds() > 0 { 21 + <br/> 22 + <strong i18n>Time spent</strong> { formatDuration(tech.TimeSpent, locale) } 23 + } 20 24 } 21 25 @components.GalleryPage( 22 26 tech.Works(db),
+81
pages/work.templ
··· 4 4 import "github.com/ewen-lbh/portfolio/shared" 5 5 import "fmt" 6 6 import "github.com/ewen-lbh/portfolio/components" 7 + import "time" 7 8 8 9 func gridAreas(layout ortfodb.Layout) shared.Selectors { 9 10 var areas []string ··· 218 219 width: 100%; 219 220 } 220 221 222 + // formatDuration formats a duration in a human-readable way using plain words, depending on the language given (fr or en) 223 + // it handles plurals and singulars, and does not using any words higher than days 224 + func formatDuration(duration time.Duration, lang string) string { 225 + var days, hours, minutes int 226 + if duration.Hours() > 24 { 227 + days = int(duration.Hours() / 24) 228 + duration -= time.Duration(days) * 24 * time.Hour 229 + } 230 + if duration.Hours() > 1 { 231 + hours = int(duration.Hours()) 232 + duration -= time.Duration(hours) * time.Hour 233 + } 234 + if duration.Minutes() > 1 { 235 + minutes = int(duration.Minutes()) 236 + duration -= time.Duration(minutes) * time.Minute 237 + } 238 + 239 + pluralize := func(word string, count int, lang string) string { 240 + switch word { 241 + case "day": 242 + switch lang { 243 + case "fr": 244 + if count > 1 { 245 + return "jours" 246 + } else { 247 + return "jour" 248 + } 249 + default: 250 + if count > 1 { 251 + return "days" 252 + } else { 253 + return "day" 254 + } 255 + } 256 + case "hour": 257 + switch lang { 258 + case "fr": 259 + if count > 1 { 260 + return "heures" 261 + } else { 262 + return "heure" 263 + } 264 + default: 265 + if count > 1 { 266 + return "hours" 267 + } else { 268 + return "hour" 269 + } 270 + } 271 + case "minute": 272 + if count > 1 { 273 + return "minutes" 274 + } else { 275 + return "minute" 276 + } 277 + default: 278 + return word 279 + } 280 + } 281 + 282 + var parts []string 283 + if days > 0 { 284 + parts = append(parts, fmt.Sprintf("%d %s", days, pluralize("day", days, lang))) 285 + } 286 + if hours > 0 { 287 + parts = append(parts, fmt.Sprintf("%d %s", hours, pluralize("hour", hours, lang))) 288 + } 289 + if minutes > 0 && days == 0 { 290 + parts = append(parts, fmt.Sprintf("%d %s", minutes, pluralize("minute", minutes, lang))) 291 + } 292 + 293 + return strings.Join(parts, " ") 294 + } 295 + 221 296 templ Work(work ortfodb.AnalyzedWork, tags []shared.Tag, techs []shared.Technology, lang string) { 222 297 @components.IntroWith( 223 298 shared.FormatDate(work.Metadata.CreatedAt(), "January 2006", lang), ··· 256 331 </div> 257 332 } 258 333 </section> 334 + if shared.TimeSpentOnProject(work).Hours() > 0 { 335 + <section class="time-spent"> 336 + <h2 i18n>Time spent</h2> 337 + <p>{ formatDuration(shared.TimeSpentOnProject(work), lang) }</p> 338 + </section> 339 + } 259 340 if len(techs) > 0 { 260 341 <section class="made-with"> 261 342 <h2 i18n>Made with</h2>
+48 -6
shared/tags_and_techs.go
··· 1 1 package shared 2 2 3 3 import ( 4 + "encoding/json" 4 5 "fmt" 5 - "strings" 6 + "time" 6 7 7 8 "github.com/metal3d/go-slugify" 8 9 ortfodb "github.com/ortfo/db" 10 + "golang.org/x/text/collate" 11 + "golang.org/x/text/language" 9 12 ) 10 13 11 14 // stringsLooselyMatch checks if s1 is equal to any of sn, but case-insensitively. 12 15 func stringsLooselyMatch(s1 string, sn ...string) bool { 13 - for _, s2 := range sn { 14 - if strings.EqualFold(s1, s2) { 15 - return true 16 - } 16 + collator := collate.New(language.English, collate.Loose) 17 + if len(sn) == 0 { 18 + return false 19 + } else { 20 + return collator.CompareString(s1, sn[0]) == 0 || stringsLooselyMatch(s1, sn[1:]...) 17 21 } 18 - return false 19 22 } 20 23 21 24 // String returns the string representation of the external site. ··· 49 52 // ReferredToBy returns whether the given name refers to the tech 50 53 func (t *Technology) ReferredToBy(name string) bool { 51 54 return stringsLooselyMatch(name, t.Slug, t.Name) || stringsLooselyMatch(name, t.Aliases...) 55 + } 56 + 57 + // CalculateTimeSpent updates the time spent on the technology, using wakatime data 58 + func (t *Technology) CalculateTimeSpent() error { 59 + // In dev, don't calculate times, it just slows everything down 60 + // if IsDev() { 61 + // return nil 62 + // } 63 + 64 + if len(timeSpentOnTechs) > 0 { 65 + for techName, duration := range timeSpentOnTechs { 66 + if t.ReferredToBy(techName) { 67 + t.TimeSpent = duration 68 + return nil 69 + } 70 + } 71 + return nil 72 + } 73 + 74 + var stats struct { 75 + Data wakatimeUserStats `json:"data"` 76 + } 77 + 78 + resp, err := wakatimeRequest("users/current/stats/all_time") 79 + decoder := json.NewDecoder(resp.Body) 80 + decoder.Decode(&stats) 81 + if err != nil { 82 + return fmt.Errorf("while fetching user stats: %w", err) 83 + } 84 + 85 + for _, tech := range stats.Data.Languages { 86 + timeSpentOnTechs[tech.Name] = time.Duration(tech.TotalSeconds) * time.Second 87 + if t.ReferredToBy(tech.Name) { 88 + t.TimeSpent = time.Duration(tech.TotalSeconds) * time.Second 89 + return nil 90 + } 91 + } 92 + 93 + return nil 52 94 } 53 95 54 96 func TagsOf(all []Tag, metadata ortfodb.WorkMetadata) []Tag {
+3
shared/types.go
··· 1 1 package shared 2 2 3 3 import ( 4 + "time" 5 + 4 6 ortfodb "github.com/ortfo/db" 5 7 ) 6 8 ··· 25 27 Aliases []string `yaml:"aliases"` 26 28 LearnMoreURL string `yaml:"learn more at"` 27 29 Description string `yaml:"description"` 30 + TimeSpent time.Duration 28 31 } 29 32 30 33 func (tech Technology) Works(db ortfodb.Database) (worksWithTech []ortfodb.AnalyzedWork) {
+161
shared/wakatime.go
··· 1 + package shared 2 + 3 + import ( 4 + "encoding/base64" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "os" 9 + "sync" 10 + "time" 11 + 12 + "github.com/fatih/color" 13 + ortfodb "github.com/ortfo/db" 14 + ) 15 + 16 + const WAKATIME_API_URL = "https://wakatime.com/api/v1" 17 + 18 + func wakatimeRequest(path string) (resp *http.Response, err error) { 19 + req, err := http.NewRequest("GET", WAKATIME_API_URL+"/"+path, nil) 20 + if err != nil { 21 + err = fmt.Errorf("while creating request: %w", err) 22 + return 23 + } 24 + 25 + req.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(os.Getenv("WAKATIME_API_KEY"))))) 26 + resp, err = http.DefaultClient.Do(req) 27 + if err != nil { 28 + err = fmt.Errorf("while sending request: %w", err) 29 + return 30 + } 31 + 32 + // bodyString, _ := io.ReadAll(resp.Body) 33 + // fmt.Printf("Got response %s\n", bodyString) 34 + return 35 + } 36 + 37 + var timeSpentOnProjects struct { 38 + mu sync.Mutex 39 + durations map[string]time.Duration 40 + computedAt map[string]time.Time 41 + } = struct { 42 + mu sync.Mutex 43 + durations map[string]time.Duration 44 + computedAt map[string]time.Time 45 + }{ 46 + durations: make(map[string]time.Duration), 47 + computedAt: make(map[string]time.Time), 48 + } 49 + 50 + const WAKATIME_CACHE_LIFETIME = 24 * time.Hour 51 + 52 + var timeSpentOnTechs map[string]time.Duration = make(map[string]time.Duration) 53 + 54 + func TimeSpentOnProject(work ortfodb.AnalyzedWork) time.Duration { 55 + // In dev, don't calculate times, it just slows everything down 56 + // if IsDev() { 57 + // return 0, nil 58 + // } 59 + 60 + timeSpentOnProjects.mu.Lock() 61 + defer timeSpentOnProjects.mu.Unlock() 62 + 63 + for id, duration := range timeSpentOnProjects.durations { 64 + if id == work.ID && time.Since(timeSpentOnProjects.computedAt[id]) < WAKATIME_CACHE_LIFETIME { 65 + return duration 66 + } 67 + } 68 + 69 + var data struct { 70 + Data wakatimeProjectStats `json:"data"` 71 + } 72 + resp, err := wakatimeRequest("users/current/all_time_since_today?project=" + work.ID) 73 + if err != nil { 74 + color.Red("[!!] Could not get time spent on %s: while fetching wakatime API: %w", work.ID, err) 75 + return 0 76 + } 77 + if resp.StatusCode == 404 { 78 + color.Yellow("[!!] Could not get time spent on %s: not found on wakatime", work.ID) 79 + } 80 + 81 + decoder := json.NewDecoder(resp.Body) 82 + decoder.Decode(&data) 83 + duration := time.Duration(data.Data.TotalSeconds) * time.Second 84 + if duration.Seconds() != 0 { 85 + fmt.Printf("[ ] Time spent on %s is %s, via wakatime\n", work.ID, duration) 86 + } 87 + timeSpentOnProjects.durations[work.ID] = duration 88 + timeSpentOnProjects.computedAt[work.ID] = time.Now() 89 + return duration 90 + } 91 + 92 + type wakatimeBestDay struct { 93 + Date string `json:"date"` 94 + TotalSeconds float64 `json:"total_seconds"` 95 + Text string `json:"text"` 96 + } 97 + 98 + type wakatimeCategory struct { 99 + Name string `json:"name"` 100 + TotalSeconds float64 `json:"total_seconds"` 101 + Percent float64 `json:"percent"` 102 + Digital string `json:"digital"` 103 + Decimal string `json:"decimal"` 104 + Text string `json:"text"` 105 + Hours int64 `json:"hours"` 106 + Minutes int64 `json:"minutes"` 107 + MachineNameID *string `json:"machine_name_id,omitempty"` 108 + } 109 + 110 + type wakatimeProjectStats struct { 111 + TotalSeconds float64 `json:"total_seconds"` 112 + IsUpToDate bool `json:"is_up_to_date"` 113 + Range struct { 114 + Start time.Time `json:"start"` 115 + End time.Time `json:"end"` 116 + } `json:"range"` 117 + } 118 + 119 + type wakatimeUserStats struct { 120 + ID string `json:"id"` 121 + UserID string `json:"user_id"` 122 + Range string `json:"range"` 123 + Start string `json:"start"` 124 + End string `json:"end"` 125 + Timeout int64 `json:"timeout"` 126 + WritesOnly bool `json:"writes_only"` 127 + Timezone string `json:"timezone"` 128 + Holidays int64 `json:"holidays"` 129 + Status string `json:"status"` 130 + CreatedAt string `json:"created_at"` 131 + ModifiedAt string `json:"modified_at"` 132 + HumanReadableDailyAverage string `json:"human_readable_daily_average"` 133 + BestDay wakatimeBestDay `json:"best_day"` 134 + HumanReadableTotalIncludingOtherLanguage string `json:"human_readable_total_including_other_language"` 135 + Machines []wakatimeCategory `json:"machines"` 136 + Projects []wakatimeCategory `json:"projects"` 137 + OperatingSystems []wakatimeCategory `json:"operating_systems"` 138 + IsUpToDatePendingFuture bool `json:"is_up_to_date_pending_future"` 139 + DaysIncludingHolidays int64 `json:"days_including_holidays"` 140 + TotalSecondsIncludingOtherLanguage float64 `json:"total_seconds_including_other_language"` 141 + TotalSeconds float64 `json:"total_seconds"` 142 + HumanReadableDailyAverageIncludingOtherLanguage string `json:"human_readable_daily_average_including_other_language"` 143 + IsAlreadyUpdating bool `json:"is_already_updating"` 144 + Editors []wakatimeCategory `json:"editors"` 145 + DaysMinusHolidays int64 `json:"days_minus_holidays"` 146 + IsStuck bool `json:"is_stuck"` 147 + PercentCalculated int64 `json:"percent_calculated"` 148 + DailyAverageIncludingOtherLanguage float64 `json:"daily_average_including_other_language"` 149 + Dependencies []wakatimeCategory `json:"dependencies"` 150 + Categories []wakatimeCategory `json:"categories"` 151 + IsUpToDate bool `json:"is_up_to_date"` 152 + DailyAverage float64 `json:"daily_average"` 153 + Languages []wakatimeCategory `json:"languages"` 154 + HumanReadableTotal string `json:"human_readable_total"` 155 + IsCached bool `json:"is_cached"` 156 + Username string `json:"username"` 157 + IsIncludingToday bool `json:"is_including_today"` 158 + HumanReadableRange string `json:"human_readable_range"` 159 + IsCodingActivityVisible bool `json:"is_coding_activity_visible"` 160 + IsOtherUsageVisible bool `json:"is_other_usage_visible"` 161 + }