mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Merge branch 'master' into stable
This commit is contained in:
commit
0760be86ff
26
README.md
26
README.md
@ -4,9 +4,7 @@
|
||||
[![Say thanks](https://img.shields.io/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg?style=flat-square)](https://saythanks.io/to/n1try)
|
||||
![](https://img.shields.io/github/license/muety/wakapi?style=flat-square)
|
||||
[![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi?style=flat-square)](https://goreportcard.com/report/github.com/muety/wakapi)
|
||||
|
||||
[![Buy me a coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoff.ee/n1try)
|
||||
|
||||
![Coding Activity](https://img.shields.io/endpoint?url=https://apps.muetsch.io/wakapi/api/compat/shields/v1/n1try/interval:any/project:wakapi&style=flat-square&color=blue)
|
||||
---
|
||||
|
||||
**A minimalist, self-hosted WakaTime-compatible backend for coding statistics**
|
||||
@ -82,7 +80,27 @@ INSERT INTO aliases (`type`, `user_id`, `key`, `value`) VALUES (0, 'your_usernam
|
||||
* OS ~ type **3**
|
||||
* Machine ~ type **4**
|
||||
|
||||
**NOTE:** In order for the aliases to take effect for non-live statistics, you would either have to wait 24 hours for the cache to be invalidated or restart Wakapi.
|
||||
## API Endpoints
|
||||
The following API endpoints are available. A more detailed Swagger documentation is about to come ([#40](https://github.com/muety/wakapi/issues/40)).
|
||||
|
||||
* `POST /api/heartbeat`
|
||||
* `GET /api/summary`
|
||||
* `string` parameter `interval`: One of `today`, `day`, `week`, `month`, `year`, `any`
|
||||
* `GET /api/compat/v1/users/current/all_time_since_today` (see [Wakatime API docs](https://wakatime.com/developers#all_time_since_today))
|
||||
* `GET /api/compat/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries))
|
||||
* `GET /api/health`
|
||||
|
||||
## Prometheus Export
|
||||
If you want to export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so please refer to an excellent tool called **[wakatime_exporter](https://github.com/MacroPower/wakatime_exporter)**.
|
||||
|
||||
[![](https://github-readme-stats.vercel.app/api/pin/?username=MacroPower&repo=wakatime_exporter&show_owner=true)](https://github.com/MacroPower/wakatime_exporter)
|
||||
|
||||
It is a standalone webserver that connects to your Wakapi instance and exposes the data as Prometheus metrics. Although originally developed to scrape data from WakaTime, it will mostly for with Wakapi as well, as the APIs are partially compatible.
|
||||
|
||||
Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/wakatime/v1"` and set your API key accordingly.
|
||||
|
||||
## Badges
|
||||
|
||||
|
||||
## Best Practices
|
||||
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
|
||||
|
@ -12,3 +12,4 @@ max_connections = 2
|
||||
|
||||
[languages]
|
||||
vue = Vue
|
||||
jsx = JSX
|
20
main.go
20
main.go
@ -15,6 +15,8 @@ import (
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/routes"
|
||||
shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1"
|
||||
wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
)
|
||||
@ -86,12 +88,17 @@ func main() {
|
||||
go heartbeatService.ScheduleCleanUp()
|
||||
}
|
||||
|
||||
// TODO: move endpoint registration to the respective routes files
|
||||
|
||||
// Handlers
|
||||
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService)
|
||||
summaryHandler := routes.NewSummaryHandler(summaryService)
|
||||
healthHandler := routes.NewHealthHandler(db)
|
||||
settingsHandler := routes.NewSettingsHandler(userService)
|
||||
publicHandler := routes.NewIndexHandler(userService, keyValueService)
|
||||
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
|
||||
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
|
||||
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
|
||||
|
||||
// Setup Routers
|
||||
router := mux.NewRouter()
|
||||
@ -99,6 +106,9 @@ func main() {
|
||||
settingsRouter := publicRouter.PathPrefix("/settings").Subrouter()
|
||||
summaryRouter := publicRouter.PathPrefix("/summary").Subrouter()
|
||||
apiRouter := router.PathPrefix("/api").Subrouter()
|
||||
compatRouter := apiRouter.PathPrefix("/compat").Subrouter()
|
||||
wakatimeV1Router := compatRouter.PathPrefix("/wakatime/v1").Subrouter()
|
||||
shieldsV1Router := compatRouter.PathPrefix("/shields/v1").Subrouter()
|
||||
|
||||
// Middlewares
|
||||
recoveryMiddleware := handlers.RecoveryHandler()
|
||||
@ -106,7 +116,7 @@ func main() {
|
||||
corsMiddleware := handlers.CORS()
|
||||
authenticateMiddleware := middlewares.NewAuthenticateMiddleware(
|
||||
userService,
|
||||
[]string{"/api/health"},
|
||||
[]string{"/api/health", "/api/compat/shields/v1"},
|
||||
).Handler
|
||||
|
||||
// Router configs
|
||||
@ -130,12 +140,20 @@ func main() {
|
||||
settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex)
|
||||
settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials)
|
||||
settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey)
|
||||
settingsRouter.Path("/badges").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostToggleBadges)
|
||||
|
||||
// API Routes
|
||||
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
|
||||
apiRouter.Path("/summary").Methods(http.MethodGet).HandlerFunc(summaryHandler.ApiGet)
|
||||
apiRouter.Path("/health").Methods(http.MethodGet).HandlerFunc(healthHandler.ApiGet)
|
||||
|
||||
// Wakatime compat V1 API Routes
|
||||
wakatimeV1Router.Path("/users/{user}/all_time_since_today").Methods(http.MethodGet).HandlerFunc(wakatimeV1AllHandler.ApiGet)
|
||||
wakatimeV1Router.Path("/users/{user}/summaries").Methods(http.MethodGet).HandlerFunc(wakatimeV1SummariesHandler.ApiGet)
|
||||
|
||||
// Shields.io compat API Routes
|
||||
shieldsV1Router.PathPrefix("/{user}").Methods(http.MethodGet).HandlerFunc(shieldV1BadgeHandler.ApiGet)
|
||||
|
||||
// Static Routes
|
||||
router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static")))
|
||||
|
||||
|
@ -1,7 +1,8 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
insert into key_string_values (`key`, `value`) values ('imprint', 'no content here');
|
||||
insert into key_string_values ("key", "value") values ('imprint', 'no content here');
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
delete from key_string_values where `key` = 'imprint';
|
||||
SET SQL_MODE=ANSI_QUOTES;
|
||||
delete from key_string_values where key = 'imprint';
|
11
migrations/sqlite3/5_badges_column.sql
Normal file
11
migrations/sqlite3/5_badges_column.sql
Normal file
@ -0,0 +1,11 @@
|
||||
-- +migrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
|
||||
alter table users
|
||||
add column `badges_enabled` tinyint(1) default 0 not null;
|
||||
|
||||
-- +migrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
|
||||
alter table users
|
||||
drop column `badges_enabled`;
|
37
models/compat/shields/v1/badge.go
Normal file
37
models/compat/shields/v1/badge.go
Normal file
@ -0,0 +1,37 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://shields.io/endpoint
|
||||
|
||||
const (
|
||||
defaultLabel = "coding time"
|
||||
defaultColor = "#2D3748" // not working
|
||||
)
|
||||
|
||||
type BadgeData struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Label string `json:"label"`
|
||||
Message string `json:"message"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
func NewBadgeDataFrom(summary *models.Summary, filters *models.Filters) *BadgeData {
|
||||
var total time.Duration
|
||||
if hasFilter, filterType, filterKey := filters.First(); hasFilter {
|
||||
total = summary.TotalTimeByKey(filterType, filterKey)
|
||||
} else {
|
||||
total = summary.TotalTime()
|
||||
}
|
||||
|
||||
return &BadgeData{
|
||||
SchemaVersion: 1,
|
||||
Label: defaultLabel,
|
||||
Message: utils.FmtWakatimeDuration(total),
|
||||
Color: defaultColor,
|
||||
}
|
||||
}
|
36
models/compat/wakatime/v1/all_time.go
Normal file
36
models/compat/wakatime/v1/all_time.go
Normal file
@ -0,0 +1,36 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://wakatime.com/developers#all_time_since_today
|
||||
|
||||
type AllTimeViewModel struct {
|
||||
Data *allTimeData `json:"data"`
|
||||
}
|
||||
|
||||
type allTimeData struct {
|
||||
TotalSeconds float32 `json:"total_seconds"` // total number of seconds logged since account created
|
||||
Text string `json:"text"` // total time logged since account created as human readable string>
|
||||
IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon>
|
||||
}
|
||||
|
||||
func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel {
|
||||
var total time.Duration
|
||||
if key := filters.Project; key != "" {
|
||||
total = summary.TotalTimeByKey(models.SummaryProject, key)
|
||||
} else {
|
||||
total = summary.TotalTime()
|
||||
}
|
||||
|
||||
return &AllTimeViewModel{
|
||||
Data: &allTimeData{
|
||||
TotalSeconds: float32(total.Seconds()),
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
IsUpToDate: true,
|
||||
},
|
||||
}
|
||||
}
|
172
models/compat/wakatime/v1/summaries.go
Normal file
172
models/compat/wakatime/v1/summaries.go
Normal file
@ -0,0 +1,172 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://wakatime.com/developers#summaries
|
||||
// https://pastr.de/v/736450
|
||||
|
||||
type SummariesViewModel struct {
|
||||
Data []*summariesData `json:"data"`
|
||||
End time.Time `json:"end"`
|
||||
Start time.Time `json:"start"`
|
||||
}
|
||||
|
||||
type summariesData struct {
|
||||
Categories []*summariesEntry `json:"categories"`
|
||||
Dependencies []*summariesEntry `json:"dependencies"`
|
||||
Editors []*summariesEntry `json:"editors"`
|
||||
Languages []*summariesEntry `json:"languages"`
|
||||
Machines []*summariesEntry `json:"machines"`
|
||||
OperatingSystems []*summariesEntry `json:"operating_systems"`
|
||||
Projects []*summariesEntry `json:"projects"`
|
||||
GrandTotal *summariesGrandTotal `json:"grand_total"`
|
||||
Range *summariesRange `json:"range"`
|
||||
}
|
||||
|
||||
type summariesEntry struct {
|
||||
Digital string `json:"digital"`
|
||||
Hours int `json:"hours"`
|
||||
Minutes int `json:"minutes"`
|
||||
Name string `json:"name"`
|
||||
Percent float64 `json:"percent"`
|
||||
Seconds int `json:"seconds"`
|
||||
Text string `json:"text"`
|
||||
TotalSeconds float64 `json:"total_seconds"`
|
||||
}
|
||||
|
||||
type summariesGrandTotal struct {
|
||||
Digital string `json:"digital"`
|
||||
Hours int `json:"hours"`
|
||||
Minutes int `json:"minutes"`
|
||||
Text string `json:"text"`
|
||||
TotalSeconds float64 `json:"total_seconds"`
|
||||
}
|
||||
|
||||
type summariesRange struct {
|
||||
Date string `json:"date"`
|
||||
End time.Time `json:"end"`
|
||||
Start time.Time `json:"start"`
|
||||
Text string `json:"text"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel {
|
||||
data := make([]*summariesData, len(summaries))
|
||||
minDate, maxDate := time.Now().Add(1*time.Second), time.Time{}
|
||||
|
||||
for i, s := range summaries {
|
||||
data[i] = newDataFrom(s)
|
||||
|
||||
if s.FromTime.Before(minDate) {
|
||||
minDate = s.FromTime
|
||||
}
|
||||
if s.ToTime.After(maxDate) {
|
||||
maxDate = s.ToTime
|
||||
}
|
||||
}
|
||||
|
||||
return &SummariesViewModel{
|
||||
Data: data,
|
||||
End: maxDate,
|
||||
Start: minDate,
|
||||
}
|
||||
}
|
||||
|
||||
func newDataFrom(s *models.Summary) *summariesData {
|
||||
zone, _ := time.Now().Zone()
|
||||
total := s.TotalTime()
|
||||
totalHrs, totalMins := int(total.Hours()), int((total - time.Duration(total.Hours())*time.Hour).Minutes())
|
||||
|
||||
data := &summariesData{
|
||||
Categories: make([]*summariesEntry, 0),
|
||||
Dependencies: make([]*summariesEntry, 0),
|
||||
Editors: make([]*summariesEntry, len(s.Editors)),
|
||||
Languages: make([]*summariesEntry, len(s.Languages)),
|
||||
Machines: make([]*summariesEntry, len(s.Machines)),
|
||||
OperatingSystems: make([]*summariesEntry, len(s.OperatingSystems)),
|
||||
Projects: make([]*summariesEntry, len(s.Projects)),
|
||||
GrandTotal: &summariesGrandTotal{
|
||||
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
|
||||
Hours: totalHrs,
|
||||
Minutes: totalMins,
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
TotalSeconds: total.Seconds(),
|
||||
},
|
||||
Range: &summariesRange{
|
||||
Date: time.Now().Format(time.RFC3339),
|
||||
End: s.ToTime,
|
||||
Start: s.FromTime,
|
||||
Text: "",
|
||||
Timezone: zone,
|
||||
},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(5)
|
||||
|
||||
go func(data *summariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Projects {
|
||||
data.Projects[i] = convertEntry(e, s.TotalTimeBy(models.SummaryProject))
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Editors {
|
||||
data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor))
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Languages {
|
||||
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
|
||||
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.OperatingSystems {
|
||||
data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS))
|
||||
}
|
||||
}(data)
|
||||
|
||||
go func(data *summariesData) {
|
||||
defer wg.Done()
|
||||
for i, e := range s.Machines {
|
||||
data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine))
|
||||
}
|
||||
}(data)
|
||||
|
||||
wg.Wait()
|
||||
return data
|
||||
}
|
||||
|
||||
func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *summariesEntry {
|
||||
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
|
||||
// TODO: fix some day, while migrating persisted summary items
|
||||
total := e.Total * time.Second
|
||||
hrs := int(total.Hours())
|
||||
mins := int((total - time.Duration(hrs)*time.Hour).Minutes())
|
||||
secs := int((total - time.Duration(hrs)*time.Hour - time.Duration(mins)*time.Minute).Seconds())
|
||||
|
||||
return &summariesEntry{
|
||||
Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs),
|
||||
Hours: hrs,
|
||||
Minutes: mins,
|
||||
Name: e.Key,
|
||||
Percent: math.Round((total.Seconds()/entityTotal.Seconds())*1e4) / 100,
|
||||
Seconds: secs,
|
||||
Text: utils.FmtWakatimeDuration(total),
|
||||
TotalSeconds: total.Seconds(),
|
||||
}
|
||||
}
|
@ -24,10 +24,49 @@ type KeyStringValue struct {
|
||||
Value string `gorm:"type:text"`
|
||||
}
|
||||
|
||||
type Filters struct {
|
||||
Project string
|
||||
OS string
|
||||
Language string
|
||||
Editor string
|
||||
Machine string
|
||||
}
|
||||
|
||||
func NewFiltersWith(entity uint8, key string) *Filters {
|
||||
switch entity {
|
||||
case SummaryProject:
|
||||
return &Filters{Project: key}
|
||||
case SummaryOS:
|
||||
return &Filters{Project: key}
|
||||
case SummaryLanguage:
|
||||
return &Filters{Project: key}
|
||||
case SummaryEditor:
|
||||
return &Filters{Project: key}
|
||||
case SummaryMachine:
|
||||
return &Filters{Project: key}
|
||||
}
|
||||
return &Filters{}
|
||||
}
|
||||
|
||||
func (f *Filters) First() (bool, uint8, string) {
|
||||
if f.Project != "" {
|
||||
return true, SummaryProject, f.Project
|
||||
} else if f.OS != "" {
|
||||
return true, SummaryOS, f.OS
|
||||
} else if f.Language != "" {
|
||||
return true, SummaryLanguage, f.Language
|
||||
} else if f.Editor != "" {
|
||||
return true, SummaryEditor, f.Editor
|
||||
} else if f.Machine != "" {
|
||||
return true, SummaryMachine, f.Machine
|
||||
}
|
||||
return false, 0, ""
|
||||
}
|
||||
|
||||
type CustomTime time.Time
|
||||
|
||||
func (j *CustomTime) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Replace(strings.Trim(string(b), "\""), ".", "", 1) // TODO: not always three decimal points!
|
||||
s := strings.Replace(strings.Trim(string(b), "\""), ".", "", 1)
|
||||
i, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -13,6 +13,24 @@ const (
|
||||
SummaryMachine uint8 = 4
|
||||
)
|
||||
|
||||
const (
|
||||
IntervalToday string = "today"
|
||||
IntervalYesterday string = "day"
|
||||
IntervalThisWeek string = "week"
|
||||
IntervalThisMonth string = "month"
|
||||
IntervalThisYear string = "year"
|
||||
IntervalPast7Days string = "7_days"
|
||||
IntervalPast30Days string = "30_days"
|
||||
IntervalPast12Months string = "12_months"
|
||||
IntervalAny string = "any"
|
||||
)
|
||||
|
||||
func Intervals() []string {
|
||||
return []string{
|
||||
IntervalToday, IntervalYesterday, IntervalThisWeek, IntervalThisMonth, IntervalThisYear, IntervalPast7Days, IntervalPast30Days, IntervalPast12Months, IntervalAny,
|
||||
}
|
||||
}
|
||||
|
||||
const UnknownSummaryKey = "unknown"
|
||||
|
||||
type Summary struct {
|
||||
@ -48,6 +66,31 @@ type SummaryViewModel struct {
|
||||
ApiKey string
|
||||
}
|
||||
|
||||
type SummaryParams struct {
|
||||
From time.Time
|
||||
To time.Time
|
||||
User *User
|
||||
Recompute bool
|
||||
}
|
||||
|
||||
func SummaryTypes() []uint8 {
|
||||
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
||||
}
|
||||
|
||||
func (s *Summary) Types() []uint8 {
|
||||
return SummaryTypes()
|
||||
}
|
||||
|
||||
func (s *Summary) MappedItems() map[uint8]*[]*SummaryItem {
|
||||
return map[uint8]*[]*SummaryItem{
|
||||
SummaryProject: &s.Projects,
|
||||
SummaryLanguage: &s.Languages,
|
||||
SummaryEditor: &s.Editors,
|
||||
SummaryOS: &s.OperatingSystems,
|
||||
SummaryMachine: &s.Machines,
|
||||
}
|
||||
}
|
||||
|
||||
/* Augments the summary in a way that at least one item is present for every type.
|
||||
If a summary has zero items for a given type, but one or more for any of the other types,
|
||||
the total summary duration can be derived from those and inserted as a dummy-item with key "unknown"
|
||||
@ -60,22 +103,13 @@ To avoid having to modify persisted data retrospectively, i.e. inserting a dummy
|
||||
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
|
||||
*/
|
||||
func (s *Summary) FillUnknown() {
|
||||
types := []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
||||
types := s.Types()
|
||||
typeItems := s.MappedItems()
|
||||
missingTypes := make([]uint8, 0)
|
||||
typeItems := map[uint8]*[]*SummaryItem{
|
||||
SummaryProject: &s.Projects,
|
||||
SummaryLanguage: &s.Languages,
|
||||
SummaryEditor: &s.Editors,
|
||||
SummaryOS: &s.OperatingSystems,
|
||||
SummaryMachine: &s.Machines,
|
||||
}
|
||||
var somePresentType uint8
|
||||
|
||||
for _, t := range types {
|
||||
if len(*typeItems[t]) == 0 {
|
||||
missingTypes = append(missingTypes, t)
|
||||
} else {
|
||||
somePresentType = t
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,11 +118,7 @@ func (s *Summary) FillUnknown() {
|
||||
return
|
||||
}
|
||||
|
||||
// calculate total duration from any of the present sets of items
|
||||
var timeSum time.Duration
|
||||
for _, item := range *typeItems[somePresentType] {
|
||||
timeSum += item.Total
|
||||
}
|
||||
timeSum := s.TotalTime()
|
||||
|
||||
// construct dummy item for all missing types
|
||||
for _, t := range missingTypes {
|
||||
@ -99,3 +129,49 @@ func (s *Summary) FillUnknown() {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Summary) TotalTime() time.Duration {
|
||||
var timeSum time.Duration
|
||||
|
||||
mappedItems := s.MappedItems()
|
||||
// calculate total duration from any of the present sets of items
|
||||
for _, t := range s.Types() {
|
||||
if items := mappedItems[t]; len(*items) > 0 {
|
||||
for _, item := range *items {
|
||||
timeSum += item.Total
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return timeSum * time.Second
|
||||
}
|
||||
|
||||
func (s *Summary) TotalTimeBy(entityType uint8) time.Duration {
|
||||
var timeSum time.Duration
|
||||
|
||||
mappedItems := s.MappedItems()
|
||||
if items := mappedItems[entityType]; len(*items) > 0 {
|
||||
for _, item := range *items {
|
||||
timeSum += item.Total
|
||||
}
|
||||
}
|
||||
|
||||
return timeSum * time.Second
|
||||
}
|
||||
|
||||
func (s *Summary) TotalTimeByKey(entityType uint8, key string) time.Duration {
|
||||
var timeSum time.Duration
|
||||
|
||||
mappedItems := s.MappedItems()
|
||||
if items := mappedItems[entityType]; len(*items) > 0 {
|
||||
for _, item := range *items {
|
||||
if item.Key != key {
|
||||
continue
|
||||
}
|
||||
timeSum += item.Total
|
||||
}
|
||||
}
|
||||
|
||||
return timeSum * time.Second
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ type User struct {
|
||||
Password string `json:"-"`
|
||||
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
|
||||
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
|
||||
BadgesEnabled bool `json:"-" gorm:"not null; default:false; type: bool"`
|
||||
}
|
||||
|
||||
type Login struct {
|
||||
|
96
routes/compat/shields/v1/badge.go
Normal file
96
routes/compat/shields/v1/badge.go
Normal file
@ -0,0 +1,96 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/shields/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
const (
|
||||
intervalPattern = `interval:([a-z0-9_]+)`
|
||||
entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-]+)`
|
||||
)
|
||||
|
||||
type BadgeHandler struct {
|
||||
userSrvc *services.UserService
|
||||
summarySrvc *services.SummaryService
|
||||
config *models.Config
|
||||
}
|
||||
|
||||
func NewBadgeHandler(summaryService *services.SummaryService, userService *services.UserService) *BadgeHandler {
|
||||
return &BadgeHandler{
|
||||
summarySrvc: summaryService,
|
||||
userSrvc: userService,
|
||||
config: models.GetConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
intervalReg := regexp.MustCompile(intervalPattern)
|
||||
entityFilterReg := regexp.MustCompile(entityFilterPattern)
|
||||
|
||||
requestedUserId := mux.Vars(r)["user"]
|
||||
user, err := h.userSrvc.GetUserById(requestedUserId)
|
||||
if err != nil || !user.BadgesEnabled {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var filterEntity, filterKey string
|
||||
if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 {
|
||||
filterEntity, filterKey = groups[1], groups[2]
|
||||
}
|
||||
|
||||
var interval = models.IntervalPast30Days
|
||||
if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 {
|
||||
interval = groups[1]
|
||||
}
|
||||
|
||||
filters := &models.Filters{}
|
||||
switch filterEntity {
|
||||
case "project":
|
||||
filters.Project = filterKey
|
||||
case "os":
|
||||
filters.OS = filterKey
|
||||
case "editor":
|
||||
filters.Editor = filterKey
|
||||
case "language":
|
||||
filters.Language = filterKey
|
||||
case "machine":
|
||||
filters.Machine = filterKey
|
||||
}
|
||||
|
||||
summary, err, status := h.loadUserSummary(user, interval)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
vm := v1.NewBadgeDataFrom(summary, filters)
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*models.Summary, error, int) {
|
||||
err, from, to := utils.ResolveInterval(interval)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusBadRequest
|
||||
}
|
||||
|
||||
summaryParams := &models.SummaryParams{
|
||||
From: from,
|
||||
To: to,
|
||||
User: user,
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
||||
return summary, nil, http.StatusOK
|
||||
}
|
63
routes/compat/wakatime/v1/all_time.go
Normal file
63
routes/compat/wakatime/v1/all_time.go
Normal file
@ -0,0 +1,63 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AllTimeHandler struct {
|
||||
summarySrvc *services.SummaryService
|
||||
config *models.Config
|
||||
}
|
||||
|
||||
func NewAllTimeHandler(summaryService *services.SummaryService) *AllTimeHandler {
|
||||
return &AllTimeHandler{
|
||||
summarySrvc: summaryService,
|
||||
config: models.GetConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AllTimeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
values, _ := url.ParseQuery(r.URL.RawQuery)
|
||||
|
||||
requestedUser := vars["user"]
|
||||
authorizedUser := r.Context().Value(models.UserKey).(*models.User)
|
||||
|
||||
if requestedUser != authorizedUser.ID && requestedUser != "current" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
summary, err, status := h.loadUserSummary(authorizedUser)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
vm := v1.NewAllTimeFrom(summary, &models.Filters{Project: values.Get("project")})
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) {
|
||||
summaryParams := &models.SummaryParams{
|
||||
From: time.Time{},
|
||||
To: time.Now(),
|
||||
User: user,
|
||||
Recompute: false,
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) // 'to' is always constant
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
||||
return summary, nil, http.StatusOK
|
||||
}
|
96
routes/compat/wakatime/v1/summaries.go
Normal file
96
routes/compat/wakatime/v1/summaries.go
Normal file
@ -0,0 +1,96 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SummariesHandler struct {
|
||||
summarySrvc *services.SummaryService
|
||||
config *models.Config
|
||||
}
|
||||
|
||||
func NewSummariesHandler(summaryService *services.SummaryService) *SummariesHandler {
|
||||
return &SummariesHandler{
|
||||
summarySrvc: summaryService,
|
||||
config: models.GetConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
TODO: support parameters: project, branches, timeout, writes_only, timezone
|
||||
https://wakatime.com/developers#summaries
|
||||
timezone can be specified via an offset suffix (e.g. +02:00) in date strings
|
||||
*/
|
||||
|
||||
func (h *SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
requestedUser := vars["user"]
|
||||
authorizedUser := r.Context().Value(models.UserKey).(*models.User)
|
||||
|
||||
if requestedUser != authorizedUser.ID && requestedUser != "current" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
summaries, err, status := h.loadUserSummaries(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
vm := v1.NewSummariesFrom(summaries, &models.Filters{})
|
||||
utils.RespondJSON(w, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
params := r.URL.Query()
|
||||
|
||||
var start, end time.Time
|
||||
// TODO: find out what other special dates are supported by wakatime (e.g. tomorrow, yesterday, ...?)
|
||||
if startKey, endKey := params.Get("start"), params.Get("end"); startKey == "today" && startKey == endKey {
|
||||
start = utils.StartOfToday()
|
||||
end = time.Now()
|
||||
} else {
|
||||
var err error
|
||||
|
||||
start, err = time.Parse(time.RFC3339, strings.Replace(startKey, " ", "+", 1))
|
||||
if err != nil {
|
||||
return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest
|
||||
}
|
||||
|
||||
end, err = time.Parse(time.RFC3339, strings.Replace(endKey, " ", "+", 1))
|
||||
if err != nil {
|
||||
return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
overallParams := &models.SummaryParams{
|
||||
From: start,
|
||||
To: end,
|
||||
User: user,
|
||||
Recompute: false,
|
||||
}
|
||||
|
||||
intervals := utils.SplitRangeByDays(overallParams.From, overallParams.To)
|
||||
summaries := make([]*models.Summary, len(intervals))
|
||||
|
||||
for i, interval := range intervals {
|
||||
summary, err := h.summarySrvc.Construct(interval[0], interval[1], user, false) // 'to' is always constant
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
summaries[i] = summary
|
||||
}
|
||||
|
||||
return summaries, nil, http.StatusOK
|
||||
}
|
@ -29,10 +29,16 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
data := map[string]interface{}{
|
||||
"User": user,
|
||||
}
|
||||
|
||||
// TODO: when alerts are present, other data will not be passed to the template
|
||||
if handleAlerts(w, r, "settings.tpl.html") {
|
||||
return
|
||||
}
|
||||
templates["settings.tpl.html"].Execute(w, nil)
|
||||
templates["settings.tpl.html"].Execute(w, data)
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
@ -110,3 +116,18 @@ func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request
|
||||
msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey))
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
|
||||
if _, err := h.userSrvc.ToggleBadges(user); err != nil {
|
||||
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings", h.config.BasePath), http.StatusFound)
|
||||
}
|
||||
|
@ -1,22 +1,10 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
IntervalToday string = "today"
|
||||
IntervalLastDay string = "day"
|
||||
IntervalLastWeek string = "week"
|
||||
IntervalLastMonth string = "month"
|
||||
IntervalLastYear string = "year"
|
||||
IntervalAny string = "any"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type SummaryHandler struct {
|
||||
@ -32,7 +20,7 @@ func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler
|
||||
}
|
||||
|
||||
func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
summary, err, status := loadUserSummary(r, h.summarySrvc)
|
||||
summary, err, status := h.loadUserSummary(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
@ -53,7 +41,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
r.URL.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
summary, err, status := loadUserSummary(r, h.summarySrvc)
|
||||
summary, err, status := h.loadUserSummary(r)
|
||||
if err != nil {
|
||||
respondAlert(w, err.Error(), "", "summary.tpl.html", status)
|
||||
return
|
||||
@ -74,39 +62,13 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
templates["summary.tpl.html"].Execute(w, vm)
|
||||
}
|
||||
|
||||
func loadUserSummary(r *http.Request, summaryService *services.SummaryService) (*models.Summary, error, int) {
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
params := r.URL.Query()
|
||||
interval := params.Get("interval")
|
||||
from, err := utils.ParseDate(params.Get("from"))
|
||||
func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, error, int) {
|
||||
summaryParams, err := utils.ParseSummaryParams(r)
|
||||
if err != nil {
|
||||
switch interval {
|
||||
case IntervalToday:
|
||||
from = utils.StartOfDay()
|
||||
case IntervalLastDay:
|
||||
from = utils.StartOfDay().Add(-24 * time.Hour)
|
||||
case IntervalLastWeek:
|
||||
from = utils.StartOfWeek()
|
||||
case IntervalLastMonth:
|
||||
from = utils.StartOfMonth()
|
||||
case IntervalLastYear:
|
||||
from = utils.StartOfYear()
|
||||
case IntervalAny:
|
||||
from = time.Time{}
|
||||
default:
|
||||
return nil, errors.New("missing 'from' parameter"), http.StatusBadRequest
|
||||
}
|
||||
return nil, err, http.StatusBadRequest
|
||||
}
|
||||
|
||||
live := (params.Get("live") != "" && params.Get("live") != "false") || interval == IntervalToday
|
||||
recompute := params.Get("recompute") != "" && params.Get("recompute") != "false"
|
||||
to := utils.StartOfDay()
|
||||
if live {
|
||||
to = time.Now()
|
||||
}
|
||||
|
||||
var summary *models.Summary
|
||||
summary, err = summaryService.Construct(from, to, user, recompute) // 'to' is always constant
|
||||
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) // 'to' is always constant
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
@ -77,7 +77,7 @@ func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) CleanUp() error {
|
||||
refTime := utils.StartOfDay().Add(-cleanUpInterval)
|
||||
refTime := utils.StartOfToday().Add(-cleanUpInterval)
|
||||
if err := srv.DeleteBefore(refTime); err != nil {
|
||||
log.Printf("Failed to clean up heartbeats older than %v – %v\n", refTime, err)
|
||||
return err
|
||||
|
@ -65,7 +65,7 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
||||
heartbeats = append(heartbeats, hb...)
|
||||
}
|
||||
|
||||
types := []uint8{models.SummaryProject, models.SummaryLanguage, models.SummaryEditor, models.SummaryOS, models.SummaryMachine}
|
||||
types := models.SummaryTypes()
|
||||
|
||||
var projectItems []*models.SummaryItem
|
||||
var languageItems []*models.SummaryItem
|
||||
|
@ -87,6 +87,19 @@ func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
|
||||
return srv.Update(user)
|
||||
}
|
||||
|
||||
func (srv *UserService) ToggleBadges(user *models.User) (*models.User, error) {
|
||||
result := srv.Db.Model(user).Update("badges_enabled", !user.BadgesEnabled)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.RowsAffected != 1 {
|
||||
return nil, errors.New("nothing updated")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
|
||||
user.Password = login.Password
|
||||
if err := utils.HashPassword(user, srv.Config.PasswordSalt); err != nil {
|
||||
|
@ -1,5 +1,5 @@
|
||||
const SHOW_TOP_N = 10
|
||||
const CHART_TARGET_SIZE = 170
|
||||
const CHART_TARGET_SIZE = 200
|
||||
|
||||
const projectsCanvas = document.getElementById('chart-projects')
|
||||
const osCanvas = document.getElementById('chart-os')
|
||||
@ -7,6 +7,16 @@ const editorsCanvas = document.getElementById('chart-editor')
|
||||
const languagesCanvas = document.getElementById('chart-language')
|
||||
const machinesCanvas = document.getElementById('chart-machine')
|
||||
|
||||
const projectContainer = document.getElementById('project-container')
|
||||
const osContainer = document.getElementById('os-container')
|
||||
const editorContainer = document.getElementById('editor-container')
|
||||
const languageContainer = document.getElementById('language-container')
|
||||
const machineContainer = document.getElementById('machine-container')
|
||||
|
||||
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer]
|
||||
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas]
|
||||
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines]
|
||||
|
||||
let charts = []
|
||||
let resizeCount = 0
|
||||
|
||||
@ -29,11 +39,6 @@ String.prototype.toHHMMSS = function () {
|
||||
}
|
||||
|
||||
function draw() {
|
||||
let titleOptions = {
|
||||
display: true,
|
||||
fontSize: 16
|
||||
}
|
||||
|
||||
function getTooltipOptions(key, type) {
|
||||
return {
|
||||
mode: 'single',
|
||||
@ -49,7 +54,8 @@ function draw() {
|
||||
|
||||
charts.forEach(c => c.destroy())
|
||||
|
||||
let projectChart = new Chart(projectsCanvas.getContext('2d'), {
|
||||
let projectChart = !projectsCanvas.classList.contains('hidden')
|
||||
? new Chart(projectsCanvas.getContext('2d'), {
|
||||
type: 'horizontalBar',
|
||||
data: {
|
||||
datasets: wakapiData.projects
|
||||
@ -63,7 +69,6 @@ function draw() {
|
||||
})
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Projects (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('projects', 'bar'),
|
||||
legend: {
|
||||
display: false
|
||||
@ -80,8 +85,10 @@ function draw() {
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
let osChart = new Chart(osCanvas.getContext('2d'), {
|
||||
let osChart = !osCanvas.classList.contains('hidden')
|
||||
? new Chart(osCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
@ -95,14 +102,15 @@ function draw() {
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Operating Systems (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('operatingSystems', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
let editorChart = new Chart(editorsCanvas.getContext('2d'), {
|
||||
let editorChart = !editorsCanvas.classList.contains('hidden')
|
||||
? new Chart(editorsCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
@ -116,14 +124,15 @@ function draw() {
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Editors (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('editors', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
let languageChart = new Chart(languagesCanvas.getContext('2d'), {
|
||||
let languageChart = !languagesCanvas.classList.contains('hidden')
|
||||
? new Chart(languagesCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
@ -137,14 +146,15 @@ function draw() {
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Languages (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('languages', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
let machineChart = new Chart(machinesCanvas.getContext('2d'), {
|
||||
let machineChart = !machinesCanvas.classList.contains('hidden')
|
||||
? new Chart(machinesCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
@ -158,22 +168,44 @@ function draw() {
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Machines (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('machines', 'pie'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
getTotal(wakapiData.operatingSystems)
|
||||
document.getElementById('grid-container').style.visibility = 'visible'
|
||||
|
||||
charts = [projectChart, osChart, editorChart, languageChart, machineChart]
|
||||
charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c)
|
||||
|
||||
charts.forEach(c => c.options.onResize(c.chart))
|
||||
equalizeHeights()
|
||||
}
|
||||
|
||||
function setTopLabels() {
|
||||
[...document.getElementsByClassName('top-label')]
|
||||
.forEach(e => e.innerText = `(top ${SHOW_TOP_N})`)
|
||||
}
|
||||
|
||||
function togglePlaceholders(mask) {
|
||||
const placeholderElements = containers.map(c => c.querySelector('.placeholder-container'))
|
||||
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
if (!mask[i]) {
|
||||
canvases[i].classList.add('hidden')
|
||||
placeholderElements[i].classList.remove('hidden')
|
||||
} else {
|
||||
canvases[i].classList.remove('hidden')
|
||||
placeholderElements[i].classList.add('hidden')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPresentDataMask() {
|
||||
return data.map(list => list.reduce((acc, e) => acc + e.total, 0) > 0)
|
||||
}
|
||||
|
||||
function getContainer(chart) {
|
||||
return chart.canvas.parentNode
|
||||
}
|
||||
@ -268,5 +300,7 @@ window.addEventListener('click', function(event) {
|
||||
})
|
||||
|
||||
window.addEventListener('load', function () {
|
||||
setTopLabels()
|
||||
togglePlaceholders(getPresentDataMask())
|
||||
draw()
|
||||
})
|
1
static/assets/images/no_data.svg
Normal file
1
static/assets/images/no_data.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 5.9 KiB |
@ -43,15 +43,14 @@ func MakeConnectionString(config *models.Config) string {
|
||||
}
|
||||
|
||||
func mySqlConnectionString(config *models.Config) string {
|
||||
location, _ := time.LoadLocation("Local")
|
||||
return fmt.Sprintf(
|
||||
"%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s",
|
||||
//location, _ := time.LoadLocation("Local")
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
||||
config.DbUser,
|
||||
config.DbPassword,
|
||||
config.DbHost,
|
||||
config.DbPort,
|
||||
config.DbName,
|
||||
location.String(),
|
||||
"Local",
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,16 @@
|
||||
package utils
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func StartOfDay() time.Time {
|
||||
ref := time.Now()
|
||||
return time.Date(ref.Year(), ref.Month(), ref.Day(), 0, 0, 0, 0, ref.Location())
|
||||
func StartOfToday() time.Time {
|
||||
return StartOfDay(time.Now())
|
||||
}
|
||||
|
||||
func StartOfDay(date time.Time) time.Time {
|
||||
return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||
}
|
||||
|
||||
func StartOfWeek() time.Time {
|
||||
@ -23,6 +29,29 @@ func StartOfYear() time.Time {
|
||||
return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location())
|
||||
}
|
||||
|
||||
func SplitRangeByDays(from time.Time, to time.Time) [][]time.Time {
|
||||
intervals := make([][]time.Time, 0)
|
||||
|
||||
for t1 := from; t1.Before(to); {
|
||||
t2 := StartOfDay(t1).Add(24 * time.Hour)
|
||||
if t2.After(to) {
|
||||
t2 = to
|
||||
}
|
||||
intervals = append(intervals, []time.Time{t1, t2})
|
||||
t1 = t2
|
||||
}
|
||||
|
||||
return intervals
|
||||
}
|
||||
|
||||
func FmtWakatimeDuration(d time.Duration) string {
|
||||
d = d.Round(time.Minute)
|
||||
h := d / time.Hour
|
||||
d -= h * time.Hour
|
||||
m := d / time.Minute
|
||||
return fmt.Sprintf("%d hrs %d mins", h, m)
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/18632496
|
||||
func firstDayOfISOWeek(year int, week int, timezone *time.Location) time.Time {
|
||||
date := time.Date(year, 0, 0, 0, 0, 0, 0, timezone)
|
||||
|
@ -8,3 +8,12 @@ import (
|
||||
func Capitalize(s string) string {
|
||||
return fmt.Sprintf("%s%s", strings.ToUpper(s[:1]), s[1:])
|
||||
}
|
||||
|
||||
func FindString(needle string, haystack []string, defaultVal string) string {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
69
utils/summary.go
Normal file
69
utils/summary.go
Normal file
@ -0,0 +1,69 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/models"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ResolveInterval(interval string) (err error, from, to time.Time) {
|
||||
to = time.Now()
|
||||
|
||||
switch interval {
|
||||
case models.IntervalToday:
|
||||
from = StartOfToday()
|
||||
case models.IntervalYesterday:
|
||||
from = StartOfToday().Add(-24 * time.Hour)
|
||||
to = StartOfToday()
|
||||
case models.IntervalThisWeek:
|
||||
from = StartOfWeek()
|
||||
case models.IntervalThisMonth:
|
||||
from = StartOfMonth()
|
||||
case models.IntervalThisYear:
|
||||
from = StartOfYear()
|
||||
case models.IntervalPast7Days:
|
||||
from = StartOfToday().AddDate(0, 0, -7)
|
||||
case models.IntervalPast30Days:
|
||||
from = StartOfToday().AddDate(0, 0, -30)
|
||||
case models.IntervalPast12Months:
|
||||
from = StartOfToday().AddDate(0, -12, 0)
|
||||
case models.IntervalAny:
|
||||
from = time.Time{}
|
||||
default:
|
||||
err = errors.New("invalid interval")
|
||||
}
|
||||
|
||||
return err, from, to
|
||||
}
|
||||
|
||||
func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
params := r.URL.Query()
|
||||
|
||||
var err error
|
||||
var from, to time.Time
|
||||
|
||||
if interval := params.Get("interval"); interval != "" {
|
||||
err, from, to = ResolveInterval(interval)
|
||||
} else {
|
||||
from, err = ParseDate(params.Get("from"))
|
||||
if err != nil {
|
||||
return nil, errors.New("missing 'from' parameter")
|
||||
}
|
||||
|
||||
to, err = ParseDate(params.Get("to"))
|
||||
if err != nil {
|
||||
return nil, errors.New("missing 'to' parameter")
|
||||
}
|
||||
}
|
||||
|
||||
recompute := params.Get("recompute") != "" && params.Get("recompute") != "false"
|
||||
|
||||
return &models.SummaryParams{
|
||||
From: from,
|
||||
To: to,
|
||||
User: user,
|
||||
Recompute: recompute,
|
||||
}, nil
|
||||
}
|
@ -1 +1 @@
|
||||
1.8.3
|
||||
1.11.1
|
@ -5,7 +5,7 @@
|
||||
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
||||
<div class="w-full flex justify-center">
|
||||
<div class="flex items-center justify-between max-w-4xl flex-grow">
|
||||
<div><a href="" class="text-gray-500 text-sm">&larr Go back</a></div>
|
||||
<div><a href="" class="text-gray-500 text-sm">← Go back</a></div>
|
||||
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Imprint & Data Privacy</h1>
|
||||
</div>
|
||||
<div></div>
|
||||
|
@ -64,9 +64,72 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4 mb-8 pb-8">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
Badges
|
||||
</div>
|
||||
|
||||
<form class="mt-6" action="settings/badges" method="post">
|
||||
<div class="text-gray-300 text-sm mb-4">
|
||||
{{ if .User.BadgesEnabled }}
|
||||
<p>Badges are currently enabled. You can disable the feature by deactivating the respective API endpoint.</p>
|
||||
|
||||
<div class="flex justify-around mt-4">
|
||||
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span>
|
||||
<button type="submit" class="py-1 px-2 rounded bg-orange-700 hover:bg-orange-800 text-white text-xs" title="Disable support for badges to secure endpoint">
|
||||
Status: public
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 class="font-semibold mb-2 mt-8">Examples</h3>
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="flex justify-between my-2">
|
||||
<div>
|
||||
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today"/>
|
||||
</div>
|
||||
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" style="max-width: 300px;">
|
||||
https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between my-2">
|
||||
<div>
|
||||
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d"/>
|
||||
</div>
|
||||
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" style="max-width: 300px;">
|
||||
https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>You can also add <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">/project:your-cool-project</span> to the URL to filter by project.</p>
|
||||
{{ else }}
|
||||
<p>You have the ability to create badges from your coding statistics using <a href="https://shields.io" target="_blank" class="border-b border-green-800">Shields.io</a>. To do so, you need to grant public, unauthorized access to the respective endpoint.</p>
|
||||
<div class="flex justify-around mt-4">
|
||||
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span>
|
||||
<button type="submit" class="py-1 px-2 rounded bg-green-700 hover:bg-green-800 text-white text-xs" title="Make endpoint public to enable badges">
|
||||
Status: protected
|
||||
</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="text/javascript">
|
||||
const baseUrl = location.href.substring(0, location.href.indexOf('/settings'))
|
||||
document.querySelectorAll('.with-url-src').forEach(e => {
|
||||
e.setAttribute('src', e.getAttribute('src').replace('%s', baseUrl))
|
||||
e.classList.remove('hidden')
|
||||
})
|
||||
document.querySelectorAll('.with-url-inner').forEach(e => {
|
||||
e.innerHTML = e.innerHTML.replace('%s', baseUrl)
|
||||
e.classList.remove('hidden')
|
||||
})
|
||||
</script>
|
||||
|
||||
{{ template "footer.tpl.html" . }}
|
||||
|
||||
{{ template "foot.tpl.html" . }}
|
||||
|
@ -5,7 +5,7 @@
|
||||
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
||||
<div class="w-full flex justify-center">
|
||||
<div class="flex items-center justify-between max-w-4xl flex-grow">
|
||||
<div><a href="" class="text-gray-500 text-sm">&larr Go back</a></div>
|
||||
<div><a href="" class="text-gray-500 text-sm">← Go back</a></div>
|
||||
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Sign Up</h1></div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
@ -36,13 +36,16 @@
|
||||
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Your Coding Statistics 🤓</h1>
|
||||
</div>
|
||||
|
||||
<div class="text-white text-sm flex items-center justify-center mt-4">
|
||||
<a href="summary?interval=today" class="m-1 border-b border-green-700">Today (live)</a>
|
||||
<a href="summary?interval=day" class="m-1 border-b border-green-700">Yesterday</a>
|
||||
<a href="summary?interval=week" class="m-1 border-b border-green-700">This Week</a>
|
||||
<a href="summary?interval=month" class="m-1 border-b border-green-700">This Month</a>
|
||||
<a href="summary?interval=year" class="m-1 border-b border-green-700">This Year</a>
|
||||
<a href="summary?interval=any" class="m-1 border-b border-green-700">All Time</a>
|
||||
<div class="text-white text-sm flex items-center justify-center mt-4 self-center max-w-lg flex-wrap">
|
||||
<a href="summary?interval=today" class="mx-2 my-1 border-b border-green-700">Today</a>
|
||||
<a href="summary?interval=day" class="mx-2 my-1 border-b border-green-700">Yesterday</a>
|
||||
<a href="summary?interval=week" class="mx-2 my-1 border-b border-green-700">This Week</a>
|
||||
<a href="summary?interval=month" class="mx-2 my-1 border-b border-green-700">This Month</a>
|
||||
<a href="summary?interval=year" class="mx-2 my-1 border-b border-green-700">This Year</a>
|
||||
<a href="summary?interval=7_days" class="mx-2 my-1 border-b border-green-700">Past 7 Days</a>
|
||||
<a href="summary?interval=30_days" class="mx-2 my-1 border-b border-green-700">Past 30 Days</a>
|
||||
<a href="summary?interval=12_months" class="mx-2 my-1 border-b border-green-700">Past 12 Months</a>
|
||||
<a href="summary?interval=any" class="mx-2 my-1 border-b border-green-700">All Time</a>
|
||||
</div>
|
||||
|
||||
{{ template "alerts.tpl.html" . }}
|
||||
@ -59,28 +62,68 @@
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center">
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 bg-white rounded shadow m-2" id="projects-container" style="height: 300px">
|
||||
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="project-container" style="height: 300px">
|
||||
<div class="self-center flex">
|
||||
<span class="font-semibold mr-1">Projects</span>
|
||||
<span id="project-top-label" class="top-label"></span>
|
||||
</div>
|
||||
<canvas id="chart-projects"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
<img src="assets/images/no_data.svg" class="w-20"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 bg-white rounded shadow m-2" id="os-container" style="height: 300px">
|
||||
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="os-container" style="height: 300px">
|
||||
<div class="self-center flex">
|
||||
<span class="font-semibold mr-1">Operating Systems</span>
|
||||
<span id="os-top-label" class="top-label"></span>
|
||||
</div>
|
||||
<canvas id="chart-os"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
<img src="assets/images/no_data.svg" class="w-20"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 bg-white rounded shadow m-2 relative" id="language-container" style="height: 300px">
|
||||
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col relative" id="language-container" style="height: 300px">
|
||||
<div class="self-center flex">
|
||||
<span class="font-semibold mr-1">Languages</span>
|
||||
<span id="language-top-label" class="top-label"></span>
|
||||
</div>
|
||||
<canvas id="chart-language"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
<img src="assets/images/no_data.svg" class="w-20"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 bg-white rounded shadow m-2" id="editor-container" style="height: 300px">
|
||||
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="editor-container" style="height: 300px">
|
||||
<div class="self-center flex">
|
||||
<span class="font-semibold mr-1">Editors</span>
|
||||
<span id="editor-top-label" class="top-label"></span>
|
||||
</div>
|
||||
<canvas id="chart-editor"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
<img src="assets/images/no_data.svg" class="w-20"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 bg-white rounded shadow m-2" id="machine-container" style="height: 300px">
|
||||
<div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="machine-container" style="height: 300px">
|
||||
<div class="self-center flex">
|
||||
<span class="font-semibold mr-1">Machines</span>
|
||||
<span id="machine-top-label" class="top-label"></span>
|
||||
</div>
|
||||
<canvas id="chart-machine"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
<img src="assets/images/no_data.svg" class="w-20"/>
|
||||
<span class="text-sm mt-4">No data available ...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
Loading…
Reference in New Issue
Block a user