Skip to main content

max / goingson

A shared project says so, and can be moved back to personal Groups M4's remaining half was filed as "frontend only" against projects.js, settings-sync.js and style.css, all of which the swap deleted. Re-measured against the described screens: the shared-only filter is already there, and neither the mark nor either write is. Two of the three are local writes and land here. A row carries a Shared badge when group_id is set. The filter shipped before the mark did, so a "Shared only" view was the only way to tell which rows it would keep, which is a filter asking the user to guess what it filters on. The detail pane offers "Move back to personal", which is set_project_scope with None across the whole subtree. Called directly rather than through unshare_project because that command is async for the sake of its siblings and this path awaits nothing. The project is resolved before the stamp for share_project's own recorded reason: a stale id stamps zero rows and would otherwise report success. SHARING INTO A GROUP IS NOT OFFERED, and the reason is not this screen. The control would be a picker over the user's groups, group_list answers by awaiting the SyncKit client, and a quasi_router::Handler is a plain fn that cannot await. There is no local table of groups to read instead: membership lives on the server. That is the same gap keeping Settings > Sharing undescribed, recorded in quasi/mod.rs's table and filed as quasicoherent 82273265. Two screens want it now, which is worth knowing when that task is picked up. Offering only the way out is deliberate. A project shared from another device shows as shared and can be moved back, so the reversal path is whole; a control that could not populate its own options would be worse than none. set_project_scope and commands::group are pub(crate) so the screen can reach the write without going through the async wrapper.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-24 17:27 UTC
Signed with PGP, not checked
Commit: 2a0069bdd0efdc5ab90ae60a84aa1672c66765dc
Parent: b281a3e
4 files changed, +233 insertions, -3 deletions
@@ -467,7 +467,7 @@
467 467 /// annotations, task_status_tokens, time_sessions); and attachments (owned by the
468 468 /// project or one of its tasks). Predicates key on `project_id`/`task_id`, which
469 469 /// this never changes, so statement order is irrelevant.
470 - fn set_project_scope(
470 + pub(crate) fn set_project_scope(
471 471 db: &Db,
472 472 user_id: goingson_core::UserId,
473 473 project_id: &str,
@@ -30,7 +30,7 @@
30 30 mod event;
31 31 pub(crate) mod export;
32 32 mod form;
33 - mod group;
33 + pub(crate) mod group;
34 34 pub(crate) mod import;
35 35 pub(crate) mod import_external;
36 36 mod milestone;
@@ -40,6 +40,30 @@
40 40 //! action a filtered screen offers has to carry the filters it was offered
41 41 //! under, or acting resets the view. [`filtered`] is that, applied to the
42 42 //! detail address, the create form and both writes.
43 + //!
44 + //! # Sharing: half of it is here, and the half that is not is not this screen's
45 + //!
46 + //! A project's scope is a local column, so the mark and the way out are both
47 + //! sayable. A row carries a `Shared` badge when `group_id` is set, and the detail
48 + //! pane offers "Move back to personal", which is
49 + //! [`crate::commands::group::set_project_scope`] with `None`.
50 + //!
51 + //! **Sharing INTO a group is not offered, and the reason is not this screen.**
52 + //! The control would be a picker over the groups the user belongs to, and
53 + //! `commands::group::group_list` answers that by awaiting the SyncKit client. A
54 + //! `quasi_router::Handler` is `fn(&S, Request) -> Result<Response, RouteError>`,
55 + //! so a handler cannot await, and there is no local table of groups to read
56 + //! instead: membership lives on the server.
57 + //!
58 + //! This is the same gap that keeps Settings > Sharing undescribed, recorded in
59 + //! [`super`]'s table as "its reads are remote, so there is no local state to draw
60 + //! a section from" and filed as quasicoherent `82273265`. Two screens now want
61 + //! the same thing, which is worth knowing when that task is picked up.
62 + //!
63 + //! Offering only the way out is deliberate rather than an oversight. A project
64 + //! shared from another device shows as shared and can be moved back, which is
65 + //! the reversal path; what is missing is the way in, and a control that could
66 + //! not populate its own options would be worse than none.
43 67
44 68 // Handlers take their request by value because `quasi_router::Handler` is a
45 69 // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
@@ -172,6 +196,13 @@
172 196 .token(Tag::badge(type_label(&project.project_type)))
173 197 .token(Tag::badge(status_label(&project.status)).tone(status_tone(&project.status)));
174 198
199 + // The filter existed before the mark did, so a "Shared only" view was the
200 + // only way to tell which rows it would have kept. A scope is a fact about the
201 + // project, and the row is where a fact about the project goes.
202 + if project.group_id.is_some() {
203 + row = row.token(Tag::badge("Shared"));
204 + }
205 +
175 206 if !project.description.is_empty() {
176 207 // Markdown, said so rather than pre-flattened. See the note above.
177 208 row = row.secondary(Prose::rich(&project.description));
@@ -378,6 +409,31 @@
378 409 slot = slot.with(Node::text(&project.description));
379 410 }
380 411
412 + // Unshare, and deliberately not share. See the module header: moving a project
413 + // back to personal scope is a local write and this is the whole of it, while
414 + // sharing needs the group list, which is a remote read a described handler
415 + // cannot make.
416 + if project.group_id.is_some() {
417 + slot = slot.with(Node::text(
418 + "Shared into a group. Its tasks, events, milestones and attachments \
419 + are shared with it.",
420 + ));
421 + slot = slot.with(Node::Act(
422 + Act::new(
423 + "Move back to personal",
424 + filtered(
425 + Action::post(format!("/projects/{}/unshare", project.id)),
426 + shared_only,
427 + show_retired,
428 + ),
429 + )
430 + .confirm(
431 + "Move this project and everything in it back to personal scope? \
432 + Other members of the group will stop seeing it.",
433 + ),
434 + ));
435 + }
436 +
381 437 slot = slot.with(Node::Act(
382 438 Act::new(
383 439 "Delete project",
@@ -623,6 +679,31 @@
623 679 wrote(state, flag(&request, "shared"), flag(&request, "retired"))
624 680 }
625 681
682 + /// Move a project and its whole subtree back to personal scope.
683 + ///
684 + /// The write is [`crate::commands::group::set_project_scope`] with `None`, which
685 + /// is what `unshare_project` does after resolving the project. Called directly
686 + /// rather than through the command because the command is `async` for the sake
687 + /// of its siblings and this path awaits nothing: the engine's UPDATE triggers
688 + /// capture each row with its new scope and the next sync re-routes them.
689 + fn unshare(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
690 + let id = project_id(&request)?;
691 +
692 + // Resolved first, for `share_project`'s reason: without it a stale id stamps
693 + // zero rows and still reports success, and the user believes a project moved
694 + // when nothing did.
695 + state
696 + .projects
697 + .get_by_id(id, DESKTOP_USER_ID)
698 + .map_err(|error| RouteError::internal(error.to_string()))?
699 + .ok_or_else(|| RouteError::not_found("no such project"))?;
700 +
701 + crate::commands::group::set_project_scope(&state.db, DESKTOP_USER_ID, &id.to_string(), None)
702 + .map_err(|error| RouteError::internal(error.to_string()))?;
703 +
704 + wrote(state, flag(&request, "shared"), flag(&request, "retired"))
705 + }
706 +
626 707 /// The projects screen's routes.
627 708 #[must_use]
628 709 pub fn routes(router: Router<AppState>) -> Router<AppState> {
@@ -635,6 +716,7 @@
635 716 .get("/projects/new", new)
636 717 .get("/projects/{id}", detail)
637 718 .post("/projects", create)
638 - .post("/projects/{id}/delete", remove);
719 + .post("/projects/{id}/delete", remove)
720 + .post("/projects/{id}/unshare", unshare);
639 721 dashboard::routes(router)
640 722 }
@@ -605,3 +605,151 @@
605 605 assert!(page.contains(word), "{word} survives: {page}");
606 606 }
607 607 }
608 +
609 + /// A project's scope, stamped the way `set_project_scope` stamps it.
610 + ///
611 + /// Written straight rather than through `share_project`: that command awaits the
612 + /// SyncKit client to confirm membership, and what is under test here is the two
613 + /// halves that read and clear the column, neither of which talks to a server.
614 + fn share_into(state: &AppState, project: &goingson_core::Project, group: &str) {
615 + crate::commands::group::set_project_scope(
616 + &state.db,
617 + DESKTOP_USER_ID,
618 + &project.id.to_string(),
619 + Some(group),
620 + )
621 + .unwrap();
622 + }
623 +
624 + fn scope_of(state: &AppState, project: &goingson_core::Project) -> Option<String> {
625 + state
626 + .db
627 + .conn()
628 + .unwrap()
629 + .query_row(
630 + "SELECT group_id FROM projects WHERE id = ?1",
631 + rusqlite::params![project.id.to_string()],
632 + |row| row.get(0),
633 + )
634 + .unwrap()
635 + }
636 +
637 + /// The filter shipped before the mark did, so a "Shared only" view was the only
638 + /// way to tell which rows it would keep.
639 + #[tokio::test]
640 + async fn a_shared_project_says_so_in_the_list() {
641 + let state = state().await;
642 + let shared = add(&state, "Shared work", ProjectStatus::Active);
643 + add(&state, "Mine alone", ProjectStatus::Active);
644 + share_into(&state, &shared, "11111111-1111-1111-1111-111111111111");
645 +
646 + let Outcome::Screen(screen) = answer(&state, "/projects", Params::new()).outcome else {
647 + panic!("the index answers with a screen");
648 + };
649 + let page = quasi_webview::Webview::new().screen(&screen);
650 + assert!(page.contains("Shared"), "{page}");
651 + }
652 +
653 + #[tokio::test]
654 + async fn a_personal_project_carries_no_shared_badge_and_no_way_out() {
655 + let state = state().await;
656 + let personal = add(&state, "Mine alone", ProjectStatus::Active);
657 +
658 + let Outcome::Fragment { node, .. } =
659 + answer(&state, &format!("/projects/{}", personal.id), Params::new()).outcome
660 + else {
661 + panic!("the detail pane answers with a fragment");
662 + };
663 + let pane = quasi_webview::Webview::new().fragment(&node);
664 + assert!(!pane.contains("Move back to personal"), "{pane}");
665 + assert!(!pane.contains("Shared into a group"), "{pane}");
666 + }
667 +
668 + #[tokio::test]
669 + async fn a_shared_project_offers_the_way_back_to_personal() {
670 + let state = state().await;
671 + let shared = add(&state, "Shared work", ProjectStatus::Active);
672 + share_into(&state, &shared, "11111111-1111-1111-1111-111111111111");
673 +
674 + let Outcome::Fragment { node, .. } =
675 + answer(&state, &format!("/projects/{}", shared.id), Params::new()).outcome
676 + else {
677 + panic!("the detail pane answers with a fragment");
678 + };
679 + let pane = quasi_webview::Webview::new().fragment(&node);
680 + assert!(pane.contains("Move back to personal"), "{pane}");
681 + assert!(
682 + pane.contains(&format!("/projects/{}/unshare", shared.id)),
683 + "{pane}"
684 + );
685 + }
686 +
687 + #[tokio::test]
688 + async fn unsharing_clears_the_scope_across_the_subtree() {
689 + let state = state().await;
690 + let shared = add(&state, "Shared work", ProjectStatus::Active);
691 + let group = "11111111-1111-1111-1111-111111111111";
692 + share_into(&state, &shared, group);
693 +
694 + let task = state
695 + .tasks
696 + .create(
697 + DESKTOP_USER_ID,
698 + goingson_core::NewTask::builder("Inside it")
699 + .project_id(shared.id)
700 + .build(),
701 + )
702 + .unwrap();
703 + // The create path stamps a child from its parent, which is what makes the
704 + // subtree worth re-checking after the clear.
705 + assert_eq!(
706 + scope_of_task(&state, &task).as_deref(),
707 + Some(group),
708 + "the child inherited the scope"
709 + );
710 +
711 + post(
712 + &state,
713 + &format!("/projects/{}/unshare", shared.id),
714 + Params::new(),
715 + );
716 +
717 + assert!(
718 + scope_of(&state, &shared).is_none(),
719 + "the project is personal"
720 + );
721 + assert!(
722 + scope_of_task(&state, &task).is_none(),
723 + "and so is everything in it"
724 + );
725 + }
726 +
727 + fn scope_of_task(state: &AppState, task: &goingson_core::Task) -> Option<String> {
728 + state
729 + .db
730 + .conn()
731 + .unwrap()
732 + .query_row(
733 + "SELECT group_id FROM tasks WHERE id = ?1",
734 + rusqlite::params![task.id.to_string()],
735 + |row| row.get(0),
736 + )
737 + .unwrap()
738 + }
739 +
740 + /// A stale id stamps zero rows and would otherwise report success, which is
741 + /// `share_project`'s own recorded lesson asked of the way back out.
742 + #[tokio::test]
743 + async fn unsharing_a_project_that_is_not_there_is_refused() {
744 + let state = state().await;
745 + let missing = uuid::Uuid::new_v4();
746 + assert!(
747 + router()
748 + .handle(
749 + &state,
750 + Request::post(format!("/projects/{missing}/unshare")),
751 + )
752 + .is_err(),
753 + "a stale id is refused rather than answered with success"
754 + );
755 + }