Notechondria

Version: 0.1.83 Build Date: 2026-04-27T02:00

What's Changed

Three production bugs reported from a sign-in trace, two behavioral changes around Inbox semantics, a learner-card UI cleanup, plus the long-deferred deep-link regression test.

Bug — editor_mode 400 on every starter-draft sync

Reported: every sign-in produced a flurry of Editor.HTTP/response — POST /api/v1/notes/ → 400 failures with 'editor_mode: "M" is not a valid choice' and "T" is not a valid choice. Backend Note.editor_mode only accepts G (GFM) / B (Blocks) / P (Plain Text), but local_starter.dart seeded welcome drafts with 'M' (markdown) and 'T' (text) codes. Sync therefore fell over before any local draft made it to the cloud.

  • Renamed 'M' → 'G' and 'T' → 'P' in editor_app and planner_app core/local_starter.dart so newly-seeded starter drafts use backend-valid codes.
  • Added _normalizeEditorMode(raw) to all three apps' core/helpers.dart. Maps stale 'M' → 'G', 'T' → 'P', passes 'G'/'B'/'P' through, and defaults unknown codes to 'P' (matching backend). Wired at the four sync call sites (editor core/draft_sync.dart; planner / portal core/local_course_builders.dart) so any pre-0.1.83 local drafts in SharedPreferences from earlier installs sync cleanly without manual data migration.

Bug — restore-starter created a duplicate Inbox when signed in

User report: clicking "Restore default inbox" while signed in created a fresh local Inbox even when the user already had a cloud Inbox. The 0.1.77 dedup only scanned _localCourses for an existing Inbox; the cloud Inbox lives in _courses, so the restore happily appended a new local row, which then attempted to sync (and the backend rejected with 400 "already exists").

  • _seedStarterInboxAlongsideExisting in editor_app/lib/core/local_starter.dart now scans _courses FIRST. If a cloud Inbox is found, the function selects it and exits — no local row created, no sync attempt. Only when no Inbox exists anywhere (local or cloud) does the function fall back to creating a fresh local Inbox.
  • Belt-and-suspenders on the backend (next item): CourseListApiView.post now treats Inbox as globally unique per user.

Behavioral change — Inbox is GLOBALLY unique per user (backend)

Replaces the 0.1.77 reject-on-collision behavior for the Inbox specifically. Other category names still reject on duplicate (case-insensitive); Inbox does idempotent get-or-create.

  • backend/notes/api.py CourseListApiView.post — when a POST collides with an existing Inbox row (case-insensitive title match), return that existing Inbox with 200 OK instead of 400. Frontend's existing _syncLocalCourse flow processes the response identically: drops the local Inbox row, remaps draft course_id references from local to remote ID, lands on the cloud Inbox. Net effect: pushing a local Inbox to a server that already has one is now seamless.

Behavioral change — Inbox is PRIVATE per user (backend)

Per the user's requirement: "the 'Inbox' Category is the only one that don't have view public notes from others". Pre-0.1.83, Inbox was the public-feed promotion surface — any note in any user's Inbox was visible to non-owners. That made Inbox a public-feed proxy rather than a private scratchpad.

  • backend/notes/api.py — three filter sites updated:
    • note_is_public() helper at line 73 — was note.is_public OR course.is_default; now just note.is_public. Inbox notes no longer auto-promote.
    • CourseSerializer.get_recent_notes() filter — was Q(is_public=True) | Q(course_id__is_default=True); now just is_public=True. Other users' Inbox notes are no longer surfaced in their course-detail recent-notes section.
    • CourseNotesApiView.get non-owner filter — same change. Others can no longer browse a user's Inbox via the course-notes endpoint.

is_default flag stays on the Course model — it's still used for the default-fallback-on-delete logic and the sort key. Only the public-visibility semantics flipped.

UX — single status icon on _LearnerNoteCard, "sync failed"

distinct from "unsynced"

User report: when a sync attempt failed, the local-draft card showed two icons (the inline cloud-upload status icon next to the title AND the cloud-upload IconButton on the right). The fix collapses to a single icon that reflects the actual state:

  • Removed the inline cloud-status icon next to the title. The "Local draft" / "Public" / "Private" badge in the metadata row already conveys the same state, so the inline icon was redundant.
  • The right-hand IconButton is now the single status / action surface. Four states:
    • Cloud note (not local draft) — static cloud_done icon, primary color, tooltip "Synced to cloud".
    • Local draft, no session — static cloud_off icon, onSurfaceVariant color, tooltip "Offline draft — sign in to sync."
    • Local draft, can sync, no prior failurecloud_upload IconButton (action), tooltip "Sync to cloud", taps trigger onSync.
    • Local draft, sync failedsync_problem IconButton (action), error color, tooltip "Sync failed: \nTap to retry." Same onSync callback.
  • Failure detection: _syncAllLocalDrafts in all three apps now stamps the failed draft with last_sync_error and last_sync_attempt_at keys when the per-item try/catch catches an exception. Persists to _localDrafts storage so the failure flag survives an app restart. On the next successful sync the draft is removed from _localDrafts entirely (success-clear automatic).

docs/TODO.md's long-standing deep-link test entry (0.1.67's fix landed without test coverage because the editor smoke harness lacks a web navigator shim) — addressed.

  • frontend/editor_app/lib/app_shell.dart_parseNoteUuidFromUrl instance method became a one-liner forwarder to a new top-level parseNoteUuidFromFragment(String fragment) function in frontend/editor_app/lib/core/auth_flows.dart. The pure-function variant is unit-testable without mounting the app or stubbing Uri.base.
  • frontend/editor_app/test/deep_link_parser_test.dart (new) — 12 cases:
    • Happy paths: leading-slash variant; no-leading-slash; trailing slash; share-link query suffix (?ref=share); uppercase-hex uuid; OAuth-callback-preserves-fragment.
    • Null paths: empty fragment; home / fragment; settings fragment; non-uuid suffix; partial-uuid; unrelated /foo/bar/baz path.
  • All 12 pass. The test now lives next to smoke_test.dart in the editor's test suite, so any future refactor that re-anchors the regex (say, back to ^/?notes/<uuid>$) fails the regression suite.

Files Changed

  • VERSION — 0.1.82 → 0.1.83.
  • backend/notes/api.pynote_is_public simplified to note.is_public; two filter sites in CourseSerializer
    • CourseNotesApiView; CourseListApiView.post treats Inbox as globally unique with idempotent get-or-create.
  • frontend/editor_app/lib/core/local_starter.dartM/T → G/P codes; _seedStarterInboxAlongsideExisting scans _courses first.
  • frontend/editor_app/lib/core/helpers.dart_normalizeEditorMode.
  • frontend/editor_app/lib/core/draft_sync.dart_normalizeEditorMode at two send sites.
  • frontend/editor_app/lib/core/maintenance_actions.dart — stamp last_sync_error on draft failure; persist.
  • frontend/editor_app/lib/modules/learner.dart_LearnerNoteCard collapsed to single icon with four states.
  • frontend/editor_app/lib/app_shell.dart_parseNoteUuidFromUrl thinned to forwarder.
  • frontend/editor_app/lib/core/auth_flows.dart — new top-level parseNoteUuidFromFragment + regex.
  • frontend/editor_app/test/deep_link_parser_test.dart (new) — 12 regression cases.
  • frontend/planner_app/lib/core/local_starter.dartM → G.
  • frontend/planner_app/lib/core/helpers.dart_normalizeEditorMode.
  • frontend/planner_app/lib/core/local_course_builders.dart_normalizeEditorMode at two send sites.
  • frontend/planner_app/lib/core/maintenance_actions.dart — same draft-failure stamping pattern.
  • frontend/portal_app/lib/core/helpers.dart_normalizeEditorMode.
  • frontend/portal_app/lib/core/local_course_builders.dart_normalizeEditorMode at two send sites.
  • frontend/portal_app/lib/core/maintenance_actions.dart — same draft-failure stamping pattern.
  • docs/TODO.md — deep-link regression-test entry removed (now covered by deep_link_parser_test.dart).

Notes

  • All four packages pass flutter analyze (zero errors) and flutter test. Editor's test count grew from 1 (smoke) to 13 (1 smoke + 12 deep-link regressions).
  • Backend changes: note_is_public and the two non-owner filter sites are now strictly more restrictive than before — non- owners see fewer notes, never more. No data migration needed. Inbox public-notes promotion was an implicit feature, not an exposed API contract; users who relied on it (e.g. publishing a note by dropping it in their Inbox) need to explicitly toggle is_public from now on. Backend syntactic check via ast.parse; not run-tested locally (no Django venv).
  • §1.5 1000-LOC cap respected; largest file is editor's core/helpers.dart at 944 lines (grew slightly from _normalizeEditorMode addition).
  • Sync-failed flag is stored under draft keys last_sync_error
    • last_sync_attempt_at. They're plain Map keys (no schema change), so older app installs reading the persisted draft list see them as harmless extra fields. Cleared automatically on successful sync because the draft is dropped from _localDrafts entirely.
  • The "Inbox is private" semantic change is irreversible without another behavioral flip: pre-0.1.83 notes that lived in an Inbox and were seen as public by other users are now hidden from those non-owners. Owners can move those notes to a public-flagged category to restore visibility.