From bf4e19378df4fe41e87bf158e82cfd853d89532a Mon Sep 17 00:00:00 2001 From: Olli Date: Fri, 29 Aug 2025 14:23:06 +0200 Subject: [PATCH] Add basic stateful application code --- application/application.go | 334 +++++++++++++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 application/application.go diff --git a/application/application.go b/application/application.go new file mode 100644 index 0000000..d8747d9 --- /dev/null +++ b/application/application.go @@ -0,0 +1,334 @@ +// 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 +}