Project · Tasks: priorities, issues, statuses, assignees, task rows
A new chapter, src/project. PriorityIndicator shows how urgent a task is in three bars, urgent in red, its words in a tip or beside it. IssueIdBadge names an issue by its team's key and number, as ENG-123. StatusSelect picks a status from the workflow, each with its mark, and AssigneePicker picks a person or no one; DueDatePicker is the forms DatePicker. TaskItem is a task in a row: its box, priority and issue, its title, then its due day, red when past and still open, and who has it. TaskList puts them in a SelectableList: arrows move, Enter opens, and a box's press or keys stay at the box. SubtaskList is the documents Checklist, which gains a field that adds a step on Enter. Along the way: forms::Choice::avatar leads a person's row with their initials, in lists and in a Select's trigger; ListItem::struck strikes a closed task's title; typography::Ellipsis now shapes with the text style's run, so a strike or underline on its box shows; two Lucide icons, circle-dashed and circle-dot-dashed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e2eb1cfc41
commit
9b506e5d54
20 files changed
+1417
-30
No files matched your search
@@ -0,0 +1,276 @@
|
||||
use std::fmt;
|
||||
|
||||
use gpui::SharedString;
|
||||
use jiff::civil::Date;
|
||||
|
||||
use crate::primitives::IconName;
|
||||
|
||||
/// Where a task stands in its workflow.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
Backlog,
|
||||
Todo,
|
||||
InProgress,
|
||||
InReview,
|
||||
Done,
|
||||
Canceled,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub const ALL: [Status; 6] = [
|
||||
Status::Backlog,
|
||||
Status::Todo,
|
||||
Status::InProgress,
|
||||
Status::InReview,
|
||||
Status::Done,
|
||||
Status::Canceled,
|
||||
];
|
||||
|
||||
pub fn words(self) -> &'static str {
|
||||
match self {
|
||||
Status::Backlog => "Backlog",
|
||||
Status::Todo => "Todo",
|
||||
Status::InProgress => "In progress",
|
||||
Status::InReview => "In review",
|
||||
Status::Done => "Done",
|
||||
Status::Canceled => "Canceled",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(self) -> IconName {
|
||||
match self {
|
||||
Status::Backlog => IconName::CircleDashed,
|
||||
Status::Todo => IconName::Circle,
|
||||
Status::InProgress => IconName::CircleDot,
|
||||
Status::InReview => IconName::CircleDotDashed,
|
||||
Status::Done => IconName::CircleCheck,
|
||||
Status::Canceled => IconName::CircleX,
|
||||
}
|
||||
}
|
||||
|
||||
/// Done or canceled: nothing more is owed.
|
||||
pub fn closed(self) -> bool {
|
||||
matches!(self, Status::Done | Status::Canceled)
|
||||
}
|
||||
|
||||
pub(crate) fn key(self) -> &'static str {
|
||||
match self {
|
||||
Status::Backlog => "backlog",
|
||||
Status::Todo => "todo",
|
||||
Status::InProgress => "in-progress",
|
||||
Status::InReview => "in-review",
|
||||
Status::Done => "done",
|
||||
Status::Canceled => "canceled",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn of_key(key: &str) -> Status {
|
||||
Status::ALL
|
||||
.into_iter()
|
||||
.find(|status| status.key() == key)
|
||||
.unwrap_or_else(|| panic!("no status {key}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// How urgent a task is, from none to urgent.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Priority {
|
||||
None,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
Urgent,
|
||||
}
|
||||
|
||||
impl Priority {
|
||||
pub fn words(self) -> &'static str {
|
||||
match self {
|
||||
Priority::None => "No priority",
|
||||
Priority::Low => "Low",
|
||||
Priority::Medium => "Medium",
|
||||
Priority::High => "High",
|
||||
Priority::Urgent => "Urgent",
|
||||
}
|
||||
}
|
||||
|
||||
/// Bars lit of three; urgent has a mark of its own.
|
||||
pub(crate) fn bars(self) -> usize {
|
||||
match self {
|
||||
Priority::None => 0,
|
||||
Priority::Low => 1,
|
||||
Priority::Medium => 2,
|
||||
Priority::High | Priority::Urgent => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An issue's name within its team: the team's key and a number, as ENG-123.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct IssueId {
|
||||
team: SharedString,
|
||||
number: u32,
|
||||
}
|
||||
|
||||
impl IssueId {
|
||||
/// The key is one to five capital letters, and numbers start at one.
|
||||
pub fn new(team: impl Into<SharedString>, number: u32) -> Self {
|
||||
let team = team.into();
|
||||
let letters = (1..=5).contains(&team.len()) && team.chars().all(|c| c.is_ascii_uppercase());
|
||||
assert!(letters, "issue team key {team} is not one to five capitals");
|
||||
assert!(number > 0, "issue {team} numbers start at one");
|
||||
Self { team, number }
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for IssueId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}-{}", self.team, self.number)
|
||||
}
|
||||
}
|
||||
|
||||
/// Someone work can go to.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Person {
|
||||
pub key: SharedString,
|
||||
pub name: SharedString,
|
||||
}
|
||||
|
||||
impl Person {
|
||||
pub fn new(key: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
|
||||
let key = key.into();
|
||||
assert!(!key.is_empty(), "a person needs a key");
|
||||
Self {
|
||||
key,
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A piece of work: its key, its issue's name if it has one, what it is, where it stands, how urgent, when it is due, and who has it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Task {
|
||||
pub key: SharedString,
|
||||
pub issue: Option<IssueId>,
|
||||
pub title: SharedString,
|
||||
pub status: Status,
|
||||
pub priority: Priority,
|
||||
pub due: Option<Date>,
|
||||
pub assignee: Option<Person>,
|
||||
}
|
||||
|
||||
impl Task {
|
||||
pub fn new(key: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
issue: None,
|
||||
title: title.into(),
|
||||
status: Status::Todo,
|
||||
priority: Priority::None,
|
||||
due: None,
|
||||
assignee: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn issue(mut self, id: IssueId) -> Self {
|
||||
self.issue = Some(id);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn status(mut self, status: Status) -> Self {
|
||||
self.status = status;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn priority(mut self, priority: Priority) -> Self {
|
||||
self.priority = priority;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn due(mut self, day: Date) -> Self {
|
||||
self.due = Some(day);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn assignee(mut self, person: Person) -> Self {
|
||||
self.assignee = Some(person);
|
||||
self
|
||||
}
|
||||
|
||||
/// Due before `today` and still open.
|
||||
pub fn overdue(&self, today: Date) -> bool {
|
||||
!self.status.closed() && self.due.is_some_and(|due| due < today)
|
||||
}
|
||||
}
|
||||
|
||||
/// A due day in few words from `today`: Today, Tomorrow, Yesterday, a weekday within the week ahead, else the date, with its year when not this one.
|
||||
pub(crate) fn due_words(due: Date, today: Date) -> String {
|
||||
let days = due.since(today).expect("days between two dates").get_days();
|
||||
match days {
|
||||
0 => "Today".into(),
|
||||
1 => "Tomorrow".into(),
|
||||
-1 => "Yesterday".into(),
|
||||
2..=6 => due.strftime("%a").to_string(),
|
||||
_ if due.year() == today.year() => due.strftime("%b %-d").to_string(),
|
||||
_ => due.strftime("%b %-d, %Y").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use jiff::civil::date;
|
||||
|
||||
use super::{IssueId, Priority, Status, Task, due_words};
|
||||
|
||||
#[test]
|
||||
fn due_days_read_near_then_far() {
|
||||
let today = date(2026, 9, 27);
|
||||
let words: Vec<String> = [27, 28, 26, 30]
|
||||
.map(|day| due_words(date(2026, 9, day), today))
|
||||
.into();
|
||||
assert_eq!(words, ["Today", "Tomorrow", "Yesterday", "Wed"]);
|
||||
assert_eq!(due_words(date(2026, 10, 4), today), "Oct 4");
|
||||
assert_eq!(due_words(date(2026, 9, 20), today), "Sep 20");
|
||||
assert_eq!(due_words(date(2027, 1, 8), today), "Jan 8, 2027");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn statuses_go_by_their_keys() {
|
||||
for status in Status::ALL {
|
||||
assert_eq!(Status::of_key(status.key()), status);
|
||||
}
|
||||
assert!(Status::Canceled.closed() && !Status::InReview.closed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priorities_rise_and_light_their_bars() {
|
||||
assert!(Priority::Urgent > Priority::High && Priority::Low > Priority::None);
|
||||
let bars = [
|
||||
Priority::None,
|
||||
Priority::Low,
|
||||
Priority::Medium,
|
||||
Priority::High,
|
||||
]
|
||||
.map(Priority::bars);
|
||||
assert_eq!(bars, [0, 1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_open_task_past_its_day_is_overdue() {
|
||||
let today = date(2026, 9, 27);
|
||||
let late = Task::new("a", "A").due(date(2026, 9, 26));
|
||||
assert!(late.overdue(today));
|
||||
assert!(!late.clone().status(Status::Canceled).overdue(today));
|
||||
assert!(!late.due(today).overdue(today));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_issue_reads_as_its_team_and_number() {
|
||||
assert_eq!(IssueId::new("ENG", 123).to_string(), "ENG-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "issue team key eng is not one to five capitals")]
|
||||
fn a_team_key_is_in_capitals() {
|
||||
IssueId::new("eng", 1);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user