using Invercargill; using Invercargill.DataStructures; using Invercargill.Expressions; using Spry; using Spry.Authentication; using Statum; using ClankerSaddle.Data; using ClankerSaddle.Services; namespace ClankerSaddle { /** A project row on the projects list, with task counts and a link to its page. */ public class ProjectRowView : Object { public int64 id { get; set; } public string name { get; set; default = ""; } public string git_url { get; set; default = ""; } public string default_branch { get; set; default = ""; } public string summary { get; set; default = ""; } public int64 open_tasks { get; set; } public int64 done_tasks { get; set; } } /** Public data of the `projects` slot. */ public class ProjectsPublic : Object { public Statum.Model.ActionDto create { get; set; } } /** * Entrypoint for /projects: lists every project with open/done task * counts and authors the create-project action (login required). */ public class ProjectsEntrypoint : StatumEntrypoint { private ProjectService project_service = Inversion.inject(); private TaskService task_service = Inversion.inject(); private UserService users = Inversion.inject(); public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } var rows = new Vector(); var tasks = yield task_service.list_tasks(null, null); foreach (var project in yield project_service.list_projects()) { var view = new ProjectRowView() { id = project.id, name = project.name, git_url = project.git_url, default_branch = project.default_branch, summary = project.summary ?? "" }; foreach (var task in tasks) { if (task.project_id != project.id) { continue; } // Goal-agent runs are internal tasks — they never // count against the project's task tallies. if (task.kind == TASK_KIND_GOAL_RUN) { continue; } if (task.status == STATUS_DONE || task.status == STATUS_FAILED) { view.done_tasks++; } else { view.open_tasks++; } } rows.add(view); } var pub = new ProjectsPublic(); pub.create = action_registry.author(); var props = (PropertyDictionary) GObjectMapping.to_properties(pub); props["projects"] = StateJson.list(rows.to_immutable_buffer()); var slot = state_service.new_slot(Scope.PAGE, new State() { type_name = "projects", public_data = props, private_data = new PropertyDictionary() }); return directives().set(slot); } } /** A linked project on the project detail page, with a remove action. */ public class ProjectLinkView : Object { public int64 link_id { get; set; } public int64 project_id { get; set; } public string name { get; set; default = ""; } public string git_url { get; set; default = ""; } public string relationship { get; set; default = ""; } public bool co_developed { get; set; } public Statum.Model.ActionDto? remove { get; set; } } /** A task row on the project detail page. */ public class ProjectTaskRowView : Object { public int64 id { get; set; } public string title { get; set; default = ""; } public string status { get; set; default = ""; } public string display { get; set; default = ""; } public int64 priority { get; set; } } /** * A goal row on the project detail page (goal 41 T9): the missing * reverse cross-link from ux-notes — the project's own goals plus * the ones linked to it as co-developed, so "what is being built * here" is answerable from the project page itself. */ public class ProjectGoalRowView : Object { public int64 id { get; set; } public string title { get; set; default = ""; } public string status { get; set; default = ""; } /** The status with underscores replaced (matches goal pages). */ public string display { get; set; default = ""; } /** "primary" for the project's own goals, else the link's relation. */ public string relationship { get; set; default = ""; } public string branch_name { get; set; default = ""; } } /** A rule attached to the project, with its detach action. */ public class ProjectRuleRowView : Object { public int64 link_id { get; set; } public int64 rule_id { get; set; } public string name { get; set; default = ""; } public string body { get; set; default = ""; } public Statum.Model.ActionDto? detach { get; set; } } /** A stack option in the apply select (project and goal pages). */ public class StackOptionView : Object { public int64 id { get; set; } public string name { get; set; default = ""; } public int64 member_count { get; set; } } /** A rule option in the attach selects (project and task pages). */ public class RuleOptionView : Object { public int64 id { get; set; } public string name { get; set; default = ""; } } /** * A project note on the project page: LLM-authored via `saddle * note`, with its date, source task link and a delete action (no * manual create — notes are user-curated, not user-written). */ public class ProjectNoteRowView : Object { public int64 id { get; set; } public string project_name { get; set; default = ""; } public int64 source_task_id { get; set; } public string body { get; set; default = ""; } public string created_display { get; set; default = ""; } public Statum.Model.ActionDto? delete { get; set; } } /** * One understanding revision on the project page. The current row * is rendered above; the history rows below it are the OLDER * revisions (newest first). An absent current understanding is an * all-empty row (bodies are never empty when stored), which the * page's empty state keys off. */ public class UnderstandingRowView : Object { public int64 id { get; set; } public string body { get; set; default = ""; } public string commit_hash { get; set; default = ""; } public bool has_commit { get; set; } public int64 source_task_id { get; set; } public string created_display { get; set; default = ""; } } /** Public data of the `project` slot. */ public class ProjectDetailPublic : Object { public bool exists { get; set; } public int64 id { get; set; } public string name { get; set; default = ""; } public string git_url { get; set; default = ""; } public string default_branch { get; set; default = ""; } public string summary { get; set; default = ""; } public string created_display { get; set; default = ""; } public Statum.Model.ActionDto? update { get; set; } public Statum.Model.ActionDto? add_link { get; set; } public Statum.Model.ActionDto? attach_rule { get; set; } public Statum.Model.ActionDto? apply_stack { get; set; } public Statum.Model.ActionDto? refresh_understanding { get; set; } public Statum.Model.ActionDto? delete { get; set; } /** True when the viewer may see the delete control (admin). */ public bool is_admin { get; set; } /** What the delete would remove ("3 goals, 9 tasks"). */ public string delete_counts { get; set; default = ""; } /** How many tasks are still live (the delete guard's refusal). */ public int64 active_tasks { get; set; } } /** * Entrypoint for /projects/{project_id}: the project's editable card, * its co-developed/reference links (with remove actions), its rules * (with detach actions and the attach form), the other projects * available for linking, and its task list. */ public class ProjectDetailEntrypoint : StatumEntrypoint { private ProjectService project_service = Inversion.inject(); private TaskService task_service = Inversion.inject(); private UnderstandingService understanding_service = Inversion.inject(); private RuleService rule_service = Inversion.inject(); private StackService stack_service = Inversion.inject(); private GoalService goal_service = Inversion.inject(); private InvercargillSql.Orm.OrmSession db = Inversion.inject(); private UserService users = Inversion.inject(); public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } int64 project_id = int64.parse(request.route("project_id") ?? "0"); var project = yield project_service.get_project(project_id); if (project == null) { return directives().set_typed("project", Scope.PAGE, new ProjectDetailPublic() { exists = false }); } var sealed = new ProjectActionPrivate() { project_id = project_id }; // The admin-only delete: authored only for admins (the // dialog below stays hidden for everyone else), with the // counts the confirmation shows. bool is_admin = SpryAuth.current(request).has_permission(SpryAuth.ADMIN_PERMISSION); string delete_counts = ""; int64 active_tasks = 0; if (is_admin) { var counts = yield project_service.deletion_counts(project_id); if (counts != null) { delete_counts = @"$(plural(((!)counts).goal_count, "goal"))" + @", $(plural(((!)counts).task_count, "task"))"; active_tasks = ((!)counts).active_task_count; } } var pub = new ProjectDetailPublic() { exists = true, id = ((!)project).id, name = ((!)project).name, git_url = ((!)project).git_url, default_branch = ((!)project).default_branch, summary = ((!)project).summary ?? "", created_display = format_stamp(((!)project).created_at), update = action_registry.author_private(sealed), add_link = action_registry.author_private(sealed), attach_rule = action_registry.author_private(sealed), apply_stack = action_registry.author_private(sealed), refresh_understanding = action_registry.author_private(sealed), delete = is_admin ? action_registry.author_private(sealed) : null, is_admin = is_admin, delete_counts = delete_counts, active_tasks = active_tasks }; var links = new Vector(); foreach (var link in yield project_service.list_links(project_id)) { var linked = yield project_service.get_project(link.linked_project_id); if (linked == null) { continue; } var private_blob = new ProjectLinkActionPrivate() { project_id = project_id, link_id = link.id }; links.add(new ProjectLinkView() { link_id = link.id, project_id = link.linked_project_id, name = ((!)linked).name, git_url = ((!)linked).git_url, relationship = link.relationship, co_developed = link.relationship == LINK_CO_DEVELOPED, remove = action_registry.author_private(private_blob) }); } var linkable = new Vector(); foreach (var other in yield project_service.list_projects()) { if (other.id != project_id) { linkable.add(new ProjectRowView() { id = other.id, name = other.name, git_url = other.git_url, default_branch = other.default_branch, summary = other.summary ?? "" }); } } var project_tasks = yield task_service.list_tasks(null, project_id); var task_display = yield task_service.display_map_for(project_tasks); var task_rows = new Vector(); foreach (var task in project_tasks) { // Goal-agent runs are internal — the goal page owns // their visibility. if (task.kind == TASK_KIND_GOAL_RUN) { continue; } var display = task_display.get_or_default(task.id.to_string()); task_rows.add(new ProjectTaskRowView() { id = task.id, title = task.title, status = task.status, display = display != null ? ((!)display).status : task.status, priority = task.priority, }); } var rule_rows = new Vector(); foreach (var link in yield rule_service.list_project_attachments(project_id)) { var rule = yield rule_service.get_rule(link.rule_id); if (rule == null) { continue; } var private_blob = new ProjectRuleActionPrivate() { project_id = project_id, link_id = link.id }; rule_rows.add(new ProjectRuleRowView() { link_id = link.id, rule_id = link.rule_id, name = ((!)rule).name, body = ((!)rule).body, detach = action_registry.author_private(private_blob) }); } var rule_options = new Vector(); foreach (var rule in yield rule_service.list_rules()) { rule_options.add(new RuleOptionView() { id = rule.id, name = rule.name }); } // Goal 41 T9: the goals reverse link (ux-notes navigation). // The project's own goals lead; goals linked as co-developed // follow with their relationship, so the page answers "what // is being built here" without a detour through /goals. // Reference links are deliberately omitted — a reference // repo is context, not work happening in this project. var goal_rows = new Vector(); var seen_goals = new Invercargill.DataStructures.HashSet(); foreach (var goal in yield goal_service.list_goals()) { if (goal.project_id == project_id) { seen_goals.add(goal.id.to_string()); goal_rows.add(new ProjectGoalRowView() { id = goal.id, title = goal.title, status = goal.status, display = goal.status.replace("_", " "), relationship = "primary", branch_name = goal.branch_name }); } } foreach (var link in yield db.query() .where(expr("project_id == $0", elem(project_id))) .materialise_async()) { if (link.relationship != "co-developed" || seen_goals.contains(link.goal_id.to_string())) { continue; } var goal = yield goal_service.get_goal(link.goal_id); if (goal == null) { continue; } goal_rows.add(new ProjectGoalRowView() { id = ((!)goal).id, title = ((!)goal).title, status = ((!)goal).status, display = ((!)goal).status.replace("_", " "), relationship = link.relationship, branch_name = ((!)goal).branch_name }); } var note_rows = new Vector(); foreach (var note in yield project_service.list_notes(project_id)) { var note_private = new ProjectNoteActionPrivate() { project_id = project_id, note_id = note.id }; note_rows.add(new ProjectNoteRowView() { id = note.id, project_name = ((!)project).name, source_task_id = note.source_task_id, body = note.body, created_display = format_stamp(note.created_at), delete = action_registry.author_private(note_private) }); } // The understanding ledger read: the current (newest) // revision renders above; the history below it lists the // OLDER revisions newest-first, so the current row is not // shown twice. No understanding yet is an empty row — the // page's empty state keys off it. var understanding_rows = new Vector(); bool first_understanding = true; foreach (var revision in yield understanding_service.history(project_id)) { if (first_understanding) { first_understanding = false; continue; } understanding_rows.add(new UnderstandingRowView() { id = revision.id, body = revision.body, commit_hash = revision.commit_hash, has_commit = revision.commit_hash.length > 0, source_task_id = revision.task_id, created_display = format_stamp(revision.created_at) }); } var current_understanding_row = new UnderstandingRowView(); var current_revision = yield understanding_service.current_understanding(project_id); if (current_revision != null) { current_understanding_row = new UnderstandingRowView() { id = ((!)current_revision).id, body = ((!)current_revision).body, commit_hash = ((!)current_revision).commit_hash, has_commit = ((!)current_revision).commit_hash.length > 0, source_task_id = ((!)current_revision).task_id, created_display = format_stamp(((!)current_revision).created_at) }; } var props = (PropertyDictionary) GObjectMapping.to_properties(pub); props["links"] = StateJson.list(links.to_immutable_buffer()); props["linkable"] = StateJson.list(linkable.to_immutable_buffer()); props["tasks"] = StateJson.list(task_rows.to_immutable_buffer()); props["goals"] = StateJson.list(goal_rows.to_immutable_buffer()); props["rules"] = StateJson.list(rule_rows.to_immutable_buffer()); props["rule_options"] = StateJson.list(rule_options.to_immutable_buffer()); props["understanding_current"] = StateJson.element(current_understanding_row); props["understanding_history"] = StateJson.list(understanding_rows.to_immutable_buffer()); var stack_options = new Vector(); foreach (var stack in yield stack_service.list_stacks()) { stack_options.add(new StackOptionView() { id = stack.id, name = stack.name, member_count = (yield stack_service.list_members(stack.id)).length }); } props["stack_options"] = StateJson.list(stack_options.to_immutable_buffer()); props["notes"] = StateJson.list(note_rows.to_immutable_buffer()); // Goal 41 T9: the breadcrumb trail (Dashboard / Projects / // ) — the shared partial renders it, replacing the // hand-drawn "All projects" anchor. var crumbs = new Vector(); crumbs.add(new CrumbView() { href = "/", label = "Dashboard" }); crumbs.add(new CrumbView() { href = "/projects", label = "Projects" }); crumbs.add(new CrumbView() { label = ((!)project).name }); props["crumbs"] = StateJson.list(crumbs.to_immutable_buffer()); var slot = state_service.new_slot(Scope.PAGE, new State() { type_name = "project", public_data = props, private_data = new PropertyDictionary() }); return directives().set(slot); } } }