feat(items): add structured notes for TASK-125 (#43)

This commit is contained in:
xarmian
2026-04-02 13:48:00 -04:00
committed by GitHub
parent 43f0e5ca9a
commit c61f4cdaaa
10 changed files with 766 additions and 15 deletions
+2
View File
@@ -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
View File
@@ -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 {
+164
View File
@@ -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
View File
@@ -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, &notes); 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"`
+59
View File
@@ -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)
}
}
+38
View File
@@ -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)
+2
View File
@@ -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)
}
+31
View File
@@ -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")
+18
View File
@@ -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);