CLI/TUI for drafting, repeating, and publishing daily standup updates as GitHub issues
github go cli golang management project tui daily
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(daily): use JSON body metadata for stable field tracking across label changes

+101 -18
+61 -5
internal/daily/entry.go
··· 1 1 package daily 2 2 3 3 import ( 4 + "encoding/json" 4 5 "fmt" 5 6 "regexp" 6 7 "strings" ··· 42 43 Body string 43 44 } 44 45 46 + type bodyMetadata struct { 47 + Fields []bodyMetadataField `json:"fields"` 48 + } 49 + 50 + type bodyMetadataField struct { 51 + ID string `json:"id"` 52 + Label string `json:"label"` 53 + } 54 + 45 55 var headingPattern = regexp.MustCompile(`^#{2,6}\s*(.*?)\s*(?:<!--\s*pad:id:([A-Za-z0-9._-]+)\s*-->)?\s*$`) 56 + var bodyMetadataPattern = regexp.MustCompile(`(?m)^<!--\s*pad:fields:(.*?)\s*-->$`) 46 57 47 58 var legacyFieldAliases = map[string][]string{ 48 59 "yesterday": {"✅ What did you do yesterday?"}, ··· 223 234 } 224 235 225 236 func (e Entry) Body() string { 226 - blocks := make([]string, 0, len(e.Template.Fields)) 237 + blocks := make([]string, 0, len(e.Template.Fields)+1) 238 + if metadata := renderBodyMetadata(e.Template); metadata != "" { 239 + blocks = append(blocks, metadata) 240 + } 227 241 for _, field := range e.Template.Fields { 228 242 switch field.Type { 229 243 case issueform.FieldMarkdown: ··· 330 344 } 331 345 332 346 func renderSection(field issueform.Field, body string) string { 333 - heading := "### " + field.Label 334 - if field.ID != "" { 335 - heading += " <!-- pad:id:" + field.ID + " -->" 347 + return "### " + field.Label + "\n" + body 348 + } 349 + 350 + func renderBodyMetadata(template issueform.Template) string { 351 + metadata := bodyMetadata{Fields: make([]bodyMetadataField, 0, len(template.Fields))} 352 + for _, field := range template.EditableFields() { 353 + if field.ID == "" || strings.TrimSpace(field.Label) == "" { 354 + continue 355 + } 356 + metadata.Fields = append(metadata.Fields, bodyMetadataField{ID: field.ID, Label: field.Label}) 336 357 } 337 - return heading + "\n" + body 358 + if len(metadata.Fields) == 0 { 359 + return "" 360 + } 361 + 362 + encoded, err := json.Marshal(metadata) 363 + if err != nil { 364 + return "" 365 + } 366 + 367 + return "<!-- pad:fields:" + string(encoded) + " -->" 338 368 } 339 369 340 370 func renderCheckboxBody(field issueform.Field, checked bool) string { ··· 354 384 current := parsedSection{} 355 385 active := false 356 386 allowedHeadings := collectAllowedHeadings(template) 387 + metadataIDs := parseBodyMetadata(body) 357 388 358 389 for _, line := range strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n") { 359 390 heading, id, ok := parseHeading(line) 391 + if ok && id == "" { 392 + id = metadataIDs[normalizeHeading(heading)] 393 + } 360 394 if ok && shouldStartSection(heading, id, allowedHeadings) { 361 395 if active { 362 396 current.Body = strings.TrimSpace(current.Body) ··· 384 418 } 385 419 386 420 return sections 421 + } 422 + 423 + func parseBodyMetadata(body string) map[string]string { 424 + matches := bodyMetadataPattern.FindAllStringSubmatch(body, -1) 425 + if len(matches) == 0 { 426 + return nil 427 + } 428 + 429 + metadata := bodyMetadata{} 430 + if err := json.Unmarshal([]byte(matches[0][1]), &metadata); err != nil { 431 + return nil 432 + } 433 + 434 + idsByHeading := make(map[string]string, len(metadata.Fields)) 435 + for _, field := range metadata.Fields { 436 + if field.ID == "" || strings.TrimSpace(field.Label) == "" { 437 + continue 438 + } 439 + idsByHeading[normalizeHeading(field.Label)] = field.ID 440 + } 441 + 442 + return idsByHeading 387 443 } 388 444 389 445 func collectAllowedHeadings(template issueform.Template) map[string]struct{} {
+37 -11
internal/daily/entry_test.go
··· 111 111 } 112 112 } 113 113 114 - func TestEntryFromIssueBodyUsesHiddenIDsWhenLabelsChange(t *testing.T) { 114 + func TestEntryFromIssueBodyUsesMetadataWhenLabelsChange(t *testing.T) { 115 + template := mustTemplateWithRenamedYesterday(t) 116 + body := `<!-- pad:fields:{"fields":[{"id":"yesterday","label":"Yesterday Work"},{"id":"today","label":"Current Focus"}]} --> 117 + 118 + ## Yesterday Work 119 + - Reviewed PR #42 120 + 121 + ## Current Focus 122 + - Continue feature work` 123 + 124 + got := EntryFromIssueBody("2026-04-17", template, body) 125 + 126 + if got.Text("yesterday") != "- Reviewed PR #42" { 127 + t.Fatalf("unexpected yesterday %q", got.Text("yesterday")) 128 + } 129 + 130 + if got.Text("today") != "- Continue feature work" { 131 + t.Fatalf("unexpected today %q", got.Text("today")) 132 + } 133 + } 134 + 135 + func TestEntryFromIssueBodyStillParsesInlineIDsWhenLabelsChange(t *testing.T) { 115 136 template := mustTemplateWithRenamedYesterday(t) 116 137 body := `## Yesterday Work <!-- pad:id:yesterday --> 117 138 - Reviewed PR #42 ··· 132 153 133 154 func TestEntryFromIssueBodyAddsCarryoverForRemovedFields(t *testing.T) { 134 155 template := mustTemplateWithRenamedYesterday(t) 135 - body := `## Yesterday Work <!-- pad:id:yesterday --> 156 + body := `<!-- pad:fields:{"fields":[{"id":"yesterday","label":"Yesterday Work"},{"id":"today","label":"Current Focus"},{"id":"comments","label":"💬 Additional Comments"}]} --> 157 + 158 + ## Yesterday Work 136 159 - Reviewed PR #42 137 160 138 - ## Current Focus <!-- pad:id:today --> 161 + ## Current Focus 139 162 - Continue feature work 140 163 141 - ## 💬 Additional Comments <!-- pad:id:comments --> 164 + ## 💬 Additional Comments 142 165 - Offline after 17:00` 143 166 144 167 got := EntryFromIssueBody("2026-04-17", template, body) ··· 157 180 158 181 func TestEntryFromIssueBodyKeepsMarkdownHeadingsInsideResponseBody(t *testing.T) { 159 182 template := mustTemplate(t) 160 - body := `## ✅ What did you do yesterday? <!-- pad:id:yesterday --> 183 + body := `<!-- pad:fields:{"fields":[{"id":"yesterday","label":"✅ What did you do yesterday?"},{"id":"today","label":"🎯 What will you do today?"},{"id":"blockers","label":"🚧 Any blockers?"}]} --> 184 + 185 + ## ✅ What did you do yesterday? 161 186 - Reviewed PR #42 162 187 163 - ## 🎯 What will you do today? <!-- pad:id:today --> 188 + ## 🎯 What will you do today? 164 189 - Continue feature work 165 190 166 191 ### Notes 167 192 - Include nested heading in response 168 193 169 - ## 🚧 Any blockers? <!-- pad:id:blockers --> 194 + ## 🚧 Any blockers? 170 195 _None._` 171 196 172 197 got := EntryFromIssueBody("2026-04-17", template, body) ··· 176 201 } 177 202 } 178 203 179 - func TestBodyRendersTemplateSectionsAndHiddenIDs(t *testing.T) { 204 + func TestBodyRendersTemplateSectionsAndMetadata(t *testing.T) { 180 205 entry := New("2026-04-16", mustTemplate(t)) 181 206 entry.SetText("yesterday", "- Reviewed PR #42") 182 207 entry.SetText("today", "- Continue feature work") ··· 184 209 body := entry.Body() 185 210 186 211 checks := []string{ 212 + `<!-- pad:fields:{"fields":[{"id":"yesterday","label":"✅ What did you do yesterday?"},{"id":"today","label":"🎯 What will you do today?"},{"id":"blockers","label":"🚧 Any blockers?"},{"id":"parking_lot","label":"🚨 Do you request a Parking Lot or escalation?"},{"id":"parking_details","label":"📝 Parking Lot Details"},{"id":"comments","label":"💬 Additional Comments"}]} -->`, 187 213 "## Daily Standup Update", 188 - "### ✅ What did you do yesterday? <!-- pad:id:yesterday -->", 189 - "### 🎯 What will you do today? <!-- pad:id:today -->", 190 - "### 🚧 Any blockers? <!-- pad:id:blockers -->", 214 + "### ✅ What did you do yesterday?", 215 + "### 🎯 What will you do today?", 216 + "### 🚧 Any blockers?", 191 217 "_No response_", 192 218 } 193 219
+3 -2
internal/tui/editor_test.go
··· 31 31 32 32 checks := []string{ 33 33 "[Daily Update] [2026/04/16]", 34 - "## ✅ What did you do yesterday? <!-- pad:id:yesterday -->", 35 - "## 🎯 What will you do today? <!-- pad:id:today -->", 34 + `<!-- pad:fields:{"fields":[{"id":"yesterday","label":"✅ What did you do yesterday?"},{"id":"today","label":"🎯 What will you do today?"},{"id":"parking_lot","label":"🚨 Do you request a Parking Lot or escalation?"}]} -->`, 35 + "### ✅ What did you do yesterday?", 36 + "### 🎯 What will you do today?", 36 37 } 37 38 38 39 for _, check := range checks {