mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Refactor to server-rendered app.
This commit is contained in:
parent
d583dd312b
commit
b7f700e7a5
21
main.go
21
main.go
@ -108,6 +108,11 @@ func main() {
|
||||
summarySrvc := &services.SummaryService{Config: config, Db: db, HeartbeatService: heartbeatSrvc, AliasService: aliasSrvc}
|
||||
aggregationSrvc := &services.AggregationService{Config: config, Db: db, UserService: userSrvc, SummaryService: summarySrvc, HeartbeatService: heartbeatSrvc}
|
||||
|
||||
services := []services.Initializable{aliasSrvc, heartbeatSrvc, summarySrvc, userSrvc, aggregationSrvc}
|
||||
for _, s := range services {
|
||||
s.Init()
|
||||
}
|
||||
|
||||
// Aggregate heartbeats to summaries and persist them
|
||||
go aggregationSrvc.Schedule()
|
||||
|
||||
@ -117,6 +122,7 @@ func main() {
|
||||
|
||||
// Middlewares
|
||||
authenticateMiddleware := &middlewares.AuthenticateMiddleware{UserSrvc: userSrvc}
|
||||
basicAuthMiddleware := &middlewares.RequireBasicAuthMiddleware{}
|
||||
corsMiddleware := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
@ -125,23 +131,36 @@ func main() {
|
||||
|
||||
// Setup Routing
|
||||
router := mux.NewRouter()
|
||||
mainRouter := mux.NewRouter().PathPrefix("").Subrouter()
|
||||
apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter()
|
||||
|
||||
// Main Routes
|
||||
index := mainRouter.Path("/").Subrouter()
|
||||
index.Methods(http.MethodGet).Path("/").HandlerFunc(summaryHandler.Index)
|
||||
|
||||
// API Routes
|
||||
heartbeats := apiRouter.Path("/heartbeat").Subrouter()
|
||||
heartbeats.Methods(http.MethodPost).HandlerFunc(heartbeatHandler.Post)
|
||||
|
||||
// Static Routes
|
||||
router.PathPrefix("/assets").Handler(negroni.Classic().With(negroni.Wrap(http.FileServer(http.Dir("./static")))))
|
||||
|
||||
aggreagations := apiRouter.Path("/summary").Subrouter()
|
||||
aggreagations.Methods(http.MethodGet).HandlerFunc(summaryHandler.Get)
|
||||
|
||||
// Sub-Routes Setup
|
||||
router.PathPrefix("/").Handler(negroni.Classic().
|
||||
With(negroni.HandlerFunc(basicAuthMiddleware.Handle),
|
||||
negroni.HandlerFunc(authenticateMiddleware.Handle),
|
||||
negroni.Wrap(mainRouter),
|
||||
))
|
||||
|
||||
router.PathPrefix("/api").Handler(negroni.Classic().
|
||||
With(corsMiddleware).
|
||||
With(
|
||||
negroni.HandlerFunc(authenticateMiddleware.Handle),
|
||||
negroni.Wrap(apiRouter),
|
||||
))
|
||||
router.PathPrefix("/").Handler(negroni.Classic().With(negroni.Wrap(http.FileServer(http.Dir("./static")))))
|
||||
|
||||
// Listen HTTP
|
||||
portString := config.Addr + ":" + strconv.Itoa(config.Port)
|
||||
|
14
middlewares/basicauth.go
Normal file
14
middlewares/basicauth.go
Normal file
@ -0,0 +1,14 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type RequireBasicAuthMiddleware struct{}
|
||||
|
||||
func (m *RequireBasicAuthMiddleware) Init() {}
|
||||
|
||||
func (m *RequireBasicAuthMiddleware) Handle(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
next(w, r)
|
||||
}
|
@ -1,16 +1,15 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/n1try/wakapi/models"
|
||||
"github.com/n1try/wakapi/services"
|
||||
"github.com/n1try/wakapi/utils"
|
||||
cache "github.com/patrickmn/go-cache"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -23,15 +22,23 @@ const (
|
||||
)
|
||||
|
||||
type SummaryHandler struct {
|
||||
SummarySrvc *services.SummaryService
|
||||
Cache *cache.Cache
|
||||
Initialized bool
|
||||
SummarySrvc *services.SummaryService
|
||||
Initialized bool
|
||||
indexTemplate *template.Template
|
||||
}
|
||||
|
||||
func (m *SummaryHandler) Init() {
|
||||
if m.Cache == nil {
|
||||
m.Cache = cache.New(24*time.Hour, 24*time.Hour)
|
||||
indexTplPath := "views/index.tpl.html"
|
||||
indexTpl, err := template.New(path.Base(indexTplPath)).Funcs(template.FuncMap{
|
||||
"json": utils.Json,
|
||||
}).ParseFiles(indexTplPath)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
m.indexTemplate = indexTpl
|
||||
|
||||
m.Initialized = true
|
||||
}
|
||||
|
||||
@ -45,6 +52,37 @@ func (h *SummaryHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
h.Init()
|
||||
}
|
||||
|
||||
summary, err, status := loadUserSummary(r, h.SummarySrvc)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondJSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
func (h *SummaryHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.Initialized {
|
||||
h.Init()
|
||||
}
|
||||
|
||||
summary, err, status := loadUserSummary(r, h.SummarySrvc)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
h.indexTemplate.Execute(w, summary)
|
||||
}
|
||||
|
||||
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")
|
||||
@ -64,9 +102,7 @@ func (h *SummaryHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
case IntervalAny:
|
||||
from = time.Time{}
|
||||
default:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("missing 'from' parameter"))
|
||||
return
|
||||
return nil, errors.New("missing 'from' parameter"), http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,34 +114,10 @@ func (h *SummaryHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var summary *models.Summary
|
||||
var cacheKey string
|
||||
if !recompute {
|
||||
cacheKey = getHash([]time.Time{from, to}, user)
|
||||
} else {
|
||||
cacheKey = uuid.NewV4().String()
|
||||
}
|
||||
if cachedSummary, ok := h.Cache.Get(cacheKey); !ok {
|
||||
// Cache Miss
|
||||
summary, err = h.SummarySrvc.Construct(from, to, user, recompute) // 'to' is always constant
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !live && !recompute {
|
||||
h.Cache.Set(cacheKey, summary, cache.DefaultExpiration)
|
||||
}
|
||||
} else {
|
||||
summary = cachedSummary.(*models.Summary)
|
||||
summary, err = summaryService.Construct(from, to, user, recompute) // 'to' is always constant
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
||||
utils.RespondJSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
func getHash(times []time.Time, user *models.User) string {
|
||||
digest := md5.New()
|
||||
for _, t := range times {
|
||||
digest.Write([]byte(strconv.Itoa(int(t.Unix()))))
|
||||
}
|
||||
digest.Write([]byte(user.ID))
|
||||
return string(digest.Sum(nil))
|
||||
return summary, nil, http.StatusOK
|
||||
}
|
||||
|
@ -28,6 +28,8 @@ type AggregationJob struct {
|
||||
To time.Time
|
||||
}
|
||||
|
||||
func (srv *AggregationService) Init() {}
|
||||
|
||||
// Schedule a job to (re-)generate summaries every day shortly after midnight
|
||||
// TODO: Make configurable
|
||||
func (srv *AggregationService) Schedule() {
|
||||
|
@ -15,6 +15,8 @@ type AliasService struct {
|
||||
|
||||
var userAliases sync.Map
|
||||
|
||||
func (srv *AliasService) Init() {}
|
||||
|
||||
func (srv *AliasService) LoadUserAliases(userId string) error {
|
||||
var aliases []*models.Alias
|
||||
if err := srv.Db.
|
||||
|
5
services/common.go
Normal file
5
services/common.go
Normal file
@ -0,0 +1,5 @@
|
||||
package services
|
||||
|
||||
type Initializable interface {
|
||||
Init()
|
||||
}
|
@ -15,6 +15,8 @@ type HeartbeatService struct {
|
||||
Db *gorm.DB
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) Init() {}
|
||||
|
||||
func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
|
||||
var batch []interface{}
|
||||
for _, h := range heartbeats {
|
||||
|
@ -1,9 +1,12 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
@ -12,6 +15,7 @@ import (
|
||||
|
||||
type SummaryService struct {
|
||||
Config *models.Config
|
||||
Cache *cache.Cache
|
||||
Db *gorm.DB
|
||||
HeartbeatService *HeartbeatService
|
||||
AliasService *AliasService
|
||||
@ -22,11 +26,21 @@ type Interval struct {
|
||||
End time.Time
|
||||
}
|
||||
|
||||
func (srv *SummaryService) Init() {
|
||||
srv.Cache = cache.New(24*time.Hour, 24*time.Hour)
|
||||
}
|
||||
|
||||
func (srv *SummaryService) Construct(from, to time.Time, user *models.User, recompute bool) (*models.Summary, error) {
|
||||
var existingSummaries []*models.Summary
|
||||
var cacheKey string
|
||||
|
||||
if recompute {
|
||||
existingSummaries = make([]*models.Summary, 0)
|
||||
} else {
|
||||
cacheKey = getHash([]time.Time{from, to}, user)
|
||||
if result, ok := srv.Cache.Get(cacheKey); ok {
|
||||
return result.(*models.Summary), nil
|
||||
}
|
||||
summaries, err := srv.GetByUserWithin(user, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -94,6 +108,10 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cacheKey != "" {
|
||||
srv.Cache.SetDefault(cacheKey, summary)
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
@ -283,3 +301,12 @@ func mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem
|
||||
|
||||
return itemList
|
||||
}
|
||||
|
||||
func getHash(times []time.Time, user *models.User) string {
|
||||
digest := md5.New()
|
||||
for _, t := range times {
|
||||
digest.Write([]byte(strconv.Itoa(int(t.Unix()))))
|
||||
}
|
||||
digest.Write([]byte(user.ID))
|
||||
return string(digest.Sum(nil))
|
||||
}
|
||||
|
@ -10,6 +10,8 @@ type UserService struct {
|
||||
Db *gorm.DB
|
||||
}
|
||||
|
||||
func (srv *UserService) Init() {}
|
||||
|
||||
func (srv *UserService) GetUserById(userId string) (*models.User, error) {
|
||||
u := &models.User{}
|
||||
if err := srv.Db.Where(&models.User{ID: userId}).First(u).Error; err != nil {
|
||||
|
44
static/assets/app.css
Normal file
44
static/assets/app.css
Normal file
@ -0,0 +1,44 @@
|
||||
body {
|
||||
font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 10px 0 20px 0;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
width: 75%;
|
||||
display: grid;
|
||||
grid-template-areas: 'header header header' 'sec1 sec1 sec2' 'sec1 sec1 sec3' 'sec1 sec1 sec4' 'footer footer footer';
|
||||
grid-gap: 10px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.projects-container {
|
||||
grid-area: sec1
|
||||
}
|
||||
|
||||
.os-container {
|
||||
grid-area: sec2
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
grid-area: sec3
|
||||
}
|
||||
|
||||
.language-container {
|
||||
grid-area: sec4
|
||||
}
|
||||
|
||||
.header-container {
|
||||
grid-area: header
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 300px;
|
||||
}
|
167
static/assets/app.js
Normal file
167
static/assets/app.js
Normal file
@ -0,0 +1,167 @@
|
||||
const SHOW_TOP_N = 10
|
||||
|
||||
const projectsCanvas = document.getElementById("chart-projects")
|
||||
const osCanvas = document.getElementById("chart-os")
|
||||
const editorsCanvas = document.getElementById("chart-editor")
|
||||
const languagesCanvas = document.getElementById("chart-language")
|
||||
|
||||
let charts = []
|
||||
|
||||
String.prototype.toHHMMSS = function () {
|
||||
var sec_num = parseInt(this, 10)
|
||||
var hours = Math.floor(sec_num / 3600)
|
||||
var minutes = Math.floor((sec_num - (hours * 3600)) / 60)
|
||||
var seconds = sec_num - (hours * 3600) - (minutes * 60)
|
||||
|
||||
if (hours < 10) {
|
||||
hours = '0' + hours
|
||||
}
|
||||
if (minutes < 10) {
|
||||
minutes = '0' + minutes
|
||||
}
|
||||
if (seconds < 10) {
|
||||
seconds = '0' + seconds
|
||||
}
|
||||
return hours + ':' + minutes + ':' + seconds
|
||||
}
|
||||
|
||||
function draw() {
|
||||
let titleOptions = {
|
||||
display: true,
|
||||
fontSize: 16
|
||||
}
|
||||
|
||||
function getTooltipOptions(key, type) {
|
||||
return {
|
||||
mode: 'single',
|
||||
callbacks: {
|
||||
label: (item) => {
|
||||
let idx = type === 'pie' ? item.index : item.datasetIndex
|
||||
let d = wakapiData[key][idx]
|
||||
return `${d.key}: ${d.total.toString().toHHMMSS()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
charts.forEach(c => c.destroy())
|
||||
|
||||
let projectChart = new Chart(projectsCanvas.getContext('2d'), {
|
||||
type: 'horizontalBar',
|
||||
data: {
|
||||
datasets: wakapiData.projects
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.projects.length))
|
||||
.map(p => {
|
||||
return {
|
||||
label: p.key,
|
||||
data: [parseInt(p.total)],
|
||||
backgroundColor: getRandomColor(p.key)
|
||||
}
|
||||
})
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Projects (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('projects', 'bar'),
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let osChart = new Chart(osCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.operatingSystems
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.operatingSystems.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.operatingSystems
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Operating Systems (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('operatingSystems', 'pie')
|
||||
}
|
||||
})
|
||||
|
||||
let editorChart = new Chart(editorsCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.editors
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.editors.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.editors
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Editors (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('editors', 'pie')
|
||||
}
|
||||
})
|
||||
|
||||
let languageChart = new Chart(languagesCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.languages
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.languages.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: wakapiData.languages
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, {text: `Languages (top ${SHOW_TOP_N})`}),
|
||||
tooltips: getTooltipOptions('languages', 'pie')
|
||||
}
|
||||
})
|
||||
|
||||
getTotal(wakapiData.operatingSystems)
|
||||
document.getElementById('grid-container').style.visibility = 'visible'
|
||||
|
||||
charts = [projectChart, osChart, editorChart, languageChart]
|
||||
}
|
||||
|
||||
function getTotal(data) {
|
||||
let total = data.reduce((acc, d) => acc + d.total, 0)
|
||||
document.getElementById("total-span").innerText = total.toString().toHHMMSS()
|
||||
}
|
||||
|
||||
function getRandomColor(seed) {
|
||||
seed = seed ? seed : '1234567';
|
||||
Math.seedrandom(seed);
|
||||
var letters = '0123456789ABCDEF'.split('');
|
||||
var color = '#';
|
||||
for (var i = 0; i < 6; i++) {
|
||||
color += letters[Math.floor(Math.random() * 16)];
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
// https://koddsson.com/posts/emoji-favicon/
|
||||
const favicon = document.querySelector("link[rel=icon]");
|
||||
if (favicon) {
|
||||
const emoji = favicon.getAttribute("data-emoji");
|
||||
if (emoji) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.height = 64;
|
||||
canvas.width = 64;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.font = "64px serif";
|
||||
ctx.fillText(emoji, 0, 64);
|
||||
favicon.href = canvas.toDataURL();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('load', function() {
|
||||
draw()
|
||||
})
|
@ -1,288 +0,0 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Coding Stats</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
<link rel="icon" data-emoji="📊" type="image/png">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 10px 0 20px 0;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
width: 75%;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'header header header'
|
||||
'sec1 sec1 sec2'
|
||||
'sec1 sec1 sec3'
|
||||
'sec1 sec1 sec4'
|
||||
'footer footer footer';
|
||||
grid-gap: 10px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.projects-container {
|
||||
grid-area: sec1
|
||||
}
|
||||
|
||||
.os-container {
|
||||
grid-area: sec2
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
grid-area: sec3
|
||||
}
|
||||
|
||||
.language-container {
|
||||
grid-area: sec4
|
||||
}
|
||||
|
||||
.header-container {
|
||||
grid-area: header
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 300px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Wakapi</h1>
|
||||
<h3>Your Coding Statistics Dashboard</h3>
|
||||
<div class="input-container" id="input-container">
|
||||
<div>
|
||||
<label for="user">User: </label>
|
||||
<input type="text" class="input" id="user-input" name="user" placeholder="Enter Username">
|
||||
<label for="pw">Password: </label>
|
||||
<input type="password" class="input" id="password-input" name="pw" placeholder="Enter Password">
|
||||
</div>
|
||||
<div style="margin-top: 10px">
|
||||
<span>Shortcuts: </span>
|
||||
<button onclick="load('today', true)">Today (live)</button>
|
||||
<button onclick="load('day', false)">Yesterday</button>
|
||||
<button onclick="load('week', false)">This Week</button>
|
||||
<button onclick="load('month', false)">This Month</button>
|
||||
<button onclick="load('year', false)">This Year</button>
|
||||
<button onclick="load('any', false)">All Time</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-container" id="grid-container">
|
||||
<div class="header-container">
|
||||
<p>
|
||||
<strong>Total:</strong> <span id="total-span"></span><br>
|
||||
</p>
|
||||
</div>
|
||||
<div class="projects-container" id="projects-container">
|
||||
<canvas id="chart-projects"></canvas>
|
||||
</div>
|
||||
<div class="os-container" id="os-container">
|
||||
<canvas id="chart-os"></canvas>
|
||||
</div>
|
||||
<div class="editor-container" id="editor-container">
|
||||
<canvas id="chart-editor"></canvas>
|
||||
</div>
|
||||
<div class="language-container" id="language-container">
|
||||
<canvas id="chart-language"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/seedrandom/2.4.4/seedrandom.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.3/Chart.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
const SHOW_TOP_N = 10
|
||||
|
||||
const projectsCanvas = document.getElementById("chart-projects")
|
||||
const osCanvas = document.getElementById("chart-os")
|
||||
const editorsCanvas = document.getElementById("chart-editor")
|
||||
const languagesCanvas = document.getElementById("chart-language")
|
||||
|
||||
let charts = []
|
||||
|
||||
String.prototype.toHHMMSS = function () {
|
||||
var sec_num = parseInt(this, 10);
|
||||
var hours = Math.floor(sec_num / 3600);
|
||||
var minutes = Math.floor((sec_num - (hours * 3600)) / 60);
|
||||
var seconds = sec_num - (hours * 3600) - (minutes * 60);
|
||||
|
||||
if (hours < 10) { hours = "0" + hours; }
|
||||
if (minutes < 10) { minutes = "0" + minutes; }
|
||||
if (seconds < 10) { seconds = "0" + seconds; }
|
||||
return hours + ':' + minutes + ':' + seconds;
|
||||
}
|
||||
|
||||
function load(interval, live) {
|
||||
let user = document.getElementById('user-input').value
|
||||
let password = document.getElementById('password-input').value
|
||||
let hashed = btoa(`${user}:${password}`)
|
||||
|
||||
fetch(`${window.location.href}/api/summary?interval=${interval}&live=${live}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Basic ${hashed}`
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 401) {
|
||||
console.error('Unauthorized')
|
||||
alert('Unauthorized')
|
||||
}
|
||||
return res
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((json) => draw(json))
|
||||
.catch(err => console.error(err));
|
||||
}
|
||||
|
||||
function draw(data) {
|
||||
let titleOptions = {
|
||||
display: true,
|
||||
fontSize: 16
|
||||
}
|
||||
|
||||
function getTooltipOptions(key, type) {
|
||||
return {
|
||||
mode: 'single',
|
||||
callbacks: {
|
||||
label: (item) => {
|
||||
let idx = type === 'pie' ? item.index : item.datasetIndex
|
||||
let d = data[key][idx]
|
||||
return `${d.key}: ${d.total.toString().toHHMMSS()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
charts.forEach(c => c.destroy())
|
||||
|
||||
let projectChart = new Chart(projectsCanvas.getContext('2d'), {
|
||||
type: 'horizontalBar',
|
||||
data: {
|
||||
datasets: data.projects
|
||||
.slice(0, Math.min(SHOW_TOP_N, data.projects.length))
|
||||
.map(p => {
|
||||
return {
|
||||
label: p.key,
|
||||
data: [parseInt(p.total)],
|
||||
backgroundColor: getRandomColor(p.key)
|
||||
}
|
||||
})
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, { text: `Projects (top ${SHOW_TOP_N})` }),
|
||||
tooltips: getTooltipOptions('projects', 'bar'),
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let osChart = new Chart(osCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: data.operating_systems
|
||||
.slice(0, Math.min(SHOW_TOP_N, data.operating_systems.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: data.operating_systems.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: data.operating_systems
|
||||
.slice(0, Math.min(SHOW_TOP_N, data.operating_systems.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, { text: `Operating Systems (top ${SHOW_TOP_N})` }),
|
||||
tooltips: getTooltipOptions('operating_systems', 'pie')
|
||||
}
|
||||
});
|
||||
|
||||
let editorChart = new Chart(editorsCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: data.editors
|
||||
.slice(0, Math.min(SHOW_TOP_N, data.editors.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: data.editors.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: data.editors
|
||||
.slice(0, Math.min(SHOW_TOP_N, data.editors.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, { text: `Editors (top ${SHOW_TOP_N})` }),
|
||||
tooltips: getTooltipOptions('editors', 'pie')
|
||||
}
|
||||
});
|
||||
|
||||
let languageChart = new Chart(languagesCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: data.languages
|
||||
.slice(0, Math.min(SHOW_TOP_N, data.languages.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: data.languages.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: data.languages
|
||||
.slice(0, Math.min(SHOW_TOP_N, data.languages.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, { text: `Languages (top ${SHOW_TOP_N})` }),
|
||||
tooltips: getTooltipOptions('languages', 'pie')
|
||||
}
|
||||
});
|
||||
|
||||
getTotal(data.operating_systems)
|
||||
document.getElementById('grid-container').style.visibility = 'visible';
|
||||
|
||||
charts = [projectChart, osChart, editorChart, languageChart]
|
||||
}
|
||||
|
||||
function getRandomColor(seed) {
|
||||
seed = seed ? seed : '1234567';
|
||||
Math.seedrandom(seed);
|
||||
var letters = '0123456789ABCDEF'.split('');
|
||||
var color = '#';
|
||||
for (var i = 0; i < 6; i++) {
|
||||
color += letters[Math.floor(Math.random() * 16)];
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
function getTotal(data) {
|
||||
let total = data.reduce((acc, d) => acc + d.total, 0)
|
||||
document.getElementById("total-span").innerText = total.toString().toHHMMSS()
|
||||
}
|
||||
|
||||
// https://koddsson.com/posts/emoji-favicon/
|
||||
const favicon = document.querySelector("link[rel=icon]");
|
||||
if (favicon) {
|
||||
const emoji = favicon.getAttribute("data-emoji");
|
||||
if (emoji) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.height = 64;
|
||||
canvas.width = 64;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.font = "64px serif";
|
||||
ctx.fillText(emoji, 0, 64);
|
||||
favicon.href = canvas.toDataURL();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
14
utils/template.go
Normal file
14
utils/template.go
Normal file
@ -0,0 +1,14 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
)
|
||||
|
||||
func Json(data interface{}) template.JS {
|
||||
d, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return template.JS(d)
|
||||
}
|
54
views/index.tpl.html
Normal file
54
views/index.tpl.html
Normal file
@ -0,0 +1,54 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Coding Stats</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
<link rel="icon" data-emoji="📊" type="image/png">
|
||||
<link rel="stylesheet" href="/assets/app.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Wakapi</h1>
|
||||
<h3>Your Coding Statistics Dashboard</h3>
|
||||
<div>
|
||||
<a href="/?interval=today">Today (live)</a>
|
||||
<a href="/?interval=day">Yesterday</a>
|
||||
<a href="/?interval=week">This Week</a>
|
||||
<a href="/?interval=month">This Month</a>
|
||||
<a href="/?interval=year">This Year</a>
|
||||
<a href="/?interval=any">All Time</a>
|
||||
</div>
|
||||
<div class="grid-container" id="grid-container">
|
||||
<div class="header-container">
|
||||
<p>
|
||||
<strong>Total:</strong> <span id="total-span"></span><br>
|
||||
</p>
|
||||
</div>
|
||||
<div class="projects-container" id="projects-container">
|
||||
<canvas id="chart-projects"></canvas>
|
||||
</div>
|
||||
<div class="os-container" id="os-container">
|
||||
<canvas id="chart-os"></canvas>
|
||||
</div>
|
||||
<div class="editor-container" id="editor-container">
|
||||
<canvas id="chart-editor"></canvas>
|
||||
</div>
|
||||
<div class="language-container" id="language-container">
|
||||
<canvas id="chart-language"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/seedrandom/2.4.4/seedrandom.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.3/Chart.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
let wakapiData = {}
|
||||
wakapiData.projects = {{ json .Projects }}
|
||||
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
|
||||
wakapiData.editors = {{ .Editors | json }}
|
||||
wakapiData.languages = {{ .Languages | json }}
|
||||
</script>
|
||||
<script src="/assets/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
Loading…
Reference in New Issue
Block a user