diff --git a/.agents/skills/frontend-skill/LICENSE.txt b/.agents/skills/frontend-skill/LICENSE.txt deleted file mode 100644 index b20752a1..00000000 --- a/.agents/skills/frontend-skill/LICENSE.txt +++ /dev/null @@ -1,201 +0,0 @@ -Apache License -Version 2.0, January 2004 -http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf of - any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/.agents/skills/frontend-skill/SKILL.md b/.agents/skills/frontend-skill/SKILL.md deleted file mode 100644 index 0ced5cf4..00000000 --- a/.agents/skills/frontend-skill/SKILL.md +++ /dev/null @@ -1,184 +0,0 @@ ---- -name: frontend-skill -description: Use when the task asks for a visually strong landing page, website, app, prototype, demo, or game UI. This skill enforces restrained composition, image-led hierarchy, cohesive content structure, and tasteful motion while avoiding generic cards, weak branding, and UI clutter. ---- - -# Frontend Skill - -Use this skill when the quality of the work depends on art direction, hierarchy, restraint, imagery, and motion rather than component count. - -Goal: ship interfaces that feel deliberate, premium, and current. Default toward award-level composition: one big idea, strong imagery, sparse copy, rigorous spacing, and a small number of memorable motions. - -## Working Model - -Before building, write three things: - -- visual thesis: one sentence describing mood, material, and energy -- content plan: hero, support, detail, final CTA -- interaction thesis: 2-3 motion ideas that change the feel of the page - -Each section gets one job, one dominant visual idea, and one primary takeaway or action. - -## Beautiful Defaults - -- Start with composition, not components. -- Prefer a full-bleed hero or full-canvas visual anchor. -- Make the brand or product name the loudest text. -- Keep copy short enough to scan in seconds. -- Use whitespace, alignment, scale, cropping, and contrast before adding chrome. -- Limit the system: two typefaces max, one accent color by default. -- Default to cardless layouts. Use sections, columns, dividers, lists, and media blocks instead. -- Treat the first viewport as a poster, not a document. - -## Landing Pages - -Default sequence: - -1. Hero: brand or product, promise, CTA, and one dominant visual -2. Support: one concrete feature, offer, or proof point -3. Detail: atmosphere, workflow, product depth, or story -4. Final CTA: convert, start, visit, or contact - -Hero rules: - -- One composition only. -- Full-bleed image or dominant visual plane. -- Canonical full-bleed rule: on branded landing pages, the hero itself must run edge-to-edge with no inherited page gutters, framed container, or shared max-width; constrain only the inner text/action column. -- Brand first, headline second, body third, CTA fourth. -- No hero cards, stat strips, logo clouds, pill soup, or floating dashboards by default. -- Keep headlines to roughly 2-3 lines on desktop and readable in one glance on mobile. -- Keep the text column narrow and anchored to a calm area of the image. -- All text over imagery must maintain strong contrast and clear tap targets. - -If the first viewport still works after removing the image, the image is too weak. If the brand disappears after hiding the nav, the hierarchy is too weak. - -Viewport budget: - -- If the first screen includes a sticky/fixed header, that header counts against the hero. The combined header + hero content must fit within the initial viewport at common desktop and mobile sizes. -- When using `100vh`/`100svh` heroes, subtract persistent UI chrome (`calc(100svh - header-height)`) or overlay the header instead of stacking it in normal flow. - -## Apps - -Default to Linear-style restraint: - -- calm surface hierarchy -- strong typography and spacing -- few colors -- dense but readable information -- minimal chrome -- cards only when the card is the interaction - -For app UI, organize around: - -- primary workspace -- navigation -- secondary context or inspector -- one clear accent for action or state - -Avoid: - -- dashboard-card mosaics -- thick borders on every region -- decorative gradients behind routine product UI -- multiple competing accent colors -- ornamental icons that do not improve scanning - -If a panel can become plain layout without losing meaning, remove the card treatment. - -## Imagery - -Imagery must do narrative work. - -- Use at least one strong, real-looking image for brands, venues, editorial pages, and lifestyle products. -- Prefer in-situ photography over abstract gradients or fake 3D objects. -- Choose or crop images with a stable tonal area for text. -- Do not use images with embedded signage, logos, or typographic clutter fighting the UI. -- Do not generate images with built-in UI frames, splits, cards, or panels. -- If multiple moments are needed, use multiple images, not one collage. - -The first viewport needs a real visual anchor. Decorative texture is not enough. - -## Copy - -- Write in product language, not design commentary. -- Let the headline carry the meaning. -- Supporting copy should usually be one short sentence. -- Cut repetition between sections. -- Do not include prompt language or design commentary into the UI. -- Give every section one responsibility: explain, prove, deepen, or convert. - -If deleting 30 percent of the copy improves the page, keep deleting. - -## Utility Copy For Product UI - -When the work is a dashboard, app surface, admin tool, or operational workspace, default to utility copy over marketing copy. - -- Prioritize orientation, status, and action over promise, mood, or brand voice. -- Start with the working surface itself: KPIs, charts, filters, tables, status, or task context. Do not introduce a hero section unless the user explicitly asks for one. -- Section headings should say what the area is or what the user can do there. -- Good: "Selected KPIs", "Plan status", "Search metrics", "Top segments", "Last sync". -- Avoid aspirational hero lines, metaphors, campaign-style language, and executive-summary banners on product surfaces unless specifically requested. -- Supporting text should explain scope, behavior, freshness, or decision value in one sentence. -- If a sentence could appear in a homepage hero or ad, rewrite it until it sounds like product UI. -- If a section does not help someone operate, monitor, or decide, remove it. -- Litmus check: if an operator scans only headings, labels, and numbers, can they understand the page immediately? - -## Motion - -Use motion to create presence and hierarchy, not noise. - -Ship at least 2-3 intentional motions for visually led work: - -- one entrance sequence in the hero -- one scroll-linked, sticky, or depth effect -- one hover, reveal, or layout transition that sharpens affordance - -Prefer Framer Motion when available for: - -- section reveals -- shared layout transitions -- scroll-linked opacity, translate, or scale shifts -- sticky storytelling -- carousels that advance narrative, not just fill space -- menus, drawers, and modal presence effects - -Motion rules: - -- noticeable in a quick recording -- smooth on mobile -- fast and restrained -- consistent across the page -- removed if ornamental only - -## Hard Rules - -- No cards by default. -- No hero cards by default. -- No boxed or center-column hero when the brief calls for full bleed. -- No more than one dominant idea per section. -- No section should need many tiny UI devices to explain itself. -- No headline should overpower the brand on branded pages. -- No filler copy. -- No split-screen hero unless text sits on a calm, unified side. -- No more than two typefaces without a clear reason. -- No more than one accent color unless the product already has a strong system. - -## Reject These Failures - -- Generic SaaS card grid as the first impression -- Beautiful image with weak brand presence -- Strong headline with no clear action -- Busy imagery behind text -- Sections that repeat the same mood statement -- Carousel with no narrative purpose -- App UI made of stacked cards instead of layout - -## Litmus Checks - -- Is the brand or product unmistakable in the first screen? -- Is there one strong visual anchor? -- Can the page be understood by scanning headlines only? -- Does each section have one job? -- Are cards actually necessary? -- Does motion improve hierarchy or atmosphere? -- Would the design still feel premium if all decorative shadows were removed? diff --git a/.agents/skills/frontend-skill/agents/openai.yaml b/.agents/skills/frontend-skill/agents/openai.yaml deleted file mode 100644 index ddafd948..00000000 --- a/.agents/skills/frontend-skill/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Frontend Skill" - short_description: "Design visually strong landing pages, websites, apps, and UI" - default_prompt: "Use $frontend-skill to establish a visual thesis, content plan, and interaction thesis before building a visually strong landing page, website, app, prototype, demo, or game UI." diff --git a/.gitignore b/.gitignore index ce91ae90..b6002430 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ src-tauri/gen/ docs/ .cursor/plans .worktrees/ +.opencode/plans \ No newline at end of file diff --git a/skills-lock.json b/skills-lock.json index ccb0cd0b..e4ef814c 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -41,11 +41,6 @@ "sourceType": "github", "computedHash": "063a0e6448123cd359ad0044cc46b0e490cc7964d45ef4bb9fd842bd2ffbca67" }, - "frontend-skill": { - "source": "openai/skills", - "sourceType": "github", - "computedHash": "2074b8fa089e7ac633689b7202965513a5fd069fd1baf2ea49a928bb85bb4c9c" - }, "nodejs-backend-patterns": { "source": "wshobson/agents", "sourceType": "github", diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index 8567cb45..c1fd6a21 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -5,7 +5,7 @@ use tauri::{AppHandle, Manager, State, WebviewUrl, WebviewWindowBuilder}; use crate::{ domain::models::{ AuthLaunchPlan, GitLabConnectionInput, GitLabUserInfo, OAuthCallbackPayload, - OAuthCallbackResolution, ProviderConnection, + OAuthCallbackResolution, ProviderConnection, ProviderConnectionInput, }, error::AppError, services::{auth, shared}, @@ -27,6 +27,13 @@ pub fn list_gitlab_connections( result } +#[tauri::command] +pub fn list_provider_connections( + state: State<'_, AppState>, +) -> Result, AppError> { + auth::load_provider_connections(&state) +} + #[tauri::command] pub fn save_gitlab_connection( state: State<'_, AppState>, @@ -35,6 +42,14 @@ pub fn save_gitlab_connection( auth::save_gitlab_connection(&state, input) } +#[tauri::command] +pub fn save_provider_connection( + state: State<'_, AppState>, + input: ProviderConnectionInput, +) -> Result { + auth::save_provider_connection(&state, input) +} + #[tauri::command] pub fn save_gitlab_pat( state: State<'_, AppState>, @@ -44,6 +59,16 @@ pub fn save_gitlab_pat( auth::save_gitlab_pat(&state, &host, &token) } +#[tauri::command] +pub fn save_provider_pat( + state: State<'_, AppState>, + provider: String, + host: String, + token: String, +) -> Result { + auth::save_provider_pat(&state, &provider, &host, &token) +} + #[tauri::command] pub fn begin_gitlab_oauth( app: AppHandle, @@ -92,3 +117,19 @@ pub async fn validate_gitlab_token( ) .await } + +#[tauri::command] +pub async fn validate_provider_token( + state: State<'_, AppState>, + provider: String, + host: String, +) -> Result { + shared::run_blocking_with_timeout( + &state, + Duration::from_secs(30), + "Token validation did not complete within 30 seconds", + "validation", + move |app_state| auth::validate_provider_token(&app_state, &provider, &host), + ) + .await +} diff --git a/src-tauri/src/commands/dashboard.rs b/src-tauri/src/commands/dashboard.rs index 890cf97e..625e3004 100644 --- a/src-tauri/src/commands/dashboard.rs +++ b/src-tauri/src/commands/dashboard.rs @@ -63,6 +63,31 @@ pub async fn sync_gitlab( outcome } +#[tauri::command] +pub async fn sync_providers( + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + let app_for_progress = app.clone(); + let outcome = shared::run_blocking_with_timeout( + &state, + Duration::from_secs(300), + "Provider sync did not complete within 5 minutes", + "sync", + move |app_state| { + let mut progress_fn = |msg: String| { + let _ = app_for_progress.emit(SYNC_PROGRESS_EVENT, &msg); + }; + sync::sync_providers(&app_state, &mut progress_fn) + }, + ) + .await; + if outcome.is_ok() { + reminders::kick_reminder_scheduler(&app); + } + outcome +} + #[tauri::command] pub fn update_schedule( state: State<'_, AppState>, diff --git a/src-tauri/src/db/bootstrap.rs b/src-tauri/src/db/bootstrap.rs index 8138da50..237ac8a5 100644 --- a/src-tauri/src/db/bootstrap.rs +++ b/src-tauri/src/db/bootstrap.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use chrono::{Datelike, Duration, Local, NaiveDate}; -use rusqlite::{params, Connection, OptionalExtension}; +use rusqlite::{params, params_from_iter, Connection, OptionalExtension}; use crate::{ domain::models::{ @@ -13,6 +13,7 @@ use crate::{ error::AppError, services::{preferences, streak}, support::{ + badge_tone_mapper::BadgeToneMapper, holidays, iteration_label::{format_iteration_range_label, month_short_label, parse_ymd_date}, }, @@ -172,7 +173,13 @@ pub fn load_bootstrap_payload(connection: &Connection) -> Result>(); + let assigned_issues = + load_assigned_issue_snapshots_for_providers(connection, &active_provider_ids)?; Ok(BootstrapPayload { app_name: DEFAULT_APP_NAME.to_string(), @@ -196,7 +203,8 @@ pub fn load_provider_connections( connection: &Connection, ) -> Result, AppError> { let mut statement = connection.prepare( - "SELECT id, provider, display_name, host, oauth_client_id, auth_mode, preferred_scope, oauth_ready, status_note, is_primary, username + "SELECT id, provider, display_name, host, oauth_client_id, auth_mode, preferred_scope, oauth_ready, status_note, is_primary, username, + CASE WHEN personal_access_token IS NOT NULL AND personal_access_token <> '' THEN 1 ELSE 0 END FROM provider_accounts ORDER BY is_primary DESC, id ASC", )?; @@ -210,7 +218,7 @@ pub fn load_provider_connections( host: row.get(3)?, username: row.get(10)?, client_id: row.get(4)?, - has_token: false, + has_token: row.get::<_, i64>(11)? == 1, state: if oauth_ready == 1 { "live".to_string() } else { @@ -321,7 +329,7 @@ fn load_issue_breakdown( FROM time_entries te JOIN work_items wi ON wi.id = te.work_item_id WHERE te.provider_account_id = ?1 AND date(te.spent_at) = ?2 - GROUP BY wi.provider_item_id, wi.title, wi.labels_json + GROUP BY te.work_item_id ORDER BY total_seconds DESC, wi.provider_item_id ASC", )?; @@ -708,11 +716,12 @@ fn empty_bootstrap_payload(actual_today: NaiveDate) -> BootstrapPayload { } } -fn load_assigned_issue_snapshots( +fn load_assigned_issue_snapshots_for_providers( connection: &Connection, - provider_account_id: i64, + provider_account_ids: &[i64], ) -> Result, AppError> { - let mut rows = load_all_assigned_issue_snapshots(connection, provider_account_id)?; + let mut rows = + load_all_assigned_issue_snapshots_for_providers(connection, provider_account_ids)?; rows.retain(|row| row.state.eq_ignore_ascii_case("opened")); rows.sort_by(|a, b| { b.iteration_start_date @@ -723,25 +732,46 @@ fn load_assigned_issue_snapshots( Ok(rows) } +#[cfg(test)] pub fn load_assigned_issues_page_from_cache( connection: &Connection, provider_account_id: i64, input: &AssignedIssuesQueryInput, today: NaiveDate, ) -> Result { - if !matches!(input.status.as_str(), "opened" | "closed" | "all") { - return Err(AppError::GitLabApi( - "Assigned issues board supports only opened, closed, or all statuses.".to_string(), + load_assigned_issues_page_from_cache_for_providers( + connection, + &[provider_account_id], + input, + today, + ) +} + +pub fn load_assigned_issues_page_from_cache_for_providers( + connection: &Connection, + provider_account_ids: &[i64], + input: &AssignedIssuesQueryInput, + today: NaiveDate, +) -> Result { + if !matches!( + input.status.as_str(), + "todo" | "doing" | "blocked" | "done" | "closed" | "all" | "opened" + ) { + return Err(AppError::ProviderApi( + "Assigned issues board supports only todo, doing, blocked, done, closed, opened, or all statuses." + .to_string(), )); } let page_size = input.page_size.clamp(1, 100); - let all_rows = load_all_assigned_issue_snapshots(connection, provider_account_id)?; + let all_rows = + load_all_assigned_issue_snapshots_for_providers(connection, provider_account_ids)?; let status_rows = all_rows .into_iter() .filter(|row| matches_assigned_issue_status(row, input.status.as_str())) .collect::>(); - let base_iteration_catalog = load_iteration_catalog_rows(connection, provider_account_id)?; + let base_iteration_catalog = + load_iteration_catalog_rows_for_providers(connection, provider_account_ids)?; let iteration_catalog = enrich_iteration_catalog_from_issue_rows(base_iteration_catalog, &status_rows); let matched_rows = build_assigned_issue_matches(status_rows.clone(), &iteration_catalog, today); @@ -750,7 +780,7 @@ pub fn load_assigned_issues_page_from_cache( let suggestions = collect_assigned_issue_suggestions(&status_rows, input.search.as_deref()); let (catalog_state, catalog_message) = load_iteration_catalog_health( connection, - provider_account_id, + provider_account_ids.first().copied().unwrap_or_default(), &status_rows, &iteration_catalog, &iteration_options, @@ -793,43 +823,56 @@ pub fn load_assigned_issues_page_from_cache( }) } -fn load_all_assigned_issue_snapshots( +fn load_all_assigned_issue_snapshots_for_providers( connection: &Connection, - provider_account_id: i64, + provider_account_ids: &[i64], ) -> Result, AppError> { - let mut statement = connection.prepare( - "SELECT provider_item_id, title, state, closed_at, provider_updated_at, web_url, labels_json, milestone_title, iteration_gitlab_id, iteration_group_id, iteration_cadence_id, iteration_cadence_title, iteration_title, iteration_start_date, iteration_due_date, issue_graphql_id, assigned_bucket - FROM work_items - WHERE provider_account_id = ?1 AND from_assigned_sync = 1", - )?; + if provider_account_ids.is_empty() { + return Ok(vec![]); + } + let placeholders = sql_placeholders(provider_account_ids.len()); + let sql = format!( + "SELECT COALESCE(pa.provider, 'gitlab'), wi.provider_item_id, wi.title, wi.state, wi.status_label, COALESCE(wi.workflow_status, 'todo'), wi.closed_at, wi.provider_updated_at, wi.web_url, wi.labels_json, wi.milestone_title, wi.iteration_gitlab_id, wi.iteration_group_id, wi.iteration_cadence_id, wi.iteration_cadence_title, wi.iteration_title, wi.iteration_start_date, wi.iteration_due_date, wi.start_date, wi.due_date, wi.issue_graphql_id, wi.assigned_bucket + FROM work_items wi + LEFT JOIN provider_accounts pa ON pa.id = wi.provider_account_id + WHERE wi.provider_account_id IN ({placeholders}) AND wi.from_assigned_sync = 1" + ); + let mut statement = connection.prepare(&sql)?; - let rows = statement.query_map([provider_account_id], |row| { - let labels_json: Option = row.get(6)?; + let rows = statement.query_map(params_from_iter(provider_account_ids.iter()), |row| { + let labels_json: Option = row.get(9)?; let labels = labels_json .as_deref() .and_then(|raw| serde_json::from_str::>(raw).ok()) .unwrap_or_default(); + let mapper = BadgeToneMapper::new(); + let label_tones = labels.iter().map(|label| mapper.map_label(label)).collect(); Ok(AssignedIssueSnapshot { - provider: "gitlab".to_string(), - issue_id: row.get(0)?, - provider_issue_ref: row.get(15)?, - key: row.get(0)?, - title: row.get(1)?, - state: row.get(2)?, - closed_at: row.get(3)?, - updated_at: row.get(4)?, - web_url: row.get(5)?, + provider: row.get::<_, String>(0)?.to_lowercase(), + issue_id: row.get(1)?, + provider_issue_ref: row.get(20)?, + key: row.get(1)?, + title: row.get(2)?, + state: row.get(3)?, + status_label: row.get(4)?, + workflow_status: row.get(5)?, + closed_at: row.get(6)?, + updated_at: row.get(7)?, + web_url: row.get(8)?, labels, - milestone_title: row.get(7)?, - iteration_gitlab_id: row.get(8)?, - iteration_group_id: row.get(9)?, - iteration_cadence_id: row.get(10)?, - iteration_cadence_title: row.get(11)?, - iteration_title: row.get(12)?, - iteration_start_date: row.get(13)?, - iteration_due_date: row.get(14)?, - assigned_bucket: row.get(16)?, + label_tones, + milestone_title: row.get(10)?, + iteration_gitlab_id: row.get(11)?, + iteration_group_id: row.get(12)?, + iteration_cadence_id: row.get(13)?, + iteration_cadence_title: row.get(14)?, + iteration_title: row.get(15)?, + iteration_start_date: row.get(16)?, + iteration_due_date: row.get(17)?, + start_date: row.get(18)?, + due_date: row.get(19)?, + assigned_bucket: row.get(21)?, }) })?; @@ -843,6 +886,24 @@ fn load_iteration_catalog_rows( crate::db::iteration_catalog::load_rows(connection, provider_account_id) } +fn load_iteration_catalog_rows_for_providers( + connection: &Connection, + provider_account_ids: &[i64], +) -> Result, AppError> { + let mut rows = Vec::new(); + for provider_account_id in provider_account_ids { + rows.extend(load_iteration_catalog_rows( + connection, + *provider_account_id, + )?); + } + Ok(rows) +} + +fn sql_placeholders(len: usize) -> String { + std::iter::repeat_n("?", len).collect::>().join(",") +} + fn enrich_iteration_catalog_from_issue_rows( iteration_catalog: Vec, rows: &[AssignedIssueSnapshot], @@ -925,23 +986,18 @@ fn load_iteration_catalog_health( .and_then(|value| value.as_str()) .map(str::to_string); - let has_iteration_backed_rows = rows.iter().any(|row| { - row.iteration_gitlab_id.is_some() - || row.iteration_group_id.is_some() - || row.iteration_start_date.is_some() - || row.iteration_due_date.is_some() - }); + let has_catalog_dependent_rows = rows.iter().any(row_needs_dated_iteration_catalog); let has_catalog_dates = iteration_catalog .iter() .any(|iteration| iteration.start_date.is_some() && iteration.due_date.is_some()); - if has_iteration_backed_rows && !has_catalog_dates && state == "ready" { + if has_catalog_dependent_rows && !has_catalog_dates && state == "ready" { state = "partial".to_string(); message = Some( "Iteration data was synced, but no dated iteration catalog could be matched yet." .to_string(), ); } - if has_iteration_backed_rows + if has_catalog_dependent_rows && iteration_options.iter().all(|option| option.id == "none") && state == "ready" { @@ -955,6 +1011,20 @@ fn load_iteration_catalog_health( (state, message) } +fn row_needs_dated_iteration_catalog(row: &AssignedIssueSnapshot) -> bool { + let has_iteration_metadata = row.iteration_gitlab_id.is_some() + || row.iteration_group_id.is_some() + || row.iteration_cadence_id.is_some() + || row.iteration_cadence_title.is_some() + || row.iteration_title.is_some() + || row.iteration_start_date.is_some() + || row.iteration_due_date.is_some(); + let has_row_level_dates = + row.iteration_start_date.is_some() && row.iteration_due_date.is_some(); + + has_iteration_metadata && !has_row_level_dates +} + #[derive(Clone, Debug)] struct MatchedAssignedIssue { snapshot: AssignedIssueSnapshot, @@ -969,12 +1039,29 @@ struct MatchedAssignedIssue { fn matches_assigned_issue_status(row: &AssignedIssueSnapshot, status: &str) -> bool { match status { "all" => true, - "closed" => row.state.eq_ignore_ascii_case("closed"), - "opened" => row.state.eq_ignore_ascii_case("opened"), + "closed" => matches_assigned_issue_closed_status(row), + "opened" => matches_assigned_issue_open_status(row), + "todo" | "doing" | "blocked" | "done" => row.workflow_status == status, _ => false, } } +fn matches_assigned_issue_open_status(row: &AssignedIssueSnapshot) -> bool { + match row.assigned_bucket.as_deref() { + Some("open") => true, + Some("recent_closed" | "archive_closed") => false, + _ => matches!(row.state.to_ascii_lowercase().as_str(), "opened" | "open"), + } +} + +fn matches_assigned_issue_closed_status(row: &AssignedIssueSnapshot) -> bool { + match row.assigned_bucket.as_deref() { + Some("recent_closed" | "archive_closed") => true, + Some("open") => false, + _ => row.state.eq_ignore_ascii_case("closed"), + } +} + fn collect_iteration_options( rows: &[MatchedAssignedIssue], today: NaiveDate, @@ -1585,6 +1672,7 @@ mod tests { username TEXT, auth_mode TEXT NOT NULL, oauth_client_id TEXT, + personal_access_token TEXT, preferred_scope TEXT NOT NULL DEFAULT 'read_api', oauth_ready INTEGER NOT NULL DEFAULT 0, status_note TEXT NOT NULL DEFAULT '', @@ -1621,6 +1709,8 @@ mod tests { provider_item_id TEXT NOT NULL, title TEXT NOT NULL, state TEXT NOT NULL, + status_label TEXT, + workflow_status TEXT NOT NULL DEFAULT 'todo', closed_at TEXT, web_url TEXT, labels_json TEXT, @@ -1636,6 +1726,8 @@ mod tests { iteration_title TEXT, iteration_start_date TEXT, iteration_due_date TEXT, + start_date TEXT, + due_date TEXT, assigned_bucket TEXT, from_assigned_sync INTEGER NOT NULL DEFAULT 0 ); @@ -1736,6 +1828,8 @@ mod tests { provider_item_id, title, state, + status_label, + workflow_status, closed_at, web_url, labels_json, @@ -1750,14 +1844,18 @@ mod tests { iteration_title, iteration_start_date, iteration_due_date, + start_date, + due_date, assigned_bucket, from_assigned_sync - ) VALUES (?1, ?2, ?3, ?4, NULL, NULL, '[]', NULL, '2026-04-08T12:00:00Z', ?5, ?6, ?7, NULL, NULL, ?8, ?9, ?10, ?11, NULL, ?12)", + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, NULL, '[]', NULL, '2026-04-08T12:00:00Z', ?7, ?8, ?9, NULL, NULL, ?10, ?11, ?12, ?13, NULL, NULL, NULL, ?14)", params![ 1_i64, provider_item_id, title, state, + state, + test_workflow_status_for_state(state), format!("gid://gitlab/Issue/{provider_item_id}"), milestone_title, iteration_gitlab_id, @@ -1771,6 +1869,15 @@ mod tests { .unwrap(); } + fn test_workflow_status_for_state(state: &str) -> &'static str { + match state.to_ascii_lowercase().as_str() { + "in progress" => "doing", + "blocked" => "blocked", + "done" | "closed" => "done", + _ => "todo", + } + } + fn insert_iteration_catalog( connection: &Connection, iteration_gitlab_id: &str, @@ -1863,15 +1970,17 @@ mod tests { host, display_name, auth_mode, + personal_access_token, is_primary, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", rusqlite::params![ 1_i64, "gitlab", "https://gitlab.example.com", "GitLab", "pat", + "fixture-token", 1_i64, "2026-04-01T00:00:00Z", ], @@ -2024,6 +2133,7 @@ mod tests { page: 1, page_size: 20, status: "opened".to_string(), + provider: None, year: None, iteration_id: None, search: None, @@ -2043,6 +2153,7 @@ mod tests { page: 1, page_size: 20, status: "closed".to_string(), + provider: None, year: None, iteration_id: None, search: None, @@ -2065,6 +2176,7 @@ mod tests { page: 1, page_size: 20, status: "all".to_string(), + provider: None, year: None, iteration_id: None, search: None, @@ -2089,6 +2201,281 @@ mod tests { assert_eq!(all_states, vec!["closed", "opened"]); } + #[test] + fn assigned_issues_status_filter_uses_normalized_assigned_bucket() { + let open = AssignedIssueSnapshot { + provider: "youtrack".to_string(), + issue_id: "YT-1".to_string(), + provider_issue_ref: "YT-1".to_string(), + key: "YT-1".to_string(), + title: "YouTrack Open".to_string(), + state: "Open".to_string(), + status_label: Some("Open".to_string()), + workflow_status: "todo".to_string(), + closed_at: None, + updated_at: None, + web_url: None, + labels: vec![], + label_tones: vec![], + milestone_title: None, + iteration_gitlab_id: None, + iteration_group_id: None, + iteration_cadence_id: None, + iteration_cadence_title: None, + iteration_title: None, + iteration_start_date: None, + iteration_due_date: None, + start_date: None, + due_date: None, + assigned_bucket: Some("open".to_string()), + }; + let done = AssignedIssueSnapshot { + state: "Done".to_string(), + status_label: Some("Done".to_string()), + workflow_status: "done".to_string(), + assigned_bucket: Some("recent_closed".to_string()), + ..open.clone() + }; + + assert!(matches_assigned_issue_status(&open, "opened")); + assert!(!matches_assigned_issue_status(&open, "closed")); + assert!(matches_assigned_issue_status(&done, "closed")); + assert!(!matches_assigned_issue_status(&done, "opened")); + } + + #[test] + fn assigned_issues_cache_query_supports_workflow_status_filters() { + let connection = setup_empty_connection(); + insert_assigned_issue( + &connection, + "yt#1", + "Todo issue", + "To Do", + None, + None, + None, + None, + None, + None, + 1, + ); + insert_assigned_issue( + &connection, + "yt#2", + "Doing issue", + "In Progress", + None, + None, + None, + None, + None, + None, + 1, + ); + insert_assigned_issue( + &connection, + "yt#3", + "Blocked issue", + "Blocked", + None, + None, + None, + None, + None, + None, + 1, + ); + insert_assigned_issue( + &connection, + "yt#4", + "Done issue", + "Done", + None, + None, + None, + None, + None, + None, + 1, + ); + insert_assigned_issue( + &connection, + "yt#5", + "Closed issue", + "closed", + None, + None, + None, + None, + None, + None, + 1, + ); + + let todo_page = load_assigned_issues_page_from_cache( + &connection, + 1, + &AssignedIssuesQueryInput { + cursor: None, + page: 1, + page_size: 20, + status: "todo".to_string(), + provider: None, + year: None, + iteration_id: None, + search: None, + }, + NaiveDate::from_ymd_opt(2026, 4, 8).unwrap(), + ) + .unwrap(); + assert_eq!(todo_page.items.len(), 1); + assert_eq!(todo_page.items[0].title, "Todo issue"); + } + + #[test] + fn assigned_issues_cache_query_uses_cached_workflow_status_before_raw_state() { + let connection = setup_empty_connection(); + connection + .execute( + "INSERT INTO work_items ( + provider_account_id, + provider_item_id, + title, + state, + status_label, + workflow_status, + closed_at, + web_url, + labels_json, + raw_json, + updated_at, + issue_graphql_id, + assigned_bucket, + from_assigned_sync + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, NULL, '[]', NULL, '2026-04-08T12:00:00Z', ?7, 'open', 1)", + params![ + 1_i64, + "yt#6", + "YouTrack doing issue", + "Open", + "In Progress", + "doing", + "yt-gql-6", + ], + ) + .unwrap(); + + let page = load_assigned_issues_page_from_cache( + &connection, + 1, + &AssignedIssuesQueryInput { + cursor: None, + page: 1, + page_size: 20, + status: "doing".to_string(), + provider: None, + year: None, + iteration_id: None, + search: None, + }, + NaiveDate::from_ymd_opt(2026, 4, 8).unwrap(), + ) + .unwrap(); + + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].workflow_status, "doing"); + assert_eq!(page.items[0].status_label.as_deref(), Some("In Progress")); + } + + #[test] + fn assigned_issues_cache_uses_row_level_iteration_dates_without_catalog_warning() { + let connection = setup_empty_connection(); + insert_assigned_issue( + &connection, + "OPS-17", + "YouTrack sprint issue", + "In Progress", + Some("youtrack:sprint:73-2"), + Some("Delivery Board"), + Some("OPS Sprint 12"), + Some("2026-04-10"), + Some("2026-04-24"), + None, + 1, + ); + + let page = load_assigned_issues_page_from_cache( + &connection, + 1, + &AssignedIssuesQueryInput { + cursor: None, + page: 1, + page_size: 20, + status: "doing".to_string(), + provider: None, + year: Some("2026".to_string()), + iteration_id: None, + search: None, + }, + NaiveDate::from_ymd_opt(2026, 4, 15).unwrap(), + ) + .unwrap(); + + assert_eq!(page.catalog_state, "ready"); + assert_eq!(page.catalog_message, None); + assert_eq!(page.years, vec!["2026".to_string()]); + assert_eq!(page.iteration_options.len(), 1); + assert_eq!( + page.iteration_options[0].id, + "youtrack:sprint:73-2".to_string() + ); + assert_eq!( + page.iteration_options[0].badge.as_deref(), + Some("Delivery Board") + ); + } + + #[test] + fn assigned_issues_cache_keeps_catalog_warning_for_catalog_dependent_rows() { + let connection = setup_empty_connection(); + insert_assigned_issue( + &connection, + "group/project#17", + "GitLab catalog issue", + "opened", + Some("gid://gitlab/Iteration/73"), + None, + Some("Iteration 73"), + None, + None, + None, + 1, + ); + + let page = load_assigned_issues_page_from_cache( + &connection, + 1, + &AssignedIssuesQueryInput { + cursor: None, + page: 1, + page_size: 20, + status: "todo".to_string(), + provider: None, + year: None, + iteration_id: None, + search: None, + }, + NaiveDate::from_ymd_opt(2026, 4, 15).unwrap(), + ) + .unwrap(); + + assert_eq!(page.catalog_state, "partial"); + assert_eq!( + page.catalog_message.as_deref(), + Some("Iteration data was synced, but no dated iteration catalog could be matched yet.") + ); + } + #[test] fn assigned_issues_cache_query_filters_and_builds_catalogs() { let connection = setup_empty_connection(); @@ -2169,6 +2556,7 @@ mod tests { page: 1, page_size: 20, status: "opened".to_string(), + provider: None, year: None, iteration_id: None, search: None, @@ -2257,6 +2645,7 @@ mod tests { page: 1, page_size: 20, status: "opened".to_string(), + provider: None, year: None, iteration_id: None, search: Some("Milestone B".to_string()), @@ -2275,6 +2664,7 @@ mod tests { page: 1, page_size: 20, status: "opened".to_string(), + provider: None, year: Some("2025".to_string()), iteration_id: None, search: None, @@ -2293,6 +2683,7 @@ mod tests { page: 1, page_size: 1, status: "opened".to_string(), + provider: None, year: None, iteration_id: Some("iter-web-current".to_string()), search: None, @@ -2314,6 +2705,7 @@ mod tests { page: 2, page_size: 1, status: "opened".to_string(), + provider: None, year: None, iteration_id: Some("iter-web-current".to_string()), search: None, @@ -2371,6 +2763,7 @@ mod tests { page: 1, page_size: 20, status: "opened".to_string(), + provider: None, year: None, iteration_id: None, search: None, @@ -2391,6 +2784,7 @@ mod tests { page: 1, page_size: 20, status: "opened".to_string(), + provider: None, year: Some("2025".to_string()), iteration_id: None, search: None, @@ -2436,6 +2830,7 @@ mod tests { page: 1, page_size: 20, status: "opened".to_string(), + provider: None, year: None, iteration_id: None, search: None, @@ -2504,6 +2899,7 @@ mod tests { page: 1, page_size: 20, status: "opened".to_string(), + provider: None, year: None, iteration_id: None, search: None, @@ -2595,6 +2991,7 @@ mod tests { page: 1, page_size: 20, status: "opened".to_string(), + provider: None, year: None, iteration_id: None, search: None, @@ -2676,6 +3073,7 @@ mod tests { page: 1, page_size: 20, status: "opened".to_string(), + provider: None, year: None, iteration_id: Some("iter-web-current".to_string()), search: None, @@ -2722,6 +3120,7 @@ mod tests { page: 1, page_size: 20, status: "opened".to_string(), + provider: None, year: None, iteration_id: None, search: None, @@ -2792,6 +3191,7 @@ mod tests { page: 1, page_size: 20, status: "opened".to_string(), + provider: None, year: None, iteration_id: None, search: None, @@ -2842,6 +3242,7 @@ mod tests { page: 1, page_size: 20, status: "opened".to_string(), + provider: None, year: None, iteration_id: None, search: None, diff --git a/src-tauri/src/db/connection.rs b/src-tauri/src/db/connection.rs index 0714f4e3..c52e4046 100644 --- a/src-tauri/src/db/connection.rs +++ b/src-tauri/src/db/connection.rs @@ -7,6 +7,7 @@ use crate::{ }; const GITLAB_PROVIDER: &str = "GitLab"; +const YOUTRACK_PROVIDER: &str = "YouTrack"; const OAUTH_READY_NOTE: &str = "GitLab OAuth client is configured locally. Register timely://auth/gitlab as an allowed redirect URI in GitLab."; const OAUTH_MISSING_NOTE: &str = "Client ID missing. Configure a GitLab OAuth application before launching auth."; @@ -37,7 +38,7 @@ pub fn upsert_gitlab_connection( let now = utc_timestamp(); let oauth_ready = client_id.is_some(); - let status_note = oauth_status_note(client_id.is_some()); + let status_note = oauth_status_note_for_provider(GITLAB_PROVIDER, client_id.is_some()); match existing_id { Some(id) => { @@ -86,6 +87,82 @@ pub fn upsert_gitlab_connection( .ok_or_else(|| rusqlite::Error::QueryReturnedNoRows.into()) } +pub fn upsert_provider_connection( + connection: &Connection, + provider: &str, + host: &str, + display_name: Option<&str>, + client_id: Option<&str>, + auth_mode: &str, + preferred_scope: &str, +) -> Result { + let provider = normalize_provider(provider); + let normalized_host = normalize_host(host); + let display_name = display_name + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| default_display_name(provider)) + .trim() + .to_string(); + + let existing_id = connection + .query_row( + "SELECT id FROM provider_accounts WHERE provider = ?1 AND host = ?2 LIMIT 1", + params![provider, normalized_host.as_str()], + |row| row.get::<_, i64>(0), + ) + .optional()?; + + let now = utc_timestamp(); + let oauth_ready = client_id.is_some(); + let status_note = oauth_status_note_for_provider(provider, client_id.is_some()); + + match existing_id { + Some(id) => { + connection.execute( + "UPDATE provider_accounts + SET display_name = ?1, oauth_client_id = ?2, auth_mode = ?3, preferred_scope = ?4, oauth_ready = ?5, status_note = ?6, last_sync_at = ?7 + WHERE id = ?8", + params![ + display_name, + client_id, + auth_mode, + preferred_scope, + bool_to_sqlite(oauth_ready), + status_note, + now, + id, + ], + )?; + } + None => { + connection.execute( + "UPDATE provider_accounts SET is_primary = 0 WHERE provider = ?1", + [provider], + )?; + connection.execute( + "INSERT INTO provider_accounts (provider, host, display_name, username, auth_mode, oauth_client_id, preferred_scope, oauth_ready, status_note, is_primary, created_at, last_sync_at) + VALUES (?1, ?2, ?3, NULL, ?4, ?5, ?6, ?7, ?8, 1, ?9, ?9)", + params![ + provider, + normalized_host, + display_name, + auth_mode, + client_id, + preferred_scope, + bool_to_sqlite(oauth_ready), + status_note, + now, + ], + )?; + } + } + + load_provider_connections(connection)? + .into_iter() + .find(|item| item.provider == provider && item.host == normalized_host) + .ok_or_else(|| rusqlite::Error::QueryReturnedNoRows.into()) +} + pub fn save_gitlab_pat( connection: &Connection, host: &str, @@ -143,6 +220,53 @@ pub fn save_gitlab_pat( .ok_or_else(|| rusqlite::Error::QueryReturnedNoRows.into()) } +pub fn save_provider_pat( + connection: &Connection, + provider: &str, + host: &str, + token: &str, +) -> Result { + let provider = normalize_provider(provider); + let normalized_host = normalize_host(host); + + let existing_id = connection + .query_row( + "SELECT id FROM provider_accounts WHERE provider = ?1 AND host = ?2 LIMIT 1", + params![provider, normalized_host.as_str()], + |row| row.get::<_, i64>(0), + ) + .optional()?; + + let now = utc_timestamp(); + let note = pat_connected_note(provider); + match existing_id { + Some(id) => { + connection.execute( + "UPDATE provider_accounts + SET auth_mode = 'PAT', personal_access_token = ?1, preferred_scope = 'api', oauth_ready = 1, status_note = ?2, last_sync_at = ?3 + WHERE id = ?4", + params![token, note, now, id], + )?; + } + None => { + connection.execute( + "UPDATE provider_accounts SET is_primary = 0 WHERE provider = ?1", + [provider], + )?; + connection.execute( + "INSERT INTO provider_accounts (provider, host, display_name, username, auth_mode, personal_access_token, preferred_scope, oauth_ready, status_note, is_primary, created_at, last_sync_at) + VALUES (?1, ?2, ?3, NULL, 'PAT', ?4, 'api', 1, ?5, 1, ?6, ?6)", + params![provider, normalized_host, normalized_host, token, note, now,], + )?; + } + } + + load_provider_connections(connection)? + .into_iter() + .find(|item| item.provider == provider && item.host == normalized_host) + .ok_or_else(|| rusqlite::Error::QueryReturnedNoRows.into()) +} + pub fn load_gitlab_connections( connection: &Connection, ) -> Result, AppError> { @@ -158,6 +282,19 @@ pub fn load_gitlab_connections( Ok(rows.collect::, _>>()?) } +pub fn load_provider_connections( + connection: &Connection, +) -> Result, AppError> { + let mut statement = connection.prepare( + "SELECT id, provider, display_name, host, oauth_client_id, auth_mode, preferred_scope, oauth_ready, status_note, is_primary, personal_access_token, username + FROM provider_accounts + ORDER BY provider ASC, is_primary DESC, id ASC", + )?; + + let rows = statement.query_map([], map_provider_connection_row)?; + Ok(rows.collect::, _>>()?) +} + pub fn load_gitlab_token(connection: &Connection, host: &str) -> Result, AppError> { let normalized_host = normalize_host(host); let token = connection @@ -171,29 +308,82 @@ pub fn load_gitlab_token(connection: &Connection, host: &str) -> Result Result, AppError> { + let provider = normalize_provider(provider); + let normalized_host = normalize_host(host); + let token = connection + .query_row( + "SELECT personal_access_token FROM provider_accounts WHERE provider = ?1 AND host = ?2 LIMIT 1", + params![provider, normalized_host.as_str()], + |row| row.get::<_, Option>(0), + ) + .optional()?; + + Ok(token.flatten().filter(|t| !t.is_empty())) +} + pub fn update_username( connection: &Connection, host: &str, username: &str, ) -> Result<(), AppError> { + update_provider_username(connection, GITLAB_PROVIDER, host, username) +} + +pub fn update_provider_username( + connection: &Connection, + provider: &str, + host: &str, + username: &str, +) -> Result<(), AppError> { + let provider = normalize_provider(provider); let normalized_host = normalize_host(host); connection.execute( "UPDATE provider_accounts SET username = ?1, status_note = ?2 WHERE provider = ?3 AND host = ?4", params![ username, format!("Authenticated as @{username}"), - GITLAB_PROVIDER, + provider, normalized_host, ], )?; Ok(()) } -fn oauth_status_note(has_client_id: bool) -> &'static str { - if has_client_id { - OAUTH_READY_NOTE +fn oauth_status_note_for_provider(provider: &str, has_client_id: bool) -> &'static str { + match (normalize_provider(provider), has_client_id) { + (GITLAB_PROVIDER, true) => OAUTH_READY_NOTE, + (GITLAB_PROVIDER, false) => OAUTH_MISSING_NOTE, + (_, true) => "OAuth client is configured locally.", + (_, false) => "Use API token or configure OAuth before launching auth.", + } +} + +fn pat_connected_note(provider: &str) -> &'static str { + if normalize_provider(provider) == GITLAB_PROVIDER { + PAT_CONNECTED_NOTE + } else { + "Connected via API token." + } +} + +fn default_display_name(provider: &str) -> &'static str { + if normalize_provider(provider) == YOUTRACK_PROVIDER { + "YouTrack workspace" + } else { + "GitLab workspace" + } +} + +pub fn normalize_provider(provider: &str) -> &'static str { + if provider.eq_ignore_ascii_case("youtrack") { + YOUTRACK_PROVIDER } else { - OAUTH_MISSING_NOTE + GITLAB_PROVIDER } } diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index 48c0163d..26d0a0be 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -708,6 +708,15 @@ pub fn migrate(connection: &Connection) -> Result<(), AppError> { ensure_column(connection, "work_items", "iteration_group_id", "TEXT")?; ensure_column(connection, "work_items", "iteration_cadence_id", "TEXT")?; ensure_column(connection, "work_items", "iteration_cadence_title", "TEXT")?; + ensure_column(connection, "work_items", "status_label", "TEXT")?; + ensure_column( + connection, + "work_items", + "workflow_status", + "TEXT NOT NULL DEFAULT 'todo'", + )?; + ensure_column(connection, "work_items", "start_date", "TEXT")?; + ensure_column(connection, "work_items", "due_date", "TEXT")?; ensure_column(connection, "work_items", "closed_at", "TEXT")?; ensure_column(connection, "work_items", "provider_updated_at", "TEXT")?; ensure_column(connection, "work_items", "assigned_bucket", "TEXT")?; diff --git a/src-tauri/src/db/sync.rs b/src-tauri/src/db/sync.rs index c5be3abf..177d995f 100644 --- a/src-tauri/src/db/sync.rs +++ b/src-tauri/src/db/sync.rs @@ -93,10 +93,12 @@ pub fn upsert_assigned_issue( match existing_id { Some(id) => { connection.execute( - "UPDATE work_items SET title = ?1, state = ?2, closed_at = ?3, web_url = ?4, labels_json = ?5, issue_graphql_id = ?6, milestone_title = ?7, iteration_gitlab_id = ?8, iteration_group_id = ?9, iteration_cadence_id = ?10, iteration_cadence_title = ?11, iteration_title = ?12, iteration_start_date = ?13, iteration_due_date = ?14, from_assigned_sync = 1, assigned_bucket = ?15, updated_at = ?16 WHERE id = ?17", + "UPDATE work_items SET title = ?1, state = ?2, status_label = ?3, workflow_status = ?4, closed_at = ?5, web_url = ?6, labels_json = ?7, issue_graphql_id = ?8, milestone_title = ?9, iteration_gitlab_id = ?10, iteration_group_id = ?11, iteration_cadence_id = ?12, iteration_cadence_title = ?13, iteration_title = ?14, iteration_start_date = ?15, iteration_due_date = ?16, start_date = ?17, due_date = ?18, from_assigned_sync = 1, assigned_bucket = ?19, updated_at = ?20 WHERE id = ?21", params![ issue.title.as_str(), issue.state.as_str(), + issue.status_label.as_deref(), + issue.workflow_status.as_str(), issue.closed_at.as_deref(), issue.web_url.as_deref(), labels_json.as_str(), @@ -109,6 +111,8 @@ pub fn upsert_assigned_issue( issue.iteration_title.as_deref(), issue.iteration_start_date.as_deref(), issue.iteration_due_date.as_deref(), + issue.start_date.as_deref(), + issue.due_date.as_deref(), bucket.as_str(), utc_timestamp(), id, @@ -122,12 +126,14 @@ pub fn upsert_assigned_issue( } None => { connection.execute( - "INSERT INTO work_items (provider_account_id, provider_item_id, title, state, closed_at, web_url, labels_json, issue_graphql_id, milestone_title, iteration_gitlab_id, iteration_group_id, iteration_cadence_id, iteration_cadence_title, iteration_title, iteration_start_date, iteration_due_date, from_assigned_sync, assigned_bucket, updated_at, provider_updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, 1, ?17, ?18, ?19)", + "INSERT INTO work_items (provider_account_id, provider_item_id, title, state, status_label, workflow_status, closed_at, web_url, labels_json, issue_graphql_id, milestone_title, iteration_gitlab_id, iteration_group_id, iteration_cadence_id, iteration_cadence_title, iteration_title, iteration_start_date, iteration_due_date, start_date, due_date, from_assigned_sync, assigned_bucket, updated_at, provider_updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, 1, ?21, ?22, ?23)", params![ provider_account_id, issue.provider_item_id.as_str(), issue.title.as_str(), issue.state.as_str(), + issue.status_label.as_deref(), + issue.workflow_status.as_str(), issue.closed_at.as_deref(), issue.web_url.as_deref(), labels_json.as_str(), @@ -140,6 +146,8 @@ pub fn upsert_assigned_issue( issue.iteration_title.as_deref(), issue.iteration_start_date.as_deref(), issue.iteration_due_date.as_deref(), + issue.start_date.as_deref(), + issue.due_date.as_deref(), bucket.as_str(), utc_timestamp(), issue.updated_at.as_deref(), @@ -469,7 +477,7 @@ pub fn rebuild_daily_buckets_in_tx( }, ) .map_err(|_| { - AppError::GitLabApi( + AppError::ProviderApi( "no default schedule profile found; configure your work schedule first".to_string(), ) })?; @@ -908,6 +916,16 @@ mod tests { provider_item_id: provider_item_id.to_string(), title: format!("Issue {provider_item_id}"), state: state.to_string(), + status_label: Some(if state.eq_ignore_ascii_case("closed") { + "Done".to_string() + } else { + "To do".to_string() + }), + workflow_status: if state.eq_ignore_ascii_case("closed") { + "done".to_string() + } else { + "todo".to_string() + }, closed_at: closed_at.map(str::to_string), updated_at: None, web_url: None, @@ -920,6 +938,8 @@ mod tests { iteration_title: None, iteration_start_date: None, iteration_due_date: None, + start_date: None, + due_date: None, } } diff --git a/src-tauri/src/domain/models.rs b/src-tauri/src/domain/models.rs index 69164b50..de7fa3ae 100644 --- a/src-tauri/src/domain/models.rs +++ b/src-tauri/src/domain/models.rs @@ -47,6 +47,17 @@ pub struct GitLabConnectionInput { pub client_id: Option, } +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderConnectionInput { + pub provider: String, + pub host: String, + pub auth_mode: String, + pub preferred_scope: String, + pub display_name: Option, + pub client_id: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AuthLaunchPlan { @@ -238,6 +249,8 @@ pub struct AssignedIssueRecord { pub provider_item_id: String, pub title: String, pub state: String, + pub status_label: Option, + pub workflow_status: String, pub closed_at: Option, pub updated_at: Option, pub web_url: Option, @@ -252,6 +265,8 @@ pub struct AssignedIssueRecord { pub iteration_start_date: Option, /// GitLab iteration due/end (YYYY-MM-DD) when GraphQL exposes it. pub iteration_due_date: Option, + pub start_date: Option, + pub due_date: Option, } /// Assigned issues shown on Home / Issues board (from local cache after sync). @@ -264,10 +279,13 @@ pub struct AssignedIssueSnapshot { pub key: String, pub title: String, pub state: String, + pub status_label: Option, + pub workflow_status: String, pub closed_at: Option, pub updated_at: Option, pub web_url: Option, pub labels: Vec, + pub label_tones: Vec, pub milestone_title: Option, pub iteration_gitlab_id: Option, pub iteration_group_id: Option, @@ -276,6 +294,8 @@ pub struct AssignedIssueSnapshot { pub iteration_title: Option, pub iteration_start_date: Option, pub iteration_due_date: Option, + pub start_date: Option, + pub due_date: Option, pub assigned_bucket: Option, } @@ -309,6 +329,18 @@ pub struct IssueActor { pub avatar_url: Option, } +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ToneName { + Neutral, + Primary, + Accent, + Success, + Warning, + Secondary, + Destructive, +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct IssueMetadataOption { @@ -317,6 +349,7 @@ pub struct IssueMetadataOption { pub color: Option, #[serde(skip_serializing_if = "Option::is_none")] pub badge: Option, + pub tone: ToneName, } #[derive(Clone, Debug, Serialize)] @@ -326,6 +359,7 @@ pub struct IssueStatusOption { pub label: String, pub color: Option, pub icon: Option, + pub tone: ToneName, } #[derive(Clone, Debug, Serialize)] @@ -397,6 +431,14 @@ pub struct IssueRelatedItem { pub labels: Vec, } +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueMetadataField { + pub id: String, + pub label: String, + pub value: String, +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct IssueDetailsSnapshot { @@ -412,12 +454,22 @@ pub struct IssueDetailsSnapshot { pub description: Option, pub status: Option, pub status_options: Option>, + pub project_name: Option, + pub issue_type: Option, + pub priority: Option, + pub start_date: Option, + pub due_date: Option, + pub estimate: Option, + pub weight: Option, + pub participants: Option>, pub labels: Vec, pub milestone_title: Option, pub milestone: Option, pub iteration: Option, + pub parent_item: Option, pub linked_items: Option>, pub child_items: Option>, + pub metadata_fields: Option>, pub activity: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub activity_has_next_page: Option, @@ -474,6 +526,7 @@ pub struct AssignedIssuesQueryInput { pub page: usize, pub page_size: usize, pub status: String, + pub provider: Option, pub year: Option, pub iteration_id: Option, pub search: Option, diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index fabcf776..14f2c604 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -16,6 +16,8 @@ pub enum AppError { Updater(#[from] tauri_plugin_updater::Error), #[error("gitlab api error: {0}")] GitLabApi(String), + #[error("provider api error: {0}")] + ProviderApi(String), #[error("operation timed out: {0}")] Timeout(String), #[error("invalid auth configuration: {0}")] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9398e580..6e862e35 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,8 +15,10 @@ use tauri_plugin_deep_link::DeepLinkExt; use crate::{ commands::{ auth::{ - begin_gitlab_oauth, list_gitlab_connections, resolve_gitlab_oauth_callback, - save_gitlab_connection, save_gitlab_pat, validate_gitlab_token, + begin_gitlab_oauth, list_gitlab_connections, list_provider_connections, + resolve_gitlab_oauth_callback, save_gitlab_connection, save_gitlab_pat, + save_provider_connection, save_provider_pat, validate_gitlab_token, + validate_provider_token, }, dashboard::{ activate_quest, bootstrap_dashboard, claim_quest_reward, diagnostics_clear, @@ -26,7 +28,8 @@ use crate::{ notification_delivery_profile, notification_permission_capability, notification_permission_state, notification_request_permission, notification_send_test, open_system_notification_settings, purchase_reward, reset_all_data, - save_app_preferences, save_setup_state, sync_gitlab, unequip_reward, update_schedule, + save_app_preferences, save_setup_state, sync_gitlab, sync_providers, unequip_reward, + update_schedule, }, issues::{ create_issue_comment, delete_issue, delete_issue_comment, load_issue_activity_page, @@ -177,12 +180,17 @@ pub fn run() { log_issue_time, bootstrap_dashboard, list_gitlab_connections, + list_provider_connections, save_gitlab_connection, save_gitlab_pat, + save_provider_connection, + save_provider_pat, validate_gitlab_token, + validate_provider_token, begin_gitlab_oauth, resolve_gitlab_oauth_callback, sync_gitlab, + sync_providers, update_schedule, load_setup_state, save_setup_state, diff --git a/src-tauri/src/providers/gitlab.rs b/src-tauri/src/providers/gitlab.rs index 5f5b1109..754fb408 100644 --- a/src-tauri/src/providers/gitlab.rs +++ b/src-tauri/src/providers/gitlab.rs @@ -1,5 +1,6 @@ use chrono::NaiveDate; use std::collections::HashSet; +use std::error::Error; use std::time::Duration; use reqwest::blocking::Client; @@ -16,6 +17,7 @@ use crate::domain::models::{ LoadIssueDetailsResponse, UpdateIssueMetadataInput, }; use crate::error::AppError; +use crate::support::badge_tone_mapper::BadgeToneMapper; use crate::support::iteration_label::iteration_display_label; const MAX_PAGES: u32 = 100; @@ -74,6 +76,99 @@ struct GraphQLError { message: String, } +/// Execute a reqwest request with up to 3 retries on transient errors (timeout/connect). +/// Retries use exponential backoff: 2s, 4s, 8s. +fn execute_gitlab_request( + req: reqwest::blocking::RequestBuilder, + mut on_progress: Option<&mut dyn FnMut(String)>, +) -> Result { + const MAX_RETRIES: usize = 3; + let backoff_secs = [2u64, 4, 8]; + + for attempt in 0..=MAX_RETRIES { + let req_for_send = match req.try_clone() { + Some(cloned) => cloned, + None => { + return req.send().map_err(|e| { + let kind_labels = collect_reqwest_kind_labels(&e); + let source_chain = collect_error_source_chain(&e); + let mut msg = format!("GitLab request failed | kind={}", kind_labels); + if !source_chain.is_empty() { + msg.push_str(&format!(" | caused by: {}", source_chain)); + } + AppError::GitLabApi(msg) + }); + } + }; + + let result = req_for_send.send(); + + match result { + Ok(resp) => return Ok(resp), + Err(error) => { + let is_retryable = error.is_timeout() || error.is_connect(); + if !is_retryable || attempt == MAX_RETRIES { + let kind_labels = collect_reqwest_kind_labels(&error); + let source_chain = collect_error_source_chain(&error); + let mut msg = format!("GitLab request failed | kind={}", kind_labels); + if !source_chain.is_empty() { + msg.push_str(&format!(" | caused by: {}", source_chain)); + } + return Err(AppError::GitLabApi(msg)); + } + + let delay = backoff_secs[attempt.saturating_sub(1)]; + if let Some(ref mut cb) = on_progress { + cb(format!( + "GitLab request timed out, retrying ({}/{}) in {}s...", + attempt + 1, + MAX_RETRIES + 1, + delay + )); + } + std::thread::sleep(std::time::Duration::from_secs(delay)); + } + } + } + unreachable!() +} + +fn collect_reqwest_kind_labels(error: &reqwest::Error) -> String { + let mut labels: Vec = Vec::new(); + if error.is_connect() { + labels.push("connect".to_string()); + } + if error.is_timeout() { + labels.push("timeout".to_string()); + } + if error.is_request() { + labels.push("request".to_string()); + } + if error.is_body() { + labels.push("body".to_string()); + } + if error.is_decode() { + labels.push("decode".to_string()); + } + if let Some(code) = error.status() { + labels.push(code.to_string()); + } + if labels.is_empty() { + labels.push("unknown".to_string()); + } + labels.join(",") +} + +fn collect_error_source_chain(error: &reqwest::Error) -> String { + let mut chain = Vec::new(); + let mut current: Option<&(dyn std::error::Error + 'static)> = error.source(); + while let Some(e) = current { + chain.push(e.to_string()); + current = e.source(); + } + chain.join(" -> ") +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct TimelogsData { @@ -189,11 +284,12 @@ impl GitLabClient { } pub fn fetch_user(&self) -> Result { - let response = self - .client - .get(format!("{}/api/v4/user", self.base_url)) - .header("PRIVATE-TOKEN", &self.token) - .send()?; + let response = execute_gitlab_request( + self.client + .get(format!("{}/api/v4/user", self.base_url)) + .header("PRIVATE-TOKEN", &self.token), + None, + )?; if !response.status().is_success() { return Err(AppError::GitLabApi(format!( @@ -249,15 +345,16 @@ impl GitLabClient { end_date: &str, cursor: Option<&str>, ) -> Result, AppError> { - let response = self - .client - .post(format!("{}/api/graphql", self.base_url)) - .header("Authorization", format!("Bearer {}", self.token)) - .header("Content-Type", "application/json") - .json(&serde_json::json!({ - "query": build_timelog_query(start_date, end_date, cursor), - })) - .send()?; + let response = execute_gitlab_request( + self.client + .post(format!("{}/api/graphql", self.base_url)) + .header("Authorization", format!("Bearer {}", self.token)) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "query": build_timelog_query(start_date, end_date, cursor), + })), + None, + )?; if !response.status().is_success() { return Err(AppError::GitLabApi(format!( @@ -272,13 +369,14 @@ impl GitLabClient { } fn post_graphql_value(&self, body: &JsonValue) -> Result { - let response = self - .client - .post(format!("{}/api/graphql", self.base_url)) - .header("Authorization", format!("Bearer {}", self.token)) - .header("Content-Type", "application/json") - .json(body) - .send()?; + let response = execute_gitlab_request( + self.client + .post(format!("{}/api/graphql", self.base_url)) + .header("Authorization", format!("Bearer {}", self.token)) + .header("Content-Type", "application/json") + .json(body), + None, + )?; if !response.status().is_success() { return Err(AppError::GitLabApi(format!( @@ -366,11 +464,10 @@ impl GitLabClient { url.push_str(&urlencoding::encode(updated_after)); } - let response = self - .client - .get(&url) - .header("PRIVATE-TOKEN", &self.token) - .send()?; + let response = execute_gitlab_request( + self.client.get(&url).header("PRIVATE-TOKEN", &self.token), + Some(on_progress), + )?; if !response.status().is_success() { return Err(AppError::GitLabApi(format!( @@ -665,11 +762,10 @@ impl GitLabClient { url.push_str(&urlencoding::encode(search)); } - let response = self - .client - .get(&url) - .header("PRIVATE-TOKEN", &self.token) - .send()?; + let response = execute_gitlab_request( + self.client.get(&url).header("PRIVATE-TOKEN", &self.token), + None, + )?; if !response.status().is_success() { return Err(AppError::GitLabApi(format!( @@ -920,6 +1016,7 @@ impl GitLabClient { label: label.to_string(), color: None, badge: None, + tone: BadgeToneMapper::new().map_label(label), }) .collect::>() }) @@ -940,6 +1037,7 @@ impl GitLabClient { .and_then(|value| value.as_str()) .map(str::to_string), badge: None, + tone: BadgeToneMapper::new().map_label(name), }) }) .collect::>(); @@ -949,6 +1047,27 @@ impl GitLabClient { .and_then(|milestone| milestone.get("title")) .and_then(|value| value.as_str()) .map(str::to_string); + let issue_type = issue + .get("issue_type") + .or_else(|| issue.get("issueType")) + .and_then(|value| value.as_str()) + .map(str::to_string); + let due_date = issue + .get("due_date") + .or_else(|| issue.get("dueDate")) + .and_then(|value| value.as_str()) + .map(str::to_string); + let weight = issue.get("weight").and_then(|value| value.as_i64()); + let participants = issue + .get("participants") + .and_then(|value| value.as_array()) + .map(|items| { + items + .iter() + .filter_map(parse_issue_actor_json) + .collect::>() + }) + .filter(|items| !items.is_empty()); let current_milestone = issue .get("milestone") @@ -993,6 +1112,7 @@ impl GitLabClient { label: value.label.clone(), color: None, badge: None, + tone: BadgeToneMapper::new().map_label(&value.label), }] }) .unwrap_or_default() @@ -1007,6 +1127,7 @@ impl GitLabClient { label: current.label.clone(), color: None, badge: None, + tone: BadgeToneMapper::new().map_label(¤t.label), }, ); } @@ -1023,6 +1144,7 @@ impl GitLabClient { label: value.label.clone(), color: value.color.clone(), icon: value.icon.clone(), + tone: BadgeToneMapper::new().map_status(&value.label), }] }); let linked_items = graph_ql_details @@ -1034,6 +1156,9 @@ impl GitLabClient { .as_ref() .and_then(parse_graphql_child_items) .map(|items| items.into_iter().collect::>()); + let parent_item = graph_ql_details + .as_ref() + .and_then(parse_graphql_parent_item); Ok(LoadIssueDetailsResponse::Full { snapshot: Box::new(IssueDetailsSnapshot { @@ -1088,12 +1213,26 @@ impl GitLabClient { .map(str::to_string), status, status_options: status_options.clone(), + project_name: None, + issue_type, + priority: None, + start_date: None, + due_date, + estimate: issue + .pointer("/time_stats/human_time_estimate") + .and_then(|value| value.as_str()) + .map(str::to_string) + .filter(|value| !value.trim().is_empty()), + weight, + participants, labels: current_labels, milestone_title, milestone: current_milestone.clone(), iteration: iteration.clone(), + parent_item, linked_items, child_items, + metadata_fields: None, activity, activity_has_next_page: Some(notes_page.next_page.is_some()), activity_next_page: notes_page.next_page, @@ -1101,7 +1240,7 @@ impl GitLabClient { issue_etag, capabilities: IssueDetailsCapabilities { status: IssueMetadataCapability { - enabled: false, + enabled: status_options.is_some(), reason: status_options.as_ref().map(|_| { "Workflow status is not editable from Timely yet.".to_string() }), @@ -1109,11 +1248,15 @@ impl GitLabClient { .clone() .unwrap_or_default() .into_iter() - .map(|value| IssueMetadataOption { - id: value.id, - label: value.label, - color: value.color, - badge: None, + .map(|value| { + let tone = BadgeToneMapper::new().map_status(&value.label); + IssueMetadataOption { + id: value.id, + label: value.label, + color: value.color, + badge: None, + tone, + } }) .collect::>(), }, @@ -1211,13 +1354,14 @@ impl GitLabClient { "{}/api/v4/projects/{}/issues/{}", self.base_url, encoded_project, issue_iid ); - let response = self - .client - .put(url) - .header("PRIVATE-TOKEN", &self.token) - .header("Content-Type", "application/json") - .json(&JsonValue::Object(body)) - .send()?; + let response = execute_gitlab_request( + self.client + .put(url) + .header("PRIVATE-TOKEN", &self.token) + .header("Content-Type", "application/json") + .json(&JsonValue::Object(body)), + None, + )?; if !response.status().is_success() { return Err(AppError::GitLabApi(format!( @@ -1287,14 +1431,15 @@ impl GitLabClient { project_path: &str, ) -> Result, AppError> { let encoded_project = urlencoding::encode(project_path); - let response = self - .client - .get(format!( - "{}/api/v4/projects/{}/milestones?state=active&per_page=100&include_parent_milestones=true", - self.base_url, encoded_project - )) - .header("PRIVATE-TOKEN", &self.token) - .send()?; + let response = execute_gitlab_request( + self.client + .get(format!( + "{}/api/v4/projects/{}/milestones?state=active&per_page=100&include_parent_milestones=true", + self.base_url, encoded_project + )) + .header("PRIVATE-TOKEN", &self.token), + None, + )?; if !response.status().is_success() { return Err(AppError::GitLabApi(format!( @@ -1309,14 +1454,15 @@ impl GitLabClient { fn fetch_group_iterations_json(&self, group_path: &str) -> Result, AppError> { let encoded_group = urlencoding::encode(group_path); - let response = self - .client - .get(format!( - "{}/api/v4/groups/{}/iterations?include_ancestors=true&state=opened&per_page=100", - self.base_url, encoded_group - )) - .header("PRIVATE-TOKEN", &self.token) - .send()?; + let response = execute_gitlab_request( + self.client + .get(format!( + "{}/api/v4/groups/{}/iterations?include_ancestors=true&state=opened&per_page=100", + self.base_url, encoded_group + )) + .header("PRIVATE-TOKEN", &self.token), + None, + )?; if !response.status().is_success() { return Err(AppError::GitLabApi(format!( @@ -1348,11 +1494,10 @@ impl GitLabClient { self.base_url, user.id, state, page ); - let response = self - .client - .get(&url) - .header("PRIVATE-TOKEN", &self.token) - .send()?; + let response = execute_gitlab_request( + self.client.get(&url).header("PRIVATE-TOKEN", &self.token), + Some(on_progress), + )?; if !response.status().is_success() { return Err(AppError::GitLabApi(format!( @@ -1520,15 +1665,16 @@ impl GitLabClient { let (project_path, issue_iid) = parse_issue_reference_key(issue_key)?; let numeric_note_id = gitlab_numeric_id_from_gid(note_id); let encoded_project = urlencoding::encode(project_path); - let response = self - .client - .put(format!( - "{}/api/v4/projects/{}/issues/{}/notes/{}", - self.base_url, encoded_project, issue_iid, numeric_note_id - )) - .header("PRIVATE-TOKEN", &self.token) - .json(&serde_json::json!({ "body": body_md })) - .send()?; + let response = execute_gitlab_request( + self.client + .put(format!( + "{}/api/v4/projects/{}/issues/{}/notes/{}", + self.base_url, encoded_project, issue_iid, numeric_note_id + )) + .header("PRIVATE-TOKEN", &self.token) + .json(&serde_json::json!({ "body": body_md })), + None, + )?; if !response.status().is_success() { return Err(AppError::GitLabApi(format!( @@ -1547,14 +1693,15 @@ impl GitLabClient { let (project_path, issue_iid) = parse_issue_reference_key(issue_key)?; let numeric_note_id = gitlab_numeric_id_from_gid(note_id); let encoded_project = urlencoding::encode(project_path); - let response = self - .client - .delete(format!( - "{}/api/v4/projects/{}/issues/{}/notes/{}", - self.base_url, encoded_project, issue_iid, numeric_note_id - )) - .header("PRIVATE-TOKEN", &self.token) - .send()?; + let response = execute_gitlab_request( + self.client + .delete(format!( + "{}/api/v4/projects/{}/issues/{}/notes/{}", + self.base_url, encoded_project, issue_iid, numeric_note_id + )) + .header("PRIVATE-TOKEN", &self.token), + None, + )?; if !response.status().is_success() { return Err(AppError::GitLabApi(format!( @@ -1572,14 +1719,15 @@ impl GitLabClient { pub fn delete_issue(&self, issue_key: &str) -> Result<(), AppError> { let (project_path, issue_iid) = parse_issue_reference_key(issue_key)?; let encoded_project = urlencoding::encode(project_path); - let response = self - .client - .delete(format!( - "{}/api/v4/projects/{}/issues/{}", - self.base_url, encoded_project, issue_iid - )) - .header("PRIVATE-TOKEN", &self.token) - .send()?; + let response = execute_gitlab_request( + self.client + .delete(format!( + "{}/api/v4/projects/{}/issues/{}", + self.base_url, encoded_project, issue_iid + )) + .header("PRIVATE-TOKEN", &self.token), + None, + )?; if !response.status().is_success() { return Err(AppError::GitLabApi(format!( @@ -1627,7 +1775,7 @@ impl GitLabClient { } } - let response = request.send()?; + let response = execute_gitlab_request(request, None)?; let status = response.status(); if status == StatusCode::NOT_MODIFIED { let etag = response @@ -1663,14 +1811,15 @@ impl GitLabClient { per_page: u32, ) -> Result { let encoded_project = urlencoding::encode(project_path); - let response = self - .client - .get(format!( - "{}/api/v4/projects/{}/issues/{}/notes?activity_filter=all_notes&sort=desc&order_by=created_at&per_page={}&page={}", - self.base_url, encoded_project, issue_iid, per_page, page - )) - .header("PRIVATE-TOKEN", &self.token) - .send()?; + let response = execute_gitlab_request( + self.client + .get(format!( + "{}/api/v4/projects/{}/issues/{}/notes?activity_filter=all_notes&sort=desc&order_by=created_at&per_page={}&page={}", + self.base_url, encoded_project, issue_iid, per_page, page + )) + .header("PRIVATE-TOKEN", &self.token), + None, + )?; if !response.status().is_success() { return Err(AppError::GitLabApi(format!( @@ -1699,14 +1848,15 @@ impl GitLabClient { issue_iid: &str, ) -> Result, AppError> { let encoded_project = urlencoding::encode(project_path); - let response = self - .client - .get(format!( - "{}/api/v4/projects/{}/issues/{}/links", - self.base_url, encoded_project, issue_iid - )) - .header("PRIVATE-TOKEN", &self.token) - .send()?; + let response = execute_gitlab_request( + self.client + .get(format!( + "{}/api/v4/projects/{}/issues/{}/links", + self.base_url, encoded_project, issue_iid + )) + .header("PRIVATE-TOKEN", &self.token), + None, + )?; if !response.status().is_success() { return Err(AppError::GitLabApi(format!( @@ -1754,6 +1904,18 @@ impl GitLabClient { } } ... on WorkItemWidgetHierarchy { + parent { + iid + title + state + webUrl + labels { + nodes { + title + color + } + } + } children { nodes { iid @@ -1808,14 +1970,15 @@ impl GitLabClient { fn fetch_project_labels_json(&self, project_path: &str) -> Result, AppError> { let encoded_project = urlencoding::encode(project_path); - let response = self - .client - .get(format!( - "{}/api/v4/projects/{}/labels?per_page=100&with_counts=false", - self.base_url, encoded_project - )) - .header("PRIVATE-TOKEN", &self.token) - .send()?; + let response = execute_gitlab_request( + self.client + .get(format!( + "{}/api/v4/projects/{}/labels?per_page=100&with_counts=false", + self.base_url, encoded_project + )) + .header("PRIVATE-TOKEN", &self.token), + None, + )?; if !response.status().is_success() { return Err(AppError::GitLabApi(format!( @@ -2275,6 +2438,8 @@ fn parse_assigned_issues_page(v: &JsonValue) -> Result, .collect::>() }) .unwrap_or_default(); + let (status_label, workflow_status) = + status_metadata_from_state_and_labels(&state, &labels); let milestone_title = node .pointer("/milestone/title") .and_then(|x| x.as_str()) @@ -2321,6 +2486,8 @@ fn parse_assigned_issues_page(v: &JsonValue) -> Result, provider_item_id, title, state, + status_label, + workflow_status, closed_at, updated_at, web_url, @@ -2333,6 +2500,8 @@ fn parse_assigned_issues_page(v: &JsonValue) -> Result, iteration_title, iteration_start_date, iteration_due_date, + start_date: None, + due_date: None, }); } @@ -2421,6 +2590,7 @@ fn parse_rest_issue_row(item: &JsonValue) -> Result, .collect::>() }) .unwrap_or_default(); + let (status_label, workflow_status) = status_metadata_from_state_and_labels(&state, &labels); let milestone_title = item .pointer("/milestone/title") @@ -2487,6 +2657,8 @@ fn parse_rest_issue_row(item: &JsonValue) -> Result, provider_item_id, title, state, + status_label, + workflow_status, closed_at, updated_at, web_url, @@ -2499,9 +2671,49 @@ fn parse_rest_issue_row(item: &JsonValue) -> Result, iteration_title, iteration_start_date, iteration_due_date, + start_date: None, + due_date: item + .get("due_date") + .or_else(|| item.get("dueDate")) + .and_then(|x| x.as_str()) + .map(str::to_string), })) } +fn status_metadata_from_state_and_labels( + state: &str, + labels: &[String], +) -> (Option, String) { + if state.eq_ignore_ascii_case("closed") { + return (Some("Done".to_string()), "done".to_string()); + } + + let labels = labels + .iter() + .map(|label| label.to_ascii_lowercase()) + .collect::>(); + let state = state.to_ascii_lowercase(); + let has = |needles: &[&str]| { + needles.iter().any(|needle| { + state.contains(needle) + || labels + .iter() + .any(|label| label.contains(needle) || label.replace('_', " ").contains(needle)) + }) + }; + + if has(&["done", "complete", "completed", "resolved", "fixed"]) { + return (Some("Done".to_string()), "done".to_string()); + } + if has(&["blocked", "stuck", "impediment"]) { + return (Some("Blocked".to_string()), "blocked".to_string()); + } + if has(&["doing", "in progress", "in-progress", "wip", "review"]) { + return (Some("In progress".to_string()), "doing".to_string()); + } + (Some("To do".to_string()), "todo".to_string()) +} + fn parse_issue_milestone_option(value: &JsonValue) -> Option { let title = value .get("title") @@ -2515,6 +2727,7 @@ fn parse_issue_milestone_option(value: &JsonValue) -> Option Option Option Option Option { + let widgets = work_item.get("widgets")?.as_array()?; + + for widget in widgets { + let Some(parent) = widget.get("parent") else { + continue; + }; + if parent.is_null() { + continue; + } + + if let Some(item) = parse_graphql_related_item(parent, "parent") { + return Some(item); + } + } + + None +} + fn parse_graphql_child_items(work_item: &JsonValue) -> Option> { let widgets = work_item.get("widgets")?.as_array()?; @@ -3086,6 +3320,7 @@ fn parse_issue_label_options(labels: Option<&JsonValue>) -> Vec>() @@ -3095,6 +3330,7 @@ fn parse_issue_label_options(labels: Option<&JsonValue>) -> Vec String { match value { + "parent" => "Parent".to_string(), "child" => "Child item".to_string(), "relates_to" | "related" | "related_to" => "Related to".to_string(), "blocks" => "Blocks".to_string(), @@ -3131,10 +3367,13 @@ fn snapshot_from_assigned_issue_record(record: &AssignedIssueRecord) -> Assigned key: record.provider_item_id.clone(), title: record.title.clone(), state: record.state.clone(), + status_label: record.status_label.clone(), + workflow_status: record.workflow_status.clone(), closed_at: record.closed_at.clone(), updated_at: record.updated_at.clone(), web_url: record.web_url.clone(), labels: record.labels.clone(), + label_tones: record.labels.iter().map(|label| BadgeToneMapper::new().map_label(label)).collect(), milestone_title: record.milestone_title.clone(), iteration_gitlab_id: record.iteration_gitlab_id.clone(), iteration_group_id: record.iteration_group_id.clone(), @@ -3143,6 +3382,8 @@ fn snapshot_from_assigned_issue_record(record: &AssignedIssueRecord) -> Assigned iteration_title: record.iteration_title.clone(), iteration_start_date: record.iteration_start_date.clone(), iteration_due_date: record.iteration_due_date.clone(), + start_date: record.start_date.clone(), + due_date: record.due_date.clone(), assigned_bucket: None, } } @@ -3473,6 +3714,32 @@ fn build_item_key(project_path: Option<&str>, separator: char, iid: &str) -> Str mod assigned_issues_tests { use super::*; + #[test] + fn parse_graphql_parent_item_reads_hierarchy_widget_parent() { + let work_item = serde_json::json!({ + "widgets": [{ + "__typename": "WorkItemWidgetHierarchy", + "parent": { + "id": "gid://gitlab/WorkItem/9", + "iid": "9", + "title": "Parent task", + "state": "opened", + "webUrl": "https://gitlab.example.com/g/p/-/issues/9", + "labels": { + "nodes": [{ "title": "planning", "color": "#6699cc" }] + } + } + }] + }); + + let parent = parse_graphql_parent_item(&work_item).expect("parent item"); + + assert_eq!(parent.key, "g/p#9"); + assert_eq!(parent.title, "Parent task"); + assert_eq!(parent.relation_label, "Parent"); + assert_eq!(parent.labels[0].label, "planning"); + } + #[test] fn parse_assigned_issues_page_reads_root_issues_connection() { let v = serde_json::json!({ @@ -3596,6 +3863,24 @@ mod assigned_issues_tests { assert_eq!(row.closed_at.as_deref(), Some("2026-04-02T10:30:00Z")); } + #[test] + fn parse_rest_issue_row_maps_closed_to_done_workflow_status() { + let item = serde_json::json!({ + "id": 77, + "iid": 42, + "title": "Closed issue", + "state": "closed", + "closed_at": "2026-04-02T10:30:00Z", + "web_url": "https://gitlab.example.com/g/p/-/issues/42", + "references": { "full": "g/p#42" }, + "labels": [] + }); + + let row = parse_rest_issue_row(&item).unwrap().unwrap(); + assert_eq!(row.status_label.as_deref(), Some("Done")); + assert_eq!(row.workflow_status, "done"); + } + #[test] fn parse_rest_issue_row_reads_updated_at() { let item = serde_json::json!({ @@ -3789,6 +4074,8 @@ mod assigned_issues_tests { provider_item_id: "g/p#1".to_string(), title: "Hello".to_string(), state: "opened".to_string(), + status_label: Some("To do".to_string()), + workflow_status: "todo".to_string(), closed_at: None, updated_at: None, web_url: None, @@ -3801,6 +4088,8 @@ mod assigned_issues_tests { iteration_title: None, iteration_start_date: None, iteration_due_date: None, + start_date: None, + due_date: None, }; assert!(iteration_overlaps_period( &row, @@ -3818,6 +4107,8 @@ mod assigned_issues_tests { provider_item_id: "g/p#7".to_string(), title: "Hello".to_string(), state: "opened".to_string(), + status_label: Some("To do".to_string()), + workflow_status: "todo".to_string(), closed_at: None, updated_at: Some("2026-04-03T09:00:00Z".to_string()), web_url: None, @@ -3830,6 +4121,8 @@ mod assigned_issues_tests { iteration_title: None, iteration_start_date: None, iteration_due_date: None, + start_date: None, + due_date: None, }); assert_eq!(snapshot.provider, "gitlab"); @@ -3852,7 +4145,7 @@ mod iteration_catalog_enrich_tests { use crate::domain::models::{ CachedIterationRecord, IssueComposerCapabilities, IssueDetailsCapabilities, IssueDetailsSnapshot, IssueIterationDetails, IssueMetadataCapability, IssueMetadataOption, - IssueReference, IssueTimeTrackingCapabilities, + IssueReference, IssueTimeTrackingCapabilities, ToneName, }; use super::enrich_and_dedupe_issue_iteration_options; @@ -3887,12 +4180,22 @@ mod iteration_catalog_enrich_tests { description: None, status: None, status_options: None, + project_name: None, + issue_type: None, + priority: None, + start_date: None, + due_date: None, + estimate: None, + weight: None, + participants: None, labels: Vec::new(), milestone_title: None, milestone: None, iteration, + parent_item: None, linked_items: None, child_items: None, + metadata_fields: None, activity: Vec::new(), activity_has_next_page: None, activity_next_page: None, @@ -3967,12 +4270,14 @@ mod iteration_catalog_enrich_tests { label: "Apr 20 - May 3, 2026".to_string(), color: None, badge: None, + tone: ToneName::Primary, }, IssueMetadataOption { id: "gid://gitlab/Iteration/10".to_string(), label: "Apr 20 - May 3, 2026".to_string(), color: None, badge: None, + tone: ToneName::Primary, }, ], None, @@ -4015,12 +4320,14 @@ mod iteration_catalog_enrich_tests { label: "Apr 20 - May 3, 2026".to_string(), color: None, badge: None, + tone: ToneName::Primary, }, IssueMetadataOption { id: "gid://gitlab/Iteration/2".to_string(), label: "Apr 20 - May 3, 2026".to_string(), color: None, badge: None, + tone: ToneName::Primary, }, ], None, @@ -4048,6 +4355,7 @@ mod iteration_catalog_enrich_tests { label: "Apr 6 - 19, 2026".to_string(), color: None, badge: None, + tone: ToneName::Primary, }], Some(IssueIterationDetails { id: "gid://gitlab/Iteration/7".to_string(), @@ -4087,12 +4395,14 @@ mod iteration_catalog_enrich_tests { label: "Custom".to_string(), color: None, badge: None, + tone: ToneName::Primary, }, IssueMetadataOption { id: "gid://gitlab/Iteration/2".to_string(), label: "Custom".to_string(), color: None, badge: None, + tone: ToneName::Primary, }, ], None, diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index 91e6a0eb..2d997346 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -1 +1,6 @@ pub mod gitlab; +#[path = "youtrack.rs"] +mod youtrack_provider; + +pub use gitlab::GitLabClient; +pub use youtrack_provider::{YouTrackClient, YouTrackUserWorkItem}; diff --git a/src-tauri/src/providers/youtrack.rs b/src-tauri/src/providers/youtrack.rs new file mode 100644 index 00000000..244d61d7 --- /dev/null +++ b/src-tauri/src/providers/youtrack.rs @@ -0,0 +1,2169 @@ +use reqwest::blocking::Client; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::error::Error; +use url::Url; +use urlencoding::encode; + +use crate::{ + domain::models::{ + AssignedIssueRecord, IssueActivityItem, IssueActivityPage, IssueActor, + IssueComposerCapabilities, IssueDetailsCapabilities, IssueDetailsSnapshot, + IssueIterationDetails, IssueMetadataCapability, IssueMetadataField, IssueMetadataOption, + IssueReference, IssueRelatedItem, IssueStatusOption, IssueTimeTrackingCapabilities, + UpdateIssueMetadataInput, + }, + error::AppError, +}; +use crate::support::badge_tone_mapper::BadgeToneMapper; + +pub struct YouTrackClient { + host: String, + token: String, + http: Client, +} + +#[derive(Clone, Debug)] +pub struct YouTrackUser { + pub username: String, + pub name: String, + pub avatar_url: Option, +} + +fn sanitize_url_for_error(url: &str) -> String { + Url::parse(url) + .map(|u| { + let mut cleaned = u.clone(); + cleaned.set_query(Some("")); + cleaned.to_string() + }) + .unwrap_or_else(|_| url.to_string()) +} + +fn collect_reqwest_kind_labels(error: &reqwest::Error) -> String { + let mut labels = Vec::new(); + if error.is_connect() { + labels.push("connect"); + } + if error.is_timeout() { + labels.push("timeout"); + } + if error.is_request() { + labels.push("request"); + } + if error.is_body() { + labels.push("body"); + } + if error.is_decode() { + labels.push("decode"); + } + if error.is_status() { + labels.push("status"); + } + if labels.is_empty() { + labels.push("unknown"); + } + labels.join(",") +} + +fn collect_error_source_chain(error: &reqwest::Error) -> String { + let mut chain = Vec::new(); + let mut current: Option<&(dyn Error + 'static)> = error.source(); + while let Some(e) = current { + chain.push(e.to_string()); + current = e.source(); + } + chain.join(" -> ") +} + +/// Formats a reqwest error into a readable diagnostic string with: +/// - operation context +/// - sanitized URL (no token query params) +/// - reqwest kind flags (connect, timeout, etc.) +/// - full std::error::Error source chain +fn format_youtrack_request_error(operation: &str, url: &str, error: &reqwest::Error) -> String { + let sanitized = sanitize_url_for_error(url); + let kind_labels = collect_reqwest_kind_labels(error); + let source_chain = collect_error_source_chain(error); + + let mut msg = + format!("YouTrack request failed: {operation} | URL: {sanitized} | kind={kind_labels}",); + if !source_chain.is_empty() { + msg.push_str(&format!(" | caused by: {source_chain}")); + } + msg +} + +/// Execute a reqwest request with up to 3 retries on transient errors (timeout/connect). +/// Retries use exponential backoff: 2s, 4s, 8s. Progress is reported via on_progress. +fn execute_youtrack_request( + operation: &str, + url_str: &str, + req: reqwest::blocking::RequestBuilder, + mut on_progress: Option<&mut dyn FnMut(String)>, +) -> Result { + const MAX_RETRIES: usize = 3; + let backoff_secs = [2u64, 4, 8]; + let mut attempt = 0usize; + + loop { + let result = { + let cloned_req = req.try_clone(); + match cloned_req { + Some(cloned) => cloned.send(), + None => { + let outcome = req.send(); + match outcome { + Ok(resp) => return Ok(resp), + Err(error) => { + return Err(AppError::ProviderApi(format_youtrack_request_error( + operation, url_str, &error, + ))); + } + } + } + } + }; + + match result { + Ok(resp) => return Ok(resp), + Err(error) => { + let is_retryable = error.is_timeout() || error.is_connect(); + if !is_retryable || attempt == MAX_RETRIES { + return Err(AppError::ProviderApi(format_youtrack_request_error( + operation, url_str, &error, + ))); + } + + let delay = backoff_secs[attempt.saturating_sub(1)]; + if let Some(ref mut cb) = on_progress { + cb(format!( + "YouTrack request timed out, retrying ({}/{}) in {}s...", + attempt + 1, + MAX_RETRIES + 1, + delay + )); + } + std::thread::sleep(std::time::Duration::from_secs(delay)); + attempt += 1; + } + } + } +} + +#[derive(Clone, Debug)] +pub struct YouTrackWorkItem { + pub id: String, + pub spent_at: String, + pub uploaded_at: Option, + pub duration_minutes: i64, +} + +#[derive(Clone, Debug)] +pub struct YouTrackUserWorkItem { + pub work_item: YouTrackWorkItem, + pub issue: Option, +} + +impl YouTrackUserWorkItem { + pub fn from_parts( + id: String, + spent_at: String, + uploaded_at: Option, + duration_minutes: i64, + issue: Option, + ) -> Self { + Self { + work_item: YouTrackWorkItem { + id, + spent_at, + uploaded_at, + duration_minutes, + }, + issue, + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct YouTrackIssue { + id: String, + id_readable: String, + summary: Option, + description: Option, + created: Option, + updated: Option, + resolved: Option, + project: Option, + tags: Option>, + comments: Option>, + custom_fields: Option>, + links: Option, +} + +#[derive(Deserialize)] +struct YouTrackTag { + name: String, +} + +#[derive(Deserialize)] +struct YouTrackProject { + name: Option, + #[serde(rename = "shortName")] + short_name: Option, +} + +#[derive(Clone, Debug, Deserialize)] +struct YouTrackAgile { + name: Option, +} + +#[derive(Clone, Debug, Deserialize)] +struct YouTrackSprint { + id: Option, + name: Option, + start: Option, + finish: Option, + agile: Option, + archived: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct YouTrackComment { + id: String, + text: Option, + created: Option, + updated: Option, + author: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct YouTrackAuthor { + login: Option, + full_name: Option, + avatar_url: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct YouTrackCustomField { + name: Option, + value: Option, +} + +#[derive(Deserialize)] +struct YouTrackCreatedItem { + id: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct YouTrackWorkItemJson { + id: String, + date: Option, + created: Option, + duration: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct YouTrackUserWorkItemJson { + id: String, + date: Option, + created: Option, + duration: Option, + issue: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct YouTrackDuration { + minutes: Option, +} + +#[derive(Clone, Debug, Default)] +struct YouTrackIterationMeta { + id: Option, + title: Option, + start_date: Option, + due_date: Option, + cadence_title: Option, +} + +#[derive(Clone, Debug, Default)] +struct YouTrackCommonMetadata { + project_name: Option, + issue_type: Option, + priority: Option, + start_date: Option, + due_date: Option, + estimate: Option, + metadata_fields: Vec, +} + +impl YouTrackClient { + pub fn new(host: &str, token: &str) -> Result { + if host.trim().is_empty() { + return Err(AppError::ProviderApi( + "YouTrack host is required".to_string(), + )); + } + if token.trim().is_empty() { + return Err(AppError::ProviderApi( + "YouTrack token is required".to_string(), + )); + } + let base_url = if host.starts_with("http://") || host.starts_with("https://") { + host.trim_end_matches('/').to_string() + } else { + format!("https://{}", host.trim_end_matches('/')) + }; + let http = Client::builder().build()?; + Ok(Self { + host: base_url, + token: token.to_string(), + http, + }) + } + + fn fetch_issues_for_query_paged( + &self, + query: &str, + fields: &str, + ) -> Result, AppError> { + const PAGE: u32 = 100; + const MAX_PAGES: u32 = 100; + let mut out = Vec::new(); + let mut skip = 0u32; + for _ in 0..MAX_PAGES { + let top = PAGE.to_string(); + let skip_value = skip.to_string(); + let mut url = Url::parse(&format!("{}/api/issues", self.base_url()))?; + url.query_pairs_mut() + .append_pair("query", query) + .append_pair("$top", top.as_str()) + .append_pair("$skip", skip_value.as_str()) + .append_pair("fields", fields); + let url_str = url.to_string(); + let response = execute_youtrack_request( + "fetch assigned issues paged", + &url_str, + self.authorized(self.http.get(url)), + None, + )?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(AppError::ProviderApi(format!( + "YouTrack issues query failed with status {status}: {body}" + ))); + } + let body = response.text()?; + let batch = match serde_json::from_str::>(&body) { + Ok(batch) => batch, + Err(error) => { + return Err(AppError::ProviderApi(format!( + "YouTrack issues decode failed for query '{query}': {error}" + ))); + } + }; + let batch_len = batch.len(); + out.extend(batch); + if batch_len < PAGE as usize { + break; + } + skip = skip.saturating_add(PAGE); + } + Ok(out) + } + + pub fn fetch_user(&self) -> Result { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct MeResponse { + login: Option, + full_name: Option, + avatar_url: Option, + } + + let url = format!( + "{}/api/users/me?fields=login,fullName,avatarUrl", + self.base_url() + ); + let response = execute_youtrack_request( + "fetch user profile", + &url, + self.http + .get(&url) + .header("Authorization", format!("Bearer {}", self.token)) + .header("Accept", "application/json"), + None, + )?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(AppError::ProviderApi(format!( + "YouTrack token validation failed with status {status}: {body}" + ))); + } + + let me = response.json::()?; + let username = me.login.unwrap_or_else(|| "youtrack-user".to_string()); + Ok(YouTrackUser { + name: me.full_name.unwrap_or_else(|| username.clone()), + username, + avatar_url: me.avatar_url, + }) + } + + pub fn load_issue_details( + &self, + reference: &IssueReference, + ) -> Result { + let issue = self.fetch_issue(&reference.issue_id)?; + let work_items = self + .fetch_issue_work_items(&reference.issue_id) + .unwrap_or_default(); + Ok(self.map_issue_details(reference, issue, work_items)) + } + + pub fn create_issue_comment(&self, issue_id: &str, body: &str) -> Result { + let url = format!( + "{}/api/issues/{issue_id}/comments?fields=id", + self.base_url() + ); + let url_str = url.clone(); + let response = execute_youtrack_request( + "create issue comment", + &url_str, + self.authorized(self.http.post(url)) + .json(&json!({ "text": body })), + None, + )?; + if !response.status().is_success() { + return Err(AppError::ProviderApi(format!( + "YouTrack create comment failed with status {}", + response.status() + ))); + } + let payload = response.json::()?; + Ok(payload.id.unwrap_or_else(|| "created".to_string())) + } + + pub fn update_issue_comment( + &self, + issue_id: &str, + comment_id: &str, + body: &str, + ) -> Result<(), AppError> { + let update_url = format!( + "{}/api/issues/{issue_id}/comments/{comment_id}?fields=id", + self.base_url() + ); + let url_str = update_url.clone(); + let response = execute_youtrack_request( + "update issue comment", + &url_str, + self.authorized(self.http.post(update_url)) + .json(&json!({ "text": body })), + None, + )?; + if response.status().is_success() { + return Ok(()); + } + + Err(AppError::ProviderApi(format!( + "YouTrack update comment failed with status {}", + response.status() + ))) + } + + pub fn delete_issue_comment(&self, issue_id: &str, comment_id: &str) -> Result<(), AppError> { + let url = format!( + "{}/api/issues/{issue_id}/comments/{comment_id}", + self.base_url() + ); + let url_str = url.clone(); + let response = execute_youtrack_request( + "delete issue comment", + &url_str, + self.authorized(self.http.delete(url)), + None, + )?; + if response.status().is_success() || response.status().as_u16() == 404 { + return Ok(()); + } + Err(AppError::ProviderApi(format!( + "YouTrack delete comment failed with status {}", + response.status() + ))) + } + + pub fn load_issue_activity_page( + &self, + reference: &IssueReference, + page: u32, + per_page: u32, + ) -> Result { + let skip = page.saturating_sub(1) * per_page; + let url = format!( + "{}/api/issues/{}/comments?$top={}&$skip={}&fields=id,text,created,updated,author(login,fullName,avatarUrl)", + self.base_url(), + reference.issue_id, + per_page, + skip + ); + let url_str = url.clone(); + let response = execute_youtrack_request( + "fetch issue activity", + &url_str, + self.authorized(self.http.get(url)), + None, + )?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(AppError::ProviderApi(format!( + "YouTrack activity load failed with status {status}: {body}" + ))); + } + let comments = response.json::>()?; + let items = comments + .iter() + .map(|comment| IssueActivityItem { + id: comment.id.clone(), + kind: "comment".to_string(), + body: comment.text.clone().unwrap_or_default(), + created_at: comment + .created + .map(to_iso) + .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()), + updated_at: comment.updated.map(to_iso), + system: false, + author: comment.author.as_ref().map(map_author), + }) + .collect::>(); + + Ok(IssueActivityPage { + has_next_page: comments.len() as u32 == per_page, + next_page: if comments.len() as u32 == per_page { + Some(page + 1) + } else { + None + }, + items, + }) + } + + pub fn log_issue_time( + &self, + issue_id: &str, + time_spent: &str, + summary: Option<&str>, + ) -> Result { + let query = log_time_command(time_spent, summary); + let url = format!("{}/api/commands?fields=id", self.base_url()); + let url_str = url.clone(); + let response = execute_youtrack_request( + "log issue time", + &url_str, + self.authorized(self.http.post(url)).json(&json!({ + "query": query, + "issues": [{ "idReadable": issue_id }], + })), + None, + )?; + if !response.status().is_success() { + return Err(AppError::ProviderApi(format!( + "YouTrack log time failed with status {}", + response.status() + ))); + } + Ok("Time logged in YouTrack.".to_string()) + } + + pub fn update_issue_metadata( + &self, + input: &UpdateIssueMetadataInput, + ) -> Result { + let issue_id = &input.reference.issue_id; + if let Some(description) = input.description.as_ref() { + let url = format!( + "{}/api/issues/{issue_id}?fields=id,idReadable", + self.base_url() + ); + let url_str = url.clone(); + let response = execute_youtrack_request( + "update issue description", + &url_str, + self.authorized(self.http.post(url)) + .json(&json!({ "description": description })), + None, + )?; + if !response.status().is_success() { + return Err(AppError::ProviderApi(format!( + "YouTrack description update failed with status {}", + response.status() + ))); + } + } + + if let Some(labels) = input.labels.as_ref() { + let tags: Vec<_> = labels.iter().map(|name| json!({ "name": name })).collect(); + let url = format!( + "{}/api/issues/{issue_id}?fields=id,idReadable", + self.base_url() + ); + let url_str = url.clone(); + let response = execute_youtrack_request( + "update issue labels", + &url_str, + self.authorized(self.http.post(url)) + .json(&json!({ "tags": tags })), + None, + )?; + if !response.status().is_success() { + return Err(AppError::ProviderApi(format!( + "YouTrack labels update failed with status {}", + response.status() + ))); + } + } + + if let Some(state) = input.state.as_ref() { + self.run_command(issue_id, &format!("State {state}"))?; + } + + self.load_issue_details(&input.reference) + } + + pub fn fetch_open_assigned_issues(&self) -> Result, AppError> { + let issues = self + .fetch_issues_for_query_paged(open_assigned_issues_query(), assigned_issue_fields())?; + Ok(issues + .into_iter() + .map(|issue| self.to_assigned_issue_record(issue)) + .collect()) + } + + pub fn fetch_recent_closed_assigned_issues( + &self, + cutoff_date: &str, + ) -> Result, AppError> { + let query = recent_closed_assigned_issues_query(cutoff_date); + let issues = self.fetch_issues_for_query_paged(&query, assigned_issue_fields())?; + Ok(issues + .into_iter() + .map(|issue| self.to_assigned_issue_record(issue)) + .collect()) + } + + pub fn fetch_all_closed_assigned_issues(&self) -> Result, AppError> { + let issues = self.fetch_issues_for_query_paged( + all_closed_assigned_issues_query(), + assigned_issue_fields(), + )?; + Ok(issues + .into_iter() + .map(|issue| self.to_assigned_issue_record(issue)) + .collect()) + } + + pub fn fetch_user_work_items( + &self, + start_date: &str, + end_date: &str, + ) -> Result, AppError> { + const PAGE: u32 = 100; + const MAX_PAGES: u32 = 100; + let mut out = Vec::new(); + let mut skip = 0u32; + for _ in 0..MAX_PAGES { + let url = self.user_work_items_url(start_date, end_date, PAGE, skip)?; + let url_str = url.to_string(); + let response = execute_youtrack_request( + "fetch user work items", + &url_str, + self.authorized(self.http.get(url)), + None, + )?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(AppError::ProviderApi(format!( + "YouTrack user work items fetch failed with status {status}: {body}" + ))); + } + + let body = response.text()?; + let rows = match serde_json::from_str::>(&body) { + Ok(rows) => rows, + Err(error) => { + return Err(AppError::ProviderApi(format!( + "YouTrack user work items decode failed: {error}" + ))); + } + }; + let batch_len = rows.len(); + out.extend( + rows.into_iter() + .filter_map(|row| self.map_user_work_item_row(row)), + ); + if batch_len < PAGE as usize { + break; + } + skip = skip.saturating_add(PAGE); + } + Ok(out) + } + + fn fetch_issue_sprints(&self, issue_id: &str) -> Result, AppError> { + let encoded_issue_id = encode(issue_id); + let mut url = Url::parse(&format!( + "{}/api/issues/{encoded_issue_id}/sprints", + self.base_url() + ))?; + url.query_pairs_mut() + .append_pair("$top", "42") + .append_pair("fields", "id,name,start,finish,agile(id,name),archived"); + let url_str = url.to_string(); + let response = execute_youtrack_request( + "fetch issue sprints", + &url_str, + self.authorized(self.http.get(url)), + None, + )?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(AppError::ProviderApi(format!( + "YouTrack issue sprints fetch failed for '{issue_id}' with status {status}: {body}" + ))); + } + + Ok(response.json::>()?) + } + + pub fn fetch_issue_work_items( + &self, + issue_id: &str, + ) -> Result, AppError> { + const PAGE: u32 = 100; + const MAX_PAGES: u32 = 50; + let mut out = Vec::new(); + let mut skip = 0u32; + for _ in 0..MAX_PAGES { + let url = format!( + "{}/api/issues/{issue_id}/timeTracking/workItems?$top={PAGE}&$skip={skip}&fields=id,date,created,duration(minutes)", + self.base_url() + ); + let url_str = url.clone(); + let response = execute_youtrack_request( + "fetch issue work items", + &url_str, + self.authorized(self.http.get(url)), + None, + )?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(AppError::ProviderApi(format!( + "YouTrack work items fetch failed with status {status}: {body}" + ))); + } + + let body = response.text()?; + + let rows = match serde_json::from_str::>(&body) { + Ok(rows) => rows, + Err(error) => { + return Err(AppError::ProviderApi(format!( + "YouTrack work items decode failed for issue '{issue_id}': {error}" + ))); + } + }; + let mapped: Vec = rows + .into_iter() + .filter_map(|row| { + let minutes = row.duration.and_then(|d| d.minutes).unwrap_or(0); + if minutes <= 0 { + return None; + } + let spent_at = row.date.map(to_iso_date).unwrap_or_else(|| { + chrono::Utc::now() + .date_naive() + .format("%Y-%m-%d") + .to_string() + }); + Some(YouTrackWorkItem { + id: row.id, + spent_at, + uploaded_at: row.created.map(to_iso), + duration_minutes: minutes, + }) + }) + .collect(); + let batch_len = mapped.len(); + out.extend(mapped); + if batch_len < PAGE as usize { + break; + } + skip = skip.saturating_add(PAGE); + } + Ok(out) + } + + fn user_work_items_url( + &self, + start_date: &str, + end_date: &str, + top: u32, + skip: u32, + ) -> Result { + let mut url = Url::parse(&format!("{}/api/workItems", self.base_url()))?; + url.query_pairs_mut() + .append_pair("$top", top.to_string().as_str()) + .append_pair("$skip", skip.to_string().as_str()) + .append_pair("author", "me") + .append_pair("startDate", start_date) + .append_pair("endDate", end_date) + .append_pair("fields", user_work_item_fields()); + Ok(url) + } + + fn map_user_work_item_row( + &self, + row: YouTrackUserWorkItemJson, + ) -> Option { + let minutes = row + .duration + .and_then(|duration| duration.minutes) + .unwrap_or(0); + if minutes <= 0 { + return None; + } + let spent_at = row.date.map(to_iso_date).unwrap_or_else(|| { + chrono::Utc::now() + .date_naive() + .format("%Y-%m-%d") + .to_string() + }); + Some(YouTrackUserWorkItem::from_parts( + row.id, + spent_at, + row.created.map(to_iso), + minutes, + row.issue.map(|issue| self.to_assigned_issue_record(issue)), + )) + } + + fn fetch_issue(&self, issue_id: &str) -> Result { + let url = format!( + "{}/api/issues/{issue_id}?fields=id,idReadable,summary,description,created,updated,resolved,project(name,shortName),tags(name),comments(id,text,created,updated,author(login,fullName,avatarUrl)),customFields(id,name,value(id,name,text,login,fullName,start,finish,minutes,presentation)),links(direction,linkType(name,directed,aggregation),issues(id,idReadable,summary,customFields(name,value(name,text))))", + self.base_url() + ); + let url_str = url.clone(); + let response = execute_youtrack_request( + "fetch issue details", + &url_str, + self.authorized(self.http.get(url)), + None, + )?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(AppError::ProviderApi(format!( + "YouTrack issue load failed with status {status}: {body}" + ))); + } + Ok(response.json::()?) + } + + fn map_issue_details( + &self, + reference: &IssueReference, + issue: YouTrackIssue, + work_items: Vec, + ) -> IssueDetailsSnapshot { + let issue_id_for_url = issue.id_readable.clone(); + let state = resolve_state(issue.custom_fields.as_deref()); + let iteration = extract_iteration_meta(issue.custom_fields.as_deref()); + let milestone = extract_milestone_meta(issue.custom_fields.as_deref()); + let common_metadata = extract_common_metadata(issue.custom_fields.as_deref()); + let project_name = issue + .project + .as_ref() + .and_then(|project| project.name.clone().or_else(|| project.short_name.clone())) + .or(common_metadata.project_name.clone()); + let (linked_items, child_items, parent_item) = extract_related_items(issue.links.as_ref()); + let labels = issue + .tags + .unwrap_or_default() + .into_iter() + .map(|tag| { + let tone = BadgeToneMapper::new().map_label(&tag.name); + IssueMetadataOption { + id: tag.name.clone(), + label: tag.name, + color: None, + badge: None, + tone, + } + }) + .collect::>(); + let activity = issue + .comments + .unwrap_or_default() + .into_iter() + .map(|comment| IssueActivityItem { + id: comment.id, + kind: "comment".to_string(), + body: comment.text.unwrap_or_default(), + created_at: comment + .created + .map(to_iso) + .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()), + updated_at: comment.updated.map(to_iso), + system: false, + author: comment.author.as_ref().map(map_author), + }) + .collect::>(); + + let status_options = build_status_options(issue.custom_fields.as_deref()); + let total_minutes = work_items + .iter() + .map(|work_item| work_item.duration_minutes) + .sum::(); + let total_time_spent = format_total_time_spent(total_minutes); + let iteration_id = iteration.id.clone().or_else(|| { + iteration + .title + .clone() + .map(|title| format!("yt-iter:{title}")) + }); + let iteration_option = + iteration + .title + .clone() + .zip(iteration_id.clone()) + .map(|(label, id)| IssueMetadataOption { + id, + label: label.clone(), + color: None, + badge: None, + tone: BadgeToneMapper::new().map_label(&label), + }); + let milestone_option = milestone.clone().map(|label| IssueMetadataOption { + id: format!("yt-ms:{label}"), + label: label.clone(), + color: None, + badge: None, + tone: BadgeToneMapper::new().map_label(&label), + }); + + IssueDetailsSnapshot { + reference: reference.clone(), + key: issue.id_readable.clone(), + title: issue.summary.unwrap_or_else(|| issue.id_readable.clone()), + state: state.clone(), + author: None, + created_at: issue.created.map(to_iso), + updated_at: issue.updated.map(to_iso), + web_url: Some(format!("{}/issue/{}", self.base_url(), issue_id_for_url)), + total_time_spent, + description: issue.description, + status: Some(IssueStatusOption { + id: state.clone(), + label: state.clone(), + color: None, + icon: None, + tone: BadgeToneMapper::new().map_status(&state), + }), + status_options: Some(status_options.clone()), + project_name, + issue_type: common_metadata.issue_type, + priority: common_metadata.priority, + start_date: common_metadata.start_date, + due_date: common_metadata.due_date, + estimate: common_metadata.estimate, + weight: None, + participants: None, + labels: labels.clone(), + milestone_title: milestone.clone(), + milestone: milestone_option.clone(), + iteration: iteration.title.clone().map(|label| IssueIterationDetails { + id: iteration_id.unwrap_or_else(|| format!("yt-iter:{label}")), + label, + start_date: iteration.start_date.clone(), + due_date: iteration.due_date.clone(), + web_url: None, + }), + parent_item, + linked_items: Some(linked_items), + child_items: Some(child_items), + metadata_fields: (!common_metadata.metadata_fields.is_empty()) + .then_some(common_metadata.metadata_fields), + activity, + activity_has_next_page: Some(false), + activity_next_page: None, + viewer_username: None, + issue_etag: None, + capabilities: IssueDetailsCapabilities { + status: IssueMetadataCapability { + enabled: true, + reason: None, + options: status_options + .iter() + .map(|status| IssueMetadataOption { + id: status.id.clone(), + label: status.label.clone(), + color: status.color.clone(), + badge: None, + tone: BadgeToneMapper::new().map_status(&status.label), + }) + .collect(), + }, + labels: IssueMetadataCapability { + enabled: true, + reason: None, + options: labels, + }, + iteration: IssueMetadataCapability { + enabled: false, + reason: Some( + "Iteration editing is not available for YouTrack in this version." + .to_string(), + ), + options: iteration_option.into_iter().collect(), + }, + milestone: IssueMetadataCapability { + enabled: false, + reason: Some( + "Milestone editing is not available for YouTrack in this version." + .to_string(), + ), + options: milestone_option.into_iter().collect(), + }, + composer: IssueComposerCapabilities { + enabled: true, + modes: vec!["write".to_string(), "preview".to_string()], + supports_quick_actions: false, + }, + time_tracking: IssueTimeTrackingCapabilities { + enabled: true, + supports_quick_actions: false, + }, + }, + } + } + + fn run_command(&self, issue_id: &str, query: &str) -> Result<(), AppError> { + let url = format!("{}/api/commands?fields=id", self.base_url()); + let url_str = url.clone(); + let response = execute_youtrack_request( + "run command", + &url_str, + self.authorized(self.http.post(url)).json(&json!({ + "query": query, + "issues": [{ "idReadable": issue_id }], + })), + None, + )?; + if !response.status().is_success() { + return Err(AppError::ProviderApi(format!( + "YouTrack command '{}' failed with status {}", + query, + response.status() + ))); + } + Ok(()) + } + + fn base_url(&self) -> &str { + &self.host + } + + fn to_assigned_issue_record(&self, issue: YouTrackIssue) -> AssignedIssueRecord { + let iteration = self.enrich_assigned_iteration_meta( + &issue.id_readable, + extract_iteration_meta(issue.custom_fields.as_deref()), + ); + let state = resolve_state(issue.custom_fields.as_deref()); + let common_metadata = extract_common_metadata(issue.custom_fields.as_deref()); + AssignedIssueRecord { + issue_graphql_id: issue.id.clone(), + provider_item_id: issue.id_readable.clone(), + title: issue.summary.unwrap_or_else(|| issue.id_readable.clone()), + state: state.clone(), + status_label: Some(state.clone()), + workflow_status: normalize_workflow_status(&state, issue.resolved.is_some()), + closed_at: issue.resolved.map(to_iso), + updated_at: issue.updated.map(to_iso), + web_url: Some(format!("{}/issue/{}", self.base_url(), issue.id_readable)), + labels: issue + .tags + .unwrap_or_default() + .into_iter() + .map(|tag| tag.name) + .collect(), + milestone_title: None, + iteration_gitlab_id: iteration.id, + iteration_group_id: None, + iteration_cadence_id: None, + iteration_cadence_title: iteration.cadence_title, + iteration_title: iteration.title, + iteration_start_date: iteration.start_date, + iteration_due_date: iteration.due_date, + start_date: common_metadata.start_date, + due_date: common_metadata.due_date, + } + } + + fn enrich_assigned_iteration_meta( + &self, + issue_id: &str, + iteration: YouTrackIterationMeta, + ) -> YouTrackIterationMeta { + if iteration_has_dates(&iteration) || (iteration.id.is_none() && iteration.title.is_none()) + { + return iteration; + } + + enrich_iteration_meta_with_sprints(iteration, self.fetch_issue_sprints(issue_id)) + } + + fn authorized( + &self, + req: reqwest::blocking::RequestBuilder, + ) -> reqwest::blocking::RequestBuilder { + req.header("Authorization", format!("Bearer {}", self.token)) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + } +} + +fn to_iso(timestamp_ms: i64) -> String { + chrono::DateTime::from_timestamp_millis(timestamp_ms) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()) +} + +fn open_assigned_issues_query() -> &'static str { + "for: me #Unresolved" +} + +fn recent_closed_assigned_issues_query(cutoff_date: &str) -> String { + format!("for: me #Resolved resolved date: {cutoff_date} .. Today") +} + +fn all_closed_assigned_issues_query() -> &'static str { + "for: me #Resolved sort by: {resolved date} desc" +} + +fn assigned_issue_fields() -> &'static str { + "id,idReadable,summary,updated,resolved,tags(name),customFields(id,name,value(id,name,text,login,fullName,start,finish,minutes,presentation))" +} + +fn user_work_item_fields() -> &'static str { + "id,date,created,updated,duration(minutes),issue(id,idReadable,summary,updated,resolved,project(name,shortName),tags(name),customFields(id,name,value(id,name,text,login,fullName,start,finish,minutes,presentation)))" +} + +fn log_time_command(time_spent: &str, summary: Option<&str>) -> String { + match summary { + Some(text) if !text.trim().is_empty() => format!("add work {time_spent} {}", text.trim()), + _ => format!("add work {time_spent}"), + } +} + +fn to_iso_date(timestamp_ms: i64) -> String { + chrono::DateTime::from_timestamp_millis(timestamp_ms) + .map(|dt| dt.date_naive().format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| { + chrono::Utc::now() + .date_naive() + .format("%Y-%m-%d") + .to_string() + }) +} + +fn map_author(author: &YouTrackAuthor) -> IssueActor { + IssueActor { + username: author.login.clone(), + name: author + .full_name + .clone() + .or_else(|| author.login.clone()) + .unwrap_or_else(|| "Unknown".to_string()), + avatar_url: author.avatar_url.clone(), + } +} + +fn resolve_state(fields: Option<&[YouTrackCustomField]>) -> String { + let Some(fields) = fields else { + return "Open".to_string(); + }; + fields + .iter() + .find(|field| field.name.as_deref() == Some("State")) + .and_then(|field| field.value.as_ref()) + .and_then(extract_youtrack_field_value_label) + .unwrap_or_else(|| "Open".to_string()) +} + +fn extract_youtrack_field_value_label(value: &Value) -> Option { + match value { + Value::Object(object) => object + .get("name") + .or_else(|| object.get("text")) + .and_then(|item| item.as_str()) + .map(ToOwned::to_owned), + Value::Array(items) => items.iter().find_map(extract_youtrack_field_value_label), + Value::String(text) => Some(text.clone()), + _ => None, + } +} + +fn build_status_options(fields: Option<&[YouTrackCustomField]>) -> Vec { + let current = resolve_state(fields); + let mut base = vec![ + "Open".to_string(), + "In Progress".to_string(), + "Done".to_string(), + "Fixed".to_string(), + ]; + if !base.iter().any(|item| item.eq_ignore_ascii_case(¤t)) { + base.push(current); + } + base.into_iter() + .map(|label| IssueStatusOption { + id: label.clone(), + label: label.clone(), + color: None, + icon: None, + tone: BadgeToneMapper::new().map_status(&label), + }) + .collect() +} + +fn normalize_workflow_status(status: &str, is_resolved: bool) -> String { + if is_resolved { + return "done".to_string(); + } + + let normalized = status.trim().to_ascii_lowercase().replace(['_', '-'], " "); + if normalized.contains("block") || normalized.contains("stuck") { + return "blocked".to_string(); + } + if matches!( + normalized.as_str(), + "done" | "fixed" | "resolved" | "complete" | "completed" + ) { + return "done".to_string(); + } + if matches!( + normalized.as_str(), + "in progress" | "in review" | "review" | "wip" | "doing" + ) { + return "doing".to_string(); + } + if matches!( + normalized.as_str(), + "open" | "submitted" | "to do" | "todo" | "backlog" | "ready" + ) { + return "todo".to_string(); + } + if normalized.is_empty() { + return "todo".to_string(); + } + "other".to_string() +} + +fn extract_iteration_meta(fields: Option<&[YouTrackCustomField]>) -> YouTrackIterationMeta { + let Some(fields) = fields else { + return YouTrackIterationMeta::default(); + }; + + let Some(raw) = fields + .iter() + .find(|field| { + field + .name + .as_deref() + .map(|name| { + let lower = name.to_ascii_lowercase(); + lower.contains("sprint") || lower.contains("iteration") + }) + .unwrap_or(false) + }) + .and_then(|field| field.value.as_ref()) + else { + return YouTrackIterationMeta::default(); + }; + + let raw = match raw { + Value::Array(items) => items.first().unwrap_or(raw), + _ => raw, + }; + + if let Value::Object(object) = raw { + let id = object + .get("id") + .and_then(|value| value.as_str()) + .map(format_youtrack_sprint_id); + let title = object + .get("name") + .or_else(|| object.get("text")) + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned); + + let start_date = object + .get("start") + .or_else(|| object.get("startDate")) + .and_then(|value| value.as_i64()) + .map(to_iso_date); + let due_date = object + .get("finish") + .or_else(|| object.get("dueDate")) + .and_then(|value| value.as_i64()) + .map(to_iso_date); + + return YouTrackIterationMeta { + id, + title, + start_date, + due_date, + cadence_title: None, + }; + } + + YouTrackIterationMeta::default() +} + +fn iteration_has_dates(iteration: &YouTrackIterationMeta) -> bool { + iteration.start_date.is_some() && iteration.due_date.is_some() +} + +fn enrich_iteration_meta_with_sprints( + iteration: YouTrackIterationMeta, + sprints: Result, AppError>, +) -> YouTrackIterationMeta { + let Ok(sprints) = sprints else { + return iteration; + }; + let Some(sprint) = pick_youtrack_sprint(&sprints) else { + return iteration; + }; + let sprint_meta = iteration_meta_from_sprint(sprint); + + YouTrackIterationMeta { + id: sprint_meta.id.or(iteration.id), + title: sprint_meta.title.or(iteration.title), + start_date: sprint_meta.start_date.or(iteration.start_date), + due_date: sprint_meta.due_date.or(iteration.due_date), + cadence_title: sprint_meta.cadence_title.or(iteration.cadence_title), + } +} + +fn pick_youtrack_sprint(sprints: &[YouTrackSprint]) -> Option<&YouTrackSprint> { + sprints + .iter() + .find(|sprint| { + sprint.archived != Some(true) && sprint.start.is_some() && sprint.finish.is_some() + }) + .or_else(|| { + sprints + .iter() + .find(|sprint| sprint.start.is_some() && sprint.finish.is_some()) + }) +} + +fn iteration_meta_from_sprint(sprint: &YouTrackSprint) -> YouTrackIterationMeta { + YouTrackIterationMeta { + id: sprint.id.as_deref().map(format_youtrack_sprint_id), + title: sprint.name.clone(), + start_date: sprint.start.map(to_iso_date), + due_date: sprint.finish.map(to_iso_date), + cadence_title: sprint.agile.as_ref().and_then(|agile| agile.name.clone()), + } +} + +fn format_youtrack_sprint_id(id: &str) -> String { + format!("youtrack:sprint:{id}") +} + +fn extract_common_metadata(fields: Option<&[YouTrackCustomField]>) -> YouTrackCommonMetadata { + let Some(fields) = fields else { + return YouTrackCommonMetadata::default(); + }; + let mut metadata = YouTrackCommonMetadata::default(); + + for field in fields { + let Some(name) = field.name.as_deref() else { + continue; + }; + let lower = name.to_ascii_lowercase(); + let value = field.value.as_ref().and_then(format_youtrack_field_value); + + if lower == "project" { + metadata.project_name = value; + continue; + } + if lower == "type" { + metadata.issue_type = value; + continue; + } + if lower == "priority" { + metadata.priority = value; + continue; + } + if lower == "estimation" || lower == "estimate" { + metadata.estimate = value; + continue; + } + if lower == "start date" || lower == "start" { + metadata.start_date = field.value.as_ref().and_then(extract_youtrack_date_value); + continue; + } + if lower == "due date" || lower == "due" { + metadata.due_date = field.value.as_ref().and_then(extract_youtrack_date_value); + continue; + } + + if is_known_youtrack_field(&lower) { + continue; + } + + if let Some(value) = value.filter(|value| !value.trim().is_empty()) { + metadata.metadata_fields.push(IssueMetadataField { + id: name.to_string(), + label: name.to_string(), + value, + }); + } + } + + metadata +} + +fn is_known_youtrack_field(lower: &str) -> bool { + lower == "state" + || lower.contains("sprint") + || lower.contains("iteration") + || lower.contains("milestone") + || lower.contains("fix version") + || lower == "assignee" +} + +fn extract_milestone_meta(fields: Option<&[YouTrackCustomField]>) -> Option { + let fields = fields?; + fields + .iter() + .find(|field| { + field + .name + .as_deref() + .map(|name| { + let lower = name.to_ascii_lowercase(); + lower.contains("milestone") || lower.contains("fix version") + }) + .unwrap_or(false) + }) + .and_then(|field| field.value.as_ref()) + .and_then(extract_youtrack_field_value_label) +} + +fn format_youtrack_field_value(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) => Some(text.clone()), + Value::Number(number) => Some(number.to_string()), + Value::Bool(value) => Some(value.to_string()), + Value::Array(items) => { + let values = items + .iter() + .filter_map(format_youtrack_field_value) + .filter(|value| !value.trim().is_empty()) + .collect::>(); + (!values.is_empty()).then(|| values.join(", ")) + } + Value::Object(object) => object + .get("presentation") + .or_else(|| object.get("name")) + .or_else(|| object.get("fullName")) + .or_else(|| object.get("login")) + .or_else(|| object.get("text")) + .and_then(|value| value.as_str()) + .map(str::to_string) + .or_else(|| { + object + .get("minutes") + .and_then(|value| value.as_i64()) + .map(format_minutes) + }), + } +} + +fn extract_youtrack_date_value(value: &Value) -> Option { + match value { + Value::Number(number) => number.as_i64().map(to_iso_date), + Value::Object(object) => object + .get("start") + .or_else(|| object.get("startDate")) + .or_else(|| object.get("finish")) + .or_else(|| object.get("dueDate")) + .and_then(|value| value.as_i64()) + .map(to_iso_date), + _ => None, + } +} + +fn format_minutes(minutes: i64) -> String { + if minutes <= 0 { + return "0m".to_string(); + } + let hours = minutes / 60; + let remainder = minutes % 60; + if hours > 0 && remainder > 0 { + return format!("{hours}h {remainder}m"); + } + if hours > 0 { + return format!("{hours}h"); + } + format!("{remainder}m") +} + +fn format_total_time_spent(total_minutes: i64) -> Option { + if total_minutes <= 0 { + return None; + } + + let hours = total_minutes / 60; + let minutes = total_minutes % 60; + if hours > 0 && minutes > 0 { + return Some(format!("{hours}h {minutes}m")); + } + if hours > 0 { + return Some(format!("{hours}h")); + } + Some(format!("{minutes}m")) +} + +fn extract_related_items( + links: Option<&Value>, +) -> ( + Vec, + Vec, + Option, +) { + let mut linked_items = Vec::new(); + let mut child_items = Vec::new(); + let mut parent_item = None; + let Some(Value::Array(groups)) = links else { + return (linked_items, child_items, parent_item); + }; + + for group in groups { + let direction = group + .get("direction") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let link_type = group + .get("linkType") + .and_then(|item| item.get("name")) + .and_then(|value| value.as_str()) + .unwrap_or("Related"); + let (relation_label, relation_group) = normalize_youtrack_relation(link_type, direction); + + let Some(Value::Array(issues)) = group.get("issues") else { + continue; + }; + for issue in issues { + let Some(issue_id) = issue.get("idReadable").and_then(|value| value.as_str()) else { + continue; + }; + let title = issue + .get("summary") + .and_then(|value| value.as_str()) + .unwrap_or(issue_id) + .to_string(); + let state = issue + .get("customFields") + .and_then(|value| value.as_array()) + .map(|fields| { + let mapped = fields + .iter() + .map(|field| YouTrackCustomField { + name: field + .get("name") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned), + value: field.get("value").cloned(), + }) + .collect::>(); + resolve_state(Some(&mapped)) + }) + .unwrap_or_else(|| "Open".to_string()); + + let related = IssueRelatedItem { + reference: IssueReference { + provider: "youtrack".to_string(), + issue_id: issue_id.to_string(), + provider_issue_ref: issue_id.to_string(), + }, + key: issue_id.to_string(), + title, + relation_label: relation_label.clone(), + state, + web_url: None, + labels: vec![], + }; + + if relation_group == "parent" && parent_item.is_none() { + parent_item = Some(related); + } else if relation_group == "child" { + child_items.push(related); + } else { + linked_items.push(related); + } + } + } + + (linked_items, child_items, parent_item) +} + +fn normalize_youtrack_relation(link_type: &str, direction: &str) -> (String, &'static str) { + let link = link_type.to_ascii_lowercase(); + let direction = direction.to_ascii_lowercase(); + if link.contains("subtask") || direction.contains("subtask") || direction.contains("child") { + if direction.contains("parent") || direction.contains("of") { + return ("Parent".to_string(), "parent"); + } + return ("Child".to_string(), "child"); + } + if link.contains("depend") { + if direction.contains("inward") + || direction.contains("required") + || direction.contains("depend") + { + return ("Blocked by".to_string(), "linked"); + } + return ("Blocks".to_string(), "linked"); + } + if link.contains("duplicate") { + return ("Duplicate".to_string(), "linked"); + } + if link.contains("relate") { + return ("Related".to_string(), "linked"); + } + (link_type.to_string(), "linked") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state_field(name: &str) -> YouTrackCustomField { + YouTrackCustomField { + name: Some("State".to_string()), + value: Some(json!({ "name": name })), + } + } + + #[test] + fn resolve_state_uses_state_custom_field() { + let fields = vec![state_field("In Progress")]; + assert_eq!(resolve_state(Some(&fields)), "In Progress"); + } + + #[test] + fn resolve_state_accepts_documented_custom_field_value_shapes() { + let object_value = vec![YouTrackCustomField { + name: Some("State".to_string()), + value: Some(json!({ "name": "Fixed", "id": "69-7", "$type": "StateBundleElement" })), + }]; + assert_eq!(resolve_state(Some(&object_value)), "Fixed"); + + let array_value = vec![YouTrackCustomField { + name: Some("State".to_string()), + value: Some(json!([{ "name": "Done", "$type": "StateBundleElement" }])), + }]; + assert_eq!(resolve_state(Some(&array_value)), "Done"); + + let primitive_value = vec![YouTrackCustomField { + name: Some("State".to_string()), + value: Some(json!("Open")), + }]; + assert_eq!(resolve_state(Some(&primitive_value)), "Open"); + + let null_value = vec![YouTrackCustomField { + name: Some("State".to_string()), + value: Some(Value::Null), + }]; + assert_eq!(resolve_state(Some(&null_value)), "Open"); + } + + #[test] + fn build_status_options_includes_current_state() { + let fields = vec![state_field("Blocked")]; + let options = build_status_options(Some(&fields)); + assert!(options.iter().any(|option| option.id == "Blocked")); + } + + #[test] + fn to_iso_date_formats_calendar_date() { + // 2026-01-15T00:00:00Z in millis + let date = to_iso_date(1_768_435_200_000); + assert_eq!(date, "2026-01-15"); + } + + #[test] + fn client_normalizes_host_with_scheme() { + let client = YouTrackClient::new("https://company.youtrack.cloud/", "token").unwrap(); + assert_eq!(client.base_url(), "https://company.youtrack.cloud"); + } + + #[test] + fn assigned_issue_queries_use_youtrack_search_syntax() { + assert_eq!(open_assigned_issues_query(), "for: me #Unresolved"); + assert_eq!( + recent_closed_assigned_issues_query("2026-02-25"), + "for: me #Resolved resolved date: 2026-02-25 .. Today" + ); + assert_eq!( + all_closed_assigned_issues_query(), + "for: me #Resolved sort by: {resolved date} desc" + ); + } + + #[test] + fn assigned_issue_fields_use_safe_youtrack_custom_field_shape() { + let fields = assigned_issue_fields(); + + assert!(fields.contains("customFields(id,name,value(")); + assert!(fields.contains("start")); + assert!(fields.contains("finish")); + assert!(!fields.contains("startDate")); + assert!(!fields.contains("dueDate")); + } + + #[test] + fn log_time_command_uses_official_add_work_syntax() { + assert_eq!(log_time_command("1h", None), "add work 1h"); + assert_eq!( + log_time_command("2h", Some("pairing on provider sync")), + "add work 2h pairing on provider sync" + ); + assert_eq!(log_time_command("30m", Some(" ")), "add work 30m"); + } + + #[test] + fn sprint_fallback_adds_dates_when_custom_field_has_only_identity() { + let base = YouTrackIterationMeta { + id: Some("youtrack:sprint:73-2".to_string()), + title: Some("OPS Sprint 12".to_string()), + start_date: None, + due_date: None, + cadence_title: None, + }; + let sprints = vec![ + YouTrackSprint { + id: Some("73-1".to_string()), + name: Some("Archived sprint".to_string()), + start: Some(1_774_569_600_000), + finish: Some(1_775_779_200_000), + agile: Some(YouTrackAgile { + name: Some("OPS Board".to_string()), + }), + archived: Some(true), + }, + YouTrackSprint { + id: Some("73-2".to_string()), + name: Some("OPS Sprint 12".to_string()), + start: Some(1_775_779_200_000), + finish: Some(1_776_988_800_000), + agile: Some(YouTrackAgile { + name: Some("Delivery Board".to_string()), + }), + archived: Some(false), + }, + ]; + + let enriched = enrich_iteration_meta_with_sprints(base, Ok(sprints)); + + assert_eq!(enriched.id.as_deref(), Some("youtrack:sprint:73-2")); + assert_eq!(enriched.title.as_deref(), Some("OPS Sprint 12")); + assert_eq!(enriched.start_date.as_deref(), Some("2026-04-10")); + assert_eq!(enriched.due_date.as_deref(), Some("2026-04-24")); + assert_eq!(enriched.cadence_title.as_deref(), Some("Delivery Board")); + } + + #[test] + fn sprint_fallback_error_keeps_base_iteration_metadata() { + let base = YouTrackIterationMeta { + id: Some("youtrack:sprint:73-2".to_string()), + title: Some("OPS Sprint 12".to_string()), + start_date: None, + due_date: None, + cadence_title: None, + }; + + let enriched = enrich_iteration_meta_with_sprints( + base, + Err(AppError::ProviderApi("sprints unavailable".to_string())), + ); + + assert_eq!(enriched.id.as_deref(), Some("youtrack:sprint:73-2")); + assert_eq!(enriched.title.as_deref(), Some("OPS Sprint 12")); + assert_eq!(enriched.start_date, None); + assert_eq!(enriched.due_date, None); + } + + #[test] + fn sprint_fallback_without_dated_sprints_keeps_base_iteration_metadata() { + let base = YouTrackIterationMeta { + id: Some("youtrack:sprint:73-2".to_string()), + title: Some("OPS Sprint 12".to_string()), + start_date: None, + due_date: None, + cadence_title: None, + }; + let sprints = vec![YouTrackSprint { + id: Some("73-9".to_string()), + name: Some("Undated board sprint".to_string()), + start: None, + finish: None, + agile: Some(YouTrackAgile { + name: Some("Delivery Board".to_string()), + }), + archived: Some(false), + }]; + + let enriched = enrich_iteration_meta_with_sprints(base, Ok(sprints)); + + assert_eq!(enriched.id.as_deref(), Some("youtrack:sprint:73-2")); + assert_eq!(enriched.title.as_deref(), Some("OPS Sprint 12")); + assert_eq!(enriched.start_date, None); + assert_eq!(enriched.due_date, None); + assert_eq!(enriched.cadence_title, None); + } + + #[test] + fn assigned_record_maps_sprint_iteration_dates_from_custom_fields() { + let client = YouTrackClient::new("https://company.youtrack.cloud/", "token").unwrap(); + let record = client.to_assigned_issue_record(YouTrackIssue { + id: "2-17".to_string(), + id_readable: "OPS-17".to_string(), + summary: Some("Sprint-linked issue".to_string()), + description: None, + created: None, + updated: Some(1_776_297_600_000), // 2026-04-10T00:00:00Z + resolved: None, + project: None, + tags: Some(vec![]), + comments: Some(vec![]), + custom_fields: Some(vec![ + YouTrackCustomField { + name: Some("State".to_string()), + value: Some(json!({ "name": "In Progress" })), + }, + YouTrackCustomField { + name: Some("Sprint".to_string()), + value: Some(json!({ + "id": "73-2", + "name": "OPS Sprint 12", + "start": 1_775_779_200_000i64, + "finish": 1_776_988_800_000i64 + })), + }, + ]), + links: None, + }); + + assert_eq!( + record.iteration_gitlab_id.as_deref(), + Some("youtrack:sprint:73-2") + ); + assert_eq!(record.iteration_title.as_deref(), Some("OPS Sprint 12")); + assert_eq!(record.iteration_start_date.as_deref(), Some("2026-04-10")); + assert_eq!(record.iteration_due_date.as_deref(), Some("2026-04-24")); + assert_eq!(record.workflow_status, "doing"); + } + + #[test] + fn issue_details_map_youtrack_metadata_and_related_items() { + let client = YouTrackClient::new("https://company.youtrack.cloud/", "token").unwrap(); + let reference = IssueReference { + provider: "youtrack".to_string(), + issue_id: "OPS-17".to_string(), + provider_issue_ref: "OPS-17".to_string(), + }; + let details = client.map_issue_details( + &reference, + YouTrackIssue { + id: "2-17".to_string(), + id_readable: "OPS-17".to_string(), + summary: Some("Issue with metadata".to_string()), + description: None, + created: None, + updated: None, + resolved: None, + project: None, + tags: Some(vec![]), + comments: Some(vec![]), + custom_fields: Some(vec![ + YouTrackCustomField { + name: Some("State".to_string()), + value: Some(json!({ "name": "In Progress" })), + }, + YouTrackCustomField { + name: Some("Milestone".to_string()), + value: Some(json!({ "name": "Q2 Roadmap" })), + }, + YouTrackCustomField { + name: Some("Sprint".to_string()), + value: Some(json!({ + "id": "73-2", + "name": "OPS Sprint 12", + "start": 1_776_297_600_000i64, + "finish": 1_777_507_200_000i64 + })), + }, + ]), + links: Some(json!([ + { + "direction": "subtask of", + "issues": [ + { + "idReadable": "OPS-3", + "summary": "Child issue", + "customFields": [{ "name": "State", "value": { "name": "Open" } }] + } + ] + }, + { + "direction": "relates to", + "issues": [ + { + "idReadable": "OPS-20", + "summary": "Linked issue", + "customFields": [{ "name": "State", "value": { "name": "Done" } }] + } + ] + } + ])), + }, + vec![ + YouTrackWorkItem { + id: "wi-1".to_string(), + spent_at: "2026-04-10".to_string(), + uploaded_at: None, + duration_minutes: 90, + }, + YouTrackWorkItem { + id: "wi-2".to_string(), + spent_at: "2026-04-11".to_string(), + uploaded_at: None, + duration_minutes: 30, + }, + ], + ); + + assert_eq!(details.milestone_title.as_deref(), Some("Q2 Roadmap")); + assert_eq!( + details.iteration.as_ref().map(|item| item.label.as_str()), + Some("OPS Sprint 12") + ); + assert_eq!(details.total_time_spent.as_deref(), Some("2h")); + assert_eq!( + details.parent_item.as_ref().map(|item| item.key.as_str()), + Some("OPS-3") + ); + assert_eq!( + details.child_items.as_ref().map(|items| items.len()), + Some(0) + ); + assert_eq!( + details.linked_items.as_ref().map(|items| items.len()), + Some(1) + ); + } + + #[test] + fn issue_details_preserve_unknown_youtrack_custom_fields() { + let client = YouTrackClient::new("https://company.youtrack.cloud/", "token").unwrap(); + let reference = IssueReference { + provider: "youtrack".to_string(), + issue_id: "OPS-42".to_string(), + provider_issue_ref: "OPS-42".to_string(), + }; + let details = client.map_issue_details( + &reference, + YouTrackIssue { + id: "2-42".to_string(), + id_readable: "OPS-42".to_string(), + summary: Some("Custom metadata issue".to_string()), + description: None, + created: None, + updated: None, + resolved: None, + project: None, + tags: Some(vec![]), + comments: Some(vec![]), + custom_fields: Some(vec![ + YouTrackCustomField { + name: Some("State".to_string()), + value: Some(json!({ "name": "Open" })), + }, + YouTrackCustomField { + name: Some("nw-sx-categories".to_string()), + value: Some(json!([{ "name": "Platform" }, { "name": "Billing" }])), + }, + YouTrackCustomField { + name: Some("mw-sx-version".to_string()), + value: Some(json!({ "name": "2026.4" })), + }, + ]), + links: None, + }, + vec![], + ); + + let fields = details.metadata_fields.expect("metadata fields"); + assert_eq!(fields[0].label, "nw-sx-categories"); + assert_eq!(fields[0].value, "Platform, Billing"); + assert_eq!(fields[1].label, "mw-sx-version"); + assert_eq!(fields[1].value, "2026.4"); + } + + #[test] + fn format_youtrack_request_error_includes_operation_context() { + // Use localhost port 9 (nothing listening) to guarantee connection error + let client = YouTrackClient::new("http://localhost:9", "token").unwrap(); + let url = "http://127.0.0.1:9/api/issues"; + let err = client.http.get(url).send().unwrap_err(); + let error_msg = format_youtrack_request_error("fetch assigned issues", url, &err); + assert!(error_msg.contains("fetch assigned issues")); + assert!(error_msg.contains("127.0.0.1:9/api/issues")); + } + + #[test] + fn format_youtrack_request_error_sanitizes_url() { + let client = YouTrackClient::new("http://localhost:9", "token").unwrap(); + let url_with_token = "http://127.0.0.1:9/api/issues?token=secret123&fields=id"; + let err = client.http.get(url_with_token).send().unwrap_err(); + let error_msg = + format_youtrack_request_error("fetch assigned issues", url_with_token, &err); + assert!(!error_msg.contains("secret123")); + assert!(error_msg.contains("127.0.0.1:9/api/issues")); + } + + #[test] + fn collect_reqwest_kind_labels_reports_non_empty_labels() { + let fake_client = YouTrackClient::new("http://127.0.0.1:65535", "token").unwrap(); + let result = fake_client.http.get("https://example.invalid/").send(); + let err = result.unwrap_err(); + let labels = collect_reqwest_kind_labels(&err); + // Should produce at least one kind label + assert!(!labels.is_empty()); + } + + #[test] + fn collect_error_source_chain_does_not_panic() { + let fake_client = YouTrackClient::new("http://127.0.0.1:65535", "token").unwrap(); + let result = fake_client.http.get("https://example.invalid/").send(); + let err = result.unwrap_err(); + // Should not panic - just run the function + let chain = collect_error_source_chain(&err); + // Chain may be empty or contain error strings + drop(chain); + } + + #[test] + fn execute_youtrack_request_wraps_send_error_with_operation_context() { + let client = YouTrackClient::new("http://127.0.0.1:65535", "token").unwrap(); + let result = execute_youtrack_request( + "test operation", + "http://127.0.0.1:65535/api/test", + client.authorized(client.http.get("http://127.0.0.1:65535/api/test")), + None, + ); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("test operation")); + assert!(err_msg.contains("kind=")); + } + + #[test] + fn user_work_items_url_filters_by_current_author_and_work_item_date_window() { + let client = YouTrackClient::new("https://company.youtrack.cloud/", "token").unwrap(); + + let url = client + .user_work_items_url("2026-04-01", "2026-04-30", 100, 200) + .unwrap(); + let query = url.query().unwrap_or_default(); + + assert_eq!(url.path(), "/api/workItems"); + assert!(query.contains("author=me")); + assert!(query.contains("startDate=2026-04-01")); + assert!(query.contains("endDate=2026-04-30")); + assert!(query.contains("%24top=100")); + assert!(query.contains("%24skip=200")); + assert!(query.contains("duration%28minutes%29")); + assert!(query.contains("issue%28id%2CidReadable%2Csummary")); + } + + #[test] + fn user_work_item_mapping_uses_work_item_date_not_created_or_updated() { + let client = YouTrackClient::new("https://company.youtrack.cloud/", "token").unwrap(); + let row = serde_json::from_value::(json!({ + "id": "115-7", + "date": 1777507200000i64, + "created": 1777394761867i64, + "updated": 1777583075850i64, + "duration": { "minutes": 90 }, + "issue": { + "id": "2-12", + "idReadable": "IRPT-12", + "summary": "Investigate YouTrack totals", + "updated": 1777583075850i64, + "resolved": null, + "project": { "name": "IRPT", "shortName": "IRPT" }, + "tags": [{ "name": "support" }], + "customFields": [{ "name": "State", "value": { "name": "In Progress" } }] + } + })) + .unwrap(); + + let mapped = client.map_user_work_item_row(row).unwrap(); + + assert_eq!(mapped.work_item.id, "115-7"); + assert_eq!(mapped.work_item.spent_at, "2026-04-30"); + assert_eq!(mapped.work_item.duration_minutes, 90); + assert_eq!( + mapped.work_item.uploaded_at.as_deref(), + Some("2026-04-28T16:46:01.867+00:00") + ); + let issue = mapped.issue.unwrap(); + assert_eq!(issue.provider_item_id, "IRPT-12"); + assert_eq!(issue.title, "Investigate YouTrack totals"); + assert_eq!(issue.workflow_status, "doing"); + } + + #[test] + fn user_work_item_mapping_ignores_non_positive_durations() { + let client = YouTrackClient::new("https://company.youtrack.cloud/", "token").unwrap(); + let row = serde_json::from_value::(json!({ + "id": "115-8", + "date": 1777507200000i64, + "duration": { "minutes": 0 }, + "issue": { "id": "2-12", "idReadable": "IRPT-12" } + })) + .unwrap(); + + assert!(client.map_user_work_item_row(row).is_none()); + } +} diff --git a/src-tauri/src/services/assigned_issues.rs b/src-tauri/src/services/assigned_issues.rs index af9edade..8b89f5c8 100644 --- a/src-tauri/src/services/assigned_issues.rs +++ b/src-tauri/src/services/assigned_issues.rs @@ -1,6 +1,6 @@ use crate::{ db, - domain::models::{AssignedIssuesPage, AssignedIssuesQueryInput}, + domain::models::{AssignedIssuesPage, AssignedIssuesQueryInput, ProviderConnection}, error::AppError, services::shared, state::AppState, @@ -11,12 +11,66 @@ pub fn load_assigned_issues_page( input: AssignedIssuesQueryInput, ) -> Result { let connection = shared::open_connection(state)?; - let primary = shared::load_primary_gitlab_connection(&connection)?; + let provider_ids = active_provider_ids_for_query( + shared::load_active_provider_connections(&connection)?, + input.provider.as_deref(), + ); - db::bootstrap::load_assigned_issues_page_from_cache( + let page = db::bootstrap::load_assigned_issues_page_from_cache_for_providers( &connection, - primary.id, + &provider_ids, &input, chrono::Local::now().date_naive(), - ) + )?; + Ok(page) +} + +fn active_provider_ids_for_query( + connections: Vec, + provider_filter: Option<&str>, +) -> Vec { + connections + .into_iter() + .filter(|connection| { + provider_filter + .is_none_or(|provider| connection.provider.eq_ignore_ascii_case(provider)) + }) + .map(|connection| connection.id) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn provider(id: i64, name: &str) -> ProviderConnection { + ProviderConnection { + id, + provider: name.to_string(), + display_name: name.to_string(), + host: format!("{}.example.com", name.to_lowercase()), + username: None, + client_id: None, + has_token: true, + state: "live".to_string(), + auth_mode: "pat".to_string(), + preferred_scope: "api".to_string(), + status_note: String::new(), + oauth_ready: false, + is_primary: true, + } + } + + #[test] + fn provider_filter_limits_active_assigned_issue_accounts() { + let connections = vec![provider(17, "YouTrack"), provider(18, "GitLab")]; + assert_eq!( + active_provider_ids_for_query(connections.clone(), Some("youtrack")), + vec![17] + ); + assert_eq!( + active_provider_ids_for_query(connections, None), + vec![17, 18] + ); + } } diff --git a/src-tauri/src/services/auth.rs b/src-tauri/src/services/auth.rs index 458238dc..5d629ece 100644 --- a/src-tauri/src/services/auth.rs +++ b/src-tauri/src/services/auth.rs @@ -2,10 +2,10 @@ use crate::{ auth, db, domain::models::{ AuthLaunchPlan, GitLabConnectionInput, GitLabUserInfo, OAuthCallbackPayload, - OAuthCallbackResolution, ProviderConnection, + OAuthCallbackResolution, ProviderConnection, ProviderConnectionInput, }, error::AppError, - providers::gitlab::GitLabClient, + providers::{GitLabClient, YouTrackClient}, services::shared, state::AppState, }; @@ -25,11 +25,32 @@ pub fn save_gitlab_connection( ) } +pub fn save_provider_connection( + state: &AppState, + input: ProviderConnectionInput, +) -> Result { + let connection = shared::open_connection(state)?; + db::connection::upsert_provider_connection( + &connection, + &input.provider, + &input.host, + input.display_name.as_deref(), + input.client_id.as_deref(), + &input.auth_mode, + &input.preferred_scope, + ) +} + pub fn load_gitlab_connections(state: &AppState) -> Result, AppError> { let connection = shared::open_connection(state)?; db::connection::load_gitlab_connections(&connection) } +pub fn load_provider_connections(state: &AppState) -> Result, AppError> { + let connection = shared::open_connection(state)?; + db::connection::load_provider_connections(&connection) +} + pub fn save_gitlab_pat( state: &AppState, host: &str, @@ -39,6 +60,16 @@ pub fn save_gitlab_pat( db::connection::save_gitlab_pat(&connection, host, token) } +pub fn save_provider_pat( + state: &AppState, + provider: &str, + host: &str, + token: &str, +) -> Result { + let connection = shared::open_connection(state)?; + db::connection::save_provider_pat(&connection, provider, host, token) +} + pub fn begin_gitlab_oauth( state: &AppState, input: GitLabConnectionInput, @@ -87,7 +118,7 @@ pub fn resolve_gitlab_oauth_callback_url( pub fn validate_gitlab_token(state: &AppState, host: &str) -> Result { let connection = shared::open_connection(state)?; let token = db::connection::load_gitlab_token(&connection, host)? - .ok_or_else(|| AppError::GitLabApi("no token found for this host".to_string()))?; + .ok_or_else(|| AppError::ProviderApi("no token found for this host".to_string()))?; let client = GitLabClient::new(host, &token)?; let user = client.fetch_user()?; @@ -101,6 +132,28 @@ pub fn validate_gitlab_token(state: &AppState, host: &str) -> Result Result { + if provider.eq_ignore_ascii_case("youtrack") { + let connection = shared::open_connection(state)?; + let token = db::connection::load_provider_token(&connection, provider, host)? + .ok_or_else(|| AppError::ProviderApi("no token found for this host".to_string()))?; + let client = YouTrackClient::new(host, &token)?; + let user = client.fetch_user()?; + db::connection::update_provider_username(&connection, provider, host, &user.username)?; + return Ok(GitLabUserInfo { + username: user.username, + name: user.name, + avatar_url: user.avatar_url, + }); + } + + validate_gitlab_token(state, host) +} + #[cfg(test)] mod tests { use std::{env, path::PathBuf}; diff --git a/src-tauri/src/services/issues.rs b/src-tauri/src/services/issues.rs index d76f4a5e..e2c51337 100644 --- a/src-tauri/src/services/issues.rs +++ b/src-tauri/src/services/issues.rs @@ -2,12 +2,13 @@ use crate::{ db, domain::models::{ CachedIterationRecord, CreateIssueCommentInput, DeleteIssueCommentInput, DeleteIssueInput, - IssueActivityPage, IssueDetailsSnapshot, LoadIssueActivityPageInput, LoadIssueDetailsInput, - LoadIssueDetailsResponse, LogIssueTimeInput, UpdateIssueCommentInput, - UpdateIssueMetadataInput, + IssueActivityPage, IssueDetailsSnapshot, IssueReference, LoadIssueActivityPageInput, + LoadIssueDetailsInput, LoadIssueDetailsResponse, LogIssueTimeInput, + UpdateIssueCommentInput, UpdateIssueMetadataInput, }, error::AppError, providers::gitlab::{enrich_and_dedupe_issue_iteration_options, GitLabClient}, + providers::YouTrackClient, services::shared, state::AppState, }; @@ -15,20 +16,35 @@ use crate::{ fn load_gitlab_client(state: &AppState) -> Result { let connection = shared::open_connection(state)?; let primary = shared::load_primary_gitlab_connection(&connection)?; - let token = db::connection::load_gitlab_token(&connection, &primary.host)? - .ok_or_else(|| AppError::GitLabApi("No token found for primary connection.".to_string()))?; + let token = + db::connection::load_gitlab_token(&connection, &primary.host)?.ok_or_else(|| { + AppError::ProviderApi("No token found for primary connection.".to_string()) + })?; GitLabClient::new(&primary.host, &token) } +fn load_youtrack_client(state: &AppState) -> Result { + let connection = shared::open_connection(state)?; + let primary = shared::load_primary_connection(&connection, "youtrack")?; + let token = db::connection::load_provider_token(&connection, "youtrack", &primary.host)? + .ok_or_else(|| { + AppError::ProviderApi("No token found for primary YouTrack connection.".to_string()) + })?; + + YouTrackClient::new(&primary.host, &token) +} + fn load_gitlab_client_and_iteration_catalog( state: &AppState, ) -> Result<(GitLabClient, Vec), AppError> { let connection = shared::open_connection(state)?; let primary = shared::load_primary_gitlab_connection(&connection)?; let catalog = db::iteration_catalog::load_rows(&connection, primary.id).unwrap_or_default(); - let token = db::connection::load_gitlab_token(&connection, &primary.host)? - .ok_or_else(|| AppError::GitLabApi("No token found for primary connection.".to_string()))?; + let token = + db::connection::load_gitlab_token(&connection, &primary.host)?.ok_or_else(|| { + AppError::ProviderApi("No token found for primary connection.".to_string()) + })?; Ok((GitLabClient::new(&primary.host, &token)?, catalog)) } @@ -52,7 +68,18 @@ pub fn load_issue_details( } Ok(response) } - other => Err(AppError::GitLabApi(format!( + "youtrack" => { + let reference = IssueReference { + provider: "youtrack".to_string(), + issue_id: input.issue_id.clone(), + provider_issue_ref: input.issue_id.clone(), + }; + let client = load_youtrack_client(state)?; + Ok(LoadIssueDetailsResponse::Full { + snapshot: Box::new(client.load_issue_details(&reference)?), + }) + } + other => Err(AppError::ProviderApi(format!( "Issue provider '{}' is not supported yet.", other ))), @@ -70,7 +97,11 @@ pub fn update_issue_metadata( enrich_and_dedupe_issue_iteration_options(&mut snapshot, &catalog); Ok(snapshot) } - other => Err(AppError::GitLabApi(format!( + "youtrack" => { + let client = load_youtrack_client(state)?; + client.update_issue_metadata(input) + } + other => Err(AppError::ProviderApi(format!( "Issue provider '{}' is not supported yet.", other ))), @@ -86,7 +117,11 @@ pub fn create_issue_comment( let client = load_gitlab_client(state)?; client.create_issue_note(&input.reference.provider_issue_ref, &input.body) } - other => Err(AppError::GitLabApi(format!( + "youtrack" => { + let client = load_youtrack_client(state)?; + client.create_issue_comment(&input.reference.issue_id, &input.body) + } + other => Err(AppError::ProviderApi(format!( "Issue provider '{}' is not supported yet.", other ))), @@ -102,7 +137,11 @@ pub fn update_issue_comment( let client = load_gitlab_client(state)?; client.update_issue_note(&input.reference.issue_id, &input.note_id, &input.body) } - other => Err(AppError::GitLabApi(format!( + "youtrack" => { + let client = load_youtrack_client(state)?; + client.update_issue_comment(&input.reference.issue_id, &input.note_id, &input.body) + } + other => Err(AppError::ProviderApi(format!( "Issue provider '{}' is not supported yet.", other ))), @@ -118,7 +157,11 @@ pub fn delete_issue_comment( let client = load_gitlab_client(state)?; client.delete_issue_note(&input.reference.issue_id, &input.note_id) } - other => Err(AppError::GitLabApi(format!( + "youtrack" => { + let client = load_youtrack_client(state)?; + client.delete_issue_comment(&input.reference.issue_id, &input.note_id) + } + other => Err(AppError::ProviderApi(format!( "Issue provider '{}' is not supported yet.", other ))), @@ -136,7 +179,15 @@ pub fn log_issue_time(state: &AppState, input: &LogIssueTimeInput) -> Result Err(AppError::GitLabApi(format!( + "youtrack" => { + let client = load_youtrack_client(state)?; + client.log_issue_time( + &input.reference.issue_id, + &input.time_spent, + input.summary.as_deref(), + ) + } + other => Err(AppError::ProviderApi(format!( "Issue provider '{}' is not supported yet.", other ))), @@ -149,7 +200,10 @@ pub fn delete_issue(state: &AppState, input: &DeleteIssueInput) -> Result<(), Ap let client = load_gitlab_client(state)?; client.delete_issue(&input.reference.issue_id) } - other => Err(AppError::GitLabApi(format!( + "youtrack" => Err(AppError::ProviderApi( + "YouTrack issue delete is not available in this version yet.".to_string(), + )), + other => Err(AppError::ProviderApi(format!( "Issue provider '{}' is not supported yet.", other ))), @@ -165,9 +219,75 @@ pub fn load_issue_activity_page( let client = load_gitlab_client(state)?; client.load_issue_activity_page(&input.reference, input.page, 10) } - other => Err(AppError::GitLabApi(format!( + "youtrack" => { + let client = load_youtrack_client(state)?; + client.load_issue_activity_page(&input.reference, input.page, 10) + } + other => Err(AppError::ProviderApi(format!( "Issue provider '{}' is not supported yet.", other ))), } } + +#[cfg(test)] +mod tests { + use std::{env, path::PathBuf}; + + use rusqlite::Connection; + + use crate::{ + db, + domain::models::{DeleteIssueInput, IssueReference, LoadIssueDetailsInput}, + error::AppError, + state::AppState, + }; + + use super::*; + + fn make_state() -> AppState { + let mut path = env::temp_dir(); + path.push(format!( + "timely-issues-test-{}-{}.sqlite3", + std::process::id(), + rand::random::() + )); + let _ = std::fs::remove_file(&path); + + let connection = Connection::open(&path).unwrap(); + db::migrate(&connection).unwrap(); + drop(connection); + + AppState::new(PathBuf::from(path)) + } + + #[test] + fn load_issue_details_rejects_unknown_provider() { + let state = make_state(); + let input = LoadIssueDetailsInput { + provider: "phantom".to_string(), + issue_id: "X-1".to_string(), + if_none_match: None, + }; + let err = load_issue_details(&state, &input).unwrap_err(); + assert!(matches!(err, AppError::ProviderApi(_))); + let message = err.to_string(); + assert!(message.contains("phantom"), "{message}"); + let _ = std::fs::remove_file(&state.db_path); + } + + #[test] + fn delete_issue_youtrack_is_unsupported() { + let state = make_state(); + let input = DeleteIssueInput { + reference: IssueReference { + provider: "youtrack".to_string(), + issue_id: "DEMO-1".to_string(), + provider_issue_ref: "DEMO-1".to_string(), + }, + }; + let err = delete_issue(&state, &input).unwrap_err(); + assert!(matches!(err, AppError::ProviderApi(_))); + let _ = std::fs::remove_file(&state.db_path); + } +} diff --git a/src-tauri/src/services/shared.rs b/src-tauri/src/services/shared.rs index 12523454..1ef78403 100644 --- a/src-tauri/src/services/shared.rs +++ b/src-tauri/src/services/shared.rs @@ -4,8 +4,6 @@ use rusqlite::Connection; use crate::{db, domain::models::ProviderConnection, error::AppError, state::AppState}; -const PRIMARY_GITLAB_CONNECTION_ERROR: &str = "No primary GitLab connection found."; - pub fn open_connection(state: &AppState) -> Result { db::open(&state.db_path) } @@ -13,10 +11,32 @@ pub fn open_connection(state: &AppState) -> Result { pub fn load_primary_gitlab_connection( connection: &Connection, ) -> Result { - db::connection::load_gitlab_connections(connection)? + load_primary_connection(connection, "gitlab") +} + +pub fn load_primary_connection( + connection: &Connection, + provider: &str, +) -> Result { + db::connection::load_provider_connections(connection)? + .into_iter() + .find(|connection| { + connection.is_primary && connection.provider.eq_ignore_ascii_case(provider) + }) + .ok_or_else(|| { + AppError::ProviderApi(format!( + "No primary connection found for provider \"{provider}\"." + )) + }) +} + +pub fn load_active_provider_connections( + connection: &Connection, +) -> Result, AppError> { + Ok(db::connection::load_provider_connections(connection)? .into_iter() - .find(|connection| connection.is_primary) - .ok_or_else(|| AppError::GitLabApi(PRIMARY_GITLAB_CONNECTION_ERROR.to_string())) + .filter(|provider| provider.has_token || provider.client_id.is_some()) + .collect()) } pub async fn run_blocking_with_timeout( @@ -35,7 +55,7 @@ where match tokio::time::timeout(timeout, task).await { Ok(join_result) => join_result - .map_err(|error| AppError::GitLabApi(format!("{task_name} task failed: {error}")))?, + .map_err(|error| AppError::ProviderApi(format!("{task_name} task failed: {error}")))?, Err(_) => Err(AppError::Timeout(timeout_message.to_string())), } } diff --git a/src-tauri/src/services/sync.rs b/src-tauri/src/services/sync.rs index bd0815b8..844c2fec 100644 --- a/src-tauri/src/services/sync.rs +++ b/src-tauri/src/services/sync.rs @@ -8,7 +8,7 @@ use crate::{ domain::models::AssignedIssueRecord, domain::models::SyncResult, error::AppError, - providers::gitlab::GitLabClient, + providers::{gitlab::GitLabClient, YouTrackClient, YouTrackUserWorkItem}, services::{localization, preferences, shared}, state::AppState, support::time::utc_timestamp, @@ -266,6 +266,279 @@ pub fn sync_gitlab( }) } +pub fn sync_providers( + state: &AppState, + on_progress: &mut dyn FnMut(String), +) -> Result { + let connection = shared::open_connection(state)?; + let providers = db::connection::load_provider_connections(&connection)?; + + let mut totals = SyncResult { + projects_synced: 0, + entries_synced: 0, + issues_synced: 0, + assigned_issues_synced: 0, + }; + + let has_gitlab = has_active_provider_token(&providers, "gitlab"); + let has_youtrack = has_active_provider_token(&providers, "youtrack"); + + if has_gitlab { + on_progress(sync_start_message("GitLab")); + let result = sync_gitlab(state, on_progress)?; + totals.projects_synced += result.projects_synced; + totals.entries_synced += result.entries_synced; + totals.issues_synced += result.issues_synced; + totals.assigned_issues_synced += result.assigned_issues_synced; + } + + if has_youtrack { + on_progress(sync_start_message("YouTrack")); + let result = sync_youtrack(state, on_progress)?; + totals.projects_synced += result.projects_synced; + totals.entries_synced += result.entries_synced; + totals.issues_synced += result.issues_synced; + totals.assigned_issues_synced += result.assigned_issues_synced; + } + + if !has_gitlab && !has_youtrack { + return Err(AppError::ProviderApi(no_active_provider_error_message())); + } + + Ok(totals) +} + +fn has_active_provider_token( + providers: &[crate::domain::models::ProviderConnection], + provider: &str, +) -> bool { + providers + .iter() + .any(|item| item.provider.eq_ignore_ascii_case(provider) && item.has_token) +} + +fn no_active_provider_error_message() -> String { + "No active provider connections with tokens.".to_string() +} + +fn sync_start_message(provider_label: &str) -> String { + format!("Starting {provider_label} sync...") +} + +fn load_youtrack_primary_and_client( + state: &AppState, +) -> Result< + ( + rusqlite::Connection, + crate::domain::models::ProviderConnection, + YouTrackClient, + ), + AppError, +> { + let connection = shared::open_connection(state)?; + let primary = shared::load_primary_connection(&connection, "youtrack")?; + db::ensure_gamification_profile(&connection, primary.id)?; + let token = db::connection::load_provider_token(&connection, "youtrack", &primary.host)? + .ok_or_else(|| { + AppError::ProviderApi("No token found for primary YouTrack connection.".to_string()) + })?; + let client = YouTrackClient::new(&primary.host, &token)?; + Ok((connection, primary, client)) +} + +fn sync_youtrack_persist_assigned_issues( + tx: &rusqlite::Transaction<'_>, + provider_account_id: i64, + merged_records: &[(AssignedIssueRecord, AssignedIssueBucket)], +) -> Result { + let mut count = 0u32; + for (record, bucket) in merged_records { + db::sync::upsert_assigned_issue(tx, provider_account_id, record, *bucket)?; + count += 1; + } + Ok(count) +} + +fn persist_youtrack_user_work_items( + tx: &rusqlite::Transaction<'_>, + provider_account_id: i64, + work_items: &[YouTrackUserWorkItem], +) -> Result<(u32, u32), AppError> { + let mut issue_rows_synced = 0u32; + let mut entries_synced = 0u32; + let mut seen_issue_ids = std::collections::HashSet::new(); + for item in work_items { + let work_item_id = if let Some(issue) = item.issue.as_ref() { + let labels_json = + serde_json::to_string(&issue.labels).unwrap_or_else(|_| "[]".to_string()); + let id = db::sync::upsert_work_item( + tx, + provider_account_id, + &issue.provider_item_id, + &issue.title, + &issue.state, + issue.web_url.as_deref(), + Some(labels_json.as_str()), + )?; + if seen_issue_ids.insert(issue.provider_item_id.clone()) { + issue_rows_synced += 1; + } + Some(id) + } else { + None + }; + + let entry_id = format!("youtrack-{}", item.work_item.id); + db::sync::upsert_time_entry( + tx, + provider_account_id, + &entry_id, + work_item_id, + &item.work_item.spent_at, + item.work_item.uploaded_at.as_deref(), + item.work_item.duration_minutes * 60, + )?; + entries_synced += 1; + } + Ok((issue_rows_synced, entries_synced)) +} + +fn sync_youtrack( + state: &AppState, + on_progress: &mut dyn FnMut(String), +) -> Result { + const RECENT_CLOSED_DAYS: i64 = 180; + let (connection, primary, client) = load_youtrack_primary_and_client(state)?; + let today = Utc::now().date_naive(); + let start_date = today.checked_sub_months(Months::new(2)).unwrap_or(today); + let end_date = today; + + on_progress("YouTrack: fetching open assigned issues...".to_string()); + let open_records = client.fetch_open_assigned_issues()?; + let cutoff = today + .checked_sub_days(Days::new(RECENT_CLOSED_DAYS as u64)) + .unwrap_or(today) + .format("%Y-%m-%d") + .to_string(); + on_progress("YouTrack: fetching recent resolved assigned issues...".to_string()); + let recent_closed_records = client.fetch_recent_closed_assigned_issues(&cutoff)?; + on_progress("YouTrack: fetching resolved assigned issue archive...".to_string()); + let all_closed_records = client.fetch_all_closed_assigned_issues()?; + on_progress(youtrack_issue_counts_message( + open_records.len(), + recent_closed_records.len(), + all_closed_records.len(), + )); + + let cutoff_timestamp = format!("{cutoff}T00:00:00Z"); + let merged_records = merge_youtrack_assigned_records( + &open_records, + &recent_closed_records, + &all_closed_records, + &cutoff_timestamp, + ); + + let tx = connection.unchecked_transaction()?; + let assigned_issue_upserts = + sync_youtrack_persist_assigned_issues(&tx, primary.id, &merged_records)?; + + on_progress("YouTrack: refreshing worklog window...".to_string()); + db::sync::delete_time_entries_in_range(&tx, primary.id, &start_date, &end_date)?; + + let start_str = start_date.format("%Y-%m-%d").to_string(); + let end_str = end_date.format("%Y-%m-%d").to_string(); + let user_work_items = client.fetch_user_work_items(&start_str, &end_str)?; + let (work_item_rows, entries_synced) = + persist_youtrack_user_work_items(&tx, primary.id, &user_work_items)?; + + db::sync::clear_missing_assigned_issues_for_buckets( + &tx, + primary.id, + &[ + AssignedIssueBucket::Open, + AssignedIssueBucket::RecentClosed, + AssignedIssueBucket::ArchiveClosed, + ], + &merged_records + .iter() + .map(|(item, _)| item.provider_item_id.clone()) + .collect::>(), + )?; + + db::sync::rebuild_daily_buckets_in_tx(&tx, primary.id, &start_date, &end_date)?; + db::sync::update_quest_progress_from_buckets(&tx, primary.id)?; + + let streak_snapshot = crate::services::streak::build_streak_snapshot(&tx, primary.id, today)?; + crate::services::streak::persist_current_streak(&tx, primary.id, streak_snapshot.current_days)?; + + let synced_at = utc_timestamp(); + db::sync::update_provider_last_sync_at(&tx, primary.id, &synced_at)?; + tx.commit()?; + + Ok(compose_provider_sync_result( + assigned_issue_upserts, + work_item_rows, + entries_synced, + )) +} + +fn merge_youtrack_assigned_records( + open_records: &[AssignedIssueRecord], + recent_closed_records: &[AssignedIssueRecord], + all_closed_records: &[AssignedIssueRecord], + cutoff_timestamp: &str, +) -> Vec<(AssignedIssueRecord, AssignedIssueBucket)> { + let mut merged = + std::collections::BTreeMap::::new(); + + for record in all_closed_records { + let bucket = bucket_for_closed_issue(record, cutoff_timestamp); + merged.insert(record.provider_item_id.clone(), (record.clone(), bucket)); + } + for record in recent_closed_records { + merged.insert( + record.provider_item_id.clone(), + (record.clone(), AssignedIssueBucket::RecentClosed), + ); + } + for record in open_records { + merged.insert( + record.provider_item_id.clone(), + (record.clone(), AssignedIssueBucket::Open), + ); + } + + merged.into_values().collect() +} + +fn calc_youtrack_issues_synced(assigned_issue_upserts: u32, work_item_upserts: u32) -> u32 { + assigned_issue_upserts.saturating_add(work_item_upserts) +} + +fn compose_provider_sync_result( + assigned_issue_upserts: u32, + work_item_upserts: u32, + entries_synced: u32, +) -> SyncResult { + SyncResult { + projects_synced: 0, + entries_synced, + issues_synced: calc_youtrack_issues_synced(assigned_issue_upserts, work_item_upserts), + assigned_issues_synced: assigned_issue_upserts, + } +} + +fn youtrack_issue_counts_message( + open_count: usize, + recent_closed_count: usize, + all_closed_count: usize, +) -> String { + format!( + "YouTrack: open {}, recent closed {}, all closed {}.", + open_count, recent_closed_count, all_closed_count + ) +} + struct AssignedIssueSyncSummary { assigned_issues_synced: u32, active_group_ids: Vec, @@ -597,3 +870,287 @@ fn parse_date(date_str: &str) -> Option { let date_part = date_str.split('T').next()?; NaiveDate::parse_from_str(date_part, "%Y-%m-%d").ok() } + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + use crate::{db, providers::YouTrackUserWorkItem}; + + fn make_record( + provider_item_id: &str, + closed_at: Option<&str>, + iteration_group_id: Option<&str>, + ) -> AssignedIssueRecord { + AssignedIssueRecord { + issue_graphql_id: format!("gql-{provider_item_id}"), + provider_item_id: provider_item_id.to_string(), + title: format!("Issue {provider_item_id}"), + state: "opened".to_string(), + status_label: Some("To do".to_string()), + workflow_status: "todo".to_string(), + closed_at: closed_at.map(str::to_string), + updated_at: None, + web_url: None, + labels: vec![], + milestone_title: None, + iteration_gitlab_id: None, + iteration_group_id: iteration_group_id.map(str::to_string), + iteration_cadence_id: None, + iteration_cadence_title: None, + iteration_title: None, + iteration_start_date: None, + iteration_due_date: None, + start_date: None, + due_date: None, + } + } + + fn setup_connection() -> Connection { + let connection = Connection::open_in_memory().unwrap(); + db::migrate(&connection).unwrap(); + connection + .execute( + "INSERT INTO provider_accounts (provider, host, display_name, auth_mode, preferred_scope, status_note, oauth_ready, is_primary, created_at) + VALUES ('YouTrack', 'company.youtrack.cloud', 'YouTrack', 'pat', 'api', 'ok', 1, 1, '2026-04-30T00:00:00Z')", + [], + ) + .unwrap(); + connection + } + + fn youtrack_user_work_item( + id: &str, + issue: AssignedIssueRecord, + spent_at: &str, + minutes: i64, + ) -> YouTrackUserWorkItem { + YouTrackUserWorkItem::from_parts( + id.to_string(), + spent_at.to_string(), + Some("2026-04-30T12:00:00Z".to_string()), + minutes, + Some(issue), + ) + } + + #[test] + fn bucket_for_closed_issue_marks_archive_before_cutoff() { + let record = make_record("YT-1", Some("2025-01-01T00:00:00Z"), None); + let bucket = bucket_for_closed_issue(&record, "2025-06-01T00:00:00Z"); + assert_eq!(bucket, AssignedIssueBucket::ArchiveClosed); + } + + #[test] + fn bucket_for_closed_issue_marks_recent_when_missing_or_after_cutoff() { + let missing = make_record("YT-2", None, None); + let recent = make_record("YT-3", Some("2025-07-01T00:00:00Z"), None); + assert_eq!( + bucket_for_closed_issue(&missing, "2025-06-01T00:00:00Z"), + AssignedIssueBucket::RecentClosed + ); + assert_eq!( + bucket_for_closed_issue(&recent, "2025-06-01T00:00:00Z"), + AssignedIssueBucket::RecentClosed + ); + } + + #[test] + fn dedupe_strings_trims_sorts_and_removes_empty_values() { + let result = dedupe_strings(vec![ + " z ".to_string(), + "".to_string(), + "a".to_string(), + "z".to_string(), + " ".to_string(), + "b".to_string(), + ]); + assert_eq!( + result, + vec!["a".to_string(), "b".to_string(), "z".to_string()] + ); + } + + #[test] + fn collect_iteration_group_ids_dedupes_groups() { + let records = vec![ + make_record("YT-4", None, Some("group-2")), + make_record("YT-5", None, Some("group-1")), + make_record("YT-6", None, Some("group-2")), + make_record("YT-7", None, None), + ]; + let groups = collect_iteration_group_ids(&records); + assert_eq!(groups, vec!["group-1".to_string(), "group-2".to_string()]); + } + + #[test] + fn persist_youtrack_user_work_items_keeps_same_issue_entries_separate_for_totals() { + let connection = setup_connection(); + let tx = connection.unchecked_transaction().unwrap(); + let issue = make_record("IRPT-12", None, None); + let work_items = vec![ + youtrack_user_work_item("115-10", issue.clone(), "2026-04-30", 60), + youtrack_user_work_item("115-11", issue, "2026-04-30", 30), + ]; + + persist_youtrack_user_work_items(&tx, 1, &work_items).unwrap(); + tx.commit().unwrap(); + + let (entry_count, seconds): (i64, i64) = connection + .query_row( + "SELECT COUNT(*), COALESCE(SUM(te.seconds), 0) + FROM time_entries te + JOIN work_items wi ON wi.id = te.work_item_id + WHERE wi.provider_item_id = 'IRPT-12' AND date(te.spent_at) = '2026-04-30'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + + assert_eq!(entry_count, 2); + assert_eq!(seconds, 5_400); + } + + #[test] + fn persist_youtrack_user_work_items_updates_same_entry_id_when_work_item_is_edited() { + let connection = setup_connection(); + let issue = make_record("IRPT-12", None, None); + let first = vec![youtrack_user_work_item( + "115-10", + issue.clone(), + "2026-04-29", + 30, + )]; + let second = vec![youtrack_user_work_item("115-10", issue, "2026-04-30", 90)]; + + let tx = connection.unchecked_transaction().unwrap(); + persist_youtrack_user_work_items(&tx, 1, &first).unwrap(); + persist_youtrack_user_work_items(&tx, 1, &second).unwrap(); + tx.commit().unwrap(); + + let (entry_count, spent_at, seconds): (i64, String, i64) = connection + .query_row( + "SELECT COUNT(*), MAX(spent_at), COALESCE(SUM(seconds), 0) + FROM time_entries + WHERE provider_account_id = 1 AND provider_entry_id = 'youtrack-115-10'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + + assert_eq!(entry_count, 1); + assert_eq!(spent_at, "2026-04-30"); + assert_eq!(seconds, 5_400); + } + + #[test] + fn merge_youtrack_assigned_records_prioritizes_open_over_closed() { + let open = vec![make_record("YT-10", None, None)]; + let recent_closed = vec![make_record("YT-10", Some("2025-08-01T00:00:00Z"), None)]; + let all_closed = vec![make_record("YT-10", Some("2025-01-01T00:00:00Z"), None)]; + let merged = merge_youtrack_assigned_records( + &open, + &recent_closed, + &all_closed, + "2025-06-01T00:00:00Z", + ); + + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].0.provider_item_id, "YT-10"); + assert_eq!(merged[0].1, AssignedIssueBucket::Open); + } + + #[test] + fn merge_youtrack_assigned_records_keeps_recent_when_not_open() { + let open = vec![]; + let recent_closed = vec![make_record("YT-20", Some("2025-08-01T00:00:00Z"), None)]; + let all_closed = vec![make_record("YT-20", Some("2025-01-01T00:00:00Z"), None)]; + let merged = merge_youtrack_assigned_records( + &open, + &recent_closed, + &all_closed, + "2025-06-01T00:00:00Z", + ); + + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].0.provider_item_id, "YT-20"); + assert_eq!(merged[0].1, AssignedIssueBucket::RecentClosed); + } + + #[test] + fn calc_youtrack_issues_synced_adds_assigned_and_work_item_rows() { + assert_eq!(calc_youtrack_issues_synced(12, 7), 19); + } + + #[test] + fn calc_youtrack_issues_synced_saturates_on_overflow() { + assert_eq!(calc_youtrack_issues_synced(u32::MAX, 2), u32::MAX); + } + + #[test] + fn compose_provider_sync_result_maps_fields_consistently() { + let result = compose_provider_sync_result(8, 5, 13); + assert_eq!(result.projects_synced, 0); + assert_eq!(result.entries_synced, 13); + assert_eq!(result.issues_synced, 13); + assert_eq!(result.assigned_issues_synced, 8); + } + + #[test] + fn compose_provider_sync_result_keeps_issue_count_saturated() { + let result = compose_provider_sync_result(u32::MAX, 10, 2); + assert_eq!(result.issues_synced, u32::MAX); + assert_eq!(result.assigned_issues_synced, u32::MAX); + assert_eq!(result.entries_synced, 2); + } + + #[test] + fn has_active_provider_token_matches_case_insensitive_provider_name() { + let providers = vec![crate::domain::models::ProviderConnection { + id: 1, + provider: "YouTrack".to_string(), + display_name: "YT".to_string(), + host: "yt.local".to_string(), + username: None, + client_id: None, + has_token: true, + state: "live".to_string(), + auth_mode: "PAT".to_string(), + preferred_scope: "api".to_string(), + status_note: "".to_string(), + oauth_ready: true, + is_primary: true, + }]; + assert!(has_active_provider_token(&providers, "youtrack")); + assert!(!has_active_provider_token(&providers, "gitlab")); + } + + #[test] + fn no_active_provider_error_message_is_stable() { + assert_eq!( + no_active_provider_error_message(), + "No active provider connections with tokens.".to_string() + ); + } + + #[test] + fn sync_start_message_is_stable() { + assert_eq!( + sync_start_message("GitLab"), + "Starting GitLab sync...".to_string() + ); + assert_eq!( + sync_start_message("YouTrack"), + "Starting YouTrack sync...".to_string() + ); + } + + #[test] + fn youtrack_issue_counts_message_is_stable() { + assert_eq!( + youtrack_issue_counts_message(3, 2, 9), + "YouTrack: open 3, recent closed 2, all closed 9.".to_string() + ); + } +} diff --git a/src-tauri/src/services/worklog.rs b/src-tauri/src/services/worklog.rs index 3614f5a7..d426c381 100644 --- a/src-tauri/src/services/worklog.rs +++ b/src-tauri/src/services/worklog.rs @@ -1,5 +1,5 @@ use chrono::{Datelike, Duration, Local, NaiveDate}; -use rusqlite::{params, Connection, OptionalExtension}; +use rusqlite::{params_from_iter, Connection, OptionalExtension}; use crate::{ db::bootstrap, @@ -20,7 +20,10 @@ pub fn load_worklog_snapshot( input: WorklogQueryInput, ) -> Result { let connection = shared::open_connection(state)?; - let primary = shared::load_primary_gitlab_connection(&connection)?; + let provider_ids = shared::load_active_provider_connections(&connection)? + .into_iter() + .map(|connection| connection.id) + .collect::>(); let app_preferences = preferences::load_app_preferences(&connection)?; let locale = localization::AppLocale::from_language_pref(&app_preferences.language); let schedule = load_schedule_profile(&connection)?; @@ -60,12 +63,12 @@ pub fn load_worklog_snapshot( localization::format_range_label(start, normalized_end, locale), ) } - _ => return Err(AppError::GitLabApi("Invalid worklog mode".to_string())), + _ => return Err(AppError::ProviderApi("Invalid worklog mode".to_string())), }; let days = load_range_days( &connection, - primary.id, + &provider_ids, range_start, range_end, &schedule.weekday_schedules, @@ -77,7 +80,7 @@ pub fn load_worklog_snapshot( let month = build_range_month_snapshot(&days, range_start, range_end); let audit_flags = build_review_flags(&days, locale); - Ok(WorklogSnapshot { + let snapshot = WorklogSnapshot { mode: input.mode, range: WorklogRangeMeta { start_date: range_start.format("%Y-%m-%d").to_string(), @@ -88,7 +91,8 @@ pub fn load_worklog_snapshot( days, month, audit_flags, - }) + }; + Ok(snapshot) } #[derive(Clone)] @@ -135,7 +139,7 @@ fn load_schedule_profile(connection: &Connection) -> Result(0)?, - row.get::<_, i64>(1)?, - row.get::<_, i64>(2)?, - row.get::<_, String>(3)?, - )) - }, - ) - .optional()?; - - let (logged_seconds, mut target_seconds, variance_seconds, mut status) = - bucket.unwrap_or((0, default_target_seconds, 0, "empty".to_string())); + let logged_seconds = load_logged_seconds_for_day(connection, provider_account_ids, date)?; + let mut target_seconds = default_target_seconds; + let variance_seconds = logged_seconds - target_seconds; + let mut status = status_for_logged_seconds(logged_seconds, target_seconds); if is_non_workday || holiday.is_some() { target_seconds = 0; @@ -214,7 +205,7 @@ fn load_day_overview( status = "under_target".to_string(); } - let top_issues = load_issue_breakdown(connection, provider_account_id, date)?; + let top_issues = load_issue_breakdown(connection, provider_account_ids, date)?; let focus_hours = top_issues .iter() .take(2) @@ -240,39 +231,91 @@ fn load_day_overview( fn load_issue_breakdown( connection: &Connection, - provider_account_id: i64, + provider_account_ids: &[i64], date: NaiveDate, ) -> Result, AppError> { - let mut statement = connection.prepare( + if provider_account_ids.is_empty() { + return Ok(vec![]); + } + let placeholders = sql_placeholders(provider_account_ids.len()); + // Group by work_item_id (the stable FK to work_items) so multiple time log entries + // for the same issue on the same day are aggregated into a single row. This prevents + // the same issue from appearing multiple times when YouTrack or GitLab records + // separate work log entries (e.g. 4 x 30min entries → 1 row showing 2h, not 4 rows). + let sql = format!( "SELECT wi.provider_item_id, wi.title, wi.labels_json, SUM(te.seconds) AS total_seconds FROM time_entries te JOIN work_items wi ON wi.id = te.work_item_id - WHERE te.provider_account_id = ?1 AND date(te.spent_at) = ?2 - GROUP BY wi.provider_item_id, wi.title, wi.labels_json - ORDER BY total_seconds DESC, wi.provider_item_id ASC", - )?; - - let rows = statement.query_map( - params![provider_account_id, date.format("%Y-%m-%d").to_string()], - |row| { - let labels_json: Option = row.get(2)?; - let tone = labels_json - .as_deref() - .and_then(parse_issue_tone) - .unwrap_or_else(|| DEFAULT_PROVIDER_TONE.to_string()); - - Ok(IssueBreakdown { - key: row.get(0)?, - title: row.get(1)?, - hours: seconds_to_hours(row.get(3)?), - tone, - }) - }, - )?; + WHERE te.provider_account_id IN ({placeholders}) AND date(te.spent_at) = ? + GROUP BY te.work_item_id + ORDER BY total_seconds DESC, wi.provider_item_id ASC" + ); + let mut statement = connection.prepare(&sql)?; + let date_string = date.format("%Y-%m-%d").to_string(); + let mut query_params = provider_account_ids + .iter() + .map(|id| id.to_string()) + .collect::>(); + query_params.push(date_string); + + let rows = statement.query_map(params_from_iter(query_params.iter()), |row| { + let labels_json: Option = row.get(2)?; + let tone = labels_json + .as_deref() + .and_then(parse_issue_tone) + .unwrap_or_else(|| DEFAULT_PROVIDER_TONE.to_string()); + + Ok(IssueBreakdown { + key: row.get(0)?, + title: row.get(1)?, + hours: seconds_to_hours(row.get(3)?), + tone, + }) + })?; Ok(rows.collect::, _>>()?) } +fn load_logged_seconds_for_day( + connection: &Connection, + provider_account_ids: &[i64], + date: NaiveDate, +) -> Result { + if provider_account_ids.is_empty() { + return Ok(0); + } + let placeholders = sql_placeholders(provider_account_ids.len()); + let sql = format!( + "SELECT COALESCE(SUM(logged_seconds), 0) + FROM daily_buckets + WHERE provider_account_id IN ({placeholders}) AND date = ?" + ); + let mut query_params = provider_account_ids + .iter() + .map(|id| id.to_string()) + .collect::>(); + query_params.push(date.format("%Y-%m-%d").to_string()); + connection + .query_row(&sql, params_from_iter(query_params.iter()), |row| { + row.get(0) + }) + .map_err(AppError::from) +} + +fn status_for_logged_seconds(logged_seconds: i64, target_seconds: i64) -> String { + if logged_seconds <= 0 { + "empty".to_string() + } else if target_seconds <= 0 { + "over_target".to_string() + } else if logged_seconds < target_seconds { + "under_target".to_string() + } else if logged_seconds == target_seconds { + "met_target".to_string() + } else { + "over_target".to_string() + } +} + fn build_range_month_snapshot( days: &[DayOverview], range_start: NaiveDate, @@ -368,7 +411,7 @@ fn parse_issue_tone(json: &str) -> Option { fn parse_date(value: &str) -> Result { NaiveDate::parse_from_str(value, "%Y-%m-%d") - .map_err(|_| AppError::GitLabApi(format!("Invalid date value: {value}"))) + .map_err(|_| AppError::ProviderApi(format!("Invalid date value: {value}"))) } fn start_of_week(today: NaiveDate, week_starts_on: u32) -> NaiveDate { @@ -420,6 +463,10 @@ fn seconds_to_hours(value: i64) -> f32 { value as f32 / 3600.0 } +fn sql_placeholders(len: usize) -> String { + std::iter::repeat_n("?", len).collect::>().join(",") +} + #[cfg(test)] mod tests { use std::{env, path::PathBuf}; @@ -428,7 +475,7 @@ mod tests { use crate::{db, domain::models::WorklogQueryInput, state::AppState}; - use super::load_worklog_snapshot; + use super::{load_issue_breakdown, load_worklog_snapshot}; #[test] fn loads_week_worklog_snapshot() { @@ -461,4 +508,59 @@ mod tests { let _ = std::fs::remove_file(path); } + + #[test] + fn issue_breakdown_aggregates_multiple_entries_for_same_issue_day() { + let connection = rusqlite::Connection::open_in_memory().unwrap(); + db::migrate(&connection).unwrap(); + connection + .execute( + "INSERT INTO provider_accounts (provider, host, display_name, auth_mode, preferred_scope, status_note, oauth_ready, is_primary, created_at) + VALUES ('YouTrack', 'company.youtrack.cloud', 'YouTrack', 'pat', 'api', 'ok', 1, 1, '2026-04-30T00:00:00Z')", + [], + ) + .unwrap(); + let provider_id = connection.last_insert_rowid(); + let work_item_id = db::sync::upsert_work_item( + &connection, + provider_id, + "IRPT-12", + "Investigate YouTrack totals", + "In Progress", + None, + Some("[]"), + ) + .unwrap(); + db::sync::upsert_time_entry( + &connection, + provider_id, + "youtrack-115-10", + Some(work_item_id), + "2026-04-30", + Some("2026-04-30T12:00:00Z"), + 3_600, + ) + .unwrap(); + db::sync::upsert_time_entry( + &connection, + provider_id, + "youtrack-115-11", + Some(work_item_id), + "2026-04-30", + Some("2026-04-30T13:00:00Z"), + 1_800, + ) + .unwrap(); + + let breakdown = load_issue_breakdown( + &connection, + &[provider_id], + NaiveDate::from_ymd_opt(2026, 4, 30).unwrap(), + ) + .unwrap(); + + assert_eq!(breakdown.len(), 1); + assert_eq!(breakdown[0].key, "IRPT-12"); + assert_eq!(breakdown[0].hours, 1.5); + } } diff --git a/src-tauri/src/support/badge_tone_mapper.rs b/src-tauri/src/support/badge_tone_mapper.rs new file mode 100644 index 00000000..11b9c3c1 --- /dev/null +++ b/src-tauri/src/support/badge_tone_mapper.rs @@ -0,0 +1,330 @@ +use std::collections::HashMap; + +use crate::domain::models::ToneName; + +pub struct BadgeToneMapper { + dictionaries: HashMap, +} + +impl BadgeToneMapper { + pub fn new() -> Self { + let mut dictionaries = HashMap::new(); + + // Success states + for key in &[ + "done", + "complete", + "completed", + "resolved", + "finished", + "closed", + "cerrado", + "finalizado", + "listo", + "terminado", + "completado", + "concluido", + "resolvido", + "fechado", + "pronto", + ] { + dictionaries.insert(key.to_string(), ToneName::Success); + } + + // Accent states (in progress) + for key in &[ + "doing", + "in progress", + "progress", + "review", + "developing", + "development", + "curso", + "progreso", + "desarrollo", + "revision", + "em progresso", + "revisao", + "desenvolvimento", + ] { + dictionaries.insert(key.to_string(), ToneName::Accent); + } + + // Primary states (todo/backlog) + for key in &[ + "todo", + "to-do", + "backlog", + "ready", + "planned", + "triage", + "open", + "opened", + "pendiente", + "hacer", + "por hacer", + "abierto", + "a fazer", + "planejado", + "aberto", + "pendente", + ] { + dictionaries.insert(key.to_string(), ToneName::Primary); + } + + // Warning states (blocked/waiting) + for key in &[ + "blocked", + "on hold", + "waiting", + "paused", + "pending", + "bloqueado", + "en espera", + "pausado", + "em espera", + ] { + dictionaries.insert(key.to_string(), ToneName::Warning); + } + + // Destructive states (urgent/critical) + for key in &[ + "urgent", + "critical", + "high priority", + "escalated", + "urgente", + "critico", + "alta prioridad", + "alta prioridade", + ] { + dictionaries.insert(key.to_string(), ToneName::Destructive); + } + + // Neutral states (archived/cancelled) + for key in &[ + "archived", + "cancelled", + "wontfix", + "wont-fix", + "duplicate", + "archivado", + "cancelado", + "arquivado", + ] { + dictionaries.insert(key.to_string(), ToneName::Neutral); + } + + Self { dictionaries } + } + + pub fn map_status(&self, status: &str) -> ToneName { + let normalized = status.trim().to_lowercase(); + + // Exact dictionary match + if let Some(tone) = self.dictionaries.get(&normalized) { + return *tone; + } + + // Heuristic regex matching + if Self::is_success(&normalized) { + return ToneName::Success; + } + if Self::is_accent(&normalized) { + return ToneName::Accent; + } + if Self::is_primary(&normalized) { + return ToneName::Primary; + } + if Self::is_warning(&normalized) { + return ToneName::Warning; + } + if Self::is_destructive(&normalized) { + return ToneName::Destructive; + } + if Self::is_neutral(&normalized) { + return ToneName::Neutral; + } + + // Hash fallback for unknowns + self.hash_tone(&normalized) + } + + pub fn map_label(&self, label: &str) -> ToneName { + let normalized = label.trim().to_lowercase(); + + // Prefix-based logic + if normalized.starts_with("category::") { + return ToneName::Accent; + } + if normalized.starts_with("team::") { + return ToneName::Secondary; + } + if normalized.starts_with("priority::") { + let suffix = normalized.split("::").nth(1).unwrap_or(&normalized); + if Self::is_destructive(suffix) { + return ToneName::Destructive; + } + if Self::is_warning(suffix) { + return ToneName::Warning; + } + return ToneName::Neutral; + } + if normalized.starts_with("workflow::") { + let suffix = normalized.split("::").nth(1).unwrap_or(&normalized); + return self.map_status(suffix); + } + + // Exact match for known labels + if let Some(tone) = self.dictionaries.get(&normalized) { + return *tone; + } + + // Hash fallback + self.hash_tone(&normalized) + } + + fn is_success(value: &str) -> bool { + value.contains("done") + || value.contains("complete") + || value.contains("resolved") + || value.contains("finished") + || value.contains("cerrado") + || value.contains("finalizado") + || value.contains("listo") + || value.contains("terminado") + || value.contains("concluido") + || value.contains("resolvido") + || value.contains("fechado") + || value.contains("pronto") + } + + fn is_accent(value: &str) -> bool { + value.contains("progress") + || value.contains("doing") + || value.contains("review") + || value.contains("develop") + || value.contains("curso") + || value.contains("progreso") + || value.contains("desarrollo") + || value.contains("revision") + || value.contains("progresso") + || value.contains("revisao") + || value.contains("desenvolvimento") + } + + fn is_primary(value: &str) -> bool { + value.contains("todo") + || value.contains("backlog") + || value.contains("ready") + || value.contains("planned") + || value.contains("triage") + || value.contains("open") + || value.contains("pendiente") + || value.contains("hacer") + || value.contains("abierto") + || value.contains("fazer") + || value.contains("planejado") + || value.contains("aberto") + || value.contains("pendente") + } + + fn is_warning(value: &str) -> bool { + value.contains("block") + || value.contains("hold") + || value.contains("wait") + || value.contains("paused") + || value.contains("pending") + || value.contains("bloqueado") + || value.contains("espera") + || value.contains("pausado") + } + + fn is_destructive(value: &str) -> bool { + value.contains("urgent") + || value.contains("critical") + || value.contains("high") + || value.contains("escalated") + || value.contains("urgente") + || value.contains("critico") + || value.contains("alta") + } + + fn is_neutral(value: &str) -> bool { + value.contains("archived") + || value.contains("cancelled") + || value.contains("wontfix") + || value.contains("duplicate") + || value.contains("archivado") + || value.contains("cancelado") + || value.contains("arquivado") + } + + fn hash_tone(&self, value: &str) -> ToneName { + let hash = Self::hash_string(value); + let tones = [ + ToneName::Primary, + ToneName::Accent, + ToneName::Success, + ToneName::Warning, + ToneName::Secondary, + ]; + tones[hash % tones.len()] + } + + fn hash_string(value: &str) -> usize { + let mut hash = 0usize; + for ch in value.chars() { + hash = hash.wrapping_mul(31).wrapping_add(ch as usize); + } + hash + } +} + +impl Default for BadgeToneMapper { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_known_statuses() { + let mapper = BadgeToneMapper::new(); + assert_eq!(mapper.map_status("Done"), ToneName::Success); + assert_eq!(mapper.map_status("In Progress"), ToneName::Accent); + assert_eq!(mapper.map_status("To Do"), ToneName::Primary); + assert_eq!(mapper.map_status("Blocked"), ToneName::Warning); + assert_eq!(mapper.map_status("Urgent"), ToneName::Destructive); + assert_eq!(mapper.map_status("Archived"), ToneName::Neutral); + } + + #[test] + fn maps_spanish_statuses() { + let mapper = BadgeToneMapper::new(); + assert_eq!(mapper.map_status("Terminado"), ToneName::Success); + assert_eq!(mapper.map_status("En progreso"), ToneName::Accent); + assert_eq!(mapper.map_status("Pendiente"), ToneName::Primary); + } + + #[test] + fn maps_labels_by_prefix() { + let mapper = BadgeToneMapper::new(); + assert_eq!(mapper.map_label("category::product"), ToneName::Accent); + assert_eq!(mapper.map_label("team::frontend"), ToneName::Secondary); + assert_eq!(mapper.map_label("priority::high"), ToneName::Destructive); + assert_eq!(mapper.map_label("priority::low"), ToneName::Neutral); + assert_eq!(mapper.map_label("workflow::done"), ToneName::Success); + } + + #[test] + fn falls_back_to_hash_for_unknowns() { + let mapper = BadgeToneMapper::new(); + let tone1 = mapper.map_status("CustomStateXYZ"); + let tone2 = mapper.map_status("CustomStateXYZ"); + assert_eq!(tone1, tone2); // Same unknown state gets same color + } +} diff --git a/src-tauri/src/support/mod.rs b/src-tauri/src/support/mod.rs index e84b9ec9..fd1fa64e 100644 --- a/src-tauri/src/support/mod.rs +++ b/src-tauri/src/support/mod.rs @@ -1,3 +1,4 @@ +pub mod badge_tone_mapper; pub mod holidays; pub mod iteration_label; pub mod logging; diff --git a/src/app/desktop/TauriService/tauri.ts b/src/app/desktop/TauriService/tauri.ts index 06d1906b..eede2f6b 100644 --- a/src/app/desktop/TauriService/tauri.ts +++ b/src/app/desktop/TauriService/tauri.ts @@ -28,6 +28,8 @@ import type { OAuthCallbackResolution, DiagnosticLogEntry, ProviderConnection, + ProviderConnectionInput, + ProviderKey, PlaySnapshot, PurchaseRewardInput, UnequipRewardInput, @@ -80,12 +82,22 @@ export async function listGitLabConnections(): Promise { return invokeTauri("list_gitlab_connections"); } +export async function listProviderConnections(): Promise { + return invokeTauri("list_provider_connections"); +} + export async function saveGitLabConnection( input: GitLabConnectionInput, ): Promise { return invokeTauri("save_gitlab_connection", { input }); } +export async function saveProviderConnection( + input: ProviderConnectionInput, +): Promise { + return invokeTauri("save_provider_connection", { input }); +} + export async function beginGitLabOAuth(input: GitLabConnectionInput): Promise { return invokeTauri("begin_gitlab_oauth", { input }); } @@ -102,6 +114,14 @@ export async function saveGitLabPat(host: string, token: string): Promise("save_gitlab_pat", { host, token }); } +export async function saveProviderPat( + provider: ProviderKey, + host: string, + token: string, +): Promise { + return invokeTauri("save_provider_pat", { provider, host, token }); +} + export async function listenForGitLabOAuthCallback( onSuccess: (payload: OAuthCallbackResolution) => void, onError: (message: string) => void, @@ -138,10 +158,21 @@ export async function validateGitLabToken(host: string): Promise return invokeTauri("validate_gitlab_token", { host }); } +export async function validateProviderToken( + provider: ProviderKey, + host: string, +): Promise { + return invokeTauri("validate_provider_token", { provider, host }); +} + export async function syncGitLab(): Promise { return invokeTauri("sync_gitlab"); } +export async function syncProviders(): Promise { + return invokeTauri("sync_providers"); +} + function mergeIssueDetailsNotModified( base: IssueDetailsSnapshot, delta: Extract, diff --git a/src/app/providers/I18nService/i18n.tsx b/src/app/providers/I18nService/i18n.tsx index 1a70026c..f7d7b1ce 100644 --- a/src/app/providers/I18nService/i18n.tsx +++ b/src/app/providers/I18nService/i18n.tsx @@ -107,6 +107,7 @@ const enMessages = { "sync.toastCompleteDescription": ({ projects, entries, issues }) => `${projects} projects, ${entries} entries, ${issues} issues synced.`, "sync.toastFailedTitle": "Sync failed", + "sync.slowSyncWarning": "Sync is taking longer than usual — still working in the background...", "topBar.lastSynced": ({ value }) => `Last synced: ${value}`, "topBar.sync": "Sync", "app.failedToLoad": "Failed to load Timely", @@ -298,6 +299,7 @@ const enMessages = { "issues.noIterationFilter": "No iteration", "issues.filterFortnight": "Iteration period", "issues.filterWorkflowStatus": "Status", + "issues.filterProvider": "Provider", "issues.filterAll": "All", "issues.filterOpened": "Open", "issues.filterClosed": "Closed", @@ -318,6 +320,7 @@ const enMessages = { "issues.jumpPages": ({ page }) => `Jump to page ${page}`, "issues.statusTodo": "To do", "issues.statusDoing": "Doing", + "issues.statusBlocked": "Blocked", "issues.statusDone": "Done", "issues.statusClosed": "Closed", "issues.statusOther": "Other", @@ -330,7 +333,7 @@ const enMessages = { "Toolbar supports headings, lists, code, links, and preview. Paste image URLs in markdown; uploading files uses GitLab in the browser.", "issues.issueCount": ({ count }) => `${count} ${Number(count) === 1 ? "issue" : "issues"}`, "issues.noSprint": "No milestone or iteration", - "issues.openInGitLab": "Open in GitLab", + "issues.openExternalIssue": ({ product }) => `Open in ${product}`, "issues.issueActions": "Issue actions", "issues.deleteIssue": "Delete issue", "issues.issueDeleted": "Issue deleted", @@ -349,7 +352,7 @@ const enMessages = { "issues.submitComment": "Post comment", "issues.closeIssueAction": "Close issue", "issues.reopenIssueAction": "Reopen issue", - "issues.timeLogged": "Time logged on GitLab", + "issues.timeLoggedOnProduct": ({ product }) => `Time logged on ${product}`, "issues.timeLogFailed": "Could not log time", "issues.noteAdded": "Comment posted", "issues.noteFailed": "Could not post comment", @@ -384,12 +387,20 @@ const enMessages = { "issues.metadataSection": "Details", "issues.metadataSectionHint": "Provider-backed controls for the fields this page can change.", "issues.statusField": "Status", + "issues.projectField": "Project", + "issues.typeField": "Type", + "issues.priorityField": "Priority", "issues.editStatus": "Edit status", "issues.selectStatus": "Select status", "issues.labelsField": "Labels", "issues.editLabels": "Edit labels", "issues.editField": "Edit", "issues.spentTimeField": "Spent time", + "issues.startDateField": "Start date", + "issues.dueDateField": "Due date", + "issues.estimateWeightField": "Estimate / Weight", + "issues.participantsField": "Participants", + "issues.parentField": "Parent", "issues.noTimeLogged": "No time logged", "issues.logTimeAction": "Log time", "issues.selectLabels": "Select labels", @@ -612,8 +623,6 @@ const enMessages = { "settings.savingSchedule": "Saving changes...", "settings.scheduleSaved": "Schedule saved", "settings.scheduleSaveToastSuccessTitle": "Schedule updated", - "settings.scheduleSaveToastSuccessDescription": - "Your weekly hours, timezone, and first day of the week are saved. Worklog and daily targets use the new values now.", "settings.scheduleSaveToastErrorTitle": "Could not save schedule", "settings.scheduleSaveToastErrorFallback": "Something went wrong while saving. Please try again.", "settings.updates": "Updates", @@ -723,6 +732,14 @@ const enMessages = { "providers.callbackValidationFailed": ({ error }) => `Callback validation failed: ${error}`, "providers.oauthPkceFallback": "OAuth PKCE + PAT fallback", "providers.oauthPkce": "OAuth PKCE", + "providers.connect": "Connect", + "providers.connectYouTrack": "Connect YouTrack", + "providers.linkYouTrack": "Link your YouTrack workspace to track work items.", + "providers.youTrackHost": "YouTrack host", + "providers.youTrackToken": "Permanent Token", + "providers.youTrackTokenHint": + "Connect with a permanent token. OAuth is not required for YouTrack.", + "providers.youTrackConnected": "YouTrack connected.", "about.title": "About Timely", "about.subtitle": "Build details for your installed desktop app.", "about.versionLabel": "Version", @@ -757,8 +774,8 @@ const enMessages = { "setup.scheduleStepWeeklyTitle": "Your weekly hours", "setup.scheduleStepWeeklyDescription": "Turn workdays on or off and set start, end, and lunch for each day.", - "setup.providerTitle": "Connect GitLab", - "setup.providerDescription": "Link your account to start tracking time", + "setup.providerTitle": "Connect a Provider", + "setup.providerDescription": "Link your account to start tracking time.", "setup.syncTitle": "Sync your data", "setup.syncDescriptionConnected": "Pulling your worklogs from GitLab", "setup.syncDescriptionDisconnected": "You can sync later from Settings", @@ -1138,6 +1155,8 @@ const esMessages: MessageDictionary = { "sync.toastCompleteDescription": ({ projects, entries, issues }) => `${projects} proyectos, ${entries} entradas, ${issues} incidencias sincronizadas.`, "sync.toastFailedTitle": "La sincronización falló", + "sync.slowSyncWarning": + "La sincronización está tardando más de lo usual — sigue trabajando en segundo plano...", "topBar.lastSynced": ({ value }) => `Última sincronización: ${value}`, "topBar.sync": "Sincronizar", "app.failedToLoad": "No se pudo cargar Timely", @@ -1293,6 +1312,7 @@ const esMessages: MessageDictionary = { "issues.noIterationFilter": "Sin iteración", "issues.filterFortnight": "Periodo de iteración", "issues.filterWorkflowStatus": "Estado", + "issues.filterProvider": "Proveedor", "issues.filterAll": "Todos", "issues.filterOpened": "Abiertas", "issues.filterClosed": "Cerradas", @@ -1313,6 +1333,7 @@ const esMessages: MessageDictionary = { "issues.jumpPages": ({ page }) => `Ir a la página ${page}`, "issues.statusTodo": "Por hacer", "issues.statusDoing": "En curso", + "issues.statusBlocked": "Bloqueado", "issues.statusDone": "Hecho", "issues.statusClosed": "Cerrada", "issues.statusOther": "Otras", @@ -1327,7 +1348,7 @@ const esMessages: MessageDictionary = { "issues.issueCount": ({ count }) => `${count} ${Number(count) === 1 ? "incidencia" : "incidencias"}`, "issues.noSprint": "Sin hito ni iteración", - "issues.openInGitLab": "Abrir en GitLab", + "issues.openExternalIssue": ({ product }) => `Abrir en ${product}`, "issues.issueActions": "Acciones de la incidencia", "issues.deleteIssue": "Eliminar incidencia", "issues.issueDeleted": "Incidencia eliminada", @@ -1346,7 +1367,7 @@ const esMessages: MessageDictionary = { "issues.submitComment": "Publicar comentario", "issues.closeIssueAction": "Cerrar incidencia", "issues.reopenIssueAction": "Reabrir incidencia", - "issues.timeLogged": "Tiempo registrado en GitLab", + "issues.timeLoggedOnProduct": ({ product }) => `Tiempo registrado en ${product}`, "issues.timeLogFailed": "No se pudo registrar el tiempo", "issues.noteAdded": "Comentario publicado", "issues.noteFailed": "No se pudo publicar el comentario", @@ -1384,12 +1405,20 @@ const esMessages: MessageDictionary = { "issues.metadataSectionHint": "Controles del proveedor para los campos que esta vista puede cambiar.", "issues.statusField": "Estado", + "issues.projectField": "Proyecto", + "issues.typeField": "Tipo", + "issues.priorityField": "Prioridad", "issues.editStatus": "Editar estado", "issues.selectStatus": "Elegir estado", "issues.labelsField": "Etiquetas", "issues.editLabels": "Editar etiquetas", "issues.editField": "Editar", "issues.spentTimeField": "Tiempo registrado", + "issues.startDateField": "Fecha de inicio", + "issues.dueDateField": "Fecha límite", + "issues.estimateWeightField": "Estimación / Peso", + "issues.participantsField": "Participantes", + "issues.parentField": "Elemento principal", "issues.noTimeLogged": "Sin tiempo registrado", "issues.logTimeAction": "Registrar tiempo", "issues.selectLabels": "Elegir etiquetas", @@ -1619,8 +1648,6 @@ const esMessages: MessageDictionary = { "settings.savingSchedule": "Guardando cambios...", "settings.scheduleSaved": "Horario guardado", "settings.scheduleSaveToastSuccessTitle": "Horario actualizado", - "settings.scheduleSaveToastSuccessDescription": - "Se guardaron tus horas semanales, la zona horaria y el primer día de la semana. El registro de trabajo y los objetivos diarios ya usan los valores nuevos.", "settings.scheduleSaveToastErrorTitle": "No se pudo guardar el horario", "settings.scheduleSaveToastErrorFallback": "Algo falló al guardar. Vuelve a intentarlo.", "settings.updates": "Actualizaciones", @@ -1731,6 +1758,14 @@ const esMessages: MessageDictionary = { "providers.callbackValidationFailed": ({ error }) => `La validación del callback falló: ${error}`, "providers.oauthPkceFallback": "OAuth PKCE + respaldo PAT", "providers.oauthPkce": "OAuth PKCE", + "providers.connect": "Conectar", + "providers.connectYouTrack": "Conectar YouTrack", + "providers.linkYouTrack": "Vincula tu espacio de YouTrack para registrar elementos de trabajo.", + "providers.youTrackHost": "Host de YouTrack", + "providers.youTrackToken": "Token permanente", + "providers.youTrackTokenHint": + "Conéctate con un token permanente. OAuth no es necesario para YouTrack.", + "providers.youTrackConnected": "YouTrack conectado.", "about.title": "Acerca de Timely", "about.subtitle": "Información de esta app de escritorio.", "about.versionLabel": "Versión", @@ -1765,8 +1800,8 @@ const esMessages: MessageDictionary = { "setup.scheduleStepWeeklyTitle": "Tus horas de la semana", "setup.scheduleStepWeeklyDescription": "Activa o desactiva días laborables y define inicio, fin y almuerzo para cada día.", - "setup.providerTitle": "Conectar GitLab", - "setup.providerDescription": "Vincula tu cuenta para empezar a registrar tiempo", + "setup.providerTitle": "Conectar un proveedor", + "setup.providerDescription": "Vincula tu cuenta para empezar a registrar tiempo.", "setup.syncTitle": "Sincroniza tus datos", "setup.syncDescriptionConnected": "Trayendo tus worklogs desde GitLab", "setup.syncDescriptionDisconnected": "Podrás sincronizar más tarde desde Ajustes", @@ -2148,6 +2183,8 @@ const ptMessages: MessageDictionary = { "sync.toastCompleteDescription": ({ projects, entries, issues }) => `${projects} projetos, ${entries} registros e ${issues} issues sincronizados.`, "sync.toastFailedTitle": "Falha na sincronização", + "sync.slowSyncWarning": + "A sincronização está demorando mais que o habitual — continue trabalhando em segundo plano...", "topBar.lastSynced": ({ value }) => `Última sincronização: ${value}`, "topBar.sync": "Sincronizar", "app.failedToLoad": "Falha ao carregar o Timely", @@ -2339,6 +2376,7 @@ const ptMessages: MessageDictionary = { "issues.noIterationFilter": "Sem iteração", "issues.filterFortnight": "Período da iteração", "issues.filterWorkflowStatus": "Estado", + "issues.filterProvider": "Provedor", "issues.filterAll": "Todos", "issues.filterOpened": "Abertos", "issues.filterClosed": "Fechados", @@ -2359,6 +2397,7 @@ const ptMessages: MessageDictionary = { "issues.jumpPages": ({ page }) => `Ir para a página ${page}`, "issues.statusTodo": "A fazer", "issues.statusDoing": "Em andamento", + "issues.statusBlocked": "Bloqueado", "issues.statusDone": "Concluído", "issues.statusClosed": "Fechado", "issues.statusOther": "Outras", @@ -2371,7 +2410,7 @@ const ptMessages: MessageDictionary = { "A barra oferece títulos, listas, código, links e pré-visualização. Para imagens, cole o endereço no texto; para enviar arquivos use o GitLab no navegador.", "issues.issueCount": ({ count }) => `${count} ${Number(count) === 1 ? "item" : "itens"}`, "issues.noSprint": "Sem marco nem iteração", - "issues.openInGitLab": "Abrir no GitLab", + "issues.openExternalIssue": ({ product }) => `Abrir no ${product}`, "issues.issueActions": "Ações do item", "issues.deleteIssue": "Excluir item", "issues.issueDeleted": "Item excluído", @@ -2390,7 +2429,7 @@ const ptMessages: MessageDictionary = { "issues.submitComment": "Publicar comentário", "issues.closeIssueAction": "Fechar item", "issues.reopenIssueAction": "Reabrir item", - "issues.timeLogged": "Tempo registrado no GitLab", + "issues.timeLoggedOnProduct": ({ product }) => `Tempo registrado no ${product}`, "issues.timeLogFailed": "Não foi possível registrar o tempo", "issues.noteAdded": "Comentário publicado", "issues.noteFailed": "Não foi possível publicar o comentário", @@ -2428,12 +2467,20 @@ const ptMessages: MessageDictionary = { "issues.metadataSectionHint": "Controles do provedor para os campos que esta tela consegue alterar.", "issues.statusField": "Estado", + "issues.projectField": "Projeto", + "issues.typeField": "Tipo", + "issues.priorityField": "Prioridade", "issues.editStatus": "Editar estado", "issues.selectStatus": "Escolher estado", "issues.labelsField": "Etiquetas", "issues.editLabels": "Editar etiquetas", "issues.editField": "Editar", "issues.spentTimeField": "Tempo registrado", + "issues.startDateField": "Data de início", + "issues.dueDateField": "Data limite", + "issues.estimateWeightField": "Estimativa / Peso", + "issues.participantsField": "Participantes", + "issues.parentField": "Item principal", "issues.noTimeLogged": "Sem tempo registrado", "issues.logTimeAction": "Registrar tempo", "issues.selectLabels": "Escolher etiquetas", @@ -2666,8 +2713,6 @@ const ptMessages: MessageDictionary = { "settings.savingSchedule": "Salvando alterações...", "settings.scheduleSaved": "Agenda salva", "settings.scheduleSaveToastSuccessTitle": "Agenda atualizada", - "settings.scheduleSaveToastSuccessDescription": - "Sua semana de trabalho, fuso horário e primeiro dia da semana foram gravados. O registro de trabalho e as metas diárias já usam os novos valores.", "settings.scheduleSaveToastErrorTitle": "Não foi possível salvar a agenda", "settings.scheduleSaveToastErrorFallback": "Algo deu errado ao salvar. Tente novamente.", "settings.updates": "Atualizações", @@ -2777,6 +2822,14 @@ const ptMessages: MessageDictionary = { "providers.callbackValidationFailed": ({ error }) => `Falha na validação do callback: ${error}`, "providers.oauthPkceFallback": "OAuth PKCE + fallback PAT", "providers.oauthPkce": "OAuth PKCE", + "providers.connect": "Conectar", + "providers.connectYouTrack": "Conectar YouTrack", + "providers.linkYouTrack": "Conecte seu espaço YouTrack para registrar itens de trabalho.", + "providers.youTrackHost": "Host do YouTrack", + "providers.youTrackToken": "Token permanente", + "providers.youTrackTokenHint": + "Conecte-se com um token permanente. OAuth não é necessário para YouTrack.", + "providers.youTrackConnected": "YouTrack conectado.", "about.title": "Sobre o Timely", "about.subtitle": "Informações deste app para desktop.", "about.versionLabel": "Versão", @@ -2814,8 +2867,8 @@ const ptMessages: MessageDictionary = { "setup.scheduleStepWeeklyTitle": "Suas horas da semana", "setup.scheduleStepWeeklyDescription": "Ative ou desative dias úteis e defina início, fim e intervalo de almoço para cada dia.", - "setup.providerTitle": "Conectar GitLab", - "setup.providerDescription": "Conecte sua conta para começar a rastrear tempo", + "setup.providerTitle": "Conectar um provedor", + "setup.providerDescription": "Conecte sua conta para começar a registrar tempo.", "setup.syncTitle": "Sincronize seus dados", "setup.syncDescriptionConnected": "Buscando seus worklogs do GitLab", "setup.syncDescriptionDisconnected": "Você pode sincronizar depois em Configurações", @@ -3201,7 +3254,7 @@ const enVoiceOverrides = { "Tour complete. Connect GitLab in Settings and let the real numbers roll in.", "setup.welcomeDescription": "Your playful worklog sidekick. Let’s get your desk sorted.", "setup.scheduleDescription": "Pick your working hours and the days that count.", - "setup.providerDescription": "Connect GitLab so Timely knows where the work lives.", + "setup.providerDescription": "Connect a provider so Timely knows where the work lives.", "setup.syncDescriptionConnected": "Pulling your GitLab worklogs into place.", "setup.syncDescriptionDisconnected": "You can sync later from Settings if you want to keep moving.", @@ -3381,7 +3434,7 @@ const esVoiceOverrides = { "setup.welcomeDescription": "Tu compañero juguetón para el Registro de trabajo. Vamos a dejarte el escritorio en orden.", "setup.scheduleDescription": "Elige tus horas de trabajo y los días que sí cuentan.", - "setup.providerDescription": "Conecta GitLab para que Timely sepa dónde vive el trabajo.", + "setup.providerDescription": "Conecta un proveedor para que Timely sepa dónde vive el trabajo.", "setup.syncDescriptionConnected": "Trayendo tus registros de GitLab a su sitio.", "setup.syncDescriptionDisconnected": "Si quieres seguir ahora, puedes sincronizar más tarde desde Ajustes.", @@ -3558,7 +3611,7 @@ const ptVoiceOverrides = { "setup.welcomeDescription": "Seu companheiro brincalhão para o Registro de trabalho. Vamos deixar sua mesa em ordem.", "setup.scheduleDescription": "Escolha seu horário e os dias que realmente contam.", - "setup.providerDescription": "Conecte o GitLab para o Timely saber onde o trabalho mora.", + "setup.providerDescription": "Conecte um provedor para o Timely saber onde o trabalho mora.", "setup.syncDescriptionConnected": "Trazendo seus registros do GitLab para o lugar certo.", "setup.syncDescriptionDisconnected": "Se quiser seguir agora, você pode sincronizar depois em Configurações.", diff --git a/src/app/root/App/App.test.tsx b/src/app/root/App/App.test.tsx index fd068a9c..0f061af5 100644 --- a/src/app/root/App/App.test.tsx +++ b/src/app/root/App/App.test.tsx @@ -53,7 +53,7 @@ vi.mock("@/app/desktop/TauriService/tauri", async () => { ); return { ...actual, - listGitLabConnections: vi.fn(async () => []), + listProviderConnections: vi.fn(async () => []), loadBootstrapPayload: vi.fn(async () => mockBootstrap), loadAppPreferences: vi.fn(async () => ({ themeMode: "system", @@ -428,7 +428,7 @@ beforeEach(async () => { resetWorklogSnapshotCache(); globalThis.localStorage.clear(); vi.mocked(tauriModule.loadSetupState).mockReset().mockResolvedValue(COMPLETE_SETUP); - vi.mocked(tauriModule.listGitLabConnections).mockReset().mockResolvedValue([]); + vi.mocked(tauriModule.listProviderConnections).mockReset().mockResolvedValue([]); vi.mocked(tauriModule.loadBootstrapPayload).mockReset().mockResolvedValue(mockBootstrap); vi.mocked(tauriModule.loadAppPreferences) .mockReset() diff --git a/src/app/root/App/App.tsx b/src/app/root/App/App.tsx index 9c282141..7054bdf1 100644 --- a/src/app/root/App/App.tsx +++ b/src/app/root/App/App.tsx @@ -24,11 +24,11 @@ import { restartApp, resolveGitLabOAuthCallback, resetAllData, - saveGitLabConnection, - saveGitLabPat, + saveProviderConnection, + saveProviderPat, updateSchedule, updateTrayIcon, - validateGitLabToken, + validateProviderToken, } from "@/app/desktop/TauriService/tauri"; import { MainLayout } from "@/app/layouts/MainLayout/MainLayout"; import { useI18n } from "@/app/providers/I18nService/i18n"; @@ -56,7 +56,7 @@ import { TooltipProvider } from "@/shared/ui/Tooltip/Tooltip"; import type { BootstrapPayload, - GitLabConnectionInput, + ProviderConnectionInput, WorklogMode, } from "@/shared/types/dashboard"; @@ -502,21 +502,29 @@ function SettingsRoute() { connections={connections} syncState={syncState} onStartSync={startSync} - onSaveConnection={async (input: GitLabConnectionInput) => { - const saved = await saveGitLabConnection(input); + onSaveConnection={async (input: ProviderConnectionInput) => { + const saved = await saveProviderConnection(input); await refreshConnections(); return saved; }} - onSavePat={async (host: string, token: string) => { - const saved = await saveGitLabPat(host, token); + onSavePat={async (provider, host: string, token: string) => { + const saved = await saveProviderPat(provider, host, token); await refreshConnections(); return saved; }} - onBeginOAuth={beginGitLabOAuth} + onBeginOAuth={(input) => + beginGitLabOAuth({ + host: input.host, + authMode: input.authMode, + preferredScope: input.preferredScope, + displayName: input.displayName, + clientId: input.clientId, + }) + } onResolveCallback={(sessionId: string, callbackUrl: string) => resolveGitLabOAuthCallback({ sessionId, callbackUrl }) } - onValidateToken={validateGitLabToken} + onValidateToken={validateProviderToken} onListenOAuthEvents={listenForGitLabOAuthCallback} onCheckForUpdates={checkForAppUpdateChannel} onInstallUpdate={downloadAndInstallAppUpdate} diff --git a/src/app/routes/SetupRoutes/SetupRoutes.tsx b/src/app/routes/SetupRoutes/SetupRoutes.tsx index 9f510606..a1d4001f 100644 --- a/src/app/routes/SetupRoutes/SetupRoutes.tsx +++ b/src/app/routes/SetupRoutes/SetupRoutes.tsx @@ -10,10 +10,10 @@ import { beginGitLabOAuth, listenForGitLabOAuthCallback, resolveGitLabOAuthCallback, - saveGitLabConnection, - saveGitLabPat, + saveProviderConnection, + saveProviderPat, updateSchedule, - validateGitLabToken, + validateProviderToken, } from "@/app/desktop/TauriService/tauri"; import { SetupShell } from "@/app/layouts/SetupLayout/components/SetupShell/SetupShell"; import { @@ -45,7 +45,7 @@ import { hasActiveConnection } from "@/shared/types/dashboard"; import type { BootstrapPayload, - GitLabConnectionInput, + ProviderConnectionInput, ScheduleInput, SetupState, TimeFormat, @@ -146,21 +146,29 @@ export function SetupProviderRouteComponent() { navigate({ to: "/setup/sync" }); persistSetupStep(completeSetupStep, "provider", t); }} - onSaveConnection={async (input: GitLabConnectionInput) => { - const saved = await saveGitLabConnection(input); + onSaveConnection={async (input: ProviderConnectionInput) => { + const saved = await saveProviderConnection(input); await refreshConnections(); return saved; }} - onSavePat={async (host: string, token: string) => { - const saved = await saveGitLabPat(host, token); + onSavePat={async (provider, host: string, token: string) => { + const saved = await saveProviderPat(provider, host, token); await refreshConnections(); return saved; }} - onBeginOAuth={beginGitLabOAuth} + onBeginOAuth={(input) => + beginGitLabOAuth({ + host: input.host, + authMode: input.authMode, + preferredScope: input.preferredScope, + displayName: input.displayName, + clientId: input.clientId, + }) + } onResolveCallback={(sessionId: string, callbackUrl: string) => resolveGitLabOAuthCallback({ sessionId, callbackUrl }) } - onValidateToken={validateGitLabToken} + onValidateToken={validateProviderToken} onListenOAuthEvents={listenForGitLabOAuthCallback} /> ); diff --git a/src/app/state/AppStore/app-store.test.ts b/src/app/state/AppStore/app-store.test.ts index adf7b7c6..3391bc71 100644 --- a/src/app/state/AppStore/app-store.test.ts +++ b/src/app/state/AppStore/app-store.test.ts @@ -9,9 +9,9 @@ vi.mock("@/app/desktop/TauriService/tauri", async () => { ); return { ...actual, - syncGitLab: vi.fn(), + syncProviders: vi.fn(), listenSyncProgress: vi.fn(async () => () => {}), - listGitLabConnections: vi.fn(async () => []), + listProviderConnections: vi.fn(async () => []), loadBootstrapPayload: vi.fn(async () => { const { mockBootstrap } = await import("@/test/fixtures/mock-data"); return mockBootstrap; @@ -64,7 +64,7 @@ function resetStore() { beforeEach(() => { resetStore(); - vi.mocked(tauriModule.syncGitLab).mockReset().mockResolvedValue(MOCK_RESULT); + vi.mocked(tauriModule.syncProviders).mockReset().mockResolvedValue(MOCK_RESULT); vi.mocked(tauriModule.listenSyncProgress) .mockReset() .mockResolvedValue(() => {}); @@ -108,7 +108,7 @@ describe("startSync", () => { }); it("does NOT increment syncVersion when sync fails", async () => { - vi.mocked(tauriModule.syncGitLab).mockRejectedValue(new Error("network error")); + vi.mocked(tauriModule.syncProviders).mockRejectedValue(new Error("network error")); await useAppStore.getState().startSync(); @@ -122,8 +122,8 @@ describe("startSync", () => { await useAppStore.getState().startSync(); - // syncGitLab should NOT have been called since we were already syncing - expect(tauriModule.syncGitLab).not.toHaveBeenCalled(); + // syncProviders should NOT have been called since we were already syncing + expect(tauriModule.syncProviders).not.toHaveBeenCalled(); }); it("appends a summary line to the log on success", async () => { diff --git a/src/app/state/AppStore/app-store.ts b/src/app/state/AppStore/app-store.ts index a05107cc..d5c0a91f 100644 --- a/src/app/state/AppStore/app-store.ts +++ b/src/app/state/AppStore/app-store.ts @@ -5,15 +5,12 @@ import { } from "@/app/bootstrap/PreferencesCache/preferences-cache"; import { clearStartupAppSnapshot } from "@/app/bootstrap/StartupAppState/startup-app-state"; import { - listGitLabConnections, + listProviderConnections, loadSetupState, loadBootstrapPayload, saveSetupState, } from "@/app/desktop/TauriService/tauri"; -import { - createBootstrapAction, - createStartSyncAction, -} from "@/app/state/AppStore/internal/app-store-actions"; +import { createBootstrapAction } from "@/app/state/AppStore/internal/app-store-actions"; import { getCompletedSetupState, getNextSetupState, @@ -23,6 +20,7 @@ import { persistStartupSnapshotFromStore, syncTrayIcon, } from "@/app/state/AppStore/internal/app-store-snapshot"; +import { createStartSyncAction } from "@/app/state/AppStore/internal/app-store-sync-actions"; import { type AppState } from "@/app/state/AppStore/internal/app-store-types"; export const useAppStore = create((set, get) => ({ @@ -42,7 +40,7 @@ export const useAppStore = create((set, get) => ({ bootstrap: createBootstrapAction(set, get), refreshConnections: async () => { - const next = await listGitLabConnections(); + const next = await listProviderConnections(); set({ connections: next }); persistStartupSnapshotFromStore(get()); }, @@ -50,7 +48,7 @@ export const useAppStore = create((set, get) => ({ refreshPayload: async () => { const [payload, connections, setupState] = await Promise.all([ loadBootstrapPayload(), - listGitLabConnections(), + listProviderConnections(), loadSetupState(), ]); set((state) => ({ diff --git a/src/app/state/AppStore/internal/app-store-actions.ts b/src/app/state/AppStore/internal/app-store-actions.ts index c2a512bd..f27630de 100644 --- a/src/app/state/AppStore/internal/app-store-actions.ts +++ b/src/app/state/AppStore/internal/app-store-actions.ts @@ -5,23 +5,22 @@ import { } from "@/app/bootstrap/PreferencesCache/preferences-cache"; import { syncStartupPrefsWithPreferences } from "@/app/bootstrap/StartupPrefs/startup-prefs"; import { - listGitLabConnections, - listenSyncProgress, + listProviderConnections, loadAppPreferences, loadBootstrapPayload, loadSetupState, logFrontendBootTiming, requestNotificationPermission, saveSetupState, - syncGitLab, } from "@/app/desktop/TauriService/tauri"; import { persistStartupSnapshot, syncTrayIcon, } from "@/app/state/AppStore/internal/app-store-snapshot"; -import { type AppStoreGet, type AppStoreSet } from "@/app/state/AppStore/internal/app-store-types"; import { getCountryCodeForTimezone, normalizeHolidayCountryMode } from "@/shared/lib/utils"; +import type { AppStoreGet, AppStoreSet } from "@/app/state/AppStore/internal/app-store-types"; + export function logStoreBoot(message: string): void { const elapsed = getBootElapsedMs(); void logFrontendBootTiming(`[app-store] ${message}`, elapsed).catch(() => { @@ -49,7 +48,7 @@ export function createBootstrapAction(set: AppStoreSet, get: AppStoreGet) { try { let [payload, connections, setupState, preferences] = await Promise.all([ timedStoreCall("bootstrap_dashboard", () => loadBootstrapPayload()), - timedStoreCall("list_gitlab_connections", () => listGitLabConnections()), + timedStoreCall("list_provider_connections", () => listProviderConnections()), timedStoreCall("load_setup_state", () => loadSetupState()), timedStoreCall("load_app_preferences", () => loadAppPreferences()), ]); @@ -124,53 +123,3 @@ export function createBootstrapAction(set: AppStoreSet, get: AppStoreGet) { } }; } - -export function createStartSyncAction(set: AppStoreSet, get: AppStoreGet) { - return async (manual = true) => { - const { syncState, refreshPayload } = get(); - if (syncState.status === "syncing") return; - - set({ syncState: { status: "syncing", log: [] }, lastSyncWasManual: manual }); - - let unlisten = () => {}; - - try { - unlisten = await listenSyncProgress((line) => { - const current = get().syncState; - set({ syncState: { ...current, log: [...current.log, line] } }); - }); - } catch (error) { - const message = String(error); - const current = get().syncState; - set({ syncState: { ...current, log: [...current.log, `WARN: ${message}`] } }); - } - - try { - const result = await syncGitLab(); - const current = get().syncState; - set({ - syncState: { - status: "done", - result, - log: [ - ...current.log, - `Synced ${result.projectsSynced} projects, ${result.entriesSynced} entries, ${result.issuesSynced} issues, ${result.assignedIssuesSynced} assigned.`, - ], - }, - }); - await refreshPayload(); - } catch (error) { - const message = String(error); - const current = get().syncState; - set({ - syncState: { - status: "error", - error: message, - log: [...current.log, `ERROR: ${message}`], - }, - }); - } finally { - unlisten(); - } - }; -} diff --git a/src/app/state/AppStore/internal/app-store-sync-actions.ts b/src/app/state/AppStore/internal/app-store-sync-actions.ts new file mode 100644 index 00000000..3e4bfd6f --- /dev/null +++ b/src/app/state/AppStore/internal/app-store-sync-actions.ts @@ -0,0 +1,145 @@ +import { listenSyncProgress, syncProviders } from "@/app/desktop/TauriService/tauri"; +import { + createSyncToastUsage, + getAutoSyncDelayMessage, + getSyncDelayMessage, + SYNC_BACKOFF_MS, +} from "@/app/state/AppStore/internal/sync-progress-messages"; + +import type { AppStoreGet, AppStoreSet } from "@/app/state/AppStore/internal/app-store-types"; + +export function createStartSyncAction(set: AppStoreSet, get: AppStoreGet) { + return async (manual = true) => { + const { syncState, refreshPayload } = get(); + if (syncState.status === "syncing") return; + + const isManual = manual; + set({ syncState: { status: "syncing", log: [] }, lastSyncWasManual: isManual }); + + // Detect which provider is currently active from the live log stream + let currentProvider: "gitlab" | "youtrack" | null = null; + + // ── Auto-sync: single gentle warning at 90s (no spam) ────────────── + if (!isManual) { + let autoToastFired = false; + const autoTimer = setTimeout(() => { + if (get().syncState.status !== "syncing" || autoToastFired) return; + autoToastFired = true; + import("sonner").then(({ toast }) => { + toast.warning(getAutoSyncDelayMessage(currentProvider), { duration: 8000 }); + }); + }, 90_000); + + const cleanup = () => clearTimeout(autoTimer); + let unlisten = () => {}; + try { + unlisten = await listenSyncProgress((line) => { + const current = get().syncState; + if (line.includes("Starting GitLab sync")) currentProvider = "gitlab"; + else if (line.includes("Starting YouTrack sync")) currentProvider = "youtrack"; + set({ syncState: { ...current, log: [...current.log, line] } }); + }); + } catch {} + try { + const result = await syncProviders(); + cleanup(); + const current = get().syncState; + set({ + syncState: { + status: "done", + result, + log: [ + ...current.log, + `Synced ${result.projectsSynced} projects, ${result.entriesSynced} entries, ${result.issuesSynced} issues, ${result.assignedIssuesSynced} assigned.`, + ], + }, + }); + await refreshPayload(); + } catch (error) { + cleanup(); + const message = String(error); + const current = get().syncState; + set({ + syncState: { + status: "error", + error: message, + log: [...current.log, `ERROR: ${message}`], + }, + }); + } finally { + unlisten(); + } + return; + } + + // ── Manual sync: recurring toasts with exponential backoff ──────────── + let toastIndex = 0; + const syncToastUsage = createSyncToastUsage(); + + const showNextToast = () => { + import("sonner").then(({ toast }) => { + toast.warning(getSyncDelayMessage(currentProvider, syncToastUsage), { duration: 12000 }); + }); + }; + + let timerId: ReturnType | null = null; + const scheduleNext = (idx: number) => { + if (idx >= SYNC_BACKOFF_MS.length) return; + timerId = setTimeout(() => { + if (get().syncState.status !== "syncing") return; + showNextToast(); + toastIndex++; + scheduleNext(toastIndex); + }, SYNC_BACKOFF_MS[idx]); + }; + scheduleNext(0); // first at 30s + + let unlisten = () => {}; + try { + unlisten = await listenSyncProgress((line) => { + const current = get().syncState; + if (line.includes("Starting GitLab sync")) currentProvider = "gitlab"; + else if (line.includes("Starting YouTrack sync")) currentProvider = "youtrack"; + set({ syncState: { ...current, log: [...current.log, line] } }); + }); + } catch (error) { + const message = String(error); + const current = get().syncState; + set({ syncState: { ...current, log: [...current.log, `WARN: ${message}`] } }); + } + + try { + const result = await syncProviders(); + if (timerId != null) { + clearTimeout(timerId); + } + const current = get().syncState; + set({ + syncState: { + status: "done", + result, + log: [ + ...current.log, + `Synced ${result.projectsSynced} projects, ${result.entriesSynced} entries, ${result.issuesSynced} issues, ${result.assignedIssuesSynced} assigned.`, + ], + }, + }); + await refreshPayload(); + } catch (error) { + if (timerId != null) { + clearTimeout(timerId); + } + const message = String(error); + const current = get().syncState; + set({ + syncState: { + status: "error", + error: message, + log: [...current.log, `ERROR: ${message}`], + }, + }); + } finally { + unlisten(); + } + }; +} diff --git a/src/app/state/AppStore/internal/sync-progress-messages.json b/src/app/state/AppStore/internal/sync-progress-messages.json new file mode 100644 index 00000000..fb16e06b --- /dev/null +++ b/src/app/state/AppStore/internal/sync-progress-messages.json @@ -0,0 +1,463 @@ +{ + "youtrack": [ + { + "en": "GitLab done — YouTrack is still going, hold on...", + "es": "GitLab listo — YouTrack sigue en lo suyo, paciencia...", + "pt": "GitLab concluído — YouTrack ainda está lá, aguarde..." + }, + { + "en": "Grab a coffee — YouTrack is still syncing...", + "es": "¿Qué tal una pausa para el café? YouTrack sigue synqueando...", + "pt": "Que tal uma pausa para o café? YouTrack ainda está syncando..." + }, + { + "en": "YouTrack server being slow again, sorry about this...", + "es": "YouTrack: el servidor está siendo lento otra vez, lo siento...", + "pt": "YouTrack: o servidor está lento de novo, desculpa..." + }, + { + "en": "YouTrack really wants to finish this — be right back...", + "es": "YouTrack quiere terminar esto de verdad — ya vuelve...", + "pt": "YouTrack quer terminar isso mesmo — já volta..." + }, + { + "en": "YouTrack.exe is not responding — just kidding it is...", + "es": "YouTrack.exe no responde — mentira, sí responde...", + "pt": "YouTrack.exe não responde — mentira, responde sim..." + }, + { + "en": "YouTrack is taking the scenic route today...", + "es": "YouTrack está tomando la ruta panorámica hoy...", + "pt": "YouTrack está pelo caminho longo hoje..." + }, + { + "en": "YouTrack has more issues than a Monday morning...", + "es": "YouTrack tiene más incidencias que un lunes a la mañana...", + "pt": "YouTrack tem mais issues que uma segunda-feira de manhã..." + }, + { + "en": "Still waiting on YouTrack — your coffee is getting cold...", + "es": "Seguimos esperando a YouTrack — tu café se está enfriando...", + "pt": "Ainda esperando o YouTrack — seu café está esfriando..." + }, + { + "en": "YouTrack is fetching everything including the kitchen sink...", + "es": "YouTrack está trayendo de todo, hasta el fregadero...", + "pt": "YouTrack está trazendo de tudo, até a pia da cozinha..." + }, + { + "en": "YouTrack: we promise it's almost done...", + "es": "YouTrack: prometemos que casi termina...", + "pt": "YouTrack: prometemos que já quase terminou..." + }, + { + "en": "YouTrack loading spinner has been spinning for a while now...", + "es": "El spinner de YouTrack lleva un buen rato girando...", + "pt": "O spinner do YouTrack está girando faz um tempão..." + }, + { + "en": "YouTrack on a coffee break — back in a bit...", + "es": "YouTrack en pausa de café — ya regresa...", + "pt": "YouTrack em pausa de café — já volta..." + }, + { + "en": "YouTrack is slow but at least it's not a spinner with a percentage...", + "es": "YouTrack es lento pero al menos no es un spinner con porcentaje...", + "pt": "YouTrack é lento mas pelo menos não é um spinner com porcentagem..." + }, + { + "en": "Somewhere a YouTrack server is working hard for you...", + "es": "En algún lugar un servidor de YouTrack trabaja duro por ti...", + "pt": "Em algum lugar um servidor do YouTrack trabalha pesado por você..." + }, + { + "en": "YouTrack has never been this slow — probably a sign of a healthy backlog?", + "es": "YouTrack nunca había estado tan lento — ¿será señal de un backlog saludable?", + "pt": "YouTrack nunca ficou tão lento — será que é sinal de um backlog saudável?" + }, + { + "en": "If YouTrack were a person it would be apologizing for this...", + "es": "Si YouTrack fuera una persona estaría disculpándose por esto...", + "pt": "Se YouTrack fosse uma pessoa estaria se desculpando por isso..." + }, + { + "en": "YouTrack is bringing your issues home — almost there...", + "es": "YouTrack está trayendo tus incidencias a casa — ya casi...", + "pt": "YouTrack está trazendo seus issues pra casa — já quase..." + }, + { + "en": "YouTrack: patience is a virtue and you're learning it today...", + "es": "YouTrack: la paciencia es una virtud y hoy la estás practicando...", + "pt": "YouTrack: paciência é uma virtude e você está aprendendo hoje..." + }, + { + "en": "YouTrack is like a tortoise — slow but gets there eventually...", + "es": "YouTrack es como una tortuga — lenta pero llega al final...", + "pt": "YouTrack é como uma tartaruga — lenta mas chega no fim..." + }, + { + "en": "YouTrack is making sure every issue counts — literally...", + "es": "YouTrack se asegura de que cada incidencia cuente — literalmente...", + "pt": "YouTrack se certifica de que cada issue conta — literalmente..." + }, + { + "en": "Still syncing YouTrack — your issue tray is being rebuilt...", + "es": "Still syncing YouTrack — tu bandeja de incidencias está siendo reconstruida...", + "pt": "Ainda syncando YouTrack — sua bandeja de issues está sendo reconstruída..." + }, + { + "en": "YouTrack is handling a lot of requests — it sends its regards...", + "es": "YouTrack está recibiendo muchas peticiones — manda sus saludos...", + "pt": "YouTrack está recebendo muitas requisições — manda suas saudações..." + }, + { + "en": "Your YouTrack issues are loading in slow motion — HD quality takes time...", + "es": "Tus incidencias de YouTrack cargan en cámara lenta — la calidad HD toma tiempo...", + "pt": "Seus issues do YouTrack carregam em câmera lenta — qualidade HD demora..." + }, + { + "en": "YouTrack server says 'thank you for your patience' — even if it doesn't mean it...", + "es": "El servidor de YouTrack dice 'gracias por su paciencia' — aunque no lo sienta...", + "pt": "O servidor do YouTrack diz 'obrigado pela paciência' — mesmo que não sinta..." + }, + { + "en": "YouTrack is taking a moment to reflect — lots of issues to review...", + "es": "YouTrack se está tomando un momento para reflexionar — muchas incidencias que revisar...", + "pt": "YouTrack está se tomando um momento pra refletir — muitos issues pra rever..." + }, + { + "en": "YouTrack is digging through your archive — old issues need love too...", + "es": "YouTrack está rebuscando en tu archivo — las incidencias viejas también necesitan cariño...", + "pt": "YouTrack está vasculhando seu arquivo — os issues velhos também precisam de carinho..." + }, + { + "en": "YouTrack is compiling your worklog — almost like magic...", + "es": "YouTrack está compilando tu registro de trabajo — casi como magia...", + "pt": "YouTrack está compilando seu registro de trabalho — quase como mágica..." + }, + { + "en": "YouTrack is running one more query than usual — blame the internet...", + "es": "YouTrack está haciendo una consulta más de lo usual — echa la culpa al internet...", + "pt": "YouTrack está fazendo uma consulta a mais do que o normal — dê culpa à internet..." + }, + { + "en": "YouTrack in the zone — don't disturb, almost done...", + "es": "YouTrack en la zona — no molestar, ya casi termina...", + "pt": "YouTrack na zona — não perturbe, já quase termina..." + }, + { + "en": "YouTrack: your issues are being wrapped with care and a little extra ribbon...", + "es": "YouTrack: tus incidencias están siendo envueltas con cariño y un poquito más de cinta...", + "pt": "YouTrack: seus issues estão sendo embrulhados com carinho e um pouquinho mais de fita..." + } + ], + "gitlab": [ + { + "en": "GitLab fetching a ton of data — this is gonna take a while...", + "es": "GitLab está trayendo muchísimos datos — esto va para largo...", + "pt": "GitLab está trazendo MUITOS dados — isso vai demorar..." + }, + { + "en": "Why does GitLab have this much data? Did all of this always exist?", + "es": "¿Por qué GitLab tiene tantos datos? ¿Siquiera existían todos estos?", + "pt": "Por que GitLab tem tantos dados? Eles sempre existiram todos esses?" + }, + { + "en": "GitLab keeps bringing things. What even is in your history?", + "es": "GitLab no para de traer cosas. ¿Qué hay en tu historial?", + "pt": "GitLab não para de trazer coisas. O que tem no seu histórico?" + }, + { + "en": "GitLab is like a friendly hoarder — it has everything you've ever done...", + "es": "GitLab es como un acumulador friendly — tiene todo lo que has hecho alguna vez...", + "pt": "GitLab é como um acumulador friendly — tem tudo que você já fez um dia..." + }, + { + "en": "GitLab found your old commits — they're still relevant, apparently...", + "es": "GitLab encontró tus commits viejos — siguen siendo relevantes, aparentemente...", + "pt": "GitLab encontrou seus commits antigos — ainda são relevantes, aparentemente..." + }, + { + "en": "GitLab is still downloading your coding history — a lot happened...", + "es": "GitLab sigue descargando tu historial de código — pasó muchas cosas...", + "pt": "GitLab ainda está baixando seu histórico de código — aconteceu muita coisa..." + }, + { + "en": "GitLab has more entries than your email inbox — congratulations?", + "es": "GitLab tiene más entradas que tu bandeja de entrada — ¿felicitaciones?", + "pt": "GitLab tem mais entradas que sua caixa de entrada — parabéns?" + }, + { + "en": "GitLab is counting your commits — every. single. one.", + "es": "GitLab está contando tus commits — cada. uno. solo.", + "pt": "GitLab está contando seus commits — cada. um. só." + }, + { + "en": "GitLab is making sure it hasn't missed any of your work — thorough...", + "es": "GitLab se está asegurando de no haberse perdido nada de tu trabajo — meticuloso...", + "pt": "GitLab está se certificando de não ter perdido nada do seu trabalho — meticuloso..." + }, + { + "en": "Your GitLab timelogs are being loaded — they remember everything...", + "es": "Tus registros de tiempo de GitLab se están cargando — recuerdan todo...", + "pt": "Seus registros de tempo do GitLab estão sendo carregados — lembram de tudo..." + }, + { + "en": "GitLab is organizing your work like a very detailed archivist...", + "es": "GitLab está organizando tu trabajo como un archivista muuuuy detallista...", + "pt": "GitLab está organizando seu trabalho como um arquivista muuuui detalhista..." + }, + { + "en": "GitLab is checking twice if you really spent that time — auditing...", + "es": "GitLab está verificando dos veces si realmente invertiste ese tiempo — auditando...", + "pt": "GitLab está verificando duas vezes se você realmente investiu aquele tempo — auditando..." + }, + { + "en": "GitLab found your project history — it's longer than expected...", + "es": "GitLab encontró tu historial de proyectos — es más largo de lo esperado...", + "pt": "GitLab encontrou seu histórico de projetos — é mais longo do que o esperado..." + }, + { + "en": "GitLab is loading your data like a warm record player...", + "es": "GitLab está cargando tus datos como un tocadiscos cálido y analógico...", + "pt": "GitLab está carregando seus dados como um giradiscos quente e analógico..." + }, + { + "en": "GitLab is sifting through your coding decade — one entry at a time...", + "es": "GitLab está cribando tu década de código — una entrada a la vez...", + "pt": "GitLab está peneirando sua década de código — uma entrada por vez..." + }, + { + "en": "Your GitLab data is being served with a side of patience...", + "es": "Tus datos de GitLab se están sirviendo con una guarnición de paciencia...", + "pt": "Seus dados do GitLab estão sendo servidos com uma porção de paciência..." + }, + { + "en": "GitLab is working through your backlog like a very motivated intern...", + "es": "GitLab está trabajando tu backlog como un pasante muy motivado...", + "pt": "GitLab está trabalhando seu backlog como um estagiário bem motivado..." + }, + { + "en": "GitLab is loading your contribution graph — it's probably green...", + "es": "GitLab está cargando tu gráfica de contribuciones — probablemente esté verde...", + "pt": "GitLab está carregando seu gráfico de contribuições — provavelmente está verde..." + }, + { + "en": "GitLab is very thorough — your work is in good hands...", + "es": "GitLab es muy meticuloso — tu trabajo está en buenas manos...", + "pt": "GitLab é muito meticuloso — seu trabalho está em boas mãos..." + }, + { + "en": "GitLab is taking its time — quality takes precedence...", + "es": "GitLab se está tomando su tiempo — la calidad es prioridad...", + "pt": "GitLab está se tomando seu tempo — qualidade é prioridade..." + }, + { + "en": "GitLab has all your data and is not letting go — possessive much?", + "es": "GitLab tiene todos tus datos y no los suelta — ¿qué tan posesivo es?", + "pt": "GitLab tem todos os seus dados e não solta — qué tão possessivo é?" + }, + { + "en": "GitLab is indexing your entire coding journey — respect...", + "es": "GitLab está indexando toda tu jornada de código — respeto...", + "pt": "GitLab está indexando toda sua jornada de código — respeito..." + }, + { + "en": "GitLab is cross-referencing your time entries — very detective...", + "es": "GitLab está cruzando tus registros de tiempo — muy detective...", + "pt": "GitLab está cruzando seus registros de tempo — muito detetive..." + }, + { + "en": "Your GitLab history is being sorted — chronologically, of course...", + "es": "Tu historial de GitLab está siendo ordenado — cronológicamente, claro...", + "pt": "Seu histórico do GitLab está sendo ordenado — cronologicamente, claro..." + }, + { + "en": "GitLab is processing your worklog — almost like it cares...", + "es": "GitLab está procesando tu registro — casi como si le importara...", + "pt": "GitLab está processando seu registro — quase como se importasse..." + }, + { + "en": "GitLab is loading every entry — nothing gets left behind...", + "es": "GitLab está cargando cada entrada — nada se queda atrás...", + "pt": "GitLab está carregando cada entrada — nada fica de fora..." + }, + { + "en": "Your GitLab data is being verified — every piece of it...", + "es": "Tus datos de GitLab están siendo verificados — cada pedacito...", + "pt": "Seus dados do GitLab estão sendo verificados — cada pedacinho..." + }, + { + "en": "GitLab is making a backup of your work history — just in case...", + "es": "GitLab está haciendo una copia de seguridad de tu historial — por si acaso...", + "pt": "GitLab está fazendo um backup do seu histórico — por via das dúvidas..." + }, + { + "en": "GitLab is reviewing your entries with a fine-tooth comb...", + "es": "GitLab está revisando tus entradas con un peine fino...", + "pt": "GitLab está revisando suas entradas com um pente fino..." + }, + { + "en": "GitLab is putting the finishing touches on your worklog — almost there...", + "es": "GitLab está poniendo los toques finales a tu registro — ya casi...", + "pt": "GitLab está colocando os toques finais no seu registro — já quase..." + } + ], + "general": [ + { + "en": "Sync is taking longer than usual — still working in the background...", + "es": "La sincronización está tardando más de lo usual — sigue en segundo plano...", + "pt": "A sincronização está demorando mais que o habitual — continua em segundo plano..." + }, + { + "en": "Still here? The servers are being very slow today...", + "es": "¿Todavía aquí? Los servidores están siendo muy lentos hoy...", + "pt": "Ainda aqui? Os servidores estão bem lentos hoje..." + }, + { + "en": "Ok this is getting embarrassing. Apologies, the server is not cooperating...", + "es": "Ok esto ya es vergonzoso. Pedimos disculpas, el servidor no coopera...", + "pt": "Ok isso já é vergonhoso. Pedimos desculpas, o servidor não coopera..." + }, + { + "en": "The sync is still going — your data is being fetched with care...", + "es": "La sincronización sigue — tus datos están siendo traídos con cariño...", + "pt": "A sincronização continua — seus dados estão sendo buscados com carinho..." + }, + { + "en": "Please hold — the internet is doing its thing...", + "es": "Por favor espera — el internet está haciendo de las suyas...", + "pt": "Por favor aguarde — a internet está fazendo das suas..." + }, + { + "en": "We are still syncing. No, really. This is not a drill.", + "es": "Seguimos sincronizando. En serio. Esto no es un simulacro.", + "pt": "Ainda estamos sincronizando. Não, realmente. Isto não é um exercício." + }, + { + "en": "The sync is a journey. We are somewhere in the middle.", + "es": "La sincronización es un viaje. Estamos en algún lugar del medio.", + "pt": "A sincronização é uma jornada. Estamos em algum lugar no meio." + }, + { + "en": "Data is traveling. Slowly, but it travels.", + "es": "Los datos están viajando. Lentamente, pero viajan.", + "pt": "Os dados estão viajando. Devagar, mas viajam." + }, + { + "en": "Sync in progress. Your data is on a slow boat to somewhere.", + "es": "Sincronización en curso. Tus datos van en un barco lento a algún lugar.", + "pt": "Sincronização em curso. Os seus dados vão num barco lento para algum lugar." + }, + { + "en": "The sync is being thorough. Thank you for your patience.", + "es": "La sincronización está siendo exhaustiva. Gracias por tu paciencia.", + "pt": "A sincronização está sendo exaustiva. Obrigado pela sua paciência." + }, + { + "en": "We are syncing the old-fashioned way. Slowly. Very slowly.", + "es": "Estamos sincronizando a la antigua. Lentamente. Muy lentamente.", + "pt": "Estamos sincronizando à moda antiga. Lentamente. Muito lentamente." + }, + { + "en": "Sync is a feature, not a bug. But it is slow today.", + "es": "La sincronización es una funcionalidad, no un error. Pero hoy es lenta.", + "pt": "A sincronização é uma funcionalidade, não um erro. Mas hoje está lenta." + }, + { + "en": "The sync is being processed with great care. And slowness.", + "es": "La sincronización se procesa con mucho cuidado. Y lentitud.", + "pt": "A sincronização está a ser processada com muito cuidado. E lentidão." + }, + { + "en": "Your data is being handled by the most patient sync engine on earth.", + "es": "Tus datos son atendidos por el motor de sincronización más paciente de la tierra.", + "pt": "Os seus dados estão a ser tratados pelo motor de sincronização mais paciente da terra." + }, + { + "en": "Sync is still going. We are as surprised as you are.", + "es": "La sincronización sigue. Estamos tan sorprendidos como tú.", + "pt": "A sincronização continua. Estamos tão surpreendidos quanto você." + }, + { + "en": "The sync is being slow. This is not a simulation.", + "es": "La sincronización está siendo lenta. Esto no es una simulación.", + "pt": "A sincronização está sendo lenta. Isto não é uma simulação." + }, + { + "en": "Data is being moved. Gently. Very gently. Very slowly.", + "es": "Los datos se mueven. Suavemente. Muy suavemente. Muy lentamente.", + "pt": "Os dados estão a ser movidos. Suavemente. Muito suavemente. Muito lentamente." + }, + { + "en": "Sync is happening. It is definitely happening. Slowly.", + "es": "La sincronización está ocurriendo. Definitivamente ocurre. Lentamente.", + "pt": "A sincronização está a ocorrer. Definitivamente está a ocorrer. Lentamente." + }, + { + "en": "The sync engine is taking a moment. Several moments, actually.", + "es": "El motor de sincronización está tomándose un momento. Varios momentos, en realidad.", + "pt": "O motor de sincronização está a tirar um momento. Vários momentos, na verdade." + }, + { + "en": "We are in the slow lane of the sync highway today.", + "es": "Estamos en el carril lento de la autopista de sincronización hoy.", + "pt": "Estamos na faixa lenta da autoestrada de sincronização hoje." + }, + { + "en": "The sync is being exceptionally thorough today.", + "es": "La sincronización está siendo excepcionalmente exhaustiva hoy.", + "pt": "A sincronização está sendo excepcionalmente exaustiva hoje." + }, + { + "en": "Sync: it works. Slowly. Very slowly. Thank you for waiting.", + "es": "Sincronización: funciona. Lentamente. Muy lentamente. Gracias por esperar.", + "pt": "Sincronização: funciona. Lentamente. Muito lentamente. Obrigado por esperar." + }, + { + "en": "Data is being very carefully relocated.", + "es": "Los datos están siendo reubicados con mucho cuidado.", + "pt": "Os dados estão a ser relocados com muito cuidado." + }, + { + "en": "Sync is happening at the speed of patience.", + "es": "La sincronización ocurre a la velocidad de la paciencia.", + "pt": "A sincronização está a ocorrer à velocidade da paciência." + }, + { + "en": "The sync is a work of art. Slow art.", + "es": "La sincronización es una obra de arte. Arte lento.", + "pt": "A sincronização é uma obra de arte. Arte lenta." + }, + { + "en": "Your data is being escorted. Very courteously. Very slowly.", + "es": "Tus datos están siendo escoltados. Muy cortésmente. Muy lentamente.", + "pt": "Os seus dados estão a ser escoltados. Muito cortêsmente. Muito lentamente." + }, + { + "en": "The sync is in no rush. Neither should you be.", + "es": "La sincronización no tiene prisa. Tú tampoco deberías.", + "pt": "A sincronização não tem pressa. Você também não deveria." + }, + { + "en": "The sync is being deliberate. Painfully deliberate.", + "es": "La sincronización está siendo deliberada. Dolorosamente deliberada.", + "pt": "A sincronização está sendo deliberada. Dolorosamente deliberada." + }, + { + "en": "We are syncing with extreme prejudice. And slowness.", + "es": "Sincronizamos con-prejuicio extremo. Y lentitud.", + "pt": "Estamos sincronizando com-preconceito extremo. E lentidão." + }, + { + "en": "The sync is being professional. Thoroughly, slowly professional.", + "es": "La sincronización está siendo profesional. Profesionalmente exhaustiva y lenta.", + "pt": "A sincronização está sendo profissional. Profissionais, exaustiva e lenta." + }, + { + "en": "The sync appreciates your continued attention and patience.", + "es": "La sincronización agradece tu atención continuada y paciencia.", + "pt": "A sincronização agradece a sua atenção continuada e paciência." + } + ] +} diff --git a/src/app/state/AppStore/internal/sync-progress-messages.ts b/src/app/state/AppStore/internal/sync-progress-messages.ts new file mode 100644 index 00000000..4f1489b3 --- /dev/null +++ b/src/app/state/AppStore/internal/sync-progress-messages.ts @@ -0,0 +1,70 @@ +import { getCachedPreferences } from "@/app/bootstrap/PreferencesCache/preferences-cache"; +import messagePools from "@/app/state/AppStore/internal/sync-progress-messages.json"; + +type SyncProvider = "gitlab" | "youtrack"; +type SyncLocale = "en" | "es" | "pt"; +type SyncMessage = { en: string; es: string; pt: string }; + +type SyncMessagePools = { + youtrack: SyncMessage[]; + gitlab: SyncMessage[]; + general: SyncMessage[]; +}; + +export interface SyncToastUsage { + youtrack: Set; + gitlab: Set; + general: Set; +} + +const POOLS = messagePools as SyncMessagePools; + +export const SYNC_BACKOFF_MS = [30_000, 60_000, 120_000]; + +export function createSyncToastUsage(): SyncToastUsage { + return { + youtrack: new Set(), + gitlab: new Set(), + general: new Set(), + }; +} + +export function getAutoSyncDelayMessage(provider: SyncProvider | null): string { + const locale = getLocale(); + if (provider === "youtrack") { + if (locale === "es") + return "GitLab listo — YouTrack sigue, el servidor parece estar lento de nuevo..."; + if (locale === "pt") + return "GitLab concluído — YouTrack continua, o servidor parece estar lento de novo..."; + return "GitLab done — YouTrack still going, server looks slow again..."; + } + if (provider === "gitlab") { + if (locale === "es") return "GitLab está siendo lento — son muchos datos, paciencia..."; + if (locale === "pt") return "GitLab está sendo lento — são muitos dados, paciência..."; + return "GitLab is being slow — lots of data, hang in there..."; + } + if (locale === "es") return "La sincronización está tardando mucho — lo siento por esto..."; + if (locale === "pt") return "A sincronização está demorando muito — desculpa por isso..."; + return "Sync is taking a long time — sorry about this..."; +} + +export function getSyncDelayMessage(provider: SyncProvider | null, usage: SyncToastUsage): string { + const locale = getLocale(); + const key = provider ?? "general"; + return pickMessage(POOLS[key], usage[key])[locale]; +} + +function pickMessage(pool: SyncMessage[], used: Set): SyncMessage { + const available = pool.map((_, index) => index).filter((index) => !used.has(index)); + const indices = available.length > 0 ? available : pool.map((_, index) => index); + const index = indices[Math.floor(Math.random() * indices.length)]!; + used.add(index); + return pool[index]!; +} + +function getLocale(): SyncLocale { + const prefs = getCachedPreferences(); + if (prefs?.language === "es") return "es"; + if (prefs?.language === "pt") return "pt"; + return "en"; +} diff --git a/src/domains/gitlab-connection/ui/ProviderConnectionRow/ProviderConnectionRow.test.tsx b/src/domains/gitlab-connection/ui/ProviderConnectionRow/ProviderConnectionRow.test.tsx new file mode 100644 index 00000000..65760ce0 --- /dev/null +++ b/src/domains/gitlab-connection/ui/ProviderConnectionRow/ProviderConnectionRow.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import GitlabIcon from "lucide-react/dist/esm/icons/gitlab.js"; +import { I18nProvider } from "@/app/providers/I18nService/i18n"; +import { ProviderConnectionRow } from "@/domains/gitlab-connection/ui/ProviderConnectionRow/ProviderConnectionRow"; + +function renderRow(overrides: Partial[0]> = {}) { + const defaults = { + providerName: "GitLab", + providerIcon: GitlabIcon, + isConnected: false, + isExpanded: false, + onToggle: vi.fn(), + children:
Auth form content
, + ...overrides, + }; + return render( + + + , + ); +} + +describe("ProviderConnectionRow", () => { + it("shows connect button when disconnected and collapsed", () => { + renderRow(); + expect(screen.getByText("Connect")).toBeInTheDocument(); + expect(screen.queryByTestId("auth-form")).not.toBeInTheDocument(); + }); + + it("shows auth form when expanded", () => { + renderRow({ isExpanded: true }); + expect(screen.getByTestId("auth-form")).toBeInTheDocument(); + }); + + it("calls onToggle when connect button clicked", () => { + const onToggle = vi.fn(); + renderRow({ onToggle }); + fireEvent.click(screen.getByText("Connect")); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it("shows disconnect button and summary when connected", () => { + const onDisconnect = vi.fn(); + renderRow({ + isConnected: true, + connectionSummary: "Connected to gitlab.com", + onDisconnect, + }); + expect(screen.getByText("Disconnect")).toBeInTheDocument(); + expect(screen.getByText("Connected to gitlab.com")).toBeInTheDocument(); + expect(screen.queryByText("Connect")).not.toBeInTheDocument(); + }); +}); diff --git a/src/domains/gitlab-connection/ui/ProviderConnectionRow/ProviderConnectionRow.tsx b/src/domains/gitlab-connection/ui/ProviderConnectionRow/ProviderConnectionRow.tsx new file mode 100644 index 00000000..e9dfe79b --- /dev/null +++ b/src/domains/gitlab-connection/ui/ProviderConnectionRow/ProviderConnectionRow.tsx @@ -0,0 +1,95 @@ +import ChevronDown from "lucide-react/dist/esm/icons/chevron-down.js"; +import LogOut from "lucide-react/dist/esm/icons/log-out.js"; +import { AnimatePresence, m } from "motion/react"; +import { useI18n } from "@/app/providers/I18nService/i18n"; +import { cn } from "@/shared/lib/utils"; +import { Button } from "@/shared/ui/Button/Button"; + +import type { LucideIcon } from "lucide-react"; + +interface ProviderConnectionRowProps { + providerName: string; + providerIcon: LucideIcon; + isConnected: boolean; + connectionSummary?: string; + isExpanded: boolean; + onToggle: () => void; + onDisconnect?: () => void; + children: React.ReactNode; +} + +export function ProviderConnectionRow({ + providerName, + providerIcon: Icon, + isConnected, + connectionSummary, + isExpanded, + onToggle, + onDisconnect, + children, +}: Readonly) { + const { t } = useI18n(); + + return ( +
+
+
+ +
+ +
+

{providerName}

+ {isConnected && connectionSummary ? ( +

{connectionSummary}

+ ) : null} +
+ + {isConnected ? ( + + ) : ( + + )} +
+ + + {isExpanded && !isConnected ? ( + +
{children}
+
+ ) : null} +
+
+ ); +} diff --git a/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.test.tsx b/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.test.tsx new file mode 100644 index 00000000..b25ffb14 --- /dev/null +++ b/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { I18nProvider } from "@/app/providers/I18nService/i18n"; +import { YouTrackAuthPanel } from "@/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel"; + +describe("YouTrackAuthPanel", () => { + it("connects with host and token", async () => { + const onSaveConnection = vi.fn().mockResolvedValue({ + id: 2, + provider: "YouTrack", + displayName: "YouTrack workspace", + host: "company.youtrack.cloud", + hasToken: true, + state: "live", + authMode: "PAT", + preferredScope: "api", + statusNote: "Connected", + oauthReady: true, + isPrimary: true, + }); + const onSavePat = vi.fn().mockResolvedValue({ + id: 2, + provider: "YouTrack", + displayName: "YouTrack workspace", + host: "company.youtrack.cloud", + hasToken: true, + state: "live", + authMode: "PAT", + preferredScope: "api", + statusNote: "Connected", + oauthReady: true, + isPrimary: true, + }); + + render( + + + , + ); + + fireEvent.change(screen.getByPlaceholderText("your-company.youtrack.cloud"), { + target: { value: "company.youtrack.cloud" }, + }); + fireEvent.change(screen.getByPlaceholderText("perm:xxxxxxxx"), { + target: { value: "perm:token" }, + }); + fireEvent.click(screen.getByRole("button", { name: /Connect with Token/i })); + + await waitFor(() => { + expect(onSaveConnection).toHaveBeenCalled(); + expect(onSavePat).toHaveBeenCalledWith("youtrack", "company.youtrack.cloud", "perm:token"); + }); + }); +}); diff --git a/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.tsx b/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.tsx new file mode 100644 index 00000000..dbe2dbd1 --- /dev/null +++ b/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.tsx @@ -0,0 +1,120 @@ +import Compass from "lucide-react/dist/esm/icons/compass.js"; +import KeyRound from "lucide-react/dist/esm/icons/key-round.js"; +import Loader2 from "lucide-react/dist/esm/icons/loader-circle.js"; +import { useState } from "react"; +import { useI18n } from "@/app/providers/I18nService/i18n"; +import { Button } from "@/shared/ui/Button/Button"; +import { Input } from "@/shared/ui/Input/Input"; +import { Label } from "@/shared/ui/Label/Label"; + +import type { ProviderConnection, ProviderConnectionInput } from "@/shared/types/dashboard"; + +interface YouTrackAuthPanelProps { + connections: ProviderConnection[]; + onSaveConnection: (input: ProviderConnectionInput) => Promise; + onSavePat: (provider: "youtrack", host: string, token: string) => Promise; + onValidateToken?: (provider: "youtrack", host: string) => Promise<{ username: string }>; +} + +export function YouTrackAuthPanel({ + connections, + onSaveConnection, + onSavePat, + onValidateToken, +}: Readonly) { + const { t } = useI18n(); + const youTrackConnection = connections.find((item) => item.provider.toLowerCase() === "youtrack"); + const [host, setHost] = useState(youTrackConnection?.host ?? ""); + const [token, setToken] = useState(""); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(null); + const connected = youTrackConnection?.hasToken === true; + + async function handleConnect() { + if (host.trim().length === 0 || token.trim().length === 0) { + setStatus(t("providers.hostAndTokenRequired")); + return; + } + setBusy(true); + setStatus(null); + try { + await onSaveConnection({ + provider: "youtrack", + host, + authMode: "PAT", + preferredScope: "api", + displayName: "YouTrack workspace", + }); + await onSavePat("youtrack", host, token); + if (onValidateToken) { + await onValidateToken("youtrack", host); + } + setStatus(t("providers.youTrackConnected")); + setToken(""); + } catch (error) { + setStatus(error instanceof Error ? error.message : t("providers.connectionFailed")); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+ +
+
+

+ {t("providers.connectYouTrack")} +

+

{t("providers.linkYouTrack")}

+
+
+ +

{t("providers.youTrackTokenHint")}

+ +
+ + setHost(event.target.value)} + placeholder="your-company.youtrack.cloud" + disabled={busy} + /> +
+ +
+ + setToken(event.target.value)} + placeholder="perm:xxxxxxxx" + disabled={busy} + /> +
+ + + + {status ?

{status}

: null} +
+ ); +} diff --git a/src/domains/schedule/state/schedule-form/schedule-form.test.ts b/src/domains/schedule/state/schedule-form/schedule-form.test.ts index 3d9df64a..e6b231ec 100644 --- a/src/domains/schedule/state/schedule-form/schedule-form.test.ts +++ b/src/domains/schedule/state/schedule-form/schedule-form.test.ts @@ -101,7 +101,7 @@ describe("buildWeekdaySchedulesInput", () => { enabled: false, shiftStart: "09:00", shiftEnd: "18:00", - lunchMinutes: 0, + lunchMinutes: 60, }); }); }); diff --git a/src/domains/schedule/state/schedule-form/schedule-form.ts b/src/domains/schedule/state/schedule-form/schedule-form.ts index 187b7087..4ed0e8f4 100644 --- a/src/domains/schedule/state/schedule-form/schedule-form.ts +++ b/src/domains/schedule/state/schedule-form/schedule-form.ts @@ -185,7 +185,11 @@ export function buildWeekdaySchedulesInput( enabled: configured?.enabled ?? DEFAULT_WORKWEEK.has(day), shiftStart: configured?.shiftStart ?? DEFAULT_SHIFT_START, shiftEnd: configured?.shiftEnd ?? DEFAULT_SHIFT_END, - lunchMinutes: Number.parseInt(configured?.lunchMinutes ?? "", 10) || 0, + lunchMinutes: (() => { + const raw = configured?.lunchMinutes ?? ""; + const parsed = Number.parseInt(raw, 10); + return Number.isNaN(parsed) ? DEFAULT_LUNCH_MINUTES : parsed; + })(), }; }); } diff --git a/src/domains/schedule/ui/ScheduleWorkspace/lib/use-schedule-workspace-controller.ts b/src/domains/schedule/ui/ScheduleWorkspace/lib/use-schedule-workspace-controller.ts index e1e87b40..369d058e 100644 --- a/src/domains/schedule/ui/ScheduleWorkspace/lib/use-schedule-workspace-controller.ts +++ b/src/domains/schedule/ui/ScheduleWorkspace/lib/use-schedule-workspace-controller.ts @@ -83,7 +83,7 @@ export function useScheduleWorkspaceController({ day !== selectedDay && WEEKDAY_ORDER.includes(day as WeekdayCode), ), ); - }, [selectedDay, selectedGroupDaysKey]); + }, [selectedDay, selectedGroupDaysKey, weekdaySchedules]); React.useLayoutEffect(() => { const element = editorCardReference.current; diff --git a/src/features/home/sections/HomeAssignedIssuesSection/HomeAssignedIssuesSection.test.tsx b/src/features/home/sections/HomeAssignedIssuesSection/HomeAssignedIssuesSection.test.tsx index 8f8f9c9d..51403440 100644 --- a/src/features/home/sections/HomeAssignedIssuesSection/HomeAssignedIssuesSection.test.tsx +++ b/src/features/home/sections/HomeAssignedIssuesSection/HomeAssignedIssuesSection.test.tsx @@ -24,7 +24,9 @@ function baseIssue(i: number): AssignedIssueSnapshot { key: `g/p#${i}`, title: `Task ${i}`, state: "opened", + workflowStatus: "todo", labels: [], + labelTones: [], }; } diff --git a/src/features/issues/lib/issue-details-optimistic.ts b/src/features/issues/lib/issue-details-optimistic.ts index 74808e57..c14d4d6a 100644 --- a/src/features/issues/lib/issue-details-optimistic.ts +++ b/src/features/issues/lib/issue-details-optimistic.ts @@ -32,7 +32,7 @@ function toIterationDetails(summary: AssignedIssueSnapshot): IssueIterationDetai } function toLabelOptions(summary: AssignedIssueSnapshot): IssueMetadataOption[] { - return summary.labels.map((label) => ({ id: label, label })); + return summary.labels.map((label) => ({ id: label, label, tone: "neutral" })); } /** @@ -53,8 +53,13 @@ export function buildOptimisticIssueDetails(summary: AssignedIssueSnapshot): Iss key: summary.key, title: summary.title, state: summary.state, + status: summary.statusLabel + ? { id: summary.statusLabel, label: summary.statusLabel, tone: "neutral" } + : undefined, updatedAt: summary.updatedAt, webUrl: summary.webUrl, + startDate: summary.startDate, + dueDate: summary.dueDate, labels: toLabelOptions(summary), milestoneTitle: summary.milestoneTitle, iteration: toIterationDetails(summary), diff --git a/src/features/issues/lib/issue-details-session-cache.test.ts b/src/features/issues/lib/issue-details-session-cache.test.ts index 6e531d74..9dcd78da 100644 --- a/src/features/issues/lib/issue-details-session-cache.test.ts +++ b/src/features/issues/lib/issue-details-session-cache.test.ts @@ -49,6 +49,7 @@ function createAssignedIssue(issueId: string, updatedAt?: string): AssignedIssue key: issueId, title: `Issue ${issueId}`, state: "opened", + workflowStatus: "doing", labels: ["workflow::doing"], updatedAt, }; diff --git a/src/features/issues/lib/issue-reference.test.ts b/src/features/issues/lib/issue-reference.test.ts index f565bb0a..76be6962 100644 --- a/src/features/issues/lib/issue-reference.test.ts +++ b/src/features/issues/lib/issue-reference.test.ts @@ -12,6 +12,7 @@ const issue: AssignedIssueSnapshot = { key: "group/project#42", title: "Fix the thing", state: "opened", + workflowStatus: "doing", labels: ["workflow::doing"], }; diff --git a/src/features/issues/screens/IssueHubPage/IssueHubPage.test.tsx b/src/features/issues/screens/IssueHubPage/IssueHubPage.test.tsx index 620957b7..aaf28a65 100644 --- a/src/features/issues/screens/IssueHubPage/IssueHubPage.test.tsx +++ b/src/features/issues/screens/IssueHubPage/IssueHubPage.test.tsx @@ -71,27 +71,27 @@ function createDetailsSnapshot( title: "Fix the thing", state: "opened", description: "### Description from API", - labels: [{ id: "workflow::doing", label: "workflow::doing" }], + labels: [{ id: "workflow::doing", label: "workflow::doing", tone: "accent" }], milestoneTitle: "Platform sprint", iteration: { id: "iteration-1", label: "Sprint 21" }, activity: [], capabilities: { status: { enabled: true, - options: [{ id: "status::todo", label: "To do" }], + options: [{ id: "status::todo", label: "To do", tone: "primary" }], }, labels: { enabled: true, - options: [{ id: "workflow::doing", label: "workflow::doing" }], + options: [{ id: "workflow::doing", label: "workflow::doing", tone: "accent" }], }, iteration: { enabled: false, reason: "Iteration editing is not available for this provider here yet.", - options: [{ id: "iteration-1", label: "Sprint 21" }], + options: [{ id: "iteration-1", label: "Sprint 21", tone: "primary" }], }, milestone: { enabled: false, - options: [{ id: "milestone-1", label: "Platform sprint" }], + options: [{ id: "milestone-1", label: "Platform sprint", tone: "primary" }], }, composer: { enabled: true, @@ -106,8 +106,8 @@ function createDetailsSnapshot( author: { name: "Cristhofer Pincetti", username: "cpincetti" }, createdAt: "2026-04-19T09:30:00Z", updatedAt: "2026-04-19T10:00:00Z", - status: { id: "status::todo", label: "To do" }, - statusOptions: [{ id: "status::todo", label: "To do" }], + status: { id: "status::todo", label: "To do", tone: "primary" }, + statusOptions: [{ id: "status::todo", label: "To do", tone: "primary" }], linkedItems: [ { reference: { provider: "gitlab", issueId: "g/p#2" }, @@ -187,7 +187,7 @@ describe("IssueHubPage", () => { expect(screen.getByText("g/p#1")).toBeInTheDocument(); expect(screen.getAllByText("Open").length).toBeGreaterThan(0); expect(screen.getAllByText("Open")[0]).toHaveClass( - ...getAssignedIssueStateBadgeClassName("opened").split(" "), + ...getAssignedIssueStateBadgeClassName("open").split(" "), ); }); @@ -317,6 +317,7 @@ describe("IssueHubPage", () => { key: "g/p#1", title: "Fix the thing", state: "opened", + workflowStatus: "doing" as const, labels: ["workflow::doing"], updatedAt: "2026-04-19T10:00:00Z", }; diff --git a/src/features/issues/screens/IssueHubPage/IssueHubPage.tsx b/src/features/issues/screens/IssueHubPage/IssueHubPage.tsx index 025db5fb..9f5582eb 100644 --- a/src/features/issues/screens/IssueHubPage/IssueHubPage.tsx +++ b/src/features/issues/screens/IssueHubPage/IssueHubPage.tsx @@ -2,6 +2,7 @@ import { Skeleton } from "boneyard-js/react"; import ArrowLeft from "lucide-react/dist/esm/icons/arrow-left.js"; import ExternalLink from "lucide-react/dist/esm/icons/external-link.js"; import MoreVertical from "lucide-react/dist/esm/icons/more-vertical.js"; +import Pencil from "lucide-react/dist/esm/icons/pencil.js"; import Trash2 from "lucide-react/dist/esm/icons/trash-2.js"; import { m } from "motion/react"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -20,6 +21,7 @@ import { IssueDetailsMainSection } from "@/features/issues/sections/IssueDetails import { IssueDetailsSidebarSection } from "@/features/issues/sections/IssueDetailsSidebarSection/IssueDetailsSidebarSection"; import { getAssignedIssueStateBadgeClassName } from "@/features/issues/ui/AssignedIssuesBoard/lib/assigned-issue-badge-tone"; import { IssueDetailsSkeleton } from "@/features/issues/ui/IssueDetailsSkeleton/IssueDetailsSkeleton"; +import { IssueOriginBadge } from "@/features/issues/ui/IssueOriginBadge/IssueOriginBadge"; import { staggerItem } from "@/shared/lib/animations/animations"; import { cn } from "@/shared/lib/utils"; import { Badge } from "@/shared/ui/Badge/Badge"; @@ -37,6 +39,17 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/Popover/Pop import type { IssueComposerMode } from "@/features/issues/types/issue-details"; import type { BootstrapPayload, IssueRouteReference } from "@/shared/types/dashboard"; +function issueProviderProductName(provider: string): string { + const key = provider.trim().toLowerCase(); + if (key === "youtrack") { + return "YouTrack"; + } + if (key === "gitlab") { + return "GitLab"; + } + return provider; +} + function overflowAllowsScroll(value: string): boolean { return value === "auto" || value === "scroll" || value === "overlay"; } @@ -119,13 +132,17 @@ export function IssueHubPage({ const handleSubmitTime = useCallback(async () => { try { await controller.submitTime(); - toast.success(t("issues.timeLogged")); + toast.success( + t("issues.timeLoggedOnProduct", { + product: issueProviderProductName(issueReference.provider), + }), + ); } catch (error) { toast.error(t("issues.timeLogFailed"), { description: error instanceof Error ? error.message : t("settings.tryAgain"), }); } - }, [controller, t]); + }, [controller, issueReference.provider, t]); const handleSubmitNote = useCallback(async () => { try { @@ -223,7 +240,7 @@ export function IssueHubPage({ const detailsUrl = controller.details?.webUrl ?? null; const canEditIssueDescription = controller.loadState.status === "ready" && - controller.loadState.details.reference.provider === "gitlab"; + controller.loadState.details.capabilities.composer.enabled; useEffect(() => { setStickyVisible(false); @@ -291,6 +308,9 @@ export function IssueHubPage({ {statusLabel(controller.details.state, t)} ) : null} + {controller.details ? ( + + ) : null}

{controller.details?.title ?? t("issues.hubPageTitle")}

@@ -309,6 +329,7 @@ export function IssueHubPage({ open={stickyMenuOpen} onOpenChange={setStickyMenuOpen} canEditIssueDescription={canEditIssueDescription} + provider={controller.details?.reference.provider ?? issueReference.provider} detailsUrl={detailsUrl} busy={controller.busyAction !== null} onEditDescription={startDescriptionEdit} @@ -391,6 +412,7 @@ export function IssueHubPage({ > {statusLabel(controller.details.state, t)} + {controller.details.key} {controller.details.author && controller.details.createdAt ? ( @@ -419,6 +441,7 @@ export function IssueHubPage({ open={menuOpen} onOpenChange={setMenuOpen} canEditIssueDescription={canEditIssueDescription} + provider={controller.details?.reference.provider ?? issueReference.provider} detailsUrl={detailsUrl} busy={controller.busyAction !== null} onEditDescription={startDescriptionEdit} @@ -458,7 +481,7 @@ export function IssueHubPage({ onDescriptionComposerModeChange={setDescriptionComposerMode} onCancelDescriptionEdit={cancelDescriptionEdit} onSaveDescription={ - controller.loadState.details.reference.provider === "gitlab" + controller.loadState.details.capabilities.composer.enabled ? handleSaveDescription : undefined } @@ -530,6 +553,7 @@ function IssueActionsMenu({ open, onOpenChange, canEditIssueDescription, + provider, detailsUrl, busy, onEditDescription, @@ -538,6 +562,7 @@ function IssueActionsMenu({ open: boolean; onOpenChange: (open: boolean) => void; canEditIssueDescription: boolean; + provider: string; detailsUrl: string | null; busy: boolean; onEditDescription: () => void; @@ -562,13 +587,14 @@ function IssueActionsMenu({ {canEditIssueDescription ? ( ) : null} @@ -582,7 +608,9 @@ function IssueActionsMenu({ }} > - {t("issues.openInGitLab")} + {t("issues.openExternalIssue", { + product: issueProviderProductName(provider), + })} ) : null} - ) : null} - - ) : ( -
- {selectedMilestoneLabel ? ( -

- {selectedMilestoneLabel} -

- ) : ( -

- {t("issues.noMilestoneAssigned")} -

- )} -
- )} - + + + {t("common.noResults")} + + + {(item: SearchComboboxOption) => ( + + {item.label} + + )} + + + + + {selectedMilestoneId ? ( + + ) : null} + + ) : ( +
+ {selectedMilestoneLabel ? ( +

+ {selectedMilestoneLabel} +

+ ) : ( +

+ {t("issues.noMilestoneAssigned")} +

+ )} +
+ )} + + ) : null} ) : null} - {rowLabel} + {rowLabel} ); @@ -574,6 +598,18 @@ export function IssueDetailsSidebarSection({ )} + + + + + + {details.metadataFields?.map((field) => ( + + ))} +

@@ -594,7 +630,9 @@ export function IssueDetailsSidebarSection({

- + + + @@ -670,6 +708,18 @@ export function IssueDetailsSidebarSection({ ); } +function ReadonlyMetadataRow({ label, value }: Readonly<{ label: string; value?: string | null }>) { + if (!value?.trim()) { + return null; + } + + return ( + +

{value}

+
+ ); +} + function InspectorRow({ label, actionLabel, diff --git a/src/features/issues/ui/AssignedIssueListRow/AssignedIssueListRow.test.tsx b/src/features/issues/ui/AssignedIssueListRow/AssignedIssueListRow.test.tsx index 503a8252..ba85e891 100644 --- a/src/features/issues/ui/AssignedIssueListRow/AssignedIssueListRow.test.tsx +++ b/src/features/issues/ui/AssignedIssueListRow/AssignedIssueListRow.test.tsx @@ -11,7 +11,9 @@ const issue: AssignedIssueSnapshot = { key: "g/p#42", title: "Fix the thing", state: "opened", + workflowStatus: "doing", labels: ["workflow::doing", "team::alpha", "frontend", "category::product"], + labelTones: [], milestoneTitle: "Sprint A", iterationTitle: "Iteration 1", iterationStartDate: "2026-01-05", @@ -35,7 +37,6 @@ describe("AssignedIssueListRow", () => { expect(screen.getByText("Iteration 1")).toBeInTheDocument(); expect(screen.getByText("Sprint A")).toBeInTheDocument(); expect(screen.getByText("2026-01-05 → 2026-01-18")).toBeInTheDocument(); - expect(screen.getByText("opened")).toBeInTheDocument(); expect(screen.getByText("Doing")).toBeInTheDocument(); expect(screen.getByText("workflow::doing")).toBeInTheDocument(); expect(screen.getByText("team::alpha")).toBeInTheDocument(); @@ -59,7 +60,6 @@ describe("AssignedIssueListRow", () => { ); const firstWorkflow = screen.getByText("workflow::doing"); - const firstYear = screen.getByText("Doing").previousElementSibling; rerender( @@ -70,6 +70,7 @@ describe("AssignedIssueListRow", () => { issueId: "g/p#43", providerIssueRef: "gid://gitlab/Issue/43", labels: ["workflow::doing", "frontend", "priority::low"], + labelTones: [], }} workflowLabel="Doing" onOpen={vi.fn()} @@ -80,6 +81,5 @@ describe("AssignedIssueListRow", () => { const repeatedWorkflow = screen.getByText("workflow::doing"); expect(firstWorkflow.className).toBe(repeatedWorkflow.className); - expect(firstYear?.className).toContain("text-success"); }); }); diff --git a/src/features/issues/ui/AssignedIssueListRow/AssignedIssueListRow.tsx b/src/features/issues/ui/AssignedIssueListRow/AssignedIssueListRow.tsx index 0c5f94ad..90060cf4 100644 --- a/src/features/issues/ui/AssignedIssueListRow/AssignedIssueListRow.tsx +++ b/src/features/issues/ui/AssignedIssueListRow/AssignedIssueListRow.tsx @@ -2,13 +2,14 @@ import { useRef } from "react"; import { schedulePrefetchIssueDetailsOnHover } from "@/features/issues/lib/issue-details-session-cache"; import { getAssignedIssueLabelBadgeClassName, - getAssignedIssueStateBadgeClassName, getAssignedIssueWorkflowBadgeClassName, + toneClasses, } from "@/features/issues/ui/AssignedIssuesBoard/lib/assigned-issue-badge-tone"; import { getWorkflowColumnId, type WorkflowColumnId, } from "@/features/issues/ui/AssignedIssuesBoard/lib/workflow-column"; +import { IssueOriginBadge } from "@/features/issues/ui/IssueOriginBadge/IssueOriginBadge"; import { cn } from "@/shared/lib/utils"; import type { AssignedIssueSnapshot, IssueRouteReference } from "@/shared/types/dashboard"; @@ -18,8 +19,8 @@ const MAX_VISIBLE_LABELS = 3; const workflowBorder: Record = { todo: "border-l-primary", doing: "border-l-accent", + blocked: "border-l-warning", done: "border-l-success", - closed: "border-l-muted-foreground", other: "border-l-secondary", }; @@ -100,14 +101,7 @@ export function AssignedIssueListRow({
- - {issue.state} - + {workflowLabel} - {visibleLabels.map((label) => ( + {visibleLabels.map((label, index) => ( diff --git a/src/features/issues/ui/AssignedIssuesBoard/AssignedIssuesBoard.test.tsx b/src/features/issues/ui/AssignedIssuesBoard/AssignedIssuesBoard.test.tsx index a65f5381..bc551ea7 100644 --- a/src/features/issues/ui/AssignedIssuesBoard/AssignedIssuesBoard.test.tsx +++ b/src/features/issues/ui/AssignedIssuesBoard/AssignedIssuesBoard.test.tsx @@ -23,7 +23,9 @@ const sample: AssignedIssueSnapshot[] = [ key: "g/p#1", title: "Hello", state: "opened", + workflowStatus: "todo", labels: [], + labelTones: [], milestoneTitle: "M1", }, ]; @@ -39,6 +41,9 @@ function renderBoard(overrides: Partial[0 appliedSearchValue: "", suggestions: [], onSearchValueChange: vi.fn(), + provider: "all", + providerOptions: [{ value: "all", label: "All" }], + onProviderChange: vi.fn(), status: "opened", onStatusChange: vi.fn(), years: [], @@ -140,6 +145,9 @@ describe("AssignedIssuesBoard", () => { appliedSearchValue: "audit", suggestions: [], onSearchValueChange: vi.fn(), + provider: "all", + providerOptions: [{ value: "all", label: "All" }], + onProviderChange: vi.fn(), status: "opened", onStatusChange: vi.fn(), years: [], diff --git a/src/features/issues/ui/AssignedIssuesBoard/AssignedIssuesBoard.tsx b/src/features/issues/ui/AssignedIssuesBoard/AssignedIssuesBoard.tsx index 3973456f..c5ddb436 100644 --- a/src/features/issues/ui/AssignedIssuesBoard/AssignedIssuesBoard.tsx +++ b/src/features/issues/ui/AssignedIssuesBoard/AssignedIssuesBoard.tsx @@ -1,14 +1,7 @@ -import { AnimatePresence, m } from "motion/react"; -import { useI18n } from "@/app/providers/I18nService/i18n"; -import { useMotionSettings } from "@/app/providers/MotionService/motion"; -import { AssignedIssueListRow } from "@/features/issues/ui/AssignedIssueListRow/AssignedIssueListRow"; +import { m } from "motion/react"; +import { AssignedIssuesBoardContent } from "@/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesBoardContent/AssignedIssuesBoardContent"; import { AssignedIssuesFilters } from "@/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesFilters/AssignedIssuesFilters"; -import { AssignedIssuesPagination } from "@/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesPagination/AssignedIssuesPagination"; -import { - getWorkflowColumnId, - workflowStatusFilterLabel, -} from "@/features/issues/ui/AssignedIssuesBoard/lib/workflow-column"; -import { easeOut, springGentle, staggerItem } from "@/shared/lib/animations/animations"; +import { staggerItem } from "@/shared/lib/animations/animations"; import { cn } from "@/shared/lib/utils"; import type { @@ -28,6 +21,9 @@ interface AssignedIssuesBoardProps { appliedSearchValue: string; suggestions: AssignedIssueSuggestion[]; onSearchValueChange: (value: string) => void; + provider: string; + providerOptions: Array<{ value: string; label: string }>; + onProviderChange: (value: string) => void; status: AssignedIssuesStatusFilter; onStatusChange: (value: AssignedIssuesStatusFilter) => void; years: string[]; @@ -59,6 +55,9 @@ export function AssignedIssuesBoard({ appliedSearchValue, suggestions, onSearchValueChange, + provider, + providerOptions, + onProviderChange, status, onStatusChange, years, @@ -79,35 +78,8 @@ export function AssignedIssuesBoard({ syncVersion, className, }: Readonly) { - const { t } = useI18n(); - const { allowDecorativeAnimation, windowVisibility } = useMotionSettings(); const filtersUnavailable = catalogState !== "ready" && iterationOptions.length === 0 && years.length === 0; - const catalogNotice = - catalogState === "partial" - ? catalogMessage || t("issues.catalogPartial") - : catalogState === "error" - ? catalogMessage || t("issues.catalogError") - : null; - const hasActiveFilters = - appliedSearchValue || year !== "all" || iterationId !== "all" || status !== "opened"; - const contentKey = [ - status, - year, - iterationId, - appliedSearchValue, - page, - totalItems, - issues.map((issue) => issue.key).join("|"), - error ?? "", - loading ? "loading" : "ready", - ].join(":"); - const shouldEnter = allowDecorativeAnimation && windowVisibility === "visible"; - const contentAnimationProps = { - initial: shouldEnter ? { opacity: 0, y: 10 } : false, - animate: { opacity: 1, y: 0 }, - transition: shouldEnter ? { duration: 0.26, ease: easeOut } : { duration: 0 }, - }; return (
@@ -115,6 +87,9 @@ export function AssignedIssuesBoard({ - - - {catalogNotice ? ( -
- {catalogNotice} -
- ) : null} - - {error ? ( -
-

{t("issues.loadError")}

- -
- ) : loading ? ( -
- {t("issues.loadingList")} -
- ) : issues.length === 0 ? ( -
-

- {hasActiveFilters ? t("issues.listEmptyAfterFilters") : t("issues.boardEmptyTitle")} -

-

- {appliedSearchValue || year !== "all" || iterationId !== "all" - ? t("issues.filterEmptyHint") - : status === "closed" - ? t("issues.boardEmptyHintClosed") - : status === "all" - ? t("issues.boardEmptyHintAll") - : t("issues.boardEmptyHint")} -

-
- ) : ( - <> -
- {issues.map((issue, index) => ( - - onOpenIssue(issue)} - syncVersion={syncVersion} - /> - - ))} -
- - - )} -
-
+
); } diff --git a/src/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesBoardContent/AssignedIssuesBoardContent.test.tsx b/src/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesBoardContent/AssignedIssuesBoardContent.test.tsx new file mode 100644 index 00000000..b3d1649d --- /dev/null +++ b/src/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesBoardContent/AssignedIssuesBoardContent.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import { I18nProvider } from "@/app/providers/I18nService/i18n"; +import { AssignedIssuesBoardContent } from "@/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesBoardContent/AssignedIssuesBoardContent"; + +vi.mock("@/app/desktop/TauriService/tauri", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + logIssueTime: vi.fn(), + createIssueComment: vi.fn(), + openExternalUrl: vi.fn(), + }; +}); + +function renderContent(overrides: Partial[0]> = {}) { + return render( + + + , + ); +} + +describe("AssignedIssuesBoardContent", () => { + it("uses filtered empty copy when a provider filter is active", () => { + renderContent({ provider: "youtrack" }); + + expect(screen.getByText("No issues match these filters.")).toBeInTheDocument(); + expect(screen.getByText("Try a different search or broaden the filters.")).toBeInTheDocument(); + }); + + it("does not show catalog warning when selected provider page is ready", () => { + renderContent({ + provider: "youtrack", + catalogState: "ready", + catalogMessage: + "Iteration data was synced, but no dated iteration catalog could be matched yet.", + }); + + expect(screen.queryByText(/dated iteration catalog/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesBoardContent/AssignedIssuesBoardContent.tsx b/src/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesBoardContent/AssignedIssuesBoardContent.tsx new file mode 100644 index 00000000..a2c65b4c --- /dev/null +++ b/src/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesBoardContent/AssignedIssuesBoardContent.tsx @@ -0,0 +1,186 @@ +import { AnimatePresence, m } from "motion/react"; +import { useI18n } from "@/app/providers/I18nService/i18n"; +import { useMotionSettings } from "@/app/providers/MotionService/motion"; +import { AssignedIssueListRow } from "@/features/issues/ui/AssignedIssueListRow/AssignedIssueListRow"; +import { AssignedIssuesPagination } from "@/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesPagination/AssignedIssuesPagination"; +import { buildAssignedIssuesContentKey } from "@/features/issues/ui/AssignedIssuesBoard/lib/assigned-issues-content-key"; +import { + getWorkflowColumnId, + workflowStatusFilterLabel, +} from "@/features/issues/ui/AssignedIssuesBoard/lib/workflow-column"; +import { easeOut, springGentle } from "@/shared/lib/animations/animations"; +import { cn } from "@/shared/lib/utils"; + +import type { AssignedIssueSnapshot, AssignedIssuesStatusFilter } from "@/shared/types/dashboard"; + +interface AssignedIssuesBoardContentProps { + issues: AssignedIssueSnapshot[]; + loading: boolean; + error?: string | null; + catalogState: "ready" | "partial" | "error"; + catalogMessage?: string | null; + appliedSearchValue: string; + provider: string; + status: AssignedIssuesStatusFilter; + year: string; + iterationId: string; + page: number; + pageSize: number; + pageSizeOptions: readonly number[]; + totalItems: number; + totalPages: number; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; + onRetry: () => void; + onOpenIssue: (issue: AssignedIssueSnapshot) => void; + syncVersion: number; +} + +export function AssignedIssuesBoardContent({ + issues, + loading, + error, + catalogState, + catalogMessage, + appliedSearchValue, + provider, + status, + year, + iterationId, + page, + pageSize, + pageSizeOptions, + totalItems, + totalPages, + onPageChange, + onPageSizeChange, + onRetry, + onOpenIssue, + syncVersion, +}: Readonly) { + const { t } = useI18n(); + const { allowDecorativeAnimation, windowVisibility } = useMotionSettings(); + const catalogNotice = + catalogState === "partial" + ? catalogMessage || t("issues.catalogPartial") + : catalogState === "error" + ? catalogMessage || t("issues.catalogError") + : null; + const hasActiveFilters = Boolean( + appliedSearchValue || + year !== "all" || + iterationId !== "all" || + status !== "opened" || + provider !== "all", + ); + const contentKey = buildAssignedIssuesContentKey({ + status, + provider, + year, + iterationId, + appliedSearchValue, + page, + totalItems, + issues, + error, + loading, + }); + const shouldEnter = allowDecorativeAnimation && windowVisibility === "visible"; + const contentAnimationProps = { + initial: shouldEnter ? { opacity: 0, y: 10 } : false, + animate: { opacity: 1, y: 0 }, + transition: shouldEnter ? { duration: 0.26, ease: easeOut } : { duration: 0 }, + }; + + return ( + + + {catalogNotice ? ( +
+ {catalogNotice} +
+ ) : null} + {renderContent()} +
+
+ ); + + function renderContent() { + if (error) { + return ( +
+

{t("issues.loadError")}

+ +
+ ); + } + if (loading) { + return ( +
+ {t("issues.loadingList")} +
+ ); + } + if (issues.length === 0) { + return ( +
+

+ {hasActiveFilters ? t("issues.listEmptyAfterFilters") : t("issues.boardEmptyTitle")} +

+

{emptyHint()}

+
+ ); + } + return ( + <> +
+ {issues.map((issue, index) => ( + + onOpenIssue(issue)} + syncVersion={syncVersion} + /> + + ))} +
+ + + ); + } + + function emptyHint() { + if (hasActiveFilters) return t("issues.filterEmptyHint"); + if (status === "all") return t("issues.boardEmptyHintAll"); + return t("issues.boardEmptyHint"); + } +} diff --git a/src/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesFilters/AssignedIssuesFilters.test.tsx b/src/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesFilters/AssignedIssuesFilters.test.tsx index c064f4e7..5fa6f271 100644 --- a/src/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesFilters/AssignedIssuesFilters.test.tsx +++ b/src/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesFilters/AssignedIssuesFilters.test.tsx @@ -7,8 +7,11 @@ describe("AssignedIssuesFilters", () => { const { container } = render( { expect(screen.getByText("Status")).toBeInTheDocument(); expect(screen.getByText("Iteration")).toBeInTheDocument(); expect(screen.getByText("Year")).toBeInTheDocument(); - expect(screen.getByDisplayValue("Open")).toBeInTheDocument(); + expect(screen.queryByText("Provider")).not.toBeInTheDocument(); + expect(screen.getAllByDisplayValue("All").length).toBe(3); expect(screen.queryByText("Search")).not.toBeInTheDocument(); expect(screen.getByPlaceholderText("Search assigned issues...")).toBeInTheDocument(); - expect(container.firstChild).toHaveClass("xl:grid-cols-[minmax(0,1.6fr)_auto_auto_auto]"); + expect(container.firstChild).toHaveClass("flex-col"); + expect(container.firstChild).toHaveClass("2xl:flex-row"); expect(container.querySelector(".xl\\:w-\\[16rem\\]")).not.toBeNull(); }); - it("shows open, closed, and all in the status combobox", () => { + it("shows normalized workflow buckets and all in the status combobox", () => { render( { fireEvent.click(screen.getAllByRole("button")[0]); - expect(screen.getByText("Open")).toBeInTheDocument(); - expect(screen.getByText("Closed")).toBeInTheDocument(); + expect(screen.getByText("To do")).toBeInTheDocument(); + expect(screen.getByText("Doing")).toBeInTheDocument(); + expect(screen.getByText("Blocked")).toBeInTheDocument(); + expect(screen.getByText("Done")).toBeInTheDocument(); + expect(screen.queryByText("Closed")).not.toBeInTheDocument(); expect(screen.getByText("All")).toBeInTheDocument(); }); + + it("renders provider combobox when more than one provider is configured", () => { + render( + + + , + ); + + expect(screen.getByText("Provider")).toBeInTheDocument(); + expect(screen.getAllByDisplayValue("All").length).toBeGreaterThan(0); + }); }); diff --git a/src/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesFilters/AssignedIssuesFilters.tsx b/src/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesFilters/AssignedIssuesFilters.tsx index 528502d4..141ae37a 100644 --- a/src/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesFilters/AssignedIssuesFilters.tsx +++ b/src/features/issues/ui/AssignedIssuesBoard/internal/AssignedIssuesFilters/AssignedIssuesFilters.tsx @@ -11,6 +11,7 @@ import type { } from "@/shared/types/dashboard"; const statusComboboxInputClassName = "min-w-0 w-full max-w-none sm:w-40"; +const providerComboboxInputClassName = "min-w-0 w-full max-w-none sm:w-40"; const iterationComboboxInputClassName = "min-w-0 w-full max-w-none xl:w-[16rem]"; const yearComboboxInputClassName = "min-w-0 w-full max-w-none sm:w-36"; const filterChipClassName = @@ -19,6 +20,9 @@ const filterChipClassName = interface AssignedIssuesFiltersProps { status: AssignedIssuesStatusFilter; onStatusChange: (value: AssignedIssuesStatusFilter) => void; + provider: string; + providerOptions: Array<{ value: string; label: string }>; + onProviderChange: (value: string) => void; disableIterationFilters?: boolean; searchValue: string; appliedSearchValue: string; @@ -35,6 +39,9 @@ interface AssignedIssuesFiltersProps { export function AssignedIssuesFilters({ status, onStatusChange, + provider, + providerOptions, + onProviderChange, disableIterationFilters = false, searchValue, appliedSearchValue, @@ -51,9 +58,11 @@ export function AssignedIssuesFilters({ const statusOptions = useMemo( () => [ - { value: "opened", label: t("issues.filterOpened") }, - { value: "closed", label: t("issues.filterClosed") }, { value: "all", label: t("issues.filterAll") }, + { value: "todo", label: t("issues.statusTodo") }, + { value: "doing", label: t("issues.statusDoing") }, + { value: "blocked", label: t("issues.statusBlocked") }, + { value: "done", label: t("issues.statusDone") }, ] satisfies Array<{ value: AssignedIssuesStatusFilter; label: string }>, [t], ); @@ -65,6 +74,15 @@ export function AssignedIssuesFilters({ })), [suggestions], ); + const showProviderFilter = providerOptions.length > 2; + const providerComboboxOptions = useMemo( + () => + providerOptions.map((option) => ({ + ...option, + label: option.value === FILTER_ALL ? t("issues.filterAll") : option.label, + })), + [providerOptions, t], + ); const yearComboboxOptions = useMemo( () => [ @@ -97,10 +115,10 @@ export function AssignedIssuesFilters({ ); return ( -
-
+
+
-
- {t("issues.filterWorkflowStatus")} - onStatusChange(value as AssignedIssuesStatusFilter)} - replaceOnFocus - searchPlaceholder={t("common.search")} - noResultsLabel={t("common.noResults")} - className={statusComboboxInputClassName} - /> -
-
- {t("issues.filterIteration")} - -
-
- {t("issues.filterYear")} - +
+ {showProviderFilter ? ( +
+ {t("issues.filterProvider")} + +
+ ) : null} +
+ {t("issues.filterWorkflowStatus")} + onStatusChange(value as AssignedIssuesStatusFilter)} + replaceOnFocus + searchPlaceholder={t("common.search")} + noResultsLabel={t("common.noResults")} + className={statusComboboxInputClassName} + /> +
+
+ {t("issues.filterIteration")} + +
+
+ {t("issues.filterYear")} + +
); diff --git a/src/features/issues/ui/AssignedIssuesBoard/internal/use-assigned-issues-board-controller.lib.ts b/src/features/issues/ui/AssignedIssuesBoard/internal/use-assigned-issues-board-controller.lib.ts index 96d0c1ea..9805afe2 100644 --- a/src/features/issues/ui/AssignedIssuesBoard/internal/use-assigned-issues-board-controller.lib.ts +++ b/src/features/issues/ui/AssignedIssuesBoard/internal/use-assigned-issues-board-controller.lib.ts @@ -4,6 +4,7 @@ import type { AssignedIssuesPage, AssignedIssuesQueryInput, AssignedIssuesStatusFilter, + ProviderConnection, } from "@/shared/types/dashboard"; export interface FilterState { @@ -15,6 +16,7 @@ export interface QueryState { page: number; pageSize: number; status: AssignedIssuesStatusFilter; + provider: string; search: string; filtersByStatus: Record; } @@ -22,7 +24,10 @@ export interface QueryState { export function createDefaultFilters(): Record { return { opened: { year: FILTER_ALL, iterationId: FILTER_ALL }, - closed: { year: FILTER_ALL, iterationId: FILTER_ALL }, + todo: { year: FILTER_ALL, iterationId: FILTER_ALL }, + doing: { year: FILTER_ALL, iterationId: FILTER_ALL }, + blocked: { year: FILTER_ALL, iterationId: FILTER_ALL }, + done: { year: FILTER_ALL, iterationId: FILTER_ALL }, all: { year: FILTER_ALL, iterationId: FILTER_ALL }, }; } @@ -37,6 +42,7 @@ export function toAssignedIssuesQueryInput(queryState: QueryState): AssignedIssu page: queryState.page, pageSize: queryState.pageSize, status: queryState.status, + provider: queryState.provider === FILTER_ALL ? undefined : queryState.provider, year: filters.year === FILTER_ALL ? undefined : filters.year, iterationId: filters.iterationId === FILTER_ALL ? undefined : filters.iterationId, search: queryState.search || undefined, @@ -48,6 +54,7 @@ export function buildAssignedIssuesQueryKey(input: AssignedIssuesQueryInput): st page: input.page, pageSize: input.pageSize, status: input.status, + provider: input.provider ?? null, year: input.year ?? null, iterationId: input.iterationId ?? null, search: input.search ?? null, @@ -73,3 +80,44 @@ export function resolveValidFilters(filters: FilterState, page: AssignedIssuesPa : filters.iterationId, }; } + +export function createProviderFilterOptions(providers: ProviderConnection[]) { + const toProviderLabel = (provider: ProviderConnection): string => { + const normalizedProvider = provider.provider.trim().toLowerCase(); + const normalizedDisplayName = provider.displayName.trim().toLowerCase(); + const normalizedHost = provider.host.trim().toLowerCase(); + + if ( + normalizedProvider === "gitlab" || + normalizedDisplayName.includes("gitlab") || + normalizedHost.includes("gitlab") + ) { + return "GitLab"; + } + + if ( + normalizedProvider === "youtrack" || + normalizedDisplayName.includes("youtrack") || + normalizedHost.includes("youtrack") + ) { + return "YouTrack"; + } + + return provider.displayName || provider.provider; + }; + + const seen = new Set(); + const options = providers + .filter((provider) => provider.hasToken || Boolean(provider.clientId)) + .map((provider) => ({ + value: provider.provider.toLowerCase(), + label: toProviderLabel(provider), + })) + .filter((provider) => { + if (seen.has(provider.value)) return false; + seen.add(provider.value); + return true; + }); + + return [{ value: FILTER_ALL, label: "All" }, ...options]; +} diff --git a/src/features/issues/ui/AssignedIssuesBoard/internal/use-assigned-issues-board-controller.test.tsx b/src/features/issues/ui/AssignedIssuesBoard/internal/use-assigned-issues-board-controller.test.tsx index f8b71336..0a925b00 100644 --- a/src/features/issues/ui/AssignedIssuesBoard/internal/use-assigned-issues-board-controller.test.tsx +++ b/src/features/issues/ui/AssignedIssuesBoard/internal/use-assigned-issues-board-controller.test.tsx @@ -1,7 +1,11 @@ import { act, renderHook } from "@testing-library/react"; import { useAssignedIssuesBoardController } from "@/features/issues/ui/AssignedIssuesBoard/internal/use-assigned-issues-board-controller"; -import type { AssignedIssueSnapshot, AssignedIssuesPage } from "@/shared/types/dashboard"; +import type { + AssignedIssueSnapshot, + AssignedIssuesPage, + ProviderConnection, +} from "@/shared/types/dashboard"; function createPage(overrides: Partial = {}): AssignedIssuesPage { return { @@ -27,11 +31,29 @@ function createIssue(key: string, state: AssignedIssueSnapshot["state"]): Assign key, title: key, state, + workflowStatus: state === "closed" ? "done" : "todo", labels: [], + labelTones: [], milestoneTitle: undefined, }; } +function createProviderConnection(provider: string, displayName: string): ProviderConnection { + return { + id: provider === "gitlab" ? 1 : 2, + provider, + displayName, + host: `${provider}.example.com`, + hasToken: true, + state: "live", + authMode: "pat", + preferredScope: "api", + statusNote: "", + oauthReady: false, + isPrimary: true, + }; +} + function createDeferredPage() { let resolve!: (value: AssignedIssuesPage) => void; let reject!: (error?: unknown) => void; @@ -48,7 +70,7 @@ function createDeferredPage() { } describe("useAssignedIssuesBoardController", () => { - it("loads the board in open-only mode by default", async () => { + it("loads the board in all mode by default", async () => { const loadPage = vi.fn().mockResolvedValue(createPage()); renderHook(() => useAssignedIssuesBoardController({ loadPage })); @@ -61,11 +83,71 @@ describe("useAssignedIssuesBoardController", () => { expect.objectContaining({ page: 1, pageSize: 10, - status: "opened", + status: "all", }), ); }); + it("passes selected provider through the assigned issues query", async () => { + const loadPage = vi.fn().mockResolvedValue(createPage()); + const listProviders = vi + .fn() + .mockResolvedValue([ + createProviderConnection("gitlab", "GitLab"), + createProviderConnection("youtrack", "YouTrack"), + ]); + + const { result } = renderHook(() => + useAssignedIssuesBoardController({ loadPage, listProviders }), + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(result.current.providerOptions.map((option) => option.value)).toEqual([ + "all", + "gitlab", + "youtrack", + ]); + + act(() => { + result.current.setProvider("youtrack"); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(loadPage.mock.lastCall?.[0]).toMatchObject({ provider: "youtrack", page: 1 }); + }); + + it("normalizes provider labels to product names", async () => { + const loadPage = vi.fn().mockResolvedValue(createPage()); + const listProviders = vi + .fn() + .mockResolvedValue([ + createProviderConnection("gitlab", "gitlab.com"), + createProviderConnection("youtrack", "YouTrack Workspace"), + ]); + + const { result } = renderHook(() => + useAssignedIssuesBoardController({ loadPage, listProviders }), + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(result.current.providerOptions).toEqual([ + { value: "all", label: "All" }, + { value: "gitlab", label: "GitLab" }, + { value: "youtrack", label: "YouTrack" }, + ]); + }); + it("debounces search before reloading", async () => { const loadPage = vi.fn().mockResolvedValue(createPage()); @@ -128,7 +210,16 @@ describe("useAssignedIssuesBoardController", () => { }); const loadPage = vi.fn().mockResolvedValue(page); - renderHook(() => useAssignedIssuesBoardController({ loadPage })); + const { result } = renderHook(() => useAssignedIssuesBoardController({ loadPage })); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + act(() => { + result.current.setStatus("todo"); + }); await act(async () => { await Promise.resolve(); @@ -257,11 +348,11 @@ describe("useAssignedIssuesBoardController", () => { }, ], }); - const closedPage = createPage({ + const donePage = createPage({ years: ["2025"], iterationOptions: [ { - id: "web-closed", + id: "web-done", label: "WEB · Mar 23 - Apr 5, 2026", badge: "WEB", searchText: "web mar 23", @@ -274,8 +365,8 @@ describe("useAssignedIssuesBoardController", () => { ], }); const loadPage = vi.fn().mockImplementation((input) => { - if (input.status === "closed") { - return Promise.resolve(closedPage); + if (input.status === "done") { + return Promise.resolve(donePage); } return Promise.resolve(openedPage); }); @@ -286,6 +377,14 @@ describe("useAssignedIssuesBoardController", () => { await Promise.resolve(); }); + act(() => { + result.current.setStatus("todo"); + }); + + await act(async () => { + await Promise.resolve(); + }); + act(() => { result.current.setYear("2026"); result.current.setIterationId("web-open"); @@ -296,7 +395,24 @@ describe("useAssignedIssuesBoardController", () => { }); act(() => { - result.current.setStatus("closed"); + result.current.setStatus("all"); + }); + + await act(async () => { + await Promise.resolve(); + }); + + act(() => { + result.current.setYear("2026"); + result.current.setIterationId("web-open"); + }); + + await act(async () => { + await Promise.resolve(); + }); + + act(() => { + result.current.setStatus("done"); }); await act(async () => { @@ -304,14 +420,14 @@ describe("useAssignedIssuesBoardController", () => { }); expect(loadPage.mock.lastCall?.[0]).toMatchObject({ - status: "closed", + status: "done", year: undefined, iterationId: undefined, }); act(() => { result.current.setYear("2025"); - result.current.setIterationId("web-closed"); + result.current.setIterationId("web-done"); }); await act(async () => { @@ -319,7 +435,7 @@ describe("useAssignedIssuesBoardController", () => { }); act(() => { - result.current.setStatus("opened"); + result.current.setStatus("todo"); }); await act(async () => { @@ -327,7 +443,7 @@ describe("useAssignedIssuesBoardController", () => { }); expect(loadPage.mock.lastCall?.[0]).toMatchObject({ - status: "opened", + status: "todo", year: "2026", iterationId: "web-open", }); @@ -371,7 +487,7 @@ describe("useAssignedIssuesBoardController", () => { act(() => { result.current.setYear("2026"); result.current.setIterationId("web-current"); - result.current.setStatus("closed"); + result.current.setStatus("done"); }); await act(async () => { @@ -380,22 +496,22 @@ describe("useAssignedIssuesBoardController", () => { }); expect(loadPage.mock.lastCall?.[0]).toMatchObject({ - status: "closed", + status: "done", year: undefined, iterationId: undefined, }); }); - it("keeps issues empty while switching from all to closed until closed data arrives", async () => { + it("keeps issues empty while switching from all to done until done data arrives", async () => { const openedRequest = createDeferredPage(); const allRequest = createDeferredPage(); - const closedRequest = createDeferredPage(); + const doneRequest = createDeferredPage(); const loadPage = vi.fn((input) => { if (input.status === "all") { return allRequest.promise; } - if (input.status === "closed") { - return closedRequest.promise; + if (input.status === "done") { + return doneRequest.promise; } return openedRequest.promise; }); @@ -403,16 +519,16 @@ describe("useAssignedIssuesBoardController", () => { const { result } = renderHook(() => useAssignedIssuesBoardController({ loadPage })); await act(async () => { - openedRequest.resolve( + allRequest.resolve( createPage({ - items: [createIssue("opened-1", "opened")], - totalItems: 1, + items: [createIssue("all-1", "opened"), createIssue("all-2", "closed")], + totalItems: 2, }), ); await Promise.resolve(); }); - expect(result.current.issues.map((issue) => issue.key)).toEqual(["opened-1"]); + expect(result.current.issues.map((issue) => issue.key)).toEqual(["all-1", "all-2"]); act(() => { result.current.setStatus("all"); @@ -422,10 +538,10 @@ describe("useAssignedIssuesBoardController", () => { expect(result.current.issues).toEqual([]); act(() => { - result.current.setStatus("closed"); + result.current.setStatus("done"); }); - expect(result.current.status).toBe("closed"); + expect(result.current.status).toBe("done"); expect(result.current.issues).toEqual([]); await act(async () => { @@ -438,30 +554,30 @@ describe("useAssignedIssuesBoardController", () => { await Promise.resolve(); }); - expect(result.current.status).toBe("closed"); + expect(result.current.status).toBe("done"); expect(result.current.issues).toEqual([]); await act(async () => { - closedRequest.resolve( + doneRequest.resolve( createPage({ - items: [createIssue("closed-1", "closed")], + items: [createIssue("done-1", "closed")], totalItems: 1, }), ); await Promise.resolve(); }); - expect(result.current.issues.map((issue) => issue.key)).toEqual(["closed-1"]); + expect(result.current.issues.map((issue) => issue.key)).toEqual(["done-1"]); }); - it("ignores a late closed response after switching back to opened", async () => { + it("ignores a late done response after switching back to todo", async () => { const initialOpenedRequest = createDeferredPage(); - const closedRequest = createDeferredPage(); + const doneRequest = createDeferredPage(); const reopenedRequest = createDeferredPage(); let openedCallCount = 0; const loadPage = vi.fn((input) => { - if (input.status === "closed") { - return closedRequest.promise; + if (input.status === "done") { + return doneRequest.promise; } openedCallCount += 1; return openedCallCount === 1 ? initialOpenedRequest.promise : reopenedRequest.promise; @@ -482,30 +598,30 @@ describe("useAssignedIssuesBoardController", () => { expect(result.current.issues.map((issue) => issue.key)).toEqual(["opened-initial"]); act(() => { - result.current.setStatus("closed"); + result.current.setStatus("done"); }); - expect(result.current.status).toBe("closed"); + expect(result.current.status).toBe("done"); expect(result.current.issues).toEqual([]); act(() => { - result.current.setStatus("opened"); + result.current.setStatus("todo"); }); - expect(result.current.status).toBe("opened"); + expect(result.current.status).toBe("todo"); expect(result.current.issues).toEqual([]); await act(async () => { - closedRequest.resolve( + doneRequest.resolve( createPage({ - items: [createIssue("closed-late", "closed")], + items: [createIssue("done-late", "closed")], totalItems: 1, }), ); await Promise.resolve(); }); - expect(result.current.status).toBe("opened"); + expect(result.current.status).toBe("todo"); expect(result.current.issues).toEqual([]); await act(async () => { diff --git a/src/features/issues/ui/AssignedIssuesBoard/internal/use-assigned-issues-board-controller.ts b/src/features/issues/ui/AssignedIssuesBoard/internal/use-assigned-issues-board-controller.ts index acfaedef..ad724496 100644 --- a/src/features/issues/ui/AssignedIssuesBoard/internal/use-assigned-issues-board-controller.ts +++ b/src/features/issues/ui/AssignedIssuesBoard/internal/use-assigned-issues-board-controller.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { loadAssignedIssuesPage } from "@/app/desktop/TauriService/tauri"; +import { listProviderConnections, loadAssignedIssuesPage } from "@/app/desktop/TauriService/tauri"; import { activeFilters, buildAssignedIssuesQueryKey, @@ -8,6 +8,7 @@ import { toAssignedIssuesQueryInput, } from "@/features/issues/ui/AssignedIssuesBoard/internal/use-assigned-issues-board-controller.lib"; import { useAssignedIssuesPageQuery } from "@/features/issues/ui/AssignedIssuesBoard/internal/use-assigned-issues-page-query"; +import { useProviderFilterOptions } from "@/features/issues/ui/AssignedIssuesBoard/internal/use-provider-filter-options"; import { FILTER_ALL, filterIterationsByYear, @@ -23,6 +24,7 @@ import type { AssignedIssuesPage, AssignedIssuesQueryInput, AssignedIssuesStatusFilter, + ProviderConnection, } from "@/shared/types/dashboard"; const DEFAULT_PAGE_SIZE = 10; @@ -31,16 +33,20 @@ const SEARCH_DEBOUNCE_MS = 300; interface UseAssignedIssuesBoardControllerOptions { loadPage?: (input: AssignedIssuesQueryInput) => Promise; + listProviders?: () => Promise; } export function useAssignedIssuesBoardController({ loadPage = loadAssignedIssuesPage, + listProviders = listProviderConnections, }: Readonly = {}) { const [searchInput, setSearchInput] = useState(""); + const providerOptions = useProviderFilterOptions(listProviders); const [queryState, setQueryState] = useState({ page: 1, pageSize: DEFAULT_PAGE_SIZE, - status: "opened", + status: "all", + provider: FILTER_ALL, search: "", filtersByStatus: createDefaultFilters(), }); @@ -95,7 +101,7 @@ export function useAssignedIssuesBoardController({ const autoSelectionKey = `${queryState.status}:${filters.year}`; if (autoSelectionKeyRef.current === autoSelectionKey) return; autoSelectionKeyRef.current = autoSelectionKey; - if (queryState.status !== "opened") return; + if (queryState.status !== "todo") return; const visibleIterations = filterIterationsByYear(page.iterationOptions ?? [], filters.year); const nextIterationId = findAutoSelectedIterationId(visibleIterations); if (!nextIterationId) return; @@ -107,11 +113,11 @@ export function useAssignedIssuesBoardController({ page: 1, filtersByStatus: { ...current.filtersByStatus, - opened: { + todo: { year: - current.filtersByStatus.opened.year === FILTER_ALL - ? (nextIteration?.year ?? current.filtersByStatus.opened.year) - : current.filtersByStatus.opened.year, + current.filtersByStatus.todo.year === FILTER_ALL + ? (nextIteration?.year ?? current.filtersByStatus.todo.year) + : current.filtersByStatus.todo.year, iterationId: nextIterationId, }, }, @@ -168,6 +174,14 @@ export function useAssignedIssuesBoardController({ searchInput, appliedSearchValue: queryState.search, setSearchInput, + provider: queryState.provider, + providerOptions, + setProvider: (value: string) => + setQueryState((current) => ({ + ...current, + provider: value, + page: 1, + })), year: activeFilters(queryState).year, setYear: (value: string) => updateFilters({ year: value }, { resetIterationWhenInvalid: true }), iterationId: activeFilters(queryState).iterationId, diff --git a/src/features/issues/ui/AssignedIssuesBoard/internal/use-provider-filter-options.ts b/src/features/issues/ui/AssignedIssuesBoard/internal/use-provider-filter-options.ts new file mode 100644 index 00000000..4a63235c --- /dev/null +++ b/src/features/issues/ui/AssignedIssuesBoard/internal/use-provider-filter-options.ts @@ -0,0 +1,27 @@ +import { useEffect, useMemo, useState } from "react"; +import { listProviderConnections } from "@/app/desktop/TauriService/tauri"; +import { createProviderFilterOptions } from "@/features/issues/ui/AssignedIssuesBoard/internal/use-assigned-issues-board-controller.lib"; + +import type { ProviderConnection } from "@/shared/types/dashboard"; + +export function useProviderFilterOptions( + listProviders: () => Promise = listProviderConnections, +) { + const [configuredProviders, setConfiguredProviders] = useState([]); + + useEffect(() => { + let cancelled = false; + void listProviders() + .then((providers) => { + if (!cancelled) setConfiguredProviders(providers); + }) + .catch(() => { + if (!cancelled) setConfiguredProviders([]); + }); + return () => { + cancelled = true; + }; + }, [listProviders]); + + return useMemo(() => createProviderFilterOptions(configuredProviders), [configuredProviders]); +} diff --git a/src/features/issues/ui/AssignedIssuesBoard/lib/assigned-issue-badge-tone.ts b/src/features/issues/ui/AssignedIssuesBoard/lib/assigned-issue-badge-tone.ts index 2a976434..d8fbb039 100644 --- a/src/features/issues/ui/AssignedIssuesBoard/lib/assigned-issue-badge-tone.ts +++ b/src/features/issues/ui/AssignedIssuesBoard/lib/assigned-issue-badge-tone.ts @@ -1,4 +1,6 @@ -const toneClasses = { +import type { ToneName } from "@/shared/types/dashboard"; + +export const toneClasses = { neutral: "border-border-subtle bg-field text-muted-foreground", primary: "border-primary/35 bg-primary/10 text-primary", accent: "border-accent/35 bg-accent/10 text-accent", @@ -8,91 +10,141 @@ const toneClasses = { destructive: "border-destructive/35 bg-destructive/10 text-destructive", } as const; -type ToneName = keyof typeof toneClasses; - -const fallbackTones: ToneName[] = ["primary", "accent", "success", "warning", "secondary"]; -const exactValueTones = new Map([ - ["task", "accent"], - ["enhancement", "warning"], - ["feature", "accent"], - ["bug", "destructive"], - ["epic", "secondary"], - ["frontend", "primary"], - ["backend", "secondary"], - ["other", "neutral"], - ["meeting", "secondary"], - ["irp", "primary"], +function buildToneMap(entries: Array<[string[], ToneName]>): Map { + const map = new Map(); + for (const [keys, tone] of entries) { + for (const key of keys) { + map.set(key, tone); + } + } + return map; +} + +// Exact dictionary — must stay in sync with Rust BadgeToneMapper::new() +const exactTones = buildToneMap([ + // Success states + [["done", "complete", "completed", "resolved", "finished", "closed", "cerrado", "finalizado", "listo", "terminado", "completado", "concluido", "resolvido", "fechado", "pronto"], "success"], + // Accent states + [["doing", "in progress", "progress", "review", "developing", "development", "curso", "progreso", "desarrollo", "revision", "em progresso", "revisao", "desenvolvimento"], "accent"], + // Primary states + [["todo", "to-do", "backlog", "ready", "planned", "triage", "open", "opened", "pendiente", "hacer", "por hacer", "abierto", "a fazer", "planejado", "aberto", "pendente"], "primary"], + // Warning states + [["blocked", "on hold", "waiting", "paused", "pending", "bloqueado", "en espera", "pausado", "em espera"], "warning"], + // Destructive states + [["urgent", "critical", "high priority", "escalated", "urgente", "critico", "alta prioridad", "alta prioridade"], "destructive"], + // Neutral states + [["archived", "cancelled", "wontfix", "wont-fix", "duplicate", "archivado", "cancelado", "arquivado"], "neutral"], + // Label-specific exact matches + [["task", "feature"], "accent"], + [["enhancement", "design", "ux", "ui", "spike", "research", "investigation", "prototype", "mvp", "discovery", "audit"], "warning"], + [["bug", "security", "hotfix"], "destructive"], + [["epic", "backend", "meeting", "database", "api", "migration", "infrastructure", "devops"], "secondary"], + [["frontend", "irp", "accessibility", "a11y", "i18n", "l10n", "planning", "support"], "primary"], + [["other", "docs", "ci", "cd", "cleanup", "debt", "maintenance", "monitoreo", "monitoring", "logging", "configuracion", "configuration"], "neutral"], + [["test", "testing", "deploy", "release"], "success"], + [["refactor", "performance", "integration"], "accent"], ]); -function hashString(value: string) { +function normalize(value: string) { + return value.trim().toLowerCase(); +} + +function hashString(value: string): number { let hash = 0; - for (const character of value) { - hash = (hash * 31 + character.charCodeAt(0)) >>> 0; + for (const ch of value) { + hash = (hash * 31 + ch.charCodeAt(0)) >>> 0; } return hash; } -function normalizeValue(value: string) { - return value.trim().toLowerCase(); +function hashTone(value: string): ToneName { + const tones: ToneName[] = [ + "primary", + "accent", + "success", + "warning", + "secondary", + "destructive", + "neutral", + ]; + return tones[hashString(value) % tones.length] ?? "primary"; } -function workflowTone(value: string): ToneName { - if (/(done|complete|completed|resolved|cerrad|finalizad|listo|terminad)/.test(value)) { - return "success"; - } - if (/(doing|progress|review|develop|curso|progreso|desarrollo)/.test(value)) { - return "accent"; - } - if (/(todo|to[\s_-]?do|backlog|ready|planned|triage|pendiente|hacer)/.test(value)) { - return "primary"; - } - if (/(closed|cerrad)/.test(value)) { - return "neutral"; - } - return "secondary"; +function isSuccess(value: string): boolean { + return /\b(done|complete|completed|resolved|finished|cerrado|finalizado|listo|terminado|concluido|resolvido|fechado|pronto)\b/.test(value); } -export function getAssignedIssueStateBadgeClassName(state: string) { - return state.toLowerCase() === "closed" ? toneClasses.neutral : toneClasses.success; +function isAccent(value: string): boolean { + return /\b(progress|doing|review|develop|curso|progreso|desarrollo|revision|progresso|revisao|desenvolvimento)\b/.test(value); } -export function getAssignedIssueWorkflowBadgeClassName(value: string) { - return toneClasses[workflowTone(normalizeValue(value))]; +function isPrimary(value: string): boolean { + return /\b(todo|to[\s_-]?do|backlog|ready|planned|triage|open|pendiente|hacer|abierto|fazer|planejado|aberto|pendente)\b/.test(value); } -export function getAssignedIssueLabelBadgeClassName(label: string) { - const normalized = normalizeValue(label); +function isWarning(value: string): boolean { + return /\b(block|hold|wait|paused|pending|bloqueado|espera|pausado)\b/.test(value); +} - if (/^\d{4}$/.test(normalized)) { - return toneClasses[fallbackTones[hashString(normalized) % fallbackTones.length] ?? "primary"]; - } +function isDestructive(value: string): boolean { + return /\b(urgent|critical|high|escalated|urgente|critico|alta)\b/.test(value); +} - if (normalized.startsWith("workflow::")) { - return toneClasses[workflowTone(normalized.split("::")[1] ?? normalized)]; - } +function isNeutral(value: string): boolean { + return /\b(archived|cancelled|wontfix|duplicate|archivado|cancelado|arquivado)\b/.test(value); +} - if (normalized.startsWith("category::")) { - return toneClasses.accent; - } +function mapStatus(status: string): ToneName { + const normalized = normalize(status); - if (normalized.startsWith("team::")) { - return toneClasses.secondary; - } + const exact = exactTones.get(normalized); + if (exact) return exact; + + if (isSuccess(normalized)) return "success"; + if (isAccent(normalized)) return "accent"; + if (isPrimary(normalized)) return "primary"; + if (isWarning(normalized)) return "warning"; + if (isDestructive(normalized)) return "destructive"; + if (isNeutral(normalized)) return "neutral"; + + return hashTone(normalized); +} + +function mapLabel(label: string): ToneName { + const normalized = normalize(label); + // Prefix-based logic (GitLab scoped labels) + if (normalized.startsWith("category::")) return "accent"; + if (normalized.startsWith("team::")) return "secondary"; if (normalized.startsWith("priority::")) { - if (/(high|critical|urgent|alta)/.test(normalized)) { - return toneClasses.destructive; - } - if (/(medium|med)/.test(normalized)) { - return toneClasses.warning; - } - return toneClasses.neutral; + const suffix = normalized.split("::")[1] ?? normalized; + if (isDestructive(suffix)) return "destructive"; + if (isWarning(suffix)) return "warning"; + return "neutral"; } - - const exactTone = exactValueTones.get(normalized); - if (exactTone) { - return toneClasses[exactTone]; + if (normalized.startsWith("workflow::")) { + const suffix = normalized.split("::")[1] ?? normalized; + return mapStatus(suffix); } - return toneClasses[fallbackTones[hashString(normalized) % fallbackTones.length] ?? "primary"]; + // Exact match + const exact = exactTones.get(normalized); + if (exact) return exact; + + // Hash fallback + return hashTone(normalized); +} + +// --- Public API --- + +export function getAssignedIssueStateBadgeClassName(state: string) { + return toneClasses[mapStatus(state)]; +} + +export function getAssignedIssueWorkflowBadgeClassName(value: string) { + return toneClasses[mapStatus(value)]; +} + +export function getAssignedIssueLabelBadgeClassName(label: string) { + return toneClasses[mapLabel(label)]; } diff --git a/src/features/issues/ui/AssignedIssuesBoard/lib/assigned-issues-content-key.ts b/src/features/issues/ui/AssignedIssuesBoard/lib/assigned-issues-content-key.ts new file mode 100644 index 00000000..4f8a0bb9 --- /dev/null +++ b/src/features/issues/ui/AssignedIssuesBoard/lib/assigned-issues-content-key.ts @@ -0,0 +1,27 @@ +import type { AssignedIssueSnapshot } from "@/shared/types/dashboard"; + +export function buildAssignedIssuesContentKey(input: { + status: string; + provider: string; + year: string; + iterationId: string; + appliedSearchValue: string; + page: number; + totalItems: number; + issues: AssignedIssueSnapshot[]; + error?: string | null; + loading: boolean; +}) { + return [ + input.status, + input.provider, + input.year, + input.iterationId, + input.appliedSearchValue, + input.page, + input.totalItems, + input.issues.map((issue) => issue.key).join("|"), + input.error ?? "", + input.loading ? "loading" : "ready", + ].join(":"); +} diff --git a/src/features/issues/ui/AssignedIssuesBoard/lib/group-issues-by-sprint.test.ts b/src/features/issues/ui/AssignedIssuesBoard/lib/group-issues-by-sprint.test.ts index 66f74070..8354f114 100644 --- a/src/features/issues/ui/AssignedIssuesBoard/lib/group-issues-by-sprint.test.ts +++ b/src/features/issues/ui/AssignedIssuesBoard/lib/group-issues-by-sprint.test.ts @@ -13,6 +13,7 @@ function issue( issueId: partial.key, providerIssueRef: "gid://gitlab/Issue/1", state: "opened", + workflowStatus: "todo", labels: [], ...partial, }; diff --git a/src/features/issues/ui/AssignedIssuesBoard/lib/workflow-column.test.ts b/src/features/issues/ui/AssignedIssuesBoard/lib/workflow-column.test.ts index 24c0efbb..4c2db4bf 100644 --- a/src/features/issues/ui/AssignedIssuesBoard/lib/workflow-column.test.ts +++ b/src/features/issues/ui/AssignedIssuesBoard/lib/workflow-column.test.ts @@ -15,14 +15,21 @@ function issue( providerIssueRef: "gid://gitlab/Issue/1", title: "T", state: "opened", + workflowStatus: "other", labels: [], ...partial, }; } describe("getWorkflowColumnId", () => { - it("returns closed for closed state", () => { - expect(getWorkflowColumnId(issue({ key: "a", state: "closed" }))).toBe("closed"); + it("returns done for closed state", () => { + expect(getWorkflowColumnId(issue({ key: "a", state: "closed" }))).toBe("done"); + }); + + it("uses normalized workflow status first", () => { + expect( + getWorkflowColumnId(issue({ key: "a", state: "opened", workflowStatus: "blocked" })), + ).toBe("blocked"); }); it("maps todo-like labels", () => { diff --git a/src/features/issues/ui/AssignedIssuesBoard/lib/workflow-column.ts b/src/features/issues/ui/AssignedIssuesBoard/lib/workflow-column.ts index 47d21780..e6426ce3 100644 --- a/src/features/issues/ui/AssignedIssuesBoard/lib/workflow-column.ts +++ b/src/features/issues/ui/AssignedIssuesBoard/lib/workflow-column.ts @@ -1,13 +1,13 @@ import type { AssignedIssueSnapshot } from "@/shared/types/dashboard"; -export type WorkflowColumnId = "todo" | "doing" | "done" | "closed" | "other"; +export type WorkflowColumnId = "todo" | "doing" | "blocked" | "done" | "other"; /** Column order left → right on the status board. */ export const WORKFLOW_COLUMN_ORDER: WorkflowColumnId[] = [ "todo", "doing", + "blocked", "done", - "closed", "other", ]; @@ -22,8 +22,8 @@ export function workflowStatusFilterLabel( key: | "issues.statusTodo" | "issues.statusDoing" + | "issues.statusBlocked" | "issues.statusDone" - | "issues.statusClosed" | "issues.statusOther", ) => string, ): string { @@ -32,19 +32,27 @@ export function workflowStatusFilterLabel( return t("issues.statusTodo"); case "doing": return t("issues.statusDoing"); + case "blocked": + return t("issues.statusBlocked"); case "done": return t("issues.statusDone"); - case "closed": - return t("issues.statusClosed"); default: return t("issues.statusOther"); } } export function getWorkflowColumnId(issue: AssignedIssueSnapshot): WorkflowColumnId { + if ( + issue.workflowStatus === "todo" || + issue.workflowStatus === "doing" || + issue.workflowStatus === "blocked" || + issue.workflowStatus === "done" + ) { + return issue.workflowStatus; + } const state = issue.state.toLowerCase(); if (state === "closed") { - return "closed"; + return "done"; } const labels = issue.labels.map((l) => l.toLowerCase()); diff --git a/src/features/issues/ui/IssueOriginBadge/IssueOriginBadge.test.tsx b/src/features/issues/ui/IssueOriginBadge/IssueOriginBadge.test.tsx new file mode 100644 index 00000000..eed730b1 --- /dev/null +++ b/src/features/issues/ui/IssueOriginBadge/IssueOriginBadge.test.tsx @@ -0,0 +1,12 @@ +import { render, screen } from "@testing-library/react"; +import { IssueOriginBadge } from "@/features/issues/ui/IssueOriginBadge/IssueOriginBadge"; + +describe("IssueOriginBadge", () => { + it("renders canonical provider labels", () => { + const { rerender } = render(); + expect(screen.getByText("GitLab")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("YouTrack")).toBeInTheDocument(); + }); +}); diff --git a/src/features/issues/ui/IssueOriginBadge/IssueOriginBadge.tsx b/src/features/issues/ui/IssueOriginBadge/IssueOriginBadge.tsx new file mode 100644 index 00000000..77ebf416 --- /dev/null +++ b/src/features/issues/ui/IssueOriginBadge/IssueOriginBadge.tsx @@ -0,0 +1,26 @@ +import { cn } from "@/shared/lib/utils"; + +interface IssueOriginBadgeProps { + provider: string; + className?: string; +} + +const PROVIDER_LABELS: Record = { + gitlab: "GitLab", + youtrack: "YouTrack", +}; + +export function IssueOriginBadge({ provider, className }: Readonly) { + const key = provider.toLowerCase(); + const label = PROVIDER_LABELS[key] ?? provider; + return ( + + {label} + + ); +} diff --git a/src/features/onboarding/screens/OnboardingFlow/OnboardingFlow.test.tsx b/src/features/onboarding/screens/OnboardingFlow/OnboardingFlow.test.tsx index 3d5350f9..06938258 100644 --- a/src/features/onboarding/screens/OnboardingFlow/OnboardingFlow.test.tsx +++ b/src/features/onboarding/screens/OnboardingFlow/OnboardingFlow.test.tsx @@ -48,7 +48,7 @@ vi.mock("@/app/desktop/TauriService/tauri", async () => { ); return { ...actual, - listGitLabConnections: vi.fn(async () => []), + listProviderConnections: vi.fn(async () => []), loadAppPreferences: vi.fn(async () => ({ themeMode: "system", issueCodeTheme: "timely-night", diff --git a/src/features/settings/hooks/use-settings-schedule-controller/use-settings-schedule-controller.ts b/src/features/settings/hooks/use-settings-schedule-controller/use-settings-schedule-controller.ts index 64913be3..a378f4e9 100644 --- a/src/features/settings/hooks/use-settings-schedule-controller/use-settings-schedule-controller.ts +++ b/src/features/settings/hooks/use-settings-schedule-controller/use-settings-schedule-controller.ts @@ -94,10 +94,7 @@ export function useSettingsScheduleController({ void prefetchPlaySnapshot().catch(() => {}); - toast.success(t("settings.scheduleSaveToastSuccessTitle"), { - description: t("settings.scheduleSaveToastSuccessDescription"), - duration: 6500, - }); + toast.success(t("settings.scheduleSaveToastSuccessTitle")); } catch (error) { dispatchScheduleForm({ type: "setSchedulePhase", phase: "idle" }); const detail = diff --git a/src/features/settings/screens/SettingsPage/SettingsPage.tsx b/src/features/settings/screens/SettingsPage/SettingsPage.tsx index dbecdb9f..a40a0c53 100644 --- a/src/features/settings/screens/SettingsPage/SettingsPage.tsx +++ b/src/features/settings/screens/SettingsPage/SettingsPage.tsx @@ -24,10 +24,11 @@ import type { AppUpdateInfo, AuthLaunchPlan, BootstrapPayload, - GitLabConnectionInput, GitLabUserInfo, OAuthCallbackResolution, + ProviderConnectionInput, ProviderConnection, + ProviderKey, ScheduleInput, SyncState, } from "@/shared/types/dashboard"; @@ -43,11 +44,11 @@ export interface SettingsPageProps { onEvent?: (event: AppUpdateDownloadEvent) => void, ) => Promise; onRestartToUpdate: () => Promise; - onSaveConnection: (input: GitLabConnectionInput) => Promise; - onSavePat: (host: string, token: string) => Promise; - onBeginOAuth: (input: GitLabConnectionInput) => Promise; + onSaveConnection: (input: ProviderConnectionInput) => Promise; + onSavePat: (provider: ProviderKey, host: string, token: string) => Promise; + onBeginOAuth: (input: ProviderConnectionInput) => Promise; onResolveCallback: (sessionId: string, callbackUrl: string) => Promise; - onValidateToken?: (host: string) => Promise; + onValidateToken?: (provider: ProviderKey, host: string) => Promise; onListenOAuthEvents?: ( onSuccess: (payload: OAuthCallbackResolution) => void, onError: (message: string) => void, diff --git a/src/features/settings/sections/SettingsConnectionSection/SettingsConnectionSection.test.tsx b/src/features/settings/sections/SettingsConnectionSection/SettingsConnectionSection.test.tsx new file mode 100644 index 00000000..08465b8e --- /dev/null +++ b/src/features/settings/sections/SettingsConnectionSection/SettingsConnectionSection.test.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { SettingsConnectionSection } from "@/features/settings/sections/SettingsConnectionSection/SettingsConnectionSection"; + +import type { ProviderConnection } from "@/shared/types/dashboard"; + +vi.mock("@/app/providers/I18nService/i18n", () => ({ + useI18n: vi.fn(() => ({ t: (key: string) => key })), +})); + +vi.mock("@/app/providers/MotionService/motion", () => ({ + useMotionSettings: vi.fn(() => ({ allowDecorativeAnimation: false })), +})); + +vi.mock("@/domains/gitlab-connection/ui/GitLabAuthPanel/GitLabAuthPanel", () => ({ + GitLabAuthPanel: () =>
, +})); + +vi.mock("@/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel", () => ({ + YouTrackAuthPanel: () =>
, +})); + +describe("SettingsConnectionSection", () => { + const youtrackConnection: ProviderConnection = { + id: 2, + provider: "youtrack", + displayName: "YouTrack", + host: "youtrack.example.com", + hasToken: true, + state: "live", + authMode: "pat", + preferredScope: "api", + statusNote: "", + oauthReady: false, + isPrimary: true, + }; + + const defaultProps = { + connectionSummary: "Connected", + isConnected: true, + connections: [youtrackConnection], + onSaveConnection: vi.fn(), + onSavePat: vi.fn(), + onBeginOAuth: vi.fn(), + onResolveCallback: vi.fn(), + }; + + it("returns a connected YouTrack row to connect state when disconnect clicked", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "settings.connection Connected" })); + fireEvent.click(screen.getByRole("button", { name: "providers.disconnect" })); + expect(screen.getAllByRole("button", { name: "providers.connect" }).length).toBeGreaterThan(0); + expect(screen.queryByText("providers.connectedToHost")).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/settings/sections/SettingsConnectionSection/SettingsConnectionSection.tsx b/src/features/settings/sections/SettingsConnectionSection/SettingsConnectionSection.tsx index d9e4ffc6..618dd1ac 100644 --- a/src/features/settings/sections/SettingsConnectionSection/SettingsConnectionSection.tsx +++ b/src/features/settings/sections/SettingsConnectionSection/SettingsConnectionSection.tsx @@ -1,28 +1,35 @@ +import Compass from "lucide-react/dist/esm/icons/compass.js"; +import GitlabIcon from "lucide-react/dist/esm/icons/gitlab.js"; import Plug from "lucide-react/dist/esm/icons/plug.js"; import { m } from "motion/react"; +import { useState } from "react"; import { useI18n } from "@/app/providers/I18nService/i18n"; import { useMotionSettings } from "@/app/providers/MotionService/motion"; import { GitLabAuthPanel } from "@/domains/gitlab-connection/ui/GitLabAuthPanel/GitLabAuthPanel"; +import { ProviderConnectionRow } from "@/domains/gitlab-connection/ui/ProviderConnectionRow/ProviderConnectionRow"; +import { YouTrackAuthPanel } from "@/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel"; import { staggerItem } from "@/shared/lib/animations/animations"; +import { isConnectionActive } from "@/shared/types/dashboard"; import { AccordionItem } from "@/shared/ui/Accordion/Accordion"; import type { AuthLaunchPlan, - GitLabConnectionInput, GitLabUserInfo, OAuthCallbackResolution, + ProviderConnectionInput, ProviderConnection, + ProviderKey, } from "@/shared/types/dashboard"; export interface SettingsConnectionSectionProps { connectionSummary: string; isConnected: boolean; connections: ProviderConnection[]; - onSaveConnection: (input: GitLabConnectionInput) => Promise; - onSavePat: (host: string, token: string) => Promise; - onBeginOAuth: (input: GitLabConnectionInput) => Promise; + onSaveConnection: (input: ProviderConnectionInput) => Promise; + onSavePat: (provider: ProviderKey, host: string, token: string) => Promise; + onBeginOAuth: (input: ProviderConnectionInput) => Promise; onResolveCallback: (sessionId: string, callbackUrl: string) => Promise; - onValidateToken?: (host: string) => Promise; + onValidateToken?: (provider: ProviderKey, host: string) => Promise; onListenOAuthEvents?: ( onSuccess: (payload: OAuthCallbackResolution) => void, onError: (message: string) => void, @@ -42,6 +49,49 @@ export function SettingsConnectionSection({ }: Readonly) { const { t } = useI18n(); const { allowDecorativeAnimation } = useMotionSettings(); + const [expandedProvider, setExpandedProvider] = useState(null); + const [disconnectedProviders, setDisconnectedProviders] = useState>( + () => new Set(), + ); + + const visibleConnections = connections.filter( + (c) => !disconnectedProviders.has(c.provider.toLowerCase() as ProviderKey), + ); + const gitlabConns = visibleConnections.filter((c) => c.provider.toLowerCase() === "gitlab"); + const youtrackConns = visibleConnections.filter((c) => c.provider.toLowerCase() === "youtrack"); + const gitlabConnected = gitlabConns.some(isConnectionActive); + const youtrackConnected = youtrackConns.some(isConnectionActive); + const gitlabPrimary = gitlabConns.find((c) => c.isPrimary) ?? gitlabConns[0]; + const youtrackPrimary = youtrackConns.find((c) => c.isPrimary) ?? youtrackConns[0]; + + function handleToggle(provider: ProviderKey) { + setExpandedProvider((prev) => (prev === provider ? null : provider)); + } + + function handleDisconnect(provider: ProviderKey) { + setDisconnectedProviders((prev) => new Set(prev).add(provider)); + setExpandedProvider(null); + } + + async function saveConnection(input: ProviderConnectionInput) { + const saved = await onSaveConnection(input); + setDisconnectedProviders((prev) => { + const next = new Set(prev); + next.delete(saved.provider.toLowerCase() as ProviderKey); + return next; + }); + return saved; + } + + async function savePat(provider: ProviderKey, host: string, token: string) { + const saved = await onSavePat(provider, host, token); + setDisconnectedProviders((prev) => { + const next = new Set(prev); + next.delete(provider); + return next; + }); + return saved; + } return ( @@ -53,15 +103,54 @@ export function SettingsConnectionSection({ defaultOpen={!isConnected} allowDecorativeAnimation={allowDecorativeAnimation} > - +
+ handleToggle("gitlab")} + onDisconnect={() => handleDisconnect("gitlab")} + > + saveConnection({ ...input, provider: "gitlab" })} + onSavePat={(host, token) => savePat("gitlab", host, token)} + onBeginOAuth={(input) => onBeginOAuth({ ...input, provider: "gitlab" })} + onResolveCallback={onResolveCallback} + onValidateToken={ + onValidateToken ? (host) => onValidateToken("gitlab", host) : undefined + } + onListenOAuthEvents={onListenOAuthEvents} + /> + + + handleToggle("youtrack")} + onDisconnect={() => handleDisconnect("youtrack")} + > + + +
); diff --git a/src/features/setup/screens/SetupProviderPage/SetupProviderPage.test.tsx b/src/features/setup/screens/SetupProviderPage/SetupProviderPage.test.tsx index 2db096fc..c9dfff1a 100644 --- a/src/features/setup/screens/SetupProviderPage/SetupProviderPage.test.tsx +++ b/src/features/setup/screens/SetupProviderPage/SetupProviderPage.test.tsx @@ -1,6 +1,8 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { SetupProviderPage } from "@/features/setup/screens/SetupProviderPage/SetupProviderPage"; +import type { ProviderConnection } from "@/shared/types/dashboard"; + vi.mock("@/app/providers/I18nService/i18n", () => ({ useI18n: vi.fn(() => ({ t: (key: string) => key })), })); @@ -9,7 +11,25 @@ vi.mock("@/domains/gitlab-connection/ui/GitLabAuthPanel/GitLabAuthPanel", () => GitLabAuthPanel: () =>
, })); +vi.mock("@/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel", () => ({ + YouTrackAuthPanel: () =>
, +})); + describe("SetupProviderPage", () => { + const gitlabConnection: ProviderConnection = { + id: 1, + provider: "gitlab", + displayName: "GitLab", + host: "gitlab.com", + hasToken: true, + state: "live", + authMode: "pat", + preferredScope: "api", + statusNote: "", + oauthReady: false, + isPrimary: true, + }; + const defaultProps = { connections: [], onBack: vi.fn(), @@ -20,12 +40,27 @@ describe("SetupProviderPage", () => { onResolveCallback: vi.fn(), }; - it("renders title and GitLabAuthPanel", () => { + it("renders title and both provider rows", () => { render(); expect(screen.getByText("setup.providerTitle")).toBeInTheDocument(); + expect(screen.getByText("GitLab")).toBeInTheDocument(); + expect(screen.getByText("YouTrack")).toBeInTheDocument(); + }); + + it("expands GitLab auth panel when connect clicked", () => { + render(); + const connectButtons = screen.getAllByText("providers.connect"); + fireEvent.click(connectButtons[0]); expect(screen.getByTestId("gitlab-auth-panel")).toBeInTheDocument(); }); + it("returns a connected GitLab row to connect state when disconnect clicked", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "providers.disconnect" })); + expect(screen.getAllByRole("button", { name: "providers.connect" }).length).toBeGreaterThan(0); + expect(screen.queryByText("providers.connectedToHost")).not.toBeInTheDocument(); + }); + it("calls onNext when continue clicked", () => { render(); fireEvent.click(screen.getByRole("button", { name: "common.skipForNow" })); diff --git a/src/features/setup/screens/SetupProviderPage/SetupProviderPage.tsx b/src/features/setup/screens/SetupProviderPage/SetupProviderPage.tsx index 6335c2f3..171fedbc 100644 --- a/src/features/setup/screens/SetupProviderPage/SetupProviderPage.tsx +++ b/src/features/setup/screens/SetupProviderPage/SetupProviderPage.tsx @@ -1,25 +1,31 @@ +import Compass from "lucide-react/dist/esm/icons/compass.js"; +import GitlabIcon from "lucide-react/dist/esm/icons/gitlab.js"; +import { useState } from "react"; import { useI18n } from "@/app/providers/I18nService/i18n"; import { GitLabAuthPanel } from "@/domains/gitlab-connection/ui/GitLabAuthPanel/GitLabAuthPanel"; -import { hasActiveConnection } from "@/shared/types/dashboard"; +import { ProviderConnectionRow } from "@/domains/gitlab-connection/ui/ProviderConnectionRow/ProviderConnectionRow"; +import { YouTrackAuthPanel } from "@/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel"; +import { hasActiveConnection, isConnectionActive } from "@/shared/types/dashboard"; import { Button } from "@/shared/ui/Button/Button"; import type { AuthLaunchPlan, - GitLabConnectionInput, GitLabUserInfo, OAuthCallbackResolution, + ProviderConnectionInput, ProviderConnection, + ProviderKey, } from "@/shared/types/dashboard"; interface SetupProviderPageProps { connections: ProviderConnection[]; onBack: () => void; onNext: () => void; - onSaveConnection: (input: GitLabConnectionInput) => Promise; - onSavePat: (host: string, token: string) => Promise; - onBeginOAuth: (input: GitLabConnectionInput) => Promise; + onSaveConnection: (input: ProviderConnectionInput) => Promise; + onSavePat: (provider: ProviderKey, host: string, token: string) => Promise; + onBeginOAuth: (input: ProviderConnectionInput) => Promise; onResolveCallback: (sessionId: string, callbackUrl: string) => Promise; - onValidateToken?: (host: string) => Promise; + onValidateToken?: (provider: ProviderKey, host: string) => Promise; onListenOAuthEvents?: ( onSuccess: (payload: OAuthCallbackResolution) => void, onError: (message: string) => void, @@ -38,7 +44,50 @@ export function SetupProviderPage({ onListenOAuthEvents, }: Readonly) { const { t } = useI18n(); - const hasConnection = hasActiveConnection(connections); + const [expandedProvider, setExpandedProvider] = useState(null); + const [disconnectedProviders, setDisconnectedProviders] = useState>( + () => new Set(), + ); + + const visibleConnections = connections.filter( + (c) => !disconnectedProviders.has(c.provider.toLowerCase() as ProviderKey), + ); + const hasConnection = hasActiveConnection(visibleConnections); + const gitlabConns = visibleConnections.filter((c) => c.provider.toLowerCase() === "gitlab"); + const youtrackConns = visibleConnections.filter((c) => c.provider.toLowerCase() === "youtrack"); + const gitlabConnected = gitlabConns.some(isConnectionActive); + const youtrackConnected = youtrackConns.some(isConnectionActive); + const gitlabPrimary = gitlabConns.find((c) => c.isPrimary) ?? gitlabConns[0]; + const youtrackPrimary = youtrackConns.find((c) => c.isPrimary) ?? youtrackConns[0]; + + function handleToggle(provider: ProviderKey) { + setExpandedProvider((prev) => (prev === provider ? null : provider)); + } + + function handleDisconnect(provider: ProviderKey) { + setDisconnectedProviders((prev) => new Set(prev).add(provider)); + setExpandedProvider(null); + } + + async function saveConnection(input: ProviderConnectionInput) { + const saved = await onSaveConnection(input); + setDisconnectedProviders((prev) => { + const next = new Set(prev); + next.delete(saved.provider.toLowerCase() as ProviderKey); + return next; + }); + return saved; + } + + async function savePat(provider: ProviderKey, host: string, token: string) { + const saved = await onSavePat(provider, host, token); + setDisconnectedProviders((prev) => { + const next = new Set(prev); + next.delete(provider); + return next; + }); + return saved; + } return (
@@ -47,16 +96,53 @@ export function SetupProviderPage({

{t("setup.providerDescription")}

-
- +
+ handleToggle("gitlab")} + onDisconnect={() => handleDisconnect("gitlab")} + > + saveConnection({ ...input, provider: "gitlab" })} + onSavePat={(host, token) => savePat("gitlab", host, token)} + onBeginOAuth={(input) => onBeginOAuth({ ...input, provider: "gitlab" })} + onResolveCallback={onResolveCallback} + onValidateToken={ + onValidateToken ? (host) => onValidateToken("gitlab", host) : undefined + } + onListenOAuthEvents={onListenOAuthEvents} + /> + + + handleToggle("youtrack")} + onDisconnect={() => handleDisconnect("youtrack")} + > + +
diff --git a/src/shared/types/dashboard.ts b/src/shared/types/dashboard.ts index 56ee3e2d..76755de5 100644 --- a/src/shared/types/dashboard.ts +++ b/src/shared/types/dashboard.ts @@ -103,9 +103,12 @@ export interface AssignedIssueSnapshot { key: string; title: string; state: string; + statusLabel?: string; + workflowStatus: "todo" | "doing" | "blocked" | "done" | "other"; updatedAt?: string; webUrl?: string; labels: string[]; + labelTones?: ToneName[]; milestoneTitle?: string; iterationGitlabId?: string; iterationGroupId?: string; @@ -116,6 +119,8 @@ export interface AssignedIssueSnapshot { iterationStartDate?: string; /** GitLab iteration due/end (YYYY-MM-DD) when sync provides it. */ iterationDueDate?: string; + startDate?: string; + dueDate?: string; } export interface IssueRouteReference { @@ -133,12 +138,15 @@ export interface IssueActor { avatarUrl?: string; } +export type ToneName = "neutral" | "primary" | "accent" | "success" | "warning" | "secondary" | "destructive"; + export interface IssueMetadataOption { id: string; label: string; color?: string; /** GitLab iteration cadence title (e.g. WEB), when known from catalog or API. */ badge?: string; + tone: ToneName; } export interface IssueStatusOption extends IssueMetadataOption { @@ -199,6 +207,12 @@ export interface IssueRelatedItem { labels: IssueMetadataOption[]; } +export interface IssueMetadataField { + id: string; + label: string; + value: string; +} + export interface IssueDetailsSnapshot { reference: IssueReference; key: string; @@ -212,12 +226,22 @@ export interface IssueDetailsSnapshot { description?: string; status?: IssueStatusOption; statusOptions?: IssueStatusOption[]; + projectName?: string; + issueType?: string; + priority?: string; + startDate?: string; + dueDate?: string; + estimate?: string; + weight?: number; + participants?: IssueActor[]; labels: IssueMetadataOption[]; milestoneTitle?: string; milestone?: IssueMetadataOption; iteration?: IssueIterationDetails; + parentItem?: IssueRelatedItem; linkedItems?: IssueRelatedItem[]; childItems?: IssueRelatedItem[]; + metadataFields?: IssueMetadataField[]; activity: IssueActivityItem[]; activityHasNextPage?: boolean; activityNextPage?: number; @@ -244,7 +268,7 @@ export type LoadIssueDetailsResponse = activityNextPage?: number | null; }; -export type AssignedIssuesStatusFilter = "all" | "opened" | "closed"; +export type AssignedIssuesStatusFilter = "all" | "opened" | "todo" | "doing" | "blocked" | "done"; export interface AssignedIssuesPeriodInput { start: string; @@ -256,6 +280,7 @@ export interface AssignedIssuesQueryInput { page: number; pageSize: number; status: AssignedIssuesStatusFilter; + provider?: string; year?: string; iterationId?: string; search?: string; @@ -326,6 +351,17 @@ export interface ProviderConnection { isPrimary: boolean; } +export type ProviderKey = "gitlab" | "youtrack"; + +export interface ProviderConnectionInput { + provider: ProviderKey; + host: string; + authMode: string; + preferredScope: string; + displayName?: string; + clientId?: string; +} + /** Whether a single connection has usable credentials (PAT or OAuth). */ export function isConnectionActive(c: ProviderConnection): boolean { return c.hasToken || Boolean(c.clientId); diff --git a/src/shared/ui/Combobox/Combobox.tsx b/src/shared/ui/Combobox/Combobox.tsx index 670a7ab1..c32fbf6c 100644 --- a/src/shared/ui/Combobox/Combobox.tsx +++ b/src/shared/ui/Combobox/Combobox.tsx @@ -109,7 +109,7 @@ function ComboboxList({ className, ...props }: Readonly & { "data-testid"?: string; + "data-issue-activity-scroll"?: string; }; }; diff --git a/src/shared/ui/SearchAutocomplete/SearchAutocomplete.test.tsx b/src/shared/ui/SearchAutocomplete/SearchAutocomplete.test.tsx index 14ad7177..0d38f6ae 100644 --- a/src/shared/ui/SearchAutocomplete/SearchAutocomplete.test.tsx +++ b/src/shared/ui/SearchAutocomplete/SearchAutocomplete.test.tsx @@ -203,7 +203,7 @@ describe("SearchAutocomplete", () => { it("lets a new query replace a previous selection without restoring old text", async () => { render(); - const input = screen.getByRole("combobox"); + const input = screen.getByRole("combobox") as HTMLInputElement; fireEvent.focus(input); fireEvent.keyDown(input, { key: "ArrowDown" }); diff --git a/src/shared/ui/SearchAutocomplete/internal/use-search-autocomplete-controller.ts b/src/shared/ui/SearchAutocomplete/internal/use-search-autocomplete-controller.ts index 0fae77c3..a7b68d88 100644 --- a/src/shared/ui/SearchAutocomplete/internal/use-search-autocomplete-controller.ts +++ b/src/shared/ui/SearchAutocomplete/internal/use-search-autocomplete-controller.ts @@ -22,7 +22,7 @@ export function useSearchAutocompleteController({ onSelectSuggestion, }: Readonly) { const inputRef = useRef(null); - const blurTimeoutRef = useRef | null>(null); + const blurTimeoutRef = useRef(null); const [isFocused, setIsFocused] = useState(false); const [highlightedIndex, setHighlightedIndex] = useState(-1); diff --git a/src/shared/ui/SearchCombobox/SearchCombobox.tsx b/src/shared/ui/SearchCombobox/SearchCombobox.tsx index d011f4c6..ab9232b7 100644 --- a/src/shared/ui/SearchCombobox/SearchCombobox.tsx +++ b/src/shared/ui/SearchCombobox/SearchCombobox.tsx @@ -195,7 +195,7 @@ export function SearchCombobox({ {(item: SearchComboboxOption) => ( - {item.label} + {item.label} )} @@ -207,7 +207,7 @@ export function SearchCombobox({ {(item: SearchComboboxOption) => ( - {item.label} + {item.label} )} diff --git a/src/styles/globals.css b/src/styles/globals.css index a50bb2cc..4427bea8 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -855,3 +855,24 @@ html[data-theme="light"] .issue-md-preview .wmde-markdown { font-size: 0.95em; margin-block: 0.2rem; } + +/* Fix Radix ScrollArea internal table wrapper that expands to fit wide content. + This constrains the wrapper to the viewport width so overflow-x-auto on + individual comment wrappers actually works. */ +[data-radix-scroll-area-viewport][data-issue-activity-scroll] > div { + table-layout: fixed !important; + width: 100% !important; +} + +/* Ensure tables and code blocks scroll horizontally within their constrained container */ +.issue-md-preview .wmde-markdown table, +.issue-md-preview .wmde-markdown pre { + max-width: 100%; + overflow-x: auto; +} + +/* Break long unbreakable text (URLs, etc.) */ +.issue-md-preview .wmde-markdown { + word-break: break-word; + overflow-wrap: break-word; +}