diff --git a/README.md b/README.md index a54028d..7907512 100644 --- a/README.md +++ b/README.md @@ -20,19 +20,24 @@ If you are new to the terminology, see: - Transition actions presented as buttons per step option - Permission model aligned to native Discourse category permissions - Workflow discovery list (`/workflow`) with quick filters -- Kanban view toggle for compatible single-workflow lists +- Workflow view selector in discovery (`List`, `Kanban`, `Chart`) when applicable +- Kanban view for compatible single-workflow lists - Drag/drop transitions in Kanban with legal/illegal drop-zone highlighting - Keyboard Kanban transitions on focused cards (`ArrowLeft` / `ArrowRight`) when legal - Workflow-level Kanban tags toggle (`show_kanban_tags`, default `true`) +- Workflow burn down chart view (`/workflow/charts` and chart mode in `/workflow`) for single-workflow context +- Chart period selector (`1` to `12` weeks), complete-week windows (Sunday to Saturday), per-step colored series - Overdue behavior with hierarchy: - global default (`workflow_overdue_days_default`) - workflow override - step override - `0` disables overdue behavior at that scope - Workflow overdue indicator column in the workflow topic list +- Stale state transition handling with explicit user-facing error and automatic refresh - Transition audit trail via small action posts - Workflow visualization modal from topic and list links - Data Explorer audit query support +- Data Explorer workflow stats query support for chart-oriented time series - Optional AI-assisted step handling with prompt + option guardrails ## Quickstart @@ -41,8 +46,9 @@ If you are new to the terminology, see: 2. Go to `Admin -> Plugins -> Discourse Workflow`, create a Workflow, then save it. 3. Add Workflow Steps (Categories in journey order), then add Step Options (actions/transitions). 4. Create a Topic in the first step Category and transition it through actions from the topic banner. -5. Use `/workflow` to view queue state, apply quick filters, toggle `List`/`Kanban`, and visualize progress. +5. Use `/workflow` to view queue state, apply quick filters, and switch between `List` / `Kanban` / `Chart` views when available. 6. In Kanban, click a card to open the topic, drag cards to legal target steps, or use keyboard arrows on focused cards. +7. Use `/workflow/charts` (or `Chart` view in `/workflow`) to see step counts over time for the currently scoped single workflow. ## Setup @@ -53,6 +59,7 @@ If you are new to the terminology, see: - `workflow_openai_api_key`: API key for AI actions. - `workflow_ai_model`: model used for AI actions. - `workflow_ai_prompt_system`: system prompt support for AI transitions. +- `workflow_charts_allowed_groups`: non-admin groups allowed to view workflow charts. ### Workflow definition setup @@ -93,6 +100,35 @@ Compatibility requires: For each directed edge, Kanban drag/keyboard transitions are option-agnostic and deterministic. +### Chart view behavior and access model + +Chart view is shown when the current workflow discovery context resolves to a single workflow. + +- Route support: `/workflow/charts` and chart mode in `/workflow` via `workflow_view=chart` +- View selector behavior: `Chart` is only shown when the user can view charts and the current discovery context is a single workflow +- Period selection: `1` to `12` weeks +- Time windows: complete weeks (Sunday through Saturday) +- Series: one line per step, color derived from step category color (or parent category color fallback) +- Response scope: chart payload includes selected workflow metadata (`selected_workflow_id`, `selected_workflow_name`) plus series data for that selected workflow context + +Access model for charts is intentionally separate from topic-level category access: + +- Admins can always view charts +- Users in `workflow_charts_allowed_groups` can view charts +- Chart access is aggregate and workflow-level; it is intentionally not constrained to per-topic visibility rules +- This allows operational/reporting audiences to monitor workflow throughput without granting direct access to every underlying topic + +If you want stricter chart data visibility, keep `workflow_charts_allowed_groups` empty and rely on admin-only access. + +### Background jobs + +The plugin schedules and runs the following jobs: + +- `Jobs::DiscourseWorkflow::DailyStats`: records daily workflow step counts +- `Jobs::DiscourseWorkflow::AiTransitions`: runs AI-enabled transitions +- `Jobs::DiscourseWorkflow::DataExplorerQueriesCompleteness`: ensures default workflow Data Explorer queries exist +- `Jobs::DiscourseWorkflow::TopicArrivalNotifier`: sends first-post arrival notifications on workflow transitions + ### AI actions You can leverage AI to handle a step. You need `workflow_openai_api_key`, AI enabled on the step, and a prompt including both `{{options}}` and `{{topic}}`. You can also tune behavior with `workflow_ai_model` and `workflow_ai_prompt_system`. @@ -131,7 +167,11 @@ Actions on a Topic are captured in a Small Action Post to help users understand ## Dashboard -A Topic Discovery filter `Workflow` gives a list of workflow instances (special workflow topics). +A Topic Discovery filter `Workflow` gives a list of workflow instances (special workflow topics), with three presentation modes when available: + +- `List`: sortable workflow topic list with workflow columns and quick filters +- `Kanban`: actionable card board for compatible single-workflow views +- `Chart`: step count trends over time for a single workflow You should keep Workflow Categories and ideally tags distinct, so you can also use those to filter for all workflow instances that are at a particular stage, or have a specific tag. diff --git a/app/controllers/discourse_workflow/workflow_charts_controller.rb b/app/controllers/discourse_workflow/workflow_charts_controller.rb new file mode 100644 index 0000000..bb9960f --- /dev/null +++ b/app/controllers/discourse_workflow/workflow_charts_controller.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module DiscourseWorkflow + class WorkflowChartsController < ApplicationController + requires_plugin ::DiscourseWorkflow::PLUGIN_NAME + + before_action :ensure_logged_in + before_action :ensure_workflow_enabled + before_action :ensure_can_view_charts + + def index + weeks = normalized_weeks + workflows = + ::DiscourseWorkflow::Workflow + .where(enabled: true) + .ordered + .includes(workflow_steps: { category: :parent_category }) + selected_workflow = selected_workflow_for(workflows) + date_range = chart_date_range(weeks) + + render_json_dump( + { + weeks: weeks, + labels: date_range.map(&:iso8601), + range_start: date_range.first.iso8601, + range_end: date_range.last.iso8601, + selected_workflow_id: selected_workflow&.id, + selected_workflow_name: selected_workflow&.name, + series: build_series(selected_workflow, date_range), + }, + ) + end + + private + + def ensure_workflow_enabled + raise Discourse::NotFound if !SiteSetting.workflow_enabled + end + + def ensure_can_view_charts + return if ::DiscourseWorkflow::ChartsPermissions.can_view?(current_user) + + raise Discourse::InvalidAccess.new( + nil, + nil, + custom_message: "discourse_workflow.errors.charts_access_denied", + ) + end + + def normalized_weeks + return 2 if params[:weeks].blank? + + requested = params[:weeks].to_i + requested = 1 if requested <= 0 + [requested, 12].min + end + + def selected_workflow_for(workflows) + selected_id = params[:workflow_id].to_i + workflows.find { |workflow| workflow.id == selected_id } || workflows.first + end + + def chart_date_range(weeks) + end_date = Date.current.end_of_week(:saturday) + start_date = end_date - ((weeks * 7) - 1).days + (start_date..end_date).to_a + end + + def build_series(workflow, date_range) + return [] if workflow.blank? + + steps = workflow.workflow_steps.sort_by { |step| step.position.to_i } + return [] if steps.blank? + + step_ids = steps.map(&:id) + stats = + ::DiscourseWorkflow::WorkflowStat + .where(workflow_id: workflow.id, workflow_step_id: step_ids) + .where( + cob_date: date_range.first.beginning_of_day..date_range.last.end_of_day, + ) + .group("DATE(cob_date)", :workflow_step_id) + .sum(:count) + counts_by_day_step = + stats.each_with_object({}) do |((date_value, step_id), count), memo| + memo[[date_value.to_date, step_id]] = count + end + + steps.map do |step| + category = step.category + { + step_id: step.id, + step_name: step.name, + step_position: step.position.to_i, + color: category&.color || category&.parent_category&.color, + data: date_range.map { |date| counts_by_day_step.fetch([date, step.id], 0) }, + } + end + end + end +end diff --git a/app/jobs/regular/discourse_workflow/topic_arrival_notifier.rb b/app/jobs/regular/discourse_workflow/topic_arrival_notifier.rb new file mode 100644 index 0000000..e103e83 --- /dev/null +++ b/app/jobs/regular/discourse_workflow/topic_arrival_notifier.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Jobs + module DiscourseWorkflow + class TopicArrivalNotifier < ::Jobs::Base + def execute(args) + return unless topic = Topic.find_by(id: args[:topic_id]) + + PostAlerter.new.after_save_post(topic.first_post, true) + end + end + end +end diff --git a/app/jobs/regular/workflow_topic_arrival_notifier.rb b/app/jobs/regular/workflow_topic_arrival_notifier.rb deleted file mode 100644 index be9a2f0..0000000 --- a/app/jobs/regular/workflow_topic_arrival_notifier.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class WorkflowTopicArrivalNotifier < ::Jobs::Base - def execute(args) - return unless topic = Topic.find_by(id: args[:topic_id]) - - PostAlerter.new.after_save_post(topic.first_post, true) - end - end -end diff --git a/app/jobs/scheduled/discourse_workflow/ai_transitions.rb b/app/jobs/scheduled/discourse_workflow/ai_transitions.rb new file mode 100644 index 0000000..548fddd --- /dev/null +++ b/app/jobs/scheduled/discourse_workflow/ai_transitions.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Jobs + module DiscourseWorkflow + class AiTransitions < ::Jobs::Scheduled + sidekiq_options retry: false + + every 1.hour + + def execute(args = {}) + ::DiscourseWorkflow::AiActions.new.transition_all + end + end + end +end diff --git a/app/jobs/scheduled/discourse_workflow/daily_stats.rb b/app/jobs/scheduled/discourse_workflow/daily_stats.rb new file mode 100644 index 0000000..44896dd --- /dev/null +++ b/app/jobs/scheduled/discourse_workflow/daily_stats.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Jobs + module DiscourseWorkflow + class DailyStats < ::Jobs::Scheduled + sidekiq_options retry: false + + every 24.hours + + def execute(args = {}) + ::DiscourseWorkflow::Stats.new.calculate_daily_stats + end + end + end +end diff --git a/app/jobs/scheduled/workflow_data_explorer_queries_completeness.rb b/app/jobs/scheduled/discourse_workflow/data_explorer_queries_completeness.rb similarity index 64% rename from app/jobs/scheduled/workflow_data_explorer_queries_completeness.rb rename to app/jobs/scheduled/discourse_workflow/data_explorer_queries_completeness.rb index dfbb5b9..83db2da 100644 --- a/app/jobs/scheduled/workflow_data_explorer_queries_completeness.rb +++ b/app/jobs/scheduled/discourse_workflow/data_explorer_queries_completeness.rb @@ -1,18 +1,20 @@ # frozen_string_literal: true -class ::Jobs::WorkflowDataExplorerQueriesCompleteness < ::Jobs::Scheduled - sidekiq_options retry: false +module Jobs + module DiscourseWorkflow + class DataExplorerQueriesCompleteness < ::Jobs::Scheduled + sidekiq_options retry: false - every 24.hours + every 24.hours - def execute(args) - if !ActiveRecord::Base.connection.table_exists?(:data_explorer_queries) - Rails.logger.warn "Skipping WorkflowDataExplorerQueriesCompleteness: doesn't look like Data Explorer plugin is properly installed" - return - end + def execute(args = {}) + if !ActiveRecord::Base.connection.table_exists?(:data_explorer_queries) + Rails.logger.warn "Skipping DataExplorerQueriesCompleteness: doesn't look like Data Explorer plugin is properly installed" + return + end - if !::DiscourseDataExplorer::Query.exists?(name: "Workflow Stats (default)") - query_sql = <<~SQL + if !::DiscourseDataExplorer::Query.exists?(name: "Workflow Stats (default)") + query_sql = <<~SQL -- [params] -- int :workflow_id = 1 -- int :num_of_days_history = 14 @@ -28,7 +30,7 @@ def execute(args) AND wstt.workflow_id = :workflow_id SQL - DB.exec <<~SQL, now: Time.zone.now, query_sql: query_sql + DB.exec <<~SQL, now: Time.zone.now, query_sql: query_sql INSERT INTO data_explorer_queries(name, description, sql, created_at, updated_at) VALUES ('Workflow Stats (default)', @@ -37,12 +39,12 @@ def execute(args) :now, :now) SQL - end + end - if !::DiscourseDataExplorer::Query.exists?( - name: "Workflow Audit Log (default)" - ) - query_sql = <<~SQL + if !::DiscourseDataExplorer::Query.exists?( + name: "Workflow Audit Log (default)" + ) + query_sql = <<~SQL -- [params] -- int :workflow_id = 1 -- int :num_of_days_history = 14 @@ -58,7 +60,7 @@ def execute(args) AND workflow_id = :workflow_id SQL - DB.exec <<~SQL, now: Time.zone.now, query_sql: query_sql + DB.exec <<~SQL, now: Time.zone.now, query_sql: query_sql INSERT INTO data_explorer_queries(name, description, sql, created_at, updated_at) VALUES ('Workflow Audit Log (default)', @@ -67,6 +69,8 @@ def execute(args) :now, :now) SQL + end + end end end end diff --git a/app/jobs/scheduled/workflow_ai_transitions.rb b/app/jobs/scheduled/workflow_ai_transitions.rb deleted file mode 100644 index 7bca0a7..0000000 --- a/app/jobs/scheduled/workflow_ai_transitions.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class ::Jobs::WorkflowAiTransitions < ::Jobs::Scheduled - sidekiq_options retry: false - - every 1.hour - - def execute(args) - ::DiscourseWorkflow::AiActions.new.transition_all - end -end diff --git a/app/jobs/scheduled/workflow_daily_stats.rb b/app/jobs/scheduled/workflow_daily_stats.rb deleted file mode 100644 index 0d03468..0000000 --- a/app/jobs/scheduled/workflow_daily_stats.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class ::Jobs::WorkflowDailyStats < ::Jobs::Scheduled - sidekiq_options retry: false - - every 24.hours - - def execute(args) - ::DiscourseWorkflow::Stats.new.calculate_daily_stats - end -end diff --git a/app/models/discourse_workflow/workflow_stat.rb b/app/models/discourse_workflow/workflow_stat.rb index 8204726..c3c1b3c 100644 --- a/app/models/discourse_workflow/workflow_stat.rb +++ b/app/models/discourse_workflow/workflow_stat.rb @@ -3,8 +3,8 @@ module ::DiscourseWorkflow class WorkflowStat < ActiveRecord::Base self.table_name = 'workflow_stats' - has_one :workflow - has_one :workflow_step + belongs_to :workflow + belongs_to :workflow_step validates :cob_date, presence: true validates :workflow_id, presence: true validates :workflow_step_id, presence: true diff --git a/assets/javascripts/discourse/connectors/discovery-list-container-top/workflow-quick-filters.gjs b/assets/javascripts/discourse/connectors/discovery-list-container-top/workflow-quick-filters.gjs index a7a3830..a691154 100644 --- a/assets/javascripts/discourse/connectors/discovery-list-container-top/workflow-quick-filters.gjs +++ b/assets/javascripts/discourse/connectors/discovery-list-container-top/workflow-quick-filters.gjs @@ -70,13 +70,32 @@ export default class WorkflowQuickFiltersConnector extends Component { sanitized.workflow_step_position = String(filters.workflow_step_position); } - if (filters?.workflow_view === "kanban") { - sanitized.workflow_view = "kanban"; + if ( + filters?.workflow_view === "kanban" || + filters?.workflow_view === "chart" + ) { + sanitized.workflow_view = filters.workflow_view; + } + + if (filters?.chart_weeks) { + const normalizedWeeks = this.normalizedChartWeeks(filters.chart_weeks); + if (normalizedWeeks) { + sanitized.chart_weeks = String(normalizedWeeks); + } } return sanitized; } + normalizedChartWeeks(value) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + return null; + } + + return Math.min(parsed, 12); + } + get routeTopicList() { const routeAttributes = this.router.currentRoute?.attributes; @@ -133,6 +152,13 @@ export default class WorkflowQuickFiltersConnector extends Component { ); } + get isWorkflowChartsRoute() { + return ( + this.router.currentRouteName === "discovery.workflowCharts" || + this.currentPathname.startsWith("/workflow/charts") + ); + } + get hasMyCategoriesFilter() { return this.currentSearchParams.get("my_categories") === "1"; } @@ -153,6 +179,10 @@ export default class WorkflowQuickFiltersConnector extends Component { return this.workflowView === "kanban"; } + get isChartView() { + return this.workflowView === "chart" || this.isWorkflowChartsRoute; + } + get canUseKanbanView() { try { return this.topicListMetadata?.workflow_kanban_compatible === true; @@ -161,18 +191,54 @@ export default class WorkflowQuickFiltersConnector extends Component { } } - get showKanbanToggle() { - return this.isKanbanView || this.canUseKanbanView; + get canUseChartView() { + try { + return ( + this.topicListMetadata?.workflow_can_view_charts === true && + Number(this.topicListMetadata?.workflow_single_workflow_id) > 0 + ); + } catch { + return false; + } } get showKanbanTags() { return this.topicListMetadata?.workflow_kanban_show_tags !== false; } - get workflowViewLabel() { - return this.isKanbanView - ? "discourse_workflow.quick_filters.list_view" - : "discourse_workflow.quick_filters.kanban_view"; + get currentWorkflowView() { + if (this.isChartView) { + return "chart"; + } + + if (this.isKanbanView) { + return "kanban"; + } + + return "list"; + } + + get showChartViewOption() { + return this.isChartView || this.canUseChartView; + } + + get showWorkflowViewSelector() { + return this.canUseKanbanView || this.showChartViewOption; + } + + get shouldRenderKanbanBoard() { + return this.canUseKanbanView && this.isKanbanView; + } + + get chartWeekOptions() { + return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + } + + get chartWeeksValue() { + return String( + this.normalizedChartWeeks(this.currentSearchParams.get("chart_weeks")) || + 2 + ); } get kanbanWorkflowName() { @@ -339,14 +405,21 @@ export default class WorkflowQuickFiltersConnector extends Component { initializeFilters() { const params = this.currentSearchParams; this.stepPosition = params.get("workflow_step_position") || ""; - this.workflowView = params.get("workflow_view") || null; + this.workflowView = + params.get("workflow_view") || + (this.isWorkflowChartsRoute ? "chart" : null); + + if (this.isWorkflowChartsRoute) { + return; + } if ( params.has("my_categories") || params.has("overdue") || params.has("overdue_days") || params.has("workflow_step_position") || - params.has("workflow_view") + params.has("workflow_view") || + params.has("chart_weeks") ) { return; } @@ -385,6 +458,7 @@ export default class WorkflowQuickFiltersConnector extends Component { overdue_days: sanitized.overdue_days || null, workflow_step_position: sanitized.workflow_step_position || null, workflow_view: sanitized.workflow_view || null, + chart_weeks: sanitized.chart_weeks || null, }; const currentParams = this.currentSearchParams; const unchanged = @@ -396,7 +470,8 @@ export default class WorkflowQuickFiltersConnector extends Component { (currentParams.get("workflow_step_position") || null) === queryParams.workflow_step_position && (currentParams.get("workflow_view") || null) === - queryParams.workflow_view; + queryParams.workflow_view && + (currentParams.get("chart_weeks") || null) === queryParams.chart_weeks; if (unchanged) { return; @@ -481,18 +556,65 @@ export default class WorkflowQuickFiltersConnector extends Component { } @action - toggleWorkflowView() { + changeWorkflowView(event) { + const nextView = event.target.value; const params = new URLSearchParams(this.currentSearchParams.toString()); - if (this.isKanbanView) { + if (nextView === "list") { params.delete("workflow_view"); this.workflowView = null; - } else { + } else if (nextView === "kanban") { + if (!this.canUseKanbanView) { + event.target.value = this.currentWorkflowView; + return; + } + params.set("workflow_view", "kanban"); this.workflowView = "kanban"; + } else if (nextView === "chart") { + if (!this.canUseChartView) { + event.target.value = this.currentWorkflowView; + return; + } + + params.set("workflow_view", "chart"); + if (!params.get("chart_weeks")) { + params.set("chart_weeks", "2"); + } + this.workflowView = "chart"; } this.syncBodyClass(); + const nextFilters = Object.fromEntries(params.entries()); + + if (this.isWorkflowChartsRoute) { + this.router.transitionTo("discovery.workflow", { + queryParams: { + my_categories: nextFilters.my_categories || null, + overdue: nextFilters.overdue || null, + overdue_days: nextFilters.overdue_days || null, + workflow_step_position: nextFilters.workflow_step_position || null, + workflow_view: nextFilters.workflow_view || null, + chart_weeks: nextFilters.chart_weeks || null, + }, + }); + return; + } + + this.navigateWithFilters(nextFilters); + } + + @action + changeChartWeeks(event) { + const weeks = this.normalizedChartWeeks(event.target.value); + if (!weeks) { + event.target.value = this.chartWeeksValue; + return; + } + + const params = new URLSearchParams(this.currentSearchParams.toString()); + params.set("workflow_view", "chart"); + params.set("chart_weeks", String(weeks)); this.navigateWithFilters(Object.fromEntries(params.entries())); } @@ -672,7 +794,19 @@ export default class WorkflowQuickFiltersConnector extends Component { syncStepPositionFromUrl() { const params = this.currentSearchParams; this.stepPosition = params.get("workflow_step_position") || ""; - this.workflowView = params.get("workflow_view") || null; + this.workflowView = + params.get("workflow_view") || + (this.isWorkflowChartsRoute ? "chart" : null); + + if (this.isChartView && !this.canUseChartView) { + const fallback = Object.fromEntries(params.entries()); + delete fallback.workflow_view; + delete fallback.chart_weeks; + this.workflowView = null; + this.navigateWithFilters(fallback); + return; + } + this.syncBodyClass(); } @@ -686,6 +820,10 @@ export default class WorkflowQuickFiltersConnector extends Component { document .querySelector("#list-area .contents") ?.classList.toggle("workflow-kanban-hide-topics", this.isKanbanView); + document.body.classList.toggle("workflow-charts-view", this.isChartView); + document + .querySelector("#list-area .contents") + ?.classList.toggle("workflow-charts-hide-topics", this.isChartView); }