From 0458f41c3215e709fbad231f6150104a0c1482de Mon Sep 17 00:00:00 2001 From: Olli Date: Thu, 28 Aug 2025 14:36:47 +0200 Subject: [PATCH 1/5] Add schema description and JSON schema files --- README.md | 76 ++++++++++++++++++++++++++++++++++++++++++++ schema/activity.json | 22 +++++++++++++ schema/project.json | 22 +++++++++++++ schema/time.json | 36 +++++++++++++++++++++ 4 files changed, 156 insertions(+) create mode 100644 README.md create mode 100644 schema/activity.json create mode 100644 schema/project.json create mode 100644 schema/time.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..c68b345 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Time Files + +Time Files is both a specification and a reference implementation for tracking +project time. + +## Schema + +All data is stored in JSON files using `.json` as file extension. Each entity +uses its own folder with each instance in a dedicated file. For some entities +sub-folders are used. + +Files and folders are structured as follows: + + projects + .json + .json + activities + / + .json + .json + / + .json + .json + times/ + / + / + / + .json + .json + .json + current.json + +Project files are stored in a “projects” folder using the UUID as file name and +the following content: + +```json +{ + "id": "UUID of the project", + "title": "Human-readable title" +} +``` + +Activity files are stored in an “activities” folder with the UUID of the project +they belong to as sub-folder. They have the following content: + +```json +{ + "id": "UUID of the activity", + "title": "Human-readable title" +} +``` + +Time files are stored in a “times” folder with sub-folders for the year, the +month and the day. If a time entry spans across multiple days, it has to be +split. They have the following content: + +```json +{ + "id": "UUID of the time entry", + "project": "UUID of the project (optional)", + "activity": "UUID of the activity (optional)", + "start": "Start time in ISO format", + "end": "End time in ISO format (optional)" +} +``` + +Additionally, the file “current.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. + +The file schemata are available as JSON schema: + +* [project](./schema/project.json) +* [activity](./schema/activity.json) +* [time](./schema/time.json) diff --git a/schema/activity.json b/schema/activity.json new file mode 100644 index 0000000..10dadbb --- /dev/null +++ b/schema/activity.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://www.suruatoel.xyz/codes/protime/activity.json", + "title": "Activity", + "description": "An activity entry", + "type": "object", + "properties": { + "id": { + "description": "", + "type": "string", + "format": "uuid" + }, + "title": { + "description": "", + "type": "string" + } + }, + "required": [ + "id", + "title" + ] +} diff --git a/schema/project.json b/schema/project.json new file mode 100644 index 0000000..2f2c4ca --- /dev/null +++ b/schema/project.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://www.suruatoel.xyz/codes/protime/project.json", + "title": "Project", + "description": "A project entry", + "type": "object", + "properties": { + "id": { + "description": "", + "type": "string", + "format": "uuid" + }, + "title": { + "description": "", + "type": "string" + } + }, + "required": [ + "id", + "title" + ] +} diff --git a/schema/time.json b/schema/time.json new file mode 100644 index 0000000..ddcc07b --- /dev/null +++ b/schema/time.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://www.suruatoel.xyz/codes/protime/time.json", + "title": "Time", + "description": "A time entry", + "type": "object", + "properties": { + "id": { + "description": "", + "type": "string", + "format": "uuid" + }, + "project": { + "description": "", + "type": "string" + }, + "activity": { + "description": "", + "type": "string" + }, + "start": { + "description": "", + "type": "string", + "format": "date-time" + }, + "end": { + "description": "", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "start" + ] +} From 529efb3668cf364a8787fa320ab822c1ce9f3210 Mon Sep 17 00:00:00 2001 From: Olli Date: Thu, 28 Aug 2025 14:36:47 +0200 Subject: [PATCH 2/5] Add schema description and JSON schema files --- README.md | 76 ++++++++++++++++++++++++++++++++++++++++++++ schema/activity.json | 22 +++++++++++++ schema/project.json | 22 +++++++++++++ schema/time.json | 36 +++++++++++++++++++++ 4 files changed, 156 insertions(+) create mode 100644 README.md create mode 100644 schema/activity.json create mode 100644 schema/project.json create mode 100644 schema/time.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..52e21c5 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Time Files + +Time Files is both a specification and a reference implementation for tracking +project time. + +## Schema + +All data is stored in JSON files using `.json` as file extension. Each entity +uses its own folder with each instance in a dedicated file. For some entities +sub-folders are used. + +Files and folders are structured as follows: + + projects + .json + .json + activities + / + .json + .json + / + .json + .json + times/ + / + / + / + .json + .json + .json + timer.json + +Project files are stored in a “projects” folder using the UUID as file name and +the following content: + +```json +{ + "id": "UUID of the project", + "title": "Human-readable title" +} +``` + +Activity files are stored in an “activities” folder with the UUID of the project +they belong to as sub-folder. They have the following content: + +```json +{ + "id": "UUID of the activity", + "title": "Human-readable title" +} +``` + +Time files are stored in a “times” folder with sub-folders for the year, the +month and the day. If a time entry spans across multiple days, it has to be +split. They have the following content: + +```json +{ + "id": "UUID of the time entry", + "project": "UUID of the project (optional)", + "activity": "UUID of the activity (optional)", + "start": "Start time in ISO format", + "end": "End time in ISO format (optional)" +} +``` + +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. + +The file schemata are available as JSON schema: + +* [project](./schema/project.json) +* [activity](./schema/activity.json) +* [time](./schema/time.json) diff --git a/schema/activity.json b/schema/activity.json new file mode 100644 index 0000000..10dadbb --- /dev/null +++ b/schema/activity.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://www.suruatoel.xyz/codes/protime/activity.json", + "title": "Activity", + "description": "An activity entry", + "type": "object", + "properties": { + "id": { + "description": "", + "type": "string", + "format": "uuid" + }, + "title": { + "description": "", + "type": "string" + } + }, + "required": [ + "id", + "title" + ] +} diff --git a/schema/project.json b/schema/project.json new file mode 100644 index 0000000..2f2c4ca --- /dev/null +++ b/schema/project.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://www.suruatoel.xyz/codes/protime/project.json", + "title": "Project", + "description": "A project entry", + "type": "object", + "properties": { + "id": { + "description": "", + "type": "string", + "format": "uuid" + }, + "title": { + "description": "", + "type": "string" + } + }, + "required": [ + "id", + "title" + ] +} diff --git a/schema/time.json b/schema/time.json new file mode 100644 index 0000000..ddcc07b --- /dev/null +++ b/schema/time.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://www.suruatoel.xyz/codes/protime/time.json", + "title": "Time", + "description": "A time entry", + "type": "object", + "properties": { + "id": { + "description": "", + "type": "string", + "format": "uuid" + }, + "project": { + "description": "", + "type": "string" + }, + "activity": { + "description": "", + "type": "string" + }, + "start": { + "description": "", + "type": "string", + "format": "date-time" + }, + "end": { + "description": "", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "start" + ] +} From fb576f3d5197fa1757e2c418bdbd27941ed209b4 Mon Sep 17 00:00:00 2001 From: Olli Date: Fri, 29 Aug 2025 11:56:45 +0200 Subject: [PATCH 3/5] Add basic library code to manage projects, activities and times. --- go.mod | 3 + lib/activities.go | 121 +++++++++++++++++++++++++++++++++++ lib/common.go | 39 +++++++++++ lib/projects.go | 142 ++++++++++++++++++++++++++++++++++++++++ lib/stats.go | 81 +++++++++++++++++++++++ lib/timer.go | 117 +++++++++++++++++++++++++++++++++ lib/times.go | 160 ++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 663 insertions(+) create mode 100644 go.mod create mode 100644 lib/activities.go create mode 100644 lib/common.go create mode 100644 lib/projects.go create mode 100644 lib/stats.go create mode 100644 lib/timer.go create mode 100644 lib/times.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8439921 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module suruatoel.xyz/timefiles + +go 1.24.2 diff --git a/lib/activities.go b/lib/activities.go new file mode 100644 index 0000000..6ef4830 --- /dev/null +++ b/lib/activities.go @@ -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 +} diff --git a/lib/common.go b/lib/common.go new file mode 100644 index 0000000..deb0a09 --- /dev/null +++ b/lib/common.go @@ -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 +} diff --git a/lib/projects.go b/lib/projects.go new file mode 100644 index 0000000..853e6d9 --- /dev/null +++ b/lib/projects.go @@ -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 +} diff --git a/lib/stats.go b/lib/stats.go new file mode 100644 index 0000000..e75b31f --- /dev/null +++ b/lib/stats.go @@ -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 +} diff --git a/lib/timer.go b/lib/timer.go new file mode 100644 index 0000000..f31cde2 --- /dev/null +++ b/lib/timer.go @@ -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 +} diff --git a/lib/times.go b/lib/times.go new file mode 100644 index 0000000..4297499 --- /dev/null +++ b/lib/times.go @@ -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 +} From bf4e19378df4fe41e87bf158e82cfd853d89532a Mon Sep 17 00:00:00 2001 From: Olli Date: Fri, 29 Aug 2025 14:23:06 +0200 Subject: [PATCH 4/5] 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 +} From 5956331a2838ba7b31b29a9d3f11c6aa231497f5 Mon Sep 17 00:00:00 2001 From: Olli Date: Thu, 16 Oct 2025 07:59:16 +0200 Subject: [PATCH 5/5] Add basic terminal user interface --- go.mod | 16 +++ go.sum | 51 +++++++++ main.go | 29 +++++ tui/day.go | 275 ++++++++++++++++++++++++++++++++++++++++++++++ tui/mainscreen.go | 78 +++++++++++++ tui/modal.go | 115 +++++++++++++++++++ tui/statusbar.go | 37 +++++++ tui/tabs.go | 163 +++++++++++++++++++++++++++ tui/tui.go | 114 +++++++++++++++++++ 9 files changed, 878 insertions(+) create mode 100644 go.sum create mode 100644 main.go create mode 100644 tui/day.go create mode 100644 tui/mainscreen.go create mode 100644 tui/modal.go create mode 100644 tui/statusbar.go create mode 100644 tui/tabs.go create mode 100644 tui/tui.go diff --git a/go.mod b/go.mod index 8439921..3f0e3c8 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9e81442 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..1393b25 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/tui/day.go b/tui/day.go new file mode 100644 index 0000000..5838921 --- /dev/null +++ b/tui/day.go @@ -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") +} diff --git a/tui/mainscreen.go b/tui/mainscreen.go new file mode 100644 index 0000000..c305ca5 --- /dev/null +++ b/tui/mainscreen.go @@ -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 + }) +} diff --git a/tui/modal.go b/tui/modal.go new file mode 100644 index 0000000..b5bf1c3 --- /dev/null +++ b/tui/modal.go @@ -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 + }) +} diff --git a/tui/statusbar.go b/tui/statusbar.go new file mode 100644 index 0000000..c8274ed --- /dev/null +++ b/tui/statusbar.go @@ -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) +} diff --git a/tui/tabs.go b/tui/tabs.go new file mode 100644 index 0000000..2a2faf6 --- /dev/null +++ b/tui/tabs.go @@ -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) +} diff --git a/tui/tui.go b/tui/tui.go new file mode 100644 index 0000000..94a8082 --- /dev/null +++ b/tui/tui.go @@ -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) + }) +}