mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
feat(items): add structured notes for TASK-125 (#43)
This commit is contained in:
@@ -255,6 +255,8 @@ pad next Recommended next task
|
||||
pad collections List collections with item counts
|
||||
pad comment <slug> "text" Add comment to an item
|
||||
pad comments <slug> View item comments
|
||||
pad note <ref> "summary" Append an implementation note to an item
|
||||
pad decide <ref> "decision" Append a decision log entry to an item
|
||||
|
||||
pad standup [--days N] Daily standup report
|
||||
pad changelog [--days N] Release notes from completed items
|
||||
|
||||
+17
-1
@@ -107,6 +107,8 @@ func main() {
|
||||
libraryCmd(),
|
||||
commentCmd(),
|
||||
commentsCmd(),
|
||||
noteCmd(),
|
||||
decideCmd(),
|
||||
blocksCmd(),
|
||||
blockedByCmd(),
|
||||
splitFromCmd(),
|
||||
@@ -2024,7 +2026,7 @@ func showCmd() *cobra.Command {
|
||||
var fields map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(item.Fields), &fields); err == nil {
|
||||
for k, v := range fields {
|
||||
if k == "github_pr" {
|
||||
if k == models.ItemFieldGitHubPR || k == models.ItemFieldImplementationNotes || k == models.ItemFieldDecisionLog {
|
||||
continue // shown in dedicated section below
|
||||
}
|
||||
fmt.Printf("%-12s %v\n", k+":", v)
|
||||
@@ -2057,6 +2059,20 @@ func showCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
if len(item.ImplementationNotes) > 0 {
|
||||
fmt.Println("\n--- Implementation Notes ---")
|
||||
for _, note := range item.ImplementationNotes {
|
||||
printStructuredTimelineEntry(note.CreatedAt, note.CreatedBy, note.Summary, note.Details)
|
||||
}
|
||||
}
|
||||
|
||||
if len(item.DecisionLog) > 0 {
|
||||
fmt.Println("\n--- Decision Log ---")
|
||||
for _, decision := range item.DecisionLog {
|
||||
printStructuredTimelineEntry(decision.CreatedAt, decision.CreatedBy, decision.Decision, decision.Rationale)
|
||||
}
|
||||
}
|
||||
|
||||
// Show dependencies and lineage relationships
|
||||
links, err := client.GetItemLinks(ws, item.Slug)
|
||||
if err == nil && len(links) > 0 {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/xarmian/pad/internal/cli"
|
||||
"github.com/xarmian/pad/internal/models"
|
||||
)
|
||||
|
||||
func noteCmd() *cobra.Command {
|
||||
var details string
|
||||
var readStdin bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "note <ref> <summary>",
|
||||
Short: "Append an implementation note to an item",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, _ := getClient()
|
||||
ws := getWorkspace()
|
||||
|
||||
item, err := client.GetItem(ws, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(details)
|
||||
if readStdin {
|
||||
body, err = readStructuredEntryBody()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fields, err := models.AppendImplementationNote(item.Fields, models.ItemImplementationNote{
|
||||
ID: newStructuredEntryID("note"),
|
||||
Summary: strings.TrimSpace(args[1]),
|
||||
Details: body,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
CreatedBy: "user",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updated, err := client.UpdateItem(ws, item.Slug, models.ItemUpdate{
|
||||
Fields: &fields,
|
||||
LastModifiedBy: "user",
|
||||
Source: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Added implementation note to %s %s\n", cli.ItemRef(*updated), updated.Title)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&details, "details", "", "implementation details")
|
||||
cmd.Flags().BoolVar(&readStdin, "stdin", false, "read note details from stdin")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func decideCmd() *cobra.Command {
|
||||
var rationale string
|
||||
var readStdin bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "decide <ref> <decision>",
|
||||
Short: "Append a decision log entry to an item",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, _ := getClient()
|
||||
ws := getWorkspace()
|
||||
|
||||
item, err := client.GetItem(ws, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(rationale)
|
||||
if readStdin {
|
||||
body, err = readStructuredEntryBody()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fields, err := models.AppendDecisionLogEntry(item.Fields, models.ItemDecisionLogEntry{
|
||||
ID: newStructuredEntryID("decision"),
|
||||
Decision: strings.TrimSpace(args[1]),
|
||||
Rationale: body,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
CreatedBy: "user",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updated, err := client.UpdateItem(ws, item.Slug, models.ItemUpdate{
|
||||
Fields: &fields,
|
||||
LastModifiedBy: "user",
|
||||
Source: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Added decision log entry to %s %s\n", cli.ItemRef(*updated), updated.Title)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&rationale, "rationale", "", "decision rationale")
|
||||
cmd.Flags().BoolVar(&readStdin, "stdin", false, "read decision rationale from stdin")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newStructuredEntryID(prefix string) string {
|
||||
return fmt.Sprintf("%s-%d", prefix, time.Now().UTC().UnixNano())
|
||||
}
|
||||
|
||||
func readStructuredEntryBody() (string, error) {
|
||||
data, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read stdin: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
func printStructuredTimelineEntry(createdAt, createdBy, title, body string) {
|
||||
if title == "" {
|
||||
return
|
||||
}
|
||||
|
||||
metaParts := make([]string, 0, 2)
|
||||
if createdAt != "" {
|
||||
if ts, err := time.Parse(time.RFC3339, createdAt); err == nil {
|
||||
metaParts = append(metaParts, cli.RelativeTime(ts))
|
||||
} else {
|
||||
metaParts = append(metaParts, createdAt)
|
||||
}
|
||||
}
|
||||
if createdBy != "" {
|
||||
metaParts = append(metaParts, createdBy)
|
||||
}
|
||||
|
||||
fmt.Printf("• %s", title)
|
||||
if len(metaParts) > 0 {
|
||||
fmt.Printf(" %s", color.New(color.Faint).Sprint(strings.Join(metaParts, " · ")))
|
||||
}
|
||||
fmt.Println()
|
||||
if body != "" {
|
||||
fmt.Printf(" %s\n", body)
|
||||
}
|
||||
}
|
||||
+137
-13
@@ -6,6 +6,12 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ItemFieldGitHubPR = "github_pr"
|
||||
ItemFieldImplementationNotes = "implementation_notes"
|
||||
ItemFieldDecisionLog = "decision_log"
|
||||
)
|
||||
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
@@ -30,12 +36,14 @@ type Item struct {
|
||||
ItemNumber *int `json:"item_number,omitempty"`
|
||||
|
||||
// Populated by joins (not stored)
|
||||
CollectionSlug string `json:"collection_slug,omitempty"`
|
||||
CollectionName string `json:"collection_name,omitempty"`
|
||||
CollectionIcon string `json:"collection_icon,omitempty"`
|
||||
CollectionPrefix string `json:"collection_prefix,omitempty"`
|
||||
DerivedClosure *ItemDerivedClosure `json:"derived_closure,omitempty"`
|
||||
CodeContext *ItemCodeContext `json:"code_context,omitempty"`
|
||||
CollectionSlug string `json:"collection_slug,omitempty"`
|
||||
CollectionName string `json:"collection_name,omitempty"`
|
||||
CollectionIcon string `json:"collection_icon,omitempty"`
|
||||
CollectionPrefix string `json:"collection_prefix,omitempty"`
|
||||
DerivedClosure *ItemDerivedClosure `json:"derived_closure,omitempty"`
|
||||
CodeContext *ItemCodeContext `json:"code_context,omitempty"`
|
||||
ImplementationNotes []ItemImplementationNote `json:"implementation_notes,omitempty"`
|
||||
DecisionLog []ItemDecisionLogEntry `json:"decision_log,omitempty"`
|
||||
}
|
||||
|
||||
// ComputeRef sets the Ref field from CollectionPrefix and ItemNumber.
|
||||
@@ -77,6 +85,22 @@ type ItemPullRequestMetadata struct {
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type ItemImplementationNote struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
Details string `json:"details,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
type ItemDecisionLogEntry struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Decision string `json:"decision"`
|
||||
Rationale string `json:"rationale,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
type githubPRFields struct {
|
||||
Number int `json:"number"`
|
||||
URL string `json:"url"`
|
||||
@@ -88,16 +112,12 @@ type githubPRFields struct {
|
||||
}
|
||||
|
||||
func ExtractItemCodeContext(fieldsJSON string) *ItemCodeContext {
|
||||
if fieldsJSON == "" || fieldsJSON == "{}" {
|
||||
fieldsMap, ok := parseItemFields(fieldsJSON)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
var fieldsMap map[string]any
|
||||
if err := json.Unmarshal([]byte(fieldsJSON), &fieldsMap); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
raw, ok := fieldsMap["github_pr"]
|
||||
raw, ok := fieldsMap[ItemFieldGitHubPR]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
@@ -133,6 +153,110 @@ func ExtractItemCodeContext(fieldsJSON string) *ItemCodeContext {
|
||||
return context
|
||||
}
|
||||
|
||||
func ExtractItemImplementationNotes(fieldsJSON string) []ItemImplementationNote {
|
||||
fieldsMap, ok := parseItemFields(fieldsJSON)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
raw, ok := fieldsMap[ItemFieldImplementationNotes]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var notes []ItemImplementationNote
|
||||
if err := json.Unmarshal(payload, ¬es); err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(notes) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
func ExtractItemDecisionLog(fieldsJSON string) []ItemDecisionLogEntry {
|
||||
fieldsMap, ok := parseItemFields(fieldsJSON)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
raw, ok := fieldsMap[ItemFieldDecisionLog]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var entries []ItemDecisionLogEntry
|
||||
if err := json.Unmarshal(payload, &entries); err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func AppendImplementationNote(fieldsJSON string, note ItemImplementationNote) (string, error) {
|
||||
fieldsMap, err := parseMutableItemFields(fieldsJSON)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
notes := ExtractItemImplementationNotes(fieldsJSON)
|
||||
notes = append(notes, note)
|
||||
fieldsMap[ItemFieldImplementationNotes] = notes
|
||||
return marshalItemFields(fieldsMap)
|
||||
}
|
||||
|
||||
func AppendDecisionLogEntry(fieldsJSON string, entry ItemDecisionLogEntry) (string, error) {
|
||||
fieldsMap, err := parseMutableItemFields(fieldsJSON)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
entries := ExtractItemDecisionLog(fieldsJSON)
|
||||
entries = append(entries, entry)
|
||||
fieldsMap[ItemFieldDecisionLog] = entries
|
||||
return marshalItemFields(fieldsMap)
|
||||
}
|
||||
|
||||
func parseItemFields(fieldsJSON string) (map[string]any, bool) {
|
||||
if fieldsJSON == "" || fieldsJSON == "{}" {
|
||||
return nil, false
|
||||
}
|
||||
var fieldsMap map[string]any
|
||||
if err := json.Unmarshal([]byte(fieldsJSON), &fieldsMap); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return fieldsMap, true
|
||||
}
|
||||
|
||||
func parseMutableItemFields(fieldsJSON string) (map[string]any, error) {
|
||||
if fieldsJSON == "" || fieldsJSON == "{}" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var fieldsMap map[string]any
|
||||
if err := json.Unmarshal([]byte(fieldsJSON), &fieldsMap); err != nil {
|
||||
return nil, fmt.Errorf("parse item fields: %w", err)
|
||||
}
|
||||
return fieldsMap, nil
|
||||
}
|
||||
|
||||
func marshalItemFields(fieldsMap map[string]any) (string, error) {
|
||||
payload, err := json.Marshal(fieldsMap)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal item fields: %w", err)
|
||||
}
|
||||
return string(payload), nil
|
||||
}
|
||||
|
||||
type ItemCreate struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content,omitempty"`
|
||||
|
||||
@@ -29,3 +29,62 @@ func TestExtractItemCodeContextReturnsNilForUnrelatedFields(t *testing.T) {
|
||||
t.Fatal("expected nil code context for unrelated fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractItemImplementationNotes(t *testing.T) {
|
||||
notes := ExtractItemImplementationNotes(`{"status":"open","implementation_notes":[{"id":"note-1","summary":"Used SSE refresh","details":"Reload phase tasks on visibility resume","created_at":"2026-04-02T15:00:00Z","created_by":"agent"}]}`)
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("expected 1 implementation note, got %d", len(notes))
|
||||
}
|
||||
if notes[0].Summary != "Used SSE refresh" {
|
||||
t.Fatalf("expected note summary, got %q", notes[0].Summary)
|
||||
}
|
||||
if notes[0].CreatedBy != "agent" {
|
||||
t.Fatalf("expected created_by agent, got %q", notes[0].CreatedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractItemDecisionLog(t *testing.T) {
|
||||
log := ExtractItemDecisionLog(`{"status":"open","decision_log":[{"id":"decision-1","decision":"Use explicit setup state","rationale":"needs_setup was too ambiguous","created_at":"2026-04-02T15:05:00Z","created_by":"user"}]}`)
|
||||
if len(log) != 1 {
|
||||
t.Fatalf("expected 1 decision entry, got %d", len(log))
|
||||
}
|
||||
if log[0].Decision != "Use explicit setup state" {
|
||||
t.Fatalf("expected decision text, got %q", log[0].Decision)
|
||||
}
|
||||
if log[0].Rationale == "" {
|
||||
t.Fatal("expected rationale to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendImplementationNotePreservesExistingFields(t *testing.T) {
|
||||
fields, err := AppendImplementationNote(`{"status":"open","priority":"high"}`, ItemImplementationNote{
|
||||
ID: "note-1",
|
||||
Summary: "Added setup guidance",
|
||||
Details: "Updated the login screen copy",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AppendImplementationNote error: %v", err)
|
||||
}
|
||||
if got := ExtractItemImplementationNotes(fields); len(got) != 1 {
|
||||
t.Fatalf("expected 1 note after append, got %#v", got)
|
||||
}
|
||||
if ExtractItemCodeContext(fields) != nil {
|
||||
t.Fatal("did not expect code context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDecisionLogEntryPreservesExistingNotes(t *testing.T) {
|
||||
fields, err := AppendDecisionLogEntry(`{"implementation_notes":[{"id":"note-1","summary":"Keep docker as external-managed"}]}`, ItemDecisionLogEntry{
|
||||
ID: "decision-1",
|
||||
Decision: "Keep docker in external-managed mode",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AppendDecisionLogEntry error: %v", err)
|
||||
}
|
||||
if got := ExtractItemImplementationNotes(fields); len(got) != 1 {
|
||||
t.Fatalf("expected implementation notes to be preserved, got %#v", got)
|
||||
}
|
||||
if got := ExtractItemDecisionLog(fields); len(got) != 1 {
|
||||
t.Fatalf("expected 1 decision log entry, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,6 +659,44 @@ func TestGetItemIncludesCodeContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetItemIncludesStructuredNotes(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
|
||||
"title": "Capture reasoning",
|
||||
"fields": `{"status":"open"}`,
|
||||
})
|
||||
var item models.Item
|
||||
parseJSON(t, rr, &item)
|
||||
|
||||
fields := `{"status":"open","implementation_notes":[{"id":"note-1","summary":"Add typed item metadata","details":"Expose the new arrays as top-level API fields","created_at":"2026-04-02T16:30:00Z","created_by":"agent"}],"decision_log":[{"id":"decision-1","decision":"Store notes in reserved field keys","rationale":"This keeps the first cut backward-compatible","created_at":"2026-04-02T16:35:00Z","created_by":"agent"}]}`
|
||||
updated, err := srv.store.UpdateItem(item.ID, models.ItemUpdate{Fields: &fields})
|
||||
if err != nil {
|
||||
t.Fatalf("update item fields: %v", err)
|
||||
}
|
||||
|
||||
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items/"+updated.Slug, nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("get item: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var fetched models.Item
|
||||
parseJSON(t, rr, &fetched)
|
||||
if len(fetched.ImplementationNotes) != 1 {
|
||||
t.Fatalf("expected 1 implementation note, got %#v", fetched.ImplementationNotes)
|
||||
}
|
||||
if fetched.ImplementationNotes[0].Summary != "Add typed item metadata" {
|
||||
t.Fatalf("expected implementation note summary, got %q", fetched.ImplementationNotes[0].Summary)
|
||||
}
|
||||
if len(fetched.DecisionLog) != 1 {
|
||||
t.Fatalf("expected 1 decision log entry, got %#v", fetched.DecisionLog)
|
||||
}
|
||||
if fetched.DecisionLog[0].Decision != "Store notes in reserved field keys" {
|
||||
t.Fatalf("expected decision log entry, got %q", fetched.DecisionLog[0].Decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemVersions(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
@@ -1048,4 +1048,6 @@ func hydrateItemComputedMetadata(item *models.Item) {
|
||||
}
|
||||
item.ComputeRef()
|
||||
item.CodeContext = models.ExtractItemCodeContext(item.Fields)
|
||||
item.ImplementationNotes = models.ExtractItemImplementationNotes(item.Fields)
|
||||
item.DecisionLog = models.ExtractItemDecisionLog(item.Fields)
|
||||
}
|
||||
|
||||
@@ -368,6 +368,37 @@ func TestListItemsIncludesCodeContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemStructuredMetadataIsHydratedOnRead(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ws := createTestWorkspace(t, s, "Test")
|
||||
col := createTestCollection(t, s, ws.ID, "Tasks")
|
||||
|
||||
item, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
||||
Title: "Capture reasoning",
|
||||
Fields: `{"status":"open","implementation_notes":[{"id":"note-1","summary":"Added typed metadata","details":"Surface it on item responses","created_at":"2026-04-02T16:00:00Z","created_by":"agent"}],"decision_log":[{"id":"decision-1","decision":"Store notes in reserved field keys","rationale":"Avoid a new table for this first cut","created_at":"2026-04-02T16:05:00Z","created_by":"agent"}]}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateItem error: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetItem(item.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetItem error: %v", err)
|
||||
}
|
||||
if len(got.ImplementationNotes) != 1 {
|
||||
t.Fatalf("expected 1 implementation note, got %#v", got.ImplementationNotes)
|
||||
}
|
||||
if got.ImplementationNotes[0].Summary != "Added typed metadata" {
|
||||
t.Fatalf("expected implementation note summary, got %q", got.ImplementationNotes[0].Summary)
|
||||
}
|
||||
if len(got.DecisionLog) != 1 {
|
||||
t.Fatalf("expected 1 decision log entry, got %#v", got.DecisionLog)
|
||||
}
|
||||
if got.DecisionLog[0].Decision != "Store notes in reserved field keys" {
|
||||
t.Fatalf("expected decision log entry, got %q", got.DecisionLog[0].Decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemListByCollection(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ws := createTestWorkspace(t, s, "Test")
|
||||
|
||||
@@ -166,6 +166,22 @@ export interface ItemCodeContext {
|
||||
pull_request?: ItemPullRequestMetadata;
|
||||
}
|
||||
|
||||
export interface ItemImplementationNote {
|
||||
id?: string;
|
||||
summary: string;
|
||||
details?: string;
|
||||
created_at?: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface ItemDecisionLogEntry {
|
||||
id?: string;
|
||||
decision: string;
|
||||
rationale?: string;
|
||||
created_at?: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
@@ -190,6 +206,8 @@ export interface Item {
|
||||
item_number?: number;
|
||||
derived_closure?: ItemDerivedClosure;
|
||||
code_context?: ItemCodeContext;
|
||||
implementation_notes?: ItemImplementationNote[];
|
||||
decision_log?: ItemDecisionLogEntry[];
|
||||
}
|
||||
|
||||
export interface ItemCreate {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { relativeTime, wikiLinksToMarkdown, markdownToWikiLinks, cleanBrokenLinks } from '$lib/utils/markdown';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import type { Item, Collection, CollectionSettings, QuickAction, ItemLink, ItemRelationRef } from '$lib/types';
|
||||
import type { Item, Collection, CollectionSettings, QuickAction, ItemLink, ItemRelationRef, ItemImplementationNote, ItemDecisionLogEntry } from '$lib/types';
|
||||
import { parseFields, parseSchema, parseSettings, formatItemRef } from '$lib/types';
|
||||
import QuickActionsMenu from '$lib/components/common/QuickActionsMenu.svelte';
|
||||
|
||||
@@ -78,6 +78,14 @@
|
||||
let relationshipGroups = $derived(item ? buildRelationshipGroups(item, itemLinks) : []);
|
||||
let closureEntries = $derived(item?.derived_closure?.related_items?.map((related) => relationRefEntry(related)) ?? []);
|
||||
let codeContext = $derived(item?.code_context ?? null);
|
||||
let implementationNotes = $derived(item?.implementation_notes ?? []);
|
||||
let decisionLog = $derived(item?.decision_log ?? []);
|
||||
let noteSummary = $state('');
|
||||
let noteDetails = $state('');
|
||||
let decisionTitle = $state('');
|
||||
let decisionRationale = $state('');
|
||||
let savingNote = $state(false);
|
||||
let savingDecision = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (wsSlug && collSlug && itemSlug) {
|
||||
@@ -218,6 +226,86 @@
|
||||
}
|
||||
}
|
||||
|
||||
function buildStructuredEntryID(prefix: string): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
}
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
async function updateStructuredFields(updatedFields: Record<string, any>) {
|
||||
if (!item) return;
|
||||
saveStatus = 'saving';
|
||||
item = await api.items.update(wsSlug, item.id, { fields: JSON.stringify(updatedFields) });
|
||||
showSaved();
|
||||
}
|
||||
|
||||
async function addImplementationNote() {
|
||||
if (!item || savingNote) return;
|
||||
const summary = noteSummary.trim();
|
||||
const details = noteDetails.trim();
|
||||
if (!summary) return;
|
||||
|
||||
savingNote = true;
|
||||
try {
|
||||
const updatedFields = {
|
||||
...fields,
|
||||
implementation_notes: [
|
||||
...(implementationNotes ?? []),
|
||||
{
|
||||
id: buildStructuredEntryID('note'),
|
||||
summary,
|
||||
details: details || undefined,
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: 'web'
|
||||
} satisfies ItemImplementationNote
|
||||
]
|
||||
};
|
||||
await updateStructuredFields(updatedFields);
|
||||
noteSummary = '';
|
||||
noteDetails = '';
|
||||
toastStore.show('Implementation note added', 'success');
|
||||
} catch {
|
||||
saveStatus = 'idle';
|
||||
toastStore.show('Failed to add implementation note', 'error');
|
||||
} finally {
|
||||
savingNote = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addDecisionLogEntry() {
|
||||
if (!item || savingDecision) return;
|
||||
const decision = decisionTitle.trim();
|
||||
const rationale = decisionRationale.trim();
|
||||
if (!decision) return;
|
||||
|
||||
savingDecision = true;
|
||||
try {
|
||||
const updatedFields = {
|
||||
...fields,
|
||||
decision_log: [
|
||||
...(decisionLog ?? []),
|
||||
{
|
||||
id: buildStructuredEntryID('decision'),
|
||||
decision,
|
||||
rationale: rationale || undefined,
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: 'web'
|
||||
} satisfies ItemDecisionLogEntry
|
||||
]
|
||||
};
|
||||
await updateStructuredFields(updatedFields);
|
||||
decisionTitle = '';
|
||||
decisionRationale = '';
|
||||
toastStore.show('Decision log entry added', 'success');
|
||||
} catch {
|
||||
saveStatus = 'idle';
|
||||
toastStore.show('Failed to add decision log entry', 'error');
|
||||
} finally {
|
||||
savingDecision = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleContentUpdate(markdown: string) {
|
||||
clearTimeout(contentDebounceTimer);
|
||||
saveStatus = 'saving';
|
||||
@@ -659,6 +747,108 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="structured-notes-section">
|
||||
<div class="structured-notes-grid">
|
||||
<section class="structured-card">
|
||||
<div class="structured-card-header">
|
||||
<h3 class="section-title">Implementation Notes</h3>
|
||||
<span class="structured-count">{implementationNotes.length}</span>
|
||||
</div>
|
||||
<div class="structured-entry-form">
|
||||
<input
|
||||
bind:value={noteSummary}
|
||||
class="structured-input"
|
||||
type="text"
|
||||
placeholder="What changed or mattered?"
|
||||
onkeydown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') addImplementationNote();
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
bind:value={noteDetails}
|
||||
class="structured-textarea"
|
||||
rows="3"
|
||||
placeholder="Optional details, tradeoffs, or follow-up context"
|
||||
></textarea>
|
||||
<button class="structured-submit" onclick={addImplementationNote} disabled={savingNote || !noteSummary.trim()}>
|
||||
{savingNote ? 'Saving…' : 'Add note'}
|
||||
</button>
|
||||
</div>
|
||||
{#if implementationNotes.length > 0}
|
||||
<div class="structured-entry-list">
|
||||
{#each implementationNotes as note (note.id ?? `${note.summary}-${note.created_at ?? ''}`)}
|
||||
<article class="structured-entry">
|
||||
<div class="structured-entry-title">{note.summary}</div>
|
||||
<div class="structured-entry-meta">
|
||||
{#if note.created_at}
|
||||
<span>{relativeTime(note.created_at)}</span>
|
||||
{/if}
|
||||
{#if note.created_by}
|
||||
<span>{note.created_by}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if note.details}
|
||||
<p class="structured-entry-body">{note.details}</p>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="structured-empty">No implementation notes yet.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="structured-card">
|
||||
<div class="structured-card-header">
|
||||
<h3 class="section-title">Decision Log</h3>
|
||||
<span class="structured-count">{decisionLog.length}</span>
|
||||
</div>
|
||||
<div class="structured-entry-form">
|
||||
<input
|
||||
bind:value={decisionTitle}
|
||||
class="structured-input"
|
||||
type="text"
|
||||
placeholder="What decision did we make?"
|
||||
onkeydown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') addDecisionLogEntry();
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
bind:value={decisionRationale}
|
||||
class="structured-textarea"
|
||||
rows="3"
|
||||
placeholder="Optional rationale, tradeoffs, or alternatives considered"
|
||||
></textarea>
|
||||
<button class="structured-submit" onclick={addDecisionLogEntry} disabled={savingDecision || !decisionTitle.trim()}>
|
||||
{savingDecision ? 'Saving…' : 'Add decision'}
|
||||
</button>
|
||||
</div>
|
||||
{#if decisionLog.length > 0}
|
||||
<div class="structured-entry-list">
|
||||
{#each decisionLog as decision (decision.id ?? `${decision.decision}-${decision.created_at ?? ''}`)}
|
||||
<article class="structured-entry">
|
||||
<div class="structured-entry-title">{decision.decision}</div>
|
||||
<div class="structured-entry-meta">
|
||||
{#if decision.created_at}
|
||||
<span>{relativeTime(decision.created_at)}</span>
|
||||
{/if}
|
||||
{#if decision.created_by}
|
||||
<span>{decision.created_by}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if decision.rationale}
|
||||
<p class="structured-entry-body">{decision.rationale}</p>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="structured-empty">No decisions recorded yet.</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase Tasks (shown only for phases collection) -->
|
||||
{#if collSlug === 'phases' && item}
|
||||
<PhaseTasks {wsSlug} {itemSlug} itemId={item.id} phaseFields={fields} onTasksChange={handlePhaseTasksChange} />
|
||||
@@ -1125,6 +1315,113 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Structured notes */
|
||||
.structured-notes-section {
|
||||
margin-top: var(--space-6);
|
||||
padding-top: var(--space-6);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.structured-notes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.structured-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.structured-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.structured-card .section-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.structured-count {
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.structured-entry-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.structured-input,
|
||||
.structured-textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font: inherit;
|
||||
}
|
||||
.structured-textarea {
|
||||
resize: vertical;
|
||||
min-height: 88px;
|
||||
}
|
||||
.structured-submit {
|
||||
align-self: flex-start;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--accent-blue);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.structured-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.structured-entry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.structured-entry {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||
}
|
||||
.structured-entry:first-child {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
.structured-entry-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.structured-entry-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: 4px;
|
||||
font-size: 0.78em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.structured-entry-body {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.structured-empty {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Comments */
|
||||
.comments-section {
|
||||
margin-top: var(--space-6);
|
||||
|
||||
Reference in New Issue
Block a user