timefiles/application/application.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
}