diff --git a/.formatter.exs b/.formatter.exs index 045e92f..da1021b 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -3,7 +3,8 @@ subdirectories: ["priv/*/migrations"], inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}", "priv/*/seeds.exs"] ] + # The HEEx formatter is deliberatly disabled # because it keeps expanding single-line tags # into multi-line blocks, which injects literal whitespace -# into pre-wrap elements and causes a blank-line bug. \ No newline at end of file +# into pre-wrap elements and causes a blank-line bug. diff --git a/.gitignore b/.gitignore index ecc5b87..c002366 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,8 @@ npm-debug.log .secrets.txt .claude/ + +# my notes +elixdo*.md +db_deletes.md +MCP_SERVER_PLAN.md diff --git a/MCP_SERVER_PLAN.md b/MCP_SERVER_PLAN.md index eb172e3..96bf431 100644 --- a/MCP_SERVER_PLAN.md +++ b/MCP_SERVER_PLAN.md @@ -6,156 +6,268 @@ Add an embedded MCP (Model Context Protocol) controller that allows an AI like C --- -## Step 1 — Add the MCP route +## What was actually built (post-implementation notes) -In `lib/elixdo_web/router.ex`, add one line inside the existing `api_auth` scope: +This document was rewritten after the implementation was complete. The original plan had several gaps that only became apparent during integration testing with Claude Code. The sections below reflect what it actually takes to get a working MCP server. + +--- + +## Step 1 — Add the MCP route in a dedicated scope + +The MCP endpoint must NOT share the `:api_auth` pipeline with the other API routes. The reason: the `:api_auth` pipeline uses `plug :accepts, ["json"]`, which causes Phoenix to return **406 Not Acceptable** when Claude Code sends `Accept: text/event-stream` — which it does for every request, as required by the MCP Streamable HTTP transport spec. + +The fix is a separate pipeline with no `:accepts` plug, since the MCP controller handles its own content negotiation: ```elixir +# MCP handles its own content negotiation (JSON and SSE), so no :accepts plug here +pipeline :mcp_auth do + plug ElixdoWeb.ApiAuthPlug +end + scope "/api/v1", ElixdoWeb.Api do pipe_through :api_auth - # ... existing routes ... - post "/mcp", McpController, :handle # ← add this +end + +scope "/api/v1", ElixdoWeb.Api do + pipe_through :mcp_auth + post "/mcp", McpController, :handle end ``` -The MCP endpoint reuses the same `ApiAuthPlug` and `AGENT_TOKEN` already in place. No new auth work needed. +**Do not** try to add `"event-stream"` to the accepts list — `text/event-stream` is not registered in Phoenix's MIME registry by default and will not work. --- -## Step 2 — Create `mcp_controller.ex` +## Step 2 — Implement the Streamable HTTP transport -Create `lib/elixdo_web/controllers/api/mcp_controller.ex`. It handles JSON-RPC 2.0 dispatch: +Claude Code uses the **MCP Streamable HTTP transport**, which requires the server to check the `Accept` request header and respond in either plain JSON or SSE format accordingly. -```elixir -defmodule ElixdoWeb.Api.McpController do - use ElixdoWeb, :controller - alias Elixdo.{Lists, SearchIndex, Clock, DateHelper} - - def handle(conn, %{"method" => "initialize", "id" => id}) do - json(conn, %{ - jsonrpc: "2.0", id: id, - result: %{ - protocolVersion: "2024-11-05", - serverInfo: %{name: "elixdo", version: "1.0"}, - capabilities: %{tools: %{}} - } - }) - end +When the client sends `Accept: text/event-stream`, the response must be: +``` +Content-Type: text/event-stream +Cache-Control: no-cache - def handle(conn, %{"method" => "tools/list", "id" => id}) do - json(conn, %{jsonrpc: "2.0", id: id, result: %{tools: tool_definitions()}}) - end +event: message +data: {"jsonrpc":"2.0","id":1,"result":{...}} - def handle(conn, %{"method" => "tools/call", "id" => id, - "params" => %{"name" => name, "arguments" => args}}) do - result = dispatch(name, args) - json(conn, %{jsonrpc: "2.0", id: id, result: %{content: [%{type: "text", text: Jason.encode!(result)}]}}) - end +``` + +Note the blank line at the end (`\n\n`) — that terminates the SSE event. - def handle(conn, %{"id" => id}) do - json(conn, %{jsonrpc: "2.0", id: id, - error: %{code: -32601, message: "Method not found"}}) +Use a private `respond/2` helper in the controller so all clauses stay clean: + +```elixir +defp respond(conn, payload) do + accept = get_req_header(conn, "accept") |> List.first("") + + if String.contains?(accept, "text/event-stream") do + data = Jason.encode!(payload) + conn + |> put_resp_content_type("text/event-stream") + |> put_resp_header("cache-control", "no-cache") + |> send_resp(200, "event: message\ndata: #{data}\n\n") + else + json(conn, payload) end end ``` +Call `respond/2` instead of `json/2` in every handler clause. + --- -## Step 3 — Implement the tool dispatcher +## Step 3 — Handle JSON-RPC notifications (no `id` field) -Still in `mcp_controller.ex`, add a private `dispatch/2` that maps tool names to `Lists` calls. +After `initialize`, Claude Code sends a `notifications/initialized` notification. JSON-RPC notifications have no `id` field. If your catch-all handler pattern-matches on `%{"id" => id}`, it will not match and Phoenix will return **400 Bad Request**, breaking the handshake. -### Tools to expose +Add a final catch-all that returns **204 No Content** for all notifications: -| Tool name | Maps to | Purpose | -|---|---|---| -| `get_today` | `Clock.today()` | AI needs to know the current date for relative operations | -| `list_items` | `Lists.get_items_for_date(date)` | Get items for a specific date | -| `list_items_range` | `Lists.get_items_for_range(from, to, statuses)` | Get items across a date range, optionally filtered by status | -| `add_item` | `Lists.create_items(date, [%{body: body}])` | Add a new item | -| `update_item` | `Repo.get(ListItem, id)` + `Lists.update_item(item, attrs)` | Edit body, status, color, bold, italic, highlighted, prefix | -| `arrow_item` | `Repo.get` + `Lists.arrow_item(item, to_date)` | Move item forward to another date | -| `search_items` | `SearchIndex.search(query)` | Full-text search across all items | +```elixir +# JSON-RPC notifications have no "id" — acknowledge with 204, no body. +def handle(conn, _params) do + send_resp(conn, 204, "") +end +``` -Note: `update_item` and `arrow_item` need a `Repo.get` first — identical to what `ItemController` already does. Extract that into a shared private function or a helper in the `Lists` context. +This must be the last `handle/2` clause. The MCP handshake will fail silently without it. --- -## Step 4 — Define tool schemas +## Step 4 — Implement the JSON-RPC 2.0 dispatch -The `tool_definitions()` function returns JSON Schema for each tool so the AI knows what arguments to pass. Example for `list_items`: +The controller handles four cases: `initialize`, `tools/list`, `tools/call`, and the unknown-method fallback (before the notification catch-all): ```elixir -%{ - name: "list_items", - description: "Get all items for a specific date", - inputSchema: %{ - type: "object", - properties: %{ - date: %{type: "string", description: "ISO 8601 date, e.g. 2026-05-04"} - }, - required: ["date"] - } -} +def handle(conn, %{"method" => "initialize", "id" => id}) do + respond(conn, %{ + jsonrpc: "2.0", id: id, + result: %{ + protocolVersion: "2024-11-05", + serverInfo: %{name: "elixdo", version: "1.0"}, + capabilities: %{tools: %{}} + } + }) +end + +def handle(conn, %{"method" => "tools/list", "id" => id}) do + respond(conn, %{jsonrpc: "2.0", id: id, result: %{tools: tool_definitions()}}) +end + +def handle(conn, %{"method" => "tools/call", "id" => id, + "params" => %{"name" => name, "arguments" => args}}) do + result = dispatch(name, args) + respond(conn, %{ + jsonrpc: "2.0", id: id, + result: %{content: [%{type: "text", text: Jason.encode!(result)}]} + }) +end + +def handle(conn, %{"id" => id}) do + respond(conn, %{jsonrpc: "2.0", id: id, + error: %{code: -32601, message: "Method not found"}}) +end + +# Must be last — notifications have no "id" +def handle(conn, _params) do + send_resp(conn, 204, "") +end ``` -Write one schema for each of the seven tools. The descriptions matter — they are how the AI decides which tool to call. +--- + +## Step 5 — Implement the tool dispatcher + +The `dispatch/2` function maps tool names to `Lists` context calls. Key notes: + +- **Emoji shortcode conversion**: `add_item` and `update_item` must call `Emoji.convert/1` on the body before storing — exactly as the LiveView handlers do. If you skip this, shortcodes like `:fire:` are stored as literal text. +- **`update_item` body is optional**: use a private helper to only convert body when the key is present: + ```elixir + defp convert_body(%{"body" => body} = attrs), do: Map.put(attrs, "body", Emoji.convert(body)) + defp convert_body(attrs), do: attrs + ``` +- **`search_items`**: `SearchIndex.search/1` returns `{id, date, body}` tuples, not `ListItem` structs. Serialize manually — do NOT pass to `ItemJSON.item/1`. +- **`arrow_item`**: use `DateHelper.resolve/1` (not `Date.from_iso8601/1`) so relative strings like `"tomorrow"` work. + +### Tools exposed + +| Tool | Maps to | Notes | +|---|---|---| +| `get_today` | `Clock.today()` | Returns `%{today: "2026-05-04"}` | +| `list_items` | `Lists.get_items_for_date(date)` | | +| `list_items_range` | `Lists.get_items_for_range(from, to, statuses)` | `statuses` is optional; map strings to atoms | +| `add_item` | `Lists.create_items(date, [%{body: body}])` | Apply `Emoji.convert/1` first | +| `update_item` | `Repo.get` + `Lists.update_item(item, attrs)` | Apply `Emoji.convert/1` to body if present | +| `arrow_item` | `Repo.get` + `Lists.arrow_item(item, to_date)` | Use `DateHelper.resolve/1` for target date | +| `search_items` | `SearchIndex.search(query)` | Returns tuples — serialize manually | --- -## Step 5 — Set the `AGENT_TOKEN` environment variable +## Step 6 — Register the server with Claude Code CLI -The token already gates the `/api/v1` routes. On Fly.io: +**The `mcpServers` key in `~/.claude/settings.json` is ignored by Claude Code.** That file controls permissions and hooks only. MCP servers must be registered using the CLI command, which writes to `~/.claude.json`: ```sh -fly secrets set AGENT_TOKEN= +claude mcp add --transport http --scope user elixdo \ + https://yourapp.fly.dev/api/v1/mcp \ + --header "Authorization: Bearer " +``` + +Verify with: +```sh +claude mcp list ``` -Locally, add to `.env` or `config/dev.secret.exs`. The MCP client sends this as `Authorization: Bearer `. +You should see `elixdo: ... (HTTP) - ✓ Connected`. If you see `✗ Failed to connect`, run through the troubleshooting section below. + +After registering, **start a new Claude Code session**. MCP servers are connected at session startup. Existing sessions will not see the new tools. --- -## Step 6 — Configure Claude to use the MCP server +## Step 7 — Set the `AGENT_TOKEN` on Fly.io -### Claude Code CLI +```sh +fly secrets set AGENT_TOKEN= +``` -Add to `~/.claude/settings.json` (or the project-level `.claude/settings.json`): +The token gates the `/api/v1/mcp` route via the existing `ApiAuthPlug`. Verify it is set: -```json -{ - "mcpServers": { - "elixdo": { - "type": "http", - "url": "https://yourapp.fly.dev/api/v1/mcp", - "headers": { - "Authorization": "Bearer " - } - } - } -} +```sh +fly secrets list --app ``` -### Claude.ai web app +--- -Go to Settings → Integrations → Add MCP server. Set the URL to `https://yourapp.fly.dev/api/v1/mcp` and add the bearer token in the headers field. +## Files changed -After connecting, Claude will call `tools/list` automatically and discover the available tools. You can then say things like "add 'buy milk' to today's list" or "show me everything I marked complete last week." +| File | Action | +|---|---| +| `lib/elixdo_web/router.ex` | Add `:mcp_auth` pipeline and separate MCP scope | +| `lib/elixdo_web/controllers/api/mcp_controller.ex` | Create (~160 lines) | --- -## What's not needed +## Troubleshooting: `✗ Failed to connect` -- No new authentication — `AGENT_TOKEN` already exists -- No new context functions — `Lists` already exposes everything needed -- No streaming/SSE — all operations are synchronous, plain JSON responses are sufficient -- No second process or deployment — this lives in the existing Phoenix app +Work through these in order: ---- +**1. Test the initialize handshake directly:** +```sh +curl -X POST https://yourapp.fly.dev/api/v1/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' +``` +Should return 200 with `protocolVersion`. If 401, the token is wrong. If 404, the route isn't deployed. -## Files to change +**2. Test SSE response:** +```sh +curl -X POST https://yourapp.fly.dev/api/v1/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream" \ + -H "Authorization: Bearer " \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' +``` +Should return `event: message\ndata: {...}`. If 406, Phoenix is rejecting `text/event-stream` — the MCP pipeline is using `plug :accepts`. -| File | Action | -|---|---| -| `lib/elixdo_web/router.ex` | Add 1 line | -| `lib/elixdo_web/controllers/api/mcp_controller.ex` | Create (~120 lines) | +**3. Test notification handling:** +```sh +curl -X POST https://yourapp.fly.dev/api/v1/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' +``` +Should return 204. If 400, the notification catch-all handler is missing. + +**4. Check tools actually load in a new session:** +```sh +claude mcp list +``` +If `✓ Connected` but tools still don't appear as `mcp__elixdo__*` in a session, restart the session — tools are loaded at startup only. + +--- + +## Test plan + +Write these tests before implementing. All should be red until the implementation is complete. + +**Core protocol:** +1. `initialize returns correct protocol version and capabilities` +2. `tools/list returns all seven tool definitions with required fields` +3. `notifications return 204 with empty body` ← easy to miss, critical for handshake +4. `unknown method returns -32601 error` +5. `missing auth returns 401` +6. `responds with SSE format when Accept: text/event-stream` ← tests the transport layer + +**Tool behaviour:** +7. `get_today returns today's date` +8. `list_items returns items for a date` +9. `list_items_range returns items across a date range` +10. `list_items_range with status filter returns only matching items` +11. `add_item creates item and it appears in list_items` +12. `add_item converts shortcodes in body` +13. `update_item changes item body` +14. `update_item converts shortcodes in body` +15. `update_item without body field is unaffected by shortcode conversion` +16. `arrow_item marks original arrowed_out and creates copy on target date` +17. `search_items returns matching results` diff --git a/assets/css/app.css b/assets/css/app.css index 434d73e..a5c00bc 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -225,47 +225,12 @@ body { line-height: 1; } .toolbar-btn:hover { background: var(--bg-4); color: white; } +.priority-btn { color: var(--text); font-size: 22px; padding: 0 4px; min-width: unset; } -/* Select-all button — ring matches item-select-btn */ -.select-btn { - display: flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - background: var(--bg-3); - border: 1px solid var(--border-light); - border-radius: var(--radius-btn); - cursor: pointer; - transition: all 0.12s; - position: relative; -} -.select-btn::before { - content: ""; - width: 22px; - height: 22px; - border-radius: 50%; - border: 2px solid oklch(72% 0.015 280); - transition: all 0.12s; - flex-shrink: 0; -} -.select-btn:hover::before { border-color: var(--accent); } -.select-btn.selected::before { - background: var(--accent); - border-color: var(--accent); -} -.select-btn.selected::after { - content: ""; - position: absolute; - width: 7px; - height: 7px; - border-radius: 50%; - background: white; - pointer-events: none; -} -/* Search button */ -.search-btn { +/* Search and sort buttons */ +.search-btn, +.sort-btn { display: flex; align-items: center; justify-content: center; @@ -278,7 +243,8 @@ body { cursor: pointer; transition: all 0.12s; } -.search-btn:hover { border-color: var(--accent); color: white; } +.search-btn:hover, +.sort-btn:hover { border-color: var(--accent); color: white; } /* Color swatches row — same 40px height */ .swatches-row { @@ -309,43 +275,83 @@ body { .swatch-purple { background: var(--c-purple); } .swatch-orange { background: var(--c-orange); } -/* Prefix input — matches toolbar height */ -.prefix-form { display: flex; align-items: center; } -.prefix-input { - width: 44px; - height: 40px; - padding: 0 8px; - background: var(--bg-4); - border: 1px solid var(--border-light); - border-radius: var(--radius-btn); - color: var(--text); - font-family: inherit; - font-size: 12px; - text-align: center; +/* Mobile priority trigger button — hidden on desktop, shown on coarse */ +.priority-mobile-btn { display: none; font-size: 22px; } + +/* Priority bottom sheet icons */ +.priority-sheet-btn { + background: none; + border: none; + font-size: 44px; + cursor: pointer; + padding: 4px 8px; + transition: transform 0.1s; +} +.priority-sheet-btn:hover, +.priority-sheet-btn:active { transform: scale(1.2); } + +/* Mobile color trigger button — hidden on desktop, shown on coarse */ +.color-mobile-btn { + display: none; + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid var(--border-color); + cursor: pointer; + flex-shrink: 0; +} +.color-mobile-btn:hover { transform: scale(1.15); border-color: var(--text); } + +/* Color bottom sheet */ +.bottom-sheet-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 200; + display: flex; + align-items: flex-end; +} +.bottom-sheet { + width: 100%; + background: var(--bg-2); + border-top: 1px solid var(--border-color); + border-radius: 16px 16px 0 0; + padding: 24px 16px 32px; + display: flex; + justify-content: center; + gap: 20px; } -.prefix-input::placeholder { color: var(--text-dim); } -.prefix-input:focus { outline: none; border-color: var(--accent); } +.color-sheet-swatch { + width: 52px; + height: 52px; + border-radius: 50%; + border: 3px solid transparent; + cursor: pointer; + flex-shrink: 0; + transition: transform 0.1s; +} +.color-sheet-swatch:hover, +.color-sheet-swatch:active { transform: scale(1.2); border-color: var(--text); } /* Today button */ .today-btn { display: flex; align-items: center; - gap: 5px; + justify-content: center; + width: 26px; height: 40px; - padding: 0 14px; - background: oklch(63% 0.27 293 / 0.14); - border: 1px solid var(--accent-dim); + padding: 0; + background: var(--bg-2); + border: 1px solid var(--border-color); border-radius: var(--radius-btn); - color: var(--accent); + color: oklch(82% 0.012 280); font-family: inherit; font-size: 24px; - font-weight: 600; cursor: pointer; - letter-spacing: 0.02em; transition: all 0.12s; - white-space: nowrap; + flex-shrink: 0; } -.today-btn:hover { background: oklch(63% 0.27 293 / 0.24); } +.today-btn:hover { border-color: var(--accent); color: white; } /* Hide old separator */ .toolbar-sep { display: none; } @@ -515,6 +521,14 @@ body { .drag-handle:active { cursor: grabbing; } .drag-ghost { opacity: 0.35; } +/* Priority character: always visible, full color, slightly larger than ⠿ */ +.drag-handle.drag-handle--priority { + opacity: 1; + color: var(--text); + font-size: 20px; + transition: none; +} + /* ─── Per-item select button ────────────────────────────────────────── */ .item-select-btn { @@ -545,23 +559,6 @@ body { background: white; } -/* ─── Prefix badge ──────────────────────────────────────────────────── */ - -.prefix-badge { - display: flex; - align-items: center; - justify-content: center; - min-width: 28px; - height: 28px; - padding: 0 5px; - background: var(--bg-4); - border: 1px solid var(--border-color); - border-radius: 6px; - font-size: 15px; - line-height: 1; - flex-shrink: 0; -} - /* ─── Item body ─────────────────────────────────────────────────────── */ .item-body { @@ -573,14 +570,6 @@ body { line-height: 1.2; cursor: pointer; } -.item-body.bold { font-weight: 700; } -.item-body.italic { font-style: italic; } -.item-body.highlighted .item-text { - background: oklch(85% 0.16 90 / 0.28); - border-radius: 3px; - padding: 0 2px; -} - .item-body.color-red { color: oklch(72% 0.20 17); } .item-body.color-blue { color: oklch(72% 0.18 240); } .item-body.color-green { color: oklch(72% 0.16 145); } @@ -655,12 +644,12 @@ body { .add-item-input { flex: 1; padding: 10px 14px; - background: var(--bg-2); + background: #000; border: 1px solid var(--border-color); border-radius: var(--radius-card); color: var(--text); font-family: inherit; - font-size: 15px; + font-size: 20px; resize: none; min-height: 44px; transition: border-color 0.12s, box-shadow 0.12s; @@ -680,7 +669,7 @@ body { border: none; border-radius: var(--radius-card); font-family: inherit; - font-size: 24px; + font-size: 20px; font-weight: 600; cursor: pointer; letter-spacing: 0.02em; @@ -690,6 +679,30 @@ body { .add-btn:hover { background: var(--accent-dim); } .add-btn:active { transform: scale(0.97); } +.mic-btn { + padding: 0 12px; + align-self: stretch; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-dim); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.12s, color 0.12s; +} +.mic-btn:hover { background: var(--bg-3); color: var(--text); } +.mic-btn.mic-recording { + color: #e53; + animation: mic-pulse 1s ease-in-out infinite; +} +@keyframes mic-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + /* ═══════════════════════════════════════════════════════════════════════ MODALS ═══════════════════════════════════════════════════════════════════════ */ @@ -817,11 +830,18 @@ body { .item { padding: 16px 14px 16px 16px; gap: 12px; } .item-body { font-size: 22px; line-height: 1.25; } .drag-handle { opacity: 0.4; font-size: 20px; padding: 0 0.6rem; min-width: 2rem; text-align: center; } + .drag-handle.drag-handle--priority { opacity: 1; font-size: 26px; } .item-select-btn { width: 32px; height: 32px; } .item-select-btn.selected::after { width: 8px; height: 8px; } .toolbar-btn { min-width: 40px; height: 38px; } - .add-item-input { font-size: 1.1rem; padding: 0.75rem; } + .priority-btn { font-size: 22px; padding: 0 4px; min-width: unset; } + .today-btn { font-size: 48px; width: 48px; } + .add-item-input { font-size: 20px; padding: 0.75rem; } .date-header { padding: 0.75rem; } .nav-arrow { font-size: 2rem; padding: 0.5rem 1.2rem; } .search-modal { min-width: unset; width: 90vw; } + .swatches-row { display: none; } + .color-mobile-btn { display: block; } + .priority-desktop-group { display: none; } + .priority-mobile-btn { display: flex; align-items: center; justify-content: center; } } diff --git a/assets/js/app.js b/assets/js/app.js index a3743f4..8c1c304 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -28,6 +28,7 @@ import Swipe from "./swipe" import AddItem from "./add_item" import EditItem from "./edit_item" import DragSort from "./drag_sort" +import VoiceInput from "./voice_input" const SearchFocus = { mounted() { this.el.focus() } @@ -39,6 +40,7 @@ Hooks.AddItem = AddItem Hooks.EditItem = EditItem Hooks.DragSort = DragSort Hooks.SearchFocus = SearchFocus +Hooks.VoiceInput = VoiceInput const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const liveSocket = new LiveSocket("/live", Socket, { diff --git a/assets/js/voice_input.js b/assets/js/voice_input.js new file mode 100644 index 0000000..1bbe6eb --- /dev/null +++ b/assets/js/voice_input.js @@ -0,0 +1,42 @@ +const VoiceInput = { + mounted() { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + if (!SpeechRecognition) return; // leave hidden on unsupported browsers + + this.el.style.display = ""; + + const recognition = new SpeechRecognition(); + recognition.lang = "en-US"; + recognition.interimResults = false; + recognition.maxAlternatives = 1; + + let recording = false; + + recognition.onresult = (e) => { + const transcript = e.results[0][0].transcript; + this.pushEvent("voice_input", {text: transcript}); + }; + + recognition.onend = () => { + recording = false; + this.el.classList.remove("mic-recording"); + }; + + recognition.onerror = () => { + recording = false; + this.el.classList.remove("mic-recording"); + }; + + this.el.addEventListener("click", () => { + if (recording) { + recognition.stop(); + } else { + recognition.start(); + recording = true; + this.el.classList.add("mic-recording"); + } + }); + } +}; + +export default VoiceInput; diff --git a/lib/elixdo/emoji.ex b/lib/elixdo/emoji.ex index 1cec7f2..03f69ab 100644 --- a/lib/elixdo/emoji.ex +++ b/lib/elixdo/emoji.ex @@ -3,7 +3,6 @@ # On Windows: Win + . opens the emoji picker # On Mac: Cmd + Ctrl + Space - defmodule Elixdo.Emoji do @moduledoc "Converts :shortcode: patterns to emoji characters." @@ -45,7 +44,7 @@ defmodule Elixdo.Emoji do "warning" => "⚠️", "tada" => "🎉", "rocket" => "🚀", - "bulb" => "💡", + "idea" => "💡", "memo" => "📝", "calendar" => "📅", "clock" => "🕐", @@ -57,7 +56,6 @@ defmodule Elixdo.Emoji do "dog" => "🐶", "cat" => "🐱", "pizza" => "🍕", - "coffee" => "☕", "lock" => "🔒", "key" => "🔑", "wrench" => "🔧", diff --git a/lib/elixdo/list_item.ex b/lib/elixdo/list_item.ex index 0e7256b..fcad867 100644 --- a/lib/elixdo/list_item.ex +++ b/lib/elixdo/list_item.ex @@ -1,7 +1,11 @@ defmodule Elixdo.ListItem do + @moduledoc "Ecto schema for a single task item. Defines fields, enums, and validation rules." + use Ecto.Schema import Ecto.Changeset + @valid_priorities ["❶", "❷", "❸", "⭐", "🔥"] + schema "list_items" do field :date, :date field :position, :integer @@ -12,15 +16,16 @@ defmodule Elixdo.ListItem do default: :active field :color, Ecto.Enum, values: [:red, :blue, :green, :purple, :orange] - field :bold, :boolean, default: false - field :italic, :boolean, default: false - field :highlighted, :boolean, default: false - field :prefix, :string + field :priority, :string field :arrowed_to_date, :date timestamps(type: :utc_datetime) end + def colors, do: Ecto.Enum.values(__MODULE__, :color) + def color_strings, do: Enum.map(colors(), &Atom.to_string/1) + def priorities, do: @valid_priorities + def changeset(item, attrs) do item |> cast(attrs, [ @@ -29,17 +34,23 @@ defmodule Elixdo.ListItem do :body, :status, :color, - :bold, - :italic, - :highlighted, - :prefix, + :priority, :arrowed_to_date ]) |> validate_required([:date, :position, :body]) |> validate_length(:body, min: 1) + |> validate_priority() |> validate_arrowed_to_date_consistency() end + defp validate_priority(changeset) do + case get_change(changeset, :priority) do + nil -> changeset + p when p in @valid_priorities -> changeset + _ -> add_error(changeset, :priority, "must be one of #{Enum.join(@valid_priorities, ", ")}") + end + end + # arrowed_to_date must be set iff status is arrowed_out defp validate_arrowed_to_date_consistency(changeset) do status = get_field(changeset, :status) diff --git a/lib/elixdo/lists.ex b/lib/elixdo/lists.ex index b932684..c719bdc 100644 --- a/lib/elixdo/lists.ex +++ b/lib/elixdo/lists.ex @@ -2,11 +2,27 @@ defmodule Elixdo.Lists do @moduledoc "Public API for list operations. Routes through per-date GenServer." alias Elixdo.Lists.{Server, DB} + alias Elixdo.Emoji def get_items_for_date(date), do: Server.get_items(date) - def get_items_for_range(from_date, to_date, statuses \\ nil), do: DB.get_items_for_range(from_date, to_date, statuses) - def create_items(date, attrs), do: Server.create_items(date, attrs) - def update_item(item, attrs), do: Server.update_item(item.date, item, attrs) + + def get_items_for_range(from_date, to_date, statuses \\ nil), + do: DB.get_items_for_range(from_date, to_date, statuses) + + def create_items(date, attrs) do + attrs = Enum.map(attrs, &convert_body/1) + Server.create_items(date, attrs) + end + + def update_item(item, attrs) do + attrs = convert_body(attrs) + Server.update_item(item.date, item, attrs) + end + def arrow_item(item, to_date), do: Server.arrow_item(item.date, item, to_date) def reorder_items(date, ids), do: Server.reorder_items(date, ids) + + defp convert_body(%{body: body} = attrs), do: %{attrs | body: Emoji.convert(body)} + defp convert_body(%{"body" => body} = attrs), do: %{attrs | "body" => Emoji.convert(body)} + defp convert_body(attrs), do: attrs end diff --git a/lib/elixdo/lists/db.ex b/lib/elixdo/lists/db.ex index 6fdd104..9140666 100644 --- a/lib/elixdo/lists/db.ex +++ b/lib/elixdo/lists/db.ex @@ -93,11 +93,7 @@ defmodule Elixdo.Lists.DB do create_items(to_date, [ %{ body: item.body, - color: item.color, - bold: item.bold, - italic: item.italic, - highlighted: item.highlighted, - prefix: item.prefix + color: item.color } ]) end) diff --git a/lib/elixdo/search_index.ex b/lib/elixdo/search_index.ex index af67c8f..980e779 100644 --- a/lib/elixdo/search_index.ex +++ b/lib/elixdo/search_index.ex @@ -49,7 +49,6 @@ defmodule Elixdo.SearchIndex do results = :ets.tab2list(:search_index) |> Enum.filter(fn {_id, _date, body} -> String.contains?(body, query) end) - |> Enum.map(fn {id, date, body} -> {id, date, body} end) {:reply, results, state} end diff --git a/lib/elixdo_web/controllers/api/helpers.ex b/lib/elixdo_web/controllers/api/helpers.ex new file mode 100644 index 0000000..903f203 --- /dev/null +++ b/lib/elixdo_web/controllers/api/helpers.ex @@ -0,0 +1,12 @@ +defmodule ElixdoWeb.Api.Helpers do + @moduledoc false + alias Elixdo.{Repo, ListItem} + + @doc "Fetch a ListItem by id, returning {:ok, item} or {:error, :not_found}." + def fetch_item(id) do + case Repo.get(ListItem, id) do + nil -> {:error, :not_found} + item -> {:ok, item} + end + end +end diff --git a/lib/elixdo_web/controllers/api/item_controller.ex b/lib/elixdo_web/controllers/api/item_controller.ex index 1daded7..c56c87c 100644 --- a/lib/elixdo_web/controllers/api/item_controller.ex +++ b/lib/elixdo_web/controllers/api/item_controller.ex @@ -1,8 +1,9 @@ defmodule ElixdoWeb.Api.ItemController do + @moduledoc false use ElixdoWeb, :controller - alias Elixdo.{Lists, DateHelper, Repo, ListItem} - alias ElixdoWeb.Api.ItemJSON + alias Elixdo.{Lists, DateHelper} + alias ElixdoWeb.Api.{ItemJSON, Helpers} # PATCH /api/v1/items/:id def update(conn, %{"id" => id} = params) do @@ -19,7 +20,9 @@ defmodule ElixdoWeb.Api.ItemController do {:error, :forbidden_transition} -> conn |> put_status(422) - |> json(%{error: %{code: "forbidden_transition", message: "Status transition is not allowed"}}) + |> json(%{ + error: %{code: "forbidden_transition", message: "Status transition is not allowed"} + }) {:error, changeset} -> conn @@ -48,7 +51,9 @@ defmodule ElixdoWeb.Api.ItemController do {:error, :forbidden_transition} -> conn |> put_status(422) - |> json(%{error: %{code: "forbidden_transition", message: "Only active items can be arrowed"}}) + |> json(%{ + error: %{code: "forbidden_transition", message: "Only active items can be arrowed"} + }) {:error, _} -> conn @@ -63,10 +68,5 @@ defmodule ElixdoWeb.Api.ItemController do |> json(%{error: %{code: "validation_error", message: "target_date is required"}}) end - defp fetch_item(id) do - case Repo.get(ListItem, id) do - nil -> {:error, :not_found} - item -> {:ok, item} - end - end + defp fetch_item(id), do: Helpers.fetch_item(id) end diff --git a/lib/elixdo_web/controllers/api/item_json.ex b/lib/elixdo_web/controllers/api/item_json.ex index e25df92..ebd38c8 100644 --- a/lib/elixdo_web/controllers/api/item_json.ex +++ b/lib/elixdo_web/controllers/api/item_json.ex @@ -1,4 +1,5 @@ defmodule ElixdoWeb.Api.ItemJSON do + @moduledoc false alias Elixdo.ListItem @doc "Serialize a single ListItem to a JSON-safe map." @@ -9,11 +10,8 @@ defmodule ElixdoWeb.Api.ItemJSON do body: item.body, status: to_string(item.status), position: item.position, - bold: item.bold, - italic: item.italic, - highlighted: item.highlighted, color: item.color && to_string(item.color), - prefix: item.prefix, + priority: item.priority, arrowed_to_date: item.arrowed_to_date && Date.to_iso8601(item.arrowed_to_date), inserted_at: format_datetime(item.inserted_at), updated_at: format_datetime(item.updated_at) diff --git a/lib/elixdo_web/controllers/api/list_controller.ex b/lib/elixdo_web/controllers/api/list_controller.ex index 0836c2e..1f0ac9c 100644 --- a/lib/elixdo_web/controllers/api/list_controller.ex +++ b/lib/elixdo_web/controllers/api/list_controller.ex @@ -1,4 +1,5 @@ defmodule ElixdoWeb.Api.ListController do + @moduledoc false use ElixdoWeb, :controller alias Elixdo.{Lists, DateHelper} @@ -69,7 +70,9 @@ defmodule ElixdoWeb.Api.ListController do {:error, :partial_reorder} -> conn |> put_status(422) - |> json(%{error: %{code: "partial_reorder", message: "IDs do not match items on this date"}}) + |> json(%{ + error: %{code: "partial_reorder", message: "IDs do not match items on this date"} + }) {:error, _} -> conn diff --git a/lib/elixdo_web/controllers/api/mcp_controller.ex b/lib/elixdo_web/controllers/api/mcp_controller.ex new file mode 100644 index 0000000..0a6512b --- /dev/null +++ b/lib/elixdo_web/controllers/api/mcp_controller.ex @@ -0,0 +1,230 @@ +defmodule ElixdoWeb.Api.McpController do + @moduledoc false + use ElixdoWeb, :controller + + alias Elixdo.{Lists, SearchIndex, Clock, DateHelper, ListItem} + alias ElixdoWeb.Api.{ItemJSON, Helpers} + + # --------------------------------------------------------------------------- + # JSON-RPC 2.0 dispatch + # --------------------------------------------------------------------------- + + def handle(conn, %{"method" => "initialize", "id" => id}) do + respond(conn, %{ + jsonrpc: "2.0", + id: id, + result: %{ + protocolVersion: "2024-11-05", + serverInfo: %{name: "elixdo", version: "1.0"}, + capabilities: %{tools: %{}} + } + }) + end + + def handle(conn, %{"method" => "tools/list", "id" => id}) do + respond(conn, %{jsonrpc: "2.0", id: id, result: %{tools: tool_definitions()}}) + end + + def handle(conn, %{ + "method" => "tools/call", + "id" => id, + "params" => %{"name" => name, "arguments" => args} + }) do + result = dispatch(name, args) + respond(conn, %{ + jsonrpc: "2.0", + id: id, + result: %{content: [%{type: "text", text: Jason.encode!(result)}]} + }) + end + + def handle(conn, %{"id" => id}) do + respond(conn, %{ + jsonrpc: "2.0", + id: id, + error: %{code: -32601, message: "Method not found"} + }) + end + + # JSON-RPC notifications have no "id" — acknowledge with 204, no body. + def handle(conn, _params) do + send_resp(conn, 204, "") + end + + # --------------------------------------------------------------------------- + # Streamable HTTP transport: respond as SSE or plain JSON based on Accept + # --------------------------------------------------------------------------- + + defp respond(conn, payload) do + accept = get_req_header(conn, "accept") |> List.first("") + + if String.contains?(accept, "text/event-stream") do + data = Jason.encode!(payload) + + conn + |> put_resp_content_type("text/event-stream") + |> put_resp_header("cache-control", "no-cache") + |> send_resp(200, "event: message\ndata: #{data}\n\n") + else + json(conn, payload) + end + end + + # --------------------------------------------------------------------------- + # Tool dispatcher + # --------------------------------------------------------------------------- + + defp dispatch("get_today", _args) do + %{today: Date.to_iso8601(Clock.today())} + end + + defp dispatch("list_items", %{"date" => date_str}) do + with {:ok, date} <- Date.from_iso8601(date_str) do + Lists.get_items_for_date(date) |> Enum.map(&ItemJSON.item/1) + end + end + + defp dispatch("list_items_range", args) do + with {:ok, from} <- Date.from_iso8601(args["from"]), + {:ok, to} <- Date.from_iso8601(args["to"]) do + statuses = args["statuses"] && Enum.map(args["statuses"], &String.to_existing_atom/1) + Lists.get_items_for_range(from, to, statuses) |> Enum.map(&ItemJSON.item/1) + end + end + + defp dispatch("add_item", %{"date" => date_str, "body" => body}) do + with {:ok, date} <- Date.from_iso8601(date_str), + {:ok, [item]} <- Lists.create_items(date, [%{body: body}]) do + ItemJSON.item(item) + end + end + + defp dispatch("update_item", %{"id" => id} = args) do + attrs = Map.drop(args, ["id"]) + + with {:ok, item} <- fetch_item(id), + {:ok, updated} <- Lists.update_item(item, attrs) do + ItemJSON.item(updated) + end + end + + defp dispatch("arrow_item", %{"id" => id, "target_date" => target_date_str}) do + with {:ok, item} <- fetch_item(id), + {:ok, target_date} <- DateHelper.resolve(target_date_str), + {:ok, original, _copy} <- Lists.arrow_item(item, target_date) do + ItemJSON.item(original) + end + end + + defp dispatch("search_items", %{"query" => query}) do + SearchIndex.search(query) + |> Enum.map(fn {id, date, body} -> + %{id: id, date: Date.to_iso8601(date), body: body} + end) + end + + defp dispatch(_name, _args) do + %{error: "unknown tool"} + end + + # --------------------------------------------------------------------------- + # Tool schema definitions + # --------------------------------------------------------------------------- + + defp tool_definitions do + [ + %{ + name: "get_today", + description: "Get the current date in the user's local timezone (America/Denver)", + inputSchema: %{type: "object", properties: %{}, required: []} + }, + %{ + name: "list_items", + description: "Get all items for a specific date", + inputSchema: %{ + type: "object", + properties: %{ + date: %{type: "string", description: "ISO 8601 date, e.g. 2026-05-04"} + }, + required: ["date"] + } + }, + %{ + name: "list_items_range", + description: "Get items across a date range, optionally filtered by status", + inputSchema: %{ + type: "object", + properties: %{ + from: %{type: "string", description: "Start date ISO 8601"}, + to: %{type: "string", description: "End date ISO 8601"}, + statuses: %{ + type: "array", + items: %{type: "string", enum: ["active", "completed", "wiggled_out", "arrowed_out"]}, + description: "Filter by these statuses (omit for all)" + } + }, + required: ["from", "to"] + } + }, + %{ + name: "add_item", + description: "Add a new item to a date's list", + inputSchema: %{ + type: "object", + properties: %{ + date: %{type: "string", description: "ISO 8601 date"}, + body: %{type: "string", description: "Item text"} + }, + required: ["date", "body"] + } + }, + %{ + name: "update_item", + description: "Edit an existing item's body, status, color, or priority", + inputSchema: %{ + type: "object", + properties: %{ + id: %{type: "integer", description: "Item ID"}, + body: %{type: "string"}, + status: %{ + type: "string", + enum: ["active", "completed", "wiggled_out"] + }, + color: %{type: "string", enum: ListItem.color_strings()}, + priority: %{type: "string", enum: ListItem.priorities()} + }, + required: ["id"] + } + }, + %{ + name: "arrow_item", + description: "Move an active item forward to another date", + inputSchema: %{ + type: "object", + properties: %{ + id: %{type: "integer", description: "Item ID"}, + target_date: %{type: "string", description: "ISO 8601 target date, or 'tomorrow'"} + }, + required: ["id", "target_date"] + } + }, + %{ + name: "search_items", + description: "Full-text search across all items", + inputSchema: %{ + type: "object", + properties: %{ + query: %{type: "string", description: "Search query"} + }, + required: ["query"] + } + } + ] + end + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + defp fetch_item(id), do: Helpers.fetch_item(id) +end diff --git a/lib/elixdo_web/controllers/health_controller.ex b/lib/elixdo_web/controllers/health_controller.ex index 8272b6e..6cb6121 100644 --- a/lib/elixdo_web/controllers/health_controller.ex +++ b/lib/elixdo_web/controllers/health_controller.ex @@ -1,4 +1,5 @@ defmodule ElixdoWeb.HealthController do + @moduledoc false use ElixdoWeb, :controller def show(conn, _params) do diff --git a/lib/elixdo_web/controllers/manifest_controller.ex b/lib/elixdo_web/controllers/manifest_controller.ex index fc9f096..1f1a8c3 100644 --- a/lib/elixdo_web/controllers/manifest_controller.ex +++ b/lib/elixdo_web/controllers/manifest_controller.ex @@ -1,4 +1,5 @@ defmodule ElixdoWeb.ManifestController do + @moduledoc false use ElixdoWeb, :controller def show(conn, _params) do diff --git a/lib/elixdo_web/controllers/page_controller.ex b/lib/elixdo_web/controllers/page_controller.ex index e82d6ca..428502a 100644 --- a/lib/elixdo_web/controllers/page_controller.ex +++ b/lib/elixdo_web/controllers/page_controller.ex @@ -1,4 +1,5 @@ defmodule ElixdoWeb.PageController do + @moduledoc false use ElixdoWeb, :controller def index(conn, _params) do diff --git a/lib/elixdo_web/live/list_live.ex b/lib/elixdo_web/live/list_live.ex index 2ec2783..478a4c5 100644 --- a/lib/elixdo_web/live/list_live.ex +++ b/lib/elixdo_web/live/list_live.ex @@ -1,7 +1,8 @@ defmodule ElixdoWeb.ListLive do + @moduledoc false use ElixdoWeb, :live_view - alias Elixdo.{Lists, DateHelper, Emoji} + alias Elixdo.{Lists, DateHelper, ListItem} @impl true def mount(%{"secret" => secret} = params, _session, socket) do @@ -37,7 +38,12 @@ defmodule ElixdoWeb.ListLive do |> assign(:arrow_item_ids, []) |> assign(:search_open, false) |> assign(:search_results, []) - |> assign(:highlighted_item_id, nil)} + |> assign(:highlighted_item_id, nil) + |> assign(:color_sheet_open, false) + |> assign(:last_color, :blue) + |> assign(:priority_sheet_open, false) + |> assign(:valid_colors, ListItem.colors()) + |> assign(:valid_priorities, ListItem.priorities())} end @impl true @@ -117,15 +123,20 @@ defmodule ElixdoWeb.ListLive do {:noreply, socket |> assign(:selected, selected) |> assign(:highlighted_item_id, nil)} end - def handle_event("toggle_all", _, socket) do - all_ids = socket.assigns.items |> Enum.map(& &1.id) |> MapSet.new() - selected = if socket.assigns.selected == all_ids, do: MapSet.new(), else: all_ids - {:noreply, assign(socket, :selected, selected)} + # Voice input + def handle_event("voice_input", %{"text" => text}, socket) do + text = String.trim(text) + + if text != "" do + Lists.create_items(socket.assigns.date, [%{body: text}]) + end + + {:noreply, socket} end # Add item def handle_event("add_item", %{"body" => body}, socket) do - body = body |> String.trim() |> Emoji.convert() + body = String.trim(body) if body != "" do Lists.create_items(socket.assigns.date, [%{body: body}]) @@ -147,7 +158,7 @@ defmodule ElixdoWeb.ListLive do def handle_event("save_edit", %{"_id" => id, "body" => body}, socket) do id = String.to_integer(id) - body = body |> String.trim() |> Emoji.convert() + body = String.trim(body) if body != "" do item = Enum.find(socket.assigns.items, &(&1.id == id)) @@ -166,39 +177,53 @@ defmodule ElixdoWeb.ListLive do @valid_statuses Ecto.Enum.values(Elixdo.ListItem, :status) @valid_status_strings Enum.map(@valid_statuses, &Atom.to_string/1) - @valid_colors Ecto.Enum.values(Elixdo.ListItem, :color) - @valid_color_strings Enum.map(@valid_colors, &Atom.to_string/1) + @valid_color_strings ListItem.color_strings() + + @valid_priorities ListItem.priorities() + + def handle_event("open_priority_sheet", _, socket) do + {:noreply, assign(socket, :priority_sheet_open, true)} + end + + def handle_event("close_priority_sheet", _, socket) do + {:noreply, assign(socket, :priority_sheet_open, false)} + end + + def handle_event("set_priority", %{"priority" => p}, socket) + when p in @valid_priorities do + selected_items = selected_items(socket) + + Enum.each(selected_items, fn item -> + Lists.update_item(item, %{priority: p}) + end) + + {:noreply, socket |> assign(:priority_sheet_open, false) |> clear_selection()} + end def handle_event("set_status", %{"status" => status_str}, socket) when status_str in @valid_status_strings do status = String.to_existing_atom(status_str) - selected_items = - Enum.filter(socket.assigns.items, &MapSet.member?(socket.assigns.selected, &1.id)) + selected_items = selected_items(socket) Enum.each(selected_items, fn item -> Lists.update_item(item, %{status: status}) end) - {:noreply, socket} + {:noreply, clear_selection(socket)} end def handle_event("set_status", _, socket), do: {:noreply, socket} # Toolbar decoration actions def handle_event("set_decoration", %{"field" => field, "setting" => setting}, socket) do - selected_items = - Enum.filter(socket.assigns.items, &MapSet.member?(socket.assigns.selected, &1.id)) + selected_items = selected_items(socket) attrs = case field do - "bold" -> %{bold: setting == "true"} - "italic" -> %{italic: setting == "true"} - "highlighted" -> %{highlighted: setting == "true"} "color" when setting == "" -> %{color: nil} "color" when setting in @valid_color_strings -> %{color: String.to_existing_atom(setting)} "color" -> %{} - "prefix" -> %{prefix: if(setting == "", do: nil, else: setting)} _ -> %{} end @@ -206,19 +231,54 @@ defmodule ElixdoWeb.ListLive do Lists.update_item(item, attrs) end) - {:noreply, socket} + {:noreply, clear_selection(socket)} + end + + # Mobile color sheet + def handle_event("open_color_sheet", _, socket) do + {:noreply, assign(socket, :color_sheet_open, true)} + end + + def handle_event("close_color_sheet", _, socket) do + {:noreply, assign(socket, :color_sheet_open, false)} + end + + def handle_event("set_color", %{"color" => color_str}, socket) + when color_str in @valid_color_strings do + color = String.to_existing_atom(color_str) + + selected_items = selected_items(socket) + + Enum.each(selected_items, fn item -> + Lists.update_item(item, %{color: color}) + end) + + {:noreply, + socket + |> assign(:color_sheet_open, false) + |> assign(:last_color, color) + |> clear_selection() + } + end + + def handle_event("set_color", _, socket) do + {:noreply, assign(socket, :color_sheet_open, false)} end # Remove all formats + restore active status (except arrowed_out, which cannot transition) def handle_event("remove_formats", _, socket) do - selected_items = - Enum.filter(socket.assigns.items, &MapSet.member?(socket.assigns.selected, &1.id)) + selected_items = selected_items(socket) Enum.each(selected_items, fn item -> - Lists.update_item(item, %{bold: false, italic: false, highlighted: false, color: nil, status: :active, arrowed_to_date: nil}) + Lists.update_item(item, %{ + color: nil, + priority: nil, + status: :active, + arrowed_to_date: nil + }) end) - {:noreply, socket} + {:noreply, clear_selection(socket)} end # Arrow-out flow @@ -265,6 +325,15 @@ defmodule ElixdoWeb.ListLive do {:noreply, socket} end + def handle_event("sort_active", _, socket) do + {active, non_active} = + Enum.split_with(socket.assigns.items, &(&1.status == :active)) + + new_ids = Enum.map(active ++ non_active, & &1.id) + Lists.reorder_items(socket.assigns.date, new_ids) + {:noreply, socket} + end + # Search def handle_event("open_search", _, socket) do {:noreply, assign(socket, search_open: true, search_results: [])} @@ -315,19 +384,19 @@ defmodule ElixdoWeb.ListLive do ~p"/#{secret}/list/#{Date.to_iso8601(date)}" end + defp clear_selection(socket), do: assign(socket, :selected, MapSet.new()) + + defp selected_items(socket) do + Enum.filter(socket.assigns.items, &MapSet.member?(socket.assigns.selected, &1.id)) + end + defp item_class(%{status: :completed}), do: "completed" defp item_class(%{status: :wiggled_out}), do: "wiggled-out" defp item_class(%{status: :arrowed_out}), do: "arrowed-out" defp item_class(_), do: "active" defp item_classes(item) do - [ - item_class(item), - if(item.bold, do: "bold", else: nil), - if(item.italic, do: "italic", else: nil), - if(item.highlighted, do: "highlighted", else: nil), - color_class(item.color) - ] + [item_class(item), color_class(item.color)] |> Enum.reject(&is_nil/1) |> Enum.join(" ") end diff --git a/lib/elixdo_web/live/list_live.html.heex b/lib/elixdo_web/live/list_live.html.heex index 14aeea4..1f60fba 100644 --- a/lib/elixdo_web/live/list_live.html.heex +++ b/lib/elixdo_web/live/list_live.html.heex @@ -2,10 +2,10 @@
- <% all_selected? = @items != [] and MapSet.size(@selected) == length(@items) %> - <%# Select-all — ring circle, no text content %> - + <%# Go to today — only shown when not on today %> + <%= if @date != @today do %> + + <% end %> <%# Search — SVG magnifier %> + + <%# Sort active-first %> +
@@ -44,34 +54,25 @@
- <%# ── Style group ───────────────────────────────────────── %> -
- - - + <%# ── Priority handle decorations ───────────────────────── %> + +
+ <%= for p <- @valid_priorities do %> + + <% end %>
- <%# ── Color swatches ────────────────────────────────────── %> + <%# ── Color swatches (desktop) + mobile trigger ─────────── %> +
- - - - - + <%= for color <- @valid_colors do %> + + <% end %>
<%# ── Clear formats ─────────────────────────────────────── %> @@ -84,24 +85,15 @@
- <%# ── Prefix input ──────────────────────────────────────── %> -
- - -
-
- <%= if @date != @today do %> - - <% end %>
<%# ── Date header ───────────────────────────────────────────── %>
- + <%# Custom styled date picker: button triggers showPicker() on hidden input %>
- <%= if item.prefix do %> - {item.prefix} - <% end %> - <%= if @editing_id == item.id do %>
@@ -169,12 +157,20 @@ +
@@ -193,6 +189,30 @@
<% end %> + <%# ── Priority bottom sheet (mobile) ──────────────────────────────── %> + <%= if @priority_sheet_open do %> +
+
+ <%= for p <- @valid_priorities do %> + + <% end %> +
+
+ <% end %> + + <%# ── Color bottom sheet (mobile) ─────────────────────────────────── %> + <%= if @color_sheet_open do %> +
+
+ <%= for color <- @valid_colors do %> + + <% end %> +
+
+ <% end %> + <%# ── Search modal ───────────────────────────────────────────────── %> <%= if @search_open do %>