@@ -678,7 +726,7 @@ onUnmounted(() => assist.clearSelection());
flex-direction: column;
}
-.sb-select, .sb-input {
+.sb-select, .sb-input, .sb-textarea {
width: 100%;
padding: 5px 8px;
border-radius: var(--fs-radius-sm);
@@ -690,9 +738,27 @@ onUnmounted(() => assist.clearSelection());
outline: none;
transition: border-color 0.15s;
}
-.sb-select:focus, .sb-input:focus {
+.sb-select:focus, .sb-input:focus, .sb-textarea:focus {
border-color: var(--fs-accent);
}
+.sb-textarea {
+ resize: vertical;
+ line-height: 1.4;
+ box-sizing: border-box;
+}
+/* No red/amber ramp, matching RuleSweepPane: a colour scale would restate the
+ sweep's ordering and force an invented "stale after N days" threshold.
+ "Never" is marked because it is categorically different from a date, not a
+ worse one — it means nobody has ever confirmed the claim. */
+.check-age {
+ font-size: 0.7rem;
+ color: var(--fs-text-secondary);
+ font-variant-numeric: tabular-nums;
+}
+.check-age.unchecked {
+ font-style: italic;
+ color: var(--fs-text-tertiary);
+}
/* Link Suggestions */
.link-suggest-field { gap: 0.4rem; }
diff --git a/src/scribe/routes/notes.py b/src/scribe/routes/notes.py
index bb28a80..58b3779 100644
--- a/src/scribe/routes/notes.py
+++ b/src/scribe/routes/notes.py
@@ -19,7 +19,10 @@ from scribe.services.notes import (
get_note_for_user,
get_or_create_note_by_title,
list_notes,
+ mark_note_verified,
+ notes_due_for_verification,
update_note,
+ verification_row,
)
from scribe.services.note_drafts import upsert_draft, get_draft, delete_draft
from scribe.services import dedup as dedup_svc
@@ -499,3 +502,62 @@ async def graph_route():
shared_tags = request.args.get("shared_tags", "false").lower() == "true"
graph = await build_note_graph(uid, project_id=project_id, include_shared_tags=shared_tags)
return jsonify(graph)
+
+
+# ── The staleness sweep (milestone 317) ──────────────────────────────────────
+# The web half of notes_due_for_verification / mark_note_verified. Same
+# contract as the MCP door and the rules routes beside it — the service holds
+# the behaviour, these two just parse and serialise.
+
+@notes_bp.route("/due-for-verification", methods=["GET"])
+@login_required
+async def notes_due_route():
+ """Notes that carry a check, oldest verification first, never-checked top.
+
+ Query params: older_than_days, project_id, never_only. A note with no
+ `verify_with` never appears — it is a decision, not a fact.
+ """
+ uid = get_current_user_id()
+ args = request.args
+ try:
+ older = int(args.get("older_than_days", 0) or 0)
+ project = int(args.get("project_id", 0) or 0)
+ except ValueError:
+ return jsonify({"error": "older_than_days and project_id must be integers"}), 400
+ try:
+ notes = await notes_due_for_verification(
+ uid,
+ older_than_days=older,
+ project_id=project or None,
+ never_only=args.get("never_only", "").lower() in ("1", "true", "yes"),
+ )
+ except ValueError as exc:
+ # A 400, not a silently narrowed result: a filter that quietly answers
+ # a different question is the failure this whole surface exists to
+ # catch.
+ return jsonify({"error": str(exc)}), 400
+ return jsonify({
+ "notes": [verification_row(n) for n in notes],
+ "total": len(notes),
+ })
+
+
+@notes_bp.route("//verify", methods=["POST"])
+@login_required
+async def mark_note_verified_route(note_id: int):
+ """Record that the note's check was run. Body: {"still_true": bool}.
+
+ `still_true: false` writes nothing — a note whose check failed is wrong,
+ not in a recordable state — so it keeps its place at the top of the sweep.
+ """
+ data = await request.get_json() or {}
+ uid = get_current_user_id()
+ still_true = bool(data.get("still_true", True))
+ note = await mark_note_verified(note_id, uid, still_true)
+ if note is None:
+ return jsonify({
+ "error": "note not found, not writable by you, or carries no verify_with"
+ }), 404
+ payload = verification_row(note)
+ payload["verified"] = still_true
+ return jsonify(payload)