16 Commits
v0.1.0 ... main

Author SHA1 Message Date
Keith Solomon
f7ff37a8ec 🐞 fix: Update action definitions
Some checks failed
Release / Build and Push Docker Image (push) Has been cancelled
2026-02-27 11:29:37 -06:00
5299190973 Add ssh skeleton directory
Some checks failed
CI / Backend (Rust) (push) Failing after 11s
CI / Frontend (Vue) (push) Successful in 10m2s
Release / Build Linux (x86_64) (push) Failing after 54s
Release / Build macOS (x86_64) (push) Has been cancelled
Release / Build Windows (x86_64) (push) Has been cancelled
Release / Create Release (push) Has been cancelled
2026-02-27 13:00:02 +00:00
Keith Solomon
f1f106c948 🐞 fix: Even more git fixes
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m57s
2026-02-25 08:05:02 -06:00
Keith Solomon
2a55d5ebec 🐞 fix: More git fixes
Some checks failed
CI / Backend (Rust) (push) Failing after 10s
CI / Frontend (Vue) (push) Successful in 9m57s
2026-02-23 18:25:20 -06:00
Keith Solomon
adefed7c74 🐞 fix: Git fixes
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m57s
2026-02-23 17:04:36 -06:00
Keith Solomon
d39bbd1801 feature: Add remote git support
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m56s
2026-02-23 08:50:06 -06:00
Keith Solomon
0b5816621b 🐞 fix: Update #2 for dbus error
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m54s
2026-02-23 08:22:32 -06:00
Keith Solomon
97508e4f0a 🐞 fix: Update for dbus error
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m53s
2026-02-23 06:53:14 -06:00
Keith Solomon
d98181bed9 feature: Add docker support
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 10m0s
2026-02-22 22:38:55 -06:00
skepsismusic
10ead43260 Update Cargo.lock for ksni dependency and add release to backend gitignore
Some checks failed
CI / Backend (Rust) (push) Failing after 1m36s
CI / Frontend (Vue) (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 14:21:36 +01:00
skepsismusic
9b9cb1f0b4 Fix cargo fmt formatting in projects.rs and tasks.rs
Some checks failed
Release / Build Linux (x86_64) (push) Has been cancelled
Release / Build macOS (x86_64) (push) Has been cancelled
Release / Build Windows (x86_64) (push) Has been cancelled
Release / Create Release (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 14:11:00 +01:00
skepsismusic
bbffc0e6bb Fix Linux build: enable ksni feature for tray-item and add libdbus-1-dev to CI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 14:05:51 +01:00
skepsismusic
7c91e1775a Update graphics for v0.2.0: roadmap, architecture, and what's-new feature highlight
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 13:57:19 +01:00
skepsismusic
781ea28097 Release v0.2.0: Task comments, recurring calendar, system tray, app branding
New features:
- Task comments with date-stamped entries and last-comment summary
- Recurring tasks expanded on calendar (daily/weekly/monthly/yearly)
- System tray mode replacing CMD window (Windows/macOS/Linux)
- Ironpad logo as exe icon, tray icon, favicon, and header logo

Technical changes:
- Backend restructured for dual-mode: dev (API-only) / prod (tray + server)
- tray-item crate for cross-platform tray, winresource for icon embedding
- Calendar view refactored with CalendarEntry interface for recurring merging
- Added CHANGELOG.md, build-local.ps1, version bumped to 0.2.0

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 13:48:54 +01:00
skepsismusic
b150a243fd update roadmap and readme 2026-02-07 17:38:34 +01:00
skepsismusic
df27a27a2d updated roadmap 2026-02-06 15:05:42 +01:00
49 changed files with 2599 additions and 284 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.github
.ferrite
backend/target
frontend/node_modules
frontend/dist
data
docs
*.log
*.tmp

11
.ferrite/state.json Normal file
View File

@@ -0,0 +1,11 @@
{
"recent_files": [
"G:\\DEV\\proman2k\\README.md",
"G:\\DEV\\proman2k\\assets\\test.md"
],
"expanded_paths": [
"G:\\DEV\\proman2k"
],
"file_tree_width": 250.0,
"show_file_tree": true
}

View File

@@ -0,0 +1,20 @@
{
"name": "Workspace",
"tabs": [
{
"name": "Tab 1",
"layout": {
"Terminal": 1
},
"terminals": {
"1": {
"shell": "Default",
"cwd": "G:\\DEV\\proman2k",
"title": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
}
}
}
],
"floating_windows": [],
"active_tab_index": 0
}

View File

@@ -1,10 +1,11 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
# push:
# branches: [main]
# pull_request:
# branches: [main]
jobs:
backend:
@@ -13,6 +14,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable

View File

@@ -6,127 +6,45 @@ on:
- 'v*'
permissions:
contents: write
contents: read
packages: write
jobs:
build:
name: Build ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- name: Linux (x86_64)
target: x86_64-unknown-linux-gnu
os: ubuntu-latest
archive: tar.gz
asset_name: ironpad-linux-x86_64
- name: macOS (x86_64)
target: x86_64-apple-darwin
os: macos-latest
archive: tar.gz
asset_name: ironpad-macos-x86_64
- name: Windows (x86_64)
target: x86_64-pc-windows-msvc
os: windows-latest
archive: zip
asset_name: ironpad-windows-x86_64
docker:
name: Build and Push Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y cmake
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Build frontend
working-directory: frontend
run: |
npm ci
npm run build
- name: Copy frontend to static
- name: Set image name
shell: bash
run: |
mkdir -p backend/static
cp -r frontend/dist/* backend/static/
run: echo "IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/ironpad" >> "$GITHUB_ENV"
- name: Build backend (release)
working-directory: backend
run: cargo build --release --target ${{ matrix.target }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Package (Unix)
if: matrix.archive == 'tar.gz'
shell: bash
run: |
RELEASE_DIR="${{ matrix.asset_name }}-${{ github.ref_name }}"
mkdir -p "$RELEASE_DIR"
cp "backend/target/${{ matrix.target }}/release/ironpad" "$RELEASE_DIR/"
cp -r backend/static "$RELEASE_DIR/static"
cp README.md LICENSE "$RELEASE_DIR/"
tar czf "$RELEASE_DIR.tar.gz" "$RELEASE_DIR"
echo "ASSET=$RELEASE_DIR.tar.gz" >> $GITHUB_ENV
- name: Package (Windows)
if: matrix.archive == 'zip'
shell: bash
run: |
RELEASE_DIR="${{ matrix.asset_name }}-${{ github.ref_name }}"
mkdir -p "$RELEASE_DIR"
cp "backend/target/${{ matrix.target }}/release/ironpad.exe" "$RELEASE_DIR/"
cp -r backend/static "$RELEASE_DIR/static"
cp README.md LICENSE "$RELEASE_DIR/"
7z a "$RELEASE_DIR.zip" "$RELEASE_DIR"
echo "ASSET=$RELEASE_DIR.zip" >> $GITHUB_ENV
- name: Upload artifact
uses: actions/upload-artifact@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
name: ${{ matrix.asset_name }}
path: ${{ env.ASSET }}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
path: artifacts
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=tag
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
- name: Build and push image
uses: docker/build-push-action@v6
with:
name: Ironpad ${{ github.ref_name }}
body: |
## Downloads
| Platform | File |
|----------|------|
| Windows (x86_64) | `ironpad-windows-x86_64-${{ github.ref_name }}.zip` |
| macOS (x86_64) | `ironpad-macos-x86_64-${{ github.ref_name }}.tar.gz` |
| Linux (x86_64) | `ironpad-linux-x86_64-${{ github.ref_name }}.tar.gz` |
**Linux:** Extract the tar.gz and run `./ironpad`. Works on any distro -- it's a standalone binary with no dependencies.
**macOS:** Extract and run. You may need to allow it in System Settings > Privacy & Security on first launch.
**Windows:** Extract the zip and run `ironpad.exe`.
---
files: artifacts/**/*
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

4
.gitignore vendored
View File

@@ -37,9 +37,13 @@ data/notes/assets/*
!data/notes/assets/.gitkeep
data/projects/*/
!data/projects/.gitkeep
Codex Session.txt
# === Stray root lock file (frontend/package-lock.json is kept for CI) ===
/package-lock.json
# === Local build output ===
release/
# === Generated images (article assets, not source) ===
/assets/

39
CHANGELOG.md Normal file
View File

@@ -0,0 +1,39 @@
# Changelog
All notable changes to Ironpad are documented here.
## [0.2.0] - 2026-02-16
### Added
- **Task comments** -- date-stamped comment entries per task, stored as YAML in frontmatter. Last comment shown as summary in task list and dashboard cards. Add/delete via API and UI.
- **Recurring tasks on calendar** -- tasks with daily/weekly/monthly/yearly recurrence now appear on the calendar even without an explicit `due_date`. Occurrences are computed from the anchor date (`due_date` or `created`) and `recurrence_interval`. Recurring entries show with a dashed border and recurrence icon to distinguish from regular due-date tasks.
- **System tray mode** -- production binary runs in the system tray instead of a console window. Tray menu with "Open in Browser" and "Quit". No CMD window on Windows in release builds (`windows_subsystem = "windows"`). Server runs on a background thread with the tray event loop on the main thread for cross-platform safety.
- **App icon and branding** -- Ironpad logo embedded in the Windows executable (Explorer icon + tray icon) via `winresource`. Favicon and logo added to the web UI (browser tab + top bar header).
- **Local build script** -- `build-local.ps1` for building a testable release package locally.
### Changed
- Backend `main.rs` restructured for dual-mode operation: development mode runs the server directly (no tray), production mode runs server on background thread with tray on main thread.
- Calendar view refactored to use `CalendarEntry` interface that merges regular due-date tasks with computed recurring occurrences.
### Dependencies
- Added `tray-item = "0.10"` for cross-platform system tray support.
- Added `windows-sys = "0.52"` (Windows only) for loading the embedded icon resource.
- Added `winresource = "0.1"` (Windows build dependency) for embedding the icon in the .exe.
---
## [0.1.0] - 2025-12-01
### Added
- Initial release of Ironpad -- local-first, file-based project and knowledge management.
- **Backend**: Rust/Axum API server with dynamic port (3000-3010), WebSocket real-time sync, file watcher, Git auto-commit (60s batching), ripgrep search.
- **Frontend**: Vue 3 SPA with Milkdown WYSIWYG editor, dark/light theme, Pinia state management.
- **File-based tasks**: each task stored as a markdown file with YAML frontmatter (title, completed, section, priority, due_date, tags, subtasks, recurrence).
- **Split-panel task view**: task list with active/backlog/completed sections, detail editor with markdown, due date picker, tag system, subtasks, recurrence picker.
- **Calendar view**: month grid showing tasks by due date with color-coded urgency and daily note indicators.
- **Dashboard**: cross-project home page with active task summaries per project.
- **Daily notes**: date-based notes with templates.
- **Git panel**: commit history with diffs, working directory changes, push/fetch with ahead/behind indicators.
- **Project notes**: split-panel notes view per project.
- **Search**: Ctrl+K search panel with ripgrep-powered full-text search.
- Cross-platform builds (Windows, macOS, Linux) via GitHub Actions.

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
FROM node:20-bookworm-slim AS frontend-builder
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM rust:1.85-bookworm AS backend-builder
WORKDIR /src/backend
RUN apt-get update \
&& apt-get install -y --no-install-recommends pkg-config libdbus-1-dev \
&& rm -rf /var/lib/apt/lists/*
COPY backend/ ./
RUN cargo build --release
FROM debian:bookworm-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates ripgrep libdbus-1-3 git openssh-client \
&& rm -rf /var/lib/apt/lists/*
COPY --from=backend-builder /src/backend/target/release/ironpad /app/ironpad
COPY --from=frontend-builder /src/frontend/dist /app/static
RUN mkdir -p /app/data
ENV IRONPAD_HOST=0.0.0.0
ENV IRONPAD_PORT=3000
ENV IRONPAD_DISABLE_TRAY=1
ENV IRONPAD_DATA_DIR=/app/data
ENV RUST_LOG=info
EXPOSE 3000
VOLUME ["/app/data"]
CMD ["./ironpad"]

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Ola Proeis
Copyright (c) 2026 Ola Prøis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,18 +1,22 @@
# Ironpad
![Ironpad Banner](docs/graphics/ironpad-banner.png)
**A local-first, file-based project & knowledge management system.**
![Build](https://github.com/OlaProeis/ironPad/actions/workflows/release.yml/badge.svg)
![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)
![Rust](https://img.shields.io/badge/rust-1.70%2B-orange)
![Version](https://img.shields.io/badge/version-0.1.0-green)
![Version](https://img.shields.io/badge/version-0.2.0-green)
Ironpad stores all your notes, projects, and tasks as plain Markdown files. No cloud services, no vendor lock-in -- your data stays on your machine in a format you can read and edit with any text editor. Every change is automatically versioned with Git.
![Ironpad Screenshot](docs/screenshot.jpg)
> **v0.1.0 -- Early Release.** This is the first public release. It's functional and we use it daily, but expect rough edges. Bug reports and feature requests are welcome via [Issues](https://github.com/OlaProeis/ironPad/issues).
> **v0.2.0** -- Task comments, recurring tasks on calendar, system tray mode, and app branding. See [CHANGELOG.md](CHANGELOG.md) for details.
![What's New in v0.2.0](docs/graphics/whats-new-v020.png)
---
@@ -22,14 +26,15 @@ Ironpad stores all your notes, projects, and tasks as plain Markdown files. No c
- **Local-first** -- Works fully offline, no internet required
- **Git integration** -- Automatic version control with 60-second commit batching, full diff viewer, push/fetch
- **WYSIWYG editing** -- Milkdown editor with real-time markdown rendering and formatting toolbar
- **Project management** -- Organize tasks and notes by project with due dates, tags, subtasks, and recurrence
- **Calendar view** -- Month grid showing tasks by due date with color-coded urgency
- **Project management** -- Organize tasks and notes by project with due dates, tags, subtasks, recurrence, and comments
- **Calendar view** -- Month grid showing tasks by due date with color-coded urgency; recurring tasks automatically expanded across the month
- **Dashboard** -- Cross-project overview with active task summaries
- **Daily notes** -- Quick capture with templates for daily journaling
- **Real-time sync** -- WebSocket-based live updates; edit in VS Code, see changes in the browser instantly
- **External editing** -- Full support for VS Code, Obsidian, Vim, or any text editor
- **Search** -- ripgrep-powered full-text search across all files (Ctrl+K)
- **Dark theme** -- Beautiful dark UI by default with light mode toggle
- **System tray** -- Runs quietly in the system tray (Windows, macOS, Linux); no console window in release builds
- **Tiny footprint** -- 5 MB binary, ~20 MB RAM, sub-second startup
## Quick Start
@@ -63,8 +68,57 @@ npm run dev
Open http://localhost:5173 in your browser.
### Option 3: Run with Docker (Centralized Server)
This runs Ironpad as a single container that serves both API and frontend on port `3000`.
```bash
# Build and start in the background
docker compose up -d --build
# View logs
docker compose logs -f
```
Then open:
- `http://localhost:3000` from the same machine, or
- `http://<your-server-ip>:3000` from another device on your network.
Data persists in `./data` on the host via the compose volume mapping.
To stop:
```bash
docker compose down
```
#### Docker + Private Git Remote Sync
Ironpad can automatically sync the `data/` git repo with a private remote over SSH.
1. Put your SSH key files on the host (example: `./deploy/ssh/id_ed25519` and `./deploy/ssh/id_ed25519.pub`).
2. Uncomment the SSH volume mount and git env vars in `docker-compose.yml`.
3. Set:
- `IRONPAD_GIT_REMOTE_URL` (example: `git@github.com:your-org/ironpad-data.git`)
- `IRONPAD_GIT_SSH_PRIVATE_KEY` (path inside container)
- `IRONPAD_GIT_SSH_KNOWN_HOSTS` (optional; defaults to `/root/.ssh/known_hosts`)
- `IRONPAD_GIT_SYNC_INTERVAL_SECS` (example: `300`)
4. Recreate the stack:
```bash
docker compose up -d --build
```
Sync behavior:
- Every cycle: `fetch -> safe fast-forward if possible -> push`
- If local and remote diverge, auto fast-forward is skipped and a warning is logged.
- If libgit2 SSH auth fails, Ironpad can fall back to `git` CLI (controlled by `IRONPAD_GIT_USE_CLI_FALLBACK`, default `true`).
## Tech Stack
![Tech Stack](docs/graphics/tech-stack.png)
| Component | Technology |
|-----------|------------|
| Backend | Rust, Axum 0.8, Tokio |
@@ -78,8 +132,13 @@ Open http://localhost:5173 in your browser.
## Roadmap
![Roadmap](docs/graphics/roadmap.png)
Ironpad is under active development. Here's what's planned:
- [x] Task comments and activity summary
- [x] Recurring tasks on calendar (daily/weekly/monthly/yearly expansion)
- [x] System tray mode (Windows, macOS, Linux)
- [ ] UI polish and animations
- [ ] Tag extraction and filtering across projects
- [ ] Backlinks between notes
@@ -87,13 +146,14 @@ Ironpad is under active development. Here's what's planned:
- [ ] Export to PDF / HTML
- [ ] Custom themes
- [ ] Global hotkey (Ctrl+Shift+Space)
- [ ] System tray mode
- [ ] Kanban board view for tasks
See [CHECKLIST.md](docs/ai-workflow/CHECKLIST.md) for detailed implementation status.
## Built With AI
![AI Workflow](docs/graphics/ai-workflow.png)
This entire application was built using AI-assisted development -- an approach we call **Open Method**. We share not just the code, but the complete process: the PRD, task breakdowns, handover documents, and workflow artifacts.
Read about the method:
@@ -108,11 +168,24 @@ Read about the method:
|---------|---------|-------------|
| Data directory | `data/` next to executable | Override with `IRONPAD_DATA_DIR` env var |
| Backend port | 3000 (auto-increments to 3010) | Dynamic port selection |
| Backend host | `127.0.0.1` | Override with `IRONPAD_HOST` (use `0.0.0.0` for Docker/server access) |
| Fixed port | disabled | Set `IRONPAD_PORT` to force a specific port |
| Disable tray mode | `false` | Set `IRONPAD_DISABLE_TRAY=1` to run headless in production static mode |
| Auto-commit | Every 60 seconds | Git commits when changes exist |
| Git remote URL | not set | `IRONPAD_GIT_REMOTE_URL` creates/updates `origin` |
| Git sync interval | `0` (disabled) | Set `IRONPAD_GIT_SYNC_INTERVAL_SECS` to enable scheduled sync |
| Git SSH private key | not set | `IRONPAD_GIT_SSH_PRIVATE_KEY` path to private key in container |
| Git SSH public key | not set | Optional `IRONPAD_GIT_SSH_PUBLIC_KEY` path |
| Git known_hosts path | `/root/.ssh/known_hosts` | Override with `IRONPAD_GIT_SSH_KNOWN_HOSTS` |
| Git SSH username | `git` | Override with `IRONPAD_GIT_SSH_USERNAME` if needed |
| Git SSH passphrase | not set | Optional `IRONPAD_GIT_SSH_PASSPHRASE` |
| Git CLI fallback | `true` | `IRONPAD_GIT_USE_CLI_FALLBACK` for fetch/push auth fallback |
| Auto-save | 1 second debounce | Frontend saves after typing stops |
## Documentation
![Architecture](docs/graphics/architecture.png)
| Document | Description |
|----------|-------------|
| [docs/API.md](docs/API.md) | Complete REST API reference |

64
ROADMAP.md Normal file
View File

@@ -0,0 +1,64 @@
# Ironpad Roadmap
## Release 0.2.0 (Current)
### Features
#### 1. Task comments & activity summary
- **Comment section** per task with date-stamped entries
- Store comments as YAML sequence in task frontmatter
- **Last comment as summary** -- most recent comment shown in task list and dashboard cards
- Add/delete comments via API and UI, newest-first display with relative timestamps
#### 2. Recurring tasks on the calendar
- Tasks with daily/weekly recurrence now appear on the calendar (previously required explicit `due_date`)
- Recurring tasks expanded into the visible month grid (daily/weekly/monthly/yearly)
- Anchor date: `due_date` if set, otherwise `created`; respects `recurrence_interval`
- Recurring occurrences shown with dashed border and recurrence icon
#### 3. System tray mode
- System tray icon replaces CMD window (Windows, macOS, Linux)
- Tray menu: **Open in Browser** | **Quit**
- No console window on Windows in release builds
- Server runs on background thread; tray event loop on main thread (cross-platform safe)
#### 4. App branding
- Ironpad logo as system tray icon and Windows exe icon
- Favicon and logo in the web UI (browser tab + header)
---
## Suggested features (future releases)
Ideas that fit the current architecture and local-first design:
### High fit (0.3.x)
- **Calendar drag-and-drop** -- reschedule tasks by dragging onto a new date
- **Week / day calendar views** -- alternative to month view for denser task planning
- **Sort task list by due date / priority** -- alongside current created-date sorting
- **Overdue indicator** -- clearer overdue badge or count in sidebar and dashboard
### Medium fit (0.4.x)
- **Quick-add task** -- global or dashboard shortcut to create a task without opening a project
- **Bulk actions** -- complete multiple tasks, move section, add/remove tags in one go
- **Task templates** -- create tasks from predefined templates (e.g. "Meeting prep", "Review")
- **Tag extraction and cross-project filtering** -- surface and filter by tags across all projects
### Longer term
- UI polish and subtle animations
- Responsive sidebar / mobile-friendly layout
- Global hotkey (e.g. Ctrl+Shift+Space)
- Backlinks between notes
- Graph view of note connections
- Export to PDF / HTML
- Custom themes
- Kanban board view
---
## Version history
| Version | Status | Date | Notes |
|---------|----------|------------|----------------------------------------------------------|
| 0.1.0 | Released | 2025-12-01 | First public release, core features in place |
| 0.2.0 | Current | 2026-02-16 | Comments, recurring calendar, system tray, app branding |

View File

@@ -67,7 +67,7 @@ ironpad/
## Implemented Features
### Backend
- API-only server (no frontend serving, no browser auto-open)
- **Dual-mode server**: API-only in development; frontend-serving + system tray in production
- Dynamic port (3000-3010)
- Notes CRUD with atomic writes
- Frontmatter auto-management
@@ -79,12 +79,13 @@ ironpad/
- Git remote info (ahead/behind tracking), fetch support
- Projects API with notes management
- **File-based Tasks API** — each task is a markdown file with frontmatter
- Fields: id, title, completed, section, priority, due_date, is_active, tags, parent_id, recurrence, recurrence_interval
- Fields: id, title, completed, section, priority, due_date, is_active, tags, parent_id, recurrence, recurrence_interval, comments
- Rich text descriptions with markdown support
- Sorted by created date (stable ordering)
- **Subtasks** — tasks with `parent_id` link to a parent task
- **Tags** — YAML sequence in frontmatter, per-task labels for filtering
- **Recurring tasks** — when completing a recurring task, auto-creates next instance with advanced due date
- **Comments** — date-stamped comment entries stored as YAML sequence in frontmatter; last comment shown as summary in list/dashboard
- Daily notes API (`/api/daily`, `/api/daily/today`, `/api/daily/:date`)
- Assets API (upload + serve)
@@ -113,9 +114,14 @@ ironpad/
- **Tag filter bar** — click tags to filter task list
- **Subtasks** — expandable subtasks under parent tasks, add subtask inline
- **Recurrence picker** — set daily/weekly/monthly/yearly recurrence
- **Task comments** — date-stamped comments section in task detail, newest first, add/delete
- **Last comment summary** — most recent comment shown in task list and dashboard cards
- Inline title editing (double-click)
- **Calendar view** — month grid showing tasks by due date
- **Calendar view** — month grid showing tasks by due date + recurring task expansion
- Tasks with due dates plotted on calendar cells
- **Recurring tasks expanded** — daily/weekly/monthly/yearly tasks shown on computed occurrences
- Recurring occurrences use anchor date (`due_date` or `created`), respect `recurrence_interval`
- Recurring entries shown with dashed border and ↻ icon to distinguish from regular tasks
- Daily notes shown as blue dots
- Color-coded urgency (overdue, today, soon)
- Month navigation + Today button
@@ -150,6 +156,8 @@ GET/POST /api/projects/:id/tasks
GET/PUT/DEL /api/projects/:id/tasks/:task_id
PUT /api/projects/:id/tasks/:task_id/toggle
PUT /api/projects/:id/tasks/:task_id/meta
POST /api/projects/:id/tasks/:task_id/comments
DELETE /api/projects/:id/tasks/:task_id/comments/:index
GET /api/tasks # All tasks across projects
GET /api/daily
@@ -224,12 +232,25 @@ WS /ws
---
## Not Yet Implemented (Phase 6+)
## Implemented in Phase 6
- **Recurring tasks on calendar** — frontend expands daily/weekly/monthly/yearly tasks into the visible month grid
- Anchor date: `due_date` if set, otherwise `created`; respects `recurrence_interval`
- Deduplicates against regular due-date entries; visual indicator (dashed border, ↻ icon)
- **System tray mode** — production binary runs in system tray (Windows, macOS, Linux)
- `#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]` hides console on Windows
- Server on background thread, tray event loop on main thread (cross-platform safe)
- Tray menu: "Open in Browser" / "Quit"
- Uses `tray-item` crate with platform-specific icon loading (`windows-sys` on Windows)
- Development mode unchanged (no tray, API-only)
---
## Not Yet Implemented (Phase 7+)
- UI polish and animations
- Responsive sidebar
- Global hotkey (Ctrl+Shift+Space)
- System tray mode
- Backlinks between notes
- Graph view
- Export (PDF / HTML)
@@ -262,12 +283,14 @@ WS /ws
| Project note ID | `{slug}-index` format |
| Task storage | Individual .md files in `tasks/` folder |
| List sorting | By created date (stable, not affected by edits) |
| Backend mode | API-only (no frontend serving) |
| Backend mode | API-only (dev); frontend-serving + system tray (production) |
| Theme | Dark by default, toggle to light, persists to localStorage |
| Tags | YAML sequence in frontmatter, project-scoped filtering |
| Subtasks | Separate task files with `parent_id` field linking to parent |
| Recurring tasks | On completion, backend auto-creates next instance with advanced due date |
| Calendar | Pure frontend month grid, tasks filtered by `due_date` presence |
| Calendar | Pure frontend month grid, tasks by `due_date` + recurring expansion |
| System tray | `tray-item` crate; main thread tray, background thread server; `windows-sys` for icon on Windows |
| Task comments | YAML sequence in frontmatter, date-stamped, last comment as list/dashboard summary |
| Dashboard | Home route `/`, loads all projects + all tasks for cross-project summary |
---

1
backend/.gitignore vendored
View File

@@ -1 +1,2 @@
/target
/release

791
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "ironpad"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
[dependencies]
@@ -47,3 +47,18 @@ uuid = { version = "1.0", features = ["v4"] }
# Utilities
lazy_static = "1.4"
tokio-util = { version = "0.7", features = ["io"] }
# System tray (production mode)
tray-item = "0.10"
# Linux: tray-item needs ksni feature (pure-Rust D-Bus StatusNotifierItem)
[target.'cfg(target_os = "linux")'.dependencies]
tray-item = { version = "0.10", features = ["ksni"] }
# Windows icon loading (for tray icon)
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.52", features = ["Win32_UI_WindowsAndMessaging", "Win32_System_LibraryLoader"] }
# Build dependencies (Windows icon embedding)
[target.'cfg(target_os = "windows")'.build-dependencies]
winresource = "0.1"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
backend/assets/ironpad.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

10
backend/build.rs Normal file
View File

@@ -0,0 +1,10 @@
fn main() {
// On Windows, embed the application icon into the .exe
// This sets both the Explorer icon and makes it available as a resource
#[cfg(target_os = "windows")]
{
let mut res = winresource::WindowsResource::new();
res.set_icon("assets/ironpad.ico");
res.compile().expect("Failed to compile Windows resources");
}
}

View File

@@ -1,4 +1,7 @@
use std::net::SocketAddr;
// Hide console window on Windows in release builds (production mode)
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use axum::{routing::get, Router};
@@ -17,26 +20,74 @@ mod websocket;
/// Find an available port and return the bound listener.
/// Avoids TOCTOU race by keeping the listener alive.
async fn find_available_port() -> (TcpListener, u16) {
let host = std::env::var("IRONPAD_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let bind_ip = match host.parse::<IpAddr>() {
Ok(ip) => ip,
Err(_) => {
warn!("Invalid IRONPAD_HOST '{}', falling back to 127.0.0.1", host);
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))
}
};
if let Ok(port_str) = std::env::var("IRONPAD_PORT") {
match port_str.parse::<u16>() {
Ok(port) => {
let addr = SocketAddr::new(bind_ip, port);
let listener = TcpListener::bind(addr)
.await
.unwrap_or_else(|e| panic!("Failed to bind to {addr}: {e}"));
return (listener, port);
}
Err(_) => {
warn!("Invalid IRONPAD_PORT '{}', falling back to 3000-3010", port_str);
}
}
}
for port in 3000..=3010 {
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let addr = SocketAddr::new(bind_ip, port);
if let Ok(listener) = TcpListener::bind(addr).await {
return (listener, port);
}
}
panic!("No available ports in range 30003010");
panic!("No available ports in range 3000-3010");
}
#[tokio::main]
async fn main() {
// Logging
tracing_subscriber::fmt().init();
fn env_flag(name: &str) -> bool {
std::env::var(name)
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
}
// Resolve data directory (production vs development mode)
fn main() {
tracing_subscriber::fmt().init();
config::init_data_dir();
// Find port and bind (listener kept alive to avoid race condition)
let production = config::is_production();
let disable_tray = env_flag("IRONPAD_DISABLE_TRAY");
if production && !disable_tray {
run_with_tray();
} else {
if production && disable_tray {
info!("Production static mode detected; running headless (IRONPAD_DISABLE_TRAY=1)");
}
// Development mode: normal tokio runtime, no tray
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(run_server(None));
}
}
/// Start the Axum server. In tray mode, sends the bound port through `port_tx`
/// before entering the serve loop.
async fn run_server(port_tx: Option<std::sync::mpsc::Sender<u16>>) {
let (listener, port) = find_available_port().await;
// Notify tray thread of the bound port
if let Some(tx) = port_tx {
let _ = tx.send(port);
}
// WebSocket state (shared across handlers)
let ws_state = Arc::new(websocket::WsState::new());
@@ -51,8 +102,14 @@ async fn main() {
warn!("Git init skipped: {}", e);
}
// Configure git remote from env (if provided)
if let Err(e) = services::git::configure_remote_from_env() {
warn!("Git remote setup skipped: {}", e);
}
// Start auto-commit background task (tries to commit every 60s)
services::git::start_auto_commit();
services::git::start_auto_sync();
// CORS layer (permissive for local-only app)
let cors = CorsLayer::permissive();
@@ -94,7 +151,6 @@ async fn main() {
.layer(cors);
// Check for embedded frontend (production mode)
// Resolve relative to the executable's directory, not the working directory
let has_frontend = config::is_production();
if has_frontend {
@@ -116,23 +172,114 @@ async fn main() {
}
// Start server
info!("🚀 Ironpad running on http://localhost:{port}");
// Auto-open browser in production mode
if has_frontend {
let url = format!("http://localhost:{}", port);
tokio::spawn(async move {
// Small delay to ensure server is ready
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
if let Err(e) = webbrowser::open(&url) {
tracing::warn!(
"Failed to open browser: {}. Open http://localhost:{} manually.",
e,
port
);
}
});
}
let bound_addr = listener
.local_addr()
.map(|a| a.to_string())
.unwrap_or_else(|_| format!("127.0.0.1:{port}"));
info!("Ironpad running on http://{bound_addr}");
axum::serve(listener, app).await.expect("Server failed");
}
// ---------------------------------------------------------------------------
// System tray (production mode)
// ---------------------------------------------------------------------------
/// Build a platform-appropriate tray icon.
///
/// On Windows the Ironpad icon is embedded in the .exe via winresource (build.rs).
/// We load it with LoadIconW using the resource ID assigned by winresource.
#[cfg(target_os = "windows")]
fn tray_icon() -> tray_item::IconSource {
let hicon = unsafe {
// winresource embeds the icon at resource ID 1.
// GetModuleHandleW(null) = current exe, MAKEINTRESOURCE(1) = 1 as PCWSTR.
let hinstance =
windows_sys::Win32::System::LibraryLoader::GetModuleHandleW(std::ptr::null());
windows_sys::Win32::UI::WindowsAndMessaging::LoadIconW(hinstance, 1 as *const u16)
};
tray_item::IconSource::RawIcon(hicon)
}
#[cfg(target_os = "macos")]
fn tray_icon() -> tray_item::IconSource {
tray_item::IconSource::Resource("")
}
#[cfg(target_os = "linux")]
fn tray_icon() -> tray_item::IconSource {
tray_item::IconSource::Resource("application-x-executable")
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
fn tray_icon() -> tray_item::IconSource {
tray_item::IconSource::Resource("")
}
/// Production mode: run the server on a background thread, tray on main thread.
/// The main thread drives the tray event loop (required on macOS; safe everywhere).
fn run_with_tray() {
use std::sync::mpsc;
enum TrayMessage {
OpenBrowser,
Quit,
}
// Channel to receive the dynamically-bound port from the server thread
let (port_tx, port_rx) = mpsc::channel::<u16>();
// Start the Axum server on a background thread with its own tokio runtime
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(run_server(Some(port_tx)));
});
// Wait for the server to report its port
let port = port_rx.recv().expect("Server failed to start");
let url = format!("http://localhost:{}", port);
// Auto-open browser after a short delay (non-blocking)
let url_for_open = url.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(400));
let _ = webbrowser::open(&url_for_open);
});
// Set up system tray icon and menu
let (tx, rx) = mpsc::sync_channel::<TrayMessage>(2);
let mut tray = match tray_item::TrayItem::new("Ironpad", tray_icon()) {
Ok(t) => t,
Err(e) => {
eprintln!("Failed to create system tray: {}. Running headless.", e);
// Keep the process alive so the server thread continues
loop {
std::thread::park();
}
}
};
let tx_open = tx.clone();
let _ = tray.add_menu_item("Open in Browser", move || {
let _ = tx_open.send(TrayMessage::OpenBrowser);
});
let tx_quit = tx;
let _ = tray.add_menu_item("Quit", move || {
let _ = tx_quit.send(TrayMessage::Quit);
});
// Main-thread event loop — processes tray menu actions
for msg in rx {
match msg {
TrayMessage::OpenBrowser => {
let _ = webbrowser::open(&url);
}
TrayMessage::Quit => {
info!("Quit requested from system tray");
std::process::exit(0);
}
}
}
}

View File

@@ -10,9 +10,9 @@ use std::fs;
use crate::config;
use crate::routes::tasks::{
create_task_handler, delete_task_handler, get_task_handler, list_project_tasks_handler,
toggle_task_handler, update_task_content_handler, update_task_meta_handler, CreateTaskRequest,
UpdateTaskMetaRequest,
add_comment_handler, create_task_handler, delete_comment_handler, delete_task_handler,
get_task_handler, list_project_tasks_handler, toggle_task_handler, update_task_content_handler,
update_task_meta_handler, AddCommentRequest, CreateTaskRequest, UpdateTaskMetaRequest,
};
use crate::services::filesystem;
use crate::services::frontmatter;
@@ -91,6 +91,14 @@ pub fn router() -> Router {
)
.route("/{id}/tasks/{task_id}/toggle", put(toggle_project_task))
.route("/{id}/tasks/{task_id}/meta", put(update_project_task_meta))
.route(
"/{id}/tasks/{task_id}/comments",
axum::routing::post(add_project_task_comment),
)
.route(
"/{id}/tasks/{task_id}/comments/{comment_index}",
axum::routing::delete(delete_project_task_comment),
)
// Note routes
.route(
"/{id}/notes",
@@ -143,6 +151,19 @@ async fn delete_project_task(Path((id, task_id)): Path<(String, String)>) -> imp
delete_task_handler(id, task_id).await
}
async fn add_project_task_comment(
Path((id, task_id)): Path<(String, String)>,
Json(payload): Json<AddCommentRequest>,
) -> impl IntoResponse {
add_comment_handler(id, task_id, payload).await
}
async fn delete_project_task_comment(
Path((id, task_id, comment_index)): Path<(String, String, usize)>,
) -> impl IntoResponse {
delete_comment_handler(id, task_id, comment_index).await
}
async fn list_projects() -> impl IntoResponse {
match list_projects_impl() {
Ok(projects) => Json(projects).into_response(),

View File

@@ -7,6 +7,13 @@ use crate::config;
use crate::services::filesystem;
use crate::services::frontmatter;
/// A single comment entry on a task
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comment {
pub date: String,
pub text: String,
}
/// Task summary for list views
#[derive(Debug, Clone, Serialize)]
pub struct Task {
@@ -25,6 +32,7 @@ pub struct Task {
pub path: String,
pub created: String,
pub updated: String,
pub last_comment: Option<String>,
}
/// Task with full content for detail view
@@ -46,6 +54,7 @@ pub struct TaskWithContent {
pub created: String,
pub updated: String,
pub content: String,
pub comments: Vec<Comment>,
}
#[derive(Debug, Deserialize)]
@@ -67,6 +76,11 @@ pub struct UpdateTaskMetaRequest {
pub recurrence_interval: Option<u32>,
}
#[derive(Debug, Deserialize)]
pub struct AddCommentRequest {
pub text: String,
}
pub fn router() -> Router {
Router::new().route("/", get(list_all_tasks_handler))
}
@@ -178,6 +192,41 @@ pub async fn delete_task_handler(project_id: String, task_id: String) -> impl In
}
}
/// Add a comment to a task
pub async fn add_comment_handler(
project_id: String,
task_id: String,
payload: AddCommentRequest,
) -> impl IntoResponse {
match add_comment_impl(&project_id, &task_id, &payload.text) {
Ok(task) => (StatusCode::CREATED, Json(task)).into_response(),
Err(err) if err.contains("not found") => (StatusCode::NOT_FOUND, err).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to add comment: {}", err),
)
.into_response(),
}
}
/// Delete a comment from a task by index
pub async fn delete_comment_handler(
project_id: String,
task_id: String,
comment_index: usize,
) -> impl IntoResponse {
match delete_comment_impl(&project_id, &task_id, comment_index) {
Ok(task) => Json(task).into_response(),
Err(err) if err.contains("not found") => (StatusCode::NOT_FOUND, err).into_response(),
Err(err) if err.contains("out of range") => (StatusCode::BAD_REQUEST, err).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to delete comment: {}", err),
)
.into_response(),
}
}
// ============ Implementation Functions ============
fn get_tasks_dir(project_id: &str) -> std::path::PathBuf {
@@ -233,6 +282,29 @@ fn list_project_tasks_impl(project_id: &str) -> Result<Vec<Task>, String> {
Ok(tasks)
}
/// Parse comments from frontmatter YAML sequence.
fn parse_comments(fm: &serde_yaml::Mapping) -> Vec<Comment> {
fm.get(&serde_yaml::Value::from("comments"))
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|item| {
let map = item.as_mapping()?;
let date = map
.get(&serde_yaml::Value::from("date"))
.and_then(|v| v.as_str())
.map(String::from)?;
let text = map
.get(&serde_yaml::Value::from("text"))
.and_then(|v| v.as_str())
.map(String::from)?;
Some(Comment { date, text })
})
.collect()
})
.unwrap_or_default()
}
/// Shared helper: extract common task fields from frontmatter.
/// Eliminates duplication between parse_task_file and parse_task_with_content.
fn extract_task_fields(fm: &serde_yaml::Mapping, path: &StdPath, project_id: &str) -> Task {
@@ -242,6 +314,9 @@ fn extract_task_fields(fm: &serde_yaml::Mapping, path: &StdPath, project_id: &st
.unwrap_or("")
.to_string();
let comments = parse_comments(fm);
let last_comment = comments.last().map(|c| c.text.clone());
Task {
id: frontmatter::get_str_or(fm, "id", &filename),
title: frontmatter::get_str_or(fm, "title", "Untitled"),
@@ -258,6 +333,7 @@ fn extract_task_fields(fm: &serde_yaml::Mapping, path: &StdPath, project_id: &st
path: format!("projects/{}/tasks/{}.md", project_id, filename),
created: frontmatter::get_str_or(fm, "created", ""),
updated: frontmatter::get_str_or(fm, "updated", ""),
last_comment,
}
}
@@ -355,6 +431,7 @@ fn create_task_impl(
created: now_str.clone(),
updated: now_str,
content: body,
comments: Vec::new(),
})
}
@@ -407,6 +484,7 @@ fn parse_task_with_content(
project_id: &str,
) -> Result<TaskWithContent, String> {
let task = extract_task_fields(fm, path, project_id);
let comments = parse_comments(fm);
Ok(TaskWithContent {
id: task.id,
title: task.title,
@@ -424,6 +502,7 @@ fn parse_task_with_content(
created: task.created,
updated: task.updated,
content: body.to_string(),
comments,
})
}
@@ -683,6 +762,7 @@ fn create_recurring_task_impl(
created: now_str.clone(),
updated: now_str,
content: body,
comments: Vec::new(),
})
}
@@ -776,6 +856,102 @@ fn update_task_meta_impl(
Ok(task)
}
/// Serialize a Vec<Comment> into a YAML sequence Value.
fn comments_to_yaml(comments: &[Comment]) -> serde_yaml::Value {
let seq: Vec<serde_yaml::Value> = comments
.iter()
.map(|c| {
let mut map = serde_yaml::Mapping::new();
map.insert(
serde_yaml::Value::from("date"),
serde_yaml::Value::from(c.date.as_str()),
);
map.insert(
serde_yaml::Value::from("text"),
serde_yaml::Value::from(c.text.as_str()),
);
serde_yaml::Value::Mapping(map)
})
.collect();
serde_yaml::Value::Sequence(seq)
}
fn add_comment_impl(
project_id: &str,
task_id: &str,
text: &str,
) -> Result<TaskWithContent, String> {
let task_path = find_task_path(project_id, task_id)?;
let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?;
let (mut fm, body, _) = frontmatter::parse_frontmatter(&existing);
// Parse existing comments and append the new one
let mut comments = parse_comments(&fm);
let now = chrono::Utc::now().to_rfc3339();
comments.push(Comment {
date: now.clone(),
text: text.to_string(),
});
// Write comments back to frontmatter
fm.insert(
serde_yaml::Value::from("comments"),
comments_to_yaml(&comments),
);
// Update timestamp
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now),
);
let new_content = frontmatter::serialize_frontmatter(&fm, &body)?;
filesystem::atomic_write(&task_path, new_content.as_bytes())?;
parse_task_with_content(&fm, &body, &task_path, project_id)
}
fn delete_comment_impl(
project_id: &str,
task_id: &str,
comment_index: usize,
) -> Result<TaskWithContent, String> {
let task_path = find_task_path(project_id, task_id)?;
let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?;
let (mut fm, body, _) = frontmatter::parse_frontmatter(&existing);
let mut comments = parse_comments(&fm);
if comment_index >= comments.len() {
return Err("Comment index out of range".to_string());
}
comments.remove(comment_index);
// Write comments back (or remove key if empty)
if comments.is_empty() {
fm.remove(&serde_yaml::Value::from("comments"));
} else {
fm.insert(
serde_yaml::Value::from("comments"),
comments_to_yaml(&comments),
);
}
// Update timestamp
let now = chrono::Utc::now().to_rfc3339();
fm.insert(
serde_yaml::Value::from("updated"),
serde_yaml::Value::from(now),
);
let new_content = frontmatter::serialize_frontmatter(&fm, &body)?;
filesystem::atomic_write(&task_path, new_content.as_bytes())?;
parse_task_with_content(&fm, &body, &task_path, project_id)
}
fn delete_task_impl(project_id: &str, task_id: &str) -> Result<(), String> {
let task_path = find_task_path(project_id, task_id)?;

View File

@@ -1,3 +1,5 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use chrono::Utc;
@@ -96,6 +98,171 @@ pub struct RemoteInfo {
/// The background task simply tries to commit every interval;
/// commit_all() already handles "no changes" gracefully.
#[derive(Debug, Clone)]
struct GitAuthConfig {
username: String,
private_key: Option<PathBuf>,
public_key: Option<PathBuf>,
passphrase: Option<String>,
}
fn git_auth_config() -> GitAuthConfig {
let username = std::env::var("IRONPAD_GIT_SSH_USERNAME").unwrap_or_else(|_| "git".to_string());
let private_key = std::env::var("IRONPAD_GIT_SSH_PRIVATE_KEY")
.ok()
.map(PathBuf::from)
.filter(|p| p.exists());
let public_key = std::env::var("IRONPAD_GIT_SSH_PUBLIC_KEY")
.ok()
.map(PathBuf::from)
.filter(|p| p.exists());
let passphrase = std::env::var("IRONPAD_GIT_SSH_PASSPHRASE")
.ok()
.and_then(|s| {
let trimmed = s.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
});
GitAuthConfig {
username,
private_key,
public_key,
passphrase,
}
}
fn remote_callbacks() -> git2::RemoteCallbacks<'static> {
let auth = git_auth_config();
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(move |_url, username_from_url, _allowed_types| {
// Always prefer configured username (env-driven).
// Some remotes embed a username in URL; that can cause auth mismatches.
let username = auth.username.as_str();
if let Some(url_user) = username_from_url {
if url_user != username {
tracing::warn!(
"Remote URL username '{}' differs from IRONPAD_GIT_SSH_USERNAME '{}'; using env value",
url_user,
username
);
}
}
if let Some(private_key) = auth.private_key.as_deref() {
let public_key: Option<&Path> = auth.public_key.as_deref();
let passphrase = auth.passphrase.as_deref();
// First try with configured public key path (if provided),
// then retry without public key file to avoid mismatch issues.
if let Some(pub_key_path) = public_key {
match git2::Cred::ssh_key(username, Some(pub_key_path), private_key, passphrase) {
Ok(cred) => return Ok(cred),
Err(e) => {
tracing::warn!(
"SSH key auth with explicit public key failed for user '{}', private '{}', public '{}': {}",
username,
private_key.display(),
pub_key_path.display(),
e
);
}
}
}
match git2::Cred::ssh_key(username, None, private_key, passphrase) {
Ok(cred) => return Ok(cred),
Err(e) => {
tracing::warn!(
"SSH key auth from private key failed for user '{}', key '{}': {}",
username,
private_key.display(),
e
);
}
}
} else {
tracing::warn!(
"IRONPAD_GIT_SSH_PRIVATE_KEY not set or file missing; falling back to SSH agent"
);
}
git2::Cred::ssh_key_from_agent(username)
});
callbacks
}
fn use_git_cli_fallback() -> bool {
std::env::var("IRONPAD_GIT_USE_CLI_FALLBACK")
.map(|v| !matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off"))
.unwrap_or(true)
}
fn known_hosts_path() -> Option<PathBuf> {
if let Ok(path) = std::env::var("IRONPAD_GIT_SSH_KNOWN_HOSTS") {
let p = PathBuf::from(path);
if p.exists() {
return Some(p);
}
}
let default = PathBuf::from("/root/.ssh/known_hosts");
if default.exists() {
Some(default)
} else {
None
}
}
fn git_ssh_command(auth: &GitAuthConfig) -> Option<String> {
let private_key = auth.private_key.as_ref()?;
let mut cmd = format!(
"ssh -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes",
private_key.display()
);
if let Some(known_hosts) = known_hosts_path() {
cmd.push_str(&format!(" -o UserKnownHostsFile={}", known_hosts.display()));
}
Some(cmd)
}
fn run_git_cli(args: &[&str]) -> Result<(), String> {
let auth = git_auth_config();
let data_path = config::data_dir();
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(data_path)
.env("GIT_TERMINAL_PROMPT", "0");
if let Some(ssh_cmd) = git_ssh_command(&auth) {
cmd.env("GIT_SSH_COMMAND", ssh_cmd);
}
let output = cmd
.output()
.map_err(|e| format!("Failed to run git CLI: {}", e))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let msg = if !stderr.is_empty() { stderr } else { stdout };
Err(format!(
"git {} failed (exit {}): {}",
args.join(" "),
output.status.code().unwrap_or(-1),
msg
))
}
}
/// Get repository status
pub fn get_status() -> Result<RepoStatus, String> {
let data_path = config::data_dir();
@@ -317,27 +484,33 @@ pub fn push_to_remote() -> Result<(), String> {
return Err("No remote URL configured".to_string());
}
// Create callbacks for authentication
let mut callbacks = git2::RemoteCallbacks::new();
// Try to use credential helper from git config
callbacks.credentials(|_url, username_from_url, _allowed_types| {
// Try SSH agent first
git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
});
// Set up push options
let mut push_options = git2::PushOptions::new();
push_options.remote_callbacks(callbacks);
push_options.remote_callbacks(remote_callbacks());
// Push the current branch
let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
remote
.push(&[&refspec], Some(&mut push_options))
.map_err(|e| format!("Push failed: {}. Make sure SSH keys are configured.", e))?;
tracing::info!("Successfully pushed to origin/{}", branch_name);
Ok(())
match remote.push(&[&refspec], Some(&mut push_options)) {
Ok(_) => {
tracing::info!("Successfully pushed to origin/{}", branch_name);
Ok(())
}
Err(e) => {
let err_text = e.to_string();
if !use_git_cli_fallback() {
return Err(format!(
"Push failed: {}. Make sure SSH keys are configured.",
err_text
));
}
tracing::warn!("libgit2 push failed, trying git CLI fallback: {}", err_text);
run_git_cli(&["push", "origin", branch_name]).map_err(|cli_err| {
format!("Push failed: {} (libgit2) / {} (git CLI)", err_text, cli_err)
})?;
tracing::info!("Successfully pushed to origin/{} (git CLI fallback)", branch_name);
Ok(())
}
}
}
/// Check if remote is configured
@@ -351,6 +524,30 @@ pub fn has_remote() -> bool {
false
}
/// Configure `origin` from IRONPAD_GIT_REMOTE_URL.
/// If `origin` exists, updates its URL. Otherwise creates it.
pub fn configure_remote_from_env() -> Result<(), String> {
let remote_url = match std::env::var("IRONPAD_GIT_REMOTE_URL") {
Ok(url) if !url.trim().is_empty() => url,
_ => return Ok(()),
};
let data_path = config::data_dir();
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
if repo.find_remote("origin").is_ok() {
repo.remote_set_url("origin", &remote_url)
.map_err(|e| format!("Failed to update origin URL: {}", e))?;
tracing::info!("Updated git remote origin from IRONPAD_GIT_REMOTE_URL");
} else {
repo.remote("origin", &remote_url)
.map_err(|e| format!("Failed to create origin remote: {}", e))?;
tracing::info!("Configured git remote origin from IRONPAD_GIT_REMOTE_URL");
}
Ok(())
}
/// Start auto-commit background task.
/// Tries to commit every 60 seconds; commit_all() already handles "no changes" gracefully.
pub fn start_auto_commit() {
@@ -374,6 +571,111 @@ pub fn start_auto_commit() {
});
}
fn fast_forward_to_upstream(repo: &Repository, branch_name: &str) -> Result<(), String> {
let local_branch = repo
.find_branch(branch_name, git2::BranchType::Local)
.map_err(|e| format!("Local branch not found: {}", e))?;
let upstream_branch = match local_branch.upstream() {
Ok(upstream) => upstream,
Err(_) => return Ok(()),
};
let local_oid = match local_branch.get().target() {
Some(oid) => oid,
None => return Ok(()),
};
let upstream_oid = match upstream_branch.get().target() {
Some(oid) => oid,
None => return Ok(()),
};
let (ahead, behind) = repo
.graph_ahead_behind(local_oid, upstream_oid)
.map_err(|e| e.to_string())?;
if behind == 0 {
return Ok(());
}
if ahead > 0 {
tracing::warn!(
"Remote is ahead by {} and local is ahead by {}; skipping auto fast-forward",
behind,
ahead
);
return Ok(());
}
let refname = format!("refs/heads/{}", branch_name);
repo.reference(&refname, upstream_oid, true, "ironpad auto fast-forward")
.map_err(|e| format!("Failed to move local branch ref: {}", e))?;
repo.set_head(&refname)
.map_err(|e| format!("Failed to set HEAD: {}", e))?;
let mut checkout = git2::build::CheckoutBuilder::new();
checkout.force();
repo.checkout_head(Some(&mut checkout))
.map_err(|e| format!("Failed to checkout fast-forwarded HEAD: {}", e))?;
tracing::info!("Fast-forwarded local branch {} by {} commit(s)", branch_name, behind);
Ok(())
}
fn sync_once() -> Result<(), String> {
configure_remote_from_env()?;
if !has_remote() {
return Ok(());
}
fetch_from_remote()?;
// Try to fast-forward local branch to upstream when safe (no divergence).
let data_path = config::data_dir();
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
if let Ok(head) = repo.head() {
if let Some(branch_name) = head.shorthand() {
let _ = fast_forward_to_upstream(&repo, branch_name);
}
}
// Push local commits (if any) after fetching.
if let Err(e) = push_to_remote() {
if !(e.contains("non-fast-forward") || e.contains("rejected")) {
return Err(e);
}
tracing::warn!("Auto-sync push skipped: {}", e);
}
Ok(())
}
/// Start periodic remote sync.
/// Controlled via env vars:
/// - IRONPAD_GIT_SYNC_INTERVAL_SECS: sync interval in seconds (default 0 = disabled)
/// - IRONPAD_GIT_REMOTE_URL: optional remote URL used to create/update `origin`
pub fn start_auto_sync() {
let interval_secs = std::env::var("IRONPAD_GIT_SYNC_INTERVAL_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
if interval_secs == 0 {
tracing::info!("Git auto-sync disabled (set IRONPAD_GIT_SYNC_INTERVAL_SECS > 0 to enable)");
return;
}
tokio::spawn(async move {
let mut ticker = interval(Duration::from_secs(interval_secs));
loop {
ticker.tick().await;
if let Err(e) = sync_once() {
tracing::warn!("Git auto-sync failed: {}", e);
}
}
});
}
/// Get commit history (most recent first)
pub fn get_log(limit: Option<usize>) -> Result<Vec<CommitDetail>, String> {
let data_path = config::data_dir();
@@ -630,18 +932,20 @@ pub fn fetch_from_remote() -> Result<(), String> {
.find_remote("origin")
.map_err(|e| format!("Remote 'origin' not found: {}", e))?;
// Create callbacks for authentication
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(|_url, username_from_url, _allowed_types| {
git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
});
let mut fetch_options = git2::FetchOptions::new();
fetch_options.remote_callbacks(callbacks);
fetch_options.remote_callbacks(remote_callbacks());
remote
.fetch(&[] as &[&str], Some(&mut fetch_options), None)
.map_err(|e| format!("Fetch failed: {}", e))?;
Ok(())
match remote.fetch(&[] as &[&str], Some(&mut fetch_options), None) {
Ok(_) => Ok(()),
Err(e) => {
let err_text = e.to_string();
if !use_git_cli_fallback() {
return Err(format!("Fetch failed: {}", err_text));
}
tracing::warn!("libgit2 fetch failed, trying git CLI fallback: {}", err_text);
run_git_cli(&["fetch", "origin", "--prune"]).map_err(|cli_err| {
format!("Fetch failed: {} (libgit2) / {} (git CLI)", err_text, cli_err)
})
}
}
}

40
build-local.ps1 Normal file
View File

@@ -0,0 +1,40 @@
# build-local.ps1 — Build a local release package for testing
# Creates: release/ironpad.exe + release/static/
# Usage: .\build-local.ps1
$ErrorActionPreference = "Stop"
$root = $PSScriptRoot
Write-Host "`n=== Ironpad Local Build ===" -ForegroundColor Cyan
# 1. Build frontend
Write-Host "`n[1/4] Building frontend..." -ForegroundColor Yellow
Push-Location "$root\frontend"
npm run build
if ($LASTEXITCODE -ne 0) { Pop-Location; throw "Frontend build failed" }
Pop-Location
# 2. Build backend (release)
Write-Host "`n[2/4] Building backend (release)..." -ForegroundColor Yellow
Push-Location "$root\backend"
cargo build --release
if ($LASTEXITCODE -ne 0) { Pop-Location; throw "Backend build failed" }
Pop-Location
# 3. Assemble release folder
Write-Host "`n[3/4] Assembling release folder..." -ForegroundColor Yellow
$releaseDir = "$root\release"
if (Test-Path $releaseDir) { Remove-Item -Recurse -Force $releaseDir }
New-Item -ItemType Directory -Path $releaseDir | Out-Null
New-Item -ItemType Directory -Path "$releaseDir\static" | Out-Null
Copy-Item "$root\backend\target\release\ironpad.exe" "$releaseDir\ironpad.exe"
Copy-Item "$root\frontend\dist\*" "$releaseDir\static\" -Recurse
# 4. Done
$size = [math]::Round((Get-Item "$releaseDir\ironpad.exe").Length / 1MB, 1)
Write-Host "`n[4/4] Done!" -ForegroundColor Green
Write-Host " Binary: $releaseDir\ironpad.exe ($size MB)"
Write-Host " Static: $releaseDir\static\"
Write-Host "`nTo run: cd release && .\ironpad.exe" -ForegroundColor Cyan
Write-Host "(Tray icon will appear - right-click for menu)`n"

0
deploy/ssh/.gitkeep Normal file
View File

32
docker-compose.yml Normal file
View File

@@ -0,0 +1,32 @@
services:
ironpad:
container_name: ironpad
restart: unless-stopped
build:
context: .
dockerfile: Dockerfile
environment:
IRONPAD_HOST: "0.0.0.0"
IRONPAD_PORT: "3000"
IRONPAD_DISABLE_TRAY: "1"
IRONPAD_DATA_DIR: "/app/data"
RUST_LOG: "info"
# Git sync (optional)
# IRONPAD_GIT_REMOTE_URL: "git@github.com:your-org/your-private-repo.git"
# IRONPAD_GIT_SSH_USERNAME: "git"
# IRONPAD_GIT_SSH_PRIVATE_KEY: "/run/secrets/ironpad_ssh/id_ed25519"
# IRONPAD_GIT_SSH_PUBLIC_KEY: "/run/secrets/ironpad_ssh/id_ed25519.pub"
# IRONPAD_GIT_SSH_KNOWN_HOSTS: "/run/secrets/ironpad_ssh/known_hosts"
# IRONPAD_GIT_SSH_PASSPHRASE: ""
# IRONPAD_GIT_SYNC_INTERVAL_SECS: "300"
# IRONPAD_GIT_USE_CLI_FALLBACK: "true"
ports:
- "3000:3000"
volumes:
- ./data:/app/data
# Mount SSH key material read-only for private remote auth
# - ./deploy/ssh:/run/secrets/ironpad_ssh:ro

View File

@@ -267,7 +267,12 @@ GET /api/projects/:id/tasks
"priority": "high",
"due_date": "2026-02-10",
"is_active": true,
"content": "## Requirements\n\n- Item 1\n- Item 2",
"tags": ["backend", "api"],
"parent_id": null,
"recurrence": null,
"recurrence_interval": null,
"project_id": "ferrite",
"last_comment": "API endpoint done, moving to frontend",
"path": "projects/ferrite/tasks/task-20260205-123456.md",
"created": "2026-02-05T12:34:56Z",
"updated": "2026-02-05T12:34:56Z"
@@ -338,6 +343,72 @@ PUT /api/projects/:id/tasks/:taskId/toggle
DELETE /api/projects/:id/tasks/:taskId
```
### Add Comment
```http
POST /api/projects/:id/tasks/:taskId/comments
Content-Type: application/json
{
"text": "Started work on this API integration is in progress."
}
```
**Response:** `201 Created`
```json
{
"id": "task-20260216-120000",
"title": "Implement feature X",
"completed": false,
"section": "Active",
"is_active": true,
"comments": [
{
"date": "2026-02-16T10:30:00+00:00",
"text": "Created initial spec"
},
{
"date": "2026-02-16T12:00:00+00:00",
"text": "Started work on this — API integration is in progress."
}
],
"content": "## Requirements\n\n- Item 1\n- Item 2",
"...": "other task fields"
}
```
Comments are stored as a YAML sequence in the task's frontmatter. The response returns the full `TaskWithContent` object with all comments.
### Delete Comment
```http
DELETE /api/projects/:id/tasks/:taskId/comments/:commentIndex
```
Removes the comment at the given zero-based index.
**Response:**
```json
{
"id": "task-20260216-120000",
"comments": [],
"...": "full TaskWithContent"
}
```
### Comment in List Views
When listing tasks (`GET /api/projects/:id/tasks` or `GET /api/tasks`), each task includes a `last_comment` field with the text of the most recent comment (or `null` if no comments exist). This enables showing a quick status summary without loading the full task.
```json
{
"id": "task-20260216-120000",
"title": "Implement feature X",
"last_comment": "Started work on this — API integration is in progress.",
"...": "other task fields"
}
```
---
## All Tasks

View File

@@ -320,6 +320,11 @@ interface Project {
### Task
```typescript
interface Comment {
date: string // ISO 8601 timestamp
text: string // Comment body
}
interface Task {
id: string // e.g., "task-20260205-123456"
title: string
@@ -328,13 +333,52 @@ interface Task {
priority?: string
due_date?: string
is_active: boolean
content: string // Markdown description
tags: string[]
parent_id?: string // Links subtask to parent
recurrence?: string // "daily" | "weekly" | "monthly" | "yearly"
recurrence_interval?: number
last_comment?: string // Most recent comment text (list views)
path: string
created: string
updated: string
}
interface TaskWithContent extends Task {
content: string // Markdown description
comments: Comment[] // Full comment history
}
```
#### Task File Format
```markdown
---
id: ferrite-task-20260216-120000
type: task
title: Implement feature X
completed: false
section: Active
priority: normal
is_active: true
tags:
- backend
- api
comments:
- date: "2026-02-16T10:30:00+00:00"
text: Started initial research
- date: "2026-02-16T14:00:00+00:00"
text: API endpoint done, moving to frontend
created: "2026-02-16T12:00:00+00:00"
updated: "2026-02-16T14:00:00+00:00"
---
# Implement feature X
Detailed description in markdown...
```
Comments are stored as a YAML sequence directly in frontmatter, keeping everything in a single file. The `last_comment` field in list views is derived at read time from the last entry in the sequence.
## API Design
### REST Conventions

View File

@@ -0,0 +1,8 @@
{
"recent_files": [],
"expanded_paths": [
"G:\\DEV\\proman2k\\docs\\ai-workflow"
],
"file_tree_width": 250.0,
"show_file_tree": true
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

BIN
docs/graphics/roadmap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

View File

@@ -0,0 +1,108 @@
# System Tray Implementation (v0.2.0) ✅
**Status:** Implemented
**Goal:** Replace the CMD window with a system tray icon. Users interact via tray menu: "Open in Browser" or "Quit". No console window on Windows.
---
## Overview
- **Scope:** Single codebase, cross-platform. CI/CD unchanged — same build pipeline produces one binary per OS.
- **Crate:** `tray-item = "0.10"` (cross-platform tray icon with simple API)
- **Windows icon:** `windows-sys = "0.52"` for loading the standard application icon via `LoadIconW`
---
## What Was Implemented
### 1. Dependencies (`backend/Cargo.toml`)
```toml
# System tray (production mode)
tray-item = "0.10"
# Windows system icon loading (for tray icon)
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.52", features = ["Win32_UI_WindowsAndMessaging"] }
```
### 2. Windows: Hide Console Window (`backend/src/main.rs`)
```rust
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
```
- **Debug builds (`cargo run`):** Console remains for logs.
- **Release builds (`cargo build --release`):** No CMD window on Windows.
### 3. Restructured `main()` for Dual-Mode Operation
```
main()
├── Development mode (no static/index.html next to exe)
│ └── Normal tokio runtime on main thread, API-only, no tray
└── Production mode (static/index.html exists)
└── run_with_tray()
├── Background thread: tokio runtime + Axum server
├── mpsc channel: server sends bound port back to main thread
├── Auto-open browser (400ms delay)
└── Main thread: tray icon + event loop
```
### 4. Tray Icon (Platform-Specific)
| Platform | Icon Source |
|----------|------------|
| Windows | `IDI_APPLICATION` via `LoadIconW` (standard system app icon) |
| macOS | `IconSource::Resource("")` (default icon) |
| Linux | `IconSource::Resource("application-x-executable")` (icon theme) |
### 5. Tray Menu
- **"Open in Browser"** — calls `webbrowser::open()` with `http://localhost:{port}`
- **"Quit"** — calls `std::process::exit(0)` to shut down server and exit
### 6. Threading Model
The tray event loop runs on the **main thread** (required on macOS, safe on all platforms). The Axum server runs on a **background thread** with its own `tokio::runtime::Runtime`. Port discovery uses `std::sync::mpsc::channel`.
---
## Behaviour Summary
| Before (v0.1.0) | After (v0.2.0) |
|------------------------|------------------------------------|
| CMD window visible | No console window (Windows release)|
| Browser opens on start | Browser opens on start + via tray |
| Quit via Ctrl+C | Quit via tray menu or Ctrl+C (dev) |
---
## Testing Checklist
- [x] Backend compiles with new dependencies (`cargo check`)
- [ ] Windows: No CMD window when running release binary
- [ ] Windows: Tray icon appears; "Open in Browser" opens correct URL
- [ ] Windows: "Quit" exits cleanly
- [ ] macOS: Tray icon in menu bar; menu works
- [ ] Linux: Tray icon in system tray; menu works
- [ ] Development mode (`cargo run`): Behaviour unchanged (no tray, API-only)
---
## Icon Asset
Currently using platform default icons (Windows system app icon, macOS default, Linux icon theme). To use a custom branded icon:
1. Create a 16×16 or 32×32 PNG/ICO
2. On Windows: embed via `.rc` resource file and `build.rs`, use `IconSource::Resource("icon-name")`
3. On macOS/Linux: use `IconSource::Data { width, height, data }` with raw RGBA bytes
---
## References
- [tray-item crate](https://crates.io/crates/tray-item)
- [tray-icon crate](https://crates.io/crates/tray-icon) (alternative, heavier)
- `#![windows_subsystem = "windows"]` — [Rust conditional compilation](https://doc.rust-lang.org/reference/conditional-compilation.html#windows_subsystem)

View File

@@ -6,6 +6,8 @@
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="apple-touch-icon" href="/logo-180.png" />
<title>Ironpad</title>
<script>
// Apply saved theme immediately to prevent flash

View File

@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
frontend/public/logo-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -15,7 +15,8 @@ import type {
CommitDetail,
DiffInfo,
RemoteInfo,
DailyNote
DailyNote,
Comment
} from '../types'
const API_BASE = '/api'
@@ -150,6 +151,20 @@ export const tasksApi = {
delete: (projectId: string, taskId: string) =>
request<void>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`, {
method: 'DELETE'
}),
// Add a comment to a task
addComment: (projectId: string, taskId: string, text: string) =>
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
}),
// Delete a comment from a task by index
deleteComment: (projectId: string, taskId: string, commentIndex: number) =>
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/comments/${commentIndex}`, {
method: 'DELETE'
})
}

View File

@@ -65,7 +65,10 @@ function goHome() {
<template>
<header class="topbar">
<div class="topbar-left">
<h1 class="app-title" @click="goHome" style="cursor: pointer" title="Dashboard">Ironpad</h1>
<h1 class="app-title" @click="goHome" style="cursor: pointer" title="Dashboard">
<img src="/logo-32.png" alt="" class="app-logo" />
Ironpad
</h1>
<div class="project-selector">
<button class="project-button" @click="toggleDropdown">
@@ -175,6 +178,15 @@ function goHome() {
font-weight: 600;
margin: 0;
color: var(--color-text);
display: flex;
align-items: center;
gap: 8px;
}
.app-logo {
width: 22px;
height: 22px;
object-fit: contain;
}
.project-selector {

View File

@@ -181,6 +181,42 @@ export const useTasksStore = defineStore('tasks', () => {
}
}
async function addComment(projectId: string, taskId: string, text: string) {
try {
error.value = null
const task = await tasksApi.addComment(projectId, taskId, text)
selectedTask.value = task
// Refresh task list so last_comment updates
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
return task
} catch (err) {
error.value = `Failed to add comment: ${err}`
throw err
}
}
async function deleteComment(projectId: string, taskId: string, commentIndex: number) {
try {
error.value = null
const task = await tasksApi.deleteComment(projectId, taskId, commentIndex)
selectedTask.value = task
// Refresh task list so last_comment updates
if (currentProjectId.value === projectId) {
await loadProjectTasks(projectId)
}
return task
} catch (err) {
error.value = `Failed to delete comment: ${err}`
throw err
}
}
function selectTask(task: Task | null) {
if (task && currentProjectId.value) {
loadTask(currentProjectId.value, task.id)
@@ -227,6 +263,8 @@ export const useTasksStore = defineStore('tasks', () => {
toggleTask,
updateTaskMeta,
deleteTask,
addComment,
deleteComment,
selectTask,
clearSelectedTask,
clearProjectTasks,

View File

@@ -40,6 +40,11 @@ export interface ProjectNoteWithContent extends ProjectNote {
content: string
}
export interface Comment {
date: string
text: string
}
export interface Task {
id: string
title: string
@@ -56,10 +61,12 @@ export interface Task {
path: string
created: string
updated: string
last_comment?: string
}
export interface TaskWithContent extends Task {
content: string
comments: Comment[]
}
export interface SearchResult {

View File

@@ -93,21 +93,144 @@ function formatDate(y: number, m: number, d: number): string {
return `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
}
// Tasks grouped by due date
// Calendar entry: a task plus whether it's a recurring occurrence (vs a real due_date)
interface CalendarEntry {
task: Task
isRecurring: boolean
}
// Tasks grouped by due date (regular, non-recurring placements)
const tasksByDate = computed(() => {
const map = new Map<string, Task[]>()
const map = new Map<string, CalendarEntry[]>()
for (const task of tasksStore.allTasks) {
if (task.due_date && !task.completed) {
const existing = map.get(task.due_date) || []
existing.push(task)
existing.push({ task, isRecurring: false })
map.set(task.due_date, existing)
}
}
return map
})
function getTasksForDate(dateStr: string): Task[] {
return tasksByDate.value.get(dateStr) || []
// Expand recurring tasks into the visible calendar range
const recurringByDate = computed(() => {
const map = new Map<string, CalendarEntry[]>()
const days = calendarDays.value
if (days.length === 0) return map
const rangeStart = days[0].date
const rangeEnd = days[days.length - 1].date
for (const task of tasksStore.allTasks) {
if (task.completed || !task.recurrence) continue
const interval = task.recurrence_interval || 1
const anchorStr = task.due_date || task.created?.split('T')[0]
if (!anchorStr) continue
const anchor = new Date(anchorStr + 'T00:00:00')
const start = new Date(rangeStart + 'T00:00:00')
const end = new Date(rangeEnd + 'T00:00:00')
const occurrences: string[] = []
switch (task.recurrence) {
case 'daily': {
let cur = new Date(anchor)
// Advance to range start (skip past occurrences efficiently)
if (cur < start) {
const daysBehind = Math.floor((start.getTime() - cur.getTime()) / 86400000)
const skipCycles = Math.floor(daysBehind / interval) * interval
cur.setDate(cur.getDate() + skipCycles)
}
while (cur <= end && occurrences.length < 60) {
if (cur >= start) {
occurrences.push(dateFromObj(cur))
}
cur.setDate(cur.getDate() + interval)
}
break
}
case 'weekly': {
const targetDow = anchor.getDay()
let cur = new Date(start)
// Find first matching weekday in range
while (cur.getDay() !== targetDow && cur <= end) {
cur.setDate(cur.getDate() + 1)
}
while (cur <= end && occurrences.length < 10) {
// Check interval alignment (weeks since anchor)
const msDiff = cur.getTime() - anchor.getTime()
const weeksDiff = Math.round(msDiff / (7 * 86400000))
if (weeksDiff >= 0 && weeksDiff % interval === 0) {
occurrences.push(dateFromObj(cur))
}
cur.setDate(cur.getDate() + 7)
}
break
}
case 'monthly': {
const targetDay = anchor.getDate()
// Check months visible in the calendar range (usually 3: prev, current, next)
for (let mOffset = -1; mOffset <= 1; mOffset++) {
let y = currentYear.value
let m = currentMonth.value + mOffset
if (m < 0) { m = 11; y-- }
if (m > 11) { m = 0; y++ }
const daysInM = new Date(y, m + 1, 0).getDate()
const day = Math.min(targetDay, daysInM)
const dateStr = formatDate(y, m, day)
if (dateStr >= rangeStart && dateStr <= rangeEnd) {
const monthsDiff = (y - anchor.getFullYear()) * 12 + (m - anchor.getMonth())
if (monthsDiff >= 0 && monthsDiff % interval === 0) {
occurrences.push(dateStr)
}
}
}
break
}
case 'yearly': {
const tgtMonth = anchor.getMonth()
const tgtDay = anchor.getDate()
const y = currentYear.value
const dateStr = formatDate(y, tgtMonth, tgtDay)
if (dateStr >= rangeStart && dateStr <= rangeEnd) {
const yearsDiff = y - anchor.getFullYear()
if (yearsDiff >= 0 && yearsDiff % interval === 0) {
occurrences.push(dateStr)
}
}
break
}
}
for (const dateStr of occurrences) {
// Skip if the task already appears on this date via its due_date
if (task.due_date === dateStr) continue
const existing = map.get(dateStr) || []
existing.push({ task, isRecurring: true })
map.set(dateStr, existing)
}
}
return map
})
function dateFromObj(d: Date): string {
return formatDate(d.getFullYear(), d.getMonth(), d.getDate())
}
// Merge regular due-date tasks and recurring occurrences for a given date
function getEntriesForDate(dateStr: string): CalendarEntry[] {
const regular = tasksByDate.value.get(dateStr) || []
const recurring = recurringByDate.value.get(dateStr) || []
return [...regular, ...recurring]
}
// Convenience: check if a date has any tasks (for cell styling)
function hasTasksOnDate(dateStr: string): boolean {
return getEntriesForDate(dateStr).length > 0
}
function hasDailyNote(dateStr: string): boolean {
@@ -211,7 +334,7 @@ onMounted(async () => {
:class="['calendar-cell', {
'other-month': !day.isCurrentMonth,
'is-today': day.isToday,
'has-tasks': getTasksForDate(day.date).length > 0,
'has-tasks': hasTasksOnDate(day.date),
}]"
>
<div class="cell-header" @click="clickDate(day.date)">
@@ -220,20 +343,21 @@ onMounted(async () => {
</div>
<div class="cell-tasks">
<div
v-for="task in getTasksForDate(day.date).slice(0, 3)"
:key="task.id"
:class="['cell-task', formatDueClass(day.date)]"
@click.stop="clickTask(task)"
:title="`${projectName(task.project_id)}: ${task.title}`"
v-for="entry in getEntriesForDate(day.date).slice(0, 3)"
:key="`${entry.task.id}-${entry.isRecurring ? 'r' : 'd'}`"
:class="['cell-task', formatDueClass(day.date), { recurring: entry.isRecurring }]"
@click.stop="clickTask(entry.task)"
:title="`${projectName(entry.task.project_id)}: ${entry.task.title}${entry.isRecurring ? ' (recurring)' : ''}`"
>
{{ task.title }}
<span v-if="entry.isRecurring" class="recurring-icon" title="Recurring">&#x21bb;</span>
{{ entry.task.title }}
</div>
<div
v-if="getTasksForDate(day.date).length > 3"
v-if="getEntriesForDate(day.date).length > 3"
class="cell-more"
@click="clickDate(day.date)"
>
+{{ getTasksForDate(day.date).length - 3 }} more
+{{ getEntriesForDate(day.date).length - 3 }} more
</div>
</div>
</div>
@@ -413,6 +537,17 @@ onMounted(async () => {
border-left-color: var(--color-warning);
}
.cell-task.recurring {
border-left-style: dashed;
opacity: 0.85;
}
.recurring-icon {
font-size: 10px;
margin-right: 2px;
opacity: 0.7;
}
.cell-more {
font-size: 10px;
color: var(--color-text-secondary);

View File

@@ -140,17 +140,24 @@ onMounted(async () => {
@click="goToTask(project.id, task)"
>
<span class="task-checkbox">&#9744;</span>
<span class="task-title">{{ task.title }}</span>
<div class="task-meta">
<span
v-for="tag in task.tags?.slice(0, 2)"
:key="tag"
class="task-tag"
>{{ tag }}</span>
<span
v-if="task.due_date && formatDueDate(task.due_date)"
:class="['task-due', formatDueDate(task.due_date)?.class]"
>{{ formatDueDate(task.due_date)?.text }}</span>
<div class="card-task-info">
<div class="card-task-row">
<span class="task-title">{{ task.title }}</span>
<div class="task-meta">
<span
v-for="tag in task.tags?.slice(0, 2)"
:key="tag"
class="task-tag"
>{{ tag }}</span>
<span
v-if="task.due_date && formatDueDate(task.due_date)"
:class="['task-due', formatDueDate(task.due_date)?.class]"
>{{ formatDueDate(task.due_date)?.text }}</span>
</div>
</div>
<div v-if="task.last_comment" class="card-task-comment">
{{ task.last_comment }}
</div>
</div>
</div>
<div
@@ -304,6 +311,20 @@ onMounted(async () => {
font-size: 14px;
}
.card-task-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.card-task-row {
display: flex;
align-items: center;
gap: 8px;
}
.card-task-item .task-title {
flex: 1;
font-size: 13px;
@@ -313,6 +334,16 @@ onMounted(async () => {
min-width: 0;
}
.card-task-comment {
font-size: 11px;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-style: italic;
opacity: 0.75;
}
.task-meta {
display: flex;
gap: 4px;

View File

@@ -338,6 +338,59 @@ async function addSubtask() {
}
}
// ============ Comment Management ============
const newCommentText = ref('')
const showCommentForm = ref(false)
function openCommentForm() {
showCommentForm.value = true
newCommentText.value = ''
}
function closeCommentForm() {
showCommentForm.value = false
newCommentText.value = ''
}
async function addComment() {
if (!newCommentText.value.trim() || !tasksStore.selectedTask) return
try {
await tasksStore.addComment(projectId.value, tasksStore.selectedTask.id, newCommentText.value.trim())
closeCommentForm()
} catch {
// Error handled in store
}
}
async function deleteComment(index: number) {
if (!tasksStore.selectedTask) return
try {
await tasksStore.deleteComment(projectId.value, tasksStore.selectedTask.id, index)
} catch {
// Error handled in store
}
}
function formatCommentDate(dateStr: string): string {
try {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
} catch {
return dateStr
}
}
// ============ Due Date Management ============
async function setDueDate(date: string) {
@@ -650,6 +703,9 @@ onUnmounted(() => {
{{ formatDueDate(task.due_date)?.text }}
</span>
</div>
<div v-if="task.last_comment" class="task-last-comment">
{{ task.last_comment }}
</div>
</div>
</template>
<button
@@ -718,6 +774,9 @@ onUnmounted(() => {
{{ formatDueDate(task.due_date)?.text }}
</span>
</div>
<div v-if="task.last_comment" class="task-last-comment">
{{ task.last_comment }}
</div>
</div>
</template>
<button
@@ -921,6 +980,49 @@ onUnmounted(() => {
<button class="tag-add-btn" @click="openSubtaskInput">+ Add subtask</button>
</div>
<!-- Comments Section -->
<div class="comments-panel">
<div class="comments-header">
<span class="comments-label">Comments ({{ tasksStore.selectedTask.comments?.length || 0 }})</span>
<button v-if="!showCommentForm" class="tag-add-btn" @click="openCommentForm">+ Comment</button>
</div>
<!-- Add comment form -->
<div v-if="showCommentForm" class="comment-form">
<textarea
v-model="newCommentText"
class="comment-input"
placeholder="Add a comment or status update..."
rows="2"
@keydown.ctrl.enter="addComment"
@keyup.escape="closeCommentForm"
autofocus
></textarea>
<div class="comment-form-actions">
<button class="primary small" @click="addComment" :disabled="!newCommentText.trim()">Add Comment</button>
<button class="small" @click="closeCommentForm">Cancel</button>
<span class="comment-hint">Ctrl+Enter to submit</span>
</div>
</div>
<!-- Comment list (newest first) -->
<div v-if="tasksStore.selectedTask.comments?.length > 0" class="comment-list">
<div
v-for="(comment, index) in [...tasksStore.selectedTask.comments].reverse()"
:key="index"
class="comment-item"
>
<div class="comment-meta">
<span class="comment-date">{{ formatCommentDate(comment.date) }}</span>
<button
class="comment-delete"
@click="deleteComment(tasksStore.selectedTask.comments.length - 1 - index)"
title="Delete comment"
>&times;</button>
</div>
<div class="comment-text">{{ comment.text }}</div>
</div>
</div>
</div>
<div class="editor-content">
<div class="editor-pane">
<MilkdownEditor
@@ -1507,6 +1609,22 @@ button.small {
color: white;
}
/* Last comment preview in task list */
.task-last-comment {
font-size: 11px;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
opacity: 0.75;
font-style: italic;
}
.task-item.selected .task-last-comment {
color: rgba(255, 255, 255, 0.7);
}
/* Tag Editor Bar (detail panel) */
.tag-editor-bar {
display: flex;
@@ -1601,6 +1719,118 @@ button.small {
background: var(--color-bg-hover);
}
/* Comments Panel */
.comments-panel {
border-bottom: 1px solid var(--color-border);
padding: 8px 16px;
flex-shrink: 0;
max-height: 280px;
overflow-y: auto;
}
.comments-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.comments-label {
font-size: 11px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.comment-form {
margin-bottom: 8px;
}
.comment-input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font-size: 13px;
font-family: inherit;
outline: none;
resize: vertical;
min-height: 48px;
box-sizing: border-box;
}
.comment-input:focus {
border-color: var(--color-primary);
}
.comment-form-actions {
display: flex;
gap: 8px;
align-items: center;
margin-top: 6px;
}
.comment-hint {
font-size: 11px;
color: var(--color-text-secondary);
margin-left: auto;
}
.comment-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.comment-item {
padding: 8px 10px;
border-radius: 6px;
background: var(--color-bg);
border: 1px solid var(--color-border);
}
.comment-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.comment-date {
font-size: 11px;
color: var(--color-text-secondary);
}
.comment-delete {
padding: 0 4px;
border: none;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 14px;
line-height: 1;
opacity: 0;
transition: opacity 0.15s;
}
.comment-item:hover .comment-delete {
opacity: 1;
}
.comment-delete:hover {
color: var(--color-danger);
}
.comment-text {
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
/* Error Banner */
.error-banner {
position: absolute;