Skip to main content

max / goingson

Describe the task edit form, at its own address The fifth finding of the task-overview port said Edit could not be offered: `tasks.openEdit` opens `form-modal.js` over the drawer, and a screen offering a control that opens a form over itself has to describe two arrangements at once. The same paragraph named the shape that fits, and this is it. `GET /tasks/{id}/edit` is a screen, `POST /tasks/{id}` is the write, and the overview links to the first rather than drawing it on top. Cancel is the overview's own address; what the modal bought by keeping the screen behind it, an address gives back by rebuilding from the database. The form asks `getTaskFormFields`'s eleven questions in its order, and refuses the way the projects form does: every complaint at once, with what was typed handed back through `Field::refilled`. Due dates go through `parse_natural_date`, the same function the JS reaches by command, so "friday 3pm" means one thing in both; the prefill is the format the parser reads back, and a test submits it unchanged to prove a field can be left alone. An unparseable date is refused rather than dropped, since dropping it loses a deadline on an edit about something else. Two things the form cannot ask, both recorded on `edit_fields`. The recurrence rule needs a field whose presence depends on another field's value and a multi-select, and `FieldKind` has neither, so the stored rule is threaded through untouched rather than flattened. A milestone belongs to a project and the select offers the current project's, so moving a task and re-filing it in one submission is refused rather than stored. `parse_choice` moves from projects to the shared module, since two forms now want one answer to what a rejected option says.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 01:26 UTC
Signed with PGP, not checked
Commit: 2384df12a4006c78c7dc7e9794f8b3a33b7a0cb9
Parent: 4044d84
5 files changed, +813 insertions, -36 deletions
M Cargo.lock +4 -4
@@ -8373,10 +8373,6 @@
8373 8373 "winnow 1.0.4",
8374 8374 ]
8375 8375
8376 - [[patch.unused]]
8377 - name = "ops-status"
8378 - version = "0.1.0"
8379 -
8380 8376 [[patch.unused]]
8381 8377 name = "quasi-axum"
8382 8378 version = "0.18.0"
@@ -8392,3 +8388,7 @@
8392 8388 [[patch.unused]]
8393 8389 name = "quasi-store"
8394 8390 version = "0.1.0"
8391 +
8392 + [[patch.unused]]
8393 + name = "ops-status"
8394 + version = "0.1.0"
@@ -324,6 +324,31 @@
324 324 }
325 325 }
326 326
327 + /// One choice, parsed strictly, adding its own complaint if it will not.
328 + ///
329 + /// A select offers a fixed set, so an unparseable value did not come from the
330 + /// form. Refused rather than defaulted: every one of these enums has a
331 + /// `from_str_or_default` that would file a typo as Medium or Pending and say
332 + /// nothing, which is the right behaviour for a database read and the wrong one
333 + /// for a submission.
334 + ///
335 + /// Lived in [`projects`] until the task edit form wanted it too. Here rather
336 + /// than duplicated, because the alternative is two answers to what a rejected
337 + /// option says.
338 + pub(crate) fn parse_choice<T: std::str::FromStr>(
339 + params: &quasi_router::Params,
340 + name: &'static str,
341 + errors: &mut Vec<(&'static str, String)>,
342 + ) -> Option<T> {
343 + match params.get(name).unwrap_or_default().parse() {
344 + Ok(value) => Some(value),
345 + Err(_) => {
346 + errors.push((name, "Not one of the options offered.".to_owned()));
347 + None
348 + }
349 + }
350 + }
351 +
327 352 /// Every described screen's routes.
328 353 #[must_use]
329 354 pub fn router() -> Router<AppState> {
@@ -50,6 +50,7 @@
50 50 use quasi_router::screen::{Act, Choice, Field, Prose, Row, Tag};
51 51 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
52 52
53 + use super::parse_choice;
53 54 use crate::state::{AppState, DESKTOP_USER_ID};
54 55
55 56 mod dashboard;
@@ -604,21 +605,6 @@
604 605 wrote(state, shared_only, show_retired)
605 606 }
606 607
607 - /// One choice, parsed strictly, adding its own complaint if it will not.
608 - fn parse_choice<T: std::str::FromStr>(
609 - params: &quasi_router::Params,
610 - name: &'static str,
611 - errors: &mut Vec<(&'static str, String)>,
612 - ) -> Option<T> {
613 - match params.get(name).unwrap_or_default().parse() {
614 - Ok(value) => Some(value),
615 - Err(_) => {
616 - errors.push((name, "Not one of the options offered.".to_owned()));
617 - None
618 - }
619 - }
620 - }
621 -
622 608 /// Delete a project.
623 609 ///
624 610 /// A 404 for a project that is not there rather than a quiet success: the
@@ -40,6 +40,8 @@
40 40 //! # The shape
41 41 //!
42 42 //! - `GET /tasks/{id}` — the whole overview.
43 + //! - `GET /tasks/{id}/edit` — the edit form, which is a screen of its own.
44 + //! - `POST /tasks/{id}` — save it.
43 45 //! - `POST /tasks/{id}/complete` — mark it done.
44 46 //! - `POST /tasks/{id}/delete` — delete it.
45 47 //! - `POST /tasks/{id}/subtasks` — add one.
@@ -49,10 +51,15 @@
49 51 //! - `POST /tasks/{id}/dependencies/{other}/remove` — cut one, carrying `role`.
50 52 //!
51 53 //! Every described control reaches one of those, which is the standard the
52 - //! contacts port set. The one control left out rather than dangled is Edit: it
53 - //! opens `form-modal.js` over the drawer, and a modal form over a screen is a
54 + //! contacts port set.
55 + //!
56 + //! Edit was the one control left out rather than dangled, on the grounds that
57 + //! `form-modal.js` opens over the drawer and a modal form over a screen is a
54 58 //! second arrangement this screen would have to describe before it could offer
55 - //! it. Recorded rather than faked.
59 + //! it. It is described now, as [`edit_screen`] rather than as an arrangement:
60 + //! the form is its own address, and the overview links to it. That was the
61 + //! shape the finding itself named, and the two things it still cannot ask for
62 + //! are recorded on [`edit_fields`].
56 63
57 64 // Handlers take their request by value because `quasi_router::Handler` is a
58 65 // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
@@ -61,7 +68,8 @@
61 68
62 69 use chrono::{DateTime, Local, Utc};
63 70 use goingson_core::{
64 - Annotation, LinkedTaskRef, Priority, Subtask, Task, TaskId, TaskStatus, TimeSession,
71 + Annotation, DbValue as _, LinkedTaskRef, Priority, Recurrence, Subtask, Task, TaskId,
72 + TaskStatus, TimeSession, UpdateTask,
65 73 };
66 74 use quasi_router::screen::{Act, Choice, Field, Figure, Meter, Row, Tag};
67 75 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
@@ -697,14 +705,13 @@
697 705 /// for the reason the projects screen gives: a write lands in more than one
698 706 /// section and a `Response` names one region.
699 707 ///
700 - /// # The fifth finding, left out rather than faked
708 + /// # The fifth finding, closed by giving the form an address
701 709 ///
702 - /// **Edit is not offered.** `tasks.openEdit` opens `form-modal.js` over the
703 - /// drawer. `Region::Modal` names the place, but a screen that offers a control
704 - /// which opens a form over itself has to describe two arrangements at once, and
705 - /// the router answers one screen at a time. Describing the edit form as its own
706 - /// address is the shape that fits, and it is a screen of its own rather than a
707 - /// control on this one.
710 + /// **Edit is not offered** was true until the form became [`edit_screen`].
711 + /// `tasks.openEdit` opens `form-modal.js` over the drawer, and a screen that
712 + /// offers a control which opens a form over itself has to describe two
713 + /// arrangements at once, which the router cannot answer. So Edit here is an
714 + /// address rather than an overlay, and the thing it addresses is a screen.
708 715 fn screen(state: &AppState, id: TaskId) -> Result<Screen, RouteError> {
709 716 let task = load(state, id)?;
710 717 let sessions = state
@@ -723,6 +730,7 @@
723 730 let candidates = blocker_candidates(state, &task, &blockers)?;
724 731
725 732 let mut band = Slot::new("task-band", RegionKind::Band).with(Node::page(&task.title));
733 + band = band.with(Node::act("Edit", Action::get(format!("/tasks/{id}/edit"))));
726 734 if task.status != TaskStatus::Completed {
727 735 band = band.with(Node::act(
728 736 "Complete",
@@ -766,6 +774,460 @@
766 774 Ok(screen(state, task_id(&request)?)?.into())
767 775 }
768 776
777 + /// The statuses the edit form offers, which are `task-forms.js:STATUS_OPTIONS`.
778 + ///
779 + /// [`TaskStatus::Deleted`] is not among them, for the reason
780 + /// [`super::move_to`] gives: deleting is its own control, and a status select
781 + /// that could delete would put it one option away from Completed.
782 + const EDIT_STATUSES: [&str; 3] = ["Pending", "Started", "Completed"];
783 +
784 + /// `task-forms.js:PRIORITIES`, in its order, which is lowest first.
785 + const EDIT_PRIORITIES: [&str; 3] = ["Low", "Medium", "High"];
786 +
787 + /// `task-forms.js:RECURRENCE_OPTIONS`.
788 + const EDIT_RECURRENCES: [&str; 4] = ["None", "Daily", "Weekly", "Monthly"];
789 +
790 + /// The due date as the form shows it, which is local wall clock.
791 + ///
792 + /// `getTaskFormFields` builds the same `YYYY-MM-DDTHH:MM` out of a `Date`, and
793 + /// [`goingson_core::parse_natural_date`] reads that format back, so the value
794 + /// the form offers is a value the form accepts. A prefill the parser would
795 + /// reject is a field that cannot be left alone.
796 + fn due_value(task: &Task) -> String {
797 + task.due
798 + .map(|due| {
799 + due.with_timezone(&Local)
800 + .format("%Y-%m-%dT%H:%M")
801 + .to_string()
802 + })
803 + .unwrap_or_default()
804 + }
805 +
806 + /// The questions the edit form asks, in `getTaskFormFields`'s order.
807 + ///
808 + /// `errors` and `submitted` are the projects form's, and work the same way:
809 + /// a refusal names what was wrong and hands back what was typed, through
810 + /// [`Field::refilled`].
811 + ///
812 + /// # What this form does not ask, and why
813 + ///
814 + /// **The recurrence rule.** The select offers the four patterns and stops
815 + /// there. `buildRecurrenceConfigHtml` grows a second form underneath it —
816 + /// an interval, a weekday multi-select, a day-of-month or nth-weekday choice,
817 + /// an end date — that appears and changes shape as the pattern changes. Two
818 + /// things are missing before that can be described, and neither is this
819 + /// screen's to invent: a field whose presence depends on another field's
820 + /// value, and a multi-select. `FieldKind` has neither.
821 + ///
822 + /// The consequence is handled rather than ignored: [`update`] threads the
823 + /// stored [`Task::recurrence_rule`] through untouched, so editing the title of
824 + /// a task that repeats on Tuesdays and Thursdays leaves it repeating on
825 + /// Tuesdays and Thursdays. Dropping it would be the silent kind of loss.
826 + ///
827 + /// **A milestone from another project.** The select offers the milestones of
828 + /// the project the task is *in*, read now. `setupMilestoneSelect` re-fetches
829 + /// them when the project select changes, which a form that is one address
830 + /// cannot do, so moving a task to another project and filing it under that
831 + /// project's milestone is two submissions here rather than one. [`update`]
832 + /// refuses the pairing rather than storing it, which is the same answer the
833 + /// JS's repopulating select gives by never offering it.
834 + fn edit_fields(
835 + state: &AppState,
836 + task: &Task,
837 + errors: &[(&str, String)],
838 + submitted: Option<&quasi_router::Params>,
839 + ) -> Result<Vec<Field>, RouteError> {
840 + let error_for = |name: &str| {
841 + errors
842 + .iter()
843 + .find(|(field, _)| *field == name)
844 + .map(|(_, message)| message.clone())
845 + };
846 + let apply = |field: Field, name: &str| match error_for(name) {
847 + Some(message) => field.error(message),
848 + None => field,
849 + };
850 + let projects = state
851 + .projects
852 + .list_all(DESKTOP_USER_ID)
853 + .map_err(|error| RouteError::internal(error.to_string()))?;
854 + let contacts = state
855 + .contacts
856 + .list_all(DESKTOP_USER_ID)
857 + .map_err(|error| RouteError::internal(error.to_string()))?;
858 + let milestones = match task.project_id {
859 + Some(project_id) => state
860 + .milestones
861 + .list_by_project(project_id, DESKTOP_USER_ID)
862 + .map_err(|error| RouteError::internal(error.to_string()))?,
863 + None => Vec::new(),
864 + };
865 +
866 + let mut title = Field::new(makeover_layout::FieldKind::Text, "title", "Title")
867 + .required()
868 + .value(&task.title);
869 + title.placeholder = Some("What needs to be done?".to_owned());
870 +
871 + let mut description = Field::new(
872 + makeover_layout::FieldKind::Textarea,
873 + "description",
874 + "Details",
875 + )
876 + .value(&task.description);
877 + description.placeholder = Some("Anything the title does not cover (optional)".to_owned());
878 +
879 + let mut project_options = vec![Choice::new("", "No Project")];
880 + project_options.extend(
881 + projects
882 + .iter()
883 + .map(|project| Choice::new(project.id.to_string(), &project.name)),
884 + );
885 +
886 + let mut contact_options = vec![Choice::new("", "No Contact")];
887 + contact_options.extend(
888 + contacts
889 + .iter()
890 + .map(|contact| Choice::new(contact.id.to_string(), &contact.display_name)),
891 + );
892 +
893 + let mut milestone_options = vec![Choice::new("", "No Milestone")];
894 + milestone_options.extend(
895 + milestones
896 + .iter()
897 + .map(|milestone| Choice::new(milestone.id.to_string(), &milestone.name)),
898 + );
899 +
900 + let mut due = Field::new(
901 + makeover_layout::FieldKind::Text,
902 + "due",
903 + "Due Date (optional)",
904 + )
905 + .value(due_value(task));
906 + due.placeholder = Some("tomorrow, friday 3pm, 2026-12-25...".to_owned());
907 +
908 + let mut estimated = Field::new(
909 + makeover_layout::FieldKind::Number,
910 + "estimated_minutes",
911 + "Estimated Time (minutes)",
912 + )
913 + .hint("Used for day plan scheduling and time tracking progress")
914 + .value(
915 + task.estimated_minutes
916 + .map(|minutes| minutes.to_string())
917 + .unwrap_or_default(),
918 + );
919 + estimated.placeholder = Some("e.g. 30, 60, 120".to_owned());
920 +
921 + let mut tags = Field::new(
922 + makeover_layout::FieldKind::Text,
923 + "tags",
924 + "Tags (comma-separated)",
925 + )
926 + .value(task.tags.join(", "));
927 + tags.placeholder = Some("work, urgent, meeting".to_owned());
928 +
929 + let fields = vec![
930 + apply(title, "title"),
931 + apply(description, "description"),
932 + apply(
933 + Field::select("project_id", "Project", project_options)
934 + .value(option_id(task.project_id.map(|id| id.to_string()))),
935 + "project_id",
936 + ),
937 + apply(
938 + Field::select(
939 + "status",
940 + "Status",
941 + EDIT_STATUSES
942 + .iter()
943 + .map(|status| Choice::new(*status, *status))
944 + .collect(),
945 + )
946 + .value(task.status.as_str()),
947 + "status",
948 + ),
949 + apply(
950 + Field::select(
951 + "priority",
952 + "Priority",
953 + EDIT_PRIORITIES
954 + .iter()
955 + .map(|priority| Choice::new(*priority, *priority))
956 + .collect(),
957 + )
958 + .value(task.priority.db_value()),
959 + "priority",
960 + ),
961 + apply(due, "due"),
962 + apply(tags, "tags"),
963 + apply(
964 + Field::select(
965 + "recurrence",
966 + "Recurrence",
967 + EDIT_RECURRENCES
968 + .iter()
969 + .map(|pattern| Choice::new(*pattern, *pattern))
970 + .collect(),
971 + )
972 + .hint("Completing a recurring task auto-creates the next occurrence")
973 + .value(task.recurrence.db_value()),
974 + "recurrence",
975 + ),
976 + apply(estimated, "estimated_minutes"),
977 + apply(
978 + Field::select("contact_id", "Contact", contact_options)
979 + .value(option_id(task.contact_id.map(|id| id.to_string()))),
980 + "contact_id",
981 + ),
982 + apply(
983 + Field::select("milestone_id", "Milestone", milestone_options)
984 + .hint("Group tasks into project phases; milestones are managed per project")
985 + .value(option_id(task.milestone_id.map(|id| id.to_string()))),
986 + "milestone_id",
987 + ),
988 + ];
989 +
990 + Ok(match submitted {
991 + Some(params) => fields
992 + .into_iter()
993 + .map(|field| field.refilled(params))
994 + .collect(),
995 + None => fields,
996 + })
997 + }
998 +
999 + /// An optional id as the select spells it, which is the empty string for none.
1000 + fn option_id(id: Option<String>) -> String {
1001 + id.unwrap_or_default()
1002 + }
1003 +
1004 + /// The edit form, at its own address.
1005 + ///
1006 + /// This is the fifth finding closed rather than recorded. It stood as **Edit is
1007 + /// not offered**, because `tasks.openEdit` opens `form-modal.js` over the drawer
1008 + /// and a screen offering a control that opens a form over itself has to describe
1009 + /// two arrangements at once. The shape that fits was named in the same
1010 + /// paragraph and is what this is: the form is a screen of its own, reached by
1011 + /// address, and the control on the overview is a link to it rather than a
1012 + /// second arrangement drawn on top.
1013 + ///
1014 + /// What the modal bought — the screen behind staying put — an address gives
1015 + /// back for free, because Cancel is the overview's own address and the overview
1016 + /// is rebuilt from the database rather than restored from memory.
1017 + fn edit_screen(
1018 + state: &AppState,
1019 + task: &Task,
1020 + errors: &[(&str, String)],
1021 + submitted: Option<&quasi_router::Params>,
1022 + ) -> Result<Screen, RouteError> {
1023 + let band = Slot::new("task-band", RegionKind::Band)
1024 + .with(Node::page(format!("Edit {}", task.title)))
1025 + .with(Node::act(
1026 + "Cancel",
1027 + Action::get(format!("/tasks/{}", task.id)),
1028 + ));
1029 +
1030 + let pane = Slot::new("task-overview", RegionKind::Pane).with(Node::Form {
1031 + action: Action::post(format!("/tasks/{}", task.id)),
1032 + submit: "Save task".to_owned(),
1033 + fields: edit_fields(state, task, errors, submitted)?,
1034 + });
1035 +
1036 + Ok(Screen::list_detail("Edit task", false)
1037 + .with(band)
1038 + .with(pane))
1039 + }
1040 +
1041 + /// The edit form.
1042 + fn edit(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1043 + let task = load(state, task_id(&request)?)?;
1044 + Ok(edit_screen(state, &task, &[], None)?.into())
1045 + }
1046 +
1047 + /// What the JS form refuses, refused here.
1048 + ///
1049 + /// The title length is `getTaskFormFields`'s own `validate` closure. The empty
1050 + /// title is `update_task`'s, which checks it server-side and is the only one of
1051 + /// the two that a described form could not skip.
1052 + fn validate_edit(title: &str) -> Vec<(&'static str, String)> {
1053 + let mut errors = Vec::new();
1054 + if title.is_empty() {
1055 + errors.push(("title", "A task needs a title.".to_owned()));
1056 + } else if title.chars().count() > 80 {
1057 + errors.push(("title", "Maximum 80 characters".to_owned()));
1058 + }
1059 + errors
1060 + }
1061 +
1062 + /// Save the edited task, or answer with the form saying why not.
1063 + ///
1064 + /// The write is [`UpdateTask`] through the repository rather than the
1065 + /// `update_task` command, which is a Tauri wrapper around exactly this. What
1066 + /// the command holds that is worth keeping — the urgency recalculation, the
1067 + /// title/description split, and reading `scheduled_start` and
1068 + /// `scheduled_duration` off the stored row so a time-blocked task does not lose
1069 + /// its block on an unrelated edit — is in core and is called here for the same
1070 + /// reason.
1071 + fn update(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1072 + let id = task_id(&request)?;
1073 + let task = load(state, id)?;
1074 +
1075 + let field = |name: &str| {
1076 + request
1077 + .payload
1078 + .get(name)
1079 + .unwrap_or_default()
1080 + .trim()
1081 + .to_owned()
1082 + };
1083 + let title = field("title");
1084 + let description = field("description");
1085 + let mut errors = validate_edit(&title);
1086 +
1087 + let status = super::parse_choice::<TaskStatus>(&request.payload, "status", &mut errors)
1088 + .filter(|status| EDIT_STATUSES.contains(&status.as_str()));
1089 + if status.is_none() && !errors.iter().any(|(name, _)| *name == "status") {
1090 + errors.push(("status", "Not a status a control can set.".to_owned()));
1091 + }
1092 + let priority = super::parse_choice::<Priority>(&request.payload, "priority", &mut errors);
1093 + let recurrence = super::parse_choice::<Recurrence>(&request.payload, "recurrence", &mut errors);
1094 +
1095 + // Blank is "no due date", which is how the field is cleared. Anything else
1096 + // has to parse, and an unparseable date is refused rather than dropped:
1097 + // dropping it is a task that silently loses its deadline on an edit that
1098 + // was about something else. `parse_natural_date` is the same function the
1099 + // JS reaches through the `parse_natural_date` command, so the two agree on
1100 + // what "friday 3pm" means.
1101 + let raw_due = field("due");
1102 + let due = if raw_due.is_empty() {
1103 + None
1104 + } else {
1105 + match goingson_core::parse_natural_date(&raw_due, Local::now().naive_local())
1106 + .and_then(|when| when.and_local_timezone(Local).single())
1107 + {
1108 + Some(when) => Some(when.with_timezone(&Utc)),
1109 + None => {
1110 + errors.push((
1111 + "due",
1112 + "Date not recognized. Try \"tomorrow\", \"friday 3pm\", or \"2026-12-25\"."
1113 + .to_owned(),
1114 + ));
1115 + None
1116 + }
1117 + }
1118 + };
1119 +
1120 + let estimated_minutes = match field("estimated_minutes").as_str() {
1121 + "" => None,
1122 + raw => match raw.parse::<i32>() {
1123 + Ok(minutes) if minutes >= 0 => Some(minutes),
1124 + _ => {
1125 + errors.push(("estimated_minutes", "A number of minutes.".to_owned()));
1126 + None
1127 + }
1128 + },
1129 + };
1130 +
1131 + let project_id = parse_optional_id(&request.payload, "project_id", &mut errors);
1132 + let contact_id = parse_optional_id(&request.payload, "contact_id", &mut errors);
1133 + let milestone_id = parse_optional_id(&request.payload, "milestone_id", &mut errors);
1134 +
1135 + // A milestone belongs to a project, and the form offered the milestones of
1136 + // the project the task was in. Moving both at once is refused rather than
1137 + // stored, because the pairing the form could offer and the pairing the
1138 + // submission carries are not the same thing. See [`edit_fields`].
1139 + if milestone_id.flatten().is_some() && project_id.flatten() != task.project_id {
1140 + errors.push((
1141 + "milestone_id",
1142 + "Move the task first, then file it under a milestone of its new project.".to_owned(),
1143 + ));
1144 + }
1145 +
1146 + let (Some(status), Some(priority), Some(recurrence)) = (status, priority, recurrence) else {
1147 + return Ok(edit_screen(state, &task, &errors, Some(&request.payload))?.into());
1148 + };
1149 + let (Some(project_id), Some(contact_id), Some(milestone_id)) =
1150 + (project_id, contact_id, milestone_id)
1151 + else {
1152 + return Ok(edit_screen(state, &task, &errors, Some(&request.payload))?.into());
1153 + };
1154 + if !errors.is_empty() {
1155 + return Ok(edit_screen(state, &task, &errors, Some(&request.payload))?.into());
1156 + }
1157 +
1158 + let tags: Vec<String> = field("tags")
1159 + .split(',')
1160 + .map(|tag| tag.trim().to_owned())
1161 + .filter(|tag| !tag.is_empty())
1162 + .collect();
1163 +
1164 + let context = state
1165 + .tasks
1166 + .get_update_context(id, DESKTOP_USER_ID)
1167 + .map_err(|error| RouteError::internal(error.to_string()))?
1168 + .ok_or_else(|| RouteError::not_found("no such task"))?;
1169 +
1170 + state
1171 + .tasks
1172 + .update(
1173 + id,
1174 + DESKTOP_USER_ID,
1175 + UpdateTask {
1176 + project_id,
1177 + milestone_id,
1178 + contact_id,
1179 + urgency: goingson_core::calculate_urgency(
1180 + &priority,
1181 + &status,
1182 + due.as_ref(),
1183 + &context.created_at,
1184 + &tags,
1185 + ),
1186 + title,
1187 + description,
1188 + status,
1189 + priority,
1190 + due,
1191 + tags,
1192 + recurrence,
1193 + // Threaded rather than rebuilt: the form cannot ask for it.
1194 + // See [`edit_fields`].
1195 + recurrence_rule: task.recurrence_rule.clone(),
1196 + scheduled_start: context.scheduled_start,
1197 + scheduled_duration: context.scheduled_duration,
1198 + estimated_minutes,
1199 + },
1200 + )
1201 + .map_err(|error| RouteError::internal(error.to_string()))?
1202 + .ok_or_else(|| RouteError::not_found("no such task"))?;
1203 +
1204 + Ok(wrote(state, id)?.toast(makeover_layout::Tone::Success, "Task saved"))
1205 + }
1206 +
1207 + /// An id a select offers as an option, where the empty option means none.
1208 + ///
1209 + /// Two layers of `Option` and both mean something: the outer is whether the
1210 + /// value parsed, the inner is whether one was chosen. Flattening them here
1211 + /// would make a typo indistinguishable from "No Project", which is the one
Lines truncated
@@ -443,14 +443,18 @@
443 443 }
444 444
445 445 #[tokio::test]
446 - async fn edit_is_absent_rather_than_dangling() {
447 - // The fifth finding. `tasks.openEdit` opens a modal form over the drawer,
448 - // which is a second arrangement this screen would have to describe. Left
449 - // out rather than offered as a control that calls nothing.
446 + async fn edit_is_an_address_rather_than_an_overlay() {
447 + // The fifth finding, and this assertion is its inverse. It read
448 + // `!page.contains(">Edit<")` while the control was left out, because
449 + // `tasks.openEdit` opens a modal form over the drawer and that is a second
450 + // arrangement this screen would have to describe. The form has its own
451 + // address now, so the control is a link to a screen rather than an overlay
452 + // drawn on this one.
450 453 let state = state().await;
451 - let task = add(&state, "Not editable here");
454 + let task = add(&state, "Editable elsewhere");
452 455 let page = html(get(&state, &format!("/tasks/{}", task.id)));
453 - assert!(!page.contains(">Edit<"));
456 + assert!(page.contains(">Edit<"), "{page}");
457 + assert!(page.contains(&format!("/tasks/{}/edit", task.id)), "{page}");
454 458 }
455 459
456 460 // The dependencies section. It exists because `338aa9f` added the blocking
@@ -622,3 +626,322 @@
622 626 assert!(!page.contains("<script>"), "{page}");
623 627 assert!(page.contains("&lt;script&gt;"), "{page}");
624 628 }
629 +
630 + /// What a response said on the way, which is not part of its content.
631 + fn notice(response: &Response) -> String {
632 + response
633 + .notice
634 + .as_ref()
635 + .map(|message| message.text.clone())
636 + .unwrap_or_default()
637 + }
638 +
639 + fn reread(state: &AppState, id: goingson_core::TaskId) -> goingson_core::Task {
640 + state
641 + .tasks
642 + .get_by_id(id, DESKTOP_USER_ID)
643 + .unwrap()
644 + .expect("the task is there")
645 + }
646 +
647 + /// Everything the edit form asks for, filled from a task, as a submission.
648 + ///
649 + /// Overrides replace rather than append: `Params::get` answers with the first
650 + /// value under a name, so a second entry for a field the form already carries
651 + /// is a value nothing reads.
652 + fn edit_params(task: &goingson_core::Task, overrides: &[(&str, &str)]) -> Params {
653 + let title = task.title.clone();
654 + let description = task.description.clone();
655 + let base: Vec<(&str, &str)> = vec![
656 + ("title", &title),
657 + ("description", &description),
658 + ("project_id", ""),
659 + ("status", "Pending"),
660 + ("priority", "High"),
661 + ("due", ""),
662 + ("tags", ""),
663 + ("recurrence", "None"),
664 + ("estimated_minutes", ""),
665 + ("contact_id", ""),
666 + ("milestone_id", ""),
667 + ];
668 + base.into_iter()
669 + .fold(Params::new(), |params, (name, value)| {
670 + let chosen = overrides
671 + .iter()
672 + .find(|(over, _)| *over == name)
673 + .map_or(value, |(_, over)| *over);
674 + params.with(name, chosen)
675 + })
676 + }
677 +
678 + #[tokio::test]
679 + async fn the_overview_offers_edit_as_an_address_rather_than_an_overlay() {
680 + let state = state().await;
681 + let task = add(&state, "Write the port");
682 +
683 + let page = html(get(&state, &format!("/tasks/{}", task.id)));
684 + assert!(page.contains(&format!("/tasks/{}/edit", task.id)), "{page}");
685 +
686 + // And the address answers with a form carrying what the task holds.
687 + let form = html(get(&state, &format!("/tasks/{}/edit", task.id)));
688 + assert!(form.contains("Write the port"), "{form}");
689 + assert!(form.contains("Save task"), "{form}");
690 + // Cancel is the overview's own address; the modal it replaces had nothing
691 + // to go back to but memory.
692 + assert!(form.contains("Cancel"), "{form}");
693 + }
694 +
695 + #[tokio::test]
696 + async fn saving_writes_every_field_the_form_asked_for() {
697 + let state = state().await;
698 + let task = add(&state, "Rough title");
699 +
700 + let response = post(
701 + &state,
702 + &format!("/tasks/{}", task.id),
703 + edit_params(
704 + &task,
705 + &[
706 + ("title", "Sharper title"),
707 + ("description", "With detail."),
708 + ("status", "Started"),
709 + ("priority", "Low"),
710 + ("tags", " work , , urgent "),
711 + ("estimated_minutes", "45"),
712 + ("due", "2026-12-25"),
713 + ],
714 + ),
715 + );
716 +
717 + assert_eq!(notice(&response), "Task saved");
718 + let saved = reread(&state, task.id);
719 + assert_eq!(saved.title, "Sharper title");
720 + assert_eq!(saved.description, "With detail.");
721 + assert_eq!(saved.status, goingson_core::TaskStatus::Started);
722 + assert_eq!(saved.priority, Priority::Low);
723 + // The blank entry between the commas is dropped, as `normalizeTags` does.
724 + assert_eq!(saved.tags, vec!["work".to_owned(), "urgent".to_owned()]);
725 + assert_eq!(saved.estimated_minutes, Some(45));
726 + assert!(saved.due.is_some());
727 + // Urgency is recalculated rather than carried, which is what makes an edit
728 + // that adds a due date move the task up the list.
729 + assert!(saved.urgency > 0.0);
730 + }
731 +
732 + #[tokio::test]
733 + async fn a_refused_edit_hands_back_what_was_typed() {
734 + let state = state().await;
735 + let task = add(&state, "Fine as it is");
736 +
737 + let long = "x".repeat(81);
738 + let page = html(post(
739 + &state,
740 + &format!("/tasks/{}", task.id),
741 + edit_params(
742 + &task,
743 + &[("title", &long), ("description", "Worth keeping.")],
744 + ),
745 + ));
746 +
747 + // The complaint, the 81 characters back in the box, and the rest of the
748 + // submission still there. A form that reports a length and empties the
749 + // field is how a user retypes to shorten.
750 + assert!(page.contains("Maximum 80 characters"), "{page}");
751 + assert!(page.contains(&long), "{page}");
752 + assert!(page.contains("Worth keeping."), "{page}");
753 + assert_eq!(reread(&state, task.id).title, "Fine as it is");
754 + }
755 +
756 + #[tokio::test]
757 + async fn an_unparseable_due_date_is_refused_rather_than_dropped() {
758 + let state = state().await;
759 + let task = add(&state, "Has a deadline");
760 + post(
761 + &state,
762 + &format!("/tasks/{}", task.id),
763 + edit_params(&task, &[("due", "friday 3pm")]),
764 + );
765 + let due = reread(&state, task.id).due.expect("friday 3pm parsed");
766 +
767 + let page = html(post(
768 + &state,
769 + &format!("/tasks/{}", task.id),
770 + edit_params(&task, &[("due", "someday")]),
771 + ));
772 +
773 + assert!(page.contains("Date not recognized"), "{page}");
774 + // Still the deadline it had. Dropping it would lose a date on an edit that
775 + // was about something else.
776 + assert_eq!(reread(&state, task.id).due, Some(due));
777 + }
778 +
779 + #[tokio::test]
780 + async fn a_blank_due_date_clears_it() {
781 + let state = state().await;
782 + let task = add(&state, "Not urgent after all");
783 + post(
784 + &state,
785 + &format!("/tasks/{}", task.id),
786 + edit_params(&task, &[("due", "tomorrow")]),
787 + );
788 + assert!(reread(&state, task.id).due.is_some());
789 +
790 + post(
791 + &state,
792 + &format!("/tasks/{}", task.id),
793 + edit_params(&task, &[]),
794 + );
795 + assert!(reread(&state, task.id).due.is_none());
796 + }
797 +
798 + #[tokio::test]
799 + async fn the_prefilled_due_date_is_one_the_form_accepts() {
800 + let state = state().await;
801 + let task = add(&state, "Round trips");
802 + post(
803 + &state,
804 + &format!("/tasks/{}", task.id),
805 + edit_params(&task, &[("due", "2026-12-25 3pm")]),
806 + );
807 + let due = reread(&state, task.id).due.expect("parsed");
808 +
809 + // The value the form offers, submitted back unchanged. A prefill the parser
810 + // would reject is a field that cannot be left alone.
811 + let prefilled = super::due_value(&reread(&state, task.id));
812 + let response = post(
813 + &state,
814 + &format!("/tasks/{}", task.id),
815 + edit_params(&task, &[("due", &prefilled)]),
816 + );
817 +
818 + assert_eq!(notice(&response), "Task saved");
819 + assert_eq!(reread(&state, task.id).due, Some(due));
820 + }
821 +
822 + #[tokio::test]
823 + async fn an_edit_leaves_the_recurrence_rule_alone() {
824 + let state = state().await;
825 + let task = add(&state, "Every other Tuesday");
826 + let rule = goingson_core::RecurrenceRule {
827 + pattern: goingson_core::Recurrence::Weekly,
828 + interval: 2,
829 + weekdays: vec![1],
830 + monthly_spec: None,
831 + until: None,
832 + };
833 + let mut with_rule = task.clone();
834 + with_rule.recurrence = goingson_core::Recurrence::Weekly;
835 + with_rule.recurrence_rule = Some(rule.clone());
836 + state
837 + .tasks
838 + .update(
839 + task.id,
840 + DESKTOP_USER_ID,
841 + goingson_core::UpdateTask {
842 + project_id: None,
843 + milestone_id: None,
844 + contact_id: None,
845 + title: with_rule.title.clone(),
846 + description: with_rule.description.clone(),
847 + status: goingson_core::TaskStatus::Pending,
848 + priority: Priority::High,
849 + due: None,
850 + tags: Vec::new(),
851 + recurrence: goingson_core::Recurrence::Weekly,
852 + recurrence_rule: Some(rule.clone()),
853 + urgency: 0.0,
854 + scheduled_start: None,
855 + scheduled_duration: None,
856 + estimated_minutes: None,
857 + },
858 + )
859 + .unwrap();
860 +
861 + // A title edit, through a form that cannot ask about weekdays at all.
862 + post(
863 + &state,
864 + &format!("/tasks/{}", task.id),
865 + edit_params(
866 + &task,
867 + &[
868 + ("title", "Every other Tuesday, renamed"),
869 + ("recurrence", "Weekly"),
870 + ],
871 + ),
872 + );
873 +
874 + let saved = reread(&state, task.id);
875 + assert_eq!(saved.title, "Every other Tuesday, renamed");
876 + let kept = saved.recurrence_rule.expect("the rule survived the edit");
877 + assert_eq!(kept.interval, 2);
878 + assert_eq!(kept.weekdays, vec![1]);
879 + }
880 +
881 + #[tokio::test]
882 + async fn a_milestone_of_another_project_is_refused() {
883 + let state = state().await;
884 + let task = add(&state, "Filed somewhere");
885 + let project = state
886 + .projects
887 + .create(
888 + DESKTOP_USER_ID,
889 + goingson_core::NewProject {
890 + name: "Elsewhere".to_owned(),
891 + description: String::new(),
892 + project_type: goingson_core::ProjectType::Other,
893 + status: goingson_core::ProjectStatus::Active,
894 + },
895 + )
896 + .unwrap();
897 + let milestone = state
898 + .milestones
899 + .create(
900 + DESKTOP_USER_ID,
901 + goingson_core::NewMilestone {
902 + project_id: project.id,
903 + name: "Phase one".to_owned(),
904 + description: String::new(),
905 + position: 0,
906 + target_date: None,
907 + },
908 + )
909 + .unwrap();
910 +
911 + // Moving the task and filing it under the new project's milestone in one
912 + // submission, which is the pairing the form never offered.
913 + let page = html(post(
914 + &state,
915 + &format!("/tasks/{}", task.id),
916 + edit_params(
917 + &task,
918 + &[
919 + ("project_id", &project.id.to_string()),
920 + ("milestone_id", &milestone.id.to_string()),
921 + ],
922 + ),
923 + ));
924 +
925 + assert!(page.contains("Move the task first"), "{page}");
926 + let saved = reread(&state, task.id);
927 + assert_eq!(saved.project_id, None);
928 + assert_eq!(saved.milestone_id, None);
929 + }
930 +
931 + #[tokio::test]
932 + async fn a_status_control_cannot_delete_a_task() {
933 + let state = state().await;
934 + let task = add(&state, "Still here");
935 +
936 + let page = html(post(
937 + &state,
938 + &format!("/tasks/{}", task.id),
939 + edit_params(&task, &[("status", "Deleted")]),
940 + ));
941 +
942 + assert!(page.contains("Not a status a control can set"), "{page}");
943 + assert_eq!(
944 + reread(&state, task.id).status,
945 + goingson_core::TaskStatus::Pending
946 + );
947 + }