diff --git a/api/api.go b/api/api.go index 5f18d73..32b41f4 100644 --- a/api/api.go +++ b/api/api.go @@ -6,6 +6,7 @@ import ( "log/slog" "reflect" "slices" + "time" "github.com/charmbracelet/log" @@ -22,12 +23,18 @@ const ( CacheNamespaceTasks cache.Namespace = "tasks" CacheNamespaceTasksList cache.Namespace = "tasks-list" CacheNamespaceTasksView cache.Namespace = "tasks-view" + + TTL = 10 + GarbageCollectorInterval = 5 ) type Api struct { Clickup *clickup.Client Cache *cache.Cache logger *log.Logger + + gcCloseChan chan struct{} + interval time.Duration } func NewApi(logger *log.Logger, cache *cache.Cache, token string) Api { @@ -39,10 +46,33 @@ func NewApi(logger *log.Logger, cache *cache.Cache, token string) Api { slog.New(log.WithPrefix(log.GetPrefix()+"/ClickUp")), ) - return Api{ - Clickup: clickup, - logger: log, - Cache: cache, + api := Api{ + Clickup: clickup, + logger: log, + Cache: cache, + gcCloseChan: make(chan struct{}), + interval: GarbageCollectorInterval * time.Second, + } + + go api.garbageCollector() + return api +} + +func (m *Api) garbageCollector() { + ticker := time.NewTicker(m.interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + m.logger.Debug("Garbage Collector: starting") + // now := time.Now().Unix() + if err := m.InvalidateCache(); err != nil { + panic(err) + } + case <-m.gcCloseChan: + return + } } } @@ -61,6 +91,25 @@ func (m *Api) GetSpaces(teamId string) ([]clickup.Space, error) { return data, nil } +func (m *Api) syncSpaces(entry cache.Entry) error { + m.logger.Debug("Sync spaces for a team", "teamId", entry.Key) + + client := m.Clickup + + m.logger.Debugf("Fetching spaces from API") + data, err := client.GetSpacesFromTeam(entry.Key.String()) + if err != nil { + return err + } + m.logger.Debugf("Found %d spaces for team: %s", len(data), entry.Key) + + entry.UpdatedTs = time.Now().Unix() + entry.Value = data + m.Cache.Update(entry) + + return nil +} + // Alias for GetTeams since they are the same thing func (m *Api) GetWorkspaces() ([]clickup.Workspace, error) { return m.GetTeams() @@ -81,6 +130,25 @@ func (m *Api) GetTeams() ([]clickup.Team, error) { return data, nil } +func (m *Api) syncTeams(entry cache.Entry) error { + m.logger.Debug("Sync Authorized Teams (Workspaces)") + + client := m.Clickup + + m.logger.Debugf("Fetching teams from API") + data, err := client.GetTeams() + if err != nil { + return err + } + m.logger.Debugf("Found %d teams", len(data)) + + entry.UpdatedTs = time.Now().Unix() + entry.Value = data + m.Cache.Update(entry) + + return nil +} + func (m *Api) GetFolders(spaceId string) ([]clickup.Folder, error) { m.logger.Debug("Getting folders for a space", "space", spaceId) @@ -96,6 +164,26 @@ func (m *Api) GetFolders(spaceId string) ([]clickup.Folder, error) { return data, nil } +func (m *Api) syncFolders(entry cache.Entry) error { + spaceId := entry.Key.String() + m.logger.Debug("Sync folders for a space", "space", spaceId) + + client := m.Clickup + + m.logger.Debugf("Fetching folders from API") + data, err := client.GetFolders(spaceId) + if err != nil { + return err + } + m.logger.Debugf("Found %d folders for space: %s", len(data), spaceId) + + entry.UpdatedTs = time.Now().Unix() + entry.Value = data + m.Cache.Update(entry) + + return nil +} + func (m *Api) GetLists(folderId string) ([]clickup.List, error) { m.logger.Debug("Getting lists for a folder", "folderId", folderId) @@ -111,6 +199,26 @@ func (m *Api) GetLists(folderId string) ([]clickup.List, error) { return data, nil } +func (m *Api) syncLists(entry cache.Entry) error { + folderId := entry.Key.String() + m.logger.Debug("Getting lists for a folder", "folderId", folderId) + + client := m.Clickup + + m.logger.Debugf("Fetching lists from API") + data, err := client.GetListsFromFolder(folderId) + if err != nil { + return err + } + m.logger.Debugf("Found %d lists for folder: %s", len(data), folderId) + + entry.UpdatedTs = time.Now().Unix() + entry.Value = data + m.Cache.Update(entry) + + return nil +} + func (m *Api) GetTask(taskId string) (clickup.Task, error) { m.logger.Debug("Getting a task", "taskId", taskId) @@ -126,6 +234,26 @@ func (m *Api) GetTask(taskId string) (clickup.Task, error) { return data, nil } +func (m *Api) syncTask(entry cache.Entry) error { + taskId := entry.Key.String() + m.logger.Debug("Sync a task", "taskId", taskId) + + client := m.Clickup + + m.logger.Debug("Fetching task from API") + data, err := client.GetTask(taskId) + if err != nil { + return err + } + m.logger.Debug("Found task", "task", taskId) + + entry.UpdatedTs = time.Now().Unix() + entry.Value = data + m.Cache.Update(entry) + + return nil +} + func (m *Api) GetTasksFromList(listId string) ([]clickup.Task, error) { m.logger.Debug("Getting tasks for a list", "listId", listId) @@ -156,6 +284,26 @@ func (m *Api) GetTasksFromView(viewId string) ([]clickup.Task, error) { return data, nil } +func (m *Api) syncTasksFromView(entry cache.Entry) error { + viewId := entry.Key.String() + m.logger.Debug("Sync tasks for a view", "viewId", viewId) + + client := m.Clickup + + m.logger.Debug("Fetching tasks from API") + data, err := client.GetTasksFromView(viewId) + if err != nil { + return err + } + m.logger.Debugf("Found %d tasks in view %s", len(data), viewId) + + entry.UpdatedTs = time.Now().Unix() + entry.Value = data + m.Cache.Update(entry) + + return nil +} + func (m *Api) GetViewsFromFolder(folderId string) ([]clickup.View, error) { m.logger.Debug("Getting views for folder", "folder", folderId) @@ -265,31 +413,48 @@ func (m *Api) InvalidateCache() error { entries := m.Cache.GetEntries() m.logger.Debug("Found cache entries", "count", len(entries)) - if err := m.Cache.Invalidate(); err != nil { - m.logger.Error("Failed to invalidate cache", "error", err) - return err - } + // if err := m.Cache.Invalidate(); err != nil { + // m.logger.Error("Failed to invalidate cache", "error", err) + // return err + // } + now := time.Now().Unix() for _, entry := range entries { - m.logger.Debug("Invalidating cache", "namespace", entry.Namespace, "key", entry.Key.String()) + if entry.UpdatedTs+TTL > now { + continue + } + var err error + + m.logger.Debug("Invalidating cache", "namespace", entry.Namespace, "key", entry.Key.String()) + switch entry.Namespace { case CacheNamespaceTeams: - _, err = m.GetTeams() + err = m.syncTeams(entry) case CacheNamespaceSpaces: - _, err = m.GetSpaces(entry.Key.String()) + err = m.syncSpaces(entry) case CacheNamespaceFolders: - _, err = m.GetFolders(entry.Key.String()) + err = m.syncFolders(entry) case CacheNamespaceLists: - _, err = m.GetLists(entry.Key.String()) - case CacheNamespaceViews: - _, err = m.GetViewsFromSpace(entry.Key.String()) - case CacheNamespaceTasksList: - _, err = m.GetTasksFromList(entry.Key.String()) + err = m.syncLists(entry) + + // TODO: + // case CacheNamespaceViews: + // m.logger.Debug("Invalidating views cache") + // _, err := m.GetViewsFromSpace(entry.Key) + // if err != nil { + // m.logger.Error("Failed to invalidate views cache", "error", err) + // } + // case CacheNamespaceTasksList: + // m.logger.Debug("Invalidating tasks cache") + // _, err := m.GetTasksFromList(entry.Key) + // if err != nil { + // m.logger.Error("Failed to invalidate tasks cache", "error", err) + // } case CacheNamespaceTasksView: - _, err = m.GetTasksFromView(entry.Key.String()) + err = m.syncTasksFromView(entry) case CacheNamespaceTasks: - _, err = m.GetTask(entry.Key.String()) + err = m.syncTask(entry) } if err != nil { diff --git a/main.go b/main.go index 2f139cf..592b986 100644 --- a/main.go +++ b/main.go @@ -149,6 +149,7 @@ func main() { logger.Info("Initializing program...") p := tea.NewProgram(mainModel, tea.WithAltScreen()) if _, err := p.Run(); err != nil { + cache.Close() termLogger.Fatal(err) } } diff --git a/ui/common/commands.go b/ui/common/commands.go index 1181e6e..8e1d379 100644 --- a/ui/common/commands.go +++ b/ui/common/commands.go @@ -91,3 +91,19 @@ func ErrCmd(err ErrMsg) tea.Cmd { return err } } + +type UITickMsg int64 + +func UITickCmd(ts int64) tea.Cmd { + return func() tea.Msg { + return UITickMsg(ts) + } +} + +type RefreshMsg string + +func RefreshCmd() tea.Cmd { + return func() tea.Msg { + return RefreshMsg("") + } +} diff --git a/ui/components/tasks-sidebar/tasksidebar.go b/ui/components/tasks-sidebar/tasksidebar.go index 13dabba..86b4578 100644 --- a/ui/components/tasks-sidebar/tasksidebar.go +++ b/ui/components/tasks-sidebar/tasksidebar.go @@ -146,15 +146,19 @@ func (m Model) renderTask(task clickup.Task) string { divider := strings.Repeat("-", runewidth.StringWidth(header)) s.WriteString(divider) - r, _ := glamour.NewTermRenderer( + r, err := glamour.NewTermRenderer( glamour.WithAutoStyle(), glamour.WithWordWrap(m.viewport.Width), ) + if err != nil { + return err.Error() + } out, err := r.Render(task.MarkdownDescription) if err != nil { return err.Error() } + s.WriteString(out) return s.String() diff --git a/ui/ui.go b/ui/ui.go index 2be09ff..44e4b38 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -2,6 +2,7 @@ package ui import ( "strings" + "time" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" @@ -73,6 +74,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { "width", msg.Width, "height", msg.Height) m.ctx.WindowSize.Set(msg.Width, msg.Height) + + case common.UITickMsg: + ts := int64(msg) + if time.Now().Unix() > ts { + m.log.Debug("Fire refresh tick") + cmds = append(cmds, common.UITickCmd(time.Now().Unix()+3)) + cmds = append(cmds, common.RefreshCmd()) + return m, tea.Batch(cmds...) + } + cmds = append(cmds, common.UITickCmd(ts)) } cmds = append(cmds, @@ -128,5 +139,6 @@ func (m Model) Init() tea.Cmd { return tea.Batch( m.viewCompact.Init(), m.dialogHelp.Init(), + common.UITickCmd(time.Now().Unix()+3), ) } diff --git a/ui/views/compact/compact.go b/ui/views/compact/compact.go index abd4def..22ead84 100644 --- a/ui/views/compact/compact.go +++ b/ui/views/compact/compact.go @@ -18,19 +18,24 @@ import ( const id = "Compact" -type Model struct { - id common.Id - ctx *context.UserContext - log *log.Logger - state common.Id - size common.Size +type State struct { + Widget common.WidgetId + View string +} +type Model struct { + ctx *context.UserContext + ViewId common.ViewId + log *log.Logger + state State spinner spinner.Model + size common.Size showSpinner bool widgetNavigator *navigator.Model widgetViewsTabs *viewstabs.Model widgetTasks *tasks.Model + } func (m Model) Size() common.Size { @@ -50,7 +55,7 @@ func (m Model) Init() tea.Cmd { } func (m Model) Help() help.KeyMap { - switch m.state { + switch m.state.Widget { case m.widgetNavigator.Id(): return m.widgetNavigator.Help() case m.widgetViewsTabs.Id(): @@ -80,7 +85,7 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { case tea.KeyMsg: switch keypress := msg.String(); keypress { case "tab": - switch m.state { + switch m.state.Widget { case m.widgetNavigator.Id(): m.state = m.widgetTasks.Id() m.widgetTasks.SetFocused(true) @@ -99,8 +104,7 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { } } - // m.getActiveElement(). - switch m.state { + switch m.state.Widget { case m.widgetNavigator.Id(): cmd = m.widgetNavigator.Update(msg) case m.widgetViewsTabs.Id(): @@ -134,14 +138,19 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { } if len(views) == 0 { - m.widgetTasks.SetTasks(nil) + if err := m.widgetTasks.SetTasks(nil); err != nil { + cmds = append(cmds, common.ErrCmd(err)) + return m, tea.Batch(cmds...) + } + m.widgetViewsTabs.SetTabs(nil) } else { tabs := viewsToTabs(views) m.widgetViewsTabs.SetTabs(tabs) initTab := m.widgetViewsTabs.SelectedTab - if err := m.reloadTasks(initTab); err != nil { + m.state.View = initTab + if err := m.widgetTasks.ReloadTasks(initTab); err != nil { cmds = append(cmds, common.ErrCmd(err)) return tea.Batch(cmds...) } @@ -192,7 +201,7 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { id := string(msg) m.log.Info("Received: ListChangeMsg", "id", id) // TODO: make state change as func - m.state = m.widgetTasks.Id() + m.state.Widget = m.widgetTasks.Id() m.widgetTasks.SetFocused(true) m.widgetViewsTabs.SetFocused(false) m.widgetNavigator.SetFocused(false) @@ -212,23 +221,43 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { if id == "" { m.log.Info("Received: LoadingTasksFromViewMsg empty") - m.widgetTasks.SetTasks(nil) + if err := m.widgetTasks.SetTasks(nil); err != nil { + cmds = append(cmds, common.ErrCmd(err)) + return m, tea.Batch(cmds...) + } + break } + m.state.View = id m.log.Info("Received: LoadingTasksFromViewMsg", "id", id) - if err := m.reloadTasks(id); err != nil { + if err := m.widgetTasks.ReloadTasks(id); err != nil { cmds = append(cmds, common.ErrCmd(err)) return tea.Batch(cmds...) } case tasks.LostFocusMsg: m.log.Info("Received: tasks.LostFocusMsg") - m.state = m.widgetNavigator.Id() + m.state.Widget = m.widgetNavigator.Id() m.widgetTasks.SetFocused(false) m.widgetViewsTabs.SetFocused(false) m.widgetNavigator.SetFocused(true) m.widgetViewsTabs.Path = m.widgetNavigator.GetPath() + + case common.RefreshMsg: + m.log.Info("Received: common.RefreshMsg") + if m.state.View == "" { + if err := m.widgetTasks.SetTasks(nil); err != nil { + cmds = append(cmds, common.ErrCmd(err)) + return m, tea.Batch(cmds...) + } + + break + } + if err := m.widgetTasks.ReloadTasks(m.state.View); err != nil { + cmds = append(cmds, common.ErrCmd(err)) + return m, tea.Batch(cmds...) + } } cmd = m.widgetNavigator.Update(msg) @@ -305,7 +334,9 @@ func InitialModel(ctx *context.UserContext, logger *log.Logger) Model { widgetViewsTabs: &widgetViewsTabs, widgetNavigator: &widgetNavigator, widgetTasks: &widgetTasks, - state: widgetNavigator.Id(), + state: State{ + Widget: widgetNavigator.Id(), + }, } } @@ -321,15 +352,6 @@ func viewsToTabs(views []clickup.View) []viewstabs.Tab { return tabs } -func (m *Model) reloadTasks(viewId string) error { - tasks, err := m.ctx.Api.GetTasksFromView(viewId) - if err != nil { - return err - } - m.widgetTasks.SetTasks(tasks) - return nil -} - func (m *Model) handleWorkspaceChangePreview(id string) tea.Cmd { views, err := m.ctx.Api.GetViewsFromWorkspace(id) if err != nil { @@ -385,16 +407,3 @@ func (m *Model) handleListChangePreview(id string) tea.Cmd { return LoadingTasksFromViewCmd(initTab) } - -// func (m *Model) getActiveElement() common.UIElement { -// switch m.state { -// case m.widgetNavigator.Id(): -// return m.widgetNavigator -// case m.widgetViewsTabs.Id(): -// return m.widgetViewsTabs -// case m.widgetTasks.Id(): -// return m.widgetTasks -// default: -// return nil -// } -// } diff --git a/ui/widgets/tasks/tasks.go b/ui/widgets/tasks/tasks.go index 149db8a..de416a7 100644 --- a/ui/widgets/tasks/tasks.go +++ b/ui/widgets/tasks/tasks.go @@ -223,7 +223,8 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { for _, task := range tasks { m.log.Debug("Opening task in the web browser", "url", task.Url) if err := common.OpenUrlInWebBrowser(task.Url); err != nil { - m.log.Fatal(err) + cmds = append(cmds, common.ErrCmd(err)) + return m, tea.Batch(cmds...) } } @@ -238,7 +239,8 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { task := m.componenetTasksTable.GetHighlightedTask() m.log.Debug("Opening task in the web browser", "url", task.Url) if err := common.OpenUrlInWebBrowser(task.Url); err != nil { - m.log.Fatal(err) + cmds = append(cmds, common.ErrCmd(err)) + return m, tea.Batch(cmds...) } case key.Matches(msg, m.keyMap.ToggleSidebar): @@ -299,6 +301,12 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { } cmds = append(cmds, cmd) + + case spinner.TickMsg: + if m.showSpinner { + m.spinner, cmd = m.spinner.Update(msg) + cmds = append(cmds, cmd) + } } cmds = append(cmds, @@ -309,20 +317,19 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { return tea.Batch(cmds...) } -func (m *Model) SetTasks(tasks []clickup.Task) { +func (m *Model) SetTasks(tasks []clickup.Task) error { m.showSpinner = false m.componenetTasksTable.SetTasks(tasks) if len(tasks) == 0 { m.componenetTasksSidebar.SetHidden(true) - return + return nil } // TODO: check if it should yield at all or move it to cmd id := tasks[0].Id - if err := m.componenetTasksSidebar.SetTask(id); err != nil { - m.log.Fatal(err) - } + + return m.componenetTasksSidebar.SetTask(id) } func (m Model) View() string { @@ -462,6 +469,25 @@ func (m *Model) SetSize(s common.Size) { m.size = s } +func (m *Model) ReloadTasks(viewId string) error { + tasks, err := m.ctx.Api.GetTasksFromView(viewId) + if err != nil { + return err + } + + m.showSpinner = false + m.componenetTasksTable.SetTasks(tasks) + + if len(tasks) == 0 { + m.componenetTasksSidebar.SetHidden(true) + return nil + } + + id := m.componenetTasksSidebar.SelectedTask.Id + + return m.componenetTasksSidebar.SetTask(id) +} + func (m *Model) Init() error { m.log.Info("Initializing...") return nil