mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
chore: show total hours on index page (resolve #88)
This commit is contained in:
parent
3a4504d56a
commit
cd97976ed5
@ -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
|
||||
|
@ -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
3
go.mod
@ -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
3
go.sum
@ -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=
|
||||
|
7
main.go
7
main.go
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
},
|
||||
|
@ -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
119
services/misc.go
Normal 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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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">🔑 Login️</a>
|
||||
<a href="login" class="py-1 px-3 h-8 block rounded border border-green-700 text-white text-sm">🔑
|
||||
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>✅ Fancy statistics and plots</li>
|
||||
<li>✅ Cool badges for readmes</li>
|
||||
<li>✅ Intuitive REST API</li>
|
||||
<li>✅ Compatible with <a href="https://wakatime.com" target="_blank" rel="noopener noreferrer" class="underline">Wakatime</a></li>
|
||||
<li>✅ <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>✅ Compatible with <a href="https://wakatime.com" target="_blank"
|
||||
rel="noopener noreferrer" class="underline">Wakatime</a></li>
|
||||
<li>✅ <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>✅ 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>
|
||||
|
Loading…
Reference in New Issue
Block a user