// 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 }