334 lines
10 KiB
Go
334 lines
10 KiB
Go
// Package application is the base of a stateful application, storing loaded data in memory.
|
|
package application
|
|
|
|
import (
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"suruatoel.xyz/timefiles/lib"
|
|
)
|
|
|
|
// Application represents a stateful time-tracker application.
|
|
//
|
|
// jobLock is a mutual-exclusive lock to limit the execution of asynchronous jobs.
|
|
//
|
|
// jobWait is a wait group to wait for asynchronous jobs to finished.
|
|
//
|
|
// ui represents the user interface allowing the application to call callbacks.
|
|
//
|
|
// projectsByID stores projects by their ID
|
|
//
|
|
// projectsByTitle stores projects by their title
|
|
//
|
|
// activitiesByID stores activities by the ID of the project they belong to and their ID
|
|
//
|
|
// activitiesByTitle stores activities by the ID of the project they belong to and their title
|
|
type Application struct {
|
|
jobLock sync.Mutex
|
|
jobWait sync.WaitGroup
|
|
ui UserInterface
|
|
projectsByID map[string]*lib.Project
|
|
projectsByTitle map[string]*lib.Project
|
|
activitiesByID map[string]map[string]*lib.Activity
|
|
activitiesByTitle map[string]map[string]*lib.Activity
|
|
}
|
|
|
|
// UserInterface is the representation of a user interface with callback functions.
|
|
//
|
|
// SetTimerStart notifies the user interface that the update of the time has started.
|
|
//
|
|
// SetTimer sends an updated timer to the user interface. If the timer was stopped, nil will be passed.
|
|
//
|
|
// SetSummaryStart notifies the user interface that calculating the summary has started.
|
|
//
|
|
// SetSummary sends the calculated summary to the user interface.
|
|
//
|
|
// SetTotalsStart notifies the user interface that calculating the totals has started.
|
|
//
|
|
// SetTotals sends the calculated totals to the user interface.
|
|
type UserInterface interface {
|
|
SetTimerStart()
|
|
SetTimer(timer *lib.Time, err error)
|
|
SetSummaryStart()
|
|
SetSummary(summary []*lib.Time, err error)
|
|
SetTotalsStart()
|
|
SetTotals(totals lib.TotalTime, err error)
|
|
}
|
|
|
|
// NewApplication creates a new application for a user interface.
|
|
func NewApplication(ui UserInterface) *Application {
|
|
app := Application{
|
|
ui: ui,
|
|
projectsByID: make(map[string]*lib.Project),
|
|
projectsByTitle: make(map[string]*lib.Project),
|
|
activitiesByID: make(map[string]map[string]*lib.Activity),
|
|
activitiesByTitle: make(map[string]map[string]*lib.Activity),
|
|
}
|
|
|
|
return &app
|
|
}
|
|
|
|
// Load loads initial application data like projects and activities.
|
|
func (app *Application) Load() {
|
|
projects, err := lib.GetProjects()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
for _, project := range projects {
|
|
app.projectsByID[project.ID] = project
|
|
app.projectsByTitle[project.Title] = project
|
|
|
|
activities, activitiesErr := lib.GetActivities(project)
|
|
if activitiesErr != nil {
|
|
return
|
|
}
|
|
|
|
activitiesByID := make(map[string]*lib.Activity)
|
|
activitiesByTitle := make(map[string]*lib.Activity)
|
|
for _, activity := range activities {
|
|
activitiesByID[activity.ID] = activity
|
|
activitiesByTitle[activity.Title] = activity
|
|
}
|
|
app.activitiesByID[project.ID] = activitiesByID
|
|
app.activitiesByTitle[project.ID] = activitiesByTitle
|
|
}
|
|
}
|
|
|
|
// GetProject returns a project by its ID.
|
|
func (app *Application) GetProject(projectID string) *lib.Project {
|
|
return app.projectsByID[projectID]
|
|
}
|
|
|
|
// GetActivity returns an activity by its ID.
|
|
func (app *Application) GetActivity(project *lib.Project, activityID string) *lib.Activity {
|
|
activities, found := app.activitiesByID[project.ID]
|
|
if !found {
|
|
return nil
|
|
}
|
|
|
|
return activities[activityID]
|
|
}
|
|
|
|
// SearchProjects searches for projects by comparing their title with a given query. The comparison is case-insensitive
|
|
// and finds any project which title contains the query string.
|
|
func (app *Application) SearchProjects(query string) (projects []*lib.Project) {
|
|
for _, project := range app.projectsByTitle {
|
|
if strings.Contains(strings.ToLower(project.Title), strings.ToLower(query)) {
|
|
projects = append(projects, project)
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// SearchActivities searches for activities of a projects by comparing their title with a given query. The comparison is
|
|
// case-insensitive and finds any activity which title contains the query string.
|
|
func (app *Application) SearchActivities(projectTitle string, query string) (activities []*lib.Activity) {
|
|
project, projectFound := app.projectsByTitle[projectTitle]
|
|
if !projectFound {
|
|
return
|
|
}
|
|
projectActivities, found := app.activitiesByTitle[project.ID]
|
|
if !found {
|
|
return
|
|
}
|
|
for _, activity := range projectActivities {
|
|
if strings.Contains(strings.ToLower(activity.Title), strings.ToLower(query)) {
|
|
activities = append(activities, activity)
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// GetTimer loads the current timer. This function queues the job and returns immediately. When the job is done,
|
|
// UserInterface.SetTimer is called.
|
|
func (app *Application) GetTimer() {
|
|
app.runJob(app.getTimer)
|
|
}
|
|
|
|
// StartTimer starts a new timer. This function queues the job and returns immediately. When the job is done,
|
|
// UserInterface.SetTimer is called.
|
|
func (app *Application) StartTimer() {
|
|
app.runJob(app.startTimer)
|
|
}
|
|
|
|
// UpdateTimer updates the current timer. This function queues the job and returns immediately. When the job is done,
|
|
// UserInterface.SetTimer is called.
|
|
func (app *Application) UpdateTimer(project string, activity string) {
|
|
app.runJob(func() {
|
|
app.updateTimer(project, activity)
|
|
})
|
|
}
|
|
|
|
// StopTimer stops the current timer. This function queues the job and returns immediately. When the job is done,
|
|
// UserInterface.SetTimer is called.
|
|
// Additionally, it gathers the time summary and calculates time totals, which will call UserInterface.SetSummary and
|
|
// UserInterface.SetTotals.
|
|
func (app *Application) StopTimer() {
|
|
app.runJob(app.stopTimer)
|
|
app.runJob(app.getTimeSummary)
|
|
app.runJob(app.getTimeTotals)
|
|
}
|
|
|
|
// GetTimeSummary gathers a list of time entries for the current day. This function queues the job and returns
|
|
// immediately. When the job is done, UserInterface.SetSummary is called.
|
|
func (app *Application) GetTimeSummary() {
|
|
app.runJob(app.getTimeSummary)
|
|
}
|
|
|
|
// GetTimeTotals collects the time spent per project and activity for the current day. This function queues the job and
|
|
// returns immediately. When the job is done, UserInterface.SetTotals is called.
|
|
func (app *Application) GetTimeTotals() {
|
|
app.runJob(app.getTimeTotals)
|
|
}
|
|
|
|
// Wait blocks until all asynchronous jobs have been completed.
|
|
func (app *Application) Wait() {
|
|
app.jobWait.Wait()
|
|
}
|
|
|
|
// runJob runs a job in a separate go-routine.
|
|
func (app *Application) runJob(job func()) {
|
|
app.jobWait.Add(1)
|
|
go func() {
|
|
job()
|
|
app.jobWait.Done()
|
|
}()
|
|
}
|
|
|
|
// getTimer loads the current timer. It calls the corresponding callback functions on the application.
|
|
func (app *Application) getTimer() {
|
|
app.ui.SetTimerStart()
|
|
timer, err := lib.GetTimer()
|
|
app.ui.SetTimer(timer, err)
|
|
}
|
|
|
|
// startTimer starts a new timer. It calls the corresponding callback functions on the application.
|
|
func (app *Application) startTimer() {
|
|
app.ui.SetTimerStart()
|
|
timer, err := lib.StartTimer()
|
|
app.ui.SetTimer(timer, err)
|
|
}
|
|
|
|
// updateTimer updates the current timer. It calls the corresponding callback functions on the application.
|
|
func (app *Application) updateTimer(projectTitle string, activityTitle string) {
|
|
app.ui.SetTimerStart()
|
|
timer, err := app.updateTimerInternal(projectTitle, activityTitle)
|
|
app.ui.SetTimer(timer, err)
|
|
}
|
|
|
|
// updateTimerInternal updates the current timer.
|
|
func (app *Application) updateTimerInternal(projectTitle string, activityTitle string) (*lib.Time, error) {
|
|
project, projectErr := app.getProjectByTitle(projectTitle)
|
|
if projectErr != nil {
|
|
return nil, projectErr
|
|
}
|
|
|
|
activity, activityErr := app.getActivityByTitle(project, activityTitle)
|
|
if activityErr != nil {
|
|
return nil, activityErr
|
|
}
|
|
|
|
return lib.UpdateTimer(project, activity)
|
|
}
|
|
|
|
// stopTimer stops the current timer.
|
|
func (app *Application) stopTimer() {
|
|
app.ui.SetTimerStart()
|
|
timer, err := lib.StopTimer()
|
|
app.ui.SetTimer(timer, err)
|
|
}
|
|
|
|
// getTimeSummary gathers a list of time entries for the current day.
|
|
func (app *Application) getTimeSummary() {
|
|
app.ui.SetSummaryStart()
|
|
summary, err := lib.GetTimeSummary()
|
|
|
|
app.ui.SetSummary(summary, err)
|
|
}
|
|
|
|
// getTimeTotals collects the time spent per project and activity for the current day.
|
|
func (app *Application) getTimeTotals() {
|
|
app.ui.SetTotalsStart()
|
|
totalTime, totalTimeErr := lib.GetTimeTotals(
|
|
time.Now().Truncate(24*time.Hour),
|
|
time.Now().Truncate(24*time.Hour).AddDate(0, 0, 1).Add(-1*time.Second),
|
|
)
|
|
|
|
// Resolve IDs to labels
|
|
for _, entry := range totalTime.Totals {
|
|
project := app.GetProject(entry.ProjectID)
|
|
if project != nil {
|
|
entry.Project = project.Title
|
|
activity := app.GetActivity(project, entry.ActivityID)
|
|
if activity != nil {
|
|
entry.Activity = activity.Title
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort entries alphabetically
|
|
slices.SortFunc(totalTime.Totals, func(a, b *lib.TotalTimeEntry) int {
|
|
return strings.Compare(a.Project, b.Project)
|
|
})
|
|
|
|
app.ui.SetTotals(totalTime, totalTimeErr)
|
|
}
|
|
|
|
// getProjectByTitle gets a project by its title. If no project with the given title exists, it creates a new project.
|
|
func (app *Application) getProjectByTitle(title string) (*lib.Project, error) {
|
|
project, found := app.projectsByTitle[title]
|
|
if found {
|
|
return project, nil
|
|
}
|
|
|
|
// Create new project
|
|
project = lib.NewProject()
|
|
project.Title = title
|
|
err := lib.SetProject(project)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Update in-memory storage
|
|
app.projectsByID[project.ID] = project
|
|
app.projectsByTitle[project.Title] = project
|
|
app.activitiesByID[project.ID] = make(map[string]*lib.Activity)
|
|
app.activitiesByTitle[project.ID] = make(map[string]*lib.Activity)
|
|
|
|
return project, nil
|
|
}
|
|
|
|
// getActivityByTitle gets an activity by its title. If no activity with the given title exists, it creates a new
|
|
// activity.
|
|
func (app *Application) getActivityByTitle(project *lib.Project, title string) (*lib.Activity, error) {
|
|
activitiesByTitle, activitiesFound := app.activitiesByTitle[project.ID]
|
|
if !activitiesFound {
|
|
// This should never happen
|
|
return nil, nil
|
|
}
|
|
|
|
activity, found := activitiesByTitle[title]
|
|
if found {
|
|
return activity, nil
|
|
}
|
|
|
|
// Create new activity
|
|
activity = &lib.Activity{
|
|
Title: title,
|
|
}
|
|
err := lib.SetActivity(project, activity)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Update in-memory storage
|
|
app.activitiesByID[project.ID][activity.ID] = activity
|
|
app.activitiesByTitle[project.ID][activity.Title] = activity
|
|
|
|
return activity, nil
|
|
}
|