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
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
1–4. 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.
- Calendar — month grid with per-day task dots; clicking a day shows all tasks due on that date.
- Task List — filterable table with status, priority, due date, and assignee columns.
- Kanban Board — columns per status with live drag-and-drop reordering.
- Roadmap — horizontal Gantt-style timeline showing sprints, milestones, and task bars.
#[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()
}
}