Переглянути джерело

Add the dashboard notes scratchpad with promote-to-goal

A personal notes/todo list on the home dashboard for jotting down batches of changes before they are ready to be full-on goals or tasks.

- notes table (body, done, done_at, goal_id, created_at) in the flattened M0001 migration, plus the Note entity and its registration.
- NoteService: add_note, set_done, delete_note, list_notes (undone first then newest first, sorted in memory since the ORM's order_by carries a single key), and promote_to_goal: creates a DRAFT goal via GoalService (title = the note's first line clamped to ~80 chars with an ellipsis, brief = the full note body), stamps the note's goal_id and freezes it against a second promote.
- NoteActions: AddNoteAction (plain action, answers with the refreshed dashboard slot), ToggleNoteAction (flips the done flag; an explicit 0/1 form value forces a direction), DeleteNoteAction (both sealed against the note id), PromoteNoteAction (creates the draft goal and navigates to /goals/<id>).
- DashboardState carries the note rows (done state, per-row sealed toggle/delete/promote DTOs, the promoted note's goal link) and the promote form's project options; the home page gains a Notes card with the add form, the toggle/delete/promote row UI and a goal link on promoted notes.
- New notes bundle in the web-stack suite (registered in BUNDLES_ORDER and bundles/__init__.py): add/list ordering, toggle round-trip with done_at stamps, delete, promote (draft goal, brief == note body, first-line title, navigation target, frozen note, re-promote refusal, validation failures) and the long-title clamp.
saddle-Clanker-Saddle 1 місяць тому
батько
коміт
1fe5ec9d0a

+ 2 - 0
meson.build

@@ -183,6 +183,7 @@ app_sources = files(
     'src/Services/Dispatcher.vala',
     'src/Services/RuleService.vala',
     'src/Services/StackService.vala',
+    'src/Services/NoteService.vala',
     'src/Services/CapabilityService.vala',
     'src/Services/TimelineService.vala',
     'src/Services/PushService.vala',
@@ -198,6 +199,7 @@ app_sources = files(
     'src/actions/SaddleActions.vala',
     'src/actions/RuleActions.vala',
     'src/actions/StackActions.vala',
+    'src/actions/NoteActions.vala',
     'src/actions/CapabilityActions.vala',
     'src/actions/UserActions.vala',
     'src/actions/ProfileActions.vala',

+ 30 - 0
src/Data/Entities.vala

@@ -403,6 +403,36 @@ namespace ClankerSaddle.Data {
         }
     }
 
+    /**
+     * A dashboard scratchpad note (goal 8 task 3): a personal
+     * todo/idea jotted down before a batch of changes is ready to be
+     * a full goal. Promoting ({@link ClankerSaddle.Services.NoteService.promote_to_goal})
+     * creates a DRAFT goal from the body and sets {@link goal_id},
+     * which links the created goal and freezes the note. Stored in
+     * the `notes` table.
+     */
+    public class Note : Object {
+
+        public int64 id { get; set; }
+        public string body { get; set; default = ""; }
+        public bool done { get; set; default = false; }
+        public DateTime? done_at { get; set; default = null; }
+        /** The goal this note promoted into (0 = not promoted). */
+        public int64 goal_id { get; set; default = 0; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<Note> cfg) {
+            cfg.table("notes")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<string>("body", o => o.body, (o, v) => o.body = v)
+                .column<bool>("done", o => o.done, (o, v) => o.done = v)
+                .column<DateTime?>("done_at", o => o.done_at, (o, v) => o.done_at = v)
+                .column<int64?>("goal_id", o => o.goal_id > 0 ? (int64?) o.goal_id : (int64?) null,
+                    (o, v) => o.goal_id = v ?? 0)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
     /**
      * A named tag used for dispatch routing (`capabilities`): saddles
      * declare the tags they carry, tasks the tags they require.

+ 24 - 1
src/Data/Migrations/M0001_Initial.vala

@@ -20,7 +20,8 @@ namespace ClankerSaddle.Data.Migrations {
      * narrative, resume-notice and log-view surface derives from it,
      * and daemon chunk pushes are idempotent on its
      * UNIQUE(session_id, seq); rules and their attachments; project
-     * notes; capability tags; the plan-11 notification tables.
+     * notes; the dashboard scratchpad notes (goal 8 task 3);
+     * capability tags; the plan-11 notification tables.
      */
     public class M0001_Initial : Migration {
 
@@ -337,6 +338,27 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<DateTime?>("created_at");
             });
 
+            // Personal scratchpad notes (goal 8 task 3): a lightweight
+            // todo/idea list on the dashboard — jotted down before a
+            // batch of changes is ready to become a full goal. A note
+            // promotes into a DRAFT goal (title = first line, brief =
+            // the whole body); goal_id then links the created goal and
+            // freezes the note (no further promote).
+            b.create_table("notes", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<string>("body")
+                    .not_null();
+                t.column<bool>("done")
+                    .not_null()
+                    .default_value<bool>(false);
+                t.column<DateTime?>("done_at");
+                t.column<int64?>("goal_id")
+                    .references("goals", "id");
+                t.column<DateTime?>("created_at");
+            });
+
             b.create_table("capabilities", (t) => {
                 t.column<int64?>("id")
                     .auto_increment()
@@ -415,6 +437,7 @@ namespace ClankerSaddle.Data.Migrations {
             b.drop_table("task_capabilities");
             b.drop_table("saddle_capabilities");
             b.drop_table("capabilities");
+            b.drop_table("notes");
             b.drop_table("project_notes");
             b.drop_table("task_rules");
             b.drop_table("rule_projects");

+ 152 - 0
src/Services/NoteService.vala

@@ -0,0 +1,152 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /**
+     * The dashboard scratchpad (goal 8 task 3): personal notes/todo
+     * rows for jotting down batches of changes BEFORE they are ready
+     * to be full-on goals or tasks. `notes` carries no project (or
+     * any other) semantics — it is the human's own list — and a note
+     * promotes into a DRAFT goal with the body as the brief, after
+     * which it is frozen and linked to that goal.
+     */
+    public class NoteService : Object {
+
+        private OrmSession db = inject<OrmSession>();
+        private GoalService goals = inject<GoalService>();
+        private StatePulse pulse = inject<StatePulse>();
+
+        /** Loads the note identified by `id`, or null. */
+        public async Note? get_note(int64 id) throws Error {
+            return yield db.query<Note>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+        }
+
+        /**
+         * Inserts a note. The body is trimmed and must be non-empty;
+         * a note starts undone and unpromoted.
+         */
+        public async Note add_note(string body) throws Error {
+            string trimmed = body.strip();
+            if (trimmed.length == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 95,
+                    "A note body is required");
+            }
+            var note = new Note() {
+                body = trimmed,
+                done = false,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<Note>(note);
+            yield pulse.bump(0, "note added");
+            return note;
+        }
+
+        /**
+         * Every note, undone first, then newest first (the sort runs
+         * in memory — the ORM's order_by carries a single key).
+         * Promotion freezes a note but does not finish it, so a
+         * promoted note keeps its place by its own done flag.
+         */
+        public async Enumerable<Note> list_notes() throws Error {
+            var rows = yield db.query<Note>()
+                .order_by_desc(expr("id"))
+                .materialise_async();
+            return rows.sort((a, b) => {
+                if (a.done != b.done) {
+                    return a.done ? 1 : -1;
+                }
+                return a.id > b.id ? -1 : 1;
+            });
+        }
+
+        /**
+         * Sets (or clears) a note's done flag, stamping done_at on
+         * the transition. Returns the saved note, or null when no
+         * such note exists. A promoted note stays toggleable — done
+         * still means "I dealt with this", promotion only locks the
+         * create-goal path.
+         */
+        public async Note? set_done(int64 note_id, bool done) throws Error {
+            var note = yield get_note(note_id);
+            if (note == null) {
+                return null;
+            }
+            if (((!)note).done != done) {
+                ((!)note).done = done;
+                ((!)note).done_at = done ? new DateTime.now_local() : null;
+                yield db.update_async<Note>((!)note);
+                yield pulse.bump(0, "note toggled");
+            }
+            return note;
+        }
+
+        /** Deletes the note (idempotent: an absent note is fine). */
+        public async void delete_note(int64 note_id) throws Error {
+            var note = yield get_note(note_id);
+            if (note == null) {
+                return;
+            }
+            yield db.delete_async<Note>((!)note);
+            yield pulse.bump(0, "note deleted");
+        }
+
+        /**
+         * Derives the promote-to-goal title from the note body: the
+         * first non-empty line, clamped to ~80 characters (the clamp
+         * appends an ellipsis when it actually cut anything).
+         */
+        public static string title_from_body(string body) {
+            string first_line = "";
+            foreach (string line in body.split("\n")) {
+                if (line.strip().length > 0) {
+                    first_line = line.strip();
+                    break;
+                }
+            }
+            if (first_line.char_count() > 80) {
+                first_line = first_line.substring(0,
+                    (long) first_line.index_of_nth_char(80)) + "…";
+            }
+            return first_line.length > 0 ? first_line : "Note goal";
+        }
+
+        /**
+         * Promotes an UNDONE, unpromoted note into a DRAFT goal on
+         * `project_id` (title = {@link title_from_body}, brief = the
+         * full note body, branch = `branch_name`), stamps the note's
+         * goal_id and returns the created goal. The human lands on
+         * the goal's page to set links/branch details and press
+         * start. A promoted (or done) note refuses — the row is
+         * frozen once its goal exists.
+         */
+        public async Goal promote_to_goal(int64 note_id, int64 project_id,
+                string branch_name) throws Error {
+            var note = yield get_note(note_id);
+            if (note == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 96,
+                    @"No note exists with id $(note_id)");
+            }
+            if (((!)note).goal_id > 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 97,
+                    @"Note #$(note_id) already promoted to goal #$(((!)note).goal_id)");
+            }
+            if (((!)note).done) {
+                throw new Error(Quark.from_string("clanker-saddle"), 98,
+                    @"Note #$(note_id) is done — only open notes promote to goals");
+            }
+            var goal = yield goals.create_goal(project_id,
+                title_from_body(((!)note).body), ((!)note).body, branch_name, null, 3);
+            ((!)note).goal_id = goal.id;
+            yield db.update_async<Note>((!)note);
+            yield pulse.bump(0, "note promoted");
+            return goal;
+        }
+    }
+}

+ 2 - 1
src/actions/DashboardActions.vala

@@ -16,6 +16,7 @@ namespace ClankerSaddle {
         private TaskService task_service = inject<TaskService>();
         private SaddleService saddle_service = inject<SaddleService>();
         private TimelineService timeline_service = inject<TimelineService>();
+        private NoteService note_service = inject<NoteService>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private PushService push_service = inject<PushService>();
         private UserService users = inject<UserService>();
@@ -28,7 +29,7 @@ namespace ClankerSaddle {
 
             var me = SpryAuth.current(request);
             var state = yield DashboardState.build(state_service, task_service,
-                saddle_service, timeline_service, db, action_registry,
+                saddle_service, timeline_service, db, note_service, action_registry,
                 push_service, me != null ? ((!)me).user_id : 0);
             return directives().set(state);
         }

+ 204 - 0
src/actions/NoteActions.vala

@@ -0,0 +1,204 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** Sealed note identity carried by the note row actions. */
+    public class NoteActionPrivate : Object {
+        public int64 note_id { get; set; }
+    }
+
+    /**
+     * Common base for actions sealed against a note id: materialises
+     * the typed private blob and exposes the resolved id (falling
+     * back to the hidden `note_id` form field).
+     */
+    public abstract class SealedNoteAction : TypedStatumAction<NoteActionPrivate> {
+
+        protected override Type private_type { get { return typeof(NoteActionPrivate); } }
+
+        /** The sealed note id, or 0 when neither blob nor form field carried one. */
+        protected int64 sealed_note_id() {
+            var ctx = request_private;
+            return ctx != null ? ((!)ctx).note_id : Forms.integer(request, "note_id", 0);
+        }
+    }
+
+    /**
+     * Common base for the note-row actions that answer with the
+     * refreshed dashboard slot (toggle/delete — add and promote carry
+     * no sealed id of their own, and add reuses the helper below):
+     * owns the service set {@link DashboardState.build} needs, so a
+     * mutation answers with refreshed state in ONE call.
+     */
+    public abstract class DashboardRefreshingNoteAction : SealedNoteAction {
+
+        protected TaskService task_service = inject<TaskService>();
+        protected SaddleService saddle_service = inject<SaddleService>();
+        protected TimelineService timeline_service = inject<TimelineService>();
+        protected InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
+        protected PushService push_service = inject<PushService>();
+        protected NoteService note_service = inject<NoteService>();
+        protected UserService users = inject<UserService>();
+
+        /**
+         * Rebuilds the dashboard slot from current data — the same
+         * build the page and its pulse refresh run.
+         */
+        protected async Statum.Slot dashboard_slot() throws GLib.Error {
+            var me = SpryAuth.current(request);
+            return yield DashboardState.build(state_service, task_service,
+                saddle_service, timeline_service, db, note_service, action_registry,
+                push_service, me != null ? ((!)me).user_id : 0);
+        }
+    }
+
+    /**
+     * Adds a dashboard scratchpad note (the body is the whole note —
+     * no project or assignment semantics). Answers with the
+     * refreshed dashboard slot, like the page's own refresh. A plain
+     * action (no sealed id — the form is not row-scoped).
+     */
+    public class AddNoteAction : StatumAction {
+
+        private NoteService notes = inject<NoteService>();
+        private TaskService task_service = inject<TaskService>();
+        private SaddleService saddle_service = inject<SaddleService>();
+        private TimelineService timeline_service = inject<TimelineService>();
+        private NoteService note_service = inject<NoteService>();
+        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
+        private PushService push_service = inject<PushService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            string body = Forms.field_raw(request, "body");
+            if (body.strip().length == 0) {
+                return directives().notify("error", "A note body is required");
+            }
+
+            try {
+                yield notes.add_note(body);
+                var me = SpryAuth.current(request);
+                var state = yield DashboardState.build(state_service, task_service,
+                    saddle_service, timeline_service, db, note_service, action_registry,
+                    push_service, me != null ? ((!)me).user_id : 0);
+                return directives().set(state);
+            } catch (Error e) {
+                return directives().notify("error", @"Could not add the note: $(e.message)");
+            }
+        }
+    }
+
+    /**
+     * Toggles a note's done flag (the checkbox on the row). The id
+     * comes from the sealed {@link NoteActionPrivate} blob; answers
+     * with the refreshed dashboard slot.
+     */
+    public class ToggleNoteAction : DashboardRefreshingNoteAction {
+
+        private NoteService notes = inject<NoteService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 note_id = sealed_note_id();
+            if (note_id <= 0) {
+                return directives().navigate("/");
+            }
+
+            try {
+                // Flip (the row button carries no direction field): an
+                // explicit "0"/"1" form value still forces a direction.
+                var current = yield notes.get_note(note_id);
+                if (current == null) {
+                    return directives().notify("error", "No such note").navigate("/");
+                }
+                string direction = Forms.field(request, "done");
+                bool target = direction.length > 0
+                    ? direction == "1" : !((!)current).done;
+                yield notes.set_done(note_id, target);
+                return directives().set(yield dashboard_slot());
+            } catch (Error e) {
+                return directives().notify("error", @"Could not update the note: $(e.message)");
+            }
+        }
+    }
+
+    /**
+     * Deletes a note. The id comes from the sealed private blob;
+     * answers with the refreshed dashboard slot.
+     */
+    public class DeleteNoteAction : DashboardRefreshingNoteAction {
+
+        private NoteService notes = inject<NoteService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 note_id = sealed_note_id();
+            if (note_id <= 0) {
+                return directives().navigate("/");
+            }
+
+            try {
+                yield notes.delete_note(note_id);
+                return directives().set(yield dashboard_slot());
+            } catch (Error e) {
+                return directives().notify("error", @"Could not delete the note: $(e.message)");
+            }
+        }
+    }
+
+    /**
+     * Promotes a note into a DRAFT goal (goal 8 task 3): title = the
+     * note's first line, brief = the full note body, on the chosen
+     * project with the chosen branch. The note is stamped with the
+     * goal id (frozen — no further promote) and the human lands on
+     * the goal's draft page to finish it (links, base branch, start).
+     */
+    public class PromoteNoteAction : SealedNoteAction {
+
+        private NoteService notes = inject<NoteService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 note_id = sealed_note_id();
+            if (note_id <= 0) {
+                return directives().navigate("/");
+            }
+
+            try {
+                var goal = yield notes.promote_to_goal(note_id,
+                    Forms.integer(request, "project_id", 0),
+                    Forms.field(request, "branch_name"));
+                return directives()
+                    .notify("success", @"Goal '$(goal.title)' created in draft from note #$(note_id) — set it up and press start")
+                    .navigate(@"/goals/$(goal.id)");
+            } catch (Error e) {
+                return directives()
+                    .notify("error", @"Could not create the goal from note #$(note_id): $(e.message)")
+                    .navigate("/");
+            }
+        }
+    }
+}

+ 2 - 1
src/actions/PushActions.vala

@@ -100,6 +100,7 @@ namespace ClankerSaddle {
         private TaskService task_service = inject<TaskService>();
         private SaddleService saddle_service = inject<SaddleService>();
         private TimelineService timeline_service = inject<TimelineService>();
+        private NoteService note_service = inject<NoteService>();
         private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
         private PushService push = inject<PushService>();
         private UserService users = inject<UserService>();
@@ -138,7 +139,7 @@ namespace ClankerSaddle {
                     }
                 }
                 var state = yield DashboardState.build(state_service, task_service,
-                    saddle_service, timeline_service, db, action_registry,
+                    saddle_service, timeline_service, db, note_service, action_registry,
                     push, me.user_id);
                 return directives().notify(kind, message).set(state);
             } catch (Error e) {

+ 83 - 2
src/entrypoints/HomeEntrypoint.vala

@@ -75,6 +75,32 @@ namespace ClankerSaddle {
         public int64 at_epoch { get; set; }
     }
 
+    /** A project option in the note promote form's select. */
+    public class DashboardProjectOptionView : Object {
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+    }
+
+    /**
+     * A scratchpad note row on the dashboard (goal 8 task 3): the
+     * body, its done state, and the per-row actions. An unpromoted,
+     * undone note carries the promote action; a promoted note carries
+     * its goal link instead (and never a second promote).
+     */
+    public class DashboardNoteView : Object {
+        public int64 id { get; set; }
+        public string body { get; set; default = ""; }
+        public bool done { get; set; }
+        public string done_display { get; set; default = ""; }
+        public string created_display { get; set; default = ""; }
+        /** The goal this note promoted into (0 = not promoted). */
+        public int64 goal_id { get; set; }
+        public string goal_title { get; set; default = ""; }
+        public Statum.Model.ActionDto? toggle { get; set; }
+        public Statum.Model.ActionDto? delete { get; set; }
+        public Statum.Model.ActionDto? promote { get; set; }
+    }
+
     /** Public data of the dashboard's `dashboard` slot. */
     public class DashboardPublic : Object {
         public int64 draft_count { get; set; }
@@ -106,6 +132,11 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? push_unsubscribe { get; set; }
         /** Queues a REAL test push to this user (the bell's probe). */
         public Statum.Model.ActionDto? push_test { get; set; }
+        /**
+         * Adds a scratchpad note (goal 8 task 3) — the Notes card's
+         * add form.
+         */
+        public Statum.Model.ActionDto? add_note { get; set; }
     }
 
     /**
@@ -126,7 +157,7 @@ namespace ClankerSaddle {
          */
         public static async Statum.Slot build(StateService state_service, TaskService tasks,
                 SaddleService saddles, TimelineService timeline,
-                InvercargillSql.Orm.OrmSession db,
+                InvercargillSql.Orm.OrmSession db, NoteService notes,
                 ActionRegistry? actions = null, PushService? push = null,
                 int64 viewer_id = 0) throws GLib.Error {
             var pub = new DashboardPublic();
@@ -304,6 +335,7 @@ namespace ClankerSaddle {
                 pub.push_subscribe = ((!)actions).author<PushSubscribeAction>();
                 pub.push_unsubscribe = ((!)actions).author<PushUnsubscribeAction>();
                 pub.push_test = ((!)actions).author<PushTestAction>();
+                pub.add_note = ((!)actions).author<AddNoteAction>();
             }
             // Web Push bell state (plan 11 task E; per-browser since
             // the user-global flag lied to second browsers): the VAPID
@@ -344,6 +376,54 @@ namespace ClankerSaddle {
             props["active_tasks"] = StateJson.list<DashboardActiveView>(active.to_immutable_buffer());
             props["pinned"] = StateJson.list<DashboardBlockView>(pinned_sorted.to_immutable_buffer());
 
+            // The scratchpad (goal 8 task 3): every note with its
+            // per-row sealed actions; promoted notes link their goal
+            // and never carry a promote action again.
+            var note_rows = new Vector<DashboardNoteView>();
+            foreach (var note in yield notes.list_notes()) {
+                var view = new DashboardNoteView() {
+                    id = note.id,
+                    body = note.body,
+                    done = note.done,
+                    created_display = format_stamp(note.created_at),
+                    toggle = ((!)actions).author_private<ToggleNoteAction, NoteActionPrivate>(
+                        new NoteActionPrivate() { note_id = note.id }),
+                    delete = ((!)actions).author_private<DeleteNoteAction, NoteActionPrivate>(
+                        new NoteActionPrivate() { note_id = note.id })
+                };
+                if (note.done_at != null) {
+                    view.done_display = format_stamp(note.done_at);
+                }
+                if (note.goal_id > 0) {
+                    view.goal_id = note.goal_id;
+                    var goal_row = yield db.query<Goal>()
+                        .where(expr("id == $0", elem<int64?>(note.goal_id)))
+                        .first_async();
+                    if (goal_row != null) {
+                        view.goal_title = ((!)goal_row).title;
+                    }
+                } else if (!note.done) {
+                    view.promote = ((!)actions).author_private<PromoteNoteAction, NoteActionPrivate>(
+                        new NoteActionPrivate() { note_id = note.id });
+                }
+                note_rows.add(view);
+            }
+            props["notes"] = StateJson.list<DashboardNoteView>(note_rows.to_immutable_buffer());
+
+            // The promote form's project options (goal 8 task 3):
+            // every project, oldest first — a stable select order.
+            var project_options = new Vector<DashboardProjectOptionView>();
+            foreach (var project in yield db.query<Project>()
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                project_options.add(new DashboardProjectOptionView() {
+                    id = project.id,
+                    name = project.name
+                });
+            }
+            props["projects"] = StateJson.list<DashboardProjectOptionView>(
+                project_options.to_immutable_buffer());
+
             return state_service.new_slot(Scope.PAGE, new Statum.State() {
                 type_name = "dashboard",
                 public_data = props,
@@ -383,6 +463,7 @@ namespace ClankerSaddle {
         private TaskService task_service = Inversion.inject<TaskService>();
         private SaddleService saddle_service = Inversion.inject<SaddleService>();
         private TimelineService timeline_service = Inversion.inject<TimelineService>();
+        private NoteService note_service = Inversion.inject<NoteService>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private PushService push_service = Inversion.inject<PushService>();
         private UserService users = Inversion.inject<UserService>();
@@ -395,7 +476,7 @@ namespace ClankerSaddle {
 
             var me = SpryAuth.current(request);
             var state = yield DashboardState.build(state_service, task_service,
-                saddle_service, timeline_service, db, action_registry,
+                saddle_service, timeline_service, db, note_service, action_registry,
                 push_service, me != null ? ((!)me).user_id : 0);
             return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
         }

+ 6 - 0
src/main.vala

@@ -64,6 +64,7 @@ int main(string[] args) {
         db.add_entity<GoalLink>(GoalLink.entity_mapping);
         db.add_entity<Stack>(Stack.entity_mapping);
         db.add_entity<StackProject>(StackProject.entity_mapping);
+        db.add_entity<Note>(Note.entity_mapping);
         db.migrate_on_startup();
 
         application.add_scoped<ProjectService>();
@@ -71,6 +72,7 @@ int main(string[] args) {
         application.add_scoped<EventService>();
         application.add_scoped<GoalService>();
         application.add_scoped<StackService>();
+        application.add_scoped<NoteService>();
         application.add_scoped<SaddleService>();
         application.add_scoped<LifecycleService>();
         application.add_scoped<RuleService>();
@@ -169,6 +171,10 @@ int main(string[] args) {
         statum.action<RemoveStackMemberAction>();
         statum.action<ApplyStackToProjectAction>();
         statum.action<ApplyStackToGoalAction>();
+        statum.action<AddNoteAction>();
+        statum.action<ToggleNoteAction>();
+        statum.action<DeleteNoteAction>();
+        statum.action<PromoteNoteAction>();
         statum.action<CreateCapabilityAction>();
         statum.action<DeleteCapabilityAction>();
         statum.action<AttachSaddleCapabilityAction>();

+ 52 - 0
src/pages/home.html

@@ -276,6 +276,58 @@
                 </section>
             </div>
 
+            <!-- The scratchpad (goal 8 task 3): jot down batches of
+                 changes before they are ready to be full-on goals.
+                 Check a note off, delete it, or promote it — promote
+                 creates a DRAFT goal (title = the first line, brief =
+                 the whole body) and jumps to its page to finish. -->
+            <h3>Notes</h3>
+            <section class="card">
+                <form stm-action="dashboard.add_note">
+                    <div class="form-row">
+                        <textarea name="body" rows="2" required
+                                  placeholder="Note down a batch of changes before it is ready to be a goal…"></textarea></div>
+                    <button type="submit">Add note</button>
+                </form>
+            </section>
+            <table stm-if="dashboard.notes.length > 0">
+                <thead><tr><th>Done</th><th>Note</th><th>Created</th><th></th></tr></thead>
+                <tbody>
+                    <tr stm-for-note-in="dashboard.notes">
+                        <td><button type="button" stm-action="note.toggle"
+                                    stm-attribute.class="note.done ? 'approve' : ''"
+                                    stm-text="note.done ? 'Done' : 'Open'">toggle</button></td>
+                        <td><span stm-attribute.class="note.done ? 'footnote' : ''"
+                                  stm-text="note.body">body</span></td>
+                        <td class="muted" stm-text="note.created_display">when</td>
+                        <td>
+                            <span class="inline" stm-if="note.goal_id > 0">
+                                <span class="goal-icon">🎯</span>
+                                <a stm-attribute.href="'/goals/' + note.goal_id"
+                                   stm-text="note.goal_title">goal</a>
+                            </span>
+                            <form class="inline" stm-if="note.goal_id == 0 && !note.done"
+                                  stm-action="note.promote">
+                                <select name="project_id" required>
+                                    <option stm-for-project-in="dashboard.projects"
+                                            stm-attribute.value="project.id"
+                                            stm-text="project.name">project</option>
+                                </select>
+                                <input type="text" name="branch_name" required
+                                       placeholder="goal/branch" size="14">
+                                <button type="submit" class="primary">Create goal</button>
+                            </form>
+                            <span class="muted inline" stm-if="note.goal_id == 0 && note.done">—</span>
+                            <form class="inline" stm-action="note.delete">
+                                <button type="submit">Delete</button>
+                            </form>
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
+            <p stm-if="dashboard.notes.length == 0" class="muted">No notes yet — jot down the
+                next batch of changes above.</p>
+
             <h3 stm-if="dashboard.pinned.length > 0">Needs a human
                 (<span stm-text="dashboard.pinned.length">0</span> open)</h3>
             <p class="muted" stm-if="dashboard.pinned.length > 0">Open questions, RFCs and

+ 2 - 1
test/bundles/__init__.py

@@ -4,7 +4,7 @@ the modules' star-imported runtime globals are the shard's own). The
 order here is the parallel runner's shard order."""
 
 from . import (allocation, answers, dispatch, goalui, goals, lineage,
-               lifecycle, pages, push, replay, stacks, subtasks, users)
+               lifecycle, notes, pages, push, replay, stacks, subtasks, users)
 
 BUNDLES = {
     "pages": pages,
@@ -20,6 +20,7 @@ BUNDLES = {
     "goalui": goalui,
     "users": users,
     "stacks": stacks,
+    "notes": notes,
 }
 
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",

+ 183 - 0
test/bundles/notes.py

@@ -0,0 +1,183 @@
+"""The notes bundle (goal 8 task 3): the dashboard scratchpad — a
+personal todo list for batches of changes that are not ready to be
+full-on goals yet, with promote-to-goal (a DRAFT goal whose brief is
+the whole note body, the note frozen and linked to the goal)."""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+def run(ctx):
+    auth = ctx.auth
+
+    def dashboard():
+        return slot_public(auth, "/", "dashboard")
+
+    def note_rows():
+        return dashboard()["notes"]
+
+    def note_by_body(fragment):
+        rows = [n for n in note_rows() if fragment in n["body"]]
+        return rows[0] if rows else None
+
+    def db_one(sql, params=()):
+        conn = db()
+        try:
+            return conn.execute(sql, params).fetchone()
+        finally:
+            conn.close()
+
+    print("== notes: the dashboard carries the scratchpad ==")
+    dash = dashboard()
+    check(dash.get("add_note") is not None,
+          "the dashboard exposes the add-note action")
+    check(dash["notes"] == [],
+          "the scratchpad starts empty")
+    check(len(dash["projects"]) >= 1,
+          "the promote form's project options list the projects")
+    check(any(p["name"] == "demo" for p in dash["projects"]),
+          "the demo project is a promote option")
+
+    print("== notes: add ==")
+    result = act(auth, dash["add_note"], {"body": ""})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error" for d in result),
+          "an empty note body refuses")
+    act(auth, dash["add_note"],
+        {"body": "Fix the login redirect\n\nAlso cover the remember-me path in tests."})
+    act(auth, dashboard()["add_note"], {"body": "Second note, older work"})
+    rows = note_rows()
+    check(len(rows) == 2,
+          "added notes land in the list")
+    check(rows[0]["body"] == "Second note, older work"
+          and rows[1]["body"].startswith("Fix the login redirect"),
+          "notes list newest first")
+    first = note_by_body("Fix the login redirect")
+    check(first["done"] is False and str(first["goal_id"]) == "0",
+          "a fresh note is undone and unpromoted")
+    check(first.get("toggle") is not None and first.get("delete") is not None
+          and first.get("promote") is not None,
+          "an undone unpromoted note carries toggle, delete and promote actions")
+
+    print("== notes: toggle done -> ordering flips ==")
+    act(auth, first["toggle"], {})
+    rows = note_rows()
+    check(rows[0]["body"] == "Second note, older work"
+          and rows[1]["body"].startswith("Fix the login redirect"),
+          "a done note sinks below every undone note")
+    done_row = note_by_body("Fix the login redirect")
+    check(done_row["done"] is True and len(done_row.get("done_display") or "") > 0,
+          "the done flag sticks with its stamp")
+    check(done_row.get("promote") is None,
+          "a done note offers no promote action")
+    conn = db()
+    stamped = conn.execute(
+        "select done, done_at from notes where id = ?", (first["id"],)).fetchone()
+    conn.close()
+    check(stamped is not None and stamped[0] == 1 and stamped[1] is not None,
+          "the done transition stamped done_at in the database")
+    # Toggle back: the note returns to the top and promote returns.
+    act(auth, done_row["toggle"], {})
+    rows = note_rows()
+    check(rows[0]["body"] == "Second note, older work"
+          and rows[1]["body"].startswith("Fix the login redirect"),
+          "untoggling restores the newest-first ordering among undone notes")
+    reopened = note_by_body("Fix the login redirect")
+    check(reopened["done"] is False and reopened.get("promote") is not None,
+          "an undone note regains its promote action")
+    conn = db()
+    cleared = conn.execute(
+        "select done, done_at from notes where id = ?", (first["id"],)).fetchone()
+    conn.close()
+    check(cleared[0] == 0 and cleared[1] is None,
+          "untoggling clears the done stamp")
+
+    print("== notes: delete ==")
+    act(auth, dashboard()["add_note"], {"body": "Throwaway note"})
+    doomed = note_by_body("Throwaway note")
+    act(auth, doomed["delete"], {})
+    check(note_by_body("Throwaway note") is None,
+          "a deleted note disappears from the list")
+    conn = db()
+    leftover = conn.execute(
+        "select count(*) from notes where body like 'Throwaway%'").fetchone()[0]
+    conn.close()
+    check(leftover == 0, "the deleted note's row is gone")
+
+    print("== notes: promote to goal ==")
+    body = ("Fix the login redirect\n\nAlso cover the remember-me path in tests.\n"
+            "The redirect should honour the originally requested page.")
+    act(auth, dashboard()["add_note"], {"body": body})
+    note = note_by_body("Fix the login redirect")
+    promote = note["promote"]
+    result = act(auth, promote,
+                 {"project_id": "1", "branch_name": "goal/note-promote"})
+    navigated = [d for d in result if d.get("type") == "navigate"]
+    check(len(navigated) == 1 and navigated[0].get("uri", "").startswith("/goals/"),
+          "promoting navigates to the created goal's page")
+    goal_id = int(navigated[0]["uri"].rsplit("/", 1)[1])
+
+    goal_row = db_one("select id, project_id, title, brief, branch_name, status"
+                      " from goals where id = ?", (goal_id,))
+    check(goal_row is not None,
+          "promoting created a goal row")
+    check(goal_row[1] == 1 and goal_row[4] == "goal/note-promote"
+          and goal_row[5] == "draft",
+          "the promoted goal sits on the chosen project and branch in draft")
+    check(goal_row[3] == body,
+          "the promoted goal's brief is the full note body")
+    check(goal_row[2] == "Fix the login redirect",
+          "the promoted goal's title is the note's first line")
+
+    state = goal_state(goal_id)
+    check(state.get("exists") and state.get("title") == "Fix the login redirect",
+          "the goal page shows the promoted goal")
+
+    rows = note_rows()
+    promoted = [n for n in rows if n["id"] == note["id"]][0]
+    check(str(promoted["goal_id"]) == str(goal_id)
+          and promoted["goal_title"] == "Fix the login redirect",
+          "the promoted note links its goal with the goal's title")
+    check(promoted.get("promote") is None,
+          "a promoted note is frozen (no further promote)")
+
+    # Promoting a promoted note refuses — the action DTO is gone from
+    # the slot, so the service-level guard is exercised via a direct
+    # re-promote attempt through the old DTO.
+    again = act(auth, promote, {"project_id": "1", "branch_name": "goal/note-promote-2"})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error"
+              and "already promoted" in (d.get("message") or "") for d in again),
+          "re-promoting a promoted note refuses with the frozen error")
+    conn = db()
+    duplicates = conn.execute(
+        "select count(*) from goals where branch_name = 'goal/note-promote-2'"
+    ).fetchone()[0]
+    conn.close()
+    check(duplicates == 0, "the refused re-promotion created no second goal")
+
+    print("== notes: promote validation ==")
+    act(auth, dashboard()["add_note"], {"body": "Never promoted\nbody"})
+    bad = note_by_body("Never promoted")
+    missing = act(auth, bad["promote"], {"project_id": "9999",
+                                         "branch_name": "goal/notes-bad"})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error" for d in missing),
+          "promoting onto a missing project refuses")
+    nobranch = act(auth, note_by_body("Never promoted")["promote"],
+                   {"project_id": "1", "branch_name": ""})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error" for d in nobranch),
+          "promoting without a branch name refuses")
+    survivor = note_by_body("Never promoted")
+    check(str(survivor["goal_id"]) == "0" and survivor.get("promote") is not None,
+          "the refused promotions left the note unpromoted")
+
+    print("== notes: the long-title clamp ==")
+    long_line = "L" * 120 + "\nand a second line"
+    act(auth, dashboard()["add_note"], {"body": long_line})
+    clamped = note_by_body("LLLL")
+    result = act(auth, clamped["promote"],
+                 {"project_id": "1", "branch_name": "goal/note-clamp"})
+    goal_id2 = int([d for d in result if d.get("type") == "navigate"][0]["uri"]
+                   .rsplit("/", 1)[1])
+    title = db_one("select title from goals where id = ?", (goal_id2,))[0]
+    check(len(title) <= 81 and title.endswith("…"),
+          "a first line over 80 characters clamps with an ellipsis")
+    brief = db_one("select brief from goals where id = ?", (goal_id2,))[0]
+    check(brief == long_line,
+          "the clamped title never truncates the brief (the full body rides along)")

+ 1 - 1
test/harness.py

@@ -1314,7 +1314,7 @@ def start_bundle(name):
 
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "push", "goals",
-                 "replay", "goalui", "forcewake", "users", "stacks")
+                 "replay", "goalui", "forcewake", "users", "stacks", "notes")
 
 
 def run_bundle(name):