Phase 4a: fetch and parse the authoritative Compass learning tasks

The Learning Tasks grid is capped at 20 rows and, on the parent page, mixes
both children, so it cannot answer "what is overdue". The parent home page
reported 6 overdue tasks for Harry while the stored grid held 1.

Fetch the per-child task list (read-only) from
POST /Services/LearningTasks.svc/GetAllLearningTasksByUserId, parse it into
task rows, and refuse to store it when the overdue count disagrees with the
home page summary (FR-12).

- fetcher: store learning-tasks-all.json + meta (userId, status, task count)
- parse: new learning-tasks-all page, JSON parser, home-page reconciliation
- store: drop superseded grid-derived task rows before the upsert
- docs: docs/phase-4a-overdue-tasks.md; close the Phase 3 known gap

Verified with live runs: Harry 70 tasks / 6 overdue, Finn 42 tasks / 2 overdue,
both equal to the home page; a tampered home page stops the parse.

Note: the fetcher and store.go files also carry the per-child session work
that was already uncommitted in this shared workspace (AUD-108).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-15 17:38:22 +11:00
parent d7d529eb0f
commit 5d263987dd
8 changed files with 913 additions and 82 deletions

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"io/fs"
"sort"
"strings"
"time"
_ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo)
@@ -348,6 +349,32 @@ func formatDue(t *time.Time) any {
return t.UTC().Format(time.RFC3339)
}
// DeleteChildCompassTasksFromPages removes a child's Compass task rows that came
// from the given source pages. It is used when an authoritative full task list
// replaces a partial grid: the legacy grid is capped
// (LearningTask_Default_Records_Count) and a stale row must not survive as if it
// were the whole set (AUD-114, FR-12). Rows from any other source page are left
// untouched.
func (s *Store) DeleteChildCompassTasksFromPages(ctx context.Context, childID int64, sourcePages ...string) (int64, error) {
if len(sourcePages) == 0 {
return 0, nil
}
placeholders := strings.Repeat("?,", len(sourcePages)-1) + "?"
args := make([]any, 0, len(sourcePages)+1)
args = append(args, childID)
for _, p := range sourcePages {
args = append(args, p)
}
res, err := s.db.ExecContext(ctx,
`DELETE FROM items
WHERE child_id = ? AND source = 'compass' AND type = 'task'
AND source_page IN (`+placeholders+`)`, args...)
if err != nil {
return 0, fmt.Errorf("delete partial task rows: %w", err)
}
return res.RowsAffected()
}
// TypeCount is the number of items of one type for one child.
type TypeCount struct {
Child string
@@ -429,3 +456,42 @@ func (s *Store) ChildByName(ctx context.Context, name string) (*Child, error) {
c.CreatedAt = parseTime(created)
return &c, nil
}
// SetChildCompassID records the child's Compass userId, discovered by the
// fetcher from the parent landing page (AGENTS.md §7).
func (s *Store) SetChildCompassID(ctx context.Context, childID int64, compassID string) error {
if compassID == "" {
return nil
}
if _, err := s.db.ExecContext(ctx,
`UPDATE children SET compass_id = ? WHERE id = ?`, compassID, childID); err != nil {
return fmt.Errorf("set compass id for child %d: %w", childID, err)
}
return nil
}
// DeleteItems removes items for one child, optionally limited to a source and a
// type. It exists so a scan can drop rows that were stored against the wrong
// child (Phase 4: nothing may leak between children) and so a later Delete
// action has a home. It returns how many rows were removed.
func (s *Store) DeleteItems(ctx context.Context, childID int64, source, itemType string) (int64, error) {
q := `DELETE FROM items WHERE child_id = ?`
args := []any{childID}
if source != "" {
q += ` AND source = ?`
args = append(args, source)
}
if itemType != "" {
q += ` AND type = ?`
args = append(args, itemType)
}
res, err := s.db.ExecContext(ctx, q, args...)
if err != nil {
return 0, fmt.Errorf("delete items for child %d: %w", childID, err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("delete items for child %d: %w", childID, err)
}
return n, nil
}