Add basic library code to manage projects, activities and times.

This commit is contained in:
Olli 2025-08-29 11:56:45 +02:00
commit fb576f3d51
7 changed files with 663 additions and 0 deletions

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
}