Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a "Calendar view" #732

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion models/view/summary.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package view

import (
"time"

"github.com/duke-git/lancet/v2/slice"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"time"
)

type SummaryViewModel struct {
Expand All @@ -14,11 +16,38 @@ type SummaryViewModel struct {
EditorColors map[string]string
LanguageColors map[string]string
OSColors map[string]string
DailyStats []*DailyProjectViewModel
RawQuery string
UserFirstData time.Time
DataRetentionMonths int
}

type DailyProjectViewModel struct {
Date time.Time `json:"date"`
Projects []*DailySingleProjectViewModel `json:"projects"`
}

type DailySingleProjectViewModel struct {
Name string `json:"name"`
Duration time.Duration `json:"duration"`
}

func NewDailyProjectStats(summaries []*models.Summary) []*DailyProjectViewModel {
dailyProjects := make([]*DailyProjectViewModel, 0)
for _, summary := range summaries {
dailyProjects = append(dailyProjects, &DailyProjectViewModel{
Date: summary.FromTime.T(),
Projects: slice.Map(summary.Projects, func(_ int, curProject *models.SummaryItem) *DailySingleProjectViewModel {
return &DailySingleProjectViewModel{
Name: curProject.Key,
Duration: curProject.Total,
}
}),
})
}
return dailyProjects
}

func (s SummaryViewModel) UserDataExpiring() bool {
cfg := conf.Get()
return cfg.Subscriptions.Enabled &&
Expand Down
30 changes: 28 additions & 2 deletions routes/summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package routes

import (
"fmt"
"net/http"
"time"

"github.com/go-chi/chi/v5"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/helpers"
Expand All @@ -10,8 +13,7 @@ import (
"github.com/muety/wakapi/models/view"
su "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"net/http"
"time"
"github.com/muety/wakapi/utils"
)

type SummaryHandler struct {
Expand Down Expand Up @@ -86,6 +88,16 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
firstData, _ = time.Parse(time.RFC822Z, firstDataKv.Value)
}

dailyStats := []*view.DailyProjectViewModel{}
if summaryParams.From.Add(time.Hour*24*31).After(summaryParams.To) && summaryParams.From.Add(time.Hour*24*3).Before(summaryParams.To) {
dailyStatsSummaries, err := h.fetchSummaryForDailyProjectStats(summaryParams)
if err != nil {
conf.Log().Request(r).Error("failed to load daily stats", "error", err)
} else {
dailyStats = view.NewDailyProjectStats(dailyStatsSummaries)
}
}

vm := view.SummaryViewModel{
SharedLoggedInViewModel: view.SharedLoggedInViewModel{
SharedViewModel: view.NewSharedViewModel(h.config, nil),
Expand All @@ -100,6 +112,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
RawQuery: rawQuery,
UserFirstData: firstData,
DataRetentionMonths: h.config.App.DataRetentionMonths,
DailyStats: dailyStats,
}

templates[conf.SummaryTemplate].Execute(w, vm)
Expand All @@ -112,3 +125,16 @@ func (h *SummaryHandler) buildViewModel(r *http.Request, w http.ResponseWriter)
},
}, r, w)
}

func (h *SummaryHandler) fetchSummaryForDailyProjectStats(params *models.SummaryParams) ([]*models.Summary, error) {
summaries := make([]*models.Summary, 0)
intervals := utils.SplitRangeByDays(params.From, params.To)
for _, interval := range intervals {
curSummary, err := h.summarySrvc.Aliased(interval[0], interval[1], params.User, h.summarySrvc.Retrieve, params.Filters, false)
if err != nil {
return nil, err
}
summaries = append(summaries, curSummary)
}
return summaries, nil
}
1 change: 1 addition & 0 deletions routes/utils/summary_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func LoadUserSummaryByParams(ss services.ISummaryService, params *models.Summary
return nil, err, http.StatusInternalServerError
}

summary.User = params.User
muety marked this conversation as resolved.
Show resolved Hide resolved
summary.FromTime = models.CustomTime(summary.FromTime.T().In(params.User.TZ()))
summary.ToTime = models.CustomTime(summary.ToTime.T().In(params.User.TZ()))

Expand Down
104 changes: 91 additions & 13 deletions static/assets/js/summary.js
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nevertheless, things still don't look perfectly right. Besides there being no margin before your chart the logic still doesn't seem to be correct.

It seemed that you get this problem after revert the last commit (this commit). The problem were fixed here.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then please apply the styling-related fixes from your latest commit, but fix the activity chart's HTML structure (misplaced divs) according to my patch.

Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const labelsCanvas = document.getElementById('chart-label')
const branchesCanvas = document.getElementById('chart-branches')
const entitiesCanvas = document.getElementById('chart-entities')
const categoriesCanvas = document.getElementById('chart-categories')
const dailyCanvas = document.getElementById('chart-daily-projects')

const projectContainer = document.getElementById('project-container')
const osContainer = document.getElementById('os-container')
Expand All @@ -25,10 +26,11 @@ const labelContainer = document.getElementById('label-container')
const branchContainer = document.getElementById('branch-container')
const entityContainer = document.getElementById('entity-container')
const categoryContainer = document.getElementById('category-container')
const dailyContainer = document.getElementById('daily-container')

const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer, labelContainer, branchContainer, entityContainer, categoryContainer]
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas, labelsCanvas, branchesCanvas, entitiesCanvas, categoriesCanvas]
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines, wakapiData.labels, wakapiData.branches, wakapiData.entities, wakapiData.categories]
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer, labelContainer, branchContainer, entityContainer, categoryContainer, dailyContainer]
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas, labelsCanvas, branchesCanvas, entitiesCanvas, categoriesCanvas, dailyCanvas]
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines, wakapiData.labels, wakapiData.branches, wakapiData.entities, wakapiData.categories, wakapiData.dailyStats]

let topNPickers = [...document.getElementsByClassName('top-picker')]
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
Expand Down Expand Up @@ -63,6 +65,13 @@ String.prototype.toHHMMSS = function () {
return `${hours}:${minutes}:${seconds}`
}

function filterLegendItem(item) {
if (!item || !item.text) return false;
item.text = item.text.length > LEGEND_CHARACTERS ? item.text.slice(0, LEGEND_CHARACTERS - 3).padEnd(LEGEND_CHARACTERS, '.') : item.text
item.text = item.text.padEnd(LEGEND_CHARACTERS + 3)
return true
}

function draw(subselection) {
function getTooltipOptions(key, stacked) {
return {
Expand All @@ -79,12 +88,6 @@ function draw(subselection) {
}
}

function filterLegendItem(item) {
item.text = item.text.length > LEGEND_CHARACTERS ? item.text.slice(0, LEGEND_CHARACTERS - 3).padEnd(LEGEND_CHARACTERS, '.') : item.text
item.text = item.text.padEnd(LEGEND_CHARACTERS + 3)
return true
}

function shouldUpdate(index) {
return !subselection || (subselection.includes(index) && data[index].length >= showTopN[index])
}
Expand All @@ -102,7 +105,7 @@ function draw(subselection) {
data: {
datasets: [{
data: wakapiData.projects
.slice(0, Math.min(showTopN[0], wakapiData.projects.length))
.slice(0, Math.min(showTopN[0], wakapiData.projects.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.projects.map((p, i) => {
const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i % baseColors.length))
Expand Down Expand Up @@ -455,7 +458,7 @@ function draw(subselection) {
callback: (label) => label.toString().toHHMMSS(),
},
stacked: true,
max: wakapiData.categories.map(c => c.total).reduce((a, b) => a+b, 0)
max: wakapiData.categories.map(c => c.total).reduce((a, b) => a + b, 0)
},
y: {
stacked: true,
Expand Down Expand Up @@ -503,7 +506,7 @@ function togglePlaceholders(mask) {
}

function getPresentDataMask() {
return data.map(list => (list ? list.reduce((acc, e) => acc + e.total, 0) : 0) > 0)
return data.map(list => (list ? list.reduce((acc, e) => acc + (e.total ? e.total : (e.projects ? e.projects.reduce((acc, f) => acc + f.duration, 0) : 0)), 0) : 0) > 0)
}

function getColor(seed, index) {
Expand Down Expand Up @@ -547,11 +550,83 @@ function extractFile(filePath) {
}

function updateNumTotal() {
for (let i = 0; i < data.length; i++) {
// Why length - 1:
// We don't have a 'topN' for the DailyProjectStats
// So there isn't a input for it.
for (let i = 0; i < data.length - 1; i++) {
document.querySelector(`span[data-entity='${i}']`).innerText = data[i].length.toString()
}
}

function drawDailyProjectChart(dailyStats) {
if (!dailyCanvas || dailyCanvas.classList.contains('hidden')) return
const formattedStats = dailyStats.map(stat => ({
...stat,
date: new Date(stat.date).toLocaleDateString() // convert to YYYY-MM-DD format
}));

const projects = formattedStats.flatMap(day => day.projects.map(project => project.name)).sort().filter((value, index, self) => self.indexOf(value) === index)

const data = formattedStats.map(day => {
var curdata = {}
for (const key in day.projects) {
curdata[day.projects[key].name] = day.projects[key].duration
}
return curdata
})

new Chart(dailyCanvas.getContext('2d'), {
type: 'bar',
data: {
labels: formattedStats.map(day => day.date),
datasets: projects.map(project => ({
label: project,
data: data.map(day => day[project] || 0),
backgroundColor: getRandomColor(project),
barPercentage: 0.95,
}))
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
title: {
display: true,
text: 'Date'
}
},
y: {
stacked: true,
title: {
display: true,
text: 'Duration (hh:mm:ss)'
},
ticks: {
callback: value => value.toString().toHHMMSS()
}
}
},
plugins: {
tooltip: {
callbacks: {
label: (context) => {
return `${context.dataset.label}: ${context.raw.toString().toHHMMSS()}`
}
}
},
legend: {
position: 'right',
labels: {
filter: filterLegendItem
}
}
}
}
})
}

window.addEventListener('load', function () {
topNPickers.forEach(e => e.addEventListener('change', () => {
parseTopN()
Expand All @@ -562,4 +637,7 @@ window.addEventListener('load', function () {
togglePlaceholders(getPresentDataMask())
draw()
updateNumTotal()
if (wakapiData.dailyStats && wakapiData.dailyStats.length > 0) {
drawDailyProjectChart(wakapiData.dailyStats)
}
})
14 changes: 14 additions & 0 deletions views/summary.tpl.html
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,19 @@ <h4 class="font-semibold text-lg text-gray-500">{{ .TotalTime | duration }}</h4>
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>

<div class="row-span-1 col-span-1 sm:row-span-3 sm:col-span-3 md:row-span-3 md:col-span-3 p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col w-full no-break"
id="daily-container" style="max-height: 224px;">
<div class="flex justify-between">
<div class="flex items-center gap-x-2">
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-nowrap">Daily Project Time {{ if .IsProjectDetails }} For this Project {{ end }}</span>
</div>
</div>
<canvas id="chart-daily-projects" class="mt-2"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data / Interval longer than a month or shorter than 3 days</span>
</div>
</div>
</div>

<div class="mt-12 flex flex-col space-y-2 text-gray-300 w-full no-break">
Expand Down Expand Up @@ -358,6 +371,7 @@ <h1 class="font-semibold text-3xl text-white m-0 mb-2">Setup Instructions</h1>
wakapiData.machines = {{ .Machines | json }}
wakapiData.labels = {{ .Labels | json }}
wakapiData.categories = {{ .Categories | json }}
wakapiData.dailyStats = {{ .DailyStats | json }}
{{ if .IsProjectDetails }}
wakapiData.branches = {{ .Branches | json }}
wakapiData.entities = {{ .Entities | json }}
Expand Down
Loading