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

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)?;