From 54bef33f0b312ac073f40b6f3b28b6649594c775 Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Wed, 25 Feb 2026 20:52:46 -0600 Subject: [PATCH] Refactor frontend state to store-first architecture - add and expand feature stores for entries, fragments, todos, lists, settings - move CRUD logic into store helpers and simplify component state handling - update SidePanel + button to create section-specific items - switch fragment UX to view-first with edit/create modes - add and update docs for store-based state management - remove deprecated account route --- Journal.App/README.md | 43 ++ .../src/lib/components/EditorPanel.svelte | 701 ++++++++++++++++-- Journal.App/src/lib/components/Navbar.svelte | 130 +--- .../src/lib/components/SidePanel.svelte | 117 ++- Journal.App/src/lib/stores/entries.ts | 28 + Journal.App/src/lib/stores/fragments.ts | 96 +++ Journal.App/src/lib/stores/lists.ts | 24 + Journal.App/src/lib/stores/settings.ts | 66 ++ Journal.App/src/lib/stores/todos.ts | 99 +++ Journal.App/src/routes/+page.svelte | 21 +- Journal.App/src/routes/account/+page.svelte | 178 ----- Journal.App/src/routes/settings/+page.svelte | 251 ++++++- docs/frontend-csharp-backend-wiring.md | 28 + 13 files changed, 1401 insertions(+), 381 deletions(-) create mode 100644 Journal.App/src/lib/stores/entries.ts create mode 100644 Journal.App/src/lib/stores/fragments.ts create mode 100644 Journal.App/src/lib/stores/lists.ts create mode 100644 Journal.App/src/lib/stores/settings.ts create mode 100644 Journal.App/src/lib/stores/todos.ts delete mode 100644 Journal.App/src/routes/account/+page.svelte diff --git a/Journal.App/README.md b/Journal.App/README.md index 858d179..61bd516 100644 --- a/Journal.App/README.md +++ b/Journal.App/README.md @@ -2,6 +2,49 @@ This template should help get you started developing with Tauri, SvelteKit and TypeScript in Vite. +## Frontend State Management + +This app uses Svelte stores as the source of truth for feature state. + +### Current Stores + +- `src/lib/stores/entries.ts` + - state: `entriesStore` + - helpers: `getDefaultEntry`, `createEntryDraft` +- `src/lib/stores/fragments.ts` + - state: `fragmentsStore` + - helpers: parse/serialize + fragment CRUD helpers (`createFragmentItem`, `updateFragmentItem`, `prependFragmentItem`, `removeFragmentItem`) +- `src/lib/stores/todos.ts` + - state: `todoListsStore`, `todosStore` + - helpers: parse/serialize + todo list/item CRUD helpers +- `src/lib/stores/lists.ts` + - state: `listsStore` + - helpers: `createListDraft` +- `src/lib/stores/settings.ts` + - state: `settingsTags`, `settingsFragmentTypes` + +### Store-First Rule + +- Components should call store helper functions for CRUD operations. +- Components should avoid embedding feature-specific mutation/parsing logic. +- UI components should focus on rendering, local form state, and invoking store operations. + +### What Still Needs Setup + +1. Move settings CRUD helpers into `settings.ts` (currently add/edit/remove logic lives in `routes/settings/+page.svelte`). +2. Add full CRUD helpers for `entries` and `lists` stores (update/remove/reorder and optional find-by-id helpers). +3. Consolidate todo state into a single custom store API (or a single store object) so `todoListsStore` and `todosStore` updates are atomic. +4. Move calendar-created notes into a dedicated calendar store (currently local to `SidePanel.svelte`). +5. Add persistence/hydration layer so store state survives app restart and can be synchronized with backend commands. + +### Suggested Next Refactor + +- Introduce feature service wrappers per store (for example `entriesService`, `fragmentsService`) that handle: + - in-memory store mutation + - backend command call (`sendCommand`) + - optimistic update / rollback policy + - error normalization for UI + ## Recommended IDE Setup [VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer). diff --git a/Journal.App/src/lib/components/EditorPanel.svelte b/Journal.App/src/lib/components/EditorPanel.svelte index fa1aca1..32b0739 100644 --- a/Journal.App/src/lib/components/EditorPanel.svelte +++ b/Journal.App/src/lib/components/EditorPanel.svelte @@ -1,13 +1,57 @@ - - diff --git a/Journal.App/src/lib/components/SidePanel.svelte b/Journal.App/src/lib/components/SidePanel.svelte index a23b1aa..57af8b6 100644 --- a/Journal.App/src/lib/components/SidePanel.svelte +++ b/Journal.App/src/lib/components/SidePanel.svelte @@ -1,5 +1,9 @@

{panelTitle}

-
diff --git a/Journal.App/src/lib/stores/entries.ts b/Journal.App/src/lib/stores/entries.ts new file mode 100644 index 0000000..0553317 --- /dev/null +++ b/Journal.App/src/lib/stores/entries.ts @@ -0,0 +1,28 @@ +import { writable } from "svelte/store"; + +export type EntryItem = { + id: string; + label: string; + initialContent: string; +}; + +const initialEntries: EntryItem[] = [ + { id: "entries/daily-notes", label: "Daily Notes", initialContent: "# Daily Notes\n\nStart writing today's entry..." }, + { id: "entries/ideas", label: "Ideas", initialContent: "# Ideas\n\nCapture ideas before they disappear." }, + { id: "entries/archive", label: "Archive", initialContent: "# Archive\n\nOlder entries and references." } +]; + +export const entriesStore = writable(initialEntries); + +export function getDefaultEntry(items: EntryItem[]): EntryItem | undefined { + return items.find((entry) => entry.id === "entries/daily-notes") ?? items[0]; +} + +export function createEntryDraft(): EntryItem { + const id = `entries/entry-${Date.now()}`; + return { + id, + label: "Untitled Entry", + initialContent: "# Untitled Entry\n\nStart writing..." + }; +} diff --git a/Journal.App/src/lib/stores/fragments.ts b/Journal.App/src/lib/stores/fragments.ts new file mode 100644 index 0000000..562fda7 --- /dev/null +++ b/Journal.App/src/lib/stores/fragments.ts @@ -0,0 +1,96 @@ +import { writable } from "svelte/store"; + +export type FragmentItem = { + id: string; + label: string; + initialContent: string; +}; + +const initialFragments: FragmentItem[] = [ + { id: "fragments/highlights", label: "Highlights", initialContent: "# Highlights\n\nType: Reference\nTags: #Personal\n\nImportant highlights and excerpts." }, + { id: "fragments/quotes", label: "Quotes", initialContent: "# Quotes\n\nType: Quote\nTags: #Ideas\n\nQuotes worth revisiting." }, + { id: "fragments/scratchpad", label: "Scratchpad", initialContent: "# Scratchpad\n\nType: Snippet\nTags: (none)\n\nTemporary notes and rough thoughts." } +]; + +export const fragmentsStore = writable(initialFragments); + +export type ParsedFragment = { + title: string; + type: string; + tags: string[]; + body: string; +}; + +export function createFragmentId(title: string): string { + const slug = title + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); + return `fragments/${slug || "fragment"}-${Date.now()}`; +} + +export function serializeFragment(payload: ParsedFragment): string { + const title = payload.title.trim() || "Untitled Fragment"; + const type = payload.type.trim(); + const tagsLine = payload.tags.length ? payload.tags.map((tag) => `#${tag}`).join(" ") : "(none)"; + const body = payload.body.trim() || "Add details for this fragment."; + return `# ${title}\n\nType: ${type}\nTags: ${tagsLine}\n\n${body}`; +} + +export function parseFragmentContent(content: string, fallbackTitle = "Untitled Fragment"): ParsedFragment { + const headingMatch = content.match(/^#\s+(.+)$/m); + const typeMatch = content.match(/^Type:\s*(.+)$/m); + const tagsMatch = content.match(/^Tags:\s*(.+)$/m); + const bodyMatch = content.match(/^#.*\n\nType:.*\nTags:.*\n\n([\s\S]*)$/); + + const rawTags = tagsMatch?.[1]?.trim() ?? "(none)"; + const tags = + rawTags.toLowerCase() === "(none)" + ? [] + : rawTags + .split(/\s+/) + .map((tag) => tag.replace(/^#/, "").trim()) + .filter(Boolean); + + return { + title: headingMatch?.[1]?.trim() || fallbackTitle, + type: typeMatch?.[1]?.trim() || "", + tags, + body: bodyMatch?.[1]?.trim() || "" + }; +} + +export function createFragmentDraft(): FragmentItem { + const id = `fragments/new-${Date.now()}`; + return { + id, + label: "New Fragment", + initialContent: "# New Fragment\n\nType: \nTags: (none)\n\n" + }; +} + +export function createFragmentItem(title: string, content: string): FragmentItem { + return { + id: createFragmentId(title), + label: title.trim() || "Untitled Fragment", + initialContent: content + }; +} + +export function updateFragmentItem(items: FragmentItem[], id: string, title: string, content: string): FragmentItem[] { + return items.map((item) => + item.id === id + ? { ...item, label: title.trim() || "Untitled Fragment", initialContent: content } + : item + ); +} + +export function prependFragmentItem(items: FragmentItem[], item: FragmentItem): FragmentItem[] { + return [item, ...items]; +} + +export function removeFragmentItem(items: FragmentItem[], id: string): FragmentItem[] { + return items.filter((item) => item.id !== id); +} diff --git a/Journal.App/src/lib/stores/lists.ts b/Journal.App/src/lib/stores/lists.ts new file mode 100644 index 0000000..7362d24 --- /dev/null +++ b/Journal.App/src/lib/stores/lists.ts @@ -0,0 +1,24 @@ +import { writable } from "svelte/store"; + +export type ListItem = { + id: string; + label: string; + initialContent: string; +}; + +const initialLists: ListItem[] = [ + { id: "lists/reading", label: "Reading", initialContent: "# Reading\n\nBooks and articles to read." }, + { id: "lists/projects", label: "Projects", initialContent: "# Projects\n\nActive and planned projects." }, + { id: "lists/someday", label: "Someday", initialContent: "# Someday\n\nLong-term ideas." } +]; + +export const listsStore = writable(initialLists); + +export function createListDraft(): ListItem { + const id = `lists/list-${Date.now()}`; + return { + id, + label: "Untitled List", + initialContent: "# Untitled List\n\n- Item 1" + }; +} diff --git a/Journal.App/src/lib/stores/settings.ts b/Journal.App/src/lib/stores/settings.ts new file mode 100644 index 0000000..f4a2578 --- /dev/null +++ b/Journal.App/src/lib/stores/settings.ts @@ -0,0 +1,66 @@ +import { writable } from "svelte/store"; +import { get } from "svelte/store"; + +export const settingsTags = writable(["Personal", "Work", "Ideas", "Journal"]); +export const settingsFragmentTypes = writable(["Quote", "Snippet", "Reference"]); + +function normalize(value: string): string { + return value.trim().toLowerCase(); +} + +function hasDuplicate(values: string[], candidate: string, excludeIndex?: number): boolean { + const normalized = normalize(candidate); + return values.some((value, index) => index !== excludeIndex && normalize(value) === normalized); +} + +export function addSettingsTag(value: string): boolean { + const next = value.trim(); + if (!next) return false; + const tags = get(settingsTags); + if (hasDuplicate(tags, next)) return false; + settingsTags.set([...tags, next]); + return true; +} + +export function updateSettingsTag(index: number, value: string): boolean { + const next = value.trim(); + if (!next) return false; + const tags = get(settingsTags); + if (index < 0 || index >= tags.length) return false; + if (hasDuplicate(tags, next, index)) return false; + settingsTags.set(tags.map((tag, idx) => (idx === index ? next : tag))); + return true; +} + +export function removeSettingsTag(index: number): boolean { + const tags = get(settingsTags); + if (index < 0 || index >= tags.length) return false; + settingsTags.set(tags.filter((_, idx) => idx !== index)); + return true; +} + +export function addFragmentType(value: string): boolean { + const next = value.trim(); + if (!next) return false; + const types = get(settingsFragmentTypes); + if (hasDuplicate(types, next)) return false; + settingsFragmentTypes.set([...types, next]); + return true; +} + +export function updateFragmentType(index: number, value: string): boolean { + const next = value.trim(); + if (!next) return false; + const types = get(settingsFragmentTypes); + if (index < 0 || index >= types.length) return false; + if (hasDuplicate(types, next, index)) return false; + settingsFragmentTypes.set(types.map((type, idx) => (idx === index ? next : type))); + return true; +} + +export function removeFragmentType(index: number): boolean { + const types = get(settingsFragmentTypes); + if (index < 0 || index >= types.length) return false; + settingsFragmentTypes.set(types.filter((_, idx) => idx !== index)); + return true; +} diff --git a/Journal.App/src/lib/stores/todos.ts b/Journal.App/src/lib/stores/todos.ts new file mode 100644 index 0000000..7a53227 --- /dev/null +++ b/Journal.App/src/lib/stores/todos.ts @@ -0,0 +1,99 @@ +import { writable } from "svelte/store"; + +export type TodoItem = { id: number; text: string; done: boolean }; +export type TodoListMeta = { id: string; label: string }; + +const initialTodoListMeta: TodoListMeta[] = [ + { id: "todos/today", label: "Today" }, + { id: "todos/scheduled", label: "Scheduled" }, + { id: "todos/completed", label: "Completed" } +]; +export const todoListsStore = writable(initialTodoListMeta); + +export const todosStore = writable>({ + "todos/today": [ + { id: 1, text: "Finalize journal sidebar interactions", done: false }, + { id: 2, text: "Review fragment taxonomy updates", done: false }, + { id: 3, text: "Capture daily notes summary", done: false } + ], + "todos/scheduled": [ + { id: 4, text: "Tuesday: sync settings to backend store", done: false }, + { id: 5, text: "Thursday: polish editor keyboard shortcuts", done: false }, + { id: 6, text: "Friday: QA fragment and todo workflows", done: false } + ], + "todos/completed": [ + { id: 7, text: "Replaced navbar profile with settings shortcut", done: true }, + { id: 8, text: "Centered modal presentation", done: true }, + { id: 9, text: "Added fragment creation form mode", done: true } + ] +}); + +export function serializeTodoList(title: string, todos: TodoItem[]): string { + const heading = title?.trim() ? `# ${title}` : "# To-Do List"; + const lines = todos.map((todo) => `- [${todo.done ? "x" : " "}] ${todo.text}`); + return `${heading}\n\n${lines.join("\n")}`; +} + +export function createTodoId(): number { + return Date.now() + Math.floor(Math.random() * 1000); +} + +export function parseTodoList(content: string): TodoItem[] { + const lines = content.replace(/\r\n/g, "\n").split("\n"); + const parsed: TodoItem[] = []; + for (const line of lines) { + const match = line.match(/^- \[( |x)\]\s+(.+)$/i); + if (!match) continue; + parsed.push({ + id: createTodoId(), + text: match[2].trim(), + done: match[1].toLowerCase() === "x" + }); + } + return parsed; +} + +export function getOrCreateTodoList( + lists: Record, + documentId: string, + fallbackContent: string +): { lists: Record; todos: TodoItem[] } { + const existing = lists[documentId]; + if (existing) { + return { lists, todos: existing }; + } + const parsed = parseTodoList(fallbackContent); + return { lists: { ...lists, [documentId]: parsed }, todos: parsed }; +} + +export function setTodoList( + lists: Record, + documentId: string, + todos: TodoItem[] +): Record { + return { ...lists, [documentId]: todos }; +} + +export function addTodoItem(todos: TodoItem[], text: string): TodoItem[] { + return [{ id: createTodoId(), text: text.trim(), done: false }, ...todos]; +} + +export function toggleTodoItem(todos: TodoItem[], id: number): TodoItem[] { + return todos.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo)); +} + +export function updateTodoItemText(todos: TodoItem[], id: number, text: string): TodoItem[] { + return todos.map((todo) => (todo.id === id ? { ...todo, text: text.trim() } : todo)); +} + +export function removeTodoItem(todos: TodoItem[], id: number): TodoItem[] { + return todos.filter((todo) => todo.id !== id); +} + +export function createTodoListDraft(): { meta: TodoListMeta; items: TodoItem[] } { + const id = `todos/list-${Date.now()}`; + return { + meta: { id, label: "New List" }, + items: [] + }; +} diff --git a/Journal.App/src/routes/+page.svelte b/Journal.App/src/routes/+page.svelte index abbc761..30b5826 100644 --- a/Journal.App/src/routes/+page.svelte +++ b/Journal.App/src/routes/+page.svelte @@ -1,9 +1,11 @@
@@ -121,6 +130,8 @@ openDocumentName={activeDocumentLabel} openDocumentContent={openDocuments[activeDocumentId] ?? ""} onDocumentContentChange={handleDocumentContentChange} + onOpenDocument={handleOpenDocument} + onDeleteDocument={handleDeleteDocument} />
diff --git a/Journal.App/src/routes/account/+page.svelte b/Journal.App/src/routes/account/+page.svelte deleted file mode 100644 index 999ee16..0000000 --- a/Journal.App/src/routes/account/+page.svelte +++ /dev/null @@ -1,178 +0,0 @@ - - -
- - -
-
-

Account

-

Manage your profile and account preferences.

-
- -
- - - - - -
-
-
- - - - - - diff --git a/Journal.App/src/routes/settings/+page.svelte b/Journal.App/src/routes/settings/+page.svelte index 2cdae21..fcd324c 100644 --- a/Journal.App/src/routes/settings/+page.svelte +++ b/Journal.App/src/routes/settings/+page.svelte @@ -2,6 +2,16 @@ import { goto } from "$app/navigation"; import AppModal from "$lib/components/AppModal.svelte"; import Navbar from "$lib/components/Navbar.svelte"; + import { + addFragmentType, + addSettingsTag, + removeFragmentType, + removeSettingsTag, + settingsFragmentTypes, + settingsTags, + updateFragmentType, + updateSettingsTag + } from "$lib/stores/settings"; const activeSection = "settings"; @@ -13,6 +23,12 @@ let modalShowCancel = false; let modalTone: "default" | "danger" = "default"; let modalAction: "logout-confirm" | "logout-info" | null = null; + let newTag = ""; + let newFragmentType = ""; + let editingTagIndex: number | null = null; + let editingTagValue = ""; + let editingFragmentTypeIndex: number | null = null; + let editingFragmentTypeValue = ""; function showModal(options: { action: "logout-confirm" | "logout-info"; @@ -77,6 +93,68 @@ goto("/"); } + + function addTag() { + if (addSettingsTag(newTag)) { + newTag = ""; + } + } + + function startEditTag(index: number, tag: string) { + editingTagIndex = index; + editingTagValue = tag; + } + + function saveEditTag() { + if (editingTagIndex === null) return; + if (updateSettingsTag(editingTagIndex, editingTagValue)) { + editingTagIndex = null; + editingTagValue = ""; + } + } + + function cancelEditTag() { + editingTagIndex = null; + editingTagValue = ""; + } + + function removeTag(index: number) { + if (!removeSettingsTag(index)) return; + if (editingTagIndex === index) { + cancelEditTag(); + } + } + + function addFragmentTypeLocal() { + if (addFragmentType(newFragmentType)) { + newFragmentType = ""; + } + } + + function startEditFragmentType(index: number, fragmentType: string) { + editingFragmentTypeIndex = index; + editingFragmentTypeValue = fragmentType; + } + + function saveEditFragmentType() { + if (editingFragmentTypeIndex === null) return; + if (updateFragmentType(editingFragmentTypeIndex, editingFragmentTypeValue)) { + editingFragmentTypeIndex = null; + editingFragmentTypeValue = ""; + } + } + + function cancelEditFragmentType() { + editingFragmentTypeIndex = null; + editingFragmentTypeValue = ""; + } + + function removeFragmentTypeLocal(index: number) { + if (!removeFragmentType(index)) return; + if (editingFragmentTypeIndex === index) { + cancelEditFragmentType(); + } + }
@@ -108,6 +186,90 @@
+ +
+

Tags

+

Add and manage tags used for notes and entries.

+ +
+ event.key === "Enter" && addTag()} + /> + +
+ +
    + {#each $settingsTags as tag, index} +
  • + {#if editingTagIndex === index} + { + if (event.key === "Enter") saveEditTag(); + if (event.key === "Escape") cancelEditTag(); + }} + /> +
    + + +
    + {:else} + {tag} +
    + + +
    + {/if} +
  • + {/each} +
+
+ +
+

Fragment Types

+

Configure custom fragment types for the Fragments section.

+ +
+ event.key === "Enter" && addFragmentTypeLocal()} + /> + +
+ +
    + {#each $settingsFragmentTypes as type, index} +
  • + {#if editingFragmentTypeIndex === index} + { + if (event.key === "Enter") saveEditFragmentType(); + if (event.key === "Escape") cancelEditFragmentType(); + }} + /> +
    + + +
    + {:else} + {type} +
    + + +
    + {/if} +
  • + {/each} +
+
@@ -152,7 +314,7 @@ display: flex; flex-direction: column; gap: 12px; - max-width: 460px; + max-width: 640px; } .toggle-row { @@ -178,6 +340,93 @@ color: var(--text-primary); padding: 8px 10px; } + + .route-card h2 { + font-size: 0.95rem; + color: var(--text-primary); + } + + .section-copy { + font-size: 0.82rem; + color: var(--text-muted); + } + + .create-row { + display: flex; + gap: 8px; + } + + .create-row input { + flex: 1; + } + + .item-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; + } + + .item-row { + border: 1px solid var(--border-soft); + border-radius: 8px; + padding: 8px 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 0.84rem; + color: var(--text-primary); + background: var(--bg-app); + } + + .item-row input, + .route-card input { + border: 1px solid var(--border-soft); + border-radius: 7px; + background: var(--bg-app); + color: var(--text-primary); + padding: 6px 9px; + min-width: 200px; + } + + .row-actions { + display: flex; + gap: 6px; + } + + .secondary-btn, + .ghost-btn, + .danger-btn { + border: 1px solid var(--border-soft); + border-radius: 7px; + padding: 6px 10px; + font-size: 0.78rem; + cursor: pointer; + color: var(--text-primary); + } + + .secondary-btn { + background: var(--surface-3); + border-color: var(--border-strong); + } + + .ghost-btn { + background: var(--surface-1); + color: var(--text-muted); + } + + .danger-btn { + background: transparent; + color: var(--text-muted); + } + + .secondary-btn:hover, + .ghost-btn:hover, + .danger-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); + } diff --git a/docs/frontend-csharp-backend-wiring.md b/docs/frontend-csharp-backend-wiring.md index 312189d..2dd6cce 100644 --- a/docs/frontend-csharp-backend-wiring.md +++ b/docs/frontend-csharp-backend-wiring.md @@ -188,3 +188,31 @@ That lets UI code remain identical. 3. Wire `SidePanel` item load to `entries.load`. 4. Add vault unlock modal + `vault.load_all` on startup. 5. Keep all backend calls behind `sendCommand` only. + +## Frontend Store Architecture (Current) + +Current frontend uses feature stores in `Journal.App/src/lib/stores/`: + +- `entries.ts` -> `entriesStore` +- `fragments.ts` -> `fragmentsStore` +- `todos.ts` -> `todoListsStore`, `todosStore` +- `lists.ts` -> `listsStore` +- `settings.ts` -> `settingsTags`, `settingsFragmentTypes` + +Current pattern is store-first for most feature CRUD and parsing (especially fragments and todos), with UI components invoking store helpers. + +## State/CRUD Gaps Still Needed + +To fully standardize state management: + +1. Move settings add/edit/remove logic into `settings.ts` helper functions (currently in route component code). +2. Add full CRUD helpers for `entries.ts` and `lists.ts` (update/remove/reorder, not only draft creation). +3. Make todo list metadata + todo items update atomically through a single store API wrapper. +4. Move calendar-created entries out of local component state into a dedicated calendar store. +5. Add persistence/hydration strategy between stores and backend (`entries.load/save`, `vault.load_all`, etc.). + +## Recommended Rule + +- Keep all feature data mutations in store helper APIs. +- Keep route/component files focused on view state and command orchestration. +- Keep backend transport (`sendCommand`) separate from pure local store mutation helpers, then compose both in thin feature services.