BProd — Production Planner

A native desktop production planner built in Rust using eframe / egui. Create and track tasks across four views — Calendar, Task List, Kanban, and Roadmap — with drag-and-drop cards, sprints, milestones, full-text search, and persistent JSON storage, all running as a single self-contained binary with no server or database required.

Preview

Production Planner

The Idea

This project started as a way to get genuinely hands-on with Rust. Reading the book is one thing; wrestling with the borrow checker on a real, stateful GUI application is another. egui is an immediate-mode UI framework — every widget is re-drawn every frame, which maps naturally onto Rust's ownership model and gave me a crash course in lifetime management, trait design, and structuring mutable state without reaching for Rc<RefCell<>> everywhere. The planning tool itself is something I actually use, which kept the scope honest.

Four Views

The app ships four interchangeable views, switchable from the toolbar or by pressing 14. Each view is a separate rendering path over the same shared AppData struct, so changes made in one view are instantly reflected in the others.

#[derive(PartialEq, Clone, Copy)]
enum View { Calendar, TaskList, KanbanBoard, Roadmap }

impl View {
    fn next(self) -> Self {
        match self {
            View::Calendar    => View::TaskList,
            View::TaskList    => View::KanbanBoard,
            View::KanbanBoard => View::Roadmap,
            View::Roadmap     => View::Calendar,
        }
    }
}

Data Model

Every entity the planner manages is a plain Rust struct that derives Serialize / Deserialize from serde. On exit the entire app state is written to a single prod_planner_data.json sidecar file and reloaded on next launch — no migrations, no database engine.

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Task {
    id:          String,          // UUID v4
    title:       String,
    description: String,
    priority:    Priority,        // Low | Medium | High | Critical
    status:      TaskStatus,      // Todo | InProgress | Done | Cut
    due_date:    Option<NaiveDate>,
    start_date:  Option<NaiveDate>,
    assigned_to: Vec<String>,
    sprint:      Option<String>,
    milestone:   Option<String>,
    created:     NaiveDate,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct AppData {
    tasks:           Vec<Task>,
    people:          Vec<String>,
    sprints:         Vec<Sprint>,
    milestones:      Vec<Milestone>,
    archived_tasks:  Vec<Task>,
}

Kanban Drag-and-Drop

Drag-and-drop in an immediate-mode UI requires explicit per-frame state. Because egui redraws every widget on every frame, there is no persistent widget identity to hook into — drag state has to live in the app struct and be threaded through the render call on each pass.

The key trick is reading raw pointer state instead of calling .interact(Sense::click()) on the card frame. A click-sense registration would fire before child widgets like the status dropdown, silently stealing the event. Using Sense::hover() for the card background keeps child widgets fully interactive while still allowing drag detection via pointer delta.

// Begin drag: press started inside card, mouse moved enough
if drag_started.is_none() && press_in_card && primary_down && delta.length() > 4.0 {
    *drag_started   = Some(task.id.clone());
    *drag_start_pos = press_origin;
}

// Cursor feedback
if drag_started.is_some() {
    ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
} else if card_hovered {
    ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}

Undo / Redo

The planner implements a classic snapshot stack for undo. Before every mutation, the current AppData is cloned and pushed onto the undo stack (capped at 50 entries). Undo pops that snapshot, pushes the current state onto the redo stack, and saves. Redo does the reverse. Because AppData fully owns its data with no shared references, a plain .clone() is all that's needed — no deep-copy logic, no reference-counting.

/// Call BEFORE mutating data.
fn snapshot(&mut self) {
    const MAX_UNDO: usize = 50;
    self.undo_stack.push(self.data.clone());
    if self.undo_stack.len() > MAX_UNDO { self.undo_stack.remove(0); }
    self.redo_stack.clear();
}

fn undo(&mut self) {
    if let Some(prev) = self.undo_stack.pop() {
        self.redo_stack.push(self.data.clone());
        self.data = prev;
        self.save_data();
    }
}

Ctrl+Z / Ctrl+Shift+Z are intercepted only when no text field is focused, so they never fire mid-typing.

Keyboard-First Workflow

The goal was a planner that rarely requires the mouse for common operations. All shortcuts are consumed via ctx.input_mut so egui never forwards them to focused widgets by accident.

// 1/2/3/4 — switch views directly
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Num1)) { self.current_view = View::Calendar; }
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Num2)) { self.current_view = View::TaskList; }

// Alt+A — open new task form pre-filled with the selected date
let alt_a = ctx.input_mut(|i| i.consume_key(Modifiers::ALT, Key::A));
if alt_a { self.show_new_task = true; ... }

// E — edit the task currently under the pointer
let e = ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::E));

// Q / W — jump to previous / next task due date on the calendar
// Ctrl+D — duplicate hovered task
// Ctrl+F — open full-text search
// Ctrl+Z / Ctrl+Shift+Z — undo / redo
// Esc / X — close any open modal

Priority & Status Color System

Both Priority and TaskStatus implement a color() method returning an egui::Color32. This keeps the visual mapping co-located with the domain type — adding a new priority level means updating the enum and its two match arms, nothing else. Card borders additionally shift to a red or amber tint when a task is overdue or unassigned, computed fresh each frame from the current date.

impl Priority {
    fn color(&self) -> Color32 {
        match self {
            Priority::Low      => Color32::from_rgb(80,  180, 80),
            Priority::Medium   => Color32::from_rgb(220, 180, 40),
            Priority::High     => Color32::from_rgb(220, 100, 30),
            Priority::Critical => Color32::from_rgb(200, 40,  40),
        }
    }
}

// Per-card border derived at render time — no stored state needed
let border_color = if is_overdue      { Color32::from_rgb(200, 50,  50) }
                   else if is_unassigned { Color32::from_rgb(200, 160, 0) }
                   else                  { Color32::from_rgb(60,  65,  78) };

JSON Persistence

The app reads and writes a single flat JSON file using serde_json. #[serde(default)] on the archived_tasks field means files saved by older versions of the app — before the archive feature was added — load without error and silently populate the new field with an empty vec. The same pattern makes forward-compatible schema changes painless.

fn save_data(&self) {
    if let Ok(json) = serde_json::to_string_pretty(&self.data) {
        let _ = fs::write(SAVE_FILE, json);
    }
}

fn load_data() -> AppData {
    if let Ok(content) = fs::read_to_string(SAVE_FILE) {
        serde_json::from_str(&content).unwrap_or_default()
    } else {
        AppData::default()
    }
}