Compare commits

..
17 changed files with 1877 additions and 2 deletions

View file

@ -28,7 +28,7 @@ Files and folders are structured as follows:
<time1-uuid>.json
<time2-uuid>.json
<time3-uuid>.json
current.json
timer.json
Project files are stored in a “projects” folder using the UUID as file name and
the following content:
@ -64,7 +64,7 @@ split. They have the following content:
}
```
Additionally, the file “current.json” is stored in the “times” folder containing
Additionally, the file “timer.json” is stored in the “times” folder containing
the currently active time tracking. End `end` property is empty in that case.
Both `project` and `activity` can be empty as well to allow both to be set after
starting time tracking.

334
application/application.go Normal file
View file

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

19
go.mod Normal file
View file

@ -0,0 +1,19 @@
module suruatoel.xyz/timefiles
go 1.24.2
require (
github.com/gdamore/tcell/v2 v2.9.0
github.com/rivo/tview v0.42.0
)
require (
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
)

51
go.sum Normal file
View file

@ -0,0 +1,51 @@
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.9.0 h1:N6t+eqK7/xwtRPwxzs1PXeRWnm0H9l02CrgJ7DLn1ys=
github.com/gdamore/tcell/v2 v2.9.0/go.mod h1:8/ZoqM9rxzYphT9tH/9LnunhV9oPBqwS8WHGYm5nrmo=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

121
lib/activities.go Normal file
View file

@ -0,0 +1,121 @@
package lib
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// Activity entity as defined by the specification.
type Activity struct {
ID string `json:"id"`
Title string `json:"title"`
}
// GetActivities retrieves all stored activity instances of a project.
func GetActivities(project *Project) ([]*Activity, error) {
folder, folderErr := getActivitiesFolder(project.ID)
if folderErr != nil {
return nil, folderErr
}
dirErr := os.MkdirAll(folder, 0o750)
if dirErr != nil {
return nil, dirErr
}
files, filesErr := os.ReadDir(folder)
if filesErr != nil {
return nil, filesErr
}
activities := []*Activity{}
for _, file := range files {
activity, fileErr := readActivity(filepath.Join(folder, file.Name()))
if fileErr != nil {
return nil, fileErr
}
activities = append(activities, activity)
}
return activities, nil
}
// SetActivity persists an activity.
func SetActivity(project *Project, activity *Activity) error {
uuid, uuidErr := newUUID()
if uuidErr != nil {
return uuidErr
}
activity.ID = uuid
file, fileErr := getActivityFile(project.ID, uuid)
if fileErr != nil {
return fileErr
}
return writeActivity(file, activity)
}
// readActivity reads an activity from a file and converts it into an entity instance.
func readActivity(filename string) (*Activity, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
project := Activity{}
if err := json.NewDecoder(file).Decode(&project); err != nil {
return nil, err
}
return &project, nil
}
// writeActivity writes an activity instance to a file.
func writeActivity(filename string, activity *Activity) error {
dirErr := os.MkdirAll(filepath.Dir(filename), 0o750)
if dirErr != nil {
return dirErr
}
fmt.Println("success")
file, fileErr := os.Create(filename)
if fileErr != nil {
return fileErr
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
jsonErr := encoder.Encode(activity)
if jsonErr != nil {
return jsonErr
}
return nil
}
// getActivitiesFolder returns the folder to store activity entity files in.
func getActivitiesFolder(projectID string) (string, error) {
folder, err := getDataDir("activities")
if err != nil {
return "", err
}
return filepath.Join(folder, projectID), nil
}
// getActivityFile returns the file name for an activity entity instance.
func getActivityFile(projectID string, name string) (string, error) {
folder, err := getActivitiesFolder(projectID)
if err != nil {
return "", err
}
return filepath.Join(folder, name+".json"), nil
}

39
lib/common.go Normal file
View file

@ -0,0 +1,39 @@
// Package lib provides routines for creating and reading data files as defined by the specification.
package lib
import (
"os"
"os/exec"
"path/filepath"
)
// dataDir holds the configured directory to read/write data from/to.
var dataDir string
// SetDataDir sets the global directory to read/write data from/to. It is typically called once at application start.
func SetDataDir(directory string) {
dataDir = directory
}
// getDataDir gets the global directory to read/write data from/to for a schema entity.
func getDataDir(entity string) (string, error) {
if dataDir != "" {
return filepath.Join(dataDir, entity), nil
}
home, homeErr := os.UserHomeDir()
if homeErr != nil {
return "", homeErr
}
return filepath.Join(home, ".protime", entity), nil
}
// newUUID creates a new UUID for an entity instance using the OS command `uuidgen`.
func newUUID() (string, error) {
out, err := exec.Command("uuidgen").Output()
if err != nil {
return "", err
}
return string(out[:len(out)-1]), nil
}

142
lib/projects.go Normal file
View file

@ -0,0 +1,142 @@
package lib
import (
"encoding/json"
"os"
"path/filepath"
)
// Project entity. In addition to the JSON properties as defined by the specification, it holds activities of the
// project organized by title and by ID in a map.
type Project struct {
ID string `json:"id"`
Title string `json:"title"`
activitiesByTitle map[string]*Activity
activitiesByID map[string]*Activity
activitiesLoaded bool
}
// NewProject creates a new project instance.
func NewProject() *Project {
return &Project{
activitiesByID: make(map[string]*Activity),
activitiesByTitle: make(map[string]*Activity),
}
}
// GetProjects retrieves all stored project instances. It does not load related data like activities or times.
func GetProjects() ([]*Project, error) {
folder, folderErr := getProjectFolder()
if folderErr != nil {
return nil, folderErr
}
dirErr := os.MkdirAll(folder, 0o750)
if dirErr != nil {
return nil, dirErr
}
files, filesErr := os.ReadDir(folder)
if filesErr != nil {
return nil, filesErr
}
projects := []*Project{}
for _, file := range files {
project, fileErr := readProject(filepath.Join(folder, file.Name()))
if fileErr != nil {
return nil, fileErr
}
projects = append(projects, project)
}
return projects, nil
}
// GetProject retrieves a single project by its ID. It does not load related data like activities or times.
func GetProject(id string) (*Project, error) {
file, fileErr := getProjectFile(id)
if fileErr != nil {
return nil, fileErr
}
project, err := readProject(file)
if err != nil {
return nil, err
}
project.ID = id
return project, nil
}
// SetProject persists a project.
func SetProject(project *Project) error {
uuid, uuidErr := newUUID()
if uuidErr != nil {
return uuidErr
}
project.ID = uuid
file, fileErr := getProjectFile(uuid)
if fileErr != nil {
return fileErr
}
return writeProject(file, project)
}
// readProject reads a project from a file and converts it into an entity instance.
func readProject(filename string) (*Project, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
project := NewProject()
if err := json.NewDecoder(file).Decode(&project); err != nil {
return nil, err
}
return project, nil
}
// writeProject writes a project instance to a file.
func writeProject(filename string, project *Project) error {
dirErr := os.MkdirAll(filepath.Dir(filename), 0o750)
if dirErr != nil {
return dirErr
}
file, fileErr := os.Create(filename)
if fileErr != nil {
return fileErr
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
jsonErr := encoder.Encode(project)
if jsonErr != nil {
return jsonErr
}
return nil
}
// getProjectFolder returns the folder to store project entity files in.
func getProjectFolder() (string, error) {
return getDataDir("projects")
}
// getProjectFile returns the file name for a project entity instance.
func getProjectFile(name string) (string, error) {
folder, err := getProjectFolder()
if err != nil {
return "", err
}
return filepath.Join(folder, name+".json"), nil
}

81
lib/stats.go Normal file
View file

@ -0,0 +1,81 @@
package lib
import (
"sort"
"time"
)
// TotalTime represents the time spent per project and activity including a total summary.
type TotalTime struct {
Totals []*TotalTimeEntry
TotalSum time.Duration
}
// TotalTimeEntry represents the time spent for a project and an activity.
type TotalTimeEntry struct {
ProjectID string
Project string
ActivityID string
Activity string
Duration time.Duration
}
// GetTimeTotals collects the time spent per project and activity for a time range.
func GetTimeTotals(start, end time.Time) (TotalTime, error) {
totalTime := TotalTime{
Totals: []*TotalTimeEntry{},
}
entries, entriesErr := readTimeRange(start, end)
if entriesErr != nil {
return totalTime, entriesErr
}
if len(entries) == 0 {
return totalTime, nil
}
totalsMap := make(map[string]map[string]time.Duration)
for _, entry := range entries {
project, projectFound := totalsMap[entry.ProjectID]
if !projectFound {
project = make(map[string]time.Duration)
totalsMap[entry.ProjectID] = project
}
activity := project[entry.ActivityID]
project[entry.ActivityID] = activity + entry.End.Sub(entry.Start)
}
if len(totalsMap) == 0 {
return totalTime, nil
}
for projectID, activities := range totalsMap {
for activity, duration := range activities {
totalEntry := TotalTimeEntry{
ProjectID: projectID,
ActivityID: activity,
Duration: duration,
}
totalTime.Totals = append(totalTime.Totals, &totalEntry)
totalTime.TotalSum = totalTime.TotalSum + duration
}
}
return totalTime, nil
}
// GetTimeSummary gathers time entries for a given day. The entries are sorted by start time ascending.
func GetTimeSummary() ([]*Time, error) {
dayStart := time.Now().Truncate(24 * time.Hour)
dayEnd := time.Now().Truncate(24*time.Hour).AddDate(0, 0, 1).Add(-1 * time.Second)
entries, entriesErr := readTimeRange(dayStart, dayEnd)
if entriesErr != nil {
return nil, entriesErr
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Start.Before(entries[j].Start)
})
return entries, nil
}

117
lib/timer.go Normal file
View file

@ -0,0 +1,117 @@
package lib
import (
"os"
"path/filepath"
"time"
)
type NoTimerActiveError struct{}
func (e *NoTimerActiveError) Error() string {
return "No timer is currently active."
}
// StartTimer starts a new timer.
func StartTimer() (*Time, error) {
// Stop active timer
activeTimer, _ := GetTimer()
if activeTimer != nil {
timer, stopErr := StopTimer()
if stopErr != nil {
return timer, stopErr
}
}
// Start new timer
newTimer := Time{
Start: time.Now(),
}
setErr := setTimer(&newTimer)
if setErr != nil {
return nil, setErr
}
return &newTimer, nil
}
// UpdateTimer sets the project and activity on the current timer.
func UpdateTimer(project *Project, activity *Activity) (*Time, error) {
timer, timerErr := GetTimer()
if timerErr != nil {
return timer, timerErr
}
if timer == nil {
return nil, &NoTimerActiveError{}
}
timer.ProjectID = project.ID
timer.ActivityID = activity.ID
setError := setTimer(timer)
if setError != nil {
return timer, setError
}
return timer, nil
}
// StopTimer stops the current timer and records the resulting time entry.
func StopTimer() (*Time, error) {
timer, timerErr := GetTimer()
if timerErr != nil {
return timer, timerErr
}
if timer == nil {
return nil, &NoTimerActiveError{}
}
timer.End = time.Now()
recordErr := SetTime(timer)
if recordErr != nil {
return timer, recordErr
}
file, fileErr := getTimerFile()
if fileErr != nil {
return timer, fileErr
}
removeErr := os.Remove(file)
if removeErr != nil {
return timer, removeErr
}
return nil, nil
}
// GetTimer returns the active timer.
func GetTimer() (*Time, error) {
file, fileErr := getTimerFile()
if fileErr != nil {
return nil, fileErr
}
return readTime(file)
}
// setTimer persists a time entry as current timer. If there is already a current timer, it will be overwritten.
func setTimer(entry *Time) error {
file, fileErr := getTimerFile()
if fileErr != nil {
return fileErr
}
return writeTime(entry, file)
}
// getTimerFile returns the file name for the timer.
func getTimerFile() (string, error) {
folder, err := getDataDir("time")
if err != nil {
return "", err
}
return filepath.Join(folder, "timer.json"), nil
}

160
lib/times.go Normal file
View file

@ -0,0 +1,160 @@
package lib
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strconv"
"time"
)
// Time entity as defined by the specification.
type Time struct {
ID string `json:"id"`
ProjectID string `json:"project"`
ActivityID string `json:"activity"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
}
// SetTime persists a time entry.
func SetTime(entry *Time) error {
// Traverse range by day (in case is spans multiple days
for date := entry.Start.Truncate(24 * time.Hour); !date.After(entry.End); date = date.AddDate(0, 0, 1) {
start := entry.Start
if date.After(start) {
start = date
}
end := entry.End
if date.AddDate(0, 0, 1).Add(-1 * time.Second).Before(end) {
end = date.AddDate(0, 0, 1).Add(-1 * time.Second)
}
dayEntry := Time{
ProjectID: entry.ProjectID,
ActivityID: entry.ActivityID,
Start: start,
End: end,
}
uuid, uuidErr := newUUID()
if uuidErr != nil {
return uuidErr
}
year, month, day := date.UTC().Date()
file, fileErr := getTimeFile(year, int(month), day, uuid)
if fileErr != nil {
return fileErr
}
writeErr := writeTime(&dayEntry, file)
if writeErr != nil {
return writeErr
}
}
return nil
}
// readTimeRange reads time entries for a given time range.
func readTimeRange(start, end time.Time) ([]*Time, error) {
entries := []*Time{}
// Traverse range by day
for date := start; !date.After(end); date = date.AddDate(0, 0, 1) {
// Traverse files
year, month, day := date.UTC().Date()
folder, folderErr := getTimeFolder(year, int(month), day)
if folderErr != nil {
return nil, folderErr
}
slog.Debug(fmt.Sprintf("Read time files from folder %s", folder))
files, filesErr := os.ReadDir(folder)
if errors.Is(filesErr, fs.ErrNotExist) {
return entries, nil
}
if filesErr != nil {
return nil, filesErr
}
for _, file := range files {
entry, entryErr := readTime(filepath.Join(folder, file.Name()))
if entryErr != nil {
return nil, entryErr
}
if (entry.Start.After(start) && entry.Start.Before(end)) || (entry.End.After(start) && entry.End.Before(end)) {
entries = append(entries, entry)
}
}
}
return entries, nil
}
// readTime reads a time entry from a file and converts it into an entity instance.
func readTime(filename string) (*Time, error) {
file, err := os.Open(filename)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer file.Close()
time := Time{}
if err := json.NewDecoder(file).Decode(&time); err != nil {
return nil, err
}
return &time, nil
}
// writeTime writes a time instance to a file.
func writeTime(time *Time, filename string) error {
dirErr := os.MkdirAll(filepath.Dir(filename), 0o750)
if dirErr != nil {
return dirErr
}
file, fileErr := os.Create(filename)
if fileErr != nil {
return fileErr
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
jsonErr := encoder.Encode(time)
if jsonErr != nil {
return jsonErr
}
return nil
}
// getTimeFolder returns the folder to store time entity files in.
func getTimeFolder(year int, month int, day int) (string, error) {
folder, err := getDataDir("times")
if err != nil {
return "", err
}
return filepath.Join(folder, strconv.Itoa(year), strconv.Itoa(month), strconv.Itoa(day)), nil
}
// getTimeFile returns the file name for a time entity instance.
func getTimeFile(year int, month int, day int, name string) (string, error) {
folder, err := getTimeFolder(year, month, day)
if err != nil {
return "", err
}
return filepath.Join(folder, name+".json"), nil
}

29
main.go Normal file
View file

@ -0,0 +1,29 @@
package main
import (
"fmt"
"log/slog"
"os"
"suruatoel.xyz/timefiles/lib"
"suruatoel.xyz/timefiles/tui"
)
func main() {
if len(os.Args) > 1 {
slog.Info(fmt.Sprintf("Set data directory to: %s", os.Args[1]))
lib.SetDataDir(os.Args[1])
}
tui, appErr := tui.NewTUI()
if appErr != nil {
slog.Error(fmt.Sprintf("Failed to initialize application: %s", appErr))
os.Exit(2)
}
runErr := tui.Run()
if runErr != nil {
slog.Error(fmt.Sprintf("Failed to run application: %s", runErr))
os.Exit(2)
}
}

275
tui/day.go Normal file
View file

@ -0,0 +1,275 @@
package tui
import (
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"suruatoel.xyz/timefiles/lib"
)
const (
PageNameTimer = "set"
)
type DayUI struct {
*tview.Pages
app *TUI
summaryList *tview.Table
totalsList *tview.Table
trackButtons *tview.Flex
startButton *tview.Button
stopButton *tview.Button
setButton *tview.Button
timerModal *ProActModal
currentTimer *lib.Time
summaryCount int
}
func NewDayUI(app *TUI) *DayUI {
ui := DayUI{
Pages: tview.NewPages(),
app: app,
}
main := tview.NewFlex().SetDirection(tview.FlexRow)
ui.AddPage("main", main, true, true)
summary := tview.NewFlex().SetDirection(tview.FlexColumn)
main.AddItem(summary, 0, 1, false)
ui.summaryList = tview.NewTable()
ui.summaryList.SetBorder(true).SetTitle("List")
ui.summaryList.SetSelectable(true, false)
summary.AddItem(ui.summaryList, 0, 1, false)
ui.totalsList = tview.NewTable()
ui.totalsList.SetBorder(true).SetTitle(" Totals ")
summary.AddItem(ui.totalsList, 0, 1, false)
track := tview.NewFlex().SetDirection(tview.FlexRow)
track.SetBorder(true).SetTitle(" Track ")
main.AddItem(track, 3, 0, true)
ui.trackButtons = tview.NewFlex().SetDirection(tview.FlexColumn)
track.AddItem(ui.trackButtons, 1, 0, true)
ui.startButton = tview.NewButton("start (n)")
ui.trackButtons.AddItem(ui.startButton, 0, 1, false)
ui.trackButtons.AddItem(nil, 1, 0, false)
ui.stopButton = tview.NewButton("stop (r)")
ui.trackButtons.AddItem(ui.stopButton, 0, 1, false)
ui.trackButtons.AddItem(nil, 1, 0, false)
ui.setButton = tview.NewButton("set (s)")
ui.trackButtons.AddItem(ui.setButton, 0, 1, false)
ui.timerModal = NewProActModal(app, "Timer")
ui.AddPage(PageNameTimer, ui.timerModal, true, false)
ui.assignHandlers()
return &ui
}
func (ui *DayUI) SetTimer(timer *lib.Time) {
if ui.currentTimer != nil {
ui.summaryList.RemoveRow(ui.summaryCount)
}
ui.currentTimer = timer
ui.setInputStates()
if timer == nil {
ui.app.Focus(ui.startButton)
ui.timerModal.SetValues(nil, nil)
} else {
ui.timerModal.SetValues(ui.resolveTimerValues(timer.ProjectID, timer.ActivityID))
if timer.ProjectID == "" || timer.ActivityID == "" {
ui.openTimerModal()
} else {
ui.app.Focus(ui.stopButton)
}
ui.includeTimerInSummary()
}
}
func (ui *DayUI) SetSummary(summary []*lib.Time) {
ui.summaryCount = len(summary)
ui.summaryList.Clear()
for i, entry := range summary {
ui.setSummaryLine(entry, i)
}
ui.includeTimerInSummary()
}
func (ui *DayUI) SetTotals(totalTime lib.TotalTime) {
ui.totalsList.Clear()
for i, entry := range totalTime.Totals {
projectLabel := entry.Project
var projectAttrs tcell.AttrMask
activityLabel := entry.Activity
var activityAttrs tcell.AttrMask
if entry.Project == "" {
projectLabel = "not set"
projectAttrs = tcell.AttrItalic
}
if entry.Activity == "" {
activityLabel = "not set"
activityAttrs = tcell.AttrItalic
}
ui.totalsList.SetCell(i, 0, tview.NewTableCell(projectLabel).SetExpansion(1).SetAttributes(projectAttrs))
ui.totalsList.SetCell(i, 1, tview.NewTableCell(activityLabel).SetExpansion(1))
ui.totalsList.SetCell(i, 2, tview.NewTableCell(entry.Duration.Round(time.Second).String()).SetExpansion(0).SetAttributes(activityAttrs))
}
ui.totalsList.SetCell(len(totalTime.Totals), 0, tview.NewTableCell("Total").SetExpansion(1).SetAttributes(tcell.AttrBold))
ui.totalsList.SetCell(len(totalTime.Totals), 2, tview.NewTableCell(totalTime.TotalSum.Round(time.Second).String()).SetExpansion(0).SetAttributes(tcell.AttrBold))
}
func (ui *DayUI) setSummaryLine(entry *lib.Time, line int) {
projectAttrs := tcell.AttrItalic
activityAttrs := tcell.AttrItalic
projectLabel := "not set"
activityLabel := "not set"
project, activity := ui.resolveTimerValues(entry.ProjectID, entry.ActivityID)
if project != nil {
projectLabel = project.Title
projectAttrs = tcell.AttrNone
if activity != nil {
activityLabel = activity.Title
activityAttrs = tcell.AttrNone
}
}
ui.summaryList.SetCell(line, 0, tview.NewTableCell(projectLabel).SetExpansion(1).SetAttributes(projectAttrs))
ui.summaryList.SetCell(line, 1, tview.NewTableCell(activityLabel).SetExpansion(1).SetAttributes(activityAttrs))
if !entry.Start.IsZero() {
ui.summaryList.SetCell(line, 2, tview.NewTableCell(formatTime(entry.Start)).SetExpansion(0))
}
if !entry.End.IsZero() {
ui.summaryList.SetCell(line, 3, tview.NewTableCell(formatTime(entry.End)).SetExpansion(0))
}
}
func (ui *DayUI) includeTimerInSummary() {
if ui.currentTimer == nil {
return
}
ui.setSummaryLine(ui.currentTimer, ui.summaryCount)
}
func (ui *DayUI) assignHandlers() {
ui.startButton.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyTAB:
ui.app.Focus(ui.stopButton)
return nil
}
return event
})
ui.startButton.SetSelectedFunc(func() {
ui.start()
})
ui.stopButton.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyTAB:
ui.app.Focus(ui.setButton)
return nil
}
return event
})
ui.stopButton.SetSelectedFunc(func() {
ui.stop()
})
ui.setButton.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyTAB:
ui.app.Focus(ui.summaryList)
return nil
}
return event
})
ui.setButton.SetSelectedFunc(func() {
ui.openTimerModal()
})
ui.timerModal.SetCloseFunc(func(project string, activity string, success bool) {
ui.HidePage(PageNameTimer)
if success {
ui.setDetails(project, activity)
ui.app.Focus(ui.stopButton)
} else {
ui.app.Focus(ui.setButton)
}
})
ui.trackButtons.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Rune() {
case 'n':
ui.start()
return nil
case 'r':
ui.stop()
return nil
case 's':
ui.openTimerModal()
return nil
}
return event
})
}
func (ui *DayUI) setInputStates() {
ui.stopButton.SetDisabled(ui.currentTimer == nil)
ui.setButton.SetDisabled(ui.currentTimer == nil)
}
func (ui *DayUI) start() {
ui.app.timeApp.StartTimer()
}
func (ui *DayUI) stop() {
if ui.currentTimer != nil {
ui.app.timeApp.StopTimer()
}
}
func (ui *DayUI) openTimerModal() {
if ui.currentTimer != nil {
ui.ShowPage(PageNameTimer)
ui.app.Focus(ui.timerModal)
}
}
func (ui *DayUI) setDetails(project, activity string) {
if ui.currentTimer == nil {
return
}
ui.app.timeApp.UpdateTimer(project, activity)
}
func (ui *DayUI) resolveTimerValues(projectID, activityID string) (*lib.Project, *lib.Activity) {
project := ui.app.timeApp.GetProject(projectID)
if project != nil {
activity := ui.app.timeApp.GetActivity(project, activityID)
return project, activity
}
return project, nil
}
func formatTime(t time.Time) string {
return t.Format("15:04:05")
}

78
tui/mainscreen.go Normal file
View file

@ -0,0 +1,78 @@
package tui
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"suruatoel.xyz/timefiles/lib"
)
// MainScreen represents the main screen of the user interface.
type MainScreen struct {
app *TUI
main *tview.Flex
tabs *Tabs
timesPage *DayUI
statusBar *StatusBar
help *tview.TextView
}
// NewMainScreen creates a new main screen of the user interface.
func NewMainScreen(app *TUI) (*MainScreen, error) {
ui := MainScreen{
app: app,
}
ui.tabs = NewTabs(true)
ui.timesPage = NewDayUI(app)
ui.tabs.AddTab("times", "Times", ui.timesPage, true)
ui.statusBar = NewStatusBar()
ui.main = tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(ui.tabs, 0, 1, false).
AddItem(ui.statusBar, 1, 1, false)
ui.tabs.Select("times")
app.uiApp.SetRoot(ui.main, true).EnableMouse(true).SetFocus(ui.timesPage)
ui.addShortcuts()
return &ui, nil
}
// SetStatus displays a message in the status bar.
func (ui *MainScreen) SetStatus(message string) {
ui.statusBar.SetStatus(message)
}
// SetTimer displays the current timer on the times page.
func (ui *MainScreen) SetTimer(timer *lib.Time) {
ui.timesPage.SetTimer(timer)
}
// SetSummary displays the time summary on the times page.
func (ui *MainScreen) SetSummary(summary []*lib.Time) {
ui.timesPage.SetSummary(summary)
}
// SetTotals displays the time totals on the times page.
func (ui *MainScreen) SetTotals(totalTime lib.TotalTime) {
ui.timesPage.SetTotals(totalTime)
}
// addShortcuts registers keyboard shortcuts for the main screen.
func (ui *MainScreen) addShortcuts() {
ui.main.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyCtrlN:
ui.app.Focus(ui.tabs.Next())
return nil
case tcell.KeyCtrlP:
ui.app.Focus(ui.tabs.Prev())
return nil
}
return event
})
}

115
tui/modal.go Normal file
View file

@ -0,0 +1,115 @@
package tui
import (
"github.com/rivo/tview"
"suruatoel.xyz/timefiles/lib"
)
type ProActModal struct {
*tview.Flex
app *TUI
projectInput *tview.InputField
activityInput *tview.InputField
form *tview.Form
closeFunc func(project string, activity string, success bool)
}
func NewProActModal(app *TUI, title string) *ProActModal {
modal := ProActModal{
Flex: tview.NewFlex(),
app: app,
}
modal.projectInput = tview.NewInputField().SetLabel("Project:")
modal.activityInput = tview.NewInputField().SetLabel("Activity: ")
modal.form = tview.NewForm().
AddFormItem(modal.projectInput).
AddFormItem(modal.activityInput).
AddButton("set", func() {
if modal.closeFunc != nil {
modal.closeFunc(modal.projectInput.GetText(), modal.activityInput.GetText(), true)
}
}).
AddButton("cancel", func() {
if modal.closeFunc != nil {
modal.closeFunc(modal.projectInput.GetText(), modal.activityInput.GetText(), false)
}
}).
SetCancelFunc(func() {
if modal.closeFunc != nil {
modal.closeFunc(modal.projectInput.GetText(), modal.activityInput.GetText(), false)
}
})
modal.form.SetTitle(title).SetBorder(true).SetBorderPadding(0, 0, 0, 0)
modal.form.
SetButtonsAlign(tview.AlignCenter).
SetButtonBackgroundColor(tview.Styles.PrimitiveBackgroundColor).
SetButtonTextColor(tview.Styles.PrimaryTextColor).
SetBackgroundColor(tview.Styles.ContrastBackgroundColor)
inner := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(modal.form, 0, 1, true)
width, height := 60, 7
modal.AddItem(nil, 0, 1, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(nil, 0, 1, false).
AddItem(inner, height, 1, true).
AddItem(nil, 0, 1, false), width, 1, true).
AddItem(nil, 0, 1, false)
modal.assignHandlers()
return &modal
}
func (modal *ProActModal) Focus(delegate func(p tview.Primitive)) {
modal.form.SetFocus(0)
delegate(modal.form)
}
func (modal *ProActModal) SetCloseFunc(closeFunc func(project string, activity string, success bool)) *ProActModal {
modal.closeFunc = closeFunc
return modal
}
func (modal *ProActModal) SetValues(project *lib.Project, activity *lib.Activity) {
projectTitle := ""
if project != nil {
projectTitle = project.Title
}
modal.form.GetFormItem(0).(*tview.InputField).SetText(projectTitle)
activityTitle := ""
if activity != nil {
activityTitle = activity.Title
}
modal.form.GetFormItem(1).(*tview.InputField).SetText(activityTitle)
}
func (modal *ProActModal) assignHandlers() {
modal.projectInput.SetAutocompleteFunc(func(currentText string) (entries []string) {
if len(currentText) == 0 {
return
}
for _, project := range modal.app.timeApp.SearchProjects(currentText) {
entries = append(entries, project.Title)
}
return
})
modal.activityInput.SetAutocompleteFunc(func(currentText string) (entries []string) {
project := modal.projectInput.GetText()
if project == "" || currentText == "" {
return
}
for _, activity := range modal.app.timeApp.SearchActivities(project, currentText) {
entries = append(entries, activity.Title)
}
return
})
}

37
tui/statusbar.go Normal file
View file

@ -0,0 +1,37 @@
package tui
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
// StatusBar represents a status bar of the user interface.
//
// status is a text view showing a status message.
type StatusBar struct {
*tview.Flex
status *tview.TextView
}
// NewStatusBar creates a new one-line status bar of the user interface.
func NewStatusBar() *StatusBar {
ui := StatusBar{
Flex: tview.NewFlex(),
}
statusLabel := tview.NewTextView().SetText("Status:")
statusLabel.SetTextColor(tcell.NewRGBColor(180, 180, 180))
ui.AddItem(statusLabel, 8, 0, false)
ui.status = tview.NewTextView()
ui.status.SetText("idle")
ui.status.SetTextColor(tcell.NewRGBColor(180, 180, 180))
ui.AddItem(ui.status, 0, 1, false)
return &ui
}
// SetStatus displays a status message.
func (ui *StatusBar) SetStatus(text string) {
ui.status.SetText(text)
}

163
tui/tabs.go Normal file
View file

@ -0,0 +1,163 @@
package tui
import (
"fmt"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
type TabPrimitive interface {
tview.Primitive
ForceRedraw()
}
type Tabs struct {
*tview.Flex
names []string
items map[string]tview.Primitive
selector *tview.TextView
pages *tview.Pages
fallover bool
unlock bool
}
func NewTabs(selectorAtTop bool) *Tabs {
widget := &Tabs{
Flex: tview.NewFlex(),
names: []string{},
items: make(map[string]tview.Primitive),
fallover: true,
}
widget.SetDirection(tview.FlexRow)
// Selector
widget.selector = tview.NewTextView().
SetDynamicColors(true).
SetRegions(true).
SetWrap(false).
SetTextAlign(tview.AlignLeft)
widget.selector.SetHighlightedFunc(
func(added, removed, remaining []string) {
if len(added) == 0 {
return
}
widget.pages.SwitchToPage(added[0])
},
)
if selectorAtTop {
widget.AddItem(widget.selector, 1, 0, false)
}
// Pages
widget.pages = tview.NewPages()
widget.AddItem(widget.pages, 0, 1, false)
if !selectorAtTop {
widget.AddItem(widget.selector, 1, 0, false)
}
return widget
}
func (widget *Tabs) AllowFallover(allow bool) {
widget.fallover = allow
}
func (widget *Tabs) AddText(text string) {
fmt.Fprint(widget.selector, text)
}
func (widget *Tabs) AddTab(name string, label string, item tview.Primitive, visible bool) {
widget.names = append(widget.names, name)
widget.items[name] = item
fmt.Fprintf(widget.selector, `["%s"][darkcyan]%s[white][""] `, name, label)
widget.pages.AddPage(name, item, true, false)
if visible {
widget.Select(name)
}
}
func (widget *Tabs) Select(name string) {
widget.selector.Highlight(name)
}
func (widget *Tabs) Next() tview.Primitive {
widget.unlock = true
if len(widget.names) == 0 {
return nil
}
index := -1
highlights := widget.selector.GetHighlights()
if len(highlights) > 0 {
currentName := highlights[0]
for currentIndex, name := range widget.names {
if name == currentName {
index = currentIndex
break
}
}
}
index++
if index >= len(widget.names) {
if widget.fallover {
index = 0
} else {
index = len(widget.names) - 1
}
}
widget.selector.Highlight(widget.names[index])
return widget.items[widget.names[index]]
}
func (widget *Tabs) Prev() tview.Primitive {
widget.unlock = true
if len(widget.names) == 0 {
return nil
}
index := len(widget.names)
highlights := widget.selector.GetHighlights()
if len(highlights) > 0 {
currentName := highlights[0]
for currentIndex, name := range widget.names {
if name == currentName {
index = currentIndex
break
}
}
}
index--
if index < 0 {
if widget.fallover {
index = len(widget.names) - 1
} else {
index = 0
}
}
widget.selector.Highlight(widget.names[index])
return widget.items[widget.names[index]]
}
func (widget *Tabs) Focus(delegate func(p tview.Primitive)) {
delegate(widget.pages)
}
func (widget *Tabs) Draw(screen tcell.Screen) {
if widget.unlock {
widgetX, widgetY, widgetWidth, widgetHeight := widget.GetInnerRect()
screen.LockRegion(widgetX, widgetY, widgetWidth, widgetHeight, false)
widget.unlock = false
}
widget.Flex.Draw(screen)
}

114
tui/tui.go Normal file
View file

@ -0,0 +1,114 @@
// Package tui implements a terminal user interface.
package tui
import (
"fmt"
"github.com/rivo/tview"
"suruatoel.xyz/timefiles/application"
"suruatoel.xyz/timefiles/lib"
)
// TUI represents the terminal user interface.
//
// timeApp is the application to manage the time files.
//
// uiApp is the tview application.
//
// uiScreen is the main screen of the terminal application.
type TUI struct {
timeApp *application.Application
uiApp *tview.Application
uiScreen *MainScreen
}
// NewTUI creates a new terminal user interface.
func NewTUI() (*TUI, error) {
tui := TUI{}
tui.timeApp = application.NewApplication(&tui)
tui.uiApp = tview.NewApplication()
ui, uiErr := NewMainScreen(&tui)
if uiErr != nil {
return nil, uiErr
}
tui.uiScreen = ui
return &tui, nil
}
// Run loads the initial application data and runs the terminal user interface. It will wait for the UI to finish and
// for all asynchronous jobs to be completed before returning.
func (ui *TUI) Run() error {
ui.timeApp.Load()
ui.timeApp.GetTimer()
ui.timeApp.GetTimeSummary()
ui.timeApp.GetTimeTotals()
err := ui.uiApp.Run()
ui.timeApp.Wait()
return err
}
// Focus delegates the focus request of the UI to TUI.uiApp.
func (ui *TUI) Focus(p tview.Primitive) {
ui.uiApp.SetFocus(p)
}
// SetTimerStart sets the status for updating the timer.
func (ui *TUI) SetTimerStart() {
ui.uiApp.QueueUpdateDraw(func() {
ui.uiScreen.SetStatus("Updating timer")
})
}
// SetTimer updates the UI with the timer.
func (ui *TUI) SetTimer(timer *lib.Time, err error) {
ui.uiApp.QueueUpdateDraw(func() {
if err == nil {
ui.uiScreen.SetStatus("Timer updated")
} else {
ui.uiScreen.SetStatus(fmt.Sprintf("Failed to update timer: %s", err))
}
ui.uiScreen.SetTimer(timer)
})
}
// SetSummaryStart sets the status for gathering the time summary.
func (ui *TUI) SetSummaryStart() {
ui.uiApp.QueueUpdateDraw(func() {
ui.uiScreen.SetStatus("Getting summary")
})
}
// SetSummary updates the UI with the time summary.
func (ui *TUI) SetSummary(summary []*lib.Time, err error) {
ui.uiApp.QueueUpdateDraw(func() {
if err == nil {
ui.uiScreen.SetStatus("Summary calculated")
} else {
ui.uiScreen.SetStatus(fmt.Sprintf("Failed get summary: %s", err))
}
ui.uiScreen.SetSummary(summary)
})
}
// SetTotalsStart sets the status for calculating the time totals.
func (ui *TUI) SetTotalsStart() {
ui.uiApp.QueueUpdateDraw(func() {
ui.uiScreen.SetStatus("Calculated totals")
})
}
// SetTotals updates the UI with the time totals.
func (ui *TUI) SetTotals(totalTime lib.TotalTime, err error) {
ui.uiApp.QueueUpdateDraw(func() {
if err == nil {
ui.uiScreen.SetStatus("Totals calculated")
} else {
ui.uiScreen.SetStatus(fmt.Sprintf("Failed get totals: %s", err))
}
ui.uiScreen.SetTotals(totalTime)
})
}