Merge the definition of done and the blocker (#185)
This commit is contained in:
@@ -1050,6 +1050,12 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
|||||||
return "", fmt.Errorf("unknown action %q", action)
|
return "", fmt.Errorf("unknown action %q", action)
|
||||||
}
|
}
|
||||||
if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil {
|
if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil {
|
||||||
|
if errors.Is(err, ipc.ErrTaskNoDoneWhen) {
|
||||||
|
// The refusal has to name what is missing, or the button looks
|
||||||
|
// broken. The field it asks for arrives with the intake form
|
||||||
|
// (Vikunja #511).
|
||||||
|
return "", errors.New("write a definition of done before confirming this candidate")
|
||||||
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return msg, nil
|
return msg, nil
|
||||||
|
|||||||
@@ -142,6 +142,11 @@ type Task struct {
|
|||||||
Weight int `json:"weight,omitempty"`
|
Weight int `json:"weight,omitempty"`
|
||||||
Resolved *time.Time `json:"resolved,omitempty"`
|
Resolved *time.Time `json:"resolved,omitempty"`
|
||||||
ResolvedBy string `json:"resolved_by,omitempty"`
|
ResolvedBy string `json:"resolved_by,omitempty"`
|
||||||
|
// DoneWhen — the acceptance criterion. Empty until he writes one, and a
|
||||||
|
// candidate with no criterion cannot be promoted to open (Vikunja #510).
|
||||||
|
DoneWhen string `json:"done_when,omitempty"`
|
||||||
|
// BlockedOn — a canonical Nexus entity id, never a name.
|
||||||
|
BlockedOn string `json:"blocked_on,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CaptureTaskReq — THE INTAKE SEAM. Everything that captures a task goes
|
// CaptureTaskReq — THE INTAKE SEAM. Everything that captures a task goes
|
||||||
@@ -170,6 +175,11 @@ type CaptureTaskReq struct {
|
|||||||
Due *time.Time `json:"due,omitempty"`
|
Due *time.Time `json:"due,omitempty"`
|
||||||
Weight int `json:"weight,omitempty"`
|
Weight int `json:"weight,omitempty"`
|
||||||
Ts time.Time `json:"ts"`
|
Ts time.Time `json:"ts"`
|
||||||
|
// DoneWhen and BlockedOn are optional at intake. A derived source leaves
|
||||||
|
// both empty: mail says what to do, not what finishing means, and guessing
|
||||||
|
// a criterion would put Maven's reading in the field he is meant to write.
|
||||||
|
DoneWhen string `json:"done_when,omitempty"`
|
||||||
|
BlockedOn string `json:"blocked_on,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CaptureTaskResp — Created is false when the same live task already existed,
|
// CaptureTaskResp — Created is false when the same live task already existed,
|
||||||
@@ -539,6 +549,16 @@ type setTaskStatusReq struct {
|
|||||||
By string `json:"by,omitempty"`
|
By string `json:"by,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setTaskFieldsReq — the write for the two board columns. Both are sent every
|
||||||
|
// time and both may be empty: clearing a blocker is as ordinary as setting one,
|
||||||
|
// so an omitted field cannot mean "leave it alone" without a second way to say
|
||||||
|
// "make it empty".
|
||||||
|
type setTaskFieldsReq struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
DoneWhen string `json:"done_when,omitempty"`
|
||||||
|
BlockedOn string `json:"blocked_on,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// idReq — methods keyed by a single id.
|
// idReq — methods keyed by a single id.
|
||||||
type idReq struct {
|
type idReq struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
@@ -847,6 +867,11 @@ type unlockReq struct {
|
|||||||
// wire round-tripping via errors.Is).
|
// wire round-tripping via errors.Is).
|
||||||
var ErrToolNotFound = errors.New("ipc: tool not found")
|
var ErrToolNotFound = errors.New("ipc: tool not found")
|
||||||
|
|
||||||
|
// ErrTaskNoDoneWhen — a candidate cannot be promoted to open with no
|
||||||
|
// definition of done (Vikunja #510). Carried across the wire so the /tasks
|
||||||
|
// form can say which refusal it hit rather than "не найдено".
|
||||||
|
var ErrTaskNoDoneWhen = errors.New("ipc: task has no definition of done")
|
||||||
|
|
||||||
// callerKey — context key for the authenticated caller. Server sets it from
|
// callerKey — context key for the authenticated caller. Server sets it from
|
||||||
// SO_PEERCRED before dispatch; in-process callers omit it (the adapter treats
|
// SO_PEERCRED before dispatch; in-process callers omit it (the adapter treats
|
||||||
// a missing Caller as "trusted same-process", the equivalent of the socket's
|
// a missing Caller as "trusted same-process", the equivalent of the socket's
|
||||||
|
|||||||
@@ -515,6 +515,10 @@ func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts
|
|||||||
return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts, By: by}, nil)
|
return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts, By: by}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
|
||||||
|
return c.call(ctx, MethodSetTaskFields, setTaskFieldsReq{ID: id, DoneWhen: doneWhen, BlockedOn: blockedOn}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
// IngestMail hands one fetched message to core for extraction. ErrUnknownMethod
|
// IngestMail hands one fetched message to core for extraction. ErrUnknownMethod
|
||||||
// means core has no email block configured — the caller should stop asking, not
|
// means core has no email block configured — the caller should stop asking, not
|
||||||
// retry.
|
// retry.
|
||||||
|
|||||||
@@ -142,6 +142,11 @@ type TaskAPI interface {
|
|||||||
// SetTaskStatus moves a task forward once: candidate→open|dropped,
|
// SetTaskStatus moves a task forward once: candidate→open|dropped,
|
||||||
// open→done|dropped. Any other move is refused.
|
// open→done|dropped. Any other move is refused.
|
||||||
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
|
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
|
||||||
|
// SetTaskFields writes the definition of done and the blocker. Not a
|
||||||
|
// status move, so it is not one-way: he may sharpen a criterion, and a
|
||||||
|
// blocker clears when the person answers. blockedOn is a canonical Nexus
|
||||||
|
// entity id or empty, never a name the caller had lying around.
|
||||||
|
SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemAPI — what the daemon knows about itself, plus the one method that
|
// SystemAPI — what the daemon knows about itself, plus the one method that
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ var mapErrPairs = []struct {
|
|||||||
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
|
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
|
||||||
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
|
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
|
||||||
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
|
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
|
||||||
|
{"ErrTaskNoDoneWhen", store.ErrTaskNoDoneWhen, ErrTaskNoDoneWhen},
|
||||||
}
|
}
|
||||||
|
|
||||||
// unmappedStoreErrors — store sentinels that deliberately have no wire twin,
|
// unmappedStoreErrors — store sentinels that deliberately have no wire twin,
|
||||||
|
|||||||
@@ -540,6 +540,9 @@ var methodTable = map[Method]handlerFunc{
|
|||||||
MethodSetTaskStatus: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskStatusReq) error {
|
MethodSetTaskStatus: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskStatusReq) error {
|
||||||
return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts, p.By)
|
return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts, p.By)
|
||||||
}),
|
}),
|
||||||
|
MethodSetTaskFields: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskFieldsReq) error {
|
||||||
|
return api.SetTaskFields(ctx, p.ID, p.DoneWhen, p.BlockedOn)
|
||||||
|
}),
|
||||||
MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) {
|
MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) {
|
||||||
out, err := api.ListProposedRoutines(ctx)
|
out, err := api.ListProposedRoutines(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -317,6 +317,8 @@ func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (Capture
|
|||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
Due: req.Due,
|
Due: req.Due,
|
||||||
Weight: req.Weight,
|
Weight: req.Weight,
|
||||||
|
DoneWhen: req.DoneWhen,
|
||||||
|
BlockedOn: req.BlockedOn,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CaptureTaskResp{}, mapErr(err)
|
return CaptureTaskResp{}, mapErr(err)
|
||||||
@@ -343,6 +345,8 @@ func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error)
|
|||||||
Weight: t.Weight,
|
Weight: t.Weight,
|
||||||
Resolved: t.ResolvedTs,
|
Resolved: t.ResolvedTs,
|
||||||
ResolvedBy: t.ResolvedBy,
|
ResolvedBy: t.ResolvedBy,
|
||||||
|
DoneWhen: t.DoneWhen,
|
||||||
|
BlockedOn: t.BlockedOn,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
@@ -352,6 +356,10 @@ func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, t
|
|||||||
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by))
|
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
|
||||||
|
return mapErr(a.s.SetTaskFields(ctx, id, doneWhen, blockedOn))
|
||||||
|
}
|
||||||
|
|
||||||
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||||
rs, err := a.s.ListProposedRoutines(ctx)
|
rs, err := a.s.ListProposedRoutines(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -458,6 +466,8 @@ func mapErr(err error) error {
|
|||||||
return ErrReminderState
|
return ErrReminderState
|
||||||
case errors.Is(err, store.ErrToolNotFound):
|
case errors.Is(err, store.ErrToolNotFound):
|
||||||
return ErrToolNotFound
|
return ErrToolNotFound
|
||||||
|
case errors.Is(err, store.ErrTaskNoDoneWhen):
|
||||||
|
return ErrTaskNoDoneWhen
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Tas
|
|||||||
func (UnimplementedCoreAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
|
func (UnimplementedCoreAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
|
||||||
return ErrNotImplemented
|
return ErrNotImplemented
|
||||||
}
|
}
|
||||||
|
func (UnimplementedCoreAPI) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
|
||||||
|
return ErrNotImplemented
|
||||||
|
}
|
||||||
func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||||
return nil, ErrNotImplemented
|
return nil, ErrNotImplemented
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const (
|
|||||||
MethodCaptureTask Method = "capture_task"
|
MethodCaptureTask Method = "capture_task"
|
||||||
MethodListTasks Method = "list_tasks"
|
MethodListTasks Method = "list_tasks"
|
||||||
MethodSetTaskStatus Method = "set_task_status"
|
MethodSetTaskStatus Method = "set_task_status"
|
||||||
|
MethodSetTaskFields Method = "set_task_fields"
|
||||||
MethodIngestMail Method = "ingest_mail"
|
MethodIngestMail Method = "ingest_mail"
|
||||||
MethodSwapModel Method = "swap_model"
|
MethodSwapModel Method = "swap_model"
|
||||||
MethodModelStatus Method = "model_status"
|
MethodModelStatus Method = "model_status"
|
||||||
|
|||||||
@@ -290,6 +290,16 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
|||||||
ALTER TABLE nudges_new RENAME TO nudges;
|
ALTER TABLE nudges_new RENAME TO nudges;
|
||||||
CREATE INDEX IF NOT EXISTS idx_nudges_rule_ts ON nudges (rule, ts DESC);
|
CREATE INDEX IF NOT EXISTS idx_nudges_rule_ts ON nudges (rule, ts DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_nudges_outcome ON nudges (outcome);`,
|
CREATE INDEX IF NOT EXISTS idx_nudges_outcome ON nudges (outcome);`,
|
||||||
|
|
||||||
|
// #22 — the two columns that make tasks a work board rather than a to-do
|
||||||
|
// list (Vikunja #510). done_when is the acceptance criterion, and blocked_on
|
||||||
|
// is a canonical Nexus entity id: it names a person, identity lives in
|
||||||
|
// Nexus, and a local free-text name would be a second answer to a question
|
||||||
|
// Nexus already owns. Both default to empty rather than NULL, because "he
|
||||||
|
// has not written one" and "there is nothing to write" are the same state
|
||||||
|
// here and no caller has to tell them apart.
|
||||||
|
`ALTER TABLE tasks ADD COLUMN done_when TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE tasks ADD COLUMN blocked_on TEXT NOT NULL DEFAULT '';`,
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrate applies every migration with a number greater than the DB's current
|
// migrate applies every migration with a number greater than the DB's current
|
||||||
|
|||||||
+68
-6
@@ -66,6 +66,16 @@ type Task struct {
|
|||||||
Weight int
|
Weight int
|
||||||
ResolvedTs *time.Time
|
ResolvedTs *time.Time
|
||||||
ResolvedBy string
|
ResolvedBy string
|
||||||
|
|
||||||
|
// DoneWhen — the acceptance criterion, in his words. It must be able to
|
||||||
|
// close on either outcome: "it already works" counts as complete, and a
|
||||||
|
// criterion only one result satisfies is a wish rather than a definition
|
||||||
|
// (Vikunja #510). Empty until he writes one.
|
||||||
|
DoneWhen string
|
||||||
|
// BlockedOn — a canonical Nexus entity id, never a name. Identity lives in
|
||||||
|
// Nexus, so storing "Саша" here would be a second answer to a question
|
||||||
|
// Nexus already owns. Empty when nothing blocks the task.
|
||||||
|
BlockedOn string
|
||||||
}
|
}
|
||||||
|
|
||||||
// CaptureResult — what CaptureTask did. Created is a new row. Promoted is an
|
// CaptureResult — what CaptureTask did. Created is a new row. Promoted is an
|
||||||
@@ -82,6 +92,12 @@ var (
|
|||||||
ErrTaskNotFound = errors.New("store: task not found")
|
ErrTaskNotFound = errors.New("store: task not found")
|
||||||
ErrTaskEmpty = errors.New("store: task text is empty")
|
ErrTaskEmpty = errors.New("store: task text is empty")
|
||||||
ErrTaskStatus = errors.New("store: invalid task status")
|
ErrTaskStatus = errors.New("store: invalid task status")
|
||||||
|
// ErrTaskNoDoneWhen — a candidate cannot be promoted to open without a
|
||||||
|
// definition of done (Vikunja #510). Same refusal ParseTaskCapture makes
|
||||||
|
// for a capture marker with nothing after it: confirming work whose
|
||||||
|
// finish line nobody wrote is how a board fills with rows that can never
|
||||||
|
// leave it. Dropping such a candidate stays legal.
|
||||||
|
ErrTaskNoDoneWhen = errors.New("store: task has no definition of done")
|
||||||
)
|
)
|
||||||
|
|
||||||
// liveTaskStatuses — the two statuses that count as outstanding work.
|
// liveTaskStatuses — the two statuses that count as outstanding work.
|
||||||
@@ -160,10 +176,10 @@ func (s *Store) CaptureTask(ctx context.Context, t Task) (CaptureResult, error)
|
|||||||
// Untargeted DO NOTHING: either unique index may be the one that fires, and
|
// Untargeted DO NOTHING: either unique index may be the one that fires, and
|
||||||
// the lookup below sorts out which.
|
// the lookup below sorts out which.
|
||||||
res, err := s.db.ExecContext(ctx,
|
res, err := s.db.ExecContext(ctx,
|
||||||
`INSERT INTO tasks (created_ts, text, norm, source, evidence, ext_id, status, due_ts, weight)
|
`INSERT INTO tasks (created_ts, text, norm, source, evidence, ext_id, status, due_ts, weight, done_when, blocked_on)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||||
ON CONFLICT DO NOTHING`,
|
ON CONFLICT DO NOTHING`,
|
||||||
created2.UnixMilli(), text, norm, t.Source, t.Evidence, ext, status, due, t.Weight)
|
created2.UnixMilli(), text, norm, t.Source, t.Evidence, ext, status, due, t.Weight, t.DoneWhen, t.BlockedOn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CaptureResult{}, fmt.Errorf("capture task: %w", err)
|
return CaptureResult{}, fmt.Errorf("capture task: %w", err)
|
||||||
}
|
}
|
||||||
@@ -194,7 +210,7 @@ func (s *Store) CaptureTask(ctx context.Context, t Task) (CaptureResult, error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if status == TaskOpen && existing.Status == TaskCandidate {
|
if status == TaskOpen && existing.Status == TaskCandidate {
|
||||||
if err := s.SetTaskStatus(ctx, existing.ID, TaskOpen, created2, t.Source); err != nil {
|
if err := s.setTaskStatus(ctx, existing.ID, TaskOpen, created2, t.Source, false); err != nil {
|
||||||
return CaptureResult{}, fmt.Errorf("capture task: promote candidate: %w", err)
|
return CaptureResult{}, fmt.Errorf("capture task: promote candidate: %w", err)
|
||||||
}
|
}
|
||||||
return CaptureResult{ID: existing.ID, Promoted: true}, nil
|
return CaptureResult{ID: existing.ID, Promoted: true}, nil
|
||||||
@@ -231,7 +247,7 @@ func (s *Store) lookupLiveTaskByNorm(ctx context.Context, norm string) (Task, er
|
|||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskSelect = `SELECT id, created_ts, text, source, evidence, COALESCE(ext_id,''), status, due_ts, weight, resolved_ts, resolved_by FROM tasks`
|
const taskSelect = `SELECT id, created_ts, text, source, evidence, COALESCE(ext_id,''), status, due_ts, weight, resolved_ts, resolved_by, done_when, blocked_on FROM tasks`
|
||||||
|
|
||||||
// LookupTask returns one task by id.
|
// LookupTask returns one task by id.
|
||||||
func (s *Store) LookupTask(ctx context.Context, id int64) (Task, error) {
|
func (s *Store) LookupTask(ctx context.Context, id int64) (Task, error) {
|
||||||
@@ -304,6 +320,16 @@ func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
|||||||
// "tap:voice"). It is recorded on the row, so a task that turns up resolved
|
// "tap:voice"). It is recorded on the row, so a task that turns up resolved
|
||||||
// says what resolved it.
|
// says what resolved it.
|
||||||
func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
|
func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
|
||||||
|
return s.setTaskStatus(ctx, id, status, ts, by, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setTaskStatus — the move, with the promotion gate optional.
|
||||||
|
//
|
||||||
|
// It is optional for exactly one caller: CaptureTask promoting a candidate he
|
||||||
|
// stated out loud (Vikunja #510). Refusing there would deny intake rather than
|
||||||
|
// ask for a criterion, and a direct open capture never had one either — the gate
|
||||||
|
// belongs to the deliberate promotion on /tasks, where there is a form to fill.
|
||||||
|
func (s *Store) setTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string, gateDoneWhen bool) error {
|
||||||
var from []string
|
var from []string
|
||||||
switch status {
|
switch status {
|
||||||
case TaskOpen:
|
case TaskOpen:
|
||||||
@@ -329,6 +355,13 @@ func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts t
|
|||||||
for _, f := range from {
|
for _, f := range from {
|
||||||
args = append(args, f)
|
args = append(args, f)
|
||||||
}
|
}
|
||||||
|
if status == TaskOpen && gateDoneWhen {
|
||||||
|
// Promotion needs an acceptance criterion. Checked in the same
|
||||||
|
// statement rather than read-then-write, so two callers confirming one
|
||||||
|
// candidate cannot race past it; the row is read afterwards only to say
|
||||||
|
// WHICH refusal this was.
|
||||||
|
q += ` AND done_when <> ''`
|
||||||
|
}
|
||||||
res, err := s.db.ExecContext(ctx, q, args...)
|
res, err := s.db.ExecContext(ctx, q, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("set task status: %w", err)
|
return fmt.Errorf("set task status: %w", err)
|
||||||
@@ -338,11 +371,40 @@ func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts t
|
|||||||
return fmt.Errorf("set task status: rows affected: %w", err)
|
return fmt.Errorf("set task status: rows affected: %w", err)
|
||||||
}
|
}
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
|
if status == TaskOpen && gateDoneWhen {
|
||||||
|
if t, lookErr := s.LookupTask(ctx, id); lookErr == nil && t.Status == TaskCandidate && t.DoneWhen == "" {
|
||||||
|
return fmt.Errorf("%w: id=%d", ErrTaskNoDoneWhen, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
return fmt.Errorf("%w: id=%d not in %v", ErrTaskNotFound, id, from)
|
return fmt.Errorf("%w: id=%d not in %v", ErrTaskNotFound, id, from)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTaskFields writes the two board columns. Separate from SetTaskStatus
|
||||||
|
// because a status move is one-way and these are not: he may sharpen a
|
||||||
|
// definition of done, and a blocker clears when the person answers.
|
||||||
|
//
|
||||||
|
// blockedOn is a canonical Nexus entity id or empty. Free text does not belong
|
||||||
|
// here — identity lives in Nexus, and a local name would be a second answer to
|
||||||
|
// a question Nexus already owns. The caller resolves before it writes.
|
||||||
|
func (s *Store) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
|
||||||
|
res, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE tasks SET done_when = ?, blocked_on = ? WHERE id = ?`,
|
||||||
|
strings.TrimSpace(doneWhen), strings.TrimSpace(blockedOn), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set task fields: %w", err)
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set task fields: rows affected: %w", err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Errorf("%w: id=%d", ErrTaskNotFound, id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// NormalizeTaskText is the dedupe key: lowercased, punctuation dropped,
|
// NormalizeTaskText is the dedupe key: lowercased, punctuation dropped,
|
||||||
// whitespace collapsed. Exported because the intake seam (and its tests) needs
|
// whitespace collapsed. Exported because the intake seam (and its tests) needs
|
||||||
// to reason about what will and will not be treated as the same task.
|
// to reason about what will and will not be treated as the same task.
|
||||||
@@ -372,7 +434,7 @@ func scanTask(sc scanner) (Task, error) {
|
|||||||
var t Task
|
var t Task
|
||||||
var created int64
|
var created int64
|
||||||
var due, resolved sql.NullInt64
|
var due, resolved sql.NullInt64
|
||||||
if err := sc.Scan(&t.ID, &created, &t.Text, &t.Source, &t.Evidence, &t.ExternalID, &t.Status, &due, &t.Weight, &resolved, &t.ResolvedBy); err != nil {
|
if err := sc.Scan(&t.ID, &created, &t.Text, &t.Source, &t.Evidence, &t.ExternalID, &t.Status, &due, &t.Weight, &resolved, &t.ResolvedBy, &t.DoneWhen, &t.BlockedOn); err != nil {
|
||||||
return Task{}, err
|
return Task{}, err
|
||||||
}
|
}
|
||||||
t.CreatedTs = time.UnixMilli(created).UTC()
|
t.CreatedTs = time.UnixMilli(created).UTC()
|
||||||
|
|||||||
@@ -126,6 +126,11 @@ func TestSetTaskStatusMovesOnceForwardOnly(t *testing.T) {
|
|||||||
if err := st.SetTaskStatus(ctx, cand, TaskDone, now, "tap:web"); !errors.Is(err, ErrTaskNotFound) {
|
if err := st.SetTaskStatus(ctx, cand, TaskDone, now, "tap:web"); !errors.Is(err, ErrTaskNotFound) {
|
||||||
t.Errorf("candidate→done err = %v, want ErrTaskNotFound", err)
|
t.Errorf("candidate→done err = %v, want ErrTaskNotFound", err)
|
||||||
}
|
}
|
||||||
|
// Promotion needs a definition of done; see
|
||||||
|
// TestPromotingACandidateNeedsADefinitionOfDone for that refusal.
|
||||||
|
if err := st.SetTaskFields(ctx, cand, "запись есть", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if err := st.SetTaskStatus(ctx, cand, TaskOpen, now, "tap:web"); err != nil {
|
if err := st.SetTaskStatus(ctx, cand, TaskOpen, now, "tap:web"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -245,7 +250,10 @@ func TestCaptureTaskExternalIDSurvivesResolution(t *testing.T) {
|
|||||||
if !first.Created {
|
if !first.Created {
|
||||||
t.Fatal("first capture must create a row")
|
t.Fatal("first capture must create a row")
|
||||||
}
|
}
|
||||||
// He confirms it and does it.
|
// He confirms it and does it. Confirming needs a criterion (Vikunja #510).
|
||||||
|
if err := st.SetTaskFields(ctx, first.ID, "страховка продлена", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if err := st.SetTaskStatus(ctx, first.ID, TaskOpen, now.Add(time.Hour), "tap:web"); err != nil {
|
if err := st.SetTaskStatus(ctx, first.ID, TaskOpen, now.Add(time.Hour), "tap:web"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -374,3 +382,86 @@ func TestListTasksIsBounded(t *testing.T) {
|
|||||||
t.Errorf("first = %q, want the newest", all[0].Text)
|
t.Errorf("first = %q, want the newest", all[0].Text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTaskBoardColumnsRoundTrip(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
res, err := st.CaptureTask(ctx, Task{
|
||||||
|
Text: "оплатить интернет", Source: "tap:voice", CreatedTs: now,
|
||||||
|
DoneWhen: "квитанция оплачена", BlockedOn: "ent_kate",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := st.LookupTask(ctx, res.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.DoneWhen != "квитанция оплачена" || got.BlockedOn != "ent_kate" {
|
||||||
|
t.Fatalf("task = %+v, want both board columns back", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not one-way, unlike a status move: he sharpens the criterion, and the
|
||||||
|
// blocker clears when the person answers.
|
||||||
|
if err := st.SetTaskFields(ctx, res.ID, " пришло подтверждение ", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err = st.LookupTask(ctx, res.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.DoneWhen != "пришло подтверждение" {
|
||||||
|
t.Errorf("done_when = %q, want the trimmed rewrite", got.DoneWhen)
|
||||||
|
}
|
||||||
|
if got.BlockedOn != "" {
|
||||||
|
t.Errorf("blocked_on = %q, want it cleared", got.BlockedOn)
|
||||||
|
}
|
||||||
|
if err := st.SetTaskFields(ctx, 9999, "x", ""); !errors.Is(err, ErrTaskNotFound) {
|
||||||
|
t.Errorf("SetTaskFields on a missing row = %v, want ErrTaskNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromotingACandidateNeedsADefinitionOfDone(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
res, err := st.CaptureTask(ctx, Task{
|
||||||
|
Text: "продлить домен", Source: "email:main", Status: TaskCandidate,
|
||||||
|
ExternalID: "msg-1:0", Evidence: "Domain expiring", CreatedTs: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A board row whose finish line nobody wrote can never leave the board.
|
||||||
|
if err := st.SetTaskStatus(ctx, res.ID, TaskOpen, now, "tap:web"); !errors.Is(err, ErrTaskNoDoneWhen) {
|
||||||
|
t.Fatalf("promotion with no criterion = %v, want ErrTaskNoDoneWhen", err)
|
||||||
|
}
|
||||||
|
// Dropping it stays legal — declining work does not need one.
|
||||||
|
dropped, err := st.CaptureTask(ctx, Task{
|
||||||
|
Text: "перезвонить в банк", Source: "email:main", Status: TaskCandidate,
|
||||||
|
ExternalID: "msg-2:0", CreatedTs: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.SetTaskStatus(ctx, dropped.ID, TaskDropped, now, "tap:web"); err != nil {
|
||||||
|
t.Fatalf("dropping a candidate with no criterion: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.SetTaskFields(ctx, res.ID, "домен продлён до 2027", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.SetTaskStatus(ctx, res.ID, TaskOpen, now, "tap:web"); err != nil {
|
||||||
|
t.Fatalf("promotion after writing a criterion: %v", err)
|
||||||
|
}
|
||||||
|
got, err := st.LookupTask(ctx, res.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Status != TaskOpen {
|
||||||
|
t.Errorf("status = %q, want open", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user