package suggestions import ( "net/http" "gitea.parodia.dev/drwily/petal/internal/auth" "gitea.parodia.dev/drwily/petal/internal/httputil" "gitea.parodia.dev/drwily/petal/internal/vocab" ) // The growth journal. // // The suggestions table already records everything this needs — it is purely a // read-side view, with no new capture and no model call. Two framing rules // decide what may appear here, and they are enforced in the SQL rather than left // to the copy: // // 1. It reports growth, never an error tally. Nothing counts what she got // wrong this month; the signals are things that *stopped* happening and // phrasing that *stuck*. // 2. It only ever compares the writer to her own past self. There is no // target, no average, no other user anywhere in these queries. // // A quiet month is quiet: every signal below is omitted rather than softened // when the data isn't there, because an invented milestone is worse than none. // Journal is one writer's growth over the recent windows. type Journal struct { // Kept / KeptBefore are edits she took on board in the last 30 days and in // the 30 before that — her own past self, the only comparison offered. Kept int `json:"kept"` KeptBefore int `json:"kept_before"` // Stuck: phrasing she was given that now turns up across her own writing. Stuck []Chunk `json:"stuck"` // Faded: things she used to need fixing and hasn't, recently. Faded []Fade `json:"faded"` } // Chunk is a phrase that has stuck: it appears in Docs of her documents now. type Chunk struct { Phrase string `json:"phrase"` Docs int `json:"docs"` } // Fade is a pattern that has stopped appearing. Times is how often it came up // during the earlier window — context for "and not since", never a scoreboard. type Fade struct { Pattern string `json:"pattern"` Times int `json:"times"` } // Journal windows, in days. `recent` is the month being reported on; `history` // reaches back far enough that a pattern's absence means something (one quiet // fortnight doesn't). const ( recentDays = 30 historyDays = 120 maxSignals = 3 // per list: a journal is a couple of warm lines, not a report ) // growth serves GET /api/suggestions/growth. func (h *Handler) growth(w http.ResponseWriter, r *http.Request) { userID := auth.UserID(r.Context()) j := Journal{Stuck: []Chunk{}, Faded: []Fade{}} err := h.DB.QueryRow( `SELECT sum(CASE WHEN s.resolved_at >= datetime('now', '-30 days') THEN 1 ELSE 0 END), sum(CASE WHEN s.resolved_at < datetime('now', '-30 days') AND s.resolved_at >= datetime('now', '-60 days') THEN 1 ELSE 0 END) FROM suggestions s JOIN documents d ON d.id = s.doc_id WHERE d.user_id = ? AND s.status = 'accepted' AND s.resolved_at IS NOT NULL`, userID, ).Scan(&nullInt{&j.Kept}, &nullInt{&j.KeptBefore}) if err != nil { httputil.ServerError(w, err) return } stuck, err := h.stuck(userID) if err != nil { httputil.ServerError(w, err) return } j.Stuck = stuck faded, err := h.faded(userID) if err != nil { httputil.ServerError(w, err) return } j.Faded = faded httputil.WriteJSON(w, http.StatusOK, j) } // stuck finds accepted phrasing that now appears in more than one of her own // documents. One document is just the edit itself, still sitting where it was // applied; a second is her reaching for the phrase on her own, which is the // whole claim the line makes. func (h *Handler) stuck(userID string) ([]Chunk, error) { rows, err := h.DB.Query( `SELECT DISTINCT s.replacement FROM suggestions s JOIN documents d ON d.id = s.doc_id WHERE d.user_id = ? AND s.status = 'accepted' AND s.resolved_at >= datetime('now', '-120 days') AND trim(s.replacement) != '' ORDER BY s.resolved_at DESC LIMIT 40`, userID, ) if err != nil { return nil, err } defer rows.Close() // vocab.PhraseKey is the same definition of "a learnable chunk" the garden // plants, so the journal and the garden can never disagree about what counts. var phrases []string for rows.Next() { var replacement string if err := rows.Scan(&replacement); err != nil { return nil, err } if key := vocab.PhraseKey(replacement); key != "" { phrases = append(phrases, key) } } if err := rows.Err(); err != nil { return nil, err } out := []Chunk{} for _, p := range phrases { var docs int if err := h.DB.QueryRow( `SELECT count(*) FROM documents WHERE user_id = ? AND instr(lower(content_text), ?) > 0`, userID, p, ).Scan(&docs); err != nil { return nil, err } if docs >= 2 { out = append(out, Chunk{Phrase: p, Docs: docs}) } } sortDesc(out, func(c Chunk) int { return c.Docs }) return trim(out, maxSignals), nil } // faded finds patterns she used to be corrected on during the earlier part of // the history window and hasn't been since. // // The guard that makes this honest: it says nothing at all unless she has // actually been writing lately. Without it, a month away from Petal would be // reported back to her as progress, which is the one way this feature could lie. func (h *Handler) faded(userID string) ([]Fade, error) { var wroteRecently int if err := h.DB.QueryRow( `SELECT count(*) FROM suggestions s JOIN documents d ON d.id = s.doc_id WHERE d.user_id = ? AND s.resolved_at >= datetime('now', '-30 days')`, userID, ).Scan(&wroteRecently); err != nil { return nil, err } if wroteRecently == 0 { return []Fade{}, nil } rows, err := h.DB.Query( `SELECT lower(trim(s.original)) AS pattern, count(*) AS times FROM suggestions s JOIN documents d ON d.id = s.doc_id WHERE d.user_id = ? AND s.status = 'accepted' AND s.resolved_at < datetime('now', '-30 days') AND s.resolved_at >= datetime('now', '-120 days') AND trim(s.original) != '' AND pattern NOT IN ( SELECT lower(trim(s2.original)) FROM suggestions s2 JOIN documents d2 ON d2.id = s2.doc_id WHERE d2.user_id = ? AND s2.status = 'accepted' AND s2.resolved_at >= datetime('now', '-30 days')) GROUP BY pattern HAVING times >= 2 ORDER BY times DESC LIMIT 3`, userID, userID, ) if err != nil { return nil, err } defer rows.Close() out := []Fade{} for rows.Next() { var f Fade if err := rows.Scan(&f.Pattern, &f.Times); err != nil { return nil, err } out = append(out, f) } return out, rows.Err() } // nullInt scans a possibly-NULL aggregate into an int (SUM over no rows is // NULL, which is a zero here, not an error). type nullInt struct{ dst *int } func (n *nullInt) Scan(v any) error { switch t := v.(type) { case int64: *n.dst = int(t) case nil: *n.dst = 0 } return nil } func sortDesc[T any](s []T, key func(T) int) { for i := 1; i < len(s); i++ { for j := i; j > 0 && key(s[j]) > key(s[j-1]); j-- { s[j], s[j-1] = s[j-1], s[j] } } } func trim[T any](s []T, n int) []T { if len(s) > n { return s[:n] } return s }