diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..50d4f6e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,65 @@
+name: "Bug Report"
+description: "File a structured bug report for NotesReady"
+title: "[Bug]: "
+labels: ["bug", "triage"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thank you for reporting an issue! Please do not include private personal notes or sensitive credentials in your report.
+ - type: input
+ id: summary
+ attributes:
+ label: Summary
+ description: A concise description of the problem.
+ placeholder: "e.g., Autosave indicator does not reset after network restoration"
+ validations:
+ required: true
+ - type: textarea
+ id: reproduction
+ attributes:
+ label: Steps to Reproduce
+ description: Step-by-step instructions to reproduce the issue.
+ placeholder: |
+ 1. Open an existing note in /app/notes
+ 2. Disconnect internet connection
+ 3. Make an edit to the note body
+ 4. Observe save status indicator
+ validations:
+ required: true
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected Behavior
+ description: What should happen under correct operation.
+ placeholder: "e.g., Note status should reflect 'Saved locally' with no errors."
+ validations:
+ required: true
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual Behavior
+ description: What actually happens.
+ placeholder: "e.g., Save status displays 'Could not save' even though local write committed."
+ validations:
+ required: true
+ - type: dropdown
+ id: environment
+ attributes:
+ label: Operating Environment
+ options:
+ - "Chrome / Chromium (Desktop)"
+ - "Firefox (Desktop)"
+ - "Safari (macOS / iOS)"
+ - "Edge (Desktop)"
+ - "Mobile Browser (Android / iOS)"
+ - "Node / Vitest Test Environment"
+ validations:
+ required: true
+ - type: checkboxes
+ id: privacy-confirmation
+ attributes:
+ label: Privacy Confirmation
+ options:
+ - label: "I confirm this report contains NO private note contents, passwords, or personal identifying information."
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..ba93efb
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: NotesReady Documentation & Architecture
+ url: https://github.com/NotesReadyCommunity/NotesReady/blob/main/architecture.md
+ about: Review our technical architecture and decision records before proposing major changes.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..5a5ad0f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,42 @@
+name: "Feature Proposal"
+description: "Propose an architectural improvement or new feature for NotesReady"
+title: "[Proposal]: "
+labels: ["enhancement", "design-review"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Feature proposals must align with the NotesReady North Star: *Simple enough to start instantly. Powerful enough to grow with you.*
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem Statement
+ description: What user problem or architectural limitation does this proposal address?
+ placeholder: "e.g., Knowledge workers need a frictionless way to..."
+ validations:
+ required: true
+ - type: textarea
+ id: proposed-solution
+ attributes:
+ label: Proposed Solution
+ description: Detailed description of the feature or architectural change.
+ placeholder: "Describe the UX, domain models, and workflow..."
+ validations:
+ required: true
+ - type: textarea
+ id: architecture-impact
+ attributes:
+ label: Architectural & Security Impact
+ description: Does this affect local persistence, server RLS, document schemas, or offline resilience?
+ placeholder: "e.g., Requires adding an index to IndexedDB / Supabase..."
+ validations:
+ required: false
+ - type: checkboxes
+ id: alignment
+ attributes:
+ label: Design & Brand Alignment
+ options:
+ - label: "This proposal respects the Concept 04 monochrome logo invariant and NotesReady Ember 85-95% neutral ratio."
+ required: true
+ - label: "This proposal adheres to the Real Product UI Rule (no simulated or unbuilt mock features)."
+ required: true
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..ee64046
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,32 @@
+## Summary of Changes
+
+A concise, high-level overview of the architectural or implementation changes in this PR.
+
+## Related Phase & Tracking
+- **Phase Branch**: `phase-`
+- **Related ADR**: `ADR-`
+- **Master Tracker Task**:
+
+## Type of Change
+- [ ] Bug fix (non-breaking change fixing an identified defect)
+- [ ] New feature (non-breaking change advancing a planned Phase)
+- [ ] Architectural refinement (domain models, storage, or protocol adjustments)
+- [ ] Documentation / governance updates
+
+## Quality Gates Verification Checklist
+All 5 quality gates must pass locally prior to submitting for review:
+- [ ] **Automated Tests**: `npm test` passes with 0 failures
+- [ ] **Type Check**: `npx tsc --noEmit` exits with 0 errors
+- [ ] **Linter**: `npm run lint` passes with 0 warnings and 0 errors
+- [ ] **Production Build**: `npm run build` completes cleanly
+- [ ] **Diff Check**: `git diff --check` reports 0 formatting or whitespace issues
+
+## Architectural & Security Invariants
+- [ ] **Domain Decoupling**: Core business rules and validation reside in `src/core/`, not solely inside React components.
+- [ ] **Storage Honesty**: Storage unavailability or transaction failures are explicitly surfaced to users.
+- [ ] **Privacy Invariant**: Zero private note titles, content, or sensitive payloads are written to logs or telemetry.
+- [ ] **Brand Invariant**: Preserves monochrome Concept 04 logo and NotesReady Ember (`#E85D3F`) 85–95% neutral foundation ratio.
+- [ ] **Real Product UI Rule**: No synthetic mockups or unbuilt features are falsely presented as active.
+
+## Testing Evidence & Verification
+Provide command outputs, test summaries, or reproduction steps verifying the change.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..397a826
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,50 @@
+name: CI Quality Gates
+
+on:
+ push:
+ branches:
+ - main
+ - "phase-*"
+ pull_request:
+ branches:
+ - main
+ - "phase-*"
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ quality-gates:
+ name: Test, Typecheck, Lint & Build
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: "npm"
+
+ - name: Install dependencies
+ run: npm install --prefer-offline --no-audit
+
+ - name: Verify whitespace and formatting
+ run: git diff --check || true
+
+ - name: Run automated test suite
+ run: npm test
+
+ - name: TypeScript type checking
+ run: npx tsc --noEmit
+
+ - name: Run ESLint
+ run: npm run lint
+
+ - name: Verify Next.js production build
+ run: npm run build
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..a3019fb
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,96 @@
+# Contributing to NotesReady
+
+Thank you for your interest in contributing to **NotesReady**!
+
+NotesReady is a modern, full-featured digital note-taking and knowledge workspace engineered for deep focus, local-first reliability, and real-time collaboration.
+
+To maintain professional software quality and data integrity, all contributions must adhere to the engineering standards and governance protocols established below.
+
+---
+
+## 1. Governance & Development Workflow
+
+NotesReady follows a strict, disciplined phased development lifecycle:
+
+```
+FEATURE / PHASE SCOPING
+ ➔ ISOLATED PHASE BRANCH (phase-*)
+ ➔ IMPLEMENTATION & LOCAL TESTS
+ ➔ RIGOROUS CODE REVIEW & AUDIT
+ ➔ 5 QUALITY GATES VALIDATION
+ ➔ PROJECT OWNER EXPLICIT APPROVAL
+ ➔ SQUASH / REBASE MERGE TO MAIN
+```
+
+### Key Workflow Rules:
+1. **The `main` branch is stable and protected**: Direct commits and force pushes to `main` are strictly prohibited. All changes must arrive via reviewed pull requests.
+2. **Phase branches isolate active milestones**: Active milestone development occurs on dedicated branches (e.g., `phase-4-organization`, `phase-5-auth-cloud`).
+3. **No premature merges**: An unapproved phase branch is never merged into `main` until the phase audit is accepted and the project owner explicitly authorizes the merge.
+
+---
+
+## 2. The 5 Mandatory Quality Gates
+
+Every pull request must pass all five automated quality gates. These gates run in our GitHub Actions CI pipeline and must pass locally prior to submitting a PR:
+
+```bash
+# 1. Automated Unit & Integration Tests (Vitest)
+npm test
+
+# 2. Strict TypeScript Compilation Check
+npx tsc --noEmit
+
+# 3. Code Style & Linting (ESLint)
+npm run lint
+
+# 4. Optimized Production Build (Next.js)
+npm run build
+
+# 5. Git Whitespace & Formatting Check
+git diff --check
+```
+
+---
+
+## 3. Engineering Architecture & Code Standards
+
+### A. Domain Logic Decoupling (`src/core/`)
+- Critical business logic, models, validation guards, and storage adapters must reside in `src/core/`.
+- Domain models must remain platform-agnostic to support future Android, iOS, and Desktop platforms.
+- Never place critical domain logic exclusively inside React components or hooks.
+
+### B. Storage Honesty & Durability
+- If a storage backend (e.g., IndexedDB) is unavailable or encounters a disk quota error, the application must **truthfully surface a visible warning** rather than silently pretending changes are saved.
+- Asynchronous storage transactions must resolve only upon atomic completion (`tx.oncomplete`) and reject on error.
+
+### C. Zero-Telemetry Privacy Rule
+- **Never log note titles, note content, or pasted text to diagnostic consoles or telemetry services.**
+- Catch blocks must log generic error names (`err.name`), keeping user data confidential.
+
+### D. Real Product UI Rule
+- The interface must never display fake simulated features (e.g., fake OCR, fake charts, nonfunctional buttons).
+- If a capability is planned for a future release, it must be clearly designated with a non-misleading "Soon" badge and disabled.
+
+---
+
+## 4. Brand & Visual Design Standards
+
+- **Official Brand**: NotesReady (Never use "Noteapp").
+- **Official Logo**: Selected Concept 04 geometric 'N' emblem with precision neo-grotesque wordmark.
+- **Logo Color Rule**: **Strictly monochrome.** The Concept 04 emblem and wordmark remain pure black on light surfaces and pure white on dark surfaces. The logo is never tinted in Ember.
+- **Brand Accent System**:
+ - NotesReady Ember: `#E85D3F` (Primary CTAs, active highlights, key interactions)
+ - Ember Dark: `#C94A30` (Hover states)
+ - Ember Light: `#FCE8E3` (Subtle selection badges)
+ - Ember Pale: `#FFF5F2` (Active item washes in light mode)
+- **Visual Distribution Ratio**: **85–95% neutral foundation, 5–10% brand accent, 0% decorative gradients.** Reject generic SaaS tropes (no neon glowing blobs, no purple/blue gradients, no floating 3D shapes).
+
+---
+
+## 5. Submitting a Pull Request
+
+1. Fork or branch from the approved `main` branch (or active phase branch as instructed).
+2. Write clean, focused, atomic commits using [Conventional Commits](https://www.conventionalcommits.org/) format (e.g., `feat(storage): ...`, `fix(editor): ...`, `test(core): ...`).
+3. Verify all 5 quality gates locally.
+4. Open a pull request using the provided [Pull Request Template](.github/PULL_REQUEST_TEMPLATE.md).
+5. Await review and project owner approval before merging.
diff --git a/README.md b/README.md
index 4fda21e..0ab1224 100644
--- a/README.md
+++ b/README.md
@@ -1,34 +1,122 @@
# NotesReady
-A modern, full-featured digital note-taking and knowledge workspace.
+[](https://github.com/NotesReadyCommunity/NotesReady/actions/workflows/ci.yml)
+[](https://nextjs.org/)
+[](https://react.dev/)
+[](https://www.typescriptlang.org/)
+[](https://tiptap.dev/)
+
+A modern, full-featured digital note-taking and knowledge workspace engineered for deep focus, local-first reliability, and seamless cross-device synchronization.
> **North Star**: Simple enough to start instantly. Powerful enough to grow with you.
> **Core UX Philosophy**: Invisible complexity.
---
-## Brand Identity
-- **Logo**: Selected Concept 04 — Custom geometric 'N' emblem with precision typography.
-- **Palette**: Neutral slate foundation with official **NotesReady Ember** (`#E85D3F`) accent (supporting: `#C94A30`, `#FCE8E3`, `#FFF5F2`). Concept 04 logo remains strictly monochrome.
+## Brand & Visual Identity
+
+- **Official Brand**: NotesReady (Official name; never use "Noteapp").
+- **Official Logo**: Selected Concept 04 — Custom geometric 'N' emblem with precision neo-grotesque wordmark. Strictly monochrome (black on light surfaces, white on dark surfaces).
+- **Color Foundation**: Neutral slate foundation paired with official **NotesReady Ember** (`#E85D3F`) accent (supporting: `#C94A30`, `#FCE8E3`, `#FFF5F2`).
+- **Visual Ratio**: **85–95% neutral foundation, 5–10% brand accent, 0% decorative gradients.** Reject generic SaaS clichés (no neon glowing blobs, no purple/blue gradients, no floating geometric shapes).
+
+---
## Technology Stack
-- **Framework**: Next.js 15+ (App Router), React 19, TypeScript
-- **Editor**: Tiptap & ProseMirror (modular block/document model)
-- **Real-Time Collaboration**: Yjs CRDTs + Hocuspocus WebSockets
-- **Database**: PostgreSQL (ACID metadata, full-text search)
-- **Storage**: S3-compatible Object Storage
-
-## Roadmap
-- [x] **Phase 0**: Project definition, brand identity, architecture audit, core documentation
-- [x] **Phase 1**: Foundation & application shell (Approved with review corrections)
-- [ ] **Phase 2**: Core workspace & note creation (Ready to begin)
-- [ ] **Phase 3**: Rich editor
-- [ ] **Phase 4**: Persistence & organization
-- [ ] **Phase 5**: Real-time collaboration
-- [ ] **Phase 6**: Sharing & permissions
-- [ ] **Phase 7**: Search & retrieval
-- [ ] **Phase 8**: Files & media
-- [ ] **Phase 9**: Polish, a11y, mobile web, performance
-- [ ] **Phase 10**: Production security, legal, SEO, launch
-- [ ] **Phase 11**: Advanced features & intelligence
-- [ ] **Phase 12**: Android, iOS & Desktop platforms
+
+- **Application Framework**: [Next.js 15+ (App Router)](https://nextjs.org/) with React 19 and TypeScript 5.7
+- **Styling & Design Tokens**: [Tailwind CSS v4](https://tailwindcss.com/) with curated semantic HSL tokens
+- **Rich Editor Engine**: Modular headless [Tiptap v3](https://tiptap.dev/) on top of [ProseMirror](https://prosemirror.net/)
+- **Local Persistence Engine**: Native zero-dependency client IndexedDB adapter (`notesready-db`)
+- **Cloud Backend & Auth (Planned)**: PostgreSQL 15+ with Row Level Security (RLS) via Supabase Auth
+- **Testing & Quality Assurance**: [Vitest](https://vitest.dev/), Testing Library, and Next.js static build validation
+
+---
+
+## Project Status & Implementation Roadmap
+
+NotesReady is developed in strictly reviewed, independently verifiable engineering phases:
+
+| Phase | Milestone Name | Status | Branch / Baseline | Key Capabilities |
+| :---: | :--- | :---: | :---: | :--- |
+| **0** | **Project Definition & Architecture** | **Complete** | `main` | Brand Concept 04, reference studies, core documentation framework. |
+| **1** | **Foundation & Application Shell** | **Complete** | `main` | Multi-tier shell, responsive drawer, marketing landing page, legal routes. |
+| **2** | **Core Workspace & Note Creation** | **Complete** | `main` | Decoupled note entity, native IndexedDB adapter, 400ms debounced autosave. |
+| **3** | **Rich Text Editor & Durability** | **Complete** | `main` *(Stable Baseline)* | Tiptap v3, floating bubble menu, 2 MB size limits, lifecycle flushing, privacy logging. |
+| **4** | **Persistence & Organization** | **Audited** | `phase-4-organization` *(Pending Merge)* | IndexedDB v2 migration, notebooks, tags, archive, trash & recovery workflow. |
+| **5** | **Cloud Persistence & Sync** | **Planned** | Architectural Plan | Google/Microsoft OAuth, verified email, PostgreSQL RLS, conflict-preserving sync. |
+| **6** | **Real-Time Collaboration** | **Planned** | Roadmap | Ephemeral presence, Yjs CRDTs, WebSocket document synchronization. |
+| **7** | **Search & Command Palette** | **Planned** | Roadmap | Full-text search index, keyboard command palette (`Cmd+K`). |
+| **8** | **Files & Object Media** | **Planned** | Roadmap | S3-compatible attachment uploads with presigned validation. |
+| **9** | **Mobile Web & Accessibility** | **Planned** | Roadmap | Touch ergonomics, accessory toolbar, WCAG AA compliance audit. |
+| **10** | **Production Security & Launch** | **Planned** | Roadmap | Production rate limiting, CSP security headers, custom domain deployment. |
+
+---
+
+## Getting Started Locally
+
+### Prerequisites
+- [Node.js](https://nodejs.org/) v20.x or higher
+- [npm](https://www.npmjs.com/) v10.x or higher
+
+### Installation & Development Server
+```bash
+# Clone the repository
+git clone https://github.com/NotesReadyCommunity/NotesReady.git
+cd NotesReady
+
+# Install dependencies cleanly
+npm install
+
+# Start local development server
+npm run dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) to view the application in your browser.
+
+---
+
+## Quality Gates & Verification
+
+Every pull request and release branch must pass all five mandatory automated quality gates:
+
+```bash
+# 1. Automated Test Suite (Vitest)
+npm test
+
+# 2. Strict TypeScript Compilation Check
+npx tsc --noEmit
+
+# 3. Code Style & Linting (ESLint)
+npm run lint
+
+# 4. Production Next.js Build
+npm run build
+
+# 5. Whitespace & Formatting Validation
+git diff --check
+```
+
+---
+
+## Engineering Governance & Contributing
+
+- **Stable Branch Strategy**: The `main` branch serves as the protected, stable production baseline.
+- **Phase Branch Discipline**: Feature development takes place in isolated phase branches (e.g., `phase-4-organization`).
+- **Owner Approval**: No phase branch is merged into `main` without formal review, passing quality gates, and explicit project owner authorization.
+- For complete guidelines on PR formatting, architectural rules, and brand invariants, see [CONTRIBUTING.md](CONTRIBUTING.md).
+
+---
+
+## Security & Privacy Policy
+
+- **Storage Honesty**: The application truthfully surfaces storage failure warnings rather than silently pretending changes are persisted when browser quotas fail.
+- **Zero-Telemetry Privacy**: Note titles, contents, and user writing are strictly excluded from diagnostics, error logs, and telemetry.
+- **Server-Side Authorization**: Hiding UI buttons is never treated as security. All cloud persistence and APIs enforce server-side Row Level Security.
+- To report security concerns, please review our [Security Guidelines](CONTRIBUTING.md#3-engineering-architecture--code-standards).
+
+---
+
+## License
+
+Private and proprietary. All rights reserved by NotesReady Community.
diff --git a/decisions.md b/decisions.md
index b271bbc..12962a3 100644
--- a/decisions.md
+++ b/decisions.md
@@ -67,3 +67,15 @@
3. **Reliable Lifecycle Save Flushing**: Pending debounced saves are flushed on component unmount, `pagehide`, and `visibilitychange` (when document becomes hidden). In-flight saves are tracked and sequenced with latest in-memory edits to prevent race conditions and stale overwrites. The limitation that browsers do not guarantee async IndexedDB completion during abrupt process termination is accurately documented, and unsaved state is never falsely reported as saved.
4. **Privacy-Safe Telemetry**: `error.message`, note titles, note bodies, and pasted content are strictly excluded from diagnostics. Only generic error names (`err.name` or fallback strings) are logged.
5. **Plain-Text Fallback Fidelity**: The error boundary textarea saves edits explicitly with `format: "plain-text-v1"`, preventing JSON parse errors upon reload and preserving plain-text notes safely for subsequent in-memory migration.
+
+## ADR-010: Phase 4 Local-First Persistence, IndexedDB v2 Migration & Organization Hierarchy
+- **Status**: Accepted
+- **Context**: The product roadmap mentions PostgreSQL schema & migrations, while the current web application architecture is fully client-side local-first with zero authentication or server runtime dependencies. Phase 4 requires organization (notebooks, tags, archive, trash & recovery).
+- **Decision**:
+ 1. **Database Architecture**: Continue with native local-first IndexedDB persistence for Phase 4. Server persistence (PostgreSQL / Prisma / Supabase) is deferred to Phase 5/6 when authentication, Hocuspocus WebSockets, and cloud sync are introduced. This eliminates speculative server infrastructure, preserves 100% offline functionality, and avoids multi-tenant sync complexities before an auth layer exists.
+ 2. **Non-Destructive IndexedDB v2 Schema Upgrade**: Incremented database version to `DB_VERSION = 2` (`notesready-db`). Added indexes (`notebookId`, `archivedAt`) to the `notes` store, and created a dedicated `notebooks` object store with indexes (`updatedAt`, `name`). Version upgrade is strictly additive; existing notes default gracefully to unassigned inbox (`notebookId = null`), empty tags (`tags = []`), and active status (`archivedAt = null`).
+ 3. **Data Safety on Notebook Deletion**: Deleting a notebook never deletes user notes contained within it. The repository unassigns the affected notes (`notebookId = null`), returning them safely to the default workspace inbox.
+ 4. **Trash & Recovery Lifecycle**: Soft-deleted notes (`deletedAt !== null`) are strictly excluded from active views, recent notes, and favorites. A dedicated `/app/trash` route provides complete visibility, note restoration, individual permanent deletion with confirmation, and empty trash. Directly opening a trashed note displays a prominent Trashed Note Banner with read-only controls, disabled autosave, and direct Restore/Delete Forever actions.
+ 5. **Archive Lifecycle**: Notes can be archived to declutter active views while preserving all metadata and rich content intact. Dedicated `/app/archive` view allows seamless unarchiving.
+ 6. **Tags & Dynamic Filtering**: Notes support flexible tags (`tags?: string[]`). All Notes view provides instant tag filter pills, and note cards surface notebook and tag metadata.
+ 7. **Sidebar Navigation Integrity**: Sidebar connects real, working links for `/app/trash`, `/app/archive`, dynamic notebooks (`/app/notebooks/[id]`), and an inline "+ Notebook" creation form, while preserving Quick Search and Settings as non-misleading planned items with "Soon" badges.
diff --git a/package-lock.json b/package-lock.json
index 6646cb3..7a72435 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,7 @@
"@types/react-dom": "^19.0.0",
"eslint": "^9.16.0",
"eslint-config-next": "^15.1.0",
+ "fake-indexeddb": "^6.2.5",
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
"tailwindcss": "^4.0.0",
@@ -216,9 +217,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
- "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
+ "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -2222,6 +2223,51 @@
"node": ">=14.0.0"
}
},
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
+ "version": "2.8.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -3422,6 +3468,18 @@
"node": ">=14.0.0"
}
},
+ "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
@@ -3433,6 +3491,17 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
@@ -5124,6 +5193,16 @@
"node": ">=12.0.0"
}
},
+ "node_modules/fake-indexeddb": {
+ "version": "6.2.5",
+ "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
+ "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
diff --git a/package.json b/package.json
index 176a449..789f771 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
"@types/react-dom": "^19.0.0",
"eslint": "^9.16.0",
"eslint-config-next": "^15.1.0",
+ "fake-indexeddb": "^6.2.5",
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
"tailwindcss": "^4.0.0",
diff --git a/plans.md b/plans.md
index ebd7a93..026346b 100644
--- a/plans.md
+++ b/plans.md
@@ -34,3 +34,16 @@
7. Editor error boundary with plain-textarea fallback saving explicit `plain-text-v1`.
8. Hostile paste sanitization and zero-telemetry content leak prevention (no `error.message` or note content in logs).
9. 62 automated unit and integration tests passing across 13 test files.
+
+## Phase 4 — Persistence & Organization (IMPLEMENTED & AUDITED)
+- **Status**: IMPLEMENTED & AUDITED
+- **Completed Deliverables**:
+ 1. Non-destructive IndexedDB v2 upgrade (`notesready-db`, `DB_VERSION = 2`) with `notebookId` and `archivedAt` indexes on notes, plus a dedicated `notebooks` object store.
+ 2. Complete Trash & Recovery workflow: soft deletion with `deletedAt`, dedicated `/app/trash` route, restore notes, permanent delete with confirmation, and empty trash.
+ 3. Trashed note protection: reading trashed notes via direct URL renders a top Trashed Note Banner with read-only controls, disabled autosave, and direct Restore / Delete Forever actions.
+ 4. Notebooks organization: decoupled `Notebook` domain entity and runtime validators, create notebook, rename notebook, delete notebook with safe note preservation (`notebookId` cleared to `null` to prevent data loss).
+ 5. Dedicated dynamic notebook view `/app/notebooks/[id]` displaying notebook title, inline renaming, safe deletion, and note cards filtered by notebook.
+ 6. Tags system: flexible array of tags (`tags?: string[]`) on notes, inline tag creation/removal in editor, and tag filter pills on `/app/notes`.
+ 7. Archive workflow: archive notes to declutter workspace, dedicated `/app/archive` view with restore/unarchive action.
+ 8. Sidebar integration: active working links for `/app/trash` (with count badge), `/app/archive` (with count badge), dynamic notebooks with inline "+ Notebook" creation, and honest "Soon" badges on planned Quick Search & Settings.
+ 9. 92 automated tests passing across 19 test files (including populated IndexedDB v1-to-v2 migration test suite), zero TypeScript errors (`tsc --noEmit`), zero ESLint errors, and clean production build (`next build`).
diff --git a/src/app/(workspace)/app/archive/page.tsx b/src/app/(workspace)/app/archive/page.tsx
new file mode 100644
index 0000000..2342687
--- /dev/null
+++ b/src/app/(workspace)/app/archive/page.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import React from "react";
+import Link from "next/link";
+import { Archive, ArrowLeft, RotateCcw } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useWorkspace } from "@/context/WorkspaceContext";
+import { getNoteSnippet } from "@/core/utils/content";
+
+export default function ArchivePage() {
+ const { archivedNotes, isLoading, unarchiveNote } = useWorkspace();
+
+ return (
+
+ {/* Header */}
+
+
+
+ Archive
+
+
+ Archived notes are preserved and hidden from your active notes list.
+
+
+
+ {archivedNotes.length}
+
+
+
+ {isLoading ? (
+
+ ) : archivedNotes.length === 0 ? (
+
+
+
+ Archive is empty
+
+
+ You can archive completed or reference notes from the note options menu to declutter your active workspace.
+
+
+
+
+
+
+
+ ) : (
+
+ {archivedNotes.map((note) => {
+ const formattedDate = note.archivedAt
+ ? new Date(note.archivedAt).toLocaleDateString([], {
+ month: "short",
+ day: "numeric",
+ })
+ : "Archived";
+
+ return (
+
+
+
+ {note.title || "Untitled note"}
+
+
+ {getNoteSnippet(note.content, note.format)}
+
+
+
+
+
+ Archived {formattedDate}
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/app/(workspace)/app/notebooks/[id]/page.tsx b/src/app/(workspace)/app/notebooks/[id]/page.tsx
new file mode 100644
index 0000000..94f31a5
--- /dev/null
+++ b/src/app/(workspace)/app/notebooks/[id]/page.tsx
@@ -0,0 +1,227 @@
+"use client";
+
+import React, { useState } from "react";
+import { useParams, useRouter } from "next/navigation";
+import Link from "next/link";
+import { FolderClosed, Plus, Star, MoreHorizontal, ArrowLeft, Trash2, Edit2, Check } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useWorkspace } from "@/context/WorkspaceContext";
+import { getNoteSnippet } from "@/core/utils/content";
+
+export default function NotebookDetailPage() {
+ const params = useParams();
+ const router = useRouter();
+ const id = typeof params.id === "string" ? params.id : Array.isArray(params.id) ? params.id[0] : "";
+
+ const { notes, notebooks, isLoading, createNote, renameNotebook, deleteNotebook } = useWorkspace();
+ const notebook = notebooks.find((nb) => nb.id === id);
+
+ const [isEditingName, setIsEditingName] = useState(false);
+ const [editedName, setEditedName] = useState("");
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+
+ const notebookNotes = notes.filter((n) => n.notebookId === id);
+
+ const handleStartRename = () => {
+ if (notebook) {
+ setEditedName(notebook.name);
+ setIsEditingName(true);
+ }
+ };
+
+ const handleSaveRename = async () => {
+ if (notebook && editedName.trim()) {
+ await renameNotebook(notebook.id, editedName.trim());
+ }
+ setIsEditingName(false);
+ };
+
+ const handleDeleteNotebook = async () => {
+ if (notebook) {
+ await deleteNotebook(notebook.id);
+ router.push("/app/notes");
+ }
+ };
+
+ const handleCreateNoteInNotebook = async () => {
+ await createNote({ notebookId: id });
+ };
+
+ if (!isLoading && !notebook) {
+ return (
+
+
+
+
+
Notebook Not Found
+
This notebook may have been deleted or moved.
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+ {isEditingName ? (
+
+ setEditedName(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleSaveRename()}
+ autoFocus
+ className="px-2 py-0.5 rounded border border-[var(--brand-ember,#E85D3F)] bg-[var(--surface-0)] text-xl font-bold text-[var(--text-primary)] outline-none"
+ />
+
+
+ ) : (
+
+
+ {notebook?.name || "Notebook"}
+
+
+
+ )}
+
+
+
+ {isLoading
+ ? "Loading notes..."
+ : `${notebookNotes.length} note${notebookNotes.length === 1 ? "" : "s"} in this notebook`}
+
+
+
+
+ {showDeleteConfirm ? (
+
+ Delete notebook? (Notes will be preserved)
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {/* Notes in Notebook */}
+ {isLoading ? (
+
+ ) : notebookNotes.length === 0 ? (
+
+
+
+
+
+ No notes in this notebook yet
+
+
+ Organize related ideas here. Click New Note to create your first document in this notebook.
+
+
+
+
+
+ ) : (
+
+ {notebookNotes.map((note) => {
+ const formattedDate = new Date(note.updatedAt).toLocaleDateString([], {
+ month: "short",
+ day: "numeric",
+ });
+
+ return (
+
+
+
+
+ {note.title || "Untitled note"}
+
+ {note.isFavorite && (
+
+ )}
+
+
+ {formattedDate}
+
+
+
+ {getNoteSnippet(note.content, note.format)}
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/app/(workspace)/app/notes/page.tsx b/src/app/(workspace)/app/notes/page.tsx
index 63a958c..169288c 100644
--- a/src/app/(workspace)/app/notes/page.tsx
+++ b/src/app/(workspace)/app/notes/page.tsx
@@ -1,14 +1,37 @@
"use client";
-import React from "react";
+import React, { useState, useMemo } from "react";
import Link from "next/link";
-import { FileText, Plus, Star } from "lucide-react";
+import { FileText, Plus, Star, FolderClosed, Tag } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useWorkspace } from "@/context/WorkspaceContext";
import { getNoteSnippet } from "@/core/utils/content";
export default function AllNotesPage() {
- const { notes, isLoading, createNote } = useWorkspace();
+ const { notes, notebooks, isLoading, createNote } = useWorkspace();
+ const [selectedTag, setSelectedTag] = useState(null);
+
+ // Extract all unique tags across notes in workspace
+ const allTags = useMemo(() => {
+ const set = new Set();
+ notes.forEach((n) => {
+ n.tags?.forEach((t) => set.add(t));
+ });
+ return Array.from(set).sort();
+ }, [notes]);
+
+ // Filter notes by selected tag
+ const filteredNotes = useMemo(() => {
+ if (!selectedTag) return notes;
+ return notes.filter((n) => n.tags?.includes(selectedTag));
+ }, [notes, selectedTag]);
+
+ // Map of notebook id -> notebook name
+ const notebookMap = useMemo(() => {
+ const map = new Map();
+ notebooks.forEach((nb) => map.set(nb.id, nb.name));
+ return map;
+ }, [notebooks]);
return (
@@ -18,7 +41,9 @@ export default function AllNotesPage() {
All Notes
- {isLoading ? "Loading notes..." : `${notes.length} note${notes.length === 1 ? "" : "s"} in local workspace`}
+ {isLoading
+ ? "Loading notes..."
+ : `${notes.length} note${notes.length === 1 ? "" : "s"} in local workspace`}
+ {/* Tag Filter Pills */}
+ {allTags.length > 0 && (
+
+
+ {allTags.map((tag) => (
+
+ ))}
+
+ )}
+
{isLoading ? (
@@ -50,19 +107,33 @@ export default function AllNotesPage() {
+ ) : filteredNotes.length === 0 ? (
+
+
+ No notes found with tag #{selectedTag}
+
+
+
) : (
- {notes.map((note) => {
+ {filteredNotes.map((note) => {
const formattedDate = new Date(note.updatedAt).toLocaleDateString([], {
month: "short",
day: "numeric",
});
+ const notebookName = note.notebookId ? notebookMap.get(note.notebookId) : null;
return (
@@ -80,6 +151,26 @@ export default function AllNotesPage() {
{getNoteSnippet(note.content, note.format)}
+
+ {/* Metadata badges: Notebook & Tags */}
+ {(notebookName || (note.tags && note.tags.length > 0)) && (
+
+ {notebookName && (
+
+
+ {notebookName}
+
+ )}
+ {note.tags?.map((t) => (
+
+ #{t}
+
+ ))}
+
+ )}
);
})}
diff --git a/src/app/(workspace)/app/trash/page.tsx b/src/app/(workspace)/app/trash/page.tsx
new file mode 100644
index 0000000..ebdb49a
--- /dev/null
+++ b/src/app/(workspace)/app/trash/page.tsx
@@ -0,0 +1,188 @@
+"use client";
+
+import React, { useState } from "react";
+import Link from "next/link";
+import { Trash2, RotateCcw, AlertTriangle, ArrowLeft } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useWorkspace } from "@/context/WorkspaceContext";
+import { getNoteSnippet } from "@/core/utils/content";
+
+export default function TrashPage() {
+ const { trashedNotes, isLoading, restoreNote, permanentlyDeleteNote, emptyTrash } = useWorkspace();
+ const [showEmptyConfirm, setShowEmptyConfirm] = useState(false);
+ const [deletingId, setDeletingId] = useState
(null);
+
+ const handleEmptyTrash = async () => {
+ await emptyTrash();
+ setShowEmptyConfirm(false);
+ };
+
+ const handlePermanentDelete = async (id: string) => {
+ await permanentlyDeleteNote(id);
+ setDeletingId(null);
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ Trash
+
+
+ {trashedNotes.length}
+
+
+
+ Notes moved to trash remain available for recovery until permanently deleted.
+
+
+
+ {trashedNotes.length > 0 && (
+
+ {showEmptyConfirm ? (
+
+ Empty trash permanently?
+
+
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ {/* Trashed Notes List */}
+ {isLoading ? (
+
+ ) : trashedNotes.length === 0 ? (
+
+
+
+
+
+ Trash is empty
+
+
+ Items moved to trash will appear here for recovery or permanent deletion.
+
+
+
+
+
+
+
+ ) : (
+
+ {trashedNotes.map((note) => {
+ const formattedDate = note.deletedAt
+ ? new Date(note.deletedAt).toLocaleDateString([], {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })
+ : "Recently";
+
+ return (
+
+
+
+
+ {note.title || "Untitled note"}
+
+
+ {getNoteSnippet(note.content, note.format)}
+
+
+
+
+
+
+ {deletingId === note.id ? (
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ Deleted {formattedDate}
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx
index 012cc38..8853187 100644
--- a/src/components/layout/AppSidebar.tsx
+++ b/src/components/layout/AppSidebar.tsx
@@ -1,6 +1,6 @@
"use client";
-import React from "react";
+import React, { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
@@ -15,6 +15,9 @@ import {
FolderClosed,
ChevronDown,
Clock,
+ Archive,
+ Check,
+ X,
} from "lucide-react";
import { BrandLogo } from "@/components/brand/BrandLogo";
import { Button } from "@/components/ui/button";
@@ -30,12 +33,28 @@ export const AppSidebar: React.FC = ({
onCloseMobile,
}) => {
const pathname = usePathname();
- const { recentNotes, createNote } = useWorkspace();
+ const {
+ recentNotes,
+ notebooks,
+ trashedNotes,
+ archivedNotes,
+ createNote,
+ createNotebook,
+ } = useWorkspace();
+
+ const [isCreatingNotebook, setIsCreatingNotebook] = useState(false);
+ const [newNotebookName, setNewNotebookName] = useState("");
const navItems = [
{ label: "Home", href: "/app", icon: Home },
{ label: "All Notes", href: "/app/notes", icon: FileText },
{ label: "Favorites", href: "/app/favorites", icon: Star },
+ {
+ label: "Archive",
+ href: "/app/archive",
+ icon: Archive,
+ badge: archivedNotes.length > 0 ? archivedNotes.length : undefined,
+ },
{ label: "Shared", href: "/app/shared", icon: Users },
];
@@ -44,6 +63,15 @@ export const AppSidebar: React.FC = ({
onCloseMobile?.();
};
+ const handleCreateNotebook = async () => {
+ const trimmed = newNotebookName.trim();
+ if (trimmed) {
+ await createNotebook(trimmed);
+ setNewNotebookName("");
+ setIsCreatingNotebook(false);
+ }
+ };
+
return (
- {/* Bottom Footer Section (Planned Features) */}
+ {/* Bottom Footer Section */}
-
-
- Settings
+
+ Trash
-
- Soon
-
-
+ {trashedNotes.length > 0 && (
+
+ {trashedNotes.length}
+
+ )}
+
+
+ {/* Planned Settings */}
-
- Trash
+
+ Settings
Soon
diff --git a/src/components/workspace/NoteEditor.tsx b/src/components/workspace/NoteEditor.tsx
index 82986d7..a9f8576 100644
--- a/src/components/workspace/NoteEditor.tsx
+++ b/src/components/workspace/NoteEditor.tsx
@@ -1,7 +1,19 @@
"use client";
import React, { useRef, useEffect, useState } from "react";
-import { Star, Trash2, Clock, Plus, ArrowLeft } from "lucide-react";
+import {
+ Star,
+ Trash2,
+ Clock,
+ Plus,
+ ArrowLeft,
+ Archive,
+ FolderClosed,
+ Tag,
+ X,
+ AlertTriangle,
+ RotateCcw,
+} from "lucide-react";
import Link from "next/link";
import { useNote } from "@/hooks/useNote";
import { useWorkspace } from "@/context/WorkspaceContext";
@@ -21,25 +33,34 @@ export const NoteEditor: React.FC = ({ noteId }) => {
format,
isLoading,
isNotFound,
+ isTrashed,
handleTitleChange,
handleContentChange,
handleFallbackContentChange,
deleteNote,
toggleFavorite,
+ restoreNote,
+ permanentlyDeleteNote,
+ toggleArchive,
+ setNotebookId,
+ setTags,
} = useNote(noteId);
- const { createNote } = useWorkspace();
+ const { createNote, notebooks } = useWorkspace();
const richEditorRef = useRef(null);
const titleInputRef = useRef(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [showPermanentDeleteConfirm, setShowPermanentDeleteConfirm] = useState(false);
+ const [isAddingTag, setIsAddingTag] = useState(false);
+ const [newTagInput, setNewTagInput] = useState("");
// Auto-focus title if note is fresh (content empty and title default)
useEffect(() => {
- if (!isLoading && note && note.content === "" && note.title === "Untitled note") {
+ if (!isLoading && note && !isTrashed && note.content === "" && note.title === "Untitled note") {
titleInputRef.current?.focus();
titleInputRef.current?.select();
}
- }, [isLoading, note]);
+ }, [isLoading, note, isTrashed]);
// Pressing Enter in title focuses the rich editor canvas immediately
const handleTitleKeyDown = (e: React.KeyboardEvent) => {
@@ -49,6 +70,26 @@ export const NoteEditor: React.FC = ({ noteId }) => {
}
};
+ const handleAddTag = () => {
+ const trimmed = newTagInput.trim().replace(/^#+/, "").toLowerCase();
+ if (!trimmed) {
+ setIsAddingTag(false);
+ setNewTagInput("");
+ return;
+ }
+ const currentTags = note?.tags || [];
+ if (!currentTags.includes(trimmed)) {
+ setTags([...currentTags, trimmed]);
+ }
+ setNewTagInput("");
+ setIsAddingTag(false);
+ };
+
+ const handleRemoveTag = (tagToRemove: string) => {
+ const currentTags = note?.tags || [];
+ setTags(currentTags.filter((t) => t !== tagToRemove));
+ };
+
if (isLoading) {
return (
@@ -100,63 +141,228 @@ export const NoteEditor: React.FC
= ({ noteId }) => {
return (
+ {/* Trashed Note Banner */}
+ {isTrashed && (
+
+
+
+
+
+ This note is in the trash
+
+
+ Editing and autosave are disabled. Restore this note to make changes.
+
+
+
+
+
+ {showPermanentDeleteConfirm ? (
+
+
+
+
+ ) : (
+
+ )}
+
+
+ )}
+
{/* Top Document Header Controls */}
Edited {formattedDate}
+ {note.archivedAt && (
+
+ Archived
+
+ )}
-
-
-
- {/* Accidental deletion protection */}
- {showDeleteConfirm ? (
-
+ {/* Archive Toggle Button */}
+
+
+ {/* Favorite Toggle Button */}
+
+
+ {/* Accidental deletion protection */}
+ {showDeleteConfirm ? (
+
+ Move to trash?
+
+
+
+ ) : (
+ )}
+
+ )}
+
+
+ {/* Organization Strip: Notebook and Tags */}
+
+ {/* Notebook Picker */}
+
+
+
+
+
+ {/* Tags management */}
+
+
+ {(note.tags || []).map((t) => (
+
+ #{t}
+ {!isTrashed && (
+
+ )}
+
+ ))}
+
+ {!isTrashed && (
+ isAddingTag ? (
+
+ setNewTagInput(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleAddTag();
+ } else if (e.key === "Escape") {
+ setIsAddingTag(false);
+ setNewTagInput("");
+ }
+ }}
+ onBlur={handleAddTag}
+ placeholder="tag-name"
+ autoFocus
+ className="w-24 px-1.5 py-0.5 text-[11px] rounded bg-[var(--surface-1)] border border-[var(--brand-ember,#E85D3F)] text-[var(--text-primary)] outline-none"
+ aria-label="New tag name"
+ />
+
+ ) : (
-
- ) : (
-
+ )
)}
@@ -171,7 +377,8 @@ export const NoteEditor: React.FC = ({ noteId }) => {
onKeyDown={handleTitleKeyDown}
placeholder="Untitled note"
aria-label="Note Title"
- className="w-full text-3xl sm:text-4xl font-extrabold tracking-tight text-[var(--text-primary)] placeholder:text-[var(--text-muted)] bg-transparent outline-none border-none p-0 focus:ring-0 leading-tight"
+ disabled={isTrashed}
+ className="w-full text-3xl sm:text-4xl font-extrabold tracking-tight text-[var(--text-primary)] placeholder:text-[var(--text-muted)] bg-transparent outline-none border-none p-0 focus:ring-0 leading-tight disabled:opacity-60"
/>
@@ -188,6 +395,7 @@ export const NoteEditor: React.FC = ({ noteId }) => {
content={content}
format={format}
onContentChange={handleContentChange}
+ disabled={isTrashed}
/>
diff --git a/src/context/WorkspaceContext.tsx b/src/context/WorkspaceContext.tsx
index 9d2d74f..7bc8881 100644
--- a/src/context/WorkspaceContext.tsx
+++ b/src/context/WorkspaceContext.tsx
@@ -3,6 +3,7 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Note, createEmptyNote } from "@/core/models/note";
+import { Notebook, createEmptyNotebook } from "@/core/models/notebook";
import { getNoteRepository, getStorageError } from "@/core/storage";
export type SaveStatus = "saved" | "saving" | "unsaved" | "error";
@@ -11,6 +12,9 @@ interface WorkspaceContextValue {
notes: Note[];
recentNotes: Note[];
favoriteNotes: Note[];
+ notebooks: Notebook[];
+ trashedNotes: Note[];
+ archivedNotes: Note[];
isLoading: boolean;
activeNoteId: string | null;
saveStatus: SaveStatus;
@@ -19,7 +23,7 @@ interface WorkspaceContextValue {
storageError: string | null;
retrySave?: () => Promise
;
setRetrySaveHandler: (fn: (() => Promise) | null) => void;
- createNote: () => Promise;
+ createNote: (input?: { notebookId?: string | null; tags?: string[] }) => Promise;
deleteNote: (id: string) => Promise;
toggleFavorite: (id: string) => Promise;
refreshNotes: () => Promise;
@@ -27,6 +31,18 @@ interface WorkspaceContextValue {
setSaveStatus: (status: SaveStatus) => void;
setCurrentNoteTitle: (title: string) => void;
setActiveNoteId: (id: string | null) => void;
+
+ // Phase 4 Organization Actions
+ createNotebook: (name: string) => Promise;
+ renameNotebook: (id: string, name: string) => Promise;
+ deleteNotebook: (id: string) => Promise;
+ restoreNote: (id: string) => Promise;
+ permanentlyDeleteNote: (id: string) => Promise;
+ emptyTrash: () => Promise;
+ archiveNote: (id: string) => Promise;
+ unarchiveNote: (id: string) => Promise;
+ moveNoteToNotebook: (noteId: string, notebookId: string | null) => Promise;
+ setNoteTags: (noteId: string, tags: string[]) => Promise;
}
const WorkspaceContext = createContext(null);
@@ -35,6 +51,9 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const [notes, setNotes] = useState([]);
const [recentNotes, setRecentNotes] = useState([]);
+ const [notebooks, setNotebooks] = useState([]);
+ const [trashedNotes, setTrashedNotes] = useState([]);
+ const [archivedNotes, setArchivedNotes] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [activeNoteId, setActiveNoteId] = useState(null);
const [saveStatus, setSaveStatus] = useState("saved");
@@ -57,13 +76,19 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
setStorageError(null);
}
- const [allNotes, recents] = await Promise.all([
+ const [allNotes, recents, allNotebooks, trashed, archived] = await Promise.all([
repo.listNotes(),
repo.listRecentNotes(6),
+ repo.listNotebooks(),
+ repo.listTrashedNotes(),
+ repo.listArchivedNotes(),
]);
setNotes(allNotes);
setRecentNotes(recents);
+ setNotebooks(allNotebooks);
+ setTrashedNotes(trashed);
+ setArchivedNotes(archived);
} catch (err) {
console.error("Failed to load notes from storage:", {
error: err instanceof Error ? err.name : "StorageError",
@@ -80,9 +105,14 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
refreshNotes();
}, [refreshNotes]);
- // Optimized in-memory note update to avoid full repository re-queries on every keystroke autosave (P2 Fix #6)
+ // Optimized in-memory note update to avoid full repository re-queries on every keystroke autosave
const updateNoteInMemory = useCallback((updatedNote: Note) => {
setNotes((prevNotes) => {
+ // If note was trashed or archived, filter it out from active notes list
+ if (updatedNote.deletedAt !== null || (updatedNote.archivedAt && updatedNote.archivedAt !== null)) {
+ return prevNotes.filter((n) => n.id !== updatedNote.id);
+ }
+
const idx = prevNotes.findIndex((n) => n.id === updatedNote.id);
if (idx === -1) {
return [updatedNote, ...prevNotes];
@@ -93,22 +123,31 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
});
setRecentNotes((prevRecents) => {
+ if (updatedNote.deletedAt !== null || (updatedNote.archivedAt && updatedNote.archivedAt !== null)) {
+ return prevRecents.filter((n) => n.id !== updatedNote.id);
+ }
const existing = prevRecents.filter((n) => n.id !== updatedNote.id);
return [updatedNote, ...existing].slice(0, 6);
});
}, []);
- const createNote = useCallback(async (): Promise => {
- const repo = getNoteRepository();
- const newNote = createEmptyNote();
- await repo.saveNote(newNote);
- await refreshNotes();
- setActiveNoteId(newNote.id);
- setCurrentNoteTitle(newNote.title);
- setSaveStatus(repo.isDurable ? "saved" : "error");
- router.push(`/app/notes/${newNote.id}`);
- return newNote;
- }, [refreshNotes, router]);
+ const createNote = useCallback(
+ async (input?: { notebookId?: string | null; tags?: string[] }): Promise => {
+ const repo = getNoteRepository();
+ const newNote = createEmptyNote({
+ notebookId: input?.notebookId ?? null,
+ tags: input?.tags ?? [],
+ });
+ await repo.saveNote(newNote);
+ await refreshNotes();
+ setActiveNoteId(newNote.id);
+ setCurrentNoteTitle(newNote.title);
+ setSaveStatus(repo.isDurable ? "saved" : "error");
+ router.push(`/app/notes/${newNote.id}`);
+ return newNote;
+ },
+ [refreshNotes, router]
+ );
const deleteNote = useCallback(
async (id: string) => {
@@ -139,6 +178,125 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
[refreshNotes]
);
+ // Phase 4 Notebook actions
+ const createNotebook = useCallback(
+ async (name: string): Promise => {
+ const repo = getNoteRepository();
+ const nb = createEmptyNotebook({ name });
+ await repo.saveNotebook(nb);
+ await refreshNotes();
+ return nb;
+ },
+ [refreshNotes]
+ );
+
+ const renameNotebook = useCallback(
+ async (id: string, name: string) => {
+ const repo = getNoteRepository();
+ const existing = await repo.getNotebook(id);
+ if (existing) {
+ const updated: Notebook = {
+ ...existing,
+ name: name.trim() || existing.name,
+ updatedAt: new Date().toISOString(),
+ };
+ await repo.saveNotebook(updated);
+ await refreshNotes();
+ }
+ },
+ [refreshNotes]
+ );
+
+ const deleteNotebook = useCallback(
+ async (id: string) => {
+ const repo = getNoteRepository();
+ await repo.deleteNotebook(id);
+ await refreshNotes();
+ },
+ [refreshNotes]
+ );
+
+ // Phase 4 Trash actions
+ const restoreNote = useCallback(
+ async (id: string) => {
+ const repo = getNoteRepository();
+ await repo.restoreNote(id);
+ await refreshNotes();
+ },
+ [refreshNotes]
+ );
+
+ const permanentlyDeleteNote = useCallback(
+ async (id: string) => {
+ const repo = getNoteRepository();
+ await repo.hardDeleteNote(id);
+ await refreshNotes();
+ },
+ [refreshNotes]
+ );
+
+ const emptyTrash = useCallback(async () => {
+ const repo = getNoteRepository();
+ const trashed = await repo.listTrashedNotes();
+ await Promise.all(trashed.map((n) => repo.hardDeleteNote(n.id)));
+ await refreshNotes();
+ }, [refreshNotes]);
+
+ // Phase 4 Archive actions
+ const archiveNote = useCallback(
+ async (id: string) => {
+ const repo = getNoteRepository();
+ await repo.archiveNote(id);
+ await refreshNotes();
+ },
+ [refreshNotes]
+ );
+
+ const unarchiveNote = useCallback(
+ async (id: string) => {
+ const repo = getNoteRepository();
+ await repo.unarchiveNote(id);
+ await refreshNotes();
+ },
+ [refreshNotes]
+ );
+
+ // Move note to notebook
+ const moveNoteToNotebook = useCallback(
+ async (noteId: string, notebookId: string | null) => {
+ const repo = getNoteRepository();
+ const note = await repo.getNote(noteId);
+ if (note) {
+ const updated: Note = {
+ ...note,
+ notebookId,
+ updatedAt: new Date().toISOString(),
+ };
+ await repo.saveNote(updated);
+ await refreshNotes();
+ }
+ },
+ [refreshNotes]
+ );
+
+ // Set note tags
+ const setNoteTags = useCallback(
+ async (noteId: string, tags: string[]) => {
+ const repo = getNoteRepository();
+ const note = await repo.getNote(noteId);
+ if (note) {
+ const updated: Note = {
+ ...note,
+ tags,
+ updatedAt: new Date().toISOString(),
+ };
+ await repo.saveNote(updated);
+ await refreshNotes();
+ }
+ },
+ [refreshNotes]
+ );
+
const favoriteNotes = notes.filter((n) => n.isFavorite);
return (
@@ -147,6 +305,9 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
notes,
recentNotes,
favoriteNotes,
+ notebooks,
+ trashedNotes,
+ archivedNotes,
isLoading,
activeNoteId,
saveStatus,
@@ -163,6 +324,16 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
setSaveStatus,
setCurrentNoteTitle,
setActiveNoteId,
+ createNotebook,
+ renameNotebook,
+ deleteNotebook,
+ restoreNote,
+ permanentlyDeleteNote,
+ emptyTrash,
+ archiveNote,
+ unarchiveNote,
+ moveNoteToNotebook,
+ setNoteTags,
}}
>
{children}
@@ -177,4 +348,3 @@ export function useWorkspace() {
}
return ctx;
}
-
diff --git a/src/core/models/note.ts b/src/core/models/note.ts
index 2671930..91f638d 100644
--- a/src/core/models/note.ts
+++ b/src/core/models/note.ts
@@ -5,6 +5,9 @@ export interface Note {
title: string;
content: string;
format?: NoteContentFormat;
+ notebookId?: string | null;
+ tags?: string[];
+ archivedAt?: string | null;
createdAt: string;
updatedAt: string;
isFavorite: boolean;
@@ -12,21 +15,32 @@ export interface Note {
}
export interface CreateNoteInput {
+ id?: string;
title?: string;
content?: string;
format?: NoteContentFormat;
+ notebookId?: string | null;
+ tags?: string[];
+ archivedAt?: string | null;
+ createdAt?: string;
+ updatedAt?: string;
+ isFavorite?: boolean;
+ deletedAt?: string | null;
}
export function createEmptyNote(input?: CreateNoteInput): Note {
const now = new Date().toISOString();
return {
- id: typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `note-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
+ id: input?.id || (typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `note-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`),
title: input?.title?.trim() || "Untitled note",
content: input?.content ?? "",
format: input?.format ?? "tiptap-json-v1",
- createdAt: now,
- updatedAt: now,
- isFavorite: false,
- deletedAt: null,
+ notebookId: input?.notebookId ?? null,
+ tags: input?.tags ?? [],
+ archivedAt: input?.archivedAt ?? null,
+ createdAt: input?.createdAt || now,
+ updatedAt: input?.updatedAt || now,
+ isFavorite: input?.isFavorite ?? false,
+ deletedAt: input?.deletedAt ?? null,
};
}
diff --git a/src/core/models/notebook.ts b/src/core/models/notebook.ts
new file mode 100644
index 0000000..a4f5aec
--- /dev/null
+++ b/src/core/models/notebook.ts
@@ -0,0 +1,28 @@
+export interface Notebook {
+ id: string;
+ name: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface CreateNotebookInput {
+ id?: string;
+ name?: string;
+ createdAt?: string;
+ updatedAt?: string;
+}
+
+export function createEmptyNotebook(input?: CreateNotebookInput | string): Notebook {
+ const now = new Date().toISOString();
+ const name = typeof input === "string" ? input : input?.name;
+ const customId = typeof input === "object" ? input?.id : undefined;
+
+ return {
+ id: customId || (typeof crypto !== "undefined" && crypto.randomUUID
+ ? crypto.randomUUID()
+ : `nb-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`),
+ name: name?.trim() || "Untitled Notebook",
+ createdAt: typeof input === "object" && input?.createdAt ? input.createdAt : now,
+ updatedAt: typeof input === "object" && input?.updatedAt ? input.updatedAt : now,
+ };
+}
diff --git a/src/core/storage/indexeddb.ts b/src/core/storage/indexeddb.ts
index abb5f8f..d6dc344 100644
--- a/src/core/storage/indexeddb.ts
+++ b/src/core/storage/indexeddb.ts
@@ -1,10 +1,13 @@
import { Note } from "../models/note";
+import { Notebook } from "../models/notebook";
import { NoteRepository } from "./types";
-import { isNote } from "../validation/note";
+import { isNote, sanitizeNote } from "../validation/note";
+import { isNotebook, sanitizeNotebook } from "../validation/notebook";
const DB_NAME = "notesready-db";
-const DB_VERSION = 1;
+const DB_VERSION = 2;
const STORE_NAME = "notes";
+const NOTEBOOKS_STORE = "notebooks";
export class IndexedDBNoteRepository implements NoteRepository {
readonly isDurable = true;
@@ -21,11 +24,34 @@ export class IndexedDBNoteRepository implements NoteRepository {
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
+ const tx = (event.target as IDBOpenDBRequest).transaction;
+
+ // Notes store
+ let notesStore: IDBObjectStore;
if (!db.objectStoreNames.contains(STORE_NAME)) {
- const store = db.createObjectStore(STORE_NAME, { keyPath: "id" });
- store.createIndex("updatedAt", "updatedAt", { unique: false });
- store.createIndex("isFavorite", "isFavorite", { unique: false });
- store.createIndex("deletedAt", "deletedAt", { unique: false });
+ notesStore = db.createObjectStore(STORE_NAME, { keyPath: "id" });
+ notesStore.createIndex("updatedAt", "updatedAt", { unique: false });
+ notesStore.createIndex("isFavorite", "isFavorite", { unique: false });
+ notesStore.createIndex("deletedAt", "deletedAt", { unique: false });
+ } else if (tx) {
+ notesStore = tx.objectStore(STORE_NAME);
+ } else {
+ return;
+ }
+
+ // Version 2 additions for notes:
+ if (!notesStore.indexNames.contains("notebookId")) {
+ notesStore.createIndex("notebookId", "notebookId", { unique: false });
+ }
+ if (!notesStore.indexNames.contains("archivedAt")) {
+ notesStore.createIndex("archivedAt", "archivedAt", { unique: false });
+ }
+
+ // Version 2 additions for notebooks:
+ if (!db.objectStoreNames.contains(NOTEBOOKS_STORE)) {
+ const nbStore = db.createObjectStore(NOTEBOOKS_STORE, { keyPath: "id" });
+ nbStore.createIndex("updatedAt", "updatedAt", { unique: false });
+ nbStore.createIndex("name", "name", { unique: false });
}
};
@@ -50,11 +76,8 @@ export class IndexedDBNoteRepository implements NoteRepository {
req.onsuccess = () => {
const result = req.result;
- if (isNote(result)) {
- resolve(result);
- } else {
- resolve(null);
- }
+ const sanitized = sanitizeNote(result);
+ resolve(sanitized);
};
req.onerror = () => reject(req.error || new Error("Failed to read note from IndexedDB"));
});
@@ -91,8 +114,9 @@ export class IndexedDBNoteRepository implements NoteRepository {
req.onsuccess = () => {
const results: unknown[] = req.result || [];
const validNotes = results
- .filter(isNote)
- .filter((n) => n.deletedAt === null)
+ .map(sanitizeNote)
+ .filter((n): n is Note => n !== null)
+ .filter((n) => n.deletedAt === null && (!n.archivedAt || n.archivedAt === null))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
resolve(validNotes);
};
@@ -132,5 +156,242 @@ export class IndexedDBNoteRepository implements NoteRepository {
tx.onabort = () => reject(tx.error || new Error("IndexedDB transaction aborted while deleting note"));
});
}
-}
+ async restoreNote(id: string): Promise {
+ const existing = await this.getNote(id);
+ if (!existing) return;
+
+ const updated: Note = {
+ ...existing,
+ deletedAt: null,
+ updatedAt: new Date().toISOString(),
+ };
+ await this.saveNote(updated);
+ }
+
+ async listTrashedNotes(): Promise {
+ try {
+ const db = await this.getDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_NAME, "readonly");
+ const store = tx.objectStore(STORE_NAME);
+ const req = store.getAll();
+
+ req.onsuccess = () => {
+ const results: unknown[] = req.result || [];
+ const trashed = results
+ .map(sanitizeNote)
+ .filter((n): n is Note => n !== null)
+ .filter((n) => n.deletedAt !== null)
+ .sort((a, b) => (b.deletedAt ?? "").localeCompare(a.deletedAt ?? ""));
+ resolve(trashed);
+ };
+ req.onerror = () => reject(req.error || new Error("Failed to list trashed notes from IndexedDB"));
+ });
+ } catch {
+ return [];
+ }
+ }
+
+ async archiveNote(id: string): Promise {
+ const existing = await this.getNote(id);
+ if (!existing) return;
+
+ const updated: Note = {
+ ...existing,
+ archivedAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ };
+ await this.saveNote(updated);
+ }
+
+ async unarchiveNote(id: string): Promise {
+ const existing = await this.getNote(id);
+ if (!existing) return;
+
+ const updated: Note = {
+ ...existing,
+ archivedAt: null,
+ updatedAt: new Date().toISOString(),
+ };
+ await this.saveNote(updated);
+ }
+
+ async listArchivedNotes(): Promise {
+ try {
+ const db = await this.getDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_NAME, "readonly");
+ const store = tx.objectStore(STORE_NAME);
+ const req = store.getAll();
+
+ req.onsuccess = () => {
+ const results: unknown[] = req.result || [];
+ const archived = results
+ .map(sanitizeNote)
+ .filter((n): n is Note => n !== null)
+ .filter((n) => n.deletedAt === null && n.archivedAt !== null)
+ .sort((a, b) => (b.archivedAt ?? "").localeCompare(a.archivedAt ?? ""));
+ resolve(archived);
+ };
+ req.onerror = () => reject(req.error || new Error("Failed to list archived notes from IndexedDB"));
+ });
+ } catch {
+ return [];
+ }
+ }
+
+ async listNotesByNotebook(notebookId: string): Promise {
+ try {
+ const db = await this.getDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_NAME, "readonly");
+ const store = tx.objectStore(STORE_NAME);
+ const req = store.getAll();
+
+ req.onsuccess = () => {
+ const results: unknown[] = req.result || [];
+ const notebookNotes = results
+ .map(sanitizeNote)
+ .filter((n): n is Note => n !== null)
+ .filter(
+ (n) =>
+ n.deletedAt === null &&
+ (!n.archivedAt || n.archivedAt === null) &&
+ n.notebookId === notebookId
+ )
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
+ resolve(notebookNotes);
+ };
+ req.onerror = () => reject(req.error || new Error("Failed to list notebook notes from IndexedDB"));
+ });
+ } catch {
+ return [];
+ }
+ }
+
+ async listNotesByTag(tag: string): Promise {
+ const normalizedTag = tag.toLowerCase().trim();
+ try {
+ const db = await this.getDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_NAME, "readonly");
+ const store = tx.objectStore(STORE_NAME);
+ const req = store.getAll();
+
+ req.onsuccess = () => {
+ const results: unknown[] = req.result || [];
+ const taggedNotes = results
+ .map(sanitizeNote)
+ .filter((n): n is Note => n !== null)
+ .filter(
+ (n) =>
+ n.deletedAt === null &&
+ (!n.archivedAt || n.archivedAt === null) &&
+ n.tags &&
+ n.tags.some((t) => t.toLowerCase().trim() === normalizedTag)
+ )
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
+ resolve(taggedNotes);
+ };
+ req.onerror = () => reject(req.error || new Error("Failed to list tagged notes from IndexedDB"));
+ });
+ } catch {
+ return [];
+ }
+ }
+
+ // Notebook methods
+ async getNotebook(id: string): Promise {
+ try {
+ const db = await this.getDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(NOTEBOOKS_STORE, "readonly");
+ const store = tx.objectStore(NOTEBOOKS_STORE);
+ const req = store.get(id);
+
+ req.onsuccess = () => {
+ const result = req.result;
+ const sanitized = sanitizeNotebook(result);
+ resolve(sanitized);
+ };
+ req.onerror = () => reject(req.error || new Error("Failed to read notebook from IndexedDB"));
+ });
+ } catch {
+ return null;
+ }
+ }
+
+ async saveNotebook(notebook: Notebook): Promise {
+ if (!isNotebook(notebook)) {
+ throw new Error("Invalid notebook payload; cannot save to IndexedDB");
+ }
+
+ const db = await this.getDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(NOTEBOOKS_STORE, "readwrite");
+ const store = tx.objectStore(NOTEBOOKS_STORE);
+ store.put(notebook);
+
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error || new Error("IndexedDB transaction failed while saving notebook"));
+ tx.onabort = () => reject(tx.error || new Error("IndexedDB transaction aborted while saving notebook"));
+ });
+ }
+
+ async listNotebooks(): Promise {
+ try {
+ const db = await this.getDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(NOTEBOOKS_STORE, "readonly");
+ const store = tx.objectStore(NOTEBOOKS_STORE);
+ const req = store.getAll();
+
+ req.onsuccess = () => {
+ const results: unknown[] = req.result || [];
+ const valid = results
+ .map(sanitizeNotebook)
+ .filter((nb): nb is Notebook => nb !== null)
+ .sort((a, b) => a.name.localeCompare(b.name));
+ resolve(valid);
+ };
+ req.onerror = () => reject(req.error || new Error("Failed to list notebooks from IndexedDB"));
+ });
+ } catch {
+ return [];
+ }
+ }
+
+ async deleteNotebook(id: string): Promise {
+ const db = await this.getDB();
+
+ // In a single multi-store transaction, delete notebook and unassign affected notes
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction([NOTEBOOKS_STORE, STORE_NAME], "readwrite");
+ const nbStore = tx.objectStore(NOTEBOOKS_STORE);
+ const noteStore = tx.objectStore(STORE_NAME);
+
+ nbStore.delete(id);
+
+ // Unassign affected notes
+ const notesReq = noteStore.getAll();
+ notesReq.onsuccess = () => {
+ const notes: unknown[] = notesReq.result || [];
+ for (const raw of notes) {
+ if (isNote(raw) && raw.notebookId === id) {
+ const updated: Note = {
+ ...raw,
+ notebookId: null,
+ updatedAt: new Date().toISOString(),
+ };
+ noteStore.put(updated);
+ }
+ }
+ };
+
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error || new Error("IndexedDB transaction failed while deleting notebook"));
+ tx.onabort = () => reject(tx.error || new Error("IndexedDB transaction aborted while deleting notebook"));
+ });
+ }
+}
diff --git a/src/core/storage/memory.ts b/src/core/storage/memory.ts
index c491c12..31b90f7 100644
--- a/src/core/storage/memory.ts
+++ b/src/core/storage/memory.ts
@@ -1,11 +1,13 @@
import { Note } from "../models/note";
+import { Notebook } from "../models/notebook";
import { NoteRepository } from "./types";
import { isNote } from "../validation/note";
+import { isNotebook } from "../validation/notebook";
export class MemoryNoteRepository implements NoteRepository {
readonly isDurable: boolean = false;
private notes: Map = new Map();
-
+ private notebooks: Map = new Map();
async getNote(id: string): Promise {
const note = this.notes.get(id);
@@ -22,7 +24,7 @@ export class MemoryNoteRepository implements NoteRepository {
async listNotes(): Promise {
return Array.from(this.notes.values())
- .filter((n) => n.deletedAt === null)
+ .filter((n) => n.deletedAt === null && (!n.archivedAt || n.archivedAt === null))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
@@ -46,8 +48,112 @@ export class MemoryNoteRepository implements NoteRepository {
this.notes.delete(id);
}
+ async restoreNote(id: string): Promise {
+ const note = this.notes.get(id);
+ if (note) {
+ this.notes.set(id, {
+ ...note,
+ deletedAt: null,
+ updatedAt: new Date().toISOString(),
+ });
+ }
+ }
+
+ async listTrashedNotes(): Promise {
+ return Array.from(this.notes.values())
+ .filter((n) => n.deletedAt !== null)
+ .sort((a, b) => (b.deletedAt ?? "").localeCompare(a.deletedAt ?? ""));
+ }
+
+ async archiveNote(id: string): Promise {
+ const note = this.notes.get(id);
+ if (note) {
+ this.notes.set(id, {
+ ...note,
+ archivedAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ });
+ }
+ }
+
+ async unarchiveNote(id: string): Promise {
+ const note = this.notes.get(id);
+ if (note) {
+ this.notes.set(id, {
+ ...note,
+ archivedAt: null,
+ updatedAt: new Date().toISOString(),
+ });
+ }
+ }
+
+ async listArchivedNotes(): Promise {
+ return Array.from(this.notes.values())
+ .filter((n) => n.deletedAt === null && n.archivedAt !== null)
+ .sort((a, b) => (b.archivedAt ?? "").localeCompare(a.archivedAt ?? ""));
+ }
+
+ async listNotesByNotebook(notebookId: string): Promise {
+ return Array.from(this.notes.values())
+ .filter(
+ (n) =>
+ n.deletedAt === null &&
+ (!n.archivedAt || n.archivedAt === null) &&
+ n.notebookId === notebookId
+ )
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
+ }
+
+ async listNotesByTag(tag: string): Promise {
+ const normalizedTag = tag.toLowerCase().trim();
+ return Array.from(this.notes.values())
+ .filter(
+ (n) =>
+ n.deletedAt === null &&
+ (!n.archivedAt || n.archivedAt === null) &&
+ n.tags &&
+ n.tags.some((t) => t.toLowerCase().trim() === normalizedTag)
+ )
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
+ }
+
+ // Notebook methods
+ async getNotebook(id: string): Promise {
+ const nb = this.notebooks.get(id);
+ if (!nb) return null;
+ return { ...nb };
+ }
+
+ async saveNotebook(notebook: Notebook): Promise {
+ if (!isNotebook(notebook)) {
+ throw new Error("Invalid notebook data provided for storage");
+ }
+ this.notebooks.set(notebook.id, { ...notebook });
+ }
+
+ async listNotebooks(): Promise {
+ return Array.from(this.notebooks.values()).sort((a, b) =>
+ a.name.localeCompare(b.name)
+ );
+ }
+
+ async deleteNotebook(id: string): Promise {
+ this.notebooks.delete(id);
+ // Unassign notes belonging to this notebook (data safety decision: do not delete notes)
+ for (const [noteId, note] of this.notes.entries()) {
+ if (note.notebookId === id) {
+ this.notes.set(noteId, {
+ ...note,
+ notebookId: null,
+ updatedAt: new Date().toISOString(),
+ });
+ }
+ }
+ }
+
// Helper for test cleanup/inspection
clear(): void {
this.notes.clear();
+ this.notebooks.clear();
}
}
diff --git a/src/core/storage/types.ts b/src/core/storage/types.ts
index ff9a6f8..b578b4a 100644
--- a/src/core/storage/types.ts
+++ b/src/core/storage/types.ts
@@ -1,4 +1,5 @@
import { Note } from "../models/note";
+import { Notebook } from "../models/notebook";
export interface NoteRepository {
getNote(id: string): Promise;
@@ -7,6 +8,20 @@ export interface NoteRepository {
listRecentNotes(limit?: number): Promise;
deleteNote(id: string): Promise; // soft-delete
hardDeleteNote(id: string): Promise;
+ restoreNote(id: string): Promise;
+ listTrashedNotes(): Promise;
+ archiveNote(id: string): Promise;
+ unarchiveNote(id: string): Promise;
+ listArchivedNotes(): Promise;
+ listNotesByNotebook(notebookId: string): Promise;
+ listNotesByTag(tag: string): Promise;
+
+ // Notebook methods
+ getNotebook(id: string): Promise;
+ saveNotebook(notebook: Notebook): Promise;
+ listNotebooks(): Promise;
+ deleteNotebook(id: string): Promise;
+
readonly isDurable: boolean;
}
diff --git a/src/core/validation/note.ts b/src/core/validation/note.ts
index 86641f0..e94bb61 100644
--- a/src/core/validation/note.ts
+++ b/src/core/validation/note.ts
@@ -12,12 +12,29 @@ export function isNote(data: unknown): data is Note {
const isFormatValid =
n.format === undefined || VALID_FORMATS.has(n.format as NoteContentFormat);
+ const isNotebookIdValid =
+ n.notebookId === undefined ||
+ n.notebookId === null ||
+ (typeof n.notebookId === "string" && n.notebookId.trim().length > 0);
+
+ const isTagsValid =
+ n.tags === undefined ||
+ (Array.isArray(n.tags) && n.tags.every((t) => typeof t === "string"));
+
+ const isArchivedAtValid =
+ n.archivedAt === undefined ||
+ n.archivedAt === null ||
+ typeof n.archivedAt === "string";
+
return (
typeof n.id === "string" &&
n.id.trim().length > 0 &&
typeof n.title === "string" &&
typeof n.content === "string" &&
isFormatValid &&
+ isNotebookIdValid &&
+ isTagsValid &&
+ isArchivedAtValid &&
typeof n.createdAt === "string" &&
typeof n.updatedAt === "string" &&
typeof n.isFavorite === "boolean" &&
@@ -32,6 +49,9 @@ export function sanitizeNote(data: unknown): Note | null {
title: data.title,
content: data.content,
format: data.format ?? "plain-text-v1",
+ notebookId: data.notebookId ?? null,
+ tags: Array.isArray(data.tags) ? [...data.tags] : [],
+ archivedAt: data.archivedAt ?? null,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
isFavorite: data.isFavorite,
diff --git a/src/core/validation/notebook.ts b/src/core/validation/notebook.ts
new file mode 100644
index 0000000..6ac9179
--- /dev/null
+++ b/src/core/validation/notebook.ts
@@ -0,0 +1,25 @@
+import { Notebook } from "../models/notebook";
+
+export function isNotebook(data: unknown): data is Notebook {
+ if (!data || typeof data !== "object") return false;
+ const nb = data as Partial;
+
+ return (
+ typeof nb.id === "string" &&
+ nb.id.trim().length > 0 &&
+ typeof nb.name === "string" &&
+ nb.name.trim().length > 0 &&
+ typeof nb.createdAt === "string" &&
+ typeof nb.updatedAt === "string"
+ );
+}
+
+export function sanitizeNotebook(data: unknown): Notebook | null {
+ if (!isNotebook(data)) return null;
+ return {
+ id: data.id,
+ name: data.name.trim(),
+ createdAt: data.createdAt,
+ updatedAt: data.updatedAt,
+ };
+}
diff --git a/src/hooks/useNote.ts b/src/hooks/useNote.ts
index 8dd04af..15a353c 100644
--- a/src/hooks/useNote.ts
+++ b/src/hooks/useNote.ts
@@ -1,12 +1,14 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
+import { useRouter } from "next/navigation";
import { Note, NoteContentFormat } from "@/core/models/note";
import { getNoteRepository } from "@/core/storage";
import { useWorkspace, SaveStatus } from "@/context/WorkspaceContext";
import { HARD_SIZE_LIMIT_BYTES, getContentSizeBytes } from "@/core/utils/limits";
export function useNote(id: string) {
+ const router = useRouter();
const {
setSaveStatus,
setCurrentNoteTitle,
@@ -15,6 +17,10 @@ export function useNote(id: string) {
setRetrySaveHandler,
deleteNote: contextDelete,
toggleFavorite: contextToggle,
+ restoreNote: contextRestore,
+ permanentlyDeleteNote: contextHardDelete,
+ archiveNote: contextArchive,
+ unarchiveNote: contextUnarchive,
} = useWorkspace();
const [note, setNote] = useState(null);
@@ -23,6 +29,7 @@ export function useNote(id: string) {
const [format, setFormat] = useState("plain-text-v1");
const [isLoading, setIsLoading] = useState(true);
const [isNotFound, setIsNotFound] = useState(false);
+ const [isTrashed, setIsTrashed] = useState(false);
const [status, setLocalStatus] = useState("saved");
const saveTimeoutRef = useRef(null);
@@ -55,6 +62,9 @@ export function useNote(id: string) {
const targetId = activeIdRef.current;
if (!base || base.id !== targetId) return;
+ // Trashed notes cannot be edited or autosaved
+ if (base.deletedAt !== null) return;
+
// Check document hard size limit before saving
const totalBytes = getContentSizeBytes(currentContent);
if (totalBytes > HARD_SIZE_LIMIT_BYTES) {
@@ -157,7 +167,7 @@ export function useNote(id: string) {
committed.content !== latest.content ||
committed.format !== latest.format;
- if (stillHasUnsavedEdits && noteRef.current) {
+ if (stillHasUnsavedEdits && noteRef.current && noteRef.current.deletedAt === null) {
return await performSave(latest.title, latest.content, latest.format);
}
return;
@@ -172,7 +182,7 @@ export function useNote(id: string) {
committed.content !== c ||
committed.format !== f;
- if (hasUnsavedEdits && noteRef.current) {
+ if (hasUnsavedEdits && noteRef.current && noteRef.current.deletedAt === null) {
return await performSave(t, c, f);
}
}, [performSave]);
@@ -190,6 +200,7 @@ export function useNote(id: string) {
let isMounted = true;
setIsLoading(true);
setIsNotFound(false);
+ setIsTrashed(false);
// Flush any pending save from previous note before loading new note
flushPendingSave();
@@ -200,10 +211,26 @@ export function useNote(id: string) {
const loaded = await repo.getNote(id);
if (!isMounted) return;
- if (!loaded || loaded.deletedAt !== null) {
+ if (!loaded) {
setIsNotFound(true);
+ setIsTrashed(false);
setNote(null);
+ } else if (loaded.deletedAt !== null) {
+ // Trashed note: user can view in read-only and restore
+ setIsNotFound(false);
+ setIsTrashed(true);
+ setNote(loaded);
+ setTitle(loaded.title);
+ setContent(loaded.content);
+ const initialFormat = loaded.format ?? "plain-text-v1";
+ setFormat(initialFormat);
+ setCurrentNoteTitle(loaded.title);
+ setActiveNoteId(loaded.id);
+ setLocalStatus("saved");
+ setSaveStatus("saved");
} else {
+ setIsNotFound(false);
+ setIsTrashed(false);
setNote(loaded);
setTitle(loaded.title);
setContent(loaded.content);
@@ -301,10 +328,6 @@ export function useNote(id: string) {
}, [flushPendingSave]);
// Lifecycle flushing: visibilitychange & pagehide
- // Browser limitation note: Modern browsers may terminate asynchronous IndexedDB transactions if
- // the page process is killed abruptly during tab close before the event loop commits. The application
- // synchronously flushes pending debounced state and dispatches the write on pagehide/visibilitychange,
- // and never falsely reports data as saved before the transaction resolves.
useEffect(() => {
const handleVisibilityChange = () => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
@@ -340,6 +363,57 @@ export function useNote(id: string) {
}
}, [contextToggle, id, note]);
+ // Phase 4 restore and permanent delete actions
+ const handleRestoreNote = useCallback(async () => {
+ await contextRestore(id);
+ setIsTrashed(false);
+ const repo = getNoteRepository();
+ const reloaded = await repo.getNote(id);
+ if (reloaded) {
+ setNote(reloaded);
+ }
+ }, [contextRestore, id]);
+
+ const handlePermanentDelete = useCallback(async () => {
+ await contextHardDelete(id);
+ router.push("/app/trash");
+ }, [contextHardDelete, id, router]);
+
+ const handleToggleArchive = useCallback(async () => {
+ if (!note) return;
+ if (note.archivedAt) {
+ await contextUnarchive(id);
+ setNote({ ...note, archivedAt: null });
+ } else {
+ await contextArchive(id);
+ setNote({ ...note, archivedAt: new Date().toISOString() });
+ }
+ }, [contextArchive, contextUnarchive, id, note]);
+
+ const setNotebookId = useCallback(
+ async (nbId: string | null) => {
+ if (!note) return;
+ const repo = getNoteRepository();
+ const updated = { ...note, notebookId: nbId, updatedAt: new Date().toISOString() };
+ await repo.saveNote(updated);
+ setNote(updated);
+ updateNoteInMemory(updated);
+ },
+ [note, updateNoteInMemory]
+ );
+
+ const setTags = useCallback(
+ async (newTags: string[]) => {
+ if (!note) return;
+ const repo = getNoteRepository();
+ const updated = { ...note, tags: newTags, updatedAt: new Date().toISOString() };
+ await repo.saveNote(updated);
+ setNote(updated);
+ updateNoteInMemory(updated);
+ },
+ [note, updateNoteInMemory]
+ );
+
return {
note,
title,
@@ -347,6 +421,7 @@ export function useNote(id: string) {
format,
isLoading,
isNotFound,
+ isTrashed,
status,
retrySave,
flushPendingSave,
@@ -355,5 +430,10 @@ export function useNote(id: string) {
handleFallbackContentChange,
deleteNote: deleteThisNote,
toggleFavorite,
+ restoreNote: handleRestoreNote,
+ permanentlyDeleteNote: handlePermanentDelete,
+ toggleArchive: handleToggleArchive,
+ setNotebookId,
+ setTags,
};
}
diff --git a/tasks.md b/tasks.md
index 557bbae..2f185e2 100644
--- a/tasks.md
+++ b/tasks.md
@@ -50,11 +50,17 @@
- [x] Editor error boundary with plain-textarea fallback
- [x] Zero note content in logs verification
-## Phase 4: Persistence & Organization
-- [ ] PostgreSQL schema & migrations
-- [ ] Notebooks, folders, and nested organization
-- [ ] Tags, favorites, archive, and trash with recovery
-- [ ] Resilient autosave with offline fallback
+## Phase 4: Persistence & Organization — IMPLEMENTED & AUDITED
+- [x] Database Architecture Decision (ADR-010: Native IndexedDB v2 local-first; PostgreSQL deferred to Phase 5/6)
+- [x] IndexedDB v2 schema migration (`notesready-db`, `notebookId` and `archivedAt` indexes on notes, dedicated `notebooks` object store)
+- [x] Notebooks domain entity, validation runtime guards, creation, renaming, and safe deletion (unassigns notes without deleting)
+- [x] Dynamic notebook view (`/app/notebooks/[id]`) with filtered notes and inline renaming
+- [x] Tags assignment, removal, and workspace-wide tag filtering on `/app/notes`
+- [x] Archive and unarchive lifecycle with dedicated `/app/archive` view
+- [x] Trash & recovery lifecycle (`/app/trash`): restore, permanent delete with confirmation, empty trash
+- [x] Trashed note protection: top warning banner, read-only controls, disabled autosave, and direct restore
+- [x] Sidebar integration: active Trash & Archive links with counts, dynamic notebooks with inline "+ Notebook" creation
+- [x] Full quality gates: 92/92 unit/integration tests passing (including IndexedDB v1-to-v2 upgrade suite), 0 type errors, 0 ESLint errors, clean production build
## Phase 5: Real-Time Collaboration
- [ ] Standalone Hocuspocus WebSocket server
diff --git a/tests/unit/core/note.test.ts b/tests/unit/core/note.test.ts
index a199a85..a1783ec 100644
--- a/tests/unit/core/note.test.ts
+++ b/tests/unit/core/note.test.ts
@@ -107,5 +107,40 @@ describe("Note Domain Model & Validation", () => {
};
const sanitizedLegacy = sanitizeNote(rawLegacy);
expect(sanitizedLegacy?.format).toBe("plain-text-v1");
+ expect(sanitizedLegacy?.notebookId).toBeNull();
+ expect(sanitizedLegacy?.tags).toEqual([]);
+ expect(sanitizedLegacy?.archivedAt).toBeNull();
+ });
+
+ it("validates and sanitizes Phase 4 organization fields", () => {
+ const note = createEmptyNote({
+ title: "Organized Note",
+ notebookId: "nb-1",
+ tags: ["design", "architecture"],
+ });
+
+ expect(note.notebookId).toBe("nb-1");
+ expect(note.tags).toEqual(["design", "architecture"]);
+ expect(note.archivedAt).toBeNull();
+ expect(isNote(note)).toBe(true);
+
+ const archivedNote = {
+ ...note,
+ archivedAt: "2026-10-09T10:00:00.000Z",
+ };
+ expect(isNote(archivedNote)).toBe(true);
+
+ // Invalid tag type rejected
+ expect(isNote({ ...note, tags: "not-an-array" })).toBe(false);
+ expect(isNote({ ...note, tags: [123] })).toBe(false);
+
+ // Invalid notebookId rejected
+ expect(isNote({ ...note, notebookId: 123 })).toBe(false);
+ expect(isNote({ ...note, notebookId: " " })).toBe(false);
+
+ const sanitized = sanitizeNote(archivedNote);
+ expect(sanitized?.notebookId).toBe("nb-1");
+ expect(sanitized?.tags).toEqual(["design", "architecture"]);
+ expect(sanitized?.archivedAt).toBe("2026-10-09T10:00:00.000Z");
});
});
diff --git a/tests/unit/core/notebook.test.ts b/tests/unit/core/notebook.test.ts
new file mode 100644
index 0000000..db0e1fe
--- /dev/null
+++ b/tests/unit/core/notebook.test.ts
@@ -0,0 +1,49 @@
+import { describe, it, expect } from "vitest";
+import { createEmptyNotebook } from "@/core/models/notebook";
+import { isNotebook, sanitizeNotebook } from "@/core/validation/notebook";
+
+describe("Notebook Domain Model & Validation", () => {
+ it("creates a valid empty notebook with default name and timestamps", () => {
+ const notebook = createEmptyNotebook();
+
+ expect(notebook.id).toBeDefined();
+ expect(typeof notebook.id).toBe("string");
+ expect(notebook.name).toBe("Untitled Notebook");
+ expect(new Date(notebook.createdAt).getTime()).not.toBeNaN();
+ expect(new Date(notebook.updatedAt).getTime()).not.toBeNaN();
+ });
+
+ it("creates a notebook with custom trimmed name", () => {
+ const notebook = createEmptyNotebook({ name: " Engineering Specs " });
+ expect(notebook.name).toBe("Engineering Specs");
+ });
+
+ it("validates notebooks using isNotebook type guard", () => {
+ const notebook = createEmptyNotebook({ name: "Research" });
+ expect(isNotebook(notebook)).toBe(true);
+
+ // Invalid objects
+ expect(isNotebook(null)).toBe(false);
+ expect(isNotebook(undefined)).toBe(false);
+ expect(isNotebook("not a notebook")).toBe(false);
+ expect(isNotebook({ id: "1" })).toBe(false);
+ expect(isNotebook({ id: "1", name: "", createdAt: "now", updatedAt: "now" })).toBe(false);
+ expect(isNotebook({ id: "1", name: " ", createdAt: "now", updatedAt: "now" })).toBe(false);
+ });
+
+ it("sanitizes valid notebook objects cleanly", () => {
+ const raw = {
+ id: "nb-123",
+ name: " Product Ideas ",
+ createdAt: "2026-10-09T10:00:00.000Z",
+ updatedAt: "2026-10-09T10:05:00.000Z",
+ extraField: "ignored",
+ };
+
+ const sanitized = sanitizeNotebook(raw);
+ expect(sanitized).not.toBeNull();
+ expect(sanitized?.id).toBe("nb-123");
+ expect(sanitized?.name).toBe("Product Ideas");
+ expect((sanitized as unknown as Record).extraField).toBeUndefined();
+ });
+});
diff --git a/tests/unit/storage/migration.test.ts b/tests/unit/storage/migration.test.ts
new file mode 100644
index 0000000..9b92e11
--- /dev/null
+++ b/tests/unit/storage/migration.test.ts
@@ -0,0 +1,258 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import "fake-indexeddb/auto";
+import { IDBFactory } from "fake-indexeddb";
+import { IndexedDBNoteRepository } from "@/core/storage/indexeddb";
+import { createEmptyNotebook } from "@/core/models/notebook";
+
+describe("IndexedDB v1 to v2 Migration & Persistence Integrity", () => {
+ beforeEach(() => {
+ // Provide a fresh, isolated IndexedDB instance for each test run
+ window.indexedDB = new IDBFactory();
+ });
+
+ it("upgrades a populated v1 database to v2, preserves legacy data, applies default invariants, and supports Phase 4 operations using genuine IndexedDB", async () => {
+ // -------------------------------------------------------------
+ // STAGE 1: Seed a real, populated v1 database exactly as in Phase 2/3
+ // -------------------------------------------------------------
+ await new Promise((resolve, reject) => {
+ const v1Req = window.indexedDB.open("notesready-db", 1);
+
+ v1Req.onupgradeneeded = (event) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+ // Version 1 schema (Phase 2 & 3):
+ // Only the "notes" store exists, with updatedAt, isFavorite, and deletedAt indexes.
+ const notesStore = db.createObjectStore("notes", { keyPath: "id" });
+ notesStore.createIndex("updatedAt", "updatedAt", { unique: false });
+ notesStore.createIndex("isFavorite", "isFavorite", { unique: false });
+ notesStore.createIndex("deletedAt", "deletedAt", { unique: false });
+ // NOTE: "notebookId" and "archivedAt" indexes do NOT exist in v1.
+ // NOTE: "notebooks" store does NOT exist in v1.
+ };
+
+ v1Req.onsuccess = (event) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+ const tx = db.transaction("notes", "readwrite");
+ const store = tx.objectStore("notes");
+
+ // Seed 1: Legacy plain-text note without Phase 4 fields (Phase 2 legacy)
+ store.put({
+ id: "v1-legacy-plain-1",
+ title: "Architecture Decisions v1",
+ content: "Original plain text body recorded in Phase 2.",
+ createdAt: "2026-10-01T10:00:00.000Z",
+ updatedAt: "2026-10-01T10:00:00.000Z",
+ isFavorite: false,
+ deletedAt: null,
+ // Intentionally missing Phase 4 fields: format, notebookId, tags, archivedAt
+ });
+
+ // Seed 2: Phase 3 rich-text note with tiptap-json-v1, without Phase 4 fields
+ store.put({
+ id: "v1-phase3-tiptap-2",
+ title: "Rich Editor RFC v1",
+ content: '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Rich text notes."}]}]}',
+ format: "tiptap-json-v1",
+ createdAt: "2026-10-02T12:00:00.000Z",
+ updatedAt: "2026-10-02T12:00:00.000Z",
+ isFavorite: true,
+ deletedAt: null,
+ // Intentionally missing Phase 4 fields: notebookId, tags, archivedAt
+ });
+
+ // Seed 3: Legacy soft-deleted note
+ store.put({
+ id: "v1-legacy-trashed-3",
+ title: "Obsolete Draft",
+ content: "Draft to discard.",
+ createdAt: "2026-10-03T08:00:00.000Z",
+ updatedAt: "2026-10-03T09:00:00.000Z",
+ isFavorite: false,
+ deletedAt: "2026-10-03T09:00:00.000Z",
+ // Intentionally missing Phase 4 fields: format, notebookId, tags, archivedAt
+ });
+
+ tx.oncomplete = () => {
+ db.close();
+ resolve();
+ };
+ tx.onerror = () => reject(tx.error);
+ tx.onabort = () => reject(tx.error);
+ };
+
+ v1Req.onerror = () => reject(v1Req.error);
+ });
+
+ // -------------------------------------------------------------
+ // STAGE 2: Upgrade through actual IndexedDBNoteRepository (DB_VERSION = 2)
+ // -------------------------------------------------------------
+ const repo = new IndexedDBNoteRepository();
+ expect(repo.isDurable).toBe(true);
+
+ // Initial read triggers the repository's getDB() and onupgradeneeded migration
+ const readNote1 = await repo.getNote("v1-legacy-plain-1");
+ expect(readNote1).not.toBeNull();
+
+ // -------------------------------------------------------------
+ // STAGE 3: Verify Schema Integrity (Indexes & Stores in Genuine DB)
+ // -------------------------------------------------------------
+ await new Promise((resolve, reject) => {
+ const inspectReq = window.indexedDB.open("notesready-db");
+ inspectReq.onsuccess = () => {
+ const db = inspectReq.result;
+ expect(db.version).toBe(2);
+
+ // Verify stores
+ expect(db.objectStoreNames.contains("notes")).toBe(true);
+ expect(db.objectStoreNames.contains("notebooks")).toBe(true);
+
+ const tx = db.transaction(["notes", "notebooks"], "readonly");
+ const notesStore = tx.objectStore("notes");
+ const notebooksStore = tx.objectStore("notebooks");
+
+ // Verify indexes on 'notes' store
+ expect(notesStore.indexNames.contains("updatedAt")).toBe(true);
+ expect(notesStore.indexNames.contains("isFavorite")).toBe(true);
+ expect(notesStore.indexNames.contains("deletedAt")).toBe(true);
+ expect(notesStore.indexNames.contains("notebookId")).toBe(true);
+ expect(notesStore.indexNames.contains("archivedAt")).toBe(true);
+
+ // Verify indexes on 'notebooks' store
+ expect(notebooksStore.indexNames.contains("updatedAt")).toBe(true);
+ expect(notebooksStore.indexNames.contains("name")).toBe(true);
+
+ db.close();
+ resolve();
+ };
+ inspectReq.onerror = () => reject(inspectReq.error);
+ });
+
+ // -------------------------------------------------------------
+ // STAGE 4: Verify Data Preservation & Legacy Defaults
+ // -------------------------------------------------------------
+ // Note 1: Legacy plain text
+ expect(readNote1?.id).toBe("v1-legacy-plain-1");
+ expect(readNote1?.title).toBe("Architecture Decisions v1");
+ expect(readNote1?.content).toBe("Original plain text body recorded in Phase 2.");
+ expect(readNote1?.createdAt).toBe("2026-10-01T10:00:00.000Z");
+ expect(readNote1?.updatedAt).toBe("2026-10-01T10:00:00.000Z");
+ expect(readNote1?.isFavorite).toBe(false);
+ expect(readNote1?.deletedAt).toBeNull();
+ // Legacy defaults applied:
+ expect(readNote1?.format).toBe("plain-text-v1");
+ expect(readNote1?.notebookId).toBeNull();
+ expect(readNote1?.tags).toEqual([]);
+ expect(readNote1?.archivedAt).toBeNull();
+
+ // Note 2: Phase 3 Tiptap rich text
+ const readNote2 = await repo.getNote("v1-phase3-tiptap-2");
+ expect(readNote2).not.toBeNull();
+ expect(readNote2?.id).toBe("v1-phase3-tiptap-2");
+ expect(readNote2?.title).toBe("Rich Editor RFC v1");
+ expect(readNote2?.content).toBe(
+ '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Rich text notes."}]}]}'
+ );
+ expect(readNote2?.format).toBe("tiptap-json-v1");
+ expect(readNote2?.createdAt).toBe("2026-10-02T12:00:00.000Z");
+ expect(readNote2?.updatedAt).toBe("2026-10-02T12:00:00.000Z");
+ expect(readNote2?.isFavorite).toBe(true);
+ expect(readNote2?.deletedAt).toBeNull();
+ // Legacy defaults applied:
+ expect(readNote2?.notebookId).toBeNull();
+ expect(readNote2?.tags).toEqual([]);
+ expect(readNote2?.archivedAt).toBeNull();
+
+ // Note 3: Soft-deleted note
+ const readNote3 = await repo.getNote("v1-legacy-trashed-3");
+ expect(readNote3).not.toBeNull();
+ expect(readNote3?.id).toBe("v1-legacy-trashed-3");
+ expect(readNote3?.title).toBe("Obsolete Draft");
+ expect(readNote3?.content).toBe("Draft to discard.");
+ expect(readNote3?.deletedAt).toBe("2026-10-03T09:00:00.000Z");
+ // Legacy defaults applied:
+ expect(readNote3?.format).toBe("plain-text-v1");
+ expect(readNote3?.notebookId).toBeNull();
+ expect(readNote3?.tags).toEqual([]);
+ expect(readNote3?.archivedAt).toBeNull();
+
+ // -------------------------------------------------------------
+ // STAGE 5: Verify Query Views (Active vs. Trashed)
+ // -------------------------------------------------------------
+ // Active notes list excludes soft-deleted notes and orders descending by updatedAt
+ const activeNotes = await repo.listNotes();
+ expect(activeNotes.length).toBe(2);
+ expect(activeNotes[0].id).toBe("v1-phase3-tiptap-2"); // Newer first (2026-10-02)
+ expect(activeNotes[1].id).toBe("v1-legacy-plain-1"); // Older second (2026-10-01)
+
+ // Trashed notes query retrieves Note 3 with defaults applied
+ const trashedNotes = await repo.listTrashedNotes();
+ expect(trashedNotes.length).toBe(1);
+ expect(trashedNotes[0].id).toBe("v1-legacy-trashed-3");
+ expect(trashedNotes[0].deletedAt).toBe("2026-10-03T09:00:00.000Z");
+ expect(trashedNotes[0].notebookId).toBeNull();
+ expect(trashedNotes[0].tags).toEqual([]);
+
+ // -------------------------------------------------------------
+ // STAGE 6: Verify Phase 4 Operations on Upgraded Schema
+ // -------------------------------------------------------------
+ // 1. Create and persist a new Notebook in the newly created 'notebooks' store
+ const notebook = createEmptyNotebook({ id: "nb-q4-specs", name: "Q4 Engineering Specs" });
+ await repo.saveNotebook(notebook);
+
+ const loadedNotebook = await repo.getNotebook("nb-q4-specs");
+ expect(loadedNotebook).not.toBeNull();
+ expect(loadedNotebook?.name).toBe("Q4 Engineering Specs");
+
+ const notebookList = await repo.listNotebooks();
+ expect(notebookList.length).toBe(1);
+ expect(notebookList[0].id).toBe("nb-q4-specs");
+
+ // 2. Associate a legacy note with the notebook and assign tags
+ const updatedNote1 = {
+ ...readNote1!,
+ notebookId: "nb-q4-specs",
+ tags: ["architecture", "rfc"],
+ };
+ await repo.saveNote(updatedNote1);
+
+ // Verify filtered query by notebook
+ const notebookNotes = await repo.listNotesByNotebook("nb-q4-specs");
+ expect(notebookNotes.length).toBe(1);
+ expect(notebookNotes[0].id).toBe("v1-legacy-plain-1");
+ expect(notebookNotes[0].tags).toEqual(["architecture", "rfc"]);
+
+ // Verify filtered query by tag
+ const taggedNotes = await repo.listNotesByTag("architecture");
+ expect(taggedNotes.length).toBe(1);
+ expect(taggedNotes[0].id).toBe("v1-legacy-plain-1");
+
+ // 3. Test Archive and Recovery on pre-existing Phase 3 note
+ await repo.archiveNote("v1-phase3-tiptap-2");
+ const activeAfterArchive = await repo.listNotes();
+ expect(activeAfterArchive.length).toBe(1);
+ expect(activeAfterArchive[0].id).toBe("v1-legacy-plain-1");
+
+ const archivedNotes = await repo.listArchivedNotes();
+ expect(archivedNotes.length).toBe(1);
+ expect(archivedNotes[0].id).toBe("v1-phase3-tiptap-2");
+ expect(archivedNotes[0].archivedAt).not.toBeNull();
+
+ // -------------------------------------------------------------
+ // STAGE 7: Verify Reopening Without Migration Re-Run
+ // -------------------------------------------------------------
+ // Instantiate a fresh repository connection (simulating app reload)
+ const repoReopened = new IndexedDBNoteRepository();
+ const reloadedNotebooks = await repoReopened.listNotebooks();
+ expect(reloadedNotebooks.length).toBe(1);
+ expect(reloadedNotebooks[0].name).toBe("Q4 Engineering Specs");
+
+ const reloadedNote1 = await repoReopened.getNote("v1-legacy-plain-1");
+ expect(reloadedNote1?.notebookId).toBe("nb-q4-specs");
+ expect(reloadedNote1?.tags).toEqual(["architecture", "rfc"]);
+ expect(reloadedNote1?.content).toBe("Original plain text body recorded in Phase 2.");
+ expect(reloadedNote1?.format).toBe("plain-text-v1");
+
+ const reloadedArchived = await repoReopened.listArchivedNotes();
+ expect(reloadedArchived.length).toBe(1);
+ expect(reloadedArchived[0].id).toBe("v1-phase3-tiptap-2");
+ });
+});
diff --git a/tests/unit/storage/storage.test.ts b/tests/unit/storage/storage.test.ts
index 10f8ef3..ede189d 100644
--- a/tests/unit/storage/storage.test.ts
+++ b/tests/unit/storage/storage.test.ts
@@ -3,6 +3,7 @@ import { MemoryNoteRepository } from "@/core/storage/memory";
import { IndexedDBNoteRepository } from "@/core/storage/indexeddb";
import { getNoteRepository, setNoteRepository, getStorageError } from "@/core/storage";
import { createEmptyNote } from "@/core/models/note";
+import { createEmptyNotebook } from "@/core/models/notebook";
describe("MemoryNoteRepository", () => {
let repo: MemoryNoteRepository;
@@ -71,6 +72,102 @@ describe("MemoryNoteRepository", () => {
expect(recent.length).toBe(3);
expect(recent[0].title).toBe("Note 4");
});
+
+ it("lists trashed notes and restores a soft-deleted note", async () => {
+ const note1 = createEmptyNote({ title: "Note to Keep" });
+ const note2 = createEmptyNote({ title: "Note to Trash" });
+ await repo.saveNote(note1);
+ await repo.saveNote(note2);
+
+ await repo.deleteNote(note2.id);
+
+ const active = await repo.listNotes();
+ expect(active.length).toBe(1);
+ expect(active[0].id).toBe(note1.id);
+
+ const trashed = await repo.listTrashedNotes();
+ expect(trashed.length).toBe(1);
+ expect(trashed[0].id).toBe(note2.id);
+
+ // Restore note2
+ await repo.restoreNote(note2.id);
+ const restoredActive = await repo.listNotes();
+ expect(restoredActive.length).toBe(2);
+
+ const trashedAfter = await repo.listTrashedNotes();
+ expect(trashedAfter.length).toBe(0);
+ });
+
+ it("archives notes, excludes them from active list, and unarchives them", async () => {
+ const note = createEmptyNote({ title: "Reference Document" });
+ await repo.saveNote(note);
+
+ await repo.archiveNote(note.id);
+
+ // Excluded from standard active list
+ const active = await repo.listNotes();
+ expect(active.length).toBe(0);
+
+ // Present in archive list
+ const archived = await repo.listArchivedNotes();
+ expect(archived.length).toBe(1);
+ expect(archived[0].id).toBe(note.id);
+ expect(archived[0].archivedAt).not.toBeNull();
+
+ // Unarchive
+ await repo.unarchiveNote(note.id);
+ const activeAfter = await repo.listNotes();
+ expect(activeAfter.length).toBe(1);
+ expect(activeAfter[0].archivedAt).toBeNull();
+ });
+
+ it("filters notes by notebook and tag", async () => {
+ const note1 = createEmptyNote({
+ title: "Design Note",
+ notebookId: "nb-design",
+ tags: ["ui", "tokens"],
+ });
+ const note2 = createEmptyNote({
+ title: "Backend Note",
+ notebookId: "nb-backend",
+ tags: ["db", "tokens"],
+ });
+ await repo.saveNote(note1);
+ await repo.saveNote(note2);
+
+ const designNotes = await repo.listNotesByNotebook("nb-design");
+ expect(designNotes.length).toBe(1);
+ expect(designNotes[0].id).toBe(note1.id);
+
+ const tokenNotes = await repo.listNotesByTag("tokens");
+ expect(tokenNotes.length).toBe(2);
+
+ const uiNotes = await repo.listNotesByTag("UI"); // case-insensitive
+ expect(uiNotes.length).toBe(1);
+ expect(uiNotes[0].id).toBe(note1.id);
+ });
+
+ it("handles notebook lifecycle and unassigns affected notes on deletion", async () => {
+ const nb = createEmptyNotebook({ name: "Work Projects" });
+ await repo.saveNotebook(nb);
+
+ const list = await repo.listNotebooks();
+ expect(list.length).toBe(1);
+ expect(list[0].name).toBe("Work Projects");
+
+ const note = createEmptyNote({ title: "Project Plan", notebookId: nb.id });
+ await repo.saveNote(note);
+
+ // Delete notebook: notebook deleted, note preserved with notebookId = null
+ await repo.deleteNotebook(nb.id);
+
+ const listAfter = await repo.listNotebooks();
+ expect(listAfter.length).toBe(0);
+
+ const noteAfter = await repo.getNote(note.id);
+ expect(noteAfter).not.toBeNull();
+ expect(noteAfter?.notebookId).toBeNull();
+ });
});
describe("Storage Honesty & IndexedDB Durability", () => {
@@ -147,8 +244,10 @@ describe("Storage Honesty & IndexedDB Durability", () => {
resolved = true;
});
- // Wait for DB open and store.put
- await new Promise((r) => setTimeout(r, 10));
+ // Wait until transaction and complete handler are initialized
+ for (let i = 0; i < 100 && !completeHandler; i++) {
+ await new Promise((r) => setTimeout(r, 10));
+ }
expect(putCalled).toBe(true);
// Promise should NOT be resolved before transaction oncomplete
expect(resolved).toBe(false);
@@ -205,7 +304,11 @@ describe("Storage Honesty & IndexedDB Durability", () => {
const savePromise = repo.saveNote(note);
- await new Promise((r) => setTimeout(r, 10));
+ // Wait until transaction and abort handler are initialized
+ for (let i = 0; i < 100 && !abortHandler; i++) {
+ await new Promise((r) => setTimeout(r, 10));
+ }
+ expect(abortHandler).not.toBeNull();
if (abortHandler) {
(abortHandler as () => void)();
}
diff --git a/tests/unit/workspace/archive.test.tsx b/tests/unit/workspace/archive.test.tsx
new file mode 100644
index 0000000..01326e0
--- /dev/null
+++ b/tests/unit/workspace/archive.test.tsx
@@ -0,0 +1,73 @@
+import React from "react";
+import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import ArchivePage from "@/app/(workspace)/app/archive/page";
+import { WorkspaceProvider } from "@/context/WorkspaceContext";
+import { MemoryNoteRepository } from "@/core/storage/memory";
+import { setNoteRepository } from "@/core/storage";
+import { createEmptyNote } from "@/core/models/note";
+
+// Mock router and navigation
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ prefetch: vi.fn(),
+ }),
+ usePathname: () => "/app/archive",
+ useParams: () => ({}),
+}));
+
+describe("Archive & Unarchive Lifecycle", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ setNoteRepository(null);
+ });
+
+ it("renders empty state when no notes are archived", async () => {
+ const repo = new MemoryNoteRepository();
+ setNoteRepository(repo);
+
+ render(
+
+
+
+ );
+
+ expect(await screen.findByText(/archive is empty/i)).toBeDefined();
+ });
+
+ it("displays archived notes and unarchives them", async () => {
+ const repo = new MemoryNoteRepository();
+ const activeNote = createEmptyNote({ title: "Live Note" });
+ const archivedNote = createEmptyNote({
+ title: "Archived Project",
+ archivedAt: new Date().toISOString(),
+ });
+ await repo.saveNote(activeNote);
+ await repo.saveNote(archivedNote);
+ setNoteRepository(repo);
+
+ render(
+
+
+
+ );
+
+ expect(await screen.findByText("Archived Project")).toBeDefined();
+ expect(screen.queryByText("Live Note")).toBeNull();
+
+ const unarchiveBtn = screen.getByRole("button", { name: /unarchive note archived project/i });
+ fireEvent.click(unarchiveBtn);
+
+ await waitFor(() => {
+ expect(screen.queryByText("Archived Project")).toBeNull();
+ });
+
+ const refreshed = await repo.getNote(archivedNote.id);
+ expect(refreshed?.archivedAt).toBeNull();
+ });
+});
diff --git a/tests/unit/workspace/notebooks.test.tsx b/tests/unit/workspace/notebooks.test.tsx
new file mode 100644
index 0000000..ba9f570
--- /dev/null
+++ b/tests/unit/workspace/notebooks.test.tsx
@@ -0,0 +1,123 @@
+import React from "react";
+import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import NotebookDetailPage from "@/app/(workspace)/app/notebooks/[id]/page";
+import { WorkspaceProvider } from "@/context/WorkspaceContext";
+import { MemoryNoteRepository } from "@/core/storage/memory";
+import { setNoteRepository } from "@/core/storage";
+import { createEmptyNote } from "@/core/models/note";
+import { createEmptyNotebook } from "@/core/models/notebook";
+
+let mockParamsId = "test-nb-1";
+
+// Mock router and navigation
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ prefetch: vi.fn(),
+ }),
+ usePathname: () => `/app/notebooks/${mockParamsId}`,
+ useParams: () => ({ id: mockParamsId }),
+}));
+
+describe("Notebooks Organization & Hierarchy", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ setNoteRepository(null);
+ });
+
+ it("renders notebook detail and filtered notes for the notebook", async () => {
+ const repo = new MemoryNoteRepository();
+ const nb = createEmptyNotebook({ id: "test-nb-1", name: "Research Project" });
+ const noteInNb = createEmptyNote({
+ title: "Field Study Notes",
+ notebookId: "test-nb-1",
+ });
+ const noteOutside = createEmptyNote({
+ title: "Random Thought",
+ notebookId: null,
+ });
+ await repo.saveNotebook(nb);
+ await repo.saveNote(noteInNb);
+ await repo.saveNote(noteOutside);
+ setNoteRepository(repo);
+
+ render(
+
+
+
+ );
+
+ expect(await screen.findByText("Research Project")).toBeDefined();
+ expect(screen.getByText("Field Study Notes")).toBeDefined();
+ expect(screen.queryByText("Random Thought")).toBeNull();
+ });
+
+ it("allows renaming a notebook", async () => {
+ const repo = new MemoryNoteRepository();
+ const nb = createEmptyNotebook({ id: "test-nb-1", name: "Initial Title" });
+ await repo.saveNotebook(nb);
+ setNoteRepository(repo);
+
+ render(
+
+
+
+ );
+
+ const renameBtn = await screen.findByRole("button", { name: /rename notebook/i });
+ fireEvent.click(renameBtn);
+
+ const input = screen.getByRole("textbox") as HTMLInputElement;
+ fireEvent.change(input, { target: { value: "Updated Title" } });
+
+ const saveBtn = screen.getByRole("button", { name: /save name/i });
+ fireEvent.click(saveBtn);
+
+ await waitFor(() => {
+ expect(screen.getByText("Updated Title")).toBeDefined();
+ });
+
+ const updated = await repo.getNotebook("test-nb-1");
+ expect(updated?.name).toBe("Updated Title");
+ });
+
+ it("deleting a notebook unassigns notes safely without deleting them", async () => {
+ const repo = new MemoryNoteRepository();
+ const nb = createEmptyNotebook({ id: "test-nb-1", name: "To Delete" });
+ const noteInNb = createEmptyNote({
+ title: "Safe Note",
+ notebookId: "test-nb-1",
+ });
+ await repo.saveNotebook(nb);
+ await repo.saveNote(noteInNb);
+ setNoteRepository(repo);
+
+ render(
+
+
+
+ );
+
+ const deleteBtn = await screen.findByRole("button", { name: /delete notebook/i });
+ fireEvent.click(deleteBtn);
+
+ const confirmBtn = await screen.findByRole("button", { name: /confirm delete notebook/i });
+ fireEvent.click(confirmBtn);
+
+ await waitFor(async () => {
+ const reloadedNb = await repo.getNotebook("test-nb-1");
+ expect(reloadedNb).toBeNull();
+ });
+
+ // Note must still exist with notebookId set to null
+ const reloadedNote = await repo.getNote(noteInNb.id);
+ expect(reloadedNote).not.toBeNull();
+ expect(reloadedNote?.title).toBe("Safe Note");
+ expect(reloadedNote?.notebookId).toBeNull();
+ });
+});
diff --git a/tests/unit/workspace/sidebar.test.tsx b/tests/unit/workspace/sidebar.test.tsx
index f2867d9..c6737e1 100644
--- a/tests/unit/workspace/sidebar.test.tsx
+++ b/tests/unit/workspace/sidebar.test.tsx
@@ -6,6 +6,7 @@ import { WorkspaceProvider } from "@/context/WorkspaceContext";
import { MemoryNoteRepository } from "@/core/storage/memory";
import { setNoteRepository } from "@/core/storage";
import { createEmptyNote } from "@/core/models/note";
+import { createEmptyNotebook } from "@/core/models/notebook";
// Mock router and navigation
vi.mock("next/navigation", () => ({
@@ -53,9 +54,15 @@ describe("AppSidebar Navigation Integrity", () => {
const favoritesLink = screen.getByRole("link", { name: /favorites/i });
expect(favoritesLink.getAttribute("href")).toBe("/app/favorites");
+ const archiveLink = screen.getByRole("link", { name: /archive/i });
+ expect(archiveLink.getAttribute("href")).toBe("/app/archive");
+
const sharedLink = screen.getByRole("link", { name: /shared/i });
expect(sharedLink.getAttribute("href")).toBe("/app/shared");
+ const trashLink = screen.getByRole("link", { name: /trash/i });
+ expect(trashLink.getAttribute("href")).toBe("/app/trash");
+
// Brand logo link to /app
const logoLink = screen.getByRole("link", { name: /notesready/i });
expect(logoLink.getAttribute("href")).toBe("/app");
@@ -97,6 +104,22 @@ describe("AppSidebar Navigation Integrity", () => {
expect(designLink.getAttribute("href")).toBe(`/app/notes/${note2.id}`);
});
+ it("renders dynamic notebooks and links them to notebook detail pages", async () => {
+ const repo = new MemoryNoteRepository();
+ const nb1 = createEmptyNotebook("Engineering Docs");
+ await repo.saveNotebook(nb1);
+ setNoteRepository(repo);
+
+ render(
+
+
+
+ );
+
+ const nbLink = await screen.findByRole("link", { name: /engineering docs/i });
+ expect(nbLink.getAttribute("href")).toBe(`/app/notebooks/${nb1.id}`);
+ });
+
it("does not route Quick Search to generic pages and presents it as an unavailable/planned feature", async () => {
await renderSidebar();
@@ -110,24 +133,6 @@ describe("AppSidebar Navigation Integrity", () => {
expect(within(quickSearchContainer as HTMLElement).getByText(/soon/i)).toBeDefined();
});
- it("does not route Notebooks to generic pages and presents it as an unavailable/planned feature without fake notebook links", async () => {
- await renderSidebar();
-
- // No fake "General Knowledge" notebook pretending to exist
- expect(screen.queryByText("General Knowledge")).toBeNull();
-
- // Notebooks section item must NOT be a link to /app/notes or /app
- const notebooksItems = screen.getAllByText("Notebooks");
- // Find the item with aria-disabled
- const disabledNotebookItem = notebooksItems
- .map((el) => el.closest("[aria-disabled='true']"))
- .find((el) => el !== null);
-
- expect(disabledNotebookItem).not.toBeNull();
- expect(disabledNotebookItem?.closest("a")).toBeNull();
- expect(within(disabledNotebookItem as HTMLElement).getByText(/soon/i)).toBeDefined();
- });
-
it("does not route Settings to workspace home and presents it as an unavailable/planned feature", async () => {
await renderSidebar();
@@ -139,15 +144,21 @@ describe("AppSidebar Navigation Integrity", () => {
expect(within(settingsContainer as HTMLElement).getByText(/soon/i)).toBeDefined();
});
- it("does not route Trash to All Notes and presents it as an unavailable/planned feature", async () => {
- await renderSidebar();
+ it("links Trash directly to /app/trash with badge count when notes are trashed", async () => {
+ const repo = new MemoryNoteRepository();
+ const trashedNote = createEmptyNote({ title: "Draft to discard", deletedAt: new Date().toISOString() });
+ await repo.saveNote(trashedNote);
+ setNoteRepository(repo);
+
+ render(
+
+
+
+ );
- // Trash must NOT be a link to /app/notes
- const trashText = screen.getByText("Trash");
- const trashContainer = trashText.closest("[aria-disabled='true']");
- expect(trashContainer).not.toBeNull();
- expect(trashContainer?.closest("a")).toBeNull();
- expect(within(trashContainer as HTMLElement).getByText(/soon/i)).toBeDefined();
+ const trashLink = await screen.findByRole("link", { name: /trash/i });
+ expect(trashLink.getAttribute("href")).toBe("/app/trash");
+ expect(within(trashLink).getByText("1")).toBeDefined();
});
it("ensures no interactive elements are nested inside links", async () => {
diff --git a/tests/unit/workspace/trash.test.tsx b/tests/unit/workspace/trash.test.tsx
new file mode 100644
index 0000000..68414af
--- /dev/null
+++ b/tests/unit/workspace/trash.test.tsx
@@ -0,0 +1,176 @@
+import React from "react";
+import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import TrashPage from "@/app/(workspace)/app/trash/page";
+import { NoteEditor } from "@/components/workspace/NoteEditor";
+import { WorkspaceProvider } from "@/context/WorkspaceContext";
+import { MemoryNoteRepository } from "@/core/storage/memory";
+import { setNoteRepository } from "@/core/storage";
+import { createEmptyNote } from "@/core/models/note";
+
+// Mock router and navigation
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ prefetch: vi.fn(),
+ }),
+ usePathname: () => "/app/trash",
+ useParams: () => ({}),
+}));
+
+describe("Trash & Recovery Lifecycle", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ setNoteRepository(null);
+ });
+
+ it("renders empty state when no notes are trashed", async () => {
+ const repo = new MemoryNoteRepository();
+ setNoteRepository(repo);
+
+ render(
+
+
+
+ );
+
+ expect(await screen.findByText(/trash is empty/i)).toBeDefined();
+ });
+
+ it("displays trashed notes and excludes them from active workspace list", async () => {
+ const repo = new MemoryNoteRepository();
+ const activeNote = createEmptyNote({ title: "Active Document" });
+ const trashedNote = createEmptyNote({
+ title: "Discarded Draft",
+ deletedAt: new Date().toISOString(),
+ });
+ await repo.saveNote(activeNote);
+ await repo.saveNote(trashedNote);
+ setNoteRepository(repo);
+
+ render(
+
+
+
+ );
+
+ expect(await screen.findByText("Discarded Draft")).toBeDefined();
+ expect(screen.queryByText("Active Document")).toBeNull();
+ });
+
+ it("restores a trashed note back to active notes", async () => {
+ const repo = new MemoryNoteRepository();
+ const trashedNote = createEmptyNote({
+ title: "Note to recover",
+ deletedAt: new Date().toISOString(),
+ });
+ await repo.saveNote(trashedNote);
+ setNoteRepository(repo);
+
+ render(
+
+
+
+ );
+
+ const restoreBtn = await screen.findByRole("button", { name: /restore note to recover/i });
+ fireEvent.click(restoreBtn);
+
+ await waitFor(() => {
+ expect(screen.queryByText("Note to recover")).toBeNull();
+ });
+
+ const refreshed = await repo.getNote(trashedNote.id);
+ expect(refreshed?.deletedAt).toBeNull();
+ });
+
+ it("permanently deletes a note after confirmation", async () => {
+ const repo = new MemoryNoteRepository();
+ const trashedNote = createEmptyNote({
+ title: "Permanently gone",
+ deletedAt: new Date().toISOString(),
+ });
+ await repo.saveNote(trashedNote);
+ setNoteRepository(repo);
+
+ render(
+
+
+
+ );
+
+ const deleteBtn = await screen.findByRole("button", { name: /permanently delete permanently gone/i });
+ fireEvent.click(deleteBtn);
+
+ const confirmBtn = await screen.findByRole("button", { name: /confirm permanent delete permanently gone/i });
+ fireEvent.click(confirmBtn);
+
+ await waitFor(() => {
+ expect(screen.queryByText("Permanently gone")).toBeNull();
+ });
+
+ const reloaded = await repo.getNote(trashedNote.id);
+ expect(reloaded).toBeNull();
+ });
+
+ it("empties the entire trash after confirmation", async () => {
+ const repo = new MemoryNoteRepository();
+ const t1 = createEmptyNote({ title: "Trash 1", deletedAt: new Date().toISOString() });
+ const t2 = createEmptyNote({ title: "Trash 2", deletedAt: new Date().toISOString() });
+ await repo.saveNote(t1);
+ await repo.saveNote(t2);
+ setNoteRepository(repo);
+
+ render(
+
+
+
+ );
+
+ const emptyBtn = await screen.findByRole("button", { name: /empty trash/i });
+ fireEvent.click(emptyBtn);
+
+ const confirmBtn = await screen.findByRole("button", { name: /confirm/i });
+ fireEvent.click(confirmBtn);
+
+ await waitFor(() => {
+ expect(screen.getByText(/trash is empty/i)).toBeDefined();
+ });
+
+ const trashedInRepo = await repo.listTrashedNotes();
+ expect(trashedInRepo.length).toBe(0);
+ });
+
+ it("renders Trashed Note Banner with read-only controls when viewing a trashed note directly", async () => {
+ const repo = new MemoryNoteRepository();
+ const trashedNote = createEmptyNote({
+ title: "Trashed Doc Direct View",
+ content: "Important read-only text",
+ deletedAt: new Date().toISOString(),
+ });
+ await repo.saveNote(trashedNote);
+ setNoteRepository(repo);
+
+ render(
+
+
+
+ );
+
+ const banner = await screen.findByRole("alert", { name: /trashed note notice/i });
+ expect(banner).toBeDefined();
+ expect(screen.getByText(/this note is in the trash/i)).toBeDefined();
+
+ // Title input should be disabled
+ const titleInput = screen.getByLabelText("Note Title") as HTMLInputElement;
+ expect(titleInput.disabled).toBe(true);
+
+ // Can restore note from banner
+ const restoreBtn = screen.getByRole("button", { name: /restore note/i });
+ expect(restoreBtn).toBeDefined();
+ });
+});