Skip to content

Commit ebe6ed4

Browse files
feat: v1.7.0 — task results, open issue IDs, responsible assignment, project-scoped export, UI stabilization
Added: - Task Result / Completion Report field with result editor dialog (press z on completed tasks) - Open Issue ID system (OI000001, OI000002…) with auto-increment counter and post-migration backfill - Responsible person assignment field in task editor - Project-scoped export (all projects or current project) - Parent project inheritance for child tasks - Storage migration v7 (result, open_issue_id, responsible columns + meta table) - JSON/CSV serialization for all new fields - Tests for OpenIssueID generation, result/responsible round-trip, parent project inheritance, patch fields Fixed: - Task tree alignment — root leaf tasks now maintain consistent left margin - Title width overflow — computed dynamically from actual rendered content instead of magic -40 budget - Detail view label alignment — computed from max label width instead of hardcoded 10 chars - FillViewport background — removed width+1 off-by-one hack and extra bottom padding line - Modal overlay widths — responsive min(60, width-4) instead of fixed 60 Changed: - UI layout engine — deterministic row rendering with pure-function-of-state layout - Removed duplicate min/max utilities (Go 1.21+ builtins) - Cleaned up redundant comments and dead stubs across codebase - Version bumped from 1.6.6 to 1.7.0
1 parent 0ab5f93 commit ebe6ed4

19 files changed

Lines changed: 687 additions & 227 deletions

File tree

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.7.0] (2026-06-03)
9+
10+
### Added
11+
- **Task Result / Completion Report**: New `result` field for documenting task outcomes. Press `z` on a completed task to open the result editor dialog; results are visible in the detail view and included in exports.
12+
- **Open Issue ID System**: Every task now receives a human-friendly ID (`OI000001`, `OI000002`…) in addition to the internal hash ID. Displayed in the detail view and configurable as a list column via `open_issue_id` in `[list.order] right`.
13+
- **Responsible Person Assignment**: New `responsible` text field in the task editor for tracking task ownership. Visible in the detail view and included in exports.
14+
- **Project-Scoped Export**: The export dialog now offers a scope selection — export all projects or limit to the current project.
15+
- **Parent Project Inheritance**: Child tasks automatically inherit the project from their parent task when no project is explicitly assigned.
16+
17+
### Changed
18+
- **Config**: Added `open_issue_id` to the default right-side column order and allowed field list.
19+
- **Storage**: Database schema migration v7 — adds `result`, `open_issue_id`, `responsible` columns and `meta` table.
20+
- **Code Cleanup**: Removed redundant comments, dead stub code, and developer notes across the codebase.
21+
- **UI Layout Engine**: Complete redesign of task list row layout — title width is now computed dynamically from actual rendered content widths instead of hardcoded magic number (`-40`).
22+
- **Detail View**: Metadata labels now use computed widths based on actual label content instead of hardcoded 10-character padding, ensuring alignment is maintained regardless of field name lengths.
23+
- **Modal Overlays**: Result editor and tag filter modals now use responsive widths (`min(60, width-4)`) instead of fixed 60 characters.
24+
- **FillViewport**: Removed `width+1` off-by-one guard hack and extra bottom padding line. Background filling now uses exact terminal dimensions.
25+
- **Duplicate Utilities**: Removed duplicate `min`/`max` functions from `editor/model.go` and `tasklist/model.go` (Go 1.21+ builtins used instead).
26+
27+
### Fixed
28+
- **Task Tree Alignment**: Root-level leaf tasks now maintain consistent left margin alignment, fixing a visual offset bug in tree rendering.
29+
- **Title Width Overflow**: Task titles no longer overflow into right-side metadata when right-side content is wider than the magic `-40` budget allowed.
30+
- **Detail Label Alignment**: Labels like "Issue ID" and "Resp" now align correctly regardless of which detail fields are visible.
31+
832
## [1.6.6] (2026-05-13)
933

1034
### Added

VERSION.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.6.6
1+
1.7.0

internal/api/api.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ type TaskDTO struct {
114114
Collapsed bool `json:"collapsed,omitempty"`
115115
CreatedAt string `json:"created_at"`
116116
UpdatedAt string `json:"updated_at"`
117+
Result string `json:"result,omitempty"`
118+
OpenIssueID string `json:"open_issue_id,omitempty"`
119+
Responsible string `json:"responsible,omitempty"`
117120
}
118121

119122
// toDTO converts a core.Task to a DTO for serialization
@@ -132,6 +135,9 @@ func toDTO(t core.Task) TaskDTO {
132135
Collapsed: t.Collapsed,
133136
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"),
134137
UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z"),
138+
Result: t.Result,
139+
OpenIssueID: t.OpenIssueID,
140+
Responsible: t.Responsible,
135141
}
136142
if t.Deadline != nil {
137143
s := t.Deadline.Format("2006-01-02T15:04:05Z")
@@ -513,15 +519,26 @@ func (api *TaskAPI) handleListTags(ctx context.Context) Response {
513519
// handleExport processes an export request
514520
func (api *TaskAPI) handleExport(ctx context.Context, payload json.RawMessage) Response {
515521
type ExportPayload struct {
516-
Format string `json:"format"`
522+
Format string `json:"format"`
523+
Project string `json:"project"`
517524
}
518525

519526
var p ExportPayload
520527
if err := json.Unmarshal(payload, &p); err != nil {
521528
p.Format = "json"
522529
}
523530

524-
tasks, err := api.service.ListAll(ctx)
531+
var tasks []core.Task
532+
var err error
533+
if p.Project != "" {
534+
tasks, err = api.service.List(ctx, core.Filter{
535+
Project: p.Project,
536+
Statuses: []core.Status{core.StatusTodo, core.StatusDoing, core.StatusDone},
537+
IncludeNilDeadline: true,
538+
})
539+
} else {
540+
tasks, err = api.service.ListAll(ctx)
541+
}
525542
if err != nil {
526543
return Response{Success: false, Error: err.Error()}
527544
}

internal/app/model.go

Lines changed: 106 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ const (
105105
ModeProjectSwitcher
106106
ModeProjectSidebar
107107
ModeFocus
108+
ModeResultEdit
108109
)
109110

110111
type Model struct {
@@ -151,6 +152,7 @@ type Model struct {
151152
mcpRunning bool
152153

153154
tagFilterInput textinput.Model // Input field for direct tag filtering in Tag View
155+
resultInput textinput.Model
154156

155157
palFullIdx *search.Index
156158
palTasksIdx *search.Index
@@ -241,6 +243,12 @@ func New(ctx context.Context, cfg config.Config, svc service.TaskService) (tea.M
241243
tagInput.CharLimit = 64
242244
tagInput.Width = 40
243245

246+
resultInput := textinput.New()
247+
resultInput.Prompt = "Result: "
248+
resultInput.Placeholder = "Describe task outcome..."
249+
resultInput.CharLimit = 500
250+
resultInput.Width = 60
251+
244252
m := &Model{
245253
ctx: ctx,
246254
cfg: cfg,
@@ -252,6 +260,7 @@ func New(ctx context.Context, cfg config.Config, svc service.TaskService) (tea.M
252260
mode: ModeList,
253261
activeProject: cfg.App.ActiveProject,
254262
tagFilterInput: tagInput,
263+
resultInput: resultInput,
255264
RainbowAnimationOffset: 0,
256265
}
257266
m.list = tasklist.New(m.s, cfg.App.VimMode, cfg.App.Animations, m.km, cfg.List.Fields.Due.Minimal)
@@ -432,16 +441,14 @@ func (m *Model) Init() tea.Cmd {
432441
func (m *Model) isInputFocused() bool {
433442
switch m.mode {
434443
case ModeEditor:
435-
// Editor has multiple text input fields that accept user input
436444
return true
437445
case ModePalette, ModeTagFilter, ModeProjectSwitcher:
438-
// Palette, TagFilter, and ProjectSwitcher have search input fields that are focused when active
439446
return true
440447
case ModeImportExport:
441-
// Import/Export menu has a file path input field
448+
return true
449+
case ModeResultEdit:
442450
return true
443451
default:
444-
// All other modes don't have active text input fields, so keybindings can safely fire
445452
return false
446453
}
447454
}
@@ -673,6 +680,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
673680
case import_export_menu.SelectMsg:
674681
if m.mode == ModeImportExport {
675682
m.mode = ModeList
683+
m.iem = m.iem.SetScope(x.Scope)
676684
var animCmd tea.Cmd
677685
if m.cfg.App.Animations {
678686
m.transitioning = m.cfg.App.Animations
@@ -1472,6 +1480,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
14721480
}
14731481
case keymapMatch(m.km.ToggleStrike, km):
14741482
selected := m.list.GetSelectedTasks()
1483+
if len(selected) == 1 && selected[0].Status == core.StatusDone {
1484+
m.resultInput.SetValue(selected[0].Result)
1485+
m.mode = ModeResultEdit
1486+
m.resultInput.Focus()
1487+
return m, nil
1488+
}
14751489
var cmds []tea.Cmd
14761490
for _, t := range selected {
14771491
newStatus := core.StatusDone
@@ -1519,6 +1533,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
15191533
}
15201534
if keymapMatch(m.km.ToggleStrike, km) {
15211535
t := m.det.Task()
1536+
if t.Status == core.StatusDone {
1537+
m.resultInput.SetValue(t.Result)
1538+
m.mode = ModeResultEdit
1539+
m.resultInput.Focus()
1540+
return m, nil
1541+
}
15221542
m.animationGen++
15231543
m.animationReverse = (t.Status == core.StatusDone)
15241544
m.animatingTaskID = t.ID
@@ -1545,6 +1565,36 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
15451565

15461566
// Delegate to active component.
15471567
switch m.mode {
1568+
case ModeResultEdit:
1569+
if km, ok := msg.(tea.KeyMsg); ok {
1570+
switch km.String() {
1571+
case "enter":
1572+
result := m.resultInput.Value()
1573+
taskID := ""
1574+
if m.mode == ModeDetail {
1575+
taskID = m.det.Task().ID
1576+
} else if item, ok := m.list.Selected(); ok {
1577+
taskID = item.ID
1578+
}
1579+
if taskID != "" {
1580+
patch := core.TaskPatch{Result: &result}
1581+
m.mode = ModeList
1582+
return m, m.updateTaskCmd(taskID, patch)
1583+
}
1584+
m.mode = ModeList
1585+
return m, nil
1586+
case "esc":
1587+
m.mode = ModeList
1588+
return m, nil
1589+
default:
1590+
var cmd tea.Cmd
1591+
m.resultInput, cmd = m.resultInput.Update(msg)
1592+
return m, cmd
1593+
}
1594+
}
1595+
var cmd tea.Cmd
1596+
m.resultInput, cmd = m.resultInput.Update(msg)
1597+
return m, cmd
15481598
case ModeTagFilter:
15491599
// Handle text input for tag filtering
15501600
var cmd tea.Cmd
@@ -1761,6 +1811,8 @@ func (m *Model) renderMainUI() string {
17611811
body = m.pm.View()
17621812
case ModeSettings:
17631813
body = m.set.View()
1814+
case ModeResultEdit:
1815+
body = m.renderResultOverlay(availableHeight)
17641816
case ModeTagFilter:
17651817
body = m.renderTagFilterOverlay(availableHeight)
17661818
case ModeEditor:
@@ -1926,15 +1978,8 @@ func (m *Model) stopMCPCmd() tea.Cmd {
19261978
}
19271979

19281980
func (m *Model) rebuildComponentSizes() {
1929-
// Component sizing is now handled dynamically in renderMainUI
19301981
}
19311982

1932-
// Add to Model struct:
1933-
// RainbowAnimationOffset int
1934-
// And inside New():
1935-
// m.RainbowAnimationOffset = 0
1936-
1937-
// renderHeaderWithWidth renders the header at a specific width.
19381983
func (m *Model) renderHeaderWithWidth(w int) string {
19391984
saved := m.width
19401985
m.width = w
@@ -2032,7 +2077,6 @@ func (m *Model) renderHeader() string {
20322077

20332078
// Animated Indicator (Bubble effect)
20342079
if m.transitioning && m.prevActiveIdx != m.activeIdx {
2035-
// ... (keep the existing logic, just ensure tabRow is constructed correctly)
20362080
pIdx := m.prevActiveIdx
20372081
aIdx := m.activeIdx
20382082
if pIdx >= 0 && pIdx < len(tabOffsets) && aIdx >= 0 && aIdx < len(tabOffsets) {
@@ -2278,6 +2322,26 @@ func (m *Model) renderFooter() string {
22782322
return render.BarLine(left, right, m.width, m.s.Theme.Bg)
22792323
}
22802324

2325+
// renderResultOverlay renders the task result input modal
2326+
func (m *Model) renderResultOverlay(h int) string {
2327+
inputLabel := m.s.Title.Render("Task Result")
2328+
input := lipgloss.NewStyle().Padding(0, 1).Render(m.resultInput.View())
2329+
2330+
modal := lipgloss.JoinVertical(lipgloss.Left,
2331+
inputLabel,
2332+
input,
2333+
m.s.Muted.Render("Describe the outcome of this task"),
2334+
)
2335+
2336+
overlayW := min(60, m.width-4)
2337+
card := m.s.Overlay.Width(overlayW).Render(modal)
2338+
2339+
return lipgloss.Place(m.width, h, lipgloss.Center, lipgloss.Center, card,
2340+
lipgloss.WithWhitespaceChars(" "),
2341+
lipgloss.WithWhitespaceBackground(m.s.Theme.Bg),
2342+
)
2343+
}
2344+
22812345
// renderTagFilterOverlay renders the tag filter input modal
22822346
func (m *Model) renderTagFilterOverlay(h int) string {
22832347
// Create filter input modal
@@ -2314,8 +2378,8 @@ func (m *Model) renderTagFilterOverlay(h int) string {
23142378
hint,
23152379
)
23162380

2317-
cardStyle := m.s.Overlay.Width(60)
2318-
card := cardStyle.Render(modal)
2381+
overlayW := min(60, m.width-4)
2382+
card := m.s.Overlay.Width(overlayW).Render(modal)
23192383

23202384
// Overlay the modal on top of the screen with proper background
23212385
return lipgloss.Place(m.width, h, lipgloss.Center, lipgloss.Center, card,
@@ -2705,7 +2769,7 @@ func (m *Model) rebuildPaletteIndex() {
27052769
search.Item{ID: "cmd:view:tag", Kind: search.KindCommand, Title: "View: By Tag", Hint: "f"},
27062770
search.Item{ID: "cmd:view:priority", Kind: search.KindCommand, Title: "View: By Priority", Hint: "5"},
27072771
search.Item{ID: "cmd:import-export", Kind: search.KindCommand, Title: "Import/Export", Hint: "x"},
2708-
search.Item{ID: "cmd:onboarding", Kind: search.KindCommand, Title: "Welcome Tour", Hint: "ctrl+d"},
2772+
search.Item{ID: "cmd:onboarding", Kind: search.KindCommand, Title: "Welcome Tour", Hint: "ctrl+w"},
27092773
)
27102774

27112775
if m.aiKey != "" {
@@ -2734,7 +2798,17 @@ func (m *Model) rebuildPaletteIndex() {
27342798
if t.Deadline != nil {
27352799
hint += " • due " + t.Deadline.Local().Format("Jan 2")
27362800
}
2737-
items = append(items, search.Item{ID: t.ID, Kind: search.KindTask, Title: t.Title, Desc: t.Description, Hint: hint})
2801+
if t.OpenIssueID != "" {
2802+
hint += " • " + t.OpenIssueID
2803+
}
2804+
if t.Responsible != "" {
2805+
hint += " • @" + t.Responsible
2806+
}
2807+
title := t.Title
2808+
if t.Result != "" {
2809+
title = t.Title + " [✓ " + t.Result + "]"
2810+
}
2811+
items = append(items, search.Item{ID: t.ID, Kind: search.KindTask, Title: title, Desc: t.Description, Hint: hint})
27382812
}
27392813

27402814
m.palFullIdx = search.NewIndex(items)
@@ -2745,7 +2819,17 @@ func (m *Model) rebuildPaletteIndex() {
27452819
if t.Deadline != nil {
27462820
hint += " • due " + t.Deadline.Local().Format("Jan 2")
27472821
}
2748-
taskItems = append(taskItems, search.Item{ID: t.ID, Kind: search.KindTask, Title: t.Title, Desc: t.Description, Hint: hint})
2822+
if t.OpenIssueID != "" {
2823+
hint += " • " + t.OpenIssueID
2824+
}
2825+
if t.Responsible != "" {
2826+
hint += " • @" + t.Responsible
2827+
}
2828+
title := t.Title
2829+
if t.Result != "" {
2830+
title = t.Title + " [✓ " + t.Result + "]"
2831+
}
2832+
taskItems = append(taskItems, search.Item{ID: t.ID, Kind: search.KindTask, Title: title, Desc: t.Description, Hint: hint})
27492833
}
27502834
m.palTasksIdx = search.NewIndex(taskItems)
27512835
m.rebuildProjectsIndex()
@@ -3002,9 +3086,13 @@ func (m *Model) handleImportExportAction(action import_export_menu.Action, path
30023086
var resp api.Response
30033087

30043088
if action.IsExport() {
3089+
scope := ""
3090+
if m.activeProject != "" && m.iem.ScopeValue() == import_export_menu.ScopeCurrentProject {
3091+
scope = m.activeProject
3092+
}
30053093
req := api.Request{
30063094
Action: "export",
3007-
Payload: []byte(fmt.Sprintf(`{"format":"%s"}`, action.Format())),
3095+
Payload: []byte(fmt.Sprintf(`{"format":"%s","project":"%s"}`, action.Format(), scope)),
30083096
}
30093097
resp = taskAPI.Execute(m.ctx, req)
30103098
if resp.Success {

internal/config/config.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,11 @@ func normalizeRightOrder(in []string) []string {
6464
return def
6565
}
6666
allowed := map[string]struct{}{
67-
"tags": {},
68-
"due": {},
69-
"priority": {},
70-
"project": {},
67+
"tags": {},
68+
"due": {},
69+
"priority": {},
70+
"project": {},
71+
"open_issue_id": {},
7172
}
7273
seen := map[string]struct{}{}
7374
out := make([]string, 0, len(in))
@@ -196,7 +197,7 @@ func Default() Config {
196197
},
197198
List: ListConfig{
198199
Order: ListOrderConfig{
199-
Right: []string{"tags", "due", "priority"},
200+
Right: []string{"tags", "due", "priority", "open_issue_id"},
200201
},
201202
Fields: ListFieldsConfig{
202203
Due: DueFieldConfig{

internal/core/codec/csv.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ func MarshalCSV(tasks []core.Task) ([]byte, error) {
1919
"ID", "Title", "Description", "Tags", "Priority", "Status",
2020
"Deadline", "Recurrence", "RecurrenceWeekly", "RecurrenceMonthly",
2121
"ParentID", "Collapsed", "CreatedAt", "UpdatedAt",
22+
"OpenIssueID", "Result", "Responsible",
2223
}
2324
if err := w.Write(header); err != nil {
2425
return nil, err
@@ -48,6 +49,9 @@ func MarshalCSV(tasks []core.Task) ([]byte, error) {
4849
collapsed,
4950
t.CreatedAt.Format(time.RFC3339),
5051
t.UpdatedAt.Format(time.RFC3339),
52+
t.OpenIssueID,
53+
t.Result,
54+
t.Responsible,
5155
}
5256
if err := w.Write(row); err != nil {
5357
return nil, err

0 commit comments

Comments
 (0)