feat: stamp CLOSED for any configured done state

This commit is contained in:
Taybin Rutkin 2026-07-21 17:48:21 -04:00
parent 5b10f4e229
commit 517b60ecd9
No known key found for this signature in database
GPG key ID: 605FA2C570B6AC9E
2 changed files with 82 additions and 63 deletions

View file

@ -103,7 +103,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if len(items) > 0 && m.cursor < len(items) { if len(items) > 0 && m.cursor < len(items) {
m.cycleStateBackward(items[m.cursor]) m.cycleStateBackward(items[m.cursor])
// Auto clock out when changing to DONE // Auto clock out when changing to DONE
if items[m.cursor].State == model.StateDONE && items[m.cursor].IsClockedIn() { if m.config.IsDoneState(string(items[m.cursor].State)) && items[m.cursor].IsClockedIn() {
items[m.cursor].ClockOut() items[m.cursor].ClockOut()
} }
m.setStatus("State changed") m.setStatus("State changed")
@ -114,8 +114,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if len(items) > 0 && m.cursor < len(items) { if len(items) > 0 && m.cursor < len(items) {
m.cycleStateForward(items[m.cursor]) m.cycleStateForward(items[m.cursor])
// Auto clock out when changing to last state (typically DONE) // Auto clock out when changing to last state (typically DONE)
stateNames := m.config.GetStateNames() if m.config.IsDoneState(string(items[m.cursor].State)) && items[m.cursor].IsClockedIn() {
if len(stateNames) > 0 && string(items[m.cursor].State) == stateNames[len(stateNames)-1] && items[m.cursor].IsClockedIn() {
items[m.cursor].ClockOut() items[m.cursor].ClockOut()
} }
m.setStatus("State changed") m.setStatus("State changed")
@ -138,8 +137,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if len(items) > 0 && m.cursor < len(items) { if len(items) > 0 && m.cursor < len(items) {
m.cycleStateForward(items[m.cursor]) m.cycleStateForward(items[m.cursor])
// Auto clock out when changing to last state (typically DONE) // Auto clock out when changing to last state (typically DONE)
stateNames := m.config.GetStateNames() if m.config.IsDoneState(string(items[m.cursor].State)) && items[m.cursor].IsClockedIn() {
if len(stateNames) > 0 && string(items[m.cursor].State) == stateNames[len(stateNames)-1] && items[m.cursor].IsClockedIn() {
items[m.cursor].ClockOut() items[m.cursor].ClockOut()
} }
m.setStatus("State changed") m.setStatus("State changed")
@ -870,6 +868,30 @@ func (m uiModel) updateSetEffort(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
// stripClosedNotes removes any note line that is a CLOSED: planning entry.
func stripClosedNotes(notes []string) []string {
var filtered []string
for _, note := range notes {
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
filtered = append(filtered, note)
}
}
return filtered
}
// applyClosedTransition stamps or clears the CLOSED timestamp based on whether
// the item entered or left the set of done states.
func applyClosedTransition(item *model.Item, wasDone, isDone bool, now time.Time) {
switch {
case isDone && !wasDone:
item.Closed = &now
item.Notes = stripClosedNotes(item.Notes)
case wasDone && !isDone:
item.Closed = nil
item.Notes = stripClosedNotes(item.Notes)
}
}
func (m *uiModel) cycleStateForward(item *model.Item) { func (m *uiModel) cycleStateForward(item *model.Item) {
stateNames := m.config.GetStateNames() stateNames := m.config.GetStateNames()
if len(stateNames) == 0 { if len(stateNames) == 0 {
@ -879,7 +901,6 @@ func (m *uiModel) cycleStateForward(item *model.Item) {
// Find current state index // Find current state index
currentIndex := -1 currentIndex := -1
currentState := string(item.State) currentState := string(item.State)
lastStateIndex := len(stateNames) - 1
// Handle empty state // Handle empty state
if currentState == "" { if currentState == "" {
@ -911,34 +932,8 @@ func (m *uiModel) cycleStateForward(item *model.Item) {
// Update the item state // Update the item state
item.State = model.TodoState(newState) item.State = model.TodoState(newState)
// Manage CLOSED timestamp // Manage CLOSED timestamp based on the configured done-state set.
wasInDoneState := (oldState == stateNames[lastStateIndex]) applyClosedTransition(item, m.config.IsDoneState(oldState), m.config.IsDoneState(newState), time.Now())
isInDoneState := (newState == stateNames[lastStateIndex])
if isInDoneState && !wasInDoneState {
// Moving TO done state - add CLOSED timestamp
now := time.Now()
item.Closed = &now
// Remove any existing CLOSED line from notes
var filteredNotes []string
for _, note := range item.Notes {
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
filteredNotes = append(filteredNotes, note)
}
}
item.Notes = filteredNotes
} else if wasInDoneState && !isInDoneState {
// Moving FROM done state - remove CLOSED timestamp
item.Closed = nil
// Remove any existing CLOSED line from notes
var filteredNotes []string
for _, note := range item.Notes {
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
filteredNotes = append(filteredNotes, note)
}
}
item.Notes = filteredNotes
}
} }
func (m *uiModel) cycleStateBackward(item *model.Item) { func (m *uiModel) cycleStateBackward(item *model.Item) {
@ -950,7 +945,6 @@ func (m *uiModel) cycleStateBackward(item *model.Item) {
// Find current state index // Find current state index
currentIndex := -1 currentIndex := -1
currentState := string(item.State) currentState := string(item.State)
lastStateIndex := len(stateNames) - 1
// Handle empty state // Handle empty state
if currentState == "" { if currentState == "" {
@ -980,34 +974,8 @@ func (m *uiModel) cycleStateBackward(item *model.Item) {
// Update the item state // Update the item state
item.State = model.TodoState(newState) item.State = model.TodoState(newState)
// Manage CLOSED timestamp // Manage CLOSED timestamp based on the configured done-state set.
wasInDoneState := (oldState == stateNames[lastStateIndex]) applyClosedTransition(item, m.config.IsDoneState(oldState), m.config.IsDoneState(newState), time.Now())
isInDoneState := (newState == stateNames[lastStateIndex])
if isInDoneState && !wasInDoneState {
// Moving TO done state - add CLOSED timestamp
now := time.Now()
item.Closed = &now
// Remove any existing CLOSED line from notes
var filteredNotes []string
for _, note := range item.Notes {
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
filteredNotes = append(filteredNotes, note)
}
}
item.Notes = filteredNotes
} else if wasInDoneState && !isInDoneState {
// Moving FROM done state - remove CLOSED timestamp
item.Closed = nil
// Remove any existing CLOSED line from notes
var filteredNotes []string
for _, note := range item.Notes {
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
filteredNotes = append(filteredNotes, note)
}
}
item.Notes = filteredNotes
}
} }
func (m *uiModel) deleteItem(item *model.Item) { func (m *uiModel) deleteItem(item *model.Item) {

51
internal/ui/modes_test.go Normal file
View file

@ -0,0 +1,51 @@
package ui
import (
"testing"
"time"
"github.com/rwejlgaard/org/internal/model"
)
func TestApplyClosedTransitionEntersDone(t *testing.T) {
now := time.Date(2026, 7, 21, 14, 30, 0, 0, time.UTC)
item := &model.Item{Notes: []string{"a note", "CLOSED: [old stamp]"}}
applyClosedTransition(item, false, true, now)
if item.Closed == nil || !item.Closed.Equal(now) {
t.Fatalf("expected Closed set to now, got %v", item.Closed)
}
if len(item.Notes) != 1 || item.Notes[0] != "a note" {
t.Fatalf("expected stale CLOSED note stripped, got %v", item.Notes)
}
}
func TestApplyClosedTransitionLeavesDone(t *testing.T) {
now := time.Now()
stamp := now
item := &model.Item{Closed: &stamp, Notes: []string{" CLOSED: [x]", "keep"}}
applyClosedTransition(item, true, false, now)
if item.Closed != nil {
t.Fatalf("expected Closed cleared, got %v", item.Closed)
}
if len(item.Notes) != 1 || item.Notes[0] != "keep" {
t.Fatalf("expected CLOSED note stripped, got %v", item.Notes)
}
}
func TestApplyClosedTransitionDoneToDoneUnchanged(t *testing.T) {
orig := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC)
item := &model.Item{Closed: &orig, Notes: []string{"keep"}}
applyClosedTransition(item, true, true, time.Now())
if item.Closed == nil || !item.Closed.Equal(orig) {
t.Fatalf("done->done must keep original Closed, got %v", item.Closed)
}
if len(item.Notes) != 1 || item.Notes[0] != "keep" {
t.Fatalf("done->done must not touch notes, got %v", item.Notes)
}
}