---
# System prepended metadata

title: Build a Zero-Cost Personal Dashboard

---

# Build a Zero-Cost Personal Dashboard
## HackMD as a Database + Netlify Serverless Functions

> **Stack:** Vanilla HTML/CSS/JS · Netlify Functions · HackMD API · HMAC Auth  
> **Cost:** $0 · **Build step:** None · **Framework:** None  
> **Frontend size:** ~3100 lines in a single `index.html`

---

## Table of Contents

1. [The Idea](#1-the-idea)
2. [Real Project Structure](#2-real-project-structure)
3. [Architecture Overview](#3-architecture-overview)
4. [Prerequisites](#4-prerequisites)
5. [Step 1 — Set Up HackMD as Your Database](#5-step-1--set-up-hackmd-as-your-database)
6. [Step 2 — Create the Netlify Project](#6-step-2--create-the-netlify-project)
7. [Step 3 — Build the Auth Function](#7-step-3--build-the-auth-function)
8. [Step 4 — Build the Tasks Function](#8-step-4--build-the-tasks-function)
9. [Step 5 — Build the Memory Function](#9-step-5--build-the-memory-function)
10. [Step 6 — Configure netlify.toml](#10-step-6--configure-netlifytoml)
11. [Step 7 — The Frontend: index.html Internals](#11-step-7--the-frontend-indexhtml-internals)
    - [7a. Design System (CSS Variables)](#7a-design-system-css-variables)
    - [7b. App Shell & HTML Structure](#7b-app-shell--html-structure)
    - [7c. Auth Flow (Password Gate)](#7c-auth-flow-password-gate)
    - [7d. Main Tab Switching](#7d-main-tab-switching)
    - [7e. Advanced Task Markdown Format](#7e-advanced-task-markdown-format)
    - [7f. Markdown Parser (parseTaskMarkdown)](#7f-markdown-parser-parsetaskmarkdown)
    - [7g. Markdown Serializer (toMarkdown)](#7g-markdown-serializer-tomarkdown)
    - [7h. Kanban Board View](#7h-kanban-board-view)
    - [7i. List View](#7i-list-view)
    - [7j. Drag-and-Drop](#7j-drag-and-drop)
    - [7k. Inline Editing](#7k-inline-editing)
    - [7l. Save Flow](#7l-save-flow)
    - [7m. Memory Viewer](#7m-memory-viewer)
12. [Step 8 — Set Environment Variables](#12-step-8--set-environment-variables)
13. [Step 9 — Deploy to Netlify](#13-step-9--deploy-to-netlify)
14. [How the Auth Token Works](#14-how-the-auth-token-works)
15. [Why This Approach?](#15-why-this-approach)

---

## 1. The Idea

Most personal projects hit the same wall: you need *somewhere* to store data, but you don't want to pay for a database or manage a server.

The insight here is simple:

> **HackMD notes are just Markdown files with a REST API on top.**

Store your tasks as Markdown checkboxes. Read and write them via HackMD's API. Use Netlify Functions as a thin, secure proxy. Serve everything as a static HTML file — no build step, no framework, no bundler.

**Result:** A fully functional, password-protected Kanban board + notes viewer — for free.

---

## 2. Real Project Structure

This is the **actual** file tree — 6 files total:

```
my-dashboard/
│
├── index.html              ← Entire frontend (~3100 lines)
│                             HTML + CSS + Vanilla JS, all in one file
│                             No framework, no bundler, no node_modules
│
├── netlify.toml            ← Netlify config:
│                             - No build command
│                             - /api/* → /.netlify/functions/* redirect
│                             - esbuild as the functions bundler
│
├── package.json            ← Minimal (name + version only, no deps)
│
└── netlify/
    └── functions/
        ├── auth.js         ← POST   /api/auth     (login → JWT-like token)
        ├── tasks.js        ← GET    /api/tasks    (load Markdown from HackMD)
        │                     PATCH  /api/tasks    (save Markdown to HackMD)
        └── memory.js       ← GET    /api/memory   (load CLAUDE.md from HackMD)
```

**Key insight:** The entire backend is 3 Node.js files. Each function is completely standalone — no shared imports, no `node_modules`.

---

## 3. Architecture Overview

```
┌──────────────────────────────────────────────────────┐
│                     Browser                          │
│                                                      │
│  index.html  (single file: HTML + CSS + JS)          │
│  ┌──────────────────┐  ┌──────────────────────────┐  │
│  │   Tasks Panel    │  │     Memory Panel         │  │
│  │  ┌────┐ ┌────┐   │  │  Parsed sections/tables  │  │
│  │  │ 📋 │ │ 📋 │   │  │  from CLAUDE.md          │  │
│  │  └────┘ └────┘   │  └──────────────────────────┘  │
│  │  Kanban / List   │                                │
│  └──────────────────┘                                │
└───────────────┬──────────────────────────────────────┘
                │  fetch('/.netlify/functions/...')
                │  Authorization: Bearer <hmac-token>
                ▼
┌──────────────────────────────────────────────────────┐
│              Netlify Edge / CDN                      │
│  /api/*  →  /.netlify/functions/:splat               │
└───────────┬──────────────────────────────────────────┘
            │
     ┌──────┼──────┐
     ▼      ▼      ▼
  auth.js  tasks.js  memory.js
  (verify  (proxy    (proxy
  password) reads &   reads)
            writes)
     │      │      │
     └──────┼──────┘
            │  HackMD REST API
            ▼  Authorization: Bearer <hackmd-api-token>
┌──────────────────────────────────────────────────────┐
│                    HackMD                            │
│                                                      │
│  Note A  →  Tasks (Markdown Kanban)                  │
│  ┌─────────────────────────────────┐                 │
│  │ # Tasks                         │                 │
│  │ ## Backlog                      │                 │
│  │ - [ ] **Task title** - note     │                 │
│  │   - [ ] subtask 1               │                 │
│  │ ## In Progress                  │                 │
│  │ - [x] **Done task**             │                 │
│  └─────────────────────────────────┘                 │
│                                                      │
│  Note B  →  Memory / CLAUDE.md                       │
│  ┌─────────────────────────────────┐                 │
│  │ # Memory                        │                 │
│  │ ## Me                           │                 │
│  │ ## People                       │                 │
│  │ | Who | Role |                  │                 │
│  └─────────────────────────────────┘                 │
└──────────────────────────────────────────────────────┘
```

**Complete request lifecycle for toggling a task:**
1. User clicks checkbox in browser
2. JS flips `task.checked`, calls `toMarkdown()` → serializes all tasks back to Markdown string
3. User clicks "Save to HackMD" button
4. `PATCH /.netlify/functions/tasks` with `{ content: markdownString }`
5. Function verifies HMAC token, calls `PATCH https://api.hackmd.io/v1/notes/<NOTE_ID>`
6. HackMD stores the updated Markdown
7. Browser shows "Saved to HackMD ✓"

---

## 4. Prerequisites

- [Netlify account](https://netlify.com) (free tier)
- [HackMD account](https://hackmd.io) (free tier)
- [Netlify CLI](https://docs.netlify.com/cli/get-started/) installed globally
- Node.js 18+

```bash
npm install -g netlify-cli
netlify --version   # verify install
```

---

## 5. Step 1 — Set Up HackMD as Your Database

### 5.1 Create two notes

Go to [hackmd.io](https://hackmd.io) and create two new notes.

**Note 1: Tasks** — This is your Kanban data store. Paste this starter:

```markdown
# Tasks

## Backlog
- [ ] **Add dark mode** - nice to have
- [ ] **Write documentation**
  - [ ] Architecture section
  - [ ] Deploy guide

## In Progress
- [ ] **Build the dashboard**

## Done
- [x] **Set up Netlify project**
- [x] **Create HackMD notes**
```

> Notice the format: `- [ ] **Bold title** - optional note`, with indented `  - [ ] subtask` lines.
> This is the exact format the parser expects.

**Note 2: Memory / CLAUDE.md** — Your personal notes. Can be any Markdown. The viewer will parse `## Sections`, key-value pairs (`**Key:** value`), and `| tables |` automatically.

### 5.2 Get Note IDs from the URL

```
https://hackmd.io/XXXXXXXXXXXXXXXXXXXX
         └──────────────────────────┘
              Note ID (copy this)
```

### 5.3 Generate a HackMD API Token

1. HackMD → **Settings → API**
2. **Generate new token** → name it `dashboard-token`
3. Copy immediately (shown only once)

> ⚠️ Store it in a password manager. Never put it in code or Git.

---

## 6. Step 2 — Create the Netlify Project

```bash
mkdir my-dashboard && cd my-dashboard
git init
mkdir -p netlify/functions
touch index.html netlify.toml package.json
touch netlify/functions/auth.js
touch netlify/functions/tasks.js
touch netlify/functions/memory.js
```

**package.json:**
```json
{
  "name": "my-dashboard",
  "version": "1.0.0"
}
```

Link to Netlify:
```bash
netlify login
netlify init
# → Create & configure a new site
# → Build command: (leave blank)
# → Publish directory: .
```

---

## 7. Step 3 — Build the Auth Function

`netlify/functions/auth.js`

```javascript
const crypto = require('crypto');

const SECRET = process.env.TOKEN_SECRET;         // HMAC signing key
const PASSWORD = process.env.DASHBOARD_PASSWORD; // Login password

// Build a self-contained token: "<expiry_ms>.<hmac_hex>"
function makeToken() {
  const expires = Date.now() + 1000 * 60 * 60 * 24 * 7; // 7 days
  const payload = String(expires);
  const sig = crypto.createHmac('sha256', SECRET).update(payload).digest('hex');
  return `${payload}.${sig}`;
}

// Verify: recompute HMAC, compare, check expiry
function verifyToken(token) {
  if (!token) return false;
  const [payload, sig] = token.split('.');
  if (!payload || !sig) return false;
  const expected = crypto.createHmac('sha256', SECRET).update(payload).digest('hex');
  if (sig !== expected) return false;        // tampered
  if (Date.now() > parseInt(payload)) return false; // expired
  return true;
}

exports.verifyToken = verifyToken;

exports.handler = async (event) => {
  const headers = {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Headers': 'Content-Type',
    'Content-Type': 'application/json',
  };

  if (event.httpMethod === 'OPTIONS') return { statusCode: 204, headers, body: '' };
  if (event.httpMethod !== 'POST') {
    return { statusCode: 405, headers, body: JSON.stringify({ error: 'Method not allowed' }) };
  }

  const { password } = JSON.parse(event.body || '{}');
  if (password !== PASSWORD) {
    return { statusCode: 401, headers, body: JSON.stringify({ error: 'Wrong password' }) };
  }

  return { statusCode: 200, headers, body: JSON.stringify({ token: makeToken() }) };
};
```

---

## 8. Step 4 — Build the Tasks Function

`netlify/functions/tasks.js`

```javascript
const crypto = require('crypto');

const HACKMD_TOKEN  = process.env.HACKMD_API_TOKEN;
const TASKS_NOTE_ID = process.env.TASKS_NOTE_ID;
const SECRET        = process.env.TOKEN_SECRET;

function verifyToken(token) {
  if (!token) return false;
  const [payload, sig] = token.split('.');
  if (!payload || !sig) return false;
  const expected = crypto.createHmac('sha256', SECRET).update(payload).digest('hex');
  if (sig !== expected) return false;
  if (Date.now() > parseInt(payload)) return false;
  return true;
}

exports.handler = async (event) => {
  const headers = {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    'Content-Type': 'application/json',
  };

  if (event.httpMethod === 'OPTIONS') return { statusCode: 204, headers, body: '' };

  // Every request must carry a valid token
  const token = (event.headers['authorization'] || '').replace('Bearer ', '');
  if (!verifyToken(token)) {
    return { statusCode: 401, headers, body: JSON.stringify({ error: 'Unauthorized' }) };
  }

  // GET — load raw Markdown from HackMD
  if (event.httpMethod === 'GET') {
    const res = await fetch(`https://api.hackmd.io/v1/notes/${TASKS_NOTE_ID}`, {
      headers: { Authorization: `Bearer ${HACKMD_TOKEN}` },
    });
    if (!res.ok) return { statusCode: res.status, headers, body: JSON.stringify({ error: 'HackMD fetch failed' }) };
    const data = await res.json();
    // Only expose 'content', not HackMD internal metadata
    return { statusCode: 200, headers, body: JSON.stringify({ content: data.content }) };
  }

  // PATCH — write updated Markdown back to HackMD
  if (event.httpMethod === 'PATCH') {
    const { content } = JSON.parse(event.body || '{}');
    if (!content) return { statusCode: 400, headers, body: JSON.stringify({ error: 'Missing content' }) };

    const res = await fetch(`https://api.hackmd.io/v1/notes/${TASKS_NOTE_ID}`, {
      method: 'PATCH',
      headers: { Authorization: `Bearer ${HACKMD_TOKEN}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ content }),
    });
    return { statusCode: res.status, headers, body: JSON.stringify({ ok: res.status < 300 }) };
  }

  return { statusCode: 405, headers, body: JSON.stringify({ error: 'Method not allowed' }) };
};
```

---

## 9. Step 5 — Build the Memory Function

`netlify/functions/memory.js`

```javascript
const crypto = require('crypto');

const HACKMD_TOKEN   = process.env.HACKMD_API_TOKEN;
const MEMORY_NOTE_ID = process.env.MEMORY_NOTE_ID;
const SECRET         = process.env.TOKEN_SECRET;

function verifyToken(token) {
  if (!token) return false;
  const [payload, sig] = token.split('.');
  if (!payload || !sig) return false;
  const expected = crypto.createHmac('sha256', SECRET).update(payload).digest('hex');
  if (sig !== expected) return false;
  if (Date.now() > parseInt(payload)) return false;
  return true;
}

exports.handler = async (event) => {
  const headers = {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    'Content-Type': 'application/json',
  };

  if (event.httpMethod === 'OPTIONS') return { statusCode: 204, headers, body: '' };

  const token = (event.headers['authorization'] || '').replace('Bearer ', '');
  if (!verifyToken(token)) {
    return { statusCode: 401, headers, body: JSON.stringify({ error: 'Unauthorized' }) };
  }

  if (event.httpMethod !== 'GET') {
    return { statusCode: 405, headers, body: JSON.stringify({ error: 'Method not allowed' }) };
  }

  const res = await fetch(`https://api.hackmd.io/v1/notes/${MEMORY_NOTE_ID}`, {
    headers: { Authorization: `Bearer ${HACKMD_TOKEN}` },
  });
  if (!res.ok) return { statusCode: res.status, headers, body: JSON.stringify({ error: 'HackMD fetch failed' }) };
  const data = await res.json();
  return { statusCode: 200, headers, body: JSON.stringify({ content: data.content }) };
};
```

---

## 10. Step 6 — Configure netlify.toml

```toml
[build]
  command   = "# no build needed"
  publish   = "."
  functions = "netlify/functions"   # explicit functions directory

# Clean URL routing: /api/auth → /.netlify/functions/auth
[[redirects]]
  from   = "/api/*"
  to     = "/.netlify/functions/:splat"
  status = 200

[functions]
  node_bundler = "esbuild"   # faster cold starts, smaller bundles
```

**Why esbuild?** Netlify uses it to tree-shake and bundle each function into a single file before deploying. Even though there are no `npm` dependencies here, it's a good default for when you add some later.

**The redirect rule means:**
| Browser calls | Netlify routes to |
|---|---|
| `POST /api/auth` | `/.netlify/functions/auth` |
| `GET /api/tasks` | `/.netlify/functions/tasks` |
| `PATCH /api/tasks` | `/.netlify/functions/tasks` |
| `GET /api/memory` | `/.netlify/functions/memory` |

---

## 11. Step 7 — The Frontend: index.html Internals

Everything lives in a single `index.html`. Here's a deep-dive into each layer.

---

### 7a. Design System (CSS Variables)

The entire UI is driven by CSS custom properties defined in `:root`. No Tailwind, no CSS framework:

```css
:root {
  /* Warm off-white palette */
  --bg-primary:   #faf9f5;   /* page background */
  --bg-secondary: #F5F4EF;   /* column / section backgrounds */
  --bg-card:      #ffffff;   /* card surfaces */

  /* Text hierarchy */
  --text-primary:   #141413;
  --text-secondary: #5c5c5a;
  --text-muted:     #8c8c8a;

  /* Accent (terracotta orange) */
  --accent:       #D97757;
  --accent-hover: #c4684a;

  /* Borders */
  --border:       #e5e4df;
  --border-light: #eeeee9;

  /* Shadows */
  --shadow:       rgba(20, 20, 19, 0.04);
  --shadow-hover: rgba(20, 20, 19, 0.08);
}

body {
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
  /* Inter loaded via Google Fonts */
}
```

Using variables means you can swap the entire theme in one place.

---

### 7b. App Shell & HTML Structure

```
index.html
│
├── <head>
│   ├── Inter font (Google Fonts)
│   ├── SVG favicon (inline data URI)
│   └── <style> — ALL CSS (~1200 lines)
│
└── <body>
    ├── #passwordGate          ← Full-screen login overlay (fixed, z-index 999)
    │   └── .password-box      ← Centered card with logo, input, button
    │
    ├── <header>               ← Always visible after login
    │   ├── Logo SVG + title "Productivity"
    │   ├── #mainTabToggle     ← [Tasks] [Memory] pill toggle
    │   ├── #taskViewToggle    ← [Board] [List] pill toggle (tasks only)
    │   ├── #syncStatus        ← "✓ Synced with HackMD" indicator
    │   └── #saveBtn           ← "Save to HackMD" (disabled until changes)
    │
    ├── #tasksPanel  (.tab-panel.active)
    │   ├── #listView          ← List view (hidden by default)
    │   └── #board             ← Kanban board (flex row of columns)
    │
    ├── #memoryPanel (.tab-panel)
    │   ├── #memoryEmptyState  ← "Loading memory…"
    │   └── #memoryMainContent
    │       ├── .memory-tabs   ← One tab per ## section in CLAUDE.md
    │       └── .memory-content-area
    │           └── #memoryContentContainer
    │
    └── .modal-overlay         ← Shared modal (used by memory viewer)
        └── .modal
            ├── .modal-header
            ├── .modal-body
            └── .modal-footer [Cancel] [Save]
```

---

### 7c. Auth Flow (Password Gate)

```javascript
// Token persists in sessionStorage — clears when tab/browser closes
let authToken = sessionStorage.getItem('dash_token') || '';

async function unlock() {
  const pw = passwordInput.value;
  const res = await fetch('/.netlify/functions/auth', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ password: pw })
  });
  const data = await res.json();
  if (res.ok && data.token) {
    authToken = data.token;
    sessionStorage.setItem('dash_token', authToken); // persist within tab
    passwordGate.classList.add('hidden');             // hide overlay
    initDashboard();                                  // load data
  } else {
    passwordError.classList.add('visible');           // show error
  }
}

// If token already in sessionStorage, skip gate immediately
if (authToken) passwordGate.classList.add('hidden');
```

**Session storage vs local storage:**
- `sessionStorage` → token gone when tab closes (more secure for personal tools)
- `localStorage` → would persist across browser restarts

---

### 7d. Main Tab Switching

Two panels: **Tasks** and **Memory**. Switching shows/hides `.tab-panel` elements:

```javascript
function switchMainTab(tab) {
  // Toggle active class on buttons
  tasksTabBtn.classList.toggle('active', tab === 'tasks');
  memoryTabBtn.classList.toggle('active', tab === 'memory');
  // Toggle active class on panels (CSS: .tab-panel { display:none } .tab-panel.active { display:flex })
  tasksPanel.classList.toggle('active', tab === 'tasks');
  memoryPanel.classList.toggle('active', tab === 'memory');
  // Hide task-specific controls when in memory tab
  taskViewToggle.style.display = tab === 'tasks' ? 'flex' : 'none';
  saveBtn.style.display        = tab === 'tasks' ? 'inline-flex' : 'none';
}
```

---

### 7e. Advanced Task Markdown Format

The task format is richer than basic `- [ ] text`. Here's the full spec:

```markdown
# Tasks

## Section Name
- [ ] **Task title** - optional inline note
  - [ ] subtask line 1
  - [x] subtask line 2 (done)
- [x] **Completed task**

## Another Section
- [ ] **Task with no note**
```

**Rules the parser follows:**

| Pattern | Meaning |
|---|---|
| `# Tasks` | Document title (ignored by parser) |
| `## Section Name` | New Kanban column |
| `## **Section Name**` | Also valid (bold headers) |
| `- [ ] **Title** - note` | Unchecked task with title and note |
| `- [x] **Title**` | Checked (done) task |
| `- [X] **Title**` | Also valid (uppercase X) |
| `  - [ ] subtask` | Subtask (2-space indent) |

---

### 7f. Markdown Parser (parseTaskMarkdown)

The parser converts the raw Markdown string into a structured JS object:

```javascript
function parseTaskMarkdown(content) {
  const resultSections = [];       // ordered array: [{ id, name }, ...]
  const resultTasks    = {};       // map: { sectionId: [task, ...] }
  let currentSectionId = null;
  let currentTask = null;

  for (const line of content.split('\n')) {

    // Match "## Section" or "## **Section**"
    const headerMatch = line.match(/^## \*{0,2}(.+?)\*{0,2}$/);
    if (headerMatch) {
      // Flush previous task if pending
      if (currentTask) resultTasks[currentSectionId].push(currentTask);
      currentTask = null;

      const name = headerMatch[1].trim();
      currentSectionId = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
      // e.g. "In Progress" → "in-progress"

      resultSections.push({ id: currentSectionId, name });
      resultTasks[currentSectionId] = [];
      continue;
    }

    // Match "- [ ] ..." or "- [x] ..." or "- [X] ..."
    if (currentSectionId && line.match(/^- \[[ xX]\]/)) {
      if (currentTask) resultTasks[currentSectionId].push(currentTask);

      const checked = /\[[xX]\]/.test(line);
      let text = line.replace(/^- \[[ xX]\]\s*/, '');

      // Extract bold title and optional note: "**Title** - note"
      let title = text, note = '';
      const boldMatch = text.match(/^\*\*(.+?)\*\*(.*)$/);
      if (boldMatch) {
        title = boldMatch[1];
        note  = boldMatch[2].replace(/^\s*-\s*/, '').trim();
      }

      currentTask = { id: Date.now() + Math.random(), title, note, checked, subtasks: [], section: currentSectionId };
      continue;
    }

    // Match "  - [ ] subtask" (2-space indent)
    if (currentTask && line.match(/^\s+- \[[ xX]\]/)) {
      currentTask.subtasks.push({
        text:    line.replace(/^\s+- \[[ xX]\]\s*/, ''),
        checked: /\[[xX]\]/.test(line)
      });
    }
  }

  // Flush final pending task
  if (currentTask) resultTasks[currentSectionId].push(currentTask);

  return { sections: resultSections, tasks: resultTasks };
}
```

**Output shape:**
```javascript
{
  sections: [
    { id: 'backlog',     name: 'Backlog' },
    { id: 'in-progress', name: 'In Progress' },
    { id: 'done',        name: 'Done' }
  ],
  tasks: {
    'backlog':      [ { id: 123, title: 'Add dark mode', note: 'nice to have', checked: false, subtasks: [] } ],
    'in-progress':  [ { id: 456, title: 'Build dashboard', note: '', checked: false, subtasks: [
                       { text: 'Auth function', checked: true },
                       { text: 'Tasks function', checked: false }
                    ]} ],
    'done':         [ { id: 789, title: 'Set up Netlify', note: '', checked: true, subtasks: [] } ]
  }
}
```

---

### 7g. Markdown Serializer (toMarkdown)

Converts the in-memory state back to Markdown for saving:

```javascript
function toMarkdown() {
  let md = '# Tasks\n';

  sections.forEach(section => {
    md += `\n## ${section.name}\n`;

    (tasks[section.id] || []).forEach(t => {
      const checkbox = t.checked ? '[x]' : '[ ]';
      const note     = t.note ? ` - ${t.note}` : '';
      md += `- ${checkbox} **${t.title}**${note}\n`;

      t.subtasks.forEach(st => {
        md += `  - ${st.checked ? '[x]' : '[ ]'} ${st.text}\n`;
      });
    });
  });

  return md.trimEnd() + '\n';
}
```

**Round-trip guarantee:** `toMarkdown(parseTaskMarkdown(md))` produces equivalent output — tasks are preserved through any number of edit cycles.

---

### 7h. Kanban Board View

Each column is a `div.column` with:
- `.column-header` — section name + task count badge
- `.cards` — scrollable task card list, drop target
- `.add-card` — "+ Add task" button at the bottom

Each card (`.task-card`) contains:
- Checkbox (click to toggle `task.checked`)
- Title (click to inline-edit)
- Note line (click to inline-edit, shows `+ Add note` on hover if empty)
- Subtasks section (each subtask has its own checkbox + inline edit)
- Delete button `×` (appears on hover, top-right corner)

The board has a **Quick-Add bar** at the bottom:
```
[ type a task name... ]  [ In Progress ▾ ]  [+ Add]
```
Clicking the section pill opens a dropdown to pick which column to add to.

---

### 7i. List View

A flat list of all tasks grouped by section — same data, different layout:

```
BACKLOG
  ○  Add dark mode          nice to have
     + Add note | + Add subtask

IN PROGRESS
  ○  Build dashboard
     ✓ Auth function
     ○ Tasks function
     + Add subtask
```

Switching between Board ↔ List is instant — same `sections`/`tasks` state, just different render function.

---

### 7j. Drag-and-Drop

Pure HTML5 drag-and-drop API (no library):

**Cards between columns:**
```javascript
// On dragstart: store task ID in dataTransfer
card.addEventListener('dragstart', (e) => {
  e.dataTransfer.setData('text/plain', task.id);
});

// On drop into a column: move task to new section
cardsContainer.addEventListener('drop', (e) => {
  const taskId = e.dataTransfer.getData('text/plain');
  // find task in old section, push to new section, re-render
});
```

**Column reordering:**
```javascript
// Column headers are draggable
header.addEventListener('dragstart', (e) => {
  e.dataTransfer.setData('text/column', id); // different MIME type!
});

// Drop indicator: a 3px orange line inserted before/after target column
const indicator = document.createElement('div');
indicator.className = 'column-drop-indicator';
// insert before or after based on mouse X position
```

Using different MIME types (`text/plain` vs `text/column`) lets the board distinguish card drags from column drags.

---

### 7k. Inline Editing

All editing is inline — no modals for tasks. The pattern is the same everywhere:

```javascript
function startEditingTitle(titleEl, task) {
  // 1. Create an <input> with matching styles
  const input = document.createElement('input');
  input.value = task.title;
  input.style.cssText = 'border: 2px solid var(--accent); ...';

  // 2. Replace the display element with the input
  titleEl.replaceWith(input);
  input.focus();
  input.select();

  // 3. Guard against double-save
  let saved = false;
  const saveEdit = () => {
    if (saved) return;
    saved = true;
    task.title = input.value.trim() || task.title;
    markChanged();   // enables Save button
    renderTasks();   // replace input back with styled display element
  };

  // 4. Save on Enter or blur, cancel on Escape
  input.addEventListener('keydown', e => {
    if (e.key === 'Enter')  { e.preventDefault(); saveEdit(); }
    if (e.key === 'Escape') { saved = true; renderTasks(); }
  });
  input.addEventListener('blur', saveEdit);
}
```

This same pattern is applied to: task titles, task notes, subtask text, and column names.

---

### 7l. Save Flow

Changes are **not auto-saved**. This is intentional — the user controls when to write to HackMD:

```javascript
let hasChanges = false;

function markChanged() {
  hasChanges = true;
  saveBtn.disabled = false;          // enable the button
  saveBtn.textContent = 'Save to HackMD';
}

// Warn before leaving with unsaved changes
window.addEventListener('beforeunload', (e) => {
  if (hasChanges) { e.preventDefault(); e.returnValue = ''; }
});

// Save button handler
saveBtn.addEventListener('click', async () => {
  saveBtn.disabled = true;
  saveBtn.textContent = 'Saving…';
  setSyncStatus('⟳ Saving…');

  const res = await fetch('/.netlify/functions/tasks', {
    method: 'PATCH',
    headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ content: toMarkdown() })
  });

  if (res.status === 401) {
    // Token expired — force re-login
    sessionStorage.removeItem('dash_token');
    location.reload();
    return;
  }

  hasChanges = false;
  showStatus('Saved to HackMD ✓');
  setSyncStatus('✓ Saved', 'var(--accent)');
});
```

**Sync status indicator** (top-right of header):
- `⟳ Loading…` — initial fetch
- `✓ Synced with HackMD` — after load (fades after 3s)
- `⟳ Saving…` — during save
- `✓ Saved` — after successful save (fades after 3s)
- `⚠ Save failed` — on error (stays visible)

---

### 7m. Memory Viewer

The memory panel fetches the CLAUDE.md note and parses it into a navigable UI:

**Parser — `parseMemoryMarkdown(content)`:**
```javascript
function parseMemoryMarkdown(content) {
  const parsed = {
    title: '',
    fields: {},     // **Key:** value pairs
    sections: {},   // ## Section → content string
    tables: [],     // Markdown tables → { headers, rows }
    rawContent: content
  };

  // Line-by-line:
  // "# Title"         → parsed.title
  // "## Section"      → creates/enters a section
  // "**Key:** value"  → parsed.fields object
  // Else              → appended to current section's content

  // Table regex extracts header row + data rows
  const tableRegex = /\|(.+)\|\n\|[-| ]+\|\n((?:\|.+\|\n?)+)/g;
  // → parsed.tables: [{ headers: ['Who', 'Role'], rows: [['Alice', 'PM']] }]
}
```

**Rendered UI:**
- Tabs: one per `## Section` found in CLAUDE.md
- Each section can render as:
  - A **table** (if the section contains `| col | col |` Markdown)
  - A **card grid** (for memory entries with frontmatter-style fields)
  - A **file card** (collapsible raw content blocks)
  - A **flat table** for key-value pairs
- A search box filters across all sections
- Stats bar shows counts per section type

---

## 12. Step 8 — Set Environment Variables

In **Netlify Dashboard → Site settings → Environment variables**:

| Variable | Mock example | Description |
|---|---|---|
| `DASHBOARD_PASSWORD` | `STRONGPASSWORDHERE!` | Login password |
| `TOKEN_SECRET` | `a1b2c3d4...` (64 hex chars) | HMAC signing key |
| `HACKMD_API_TOKEN` | `hmd_api_xxxxxxxx` | HackMD API bearer token |
| `TASKS_NOTE_ID` | `aBcDeFgHiJkLmNoPqRsTuV` | HackMD Note ID for tasks |
| `MEMORY_NOTE_ID` | `xYzAbCdEfGhIjKlMnOpQrS` | HackMD Note ID for CLAUDE.md |

Generate a strong TOKEN_SECRET:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# outputs something like: a3f9c2d1e4b567890abcdef01234567890abcdef01234567890abcdef012345678
```

Or via CLI:
```bash
netlify env:set DASHBOARD_PASSWORD  "your-password-here"
netlify env:set TOKEN_SECRET        "$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")"
netlify env:set HACKMD_API_TOKEN    "your-hackmd-token"
netlify env:set TASKS_NOTE_ID       "your-tasks-note-id"
netlify env:set MEMORY_NOTE_ID      "your-memory-note-id"
```

---

## 13. Step 9 — Deploy to Netlify

```bash
# Preview deploy (gets a draft URL, not production)
netlify deploy --dir=.

# Production deploy
netlify deploy --prod --dir=.
```

> The `--dir=.` flag tells Netlify to deploy the current directory as-is. Since `netlify.toml` has `publish = "."`, you could also just run `netlify deploy --prod`.

Your site is live at `https://your-site-name.netlify.app` 🎉

---

## 14. How the Auth Token Works

```
Token format:
"<unix_expiry_ms>.<hmac_sha256_hex>"

Example (mocked):
"1743000000000.a3f9c2d1e4b56789abcdef0123456789abcdef0123456789abcdef01234567"
 └───────────┘ └──────────────────────────────────────────────────────────────┘
  Expiry: Unix    HMAC-SHA256("1743000000000", TOKEN_SECRET)
  timestamp ms
```

**Login:**
```
Browser                         auth.js                         HackMD
   │                               │                               │
   │── POST /.netlify/functions/auth
   │   { password: "***" }         │
   │                               │ compare password === env var
   │                               │ makeToken() → expiry + HMAC
   │◀── 200 { token: "123.abc" } ──│
   │                               │
   │ sessionStorage.setItem(       │
   │   'dash_token', token)        │
```

**Subsequent API calls:**
```
Browser                         tasks.js
   │                               │
   │── GET /.netlify/functions/tasks
   │   Authorization: Bearer 123.abc
   │                               │ split(".") → ["123", "abc"]
   │                               │ recompute HMAC("123", SECRET)
   │                               │ compare → match?
   │                               │ Date.now() > 123? → expired?
   │                               │ ✓ valid → fetch HackMD
   │◀── 200 { content: "# Tasks..." }
```

**Security properties:**
- Stateless — server holds no session state
- Unforgeable — requires knowing `TOKEN_SECRET`
- Self-expiring — timestamp baked into the token
- Tab-scoped — `sessionStorage` clears on tab close
- 401 auto-logout — any expired/invalid token triggers `location.reload()`

---

## 15. Why This Approach?

| Concern | Traditional approach | This approach |
|---|---|---|
| Data store | PostgreSQL / MongoDB | HackMD Note (plain Markdown) |
| Backend | Express on a $5–20/mo VPS | Netlify Functions (free, serverless) |
| Auth | JWT lib + session database | 10 lines of `crypto` + HMAC |
| Build pipeline | Webpack / Vite / Docker / CI | None — deploy the HTML file |
| Data portability | Locked in DB format | Edit the note directly on HackMD |
| Emergency fix | SSH + DB client + migration | Open HackMD, edit the Markdown |
| Cold start | Always-on server | ~100ms Netlify function cold start |

**Tradeoffs to know:**
- HackMD API rate limits — fine for personal use, not team use
- No offline support
- Single-user only (one shared password)
- Not suitable for truly sensitive data without additional encryption at rest

**Perfect for:**
- Personal productivity tools
- Quick prototypes that need real persistence
- Learning serverless + API integration without infrastructure cost
- Tools where you want to edit data both programmatically *and* by hand

---

*Built by Ali Gamal · Frontend Team Lead · Luftborn*  
*Stack: Vanilla JS · Netlify Functions (esbuild) · HackMD API · HMAC-SHA256*
