Add time page

This commit is contained in:
Martin
2026-02-01 21:30:19 +01:00
committed by Martin Pander
parent effd95f6c1
commit 681ed7e635
12 changed files with 915 additions and 115 deletions

View File

@ -26,25 +26,30 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage {
}
selected := common.TW.GetActiveContext().Name
options := make([]string, 0)
for _, c := range p.contexts {
if c.Name != "none" {
options = append(options, c.Name)
itemProvider := func() []list.Item {
contexts := common.TW.GetContexts()
options := make([]string, 0)
for _, c := range contexts {
if c.Name != "none" {
options = append(options, c.Name)
}
}
}
slices.Sort(options)
options = append([]string{"(none)"}, options...)
slices.Sort(options)
options = append([]string{"(none)"}, options...)
items := []list.Item{}
for _, opt := range options {
items = append(items, picker.NewItem(opt))
items := []list.Item{}
for _, opt := range options {
items = append(items, picker.NewItem(opt))
}
return items
}
onSelect := func(item list.Item) tea.Cmd {
return func() tea.Msg { return contextSelectedMsg{item: item} }
}
p.picker = picker.New(common, "Contexts", items, onSelect)
p.picker = picker.New(common, "Contexts", itemProvider, onSelect)
// Set active context
if selected == "" {
@ -87,7 +92,7 @@ func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
case contextSelectedMsg:
name := msg.item.(picker.Item).Title()
name := msg.item.FilterValue() // Use FilterValue (which is the name/text)
if name == "(none)" {
name = ""
}
@ -101,14 +106,16 @@ func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return model, func() tea.Msg { return UpdateContextMsg(ctx) }
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
if !p.picker.IsFiltering() {
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
}
return model, BackCmd
}
}
@ -134,4 +141,4 @@ func (p *ContextPickerPage) View() string {
)
}
type UpdateContextMsg *taskwarrior.Context
type UpdateContextMsg *taskwarrior.Context

View File

@ -1,14 +1,18 @@
package pages
import (
tea "github.com/charmbracelet/bubbletea"
"tasksquire/common"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
)
type MainPage struct {
common *common.Common
activePage common.Component
taskPage common.Component
timePage common.Component
}
func NewMainPage(common *common.Common) *MainPage {
@ -16,15 +20,16 @@ func NewMainPage(common *common.Common) *MainPage {
common: common,
}
m.activePage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
// m.activePage = NewTaskEditorPage(common, taskwarrior.Task{})
m.taskPage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
m.timePage = NewTimePage(common)
m.activePage = m.taskPage
return m
}
func (m *MainPage) Init() tea.Cmd {
return m.activePage.Init()
return tea.Batch(m.taskPage.Init(), m.timePage.Init())
}
func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@ -33,6 +38,19 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.common.SetSize(msg.Width, msg.Height)
case tea.KeyMsg:
if key.Matches(msg, m.common.Keymap.Next) {
if m.activePage == m.taskPage {
m.activePage = m.timePage
} else {
m.activePage = m.taskPage
}
// Re-size the new active page just in case
m.activePage.SetSize(m.common.Width(), m.common.Height())
// Trigger a refresh/init on switch? Maybe not needed if we keep state.
// But we might want to refresh data.
return m, m.activePage.Init()
}
}
activePage, cmd := m.activePage.Update(msg)

View File

@ -21,17 +21,25 @@ func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectP
common: common,
}
projects := common.TW.GetProjects()
items := []list.Item{picker.NewItem("(none)")}
for _, proj := range projects {
items = append(items, picker.NewItem(proj))
itemProvider := func() []list.Item {
projects := common.TW.GetProjects()
items := []list.Item{picker.NewItem("(none)")}
for _, proj := range projects {
items = append(items, picker.NewItem(proj))
}
return items
}
onSelect := func(item list.Item) tea.Cmd {
return func() tea.Msg { return projectSelectedMsg{item: item} }
}
p.picker = picker.New(common, "Projects", items, onSelect)
// onCreate := func(name string) tea.Cmd {
// return func() tea.Msg { return projectSelectedMsg{item: picker.NewItem(name)} }
// }
// p.picker = picker.New(common, "Projects", itemProvider, onSelect, picker.WithOnCreate(onCreate))
p.picker = picker.New(common, "Projects", itemProvider, onSelect)
// Set active project
if activeProject == "" {
@ -74,7 +82,7 @@ func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
case projectSelectedMsg:
proj := msg.item.(picker.Item).Title()
proj := msg.item.FilterValue() // Use FilterValue (text)
if proj == "(none)" {
proj = ""
}
@ -86,14 +94,16 @@ func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return model, func() tea.Msg { return UpdateProjectMsg(proj) }
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
if !p.picker.IsFiltering() {
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
}
return model, BackCmd
}
}
@ -123,4 +133,4 @@ func (p *ProjectPickerPage) updateProjectCmd() tea.Msg {
return nil
}
type UpdateProjectMsg string
type UpdateProjectMsg string

View File

@ -4,18 +4,19 @@ import (
"log/slog"
"slices"
"tasksquire/common"
"tasksquire/components/picker"
"tasksquire/taskwarrior"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
type ReportPickerPage struct {
common *common.Common
reports taskwarrior.Reports
form *huh.Form
picker *picker.Picker
}
func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report) *ReportPickerPage {
@ -24,27 +25,29 @@ func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report
reports: common.TW.GetReports(),
}
selected := activeReport.Name
itemProvider := func() []list.Item {
options := make([]string, 0)
for _, r := range p.reports {
options = append(options, r.Name)
}
slices.Sort(options)
options := make([]string, 0)
for _, r := range p.reports {
options = append(options, r.Name)
items := []list.Item{}
for _, opt := range options {
items = append(items, picker.NewItem(opt))
}
return items
}
slices.Sort(options)
p.form = huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Key("report").
Options(huh.NewOptions(options...)...).
Title("Reports").
Description("Choose a report").
Value(&selected).
WithTheme(common.Styles.Form),
),
).
WithShowHelp(false).
WithShowErrors(false)
onSelect := func(item list.Item) tea.Cmd {
return func() tea.Msg { return reportSelectedMsg{item: item} }
}
p.picker = picker.New(common, "Reports", itemProvider, onSelect)
if activeReport != nil {
p.picker.SelectItemByFilterValue(activeReport.Name)
}
p.SetSize(common.Width(), common.Height())
@ -54,72 +57,76 @@ func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report
func (p *ReportPickerPage) SetSize(width, height int) {
p.common.SetSize(width, height)
if width >= 20 {
p.form = p.form.WithWidth(20)
} else {
p.form = p.form.WithWidth(width)
// Set list size with some padding/limits to look like a picker
listWidth := width - 4
if listWidth > 40 {
listWidth = 40
}
if height >= 30 {
p.form = p.form.WithHeight(30)
} else {
p.form = p.form.WithHeight(height)
listHeight := height - 6
if listHeight > 20 {
listHeight = 20
}
p.picker.SetSize(listWidth, listHeight)
}
func (p *ReportPickerPage) Init() tea.Cmd {
return p.form.Init()
return p.picker.Init()
}
type reportSelectedMsg struct {
item list.Item
}
func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
}
}
case reportSelectedMsg:
reportName := msg.item.FilterValue()
report := p.common.TW.GetReport(reportName)
f, cmd := p.form.Update(msg)
if f, ok := f.(*huh.Form); ok {
p.form = f
cmds = append(cmds, cmd)
}
if p.form.State == huh.StateCompleted {
cmds = append(cmds, []tea.Cmd{BackCmd, p.updateReportCmd}...)
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, tea.Batch(cmds...)
return model, func() tea.Msg { return UpdateReportMsg(report) }
case tea.KeyMsg:
if !p.picker.IsFiltering() {
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
}
}
}
return p, tea.Batch(cmds...)
_, cmd = p.picker.Update(msg)
return p, cmd
}
func (p *ReportPickerPage) View() string {
width := p.common.Width() - 4
if width > 40 {
width = 40
}
content := p.picker.View()
styledContent := lipgloss.NewStyle().Width(width).Render(content)
return lipgloss.Place(
p.common.Width(),
p.common.Height(),
lipgloss.Center,
lipgloss.Center,
p.common.Styles.Base.Render(p.form.View()),
p.common.Styles.Base.Render(styledContent),
)
}
func (p *ReportPickerPage) updateReportCmd() tea.Msg {
return UpdateReportMsg(p.common.TW.GetReport(p.form.GetString("report")))
}
type UpdateReportMsg *taskwarrior.Report
type UpdateReportMsg *taskwarrior.Report

161
pages/timePage.go Normal file
View File

@ -0,0 +1,161 @@
package pages
import (
"tasksquire/common"
"tasksquire/components/timetable"
"tasksquire/timewarrior"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
)
type TimePage struct {
common *common.Common
intervals timetable.Model
data timewarrior.Intervals
shouldSelectActive bool
}
func NewTimePage(com *common.Common) *TimePage {
p := &TimePage{
common: com,
}
p.populateTable(timewarrior.Intervals{})
return p
}
func (p *TimePage) Init() tea.Cmd {
return tea.Batch(p.getIntervals(), doTick())
}
func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
case intervalsMsg:
p.data = timewarrior.Intervals(msg)
p.populateTable(p.data)
case tickMsg:
cmds = append(cmds, p.getIntervals())
cmds = append(cmds, doTick())
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Quit):
return p, tea.Quit
case key.Matches(msg, p.common.Keymap.StartStop):
row := p.intervals.SelectedRow()
if row != nil {
interval := (*timewarrior.Interval)(row)
if interval.IsActive() {
p.common.TimeW.StopTracking()
} else {
p.common.TimeW.ContinueInterval(interval.ID)
p.shouldSelectActive = true
}
return p, tea.Batch(p.getIntervals(), doTick())
}
case key.Matches(msg, p.common.Keymap.Delete):
row := p.intervals.SelectedRow()
if row != nil {
interval := (*timewarrior.Interval)(row)
p.common.TimeW.DeleteInterval(interval.ID)
return p, tea.Batch(p.getIntervals(), doTick())
}
}
}
var cmd tea.Cmd
p.intervals, cmd = p.intervals.Update(msg)
cmds = append(cmds, cmd)
return p, tea.Batch(cmds...)
}
func (p *TimePage) View() string {
if len(p.data) == 0 {
return p.common.Styles.Base.Render("No intervals found for today")
}
return p.intervals.View()
}
func (p *TimePage) SetSize(width int, height int) {
p.common.SetSize(width, height)
p.intervals.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize())
p.intervals.SetHeight(height - p.common.Styles.Base.GetVerticalFrameSize())
}
func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
var selectedStart string
currentIdx := p.intervals.Cursor()
if row := p.intervals.SelectedRow(); row != nil {
selectedStart = row.Start
}
columns := []timetable.Column{
{Title: "ID", Name: "id", Width: 4},
{Title: "Start", Name: "start", Width: 16},
{Title: "End", Name: "end", Width: 16},
{Title: "Duration", Name: "duration", Width: 10},
{Title: "Tags", Name: "tags", Width: 0}, // flexible width
}
p.intervals = timetable.New(
p.common,
timetable.WithColumns(columns),
timetable.WithIntervals(intervals),
timetable.WithFocused(true),
timetable.WithWidth(p.common.Width()-p.common.Styles.Base.GetVerticalFrameSize()),
timetable.WithHeight(p.common.Height()-p.common.Styles.Base.GetHorizontalFrameSize()),
timetable.WithStyles(p.common.Styles.TableStyle),
)
if len(intervals) > 0 {
newIdx := -1
if p.shouldSelectActive {
for i, interval := range intervals {
if interval.IsActive() {
newIdx = i
break
}
}
p.shouldSelectActive = false
}
if newIdx == -1 && selectedStart != "" {
for i, interval := range intervals {
if interval.Start == selectedStart {
newIdx = i
break
}
}
}
if newIdx == -1 {
newIdx = currentIdx
}
if newIdx >= len(intervals) {
newIdx = len(intervals) - 1
}
if newIdx < 0 {
newIdx = 0
}
p.intervals.SetCursor(newIdx)
}
}
type intervalsMsg timewarrior.Intervals
func (p *TimePage) getIntervals() tea.Cmd {
return func() tea.Msg {
// ":day" is a timewarrior hint for "today"
intervals := p.common.TimeW.GetIntervals(":day")
return intervalsMsg(intervals)
}
}