diff --git a/AGENTS.md b/AGENTS.md index b7d2fad..66e2c80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,6 +168,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A default only the component can work out, such as a crop fitted to a picture it decodes, reaches the owner once through `window.defer` after render (`media::ImageUpload`). - A menu of times goes through `mail::times`: each row names its day and hour, a moment comes once (tomorrow is next Monday on a Sunday), and "Pick a time…" opens one dialog that waits for a time ahead of now (`ScheduleSend`, `SnoozePicker`). - A list whose cursor a field drives marks the cursor only while the field has focus; at rest its first row would look hovered (`mail::LabelPicker`). Its rows are option rows, and `forms::marked_row` leads one with a mark such as a label's hue. +- A choice that names a person leads with their initials through `forms::Choice::avatar`, in the list and in a Select's trigger alike (`project::AssigneePicker`). +- `typography::Ellipsis` shapes its line with the text style's own run, so a strike or an underline set on its box shows (`lists::ListItem::struck`, a closed task's title). ## Decisions diff --git a/TASKS.md b/TASKS.md index 326b6f3..2e3ea35 100644 --- a/TASKS.md +++ b/TASKS.md @@ -110,8 +110,11 @@ Per-component lines live in `tasks/`. Tags there: - [x] T30a Calendar · Months - [x] T30b Calendar · Time grid - [x] T30c Calendar · Agenda and people -- [ ] T30d Calendar · Editing -- [ ] T31 Project — `tasks/ch31-43.md` +- [x] T30d Calendar · Editing +- [ ] T31a Project · Tasks — `tasks/ch31-43.md` +- [ ] T31b Project · Issues and boards +- [ ] T31c Project · Plans +- [ ] T31d Project · Time and databases - [ ] T32 Canvas & Design - [ ] T33 DB & Dev Tools - [ ] T34 Dashboard @@ -359,3 +362,4 @@ Per-component lines live in `tasks/`. Tags there: | T30b | 2 | PASS | | | T30c | 1 | PASS | | | T30d | 1 | FAIL | a chosen day outlived the owner's days, and the next render panicked | +| T30d | 2 | PASS | | diff --git a/assets/icons/circle-dashed.svg b/assets/icons/circle-dashed.svg new file mode 100644 index 0000000..177376d --- /dev/null +++ b/assets/icons/circle-dashed.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/assets/icons/circle-dot-dashed.svg b/assets/icons/circle-dot-dashed.svg new file mode 100644 index 0000000..85ae29a --- /dev/null +++ b/assets/icons/circle-dot-dashed.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index d50a6d0..92b1077 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -24,6 +24,7 @@ mod motion; mod navigation; mod overlays; mod primitives; +mod project; mod shell; mod tables; mod terminal; @@ -75,6 +76,7 @@ pub const ALL: &[Page] = &[ messaging::PAGE, mail::PAGE, calendar::PAGE, + project::PAGE, theme::PAGE, ]; diff --git a/examples/gallery/pages/project/mod.rs b/examples/gallery/pages/project/mod.rs new file mode 100644 index 0000000..3c81be4 --- /dev/null +++ b/examples/gallery/pages/project/mod.rs @@ -0,0 +1,115 @@ +use ely_gpui_component::{ + project::{IssueId, Person, Priority, Status, Task}, + theme::{ActiveTheme, TextSize}, +}; +use gpui::{ + AnyElement, App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, +}; +use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone}; + +mod tasks; + +use super::Page; +use crate::{script::Step, ui::keep}; + +pub const PAGE: Page = Page { + number: 31, + slug: "project", + title: "Project", + summary: "Tasks and issues, who has them, where they stand, and when they are due.", + render, + script: SCRIPT, +}; + +const SCRIPT: &[Step] = &[ + Step::DownAt("project-fields", 150.0, 16.0), + Step::UpAt("project-fields", 150.0, 16.0), + Step::Wait(250), + Step::Shot("status-open"), + Step::Key("escape"), + Step::DownAt("project-tasks", 26.0, 22.0), + Step::UpAt("project-tasks", 26.0, 22.0), + Step::Wait(200), + Step::Shot("task-ticked"), +]; + +/// What a demo says it did, under it. +fn quiet(text: SharedString, cx: &App) -> impl IntoElement + use<> { + let theme = cx.theme(); + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(text) +} + +/// Today in the gallery's zone. +fn today() -> Date { + let zone = TimeZone::try_system().expect("the gallery reads the system time zone"); + Timestamp::now().to_zoned(zone).date() +} + +fn team() -> Vec { + vec![ + Person::new("chloe", "Chloé Martin"), + Person::new("ana", "Ana Lima"), + Person::new("ben", "Ben Ito"), + Person::new("dev", "Dev Rao"), + ] +} + +/// A studio's open work around today: late, due soon, in review, done. +fn work(today: Date) -> Vec { + let day = |offset: i64| today.checked_add(offset.days()).expect("a day nearby"); + let who = |key: &str| { + team() + .into_iter() + .find(|person| person.key.as_ref() == key) + .expect("one of the team") + }; + vec![ + Task::new("brief", "Write the client brief") + .issue(IssueId::new("ARC", 41)) + .status(Status::InProgress) + .priority(Priority::Urgent) + .due(day(-1)) + .assignee(who("ana")), + Task::new("samples", "Order plaster samples") + .issue(IssueId::new("ARC", 44)) + .priority(Priority::High) + .due(day(0)) + .assignee(who("ben")), + Task::new("model", "Photograph the site model") + .issue(IssueId::new("ARC", 38)) + .status(Status::InReview) + .priority(Priority::Medium) + .due(day(3)) + .assignee(who("chloe")), + Task::new("budget", "Revise the lighting budget") + .issue(IssueId::new("ARC", 47)) + .priority(Priority::Low) + .due(day(12)), + Task::new("survey", "Book the land survey") + .issue(IssueId::new("ARC", 29)) + .status(Status::Done) + .priority(Priority::Medium) + .due(day(-4)) + .assignee(who("dev")), + Task::new("archive", "Archive last year's renders") + .issue(IssueId::new("ARC", 17)) + .status(Status::Backlog), + ] +} + +/// The page's tasks, which ticks and picks change. +fn board(window: &mut Window, cx: &mut App) -> Entity> { + keep("project-work", || work(today()), window, cx) +} + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(tasks::marks(cx)) + .child(tasks::fields(window, cx)) + .child(tasks::list(window, cx)) + .child(tasks::subtasks(window, cx)) + .into_any_element() +} diff --git a/examples/gallery/pages/project/tasks.rs b/examples/gallery/pages/project/tasks.rs new file mode 100644 index 0000000..b3bd7f6 --- /dev/null +++ b/examples/gallery/pages/project/tasks.rs @@ -0,0 +1,187 @@ +use ely_gpui_component::{ + documents::Checklist, + forms::DatePicker, + project::{ + AssigneePicker, IssueId, IssueIdBadge, Priority, PriorityIndicator, Status, StatusSelect, + TaskList, + }, + theme::{ActiveTheme, TextSize}, +}; +use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use super::{board, quiet, team}; +use crate::{ + probe::probe, + ui::{change, keep, section, set}, +}; + +pub fn marks(cx: &mut App) -> impl IntoElement + use<> { + let priorities = [ + Priority::Urgent, + Priority::High, + Priority::Medium, + Priority::Low, + Priority::None, + ]; + section( + "PriorityIndicator / IssueIdBadge", + "How urgent a task is in three bars, urgent in red, its words in a tip or beside it. An issue goes by its team's key and its number.", + cx, + ) + .child(probe( + "project-marks", + div() + .flex() + .flex_wrap() + .items_center() + .gap_6() + .children(priorities.map(|priority| { + let id = SharedString::from(format!("project-priority-{priority:?}")); + PriorityIndicator::new(id, priority).labeled() + })) + .child(IssueIdBadge::new(IssueId::new("ARC", 41))) + .child(IssueIdBadge::new(IssueId::new("ENG", 1204))), + )) +} + +/// A label and its control, the label in a fixed column. +fn property(label: &'static str, control: AnyElement, cx: &App) -> impl IntoElement + use<> { + let theme = cx.theme(); + div() + .flex() + .items_center() + .gap_3() + .child( + div() + .w(px(88.)) + .flex_none() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(label), + ) + .child(div().flex_1().min_w_0().child(control)) +} + +pub fn fields(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let tasks = board(window, cx); + let task = tasks + .read(cx) + .iter() + .find(|task| task.key.as_ref() == "brief") + .cloned() + .expect("the brief is listed"); + let edit = move |cx: &mut App, apply: &dyn Fn(&mut ely_gpui_component::project::Task)| { + change(&tasks, cx, |tasks| { + let brief = tasks + .iter_mut() + .find(|task| task.key.as_ref() == "brief") + .expect("the brief is listed"); + apply(brief) + }) + }; + let (moved, handed, dated) = (edit.clone(), edit.clone(), edit); + let status = StatusSelect::new("project-status", task.status) + .on_change(move |status, _, cx| moved(cx, &|task| task.status = status)); + let assignee = task.assignee.as_ref().map(|person| person.key.clone()); + let picker = AssigneePicker::new("project-assignee", team()).on_change(move |person, _, cx| { + let person = person.cloned(); + handed(cx, &|task| task.assignee = person.clone()) + }); + let picker = match assignee { + Some(key) => picker.assignee(key), + None => picker, + }; + let due = DatePicker::new("project-due", task.due) + .on_change(move |day, _, cx| dated(cx, &|task| task.due = Some(day))); + section( + "StatusSelect / AssigneePicker / DueDatePicker", + "A task's properties: where it stands in its workflow, who has it, and when it is due; DueDatePicker is the forms DatePicker. Each change shows in the list below.", + cx, + ) + .child(probe( + "project-fields", + div() + .w(px(360.)) + .flex() + .flex_col() + .gap_3() + .child(property("Status", status.into_any_element(), cx)) + .child(property("Assignee", picker.into_any_element(), cx)) + .child(property("Due", due.into_any_element(), cx)), + )) +} + +pub fn list(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let tasks = board(window, cx); + let listed = tasks.read(cx).clone(); + let said = keep("project-list-said", || None::, window, cx); + let shown = said.read(cx).clone(); + let titles = listed.clone(); + section( + "TaskItem / TaskList", + "Tasks in rows: a box ticks one done, then its priority, its issue and its title; the due day and who has it sit at the end, and a day past due reads in red. Arrows move between rows; Enter or a double press opens one.", + cx, + ) + .child(probe( + "project-tasks", + div() + .w(px(560.)) + .flex() + .flex_col() + .gap_3() + .child( + TaskList::new("project-tasks", listed) + .on_toggle(move |key, done, _, cx| { + change(&tasks, cx, |tasks| { + let task = tasks + .iter_mut() + .find(|task| task.key == *key) + .expect("a listed task"); + task.status = if done { Status::Done } else { Status::Todo }; + }) + }) + .on_open(move |key, _, cx| { + let task = titles + .iter() + .find(|task| task.key == *key) + .expect("a listed task"); + set(&said, Some(format!("Opened {}.", task.title).into()), cx) + }), + ) + .children(shown.map(|shown| quiet(shown, cx))), + )) +} + +pub fn subtasks(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let items = keep( + "project-subtasks", + || { + vec![ + (SharedString::from("Measure the room"), true), + (SharedString::from("Sketch two options"), true), + (SharedString::from("Price the fixtures"), false), + (SharedString::from("Send to Chloé"), false), + ] + }, + window, + cx, + ); + let listed = items.read(cx).clone(); + let (toggled, added) = (items.clone(), items); + section( + "SubtaskList", + "A task's steps with how many are done, as a count and a bar. It is the documents Checklist, whose field adds a step on Enter.", + cx, + ) + .child(probe( + "project-subtasks", + div().w(px(360.)).child( + Checklist::new("project-subtasks", "Subtasks", listed) + .on_toggle(move |ix, done, _, cx| change(&toggled, cx, |items| items[ix].1 = done)) + .on_add("Add a subtask", move |words, _, cx| { + let words = words.clone(); + change(&added, cx, |items| items.push((words, false))) + }), + ), + )) +} diff --git a/src/documents/todo.rs b/src/documents/todo.rs index c948126..a8fd7eb 100644 --- a/src/documents/todo.rs +++ b/src/documents/todo.rs @@ -1,12 +1,12 @@ use std::rc::Rc; use gpui::{ - App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, - Window, div, prelude::*, + App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce, + SharedString, Styled, Window, div, prelude::*, }; use crate::{ - forms::{Checkbox, OnFlag}, + forms::{Checkbox, Enter, Input, OnFlag, OnValue, TextInput}, motion::ProgressBar, theme::{ActiveTheme, TextSize}, typography::tabular, @@ -59,13 +59,14 @@ impl RenderOnce for TodoItem { } } -/// To-dos under a title, with how many are done as a count and a bar. +/// To-dos under a title, with how many are done as a count and a bar. With `on_add`, a field under them adds one. #[derive(IntoElement)] pub struct Checklist { id: ElementId, title: SharedString, items: Vec<(SharedString, bool)>, on_toggle: Option, + adding: Option<(SharedString, OnValue)>, } impl Checklist { @@ -82,9 +83,20 @@ impl Checklist { .map(|(label, done)| (label.into(), done)) .collect(), on_toggle: None, + adding: None, } } + /// A field under the items: Enter hands on its words, trimmed, and clears it. + pub fn on_add( + mut self, + placeholder: impl Into, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.adding = Some((placeholder.into(), Rc::new(handler))); + self + } + /// Tells which item changed, by index, and whether it is now done. pub fn on_toggle( mut self, @@ -96,7 +108,25 @@ impl Checklist { } impl RenderOnce for Checklist { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let add = self.adding.map(|(placeholder, on_add)| { + let field = window.use_keyed_state((self.id.clone(), "add"), cx, |window, cx| { + TextInput::new(window, cx).placeholder(placeholder) + }); + let (id, typed) = (self.id.clone(), field.clone()); + div() + .capture_action(move |_: &Enter, window, cx| { + let words = typed.read(cx).text().trim().to_string(); + if words.is_empty() { + return; + } + cx.stop_propagation(); + log::info!("checklist {id:?}: add {words}"); + typed.update(cx, |input, cx| input.set_text(String::new(), cx)); + on_add(&words.into(), window, cx); + }) + .child(Input::new(&field)) + }); let theme = cx.theme(); let done = self.items.iter().filter(|(_, done)| *done).count(); let total = self.items.len(); @@ -142,5 +172,6 @@ impl RenderOnce for Checklist { } }), ) + .children(add) } } diff --git a/src/forms/options.rs b/src/forms/options.rs index 635f395..cb33f6a 100644 --- a/src/forms/options.rs +++ b/src/forms/options.rs @@ -8,9 +8,10 @@ use gpui::{ }; use crate::{ + data_display::Avatar, motion, primitives::{Icon, IconName, raise}, - theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize}, + theme::{ActiveTheme, AvatarSize, ControlSize, Elevation, IconSize, Radius, TextSize}, typography::Ellipsis, }; @@ -20,6 +21,7 @@ pub struct Choice { pub value: SharedString, pub label: SharedString, pub icon: Option, + pub avatar: Option, pub note: Option, pub disabled: bool, pub depth: usize, @@ -31,6 +33,7 @@ impl Choice { value: value.into(), label: label.into(), icon: None, + avatar: None, note: None, disabled: false, depth: 0, @@ -48,6 +51,12 @@ impl Choice { self } + /// Leads with the initials of `name` on its tone, as a person's row does. + pub fn avatar(mut self, name: impl Into) -> Self { + self.avatar = Some(name.into()); + self + } + /// Quiet text at the row's end, such as a count or a shortcut. pub fn note(mut self, note: impl Into) -> Self { self.note = Some(note.into()); @@ -100,6 +109,7 @@ pub(crate) fn marked_row( checked: Option, cx: &App, ) -> Stateful
{ + let id = id.into(); let theme = cx.theme(); let colors = &theme.colors; let fg = if choice.disabled { @@ -108,7 +118,7 @@ pub(crate) fn marked_row( colors.fg }; div() - .id(id) + .id(id.clone()) .flex() .items_center() .gap_2() @@ -140,6 +150,9 @@ pub(crate) fn marked_row( .when_some(choice.icon, |row, icon| { row.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted)) }) + .when_some(choice.avatar.clone(), |row, name| { + row.child(Avatar::new((id, "avatar"), name).size(AvatarSize::Xs)) + }) .children(mark) .child( div() diff --git a/src/forms/select.rs b/src/forms/select.rs index a99d2f8..3884000 100644 --- a/src/forms/select.rs +++ b/src/forms/select.rs @@ -12,8 +12,9 @@ use super::{ options::{OnValue, Pick, Popup, Run, reveal, step}, }; use crate::{ + data_display::Avatar, primitives::{Icon, IconName, tab_stop}, - theme::{ActiveTheme, ControlSize, IconSize, Radius}, + theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius}, typography::Ellipsis, }; @@ -330,6 +331,13 @@ impl RenderOnce for Select { trigger.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted)) }, ) + .when_some( + shown.as_ref().and_then(|choice| choice.avatar.clone()), + |trigger, name| { + let id = (self.id.clone(), "avatar"); + trigger.child(Avatar::new(id, name).size(AvatarSize::Xs)) + }, + ) .child(field_text( shown.map(|choice| choice.label), self.placeholder, diff --git a/src/lib.rs b/src/lib.rs index a0cebc3..aa376d3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,7 @@ pub mod motion; pub mod navigation; pub mod overlays; pub mod primitives; +pub mod project; pub mod shell; pub mod tables; pub mod terminal; diff --git a/src/lists/item.rs b/src/lists/item.rs index 77f010b..f6bb9f6 100644 --- a/src/lists/item.rs +++ b/src/lists/item.rs @@ -28,6 +28,7 @@ pub struct ListItem { disabled: bool, strong: bool, quiet: bool, + struck: bool, on_click: Option, } @@ -45,6 +46,7 @@ impl ListItem { disabled: false, strong: false, quiet: false, + struck: false, on_click: None, } } @@ -61,6 +63,12 @@ impl ListItem { self } + /// Strikes the title through, as a finished task has it. + pub fn struck(mut self, struck: bool) -> Self { + self.struck = struck; + self + } + /// A second line, quieter than the title. pub fn description(mut self, text: impl Into) -> Self { self.description = Some(text.into()); @@ -162,6 +170,7 @@ impl RenderOnce for ListItem { line(self.title) .text_size(theme.text_size(TextSize::Base)) .when(self.strong, |title| title.font_weight(FontWeight::SEMIBOLD)) + .when(self.struck, |title| title.line_through()) .text_color(if self.quiet { colors.fg_muted } else { diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index a1958a3..5cc2830 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -93,7 +93,9 @@ icons! { ChevronsUpDown => "chevrons-up-down", CircleAlert => "circle-alert", CircleCheck => "circle-check", + CircleDashed => "circle-dashed", CircleDot => "circle-dot", + CircleDotDashed => "circle-dot-dashed", CircleHelp => "circle-help", CirclePause => "circle-pause", CircleStop => "circle-stop", diff --git a/src/project/marks.rs b/src/project/marks.rs new file mode 100644 index 0000000..d257f3e --- /dev/null +++ b/src/project/marks.rs @@ -0,0 +1,234 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, + StatefulInteractiveElement, Styled, Window, div, prelude::*, relative, +}; + +use super::work::{IssueId, Person, Priority, Status}; +use crate::{ + forms::{Choice, Select}, + primitives::{Icon, IconName, Tooltip}, + theme::{ActiveTheme, ControlSize, IconSize, TextSize}, + typography::tabular, +}; + +type OnStatus = Rc; +type OnAssignee = Rc, &mut Window, &mut App)>; + +/// A priority at a glance: three bars, as many lit as it is high, or a red mark for urgent. Its words show in a tip, or beside it when labeled. +#[derive(IntoElement)] +pub struct PriorityIndicator { + id: ElementId, + priority: Priority, + labeled: bool, +} + +impl PriorityIndicator { + pub fn new(id: impl Into, priority: Priority) -> Self { + Self { + id: id.into(), + priority, + labeled: false, + } + } + + /// Its words beside it. + pub fn labeled(mut self) -> Self { + self.labeled = true; + self + } +} + +impl RenderOnce for PriorityIndicator { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = &theme.colors; + let mark = match self.priority { + Priority::Urgent => Icon::new(IconName::CircleAlert) + .size(IconSize::Sm) + .color(colors.danger) + .into_any_element(), + priority => div() + .size(theme.icon_size(IconSize::Sm)) + .py_0p5() + .flex() + .items_end() + .justify_center() + .gap_0p5() + .children([0.5, 0.75, 1.0].into_iter().enumerate().map(|(ix, tall)| { + let lit = ix < priority.bars(); + div().w_0p5().h(relative(tall)).rounded_full().bg(if lit { + colors.fg_muted + } else { + colors.border_strong + }) + })) + .into_any_element(), + }; + let words = self.priority.words(); + div() + .id(self.id) + .flex_none() + .flex() + .items_center() + .gap_1p5() + .child(mark) + .map(|row| match self.labeled { + true => row.child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child(words), + ), + false => row.tooltip(Tooltip::text(words)), + }) + } +} + +/// An issue's name, as ENG-123, quiet and in even figures. +#[derive(IntoElement)] +pub struct IssueIdBadge { + issue: IssueId, +} + +impl IssueIdBadge { + pub fn new(issue: IssueId) -> Self { + Self { issue } + } +} + +impl RenderOnce for IssueIdBadge { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + tabular(div()) + .flex_none() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(self.issue.to_string()) + } +} + +/// Where a task stands, picked from its workflow, each status with its mark. +#[derive(IntoElement)] +pub struct StatusSelect { + id: ElementId, + status: Status, + size: ControlSize, + on_change: Option, +} + +impl StatusSelect { + pub fn new(id: impl Into, status: Status) -> Self { + Self { + id: id.into(), + status, + size: ControlSize::default(), + on_change: None, + } + } + + pub fn size(mut self, size: ControlSize) -> Self { + self.size = size; + self + } + + pub fn on_change(mut self, handler: impl Fn(Status, &mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for StatusSelect { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let choices = + Status::ALL.map(|status| Choice::new(status.key(), status.words()).icon(status.icon())); + let (id, on_change) = (self.id.clone(), self.on_change); + Select::new(self.id, choices) + .selected(self.status.key()) + .size(self.size) + .on_change(move |key: &SharedString, window, cx| { + let status = Status::of_key(key); + log::info!("status select {id:?}: {}", status.words()); + if let Some(on_change) = &on_change { + on_change(status, window, cx); + } + }) + } +} + +/// Who has a task, picked from people by name, or no one. +#[derive(IntoElement)] +pub struct AssigneePicker { + id: ElementId, + people: Vec, + assignee: Option, + size: ControlSize, + on_change: Option, +} + +impl AssigneePicker { + pub fn new(id: impl Into, people: impl IntoIterator) -> Self { + let people: Vec = people.into_iter().collect(); + for (ix, person) in people.iter().enumerate() { + let twice = people[..ix].iter().any(|other| other.key == person.key); + assert!(!twice, "person {} twice", person.key); + } + Self { + id: id.into(), + people, + assignee: None, + size: ControlSize::default(), + on_change: None, + } + } + + /// Who has it now, by key. + pub fn assignee(mut self, key: impl Into) -> Self { + self.assignee = Some(key.into()); + self + } + + pub fn size(mut self, size: ControlSize) -> Self { + self.size = size; + self + } + + /// Gets the person picked, or none for no one. + pub fn on_change( + mut self, + handler: impl Fn(Option<&Person>, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for AssigneePicker { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + if let Some(key) = &self.assignee { + let known = self.people.iter().any(|person| person.key == *key); + assert!(known, "assignee picker {:?}: no person {key}", self.id); + } + let nobody = Choice::new("", "No assignee").icon(IconName::User); + let choices: Vec = std::iter::once(nobody) + .chain(self.people.iter().map(|person| { + Choice::new(person.key.clone(), person.name.clone()).avatar(person.name.clone()) + })) + .collect(); + let (id, people, on_change) = (self.id.clone(), self.people, self.on_change); + Select::new(self.id, choices) + .selected(self.assignee.unwrap_or_default()) + .size(self.size) + .on_change(move |key: &SharedString, window, cx| { + let person = people.iter().find(|person| person.key == *key); + log::info!( + "assignee picker {id:?}: {}", + person.map_or("no one", |person| person.name.as_ref()) + ); + if let Some(on_change) = &on_change { + on_change(person, window, cx); + } + }) + } +} diff --git a/src/project/mod.rs b/src/project/mod.rs new file mode 100644 index 0000000..768907e --- /dev/null +++ b/src/project/mod.rs @@ -0,0 +1,10 @@ +mod marks; +mod tasks; +mod work; + +#[cfg(all(test, feature = "test-support"))] +mod tests; + +pub use marks::{AssigneePicker, IssueIdBadge, PriorityIndicator, StatusSelect}; +pub use tasks::{TaskItem, TaskList}; +pub use work::{IssueId, Person, Priority, Status, Task}; diff --git a/src/project/tasks.rs b/src/project/tasks.rs new file mode 100644 index 0000000..ed7bf05 --- /dev/null +++ b/src/project/tasks.rs @@ -0,0 +1,253 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, + RenderOnce, SharedString, Styled, Window, div, +}; +use jiff::{Timestamp, civil::Date}; + +use super::{ + marks::{IssueIdBadge, PriorityIndicator}, + work::{Status, Task, due_words}, +}; +use crate::{ + data_display::Avatar, + forms::{Checkbox, OnFlag, OnValue}, + lists::{ListItem, SelectableList}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, AvatarSize, IconSize, TextSize}, + typography::{format, fresh}, +}; + +type OnTaskToggle = Rc; + +/// Today by the clock, in the system's zone. +fn clock_today(user: &str) -> Date { + Timestamp::now().to_zoned(format::system_zone(user)).date() +} + +/// A task in a row: its box, its priority and its issue's name, its title, then when it is due and who has it. Closed, the title goes quiet and struck through; past due and still open, the day reads in red. +#[derive(IntoElement)] +pub struct TaskItem { + id: ElementId, + task: Task, + today: Option, + on_toggle: Option, +} + +impl TaskItem { + pub fn new(id: impl Into, task: Task) -> Self { + Self { + id: id.into(), + task, + today: None, + on_toggle: None, + } + } + + /// The day due dates count from; the clock's otherwise. + pub fn today(mut self, day: Date) -> Self { + self.today = Some(day); + self + } + + /// A box that ticks the task done; it gets whether the box is now ticked. Without it, the status mark leads. + pub fn on_toggle(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { + self.on_toggle = Some(Rc::new(handler)); + self + } + + fn item(self, today: Date, cx: &App) -> ListItem { + let theme = cx.theme(); + let colors = &theme.colors; + let task = self.task; + let lead: AnyElement = match self.on_toggle { + Some(on_toggle) => { + let (key, named) = (task.key.clone(), task.key.clone()); + div() + .debug_selector(move || format!("task-box {named}")) + .flex_none() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_key_down(|event, _, cx| { + let key = event.keystroke.key.as_str(); + if matches!(key, "space" | "enter") && !event.keystroke.modifiers.modified() + { + cx.stop_propagation(); + } + }) + .child( + Checkbox::new((self.id.clone(), "done"), task.status == Status::Done) + .on_change(move |done, window, cx| { + log::info!("task {key}: {}", if done { "done" } else { "open" }); + on_toggle(done, window, cx) + }), + ) + .into_any_element() + } + None => Icon::new(task.status.icon()) + .size(IconSize::Sm) + .color(colors.fg_muted) + .into_any_element(), + }; + let due = task.due.map(|due| { + let late = task.overdue(today); + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(if late { colors.danger } else { colors.fg_muted }) + .child(due_words(due, today)) + }); + let assignee = match task.assignee { + Some(person) => Avatar::new((self.id.clone(), "assignee"), person.name) + .size(AvatarSize::Xs) + .into_any_element(), + None => div() + .size(theme.avatar_size(AvatarSize::Xs)) + .flex() + .items_center() + .justify_center() + .child( + Icon::new(IconName::User) + .size(IconSize::Xs) + .color(colors.fg_subtle), + ) + .into_any_element(), + }; + let lead = div() + .flex() + .items_center() + .gap_2() + .child(lead) + .child(PriorityIndicator::new( + (self.id.clone(), "priority"), + task.priority, + )) + .children(task.issue.map(IssueIdBadge::new)); + let trail = div() + .flex_none() + .flex() + .items_center() + .gap_2() + .children(due) + .child(assignee); + let closed = task.status.closed(); + ListItem::new(self.id, task.title) + .leading(lead) + .struck(closed) + .quiet(closed) + .trailing(trail) + } +} + +impl RenderOnce for TaskItem { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let today = match self.today { + Some(day) => day, + None => { + fresh((self.id.clone(), "clock"), window, cx); + clock_today("task item") + } + }; + self.item(today, cx) + } +} + +/// Tasks in rows: arrows move between them, Enter or a double press opens one, and each box ticks its task done. +#[derive(IntoElement)] +pub struct TaskList { + id: ElementId, + tasks: Vec, + today: Option, + on_open: Option, + on_toggle: Option, +} + +impl TaskList { + pub fn new(id: impl Into, tasks: impl IntoIterator) -> Self { + let tasks: Vec = tasks.into_iter().collect(); + for (ix, task) in tasks.iter().enumerate() { + let twice = tasks[..ix].iter().any(|other| other.key == task.key); + assert!(!twice, "task {} twice", task.key); + } + Self { + id: id.into(), + tasks, + today: None, + on_open: None, + on_toggle: None, + } + } + + /// The day due dates count from; the clock's otherwise. + pub fn today(mut self, day: Date) -> Self { + self.today = Some(day); + self + } + + /// Gets the key of the task opened. + pub fn on_open( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } + + /// Gets a task's key and whether its box is now ticked. + pub fn on_toggle( + mut self, + handler: impl Fn(&SharedString, bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_toggle = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for TaskList { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + if self.tasks.is_empty() { + return div() + .debug_selector(|| "task-none".into()) + .min_w_full() + .px_3() + .py_4() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child("No tasks.") + .into_any_element(); + } + let today = match self.today { + Some(day) => day, + None => { + fresh((self.id.clone(), "clock"), window, cx); + clock_today("task list") + } + }; + let list = self.tasks.into_iter().fold( + SelectableList::new((self.id.clone(), "tasks")), + |list, task| { + let key = task.key.clone(); + let item = TaskItem::new((self.id.clone(), format!("task-{key}")), task); + let item = match self.on_toggle.clone() { + Some(toggle) => { + let ticked = key.clone(); + item.on_toggle(move |done, window, cx| toggle(&ticked, done, window, cx)) + } + None => item, + }; + list.row(key, item.item(today, cx)) + }, + ); + let (id, on_open) = (self.id.clone(), self.on_open); + div() + .debug_selector(|| "task-list".into()) + .min_w_full() + .child(list.on_activate(move |key, window, cx| { + log::info!("task list {id:?}: open {key}"); + if let Some(on_open) = &on_open { + on_open(key, window, cx); + } + })) + .into_any_element() + } +} diff --git a/src/project/tests.rs b/src/project/tests.rs new file mode 100644 index 0000000..6b3d246 --- /dev/null +++ b/src/project/tests.rs @@ -0,0 +1,193 @@ +use gpui::{ + AnyElement, Context, Entity, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers, + ParentElement, Render, Styled, TestAppContext, VisualTestContext, Window, div, px, +}; +use jiff::civil::date; + +use super::{AssigneePicker, IssueId, Person, Priority, Status, StatusSelect, Task, TaskList}; +use crate::{documents::Checklist, forms::bind_keys, primitives::FocusNext, theme::Theme}; + +/// A view that shows one project part and keeps what it heard. +struct Desk { + part: fn(Entity) -> AnyElement, + heard: Vec, +} + +impl Render for Desk { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div().w(px(640.0)).child((self.part)(cx.entity())) + } +} + +fn settle(cx: &mut VisualTestContext) { + for _ in 0..3 { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + } + cx.run_until_parked(); +} + +fn desk( + part: fn(Entity) -> AnyElement, + cx: &mut TestAppContext, +) -> (Entity, &mut VisualTestContext) { + cx.update(|cx| { + Theme::init(cx); + Theme::update(cx, |theme| theme.reduced_motion = true); + bind_keys(cx); + cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]); + }); + let (host, cx) = cx.add_window_view(|_, _| Desk { + part, + heard: Vec::new(), + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + (host, cx) +} + +fn note(owner: &Entity, what: String, cx: &mut gpui::App) { + owner.update(cx, |desk, cx| { + desk.heard.push(what); + cx.notify(); + }); +} + +fn heard(host: &Entity, cx: &mut VisualTestContext) -> Vec { + host.read_with(cx, |desk, _| desk.heard.clone()) +} + +/// Moves focus to the `nth` Tab stop from none. +fn tab_to(nth: usize, cx: &mut VisualTestContext) { + cx.update(|window, _| { + window.blur(); + for _ in 0..nth { + window.focus_next(); + } + }); + settle(cx); +} + +fn press(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + settle(cx); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).expect("a key"), + }); + settle(cx); +} + +fn tasks() -> Vec { + let ana = Person::new("ana", "Ana Lima"); + vec![ + Task::new("brief", "Write the brief") + .issue(IssueId::new("ENG", 12)) + .priority(Priority::High) + .due(date(2026, 9, 25)) + .assignee(ana), + Task::new("specs", "Check the specs").status(Status::Done), + ] +} + +fn list(owner: Entity) -> AnyElement { + let (opened, ticked) = (owner.clone(), owner); + TaskList::new("tasks", tasks()) + .today(date(2026, 9, 27)) + .on_open(move |key, _, cx| note(&opened, format!("open {key}"), cx)) + .on_toggle(move |key, done, _, cx| note(&ticked, format!("tick {key} {done}"), cx)) + .into_any_element() +} + +#[gpui::test] +fn a_box_ticks_its_task_and_leaves_the_row_alone(cx: &mut TestAppContext) { + let (host, cx) = desk(list, cx); + let tick = cx.debug_bounds("task-box specs").expect("the box draws"); + cx.simulate_click(tick.center(), Modifiers::none()); + settle(cx); + tab_to(2, cx); + press("enter", cx); + tab_to(1, cx); + press("enter", cx); + assert_eq!( + heard(&host, cx), + ["tick specs false", "tick brief true", "open brief"], + "a press on a box leaves the cursor, and Enter on a box does not open its row" + ); +} + +#[gpui::test] +fn enter_opens_the_task_under_the_cursor(cx: &mut TestAppContext) { + let (host, cx) = desk(list, cx); + tab_to(1, cx); + press("enter", cx); + press("down", cx); + press("enter", cx); + assert_eq!(heard(&host, cx), ["open brief", "open specs"]); +} + +fn status(owner: Entity) -> AnyElement { + StatusSelect::new("status", Status::Todo) + .on_change(move |status, _, cx| note(&owner, status.words().into(), cx)) + .into_any_element() +} + +#[gpui::test] +fn a_status_picked_by_key_hands_on_its_status(cx: &mut TestAppContext) { + let (host, cx) = desk(status, cx); + tab_to(1, cx); + cx.simulate_keystrokes("down down enter"); + settle(cx); + assert_eq!(heard(&host, cx), ["In progress"]); +} + +fn assignee(owner: Entity) -> AnyElement { + let people = [ + Person::new("ana", "Ana Lima"), + Person::new("ben", "Ben Ito"), + ]; + AssigneePicker::new("assignee", people) + .assignee("ana") + .on_change(move |person, _, cx| { + let name = person.map_or("no one".into(), |person| person.name.to_string()); + note(&owner, name, cx) + }) + .into_any_element() +} + +#[gpui::test] +fn the_picker_hands_on_a_person_or_no_one(cx: &mut TestAppContext) { + let (host, cx) = desk(assignee, cx); + tab_to(1, cx); + cx.simulate_keystrokes("down down enter"); + settle(cx); + tab_to(1, cx); + cx.simulate_keystrokes("down up enter"); + settle(cx); + assert_eq!(heard(&host, cx), ["Ben Ito", "no one"]); +} + +fn checklist(owner: Entity) -> AnyElement { + Checklist::new( + "subtasks", + "Subtasks", + [("Outline", true), ("Draft", false)], + ) + .on_add("Add a subtask", move |words, _, cx| { + note(&owner, format!("add {words}"), cx) + }) + .into_any_element() +} + +#[gpui::test] +fn a_checklist_adds_what_is_typed_and_clears(cx: &mut TestAppContext) { + let (host, cx) = desk(checklist, cx); + tab_to(3, cx); + cx.simulate_input(" Review "); + press("enter", cx); + press("enter", cx); + assert_eq!( + heard(&host, cx), + ["add Review"], + "the field clears, and empty adds nothing" + ); +} diff --git a/src/project/work.rs b/src/project/work.rs new file mode 100644 index 0000000..a448494 --- /dev/null +++ b/src/project/work.rs @@ -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, 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, name: impl Into) -> 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, + pub title: SharedString, + pub status: Status, + pub priority: Priority, + pub due: Option, + pub assignee: Option, +} + +impl Task { + pub fn new(key: impl Into, title: impl Into) -> 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 = [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); + } +} diff --git a/src/typography/fit.rs b/src/typography/fit.rs index eb2da59..f4612b9 100644 --- a/src/typography/fit.rs +++ b/src/typography/fit.rs @@ -40,14 +40,7 @@ pub(crate) fn text_width(text: &str, size: Pixels, window: &Window) -> Pixels { fn shape(text: &str, window: &mut Window) -> ShapedLine { let style = window.text_style(); - let run = TextRun { - len: text.len(), - font: style.font(), - color: style.color, - background_color: None, - underline: None, - strikethrough: None, - }; + let run = style.to_run(text.len()); let size = style.font_size.to_pixels(window.rem_size()); window .text_system() diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 019c605..f03750e 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -1,25 +1,34 @@ # Chapters 31–43 -## T31 Project — `src/project` +## T31a Project · Tasks — `src/project` +- [x] PriorityIndicator +- [x] IssueIdBadge +- [x] StatusSelect +- [x] AssigneePicker +- [x] DueDatePicker → `forms::DatePicker` +- [x] TaskItem — check, priority, due date, assignee +- [x] TaskList +- [x] SubtaskList → `documents::Checklist`, which gains a field to add one + +## T31b Project · Issues and boards + +- [ ] IssueCard +- [ ] LabelManager - [ ] KanbanBoard / KanbanColumn / KanbanCard -- [ ] TaskList -- [ ] TaskItem — check, priority, due date, assignee +- [ ] Sprint Board → `project::KanbanBoard` - [ ] TaskDetailPanel -- [ ] SubtaskList + +## T31c Project · Plans + - [ ] GanttChart / TimelineView → `charts::GanttChart` - [ ] Roadmap -- [ ] Sprint Board → `project::KanbanBoard` - [ ] Burndown Chart -- [ ] PriorityIndicator -- [ ] StatusSelect -- [ ] AssigneePicker -- [ ] DueDatePicker → `forms::DatePicker` -- [ ] LabelManager -- [ ] IssueCard -- [ ] IssueIdBadge - [ ] MilestoneProgress - [ ] WorkloadView + +## T31d Project · Time and databases + - [ ] TimeTracker - [ ] Pomodoro Timer - [ ] DatabaseView (from T21c) — table, board, gallery, calendar, timeline