chore: show total hours on index page (resolve #88)

This commit is contained in:
Ferdinand Mütsch 2021-01-17 09:24:09 +01:00
parent 3a4504d56a
commit cd97976ed5
14 changed files with 219 additions and 22 deletions

View File

@ -10,6 +10,7 @@ server:
app:
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
counting_time: '05:15' # time at which to run daily job to count total hours tracked in the system
custom_languages:
vue: Vue
jsx: JSX

View File

@ -27,6 +27,9 @@ const (
SQLDialectMysql = "mysql"
SQLDialectPostgres = "postgres"
SQLDialectSqlite = "sqlite3"
KeyLatestTotalTime = "latest_total_time"
KeyLatestTotalUsers = "latest_total_users"
)
var cfg *Config
@ -34,6 +37,7 @@ var cFlag = flag.String("config", defaultConfigPath, "config file location")
type appConfig struct {
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
CountingTime string `yaml:"counting_time" default:"05:15" env:"WAKAPI_COUNTING_TIME"`
CustomLanguages map[string]string `yaml:"custom_languages"`
LanguageColors map[string]string `yaml:"-"`
}

3
go.mod
View File

@ -17,6 +17,7 @@ require (
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc
github.com/satori/go.uuid v1.2.0
github.com/stretchr/testify v1.6.1
go.uber.org/atomic v1.6.0
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/ini.v1 v1.50.0
@ -24,5 +25,5 @@ require (
gorm.io/driver/mysql v1.0.3
gorm.io/driver/postgres v1.0.5
gorm.io/driver/sqlite v1.1.3
gorm.io/gorm v1.20.5
gorm.io/gorm v1.20.11
)

3
go.sum
View File

@ -408,6 +408,7 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
@ -570,6 +571,8 @@ gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.5 h1:g3tpSF9kggASzReK+Z3dYei1IJODLqNUbOjSuCczY8g=
gorm.io/gorm v1.20.5/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.11 h1:jYHQ0LLUViV85V8dM1TP9VBBkfzKTnuTXDjYObkI6yc=
gorm.io/gorm v1.20.11/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@ -45,6 +45,7 @@ var (
summaryService services.ISummaryService
aggregationService services.IAggregationService
keyValueService services.IKeyValueService
miscService services.IMiscService
)
// TODO: Refactor entire project to be structured after business domains
@ -97,9 +98,11 @@ func main() {
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
keyValueService = services.NewKeyValueService(keyValueRepository)
miscService = services.NewMiscService(userService, summaryService, keyValueService)
// Aggregate heartbeats to summaries and persist them
// Schedule background tasks
go aggregationService.Schedule()
go miscService.ScheduleCountTotalTime()
// TODO: move endpoint registration to the respective routes files
@ -110,7 +113,7 @@ func main() {
healthHandler := routes.NewHealthHandler(db)
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService, languageMappingService)
settingsHandler := routes.NewSettingsHandler(userService, summaryService, aggregationService, languageMappingService)
homeHandler := routes.NewHomeHandler()
homeHandler := routes.NewHomeHandler(keyValueService)
loginHandler := routes.NewLoginHandler(userService)
imprintHandler := routes.NewImprintHandler(keyValueService)
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)

View File

@ -1,8 +1,10 @@
package view
type HomeViewModel struct {
Success string
Error string
Success string
Error string
TotalHours int
TotalUsers int
}
func (s *HomeViewModel) WithSuccess(m string) *HomeViewModel {

View File

@ -4,6 +4,7 @@ import (
"errors"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type KeyValueRepository struct {
@ -27,9 +28,12 @@ func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, erro
func (r *KeyValueRepository) PutString(kv *models.KeyStringValue) error {
result := r.db.
Clauses(clause.OnConflict{
UpdateAll: true,
}).
Where(&models.KeyStringValue{Key: kv.Key}).
Assign(kv).
FirstOrCreate(kv)
Create(kv)
if err := result.Error; err != nil {
return err

View File

@ -6,19 +6,24 @@ import (
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/models/view"
"github.com/muety/wakapi/services"
"net/http"
"strconv"
"time"
)
type HomeHandler struct {
config *conf.Config
config *conf.Config
keyValueSrvc services.IKeyValueService
}
var loginDecoder = schema.NewDecoder()
var signupDecoder = schema.NewDecoder()
func NewHomeHandler() *HomeHandler {
func NewHomeHandler(keyValueService services.IKeyValueService) *HomeHandler {
return &HomeHandler{
config: conf.Get(),
config: conf.Get(),
keyValueSrvc: keyValueService,
}
}
@ -36,8 +41,25 @@ func (h *HomeHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
}
func (h *HomeHandler) buildViewModel(r *http.Request) *view.HomeViewModel {
var totalHours int
var totalUsers int
if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalTime); err == nil && t != nil && t.Value != "" {
if d, err := time.ParseDuration(t.Value); err == nil {
totalHours = int(d.Hours())
}
}
if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalUsers); err == nil && t != nil && t.Value != "" {
if d, err := strconv.Atoi(t.Value); err == nil {
totalUsers = d
}
}
return &view.HomeViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
TotalHours: totalHours,
TotalUsers: totalUsers,
}
}

View File

@ -23,6 +23,7 @@ func loadTemplates() {
"date": utils.FormatDateHuman,
"title": strings.Title,
"capitalize": utils.Capitalize,
"toRunes": utils.ToRunes,
"getBasePath": func() string {
return config.Get().Server.BasePath
},

View File

@ -40,7 +40,7 @@ type AggregationJob struct {
func (srv *AggregationService) Schedule() {
// Run once initially
if err := srv.Run(nil); err != nil {
log.Fatalf("failed to run aggregation jobs: %v\n", err)
log.Fatalf("failed to run AggregationJob: %v\n", err)
}
s := gocron.NewScheduler(time.Local)

119
services/misc.go Normal file
View File

@ -0,0 +1,119 @@
package services
import (
"github.com/muety/wakapi/config"
"go.uber.org/atomic"
"log"
"runtime"
"strconv"
"time"
"github.com/go-co-op/gocron"
"github.com/muety/wakapi/models"
)
type MiscService struct {
config *config.Config
userService IUserService
summaryService ISummaryService
keyValueService IKeyValueService
jobCount atomic.Uint32
}
func NewMiscService(userService IUserService, summaryService ISummaryService, keyValueService IKeyValueService) *MiscService {
return &MiscService{
config: config.Get(),
userService: userService,
summaryService: summaryService,
keyValueService: keyValueService,
}
}
type CountTotalTimeJob struct {
UserID string
NumJobs int
}
type CountTotalTimeResult struct {
UserId string
Total time.Duration
}
func (srv *MiscService) ScheduleCountTotalTime() {
// Run once initially
if err := srv.runCountTotalTime(); err != nil {
log.Fatalf("failed to run CountTotalTimeJob: %v\n", err)
}
s := gocron.NewScheduler(time.Local)
s.Every(1).Day().At(srv.config.App.CountingTime).Do(srv.runCountTotalTime)
s.StartBlocking()
}
func (srv *MiscService) runCountTotalTime() error {
jobs := make(chan *CountTotalTimeJob)
results := make(chan *CountTotalTimeResult)
defer close(jobs)
for i := 0; i < runtime.NumCPU(); i++ {
go srv.countTotalTimeWorker(jobs, results)
}
go srv.persistTotalTimeWorker(results)
// generate the jobs
if users, err := srv.userService.GetAll(); err == nil {
for _, u := range users {
jobs <- &CountTotalTimeJob{
UserID: u.ID,
NumJobs: len(users),
}
}
} else {
return err
}
return nil
}
func (srv *MiscService) countTotalTimeWorker(jobs <-chan *CountTotalTimeJob, results chan<- *CountTotalTimeResult) {
for job := range jobs {
if result, err := srv.summaryService.Aliased(time.Time{}, time.Now(), &models.User{ID: job.UserID}, srv.summaryService.Retrieve); err != nil {
log.Printf("Failed to count total for user %s: %v.\n", job.UserID, err)
} else {
log.Printf("Successfully counted total for user %s.\n", job.UserID)
results <- &CountTotalTimeResult{
UserId: job.UserID,
Total: result.TotalTime(),
}
}
if srv.jobCount.Inc() == uint32(job.NumJobs) {
srv.jobCount.Store(0)
close(results)
}
}
}
func (srv *MiscService) persistTotalTimeWorker(results <-chan *CountTotalTimeResult) {
var c int
var total time.Duration
for result := range results {
total += result.Total
c++
}
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: config.KeyLatestTotalTime,
Value: total.String(),
}); err != nil {
log.Printf("Failed to save total time count: %v\n", err)
}
if err := srv.keyValueService.PutString(&models.KeyStringValue{
Key: config.KeyLatestTotalUsers,
Value: strconv.Itoa(c),
}); err != nil {
log.Printf("Failed to save total users count: %v\n", err)
}
}

View File

@ -10,6 +10,10 @@ type IAggregationService interface {
Run(map[string]bool) error
}
type IMiscService interface {
ScheduleCountTotalTime()
}
type IAliasService interface {
LoadUserAliases(string) error
GetAliasOrDefault(string, uint8, string) (string, error)

View File

@ -12,3 +12,10 @@ func Json(data interface{}) template.JS {
}
return template.JS(d)
}
func ToRunes(s string) (r []string) {
for _, c := range []rune(s) {
r = append(r, string(c))
}
return r
}

View File

@ -11,21 +11,40 @@
<div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
<div class="mx-1">
<a href="login" class="py-1 px-3 h-8 block rounded border border-green-700 text-white text-sm">🔑 &nbsp;Login</a>
<a href="login" class="py-1 px-3 h-8 block rounded border border-green-700 text-white text-sm">🔑
&nbsp;Login</a>
</div>
</div>
<main class="mt-10 flex-grow flex justify-center w-full">
<div class="flex flex-col text-white">
<h1 class="text-4xl font-semibold antialiased text-center mb-2">Keep Track of <span class="text-green-700">Your</span> Coding Time 🕓</h1>
<p class="text-center text-gray-500 text-xl my-2">Wakapi is an open-source tool that helps you keep track of the time you have spent coding on different projects in different programming languages and more. Ideal for statistics freaks any anyone else.</p>
<h1 class="text-4xl font-semibold antialiased text-center mb-2">Keep Track of <span
class="text-green-700">Your</span> Coding Time 🕓</h1>
<p class="text-center text-gray-500 text-xl my-2">Wakapi is an open-source tool that helps you keep track of the
time you have spent coding on different projects in different programming languages and more. Ideal for
statistics freaks any anyone else.</p>
<p class="text-center text-gray-500 text-xl my-4">
<span class="mr-1">💡 The system has tracked a total of </span>
{{ range $d := .TotalHours | printf "%d" | toRunes }}
<span class="bg-gray-900 rounded-sm p-1 border border-gray-700 font-mono" style="margin: auto -2px;" title="{{ $.TotalHours }} hours (updated once a day)">{{ $d }}</span>
{{ end }}
<span class="mx-1">hours of coding time of</span>
{{ range $d := .TotalUsers | printf "%d" | toRunes }}
<span class="bg-gray-900 rounded-sm p-1 border border-gray-700 font-mono" style="margin: auto -2px;" title="{{ $.TotalUsers }} users (updated once a day)">{{ $d }}</span>
{{ end }}
<span class="ml-1">users.</span>
</p>
<div class="flex justify-center mt-4 mb-8 space-x-2">
<a href="login">
<button type="button" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white font-semibold">🚀 Try it!</button>
<button type="button"
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white font-semibold">🚀 Try it!
</button>
</a>
<a href="https://github.com/muety/wakapi#%EF%B8%8F-server-setup" target="_blank" rel="noopener noreferrer">
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white">📡 Host it</button>
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white">📡 Host it
</button>
</a>
<a href="https://github.com/muety/wakapi" target="_blank" rel="noopener noreferrer">
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white">
@ -47,17 +66,24 @@
<li>&nbsp; Fancy statistics and plots</li>
<li>&nbsp; Cool badges for readmes</li>
<li>&nbsp; Intuitive REST API</li>
<li>&nbsp; Compatible with <a href="https://wakatime.com" target="_blank" rel="noopener noreferrer" class="underline">Wakatime</a></li>
<li>&nbsp; <a href="https://prometheus.io" target="_blank" rel="noopener noreferrer" class="underline">Prometheus</a> metrics via <a href="https://github.com/MacroPower/wakatime_exporter" target="_blank" rel="noopener noreferrer" class="underline">exporter</a></li>
<li>&nbsp; Compatible with <a href="https://wakatime.com" target="_blank"
rel="noopener noreferrer" class="underline">Wakatime</a></li>
<li>&nbsp; <a href="https://prometheus.io" target="_blank" rel="noopener noreferrer"
class="underline">Prometheus</a> metrics via <a
href="https://github.com/MacroPower/wakatime_exporter" target="_blank"
rel="noopener noreferrer" class="underline">exporter</a></li>
<li>&nbsp; Self-hosted</li>
</ul>
</div>
</div>
<div class="flex justify-center space-x-2 mt-12">
<img alt="License badge" src="https://badges.fw-web.space/github/license/muety/wakapi?color=%232F855A&style=flat-square">
<img alt="Go version badge" src="https://badges.fw-web.space/github/go-mod/go-version/muety/wakapi?color=%232F855A&style=flat-square">
<img alt="Wakapi coding time badge" src="https://badges.fw-web.space/endpoint?color=%232F855A&style=flat-square&label=wakapi&url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi">
<img alt="License badge"
src="https://badges.fw-web.space/github/license/muety/wakapi?color=%232F855A&style=flat-square">
<img alt="Go version badge"
src="https://badges.fw-web.space/github/go-mod/go-version/muety/wakapi?color=%232F855A&style=flat-square">
<img alt="Wakapi coding time badge"
src="https://badges.fw-web.space/endpoint?color=%232F855A&style=flat-square&label=wakapi&url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi">
</div>
</div>
</main>