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>
This commit is contained in:
skepsismusic
2026-02-16 13:48:54 +01:00
parent b150a243fd
commit 781ea28097
29 changed files with 1735 additions and 219 deletions

3
.gitignore vendored
View File

@@ -41,5 +41,8 @@ data/projects/*/
# === 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.

View File

@@ -8,13 +8,13 @@
![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.
---
@@ -24,14 +24,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
@@ -86,6 +87,9 @@ Open http://localhost:5173 in your browser.
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
@@ -93,7 +97,6 @@ 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.

View File

@@ -1,28 +1,30 @@
# Ironpad Roadmap
## Release 0.2.0 (Next)
## Release 0.2.0 (Current)
### Planned Features
### Features
#### 1. Task comments & activity summary
- **Comment section** per task with date-stamped entries
- Store comments (e.g. in task file as YAML sequence or dedicated section)
- **Last comment as summary** — show the most recent comment/activity in the main task list and dashboard to indicate last action or status
- Enables quick status updates without editing the full description
- 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
- **Bug/feature gap**: Tasks with daily/weekly recurrence but no explicit `due_date` currently do not appear on the calendar (calendar only shows tasks with `task.due_date`)
- **Change**: Expand recurring tasks into the calendar for the visible month:
- **Daily** — show on every day in the month (or cap at reasonable limit)
- **Weekly** — show on the matching weekday(s) in the month
- **Monthly** — show on the day-of-month if set, else treat as “floating”
- Requires frontend logic to compute occurrences from `recurrence`, `recurrence_interval`, and optionally `due_date` / `created`
- 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
- **Replace CMD window** with a system tray icon (Windows, macOS, Linux)
- System tray icon replaces CMD window (Windows, macOS, Linux)
- Tray menu: **Open in Browser** | **Quit**
- No console window on Windows in release builds
- Implementation doc: [docs/system-tray-implementation.md](docs/system-tray-implementation.md)
- 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)
---
@@ -31,18 +33,18 @@
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 (already listed in ai-context)
- **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
- **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
- **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 (Phase 6+)
### Longer term
- UI polish and subtle animations
- Responsive sidebar / mobile-friendly layout
- Global hotkey (e.g. Ctrl+Shift+Space)
@@ -56,7 +58,7 @@ Ideas that fit the current architecture and local-first design:
## Version history
| Version | Status | Notes |
|---------|---------|----------------------------------------------------|
| 0.1.0 | Current | First public release, core features in place |
| 0.2.0 | Planned | Comments, recurring tasks on calendar, system tray |
| 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 |
---

628
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]
@@ -46,4 +46,15 @@ uuid = { version = "1.0", features = ["v4"] }
# Utilities
lazy_static = "1.4"
tokio-util = { version = "0.7", features = ["io"] }
tokio-util = { version = "0.7", features = ["io"] }
# System tray (production mode)
tray-item = "0.10"
# 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,3 +1,6 @@
// Hide console window on Windows in release builds (production mode)
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::net::SocketAddr;
use std::sync::Arc;
@@ -26,17 +29,29 @@ async fn find_available_port() -> (TcpListener, u16) {
panic!("No available ports in range 30003010");
}
#[tokio::main]
async fn main() {
// Logging
fn main() {
tracing_subscriber::fmt().init();
// Resolve data directory (production vs development mode)
config::init_data_dir();
// Find port and bind (listener kept alive to avoid race condition)
if config::is_production() {
run_with_tray();
} else {
// 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());
@@ -94,7 +109,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 +130,110 @@ 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
);
}
});
}
info!("Ironpad running on http://localhost:{port}");
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,8 +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,
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;
@@ -91,6 +92,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 +152,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,43 @@ 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 +284,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 +316,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 +335,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 +433,7 @@ fn create_task_impl(
created: now_str.clone(),
updated: now_str,
content: body,
comments: Vec::new(),
})
}
@@ -407,6 +486,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 +504,7 @@ fn parse_task_with_content(
created: task.created,
updated: task.updated,
content: body.to_string(),
comments,
})
}
@@ -683,6 +764,7 @@ fn create_recurring_task_impl(
created: now_str.clone(),
updated: now_str,
content: body,
comments: Vec::new(),
})
}
@@ -776,6 +858,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)?;

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"

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

@@ -1,4 +1,6 @@
# System Tray Implementation (v0.2.0)
# 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.
@@ -6,100 +8,101 @@
## Overview
- **Scope:** Single codebase, cross-platform. CI/CD unchangedsame build pipeline produces one binary per OS.
- **Complexity:** Lowmedium. Uses a cross-platform Rust crate; platform-specific code is minimal.
- **Scope:** Single codebase, cross-platform. CI/CD unchangedsame 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`
---
## Implementation Steps
## What Was Implemented
### 1. Add Tray Crate Dependency
Add to `backend/Cargo.toml`:
### 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"] }
```
Alternative: `tray-icon` (more features, heavier; requires event loop integration).
### 2. Windows: Hide Console Window
Add near the top of `backend/src/main.rs` (after `mod` declarations if any, before `fn main`):
### 2. Windows: Hide Console Window (`backend/src/main.rs`)
```rust
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
```
- **Debug builds:** Console remains (for logs).
- **Release builds:** No CMD window on Windows.
- **Debug builds (`cargo run`):** Console remains for logs.
- **Release builds (`cargo build --release`):** No CMD window on Windows.
### 3. Remove Auto-Open Browser on Startup
### 3. Restructured `main()` for Dual-Mode Operation
In `main.rs`, remove or conditionally disable the auto-open logic (the `tokio::spawn` block that calls `webbrowser::open()`). The user will open the browser from the tray menu instead.
### 4. Add Tray Icon and Menu (Production Mode Only)
When `has_frontend` is true (production mode):
1. Create tray icon with an appropriate icon (or placeholder).
2. Add menu items:
- **"Open in Browser"** — calls `webbrowser::open()` with `http://localhost:{port}`.
- **"Quit"** — shuts down the server and exits the process.
### 5. Threading Considerations
- **macOS:** Some tray crates expect event handling on the main thread. May need to run tray logic on main thread and spawn the Axum server on a background thread, or use crate-specific patterns.
- **Windows/Linux:** Usually more flexible; verify with the chosen crates docs.
### 6. CI/CD Changes (If Needed)
Current `release.yml` builds for Windows, macOS, and Linux. Likely no changes required.
If using a crate that needs GTK on Linux (e.g. `tray-icon`), add to the "Install system dependencies (Linux)" step:
```yaml
sudo apt-get install -y cmake libgtk-3-dev libappindicator3-dev
```
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
```
Note: Linux users would then need GTK installed at runtime. For `tray-item`, check whether it has different Linux deps.
### 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) |
| Browser opens on start | Browser opens via tray menu |
| Quit via Ctrl+C | Quit via tray menu |
| 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
- [ ] 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).
- [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
Youll need a tray icon (e.g. 16×16 or 32×32 PNG). Options:
Currently using platform default icons (Windows system app icon, macOS default, Linux icon theme). To use a custom branded icon:
- Extract from existing branding/logo.
- Use a simple placeholder (e.g. filled circle) for initial implementation.
- Store in `backend/` or `backend/static/` and load at runtime.
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)
- `#![windows_subsystem = "windows"]` — [Rust embed documentation](https://doc.rust-lang.org/reference/conditional-compilation.html#windows_subsystem)
- [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;