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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
39
CHANGELOG.md
Normal 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.
|
||||
13
README.md
13
README.md
@@ -8,13 +8,13 @@
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
> **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.
|
||||
|
||||
54
ROADMAP.md
54
ROADMAP.md
@@ -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 |
|
||||
|
||||
@@ -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
628
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
BIN
backend/assets/icon-32x32.png
Normal file
BIN
backend/assets/icon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
backend/assets/ironpad.ico
Normal file
BIN
backend/assets/ironpad.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
10
backend/build.rs
Normal file
10
backend/build.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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 3000–3010");
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
40
build-local.ps1
Normal 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"
|
||||
73
docs/API.md
73
docs/API.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 unchanged—same build pipeline produces one binary per OS.
|
||||
- **Complexity:** Low–medium. Uses a cross-platform Rust crate; platform-specific code is minimal.
|
||||
- **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`
|
||||
|
||||
---
|
||||
|
||||
## 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 crate’s 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
|
||||
|
||||
You’ll 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
BIN
frontend/public/logo-180.png
Normal file
BIN
frontend/public/logo-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/public/logo-32.png
Normal file
BIN
frontend/public/logo-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">↻</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);
|
||||
|
||||
@@ -140,17 +140,24 @@ onMounted(async () => {
|
||||
@click="goToTask(project.id, task)"
|
||||
>
|
||||
<span class="task-checkbox">☐</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;
|
||||
|
||||
@@ -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"
|
||||
>×</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;
|
||||
|
||||
Reference in New Issue
Block a user