Skip to main content

peisear_core/
lib.rs

1//! Pure domain types shared across the workspace.
2//!
3//! This crate intentionally depends only on `serde`, `chrono`, and
4//! `thiserror`; it has no knowledge of axum, sqlx, jsonwebtoken, or any
5//! other runtime concern. Consumers include:
6//!
7//! - `peisear-storage`: constructs values of these types from DB rows
8//! - `peisear-web`: receives them from storage and feeds them to
9//!   handlers / Leptos components
10//! - future `peisear-cli` or admin tools that will speak the same
11//!   domain vocabulary without pulling in the web stack
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use std::fmt;
16
17#[derive(Debug, Clone)]
18pub struct User {
19    pub id: String,
20    pub email: String,
21    pub password_hash: String,
22    pub display_name: String,
23    /// Optional global capacity in story points. `None` means "not
24    /// set, do not show a workload warning for this user" — opt-in
25    /// rather than enforced. Mirrors the same pattern as
26    /// [`Issue::effort`]: estimation is gradual.
27    ///
28    /// When period-scoped capacities land in a future release, this
29    /// field migrates to a `user_capacities` table; see migration
30    /// `0004_user_capacity.sql` for the planned shape.
31    pub capacity_points: Option<i64>,
32    /// Optional personal WIP limit (count of in-progress issues).
33    /// `None` means use the project-level default, or fall back to
34    /// [`personal_metrics::DEFAULT_WIP_LIMIT`]. Distinct from
35    /// `capacity_points` — see migration
36    /// `0005_personal_limits.sql` for the rationale.
37    pub wip_limit: Option<i64>,
38    pub created_at: DateTime<Utc>,
39}
40
41#[derive(Debug, Clone, Serialize)]
42pub struct Project {
43    pub id: String,
44    pub owner_id: String,
45    pub name: String,
46    pub description: String,
47    /// Optional project-level default WIP limit (count of
48    /// in-progress issues). Overrides
49    /// [`personal_metrics::DEFAULT_WIP_LIMIT`] for users who have
50    /// not set their own [`User::wip_limit`].
51    pub wip_limit_default: Option<i64>,
52    /// Optional team this project belongs to. `None` is a
53    /// personal project (the default; see migration 0011 for
54    /// the team feature). Members of the linked team get access
55    /// to the project's issues per their team role.
56    pub team_id: Option<String>,
57    pub created_at: DateTime<Utc>,
58    pub updated_at: DateTime<Utc>,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum IssueStatus {
64    Open,
65    InProgress,
66    Done,
67}
68
69impl IssueStatus {
70    pub fn as_str(&self) -> &'static str {
71        match self {
72            Self::Open => "open",
73            Self::InProgress => "in_progress",
74            Self::Done => "done",
75        }
76    }
77
78    pub fn label(&self) -> &'static str {
79        match self {
80            Self::Open => "Open",
81            Self::InProgress => "In Progress",
82            Self::Done => "Done",
83        }
84    }
85
86    pub fn parse(s: &str) -> Option<Self> {
87        match s {
88            "open" => Some(Self::Open),
89            "in_progress" => Some(Self::InProgress),
90            "done" => Some(Self::Done),
91            _ => None,
92        }
93    }
94
95    pub fn all() -> [IssueStatus; 3] {
96        [Self::Open, Self::InProgress, Self::Done]
97    }
98}
99
100impl fmt::Display for IssueStatus {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        f.write_str(self.as_str())
103    }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum Priority {
109    Low,
110    Medium,
111    High,
112    Urgent,
113}
114
115impl Priority {
116    pub fn as_str(&self) -> &'static str {
117        match self {
118            Self::Low => "low",
119            Self::Medium => "medium",
120            Self::High => "high",
121            Self::Urgent => "urgent",
122        }
123    }
124
125    pub fn label(&self) -> &'static str {
126        match self {
127            Self::Low => "Low",
128            Self::Medium => "Medium",
129            Self::High => "High",
130            Self::Urgent => "Urgent",
131        }
132    }
133
134    pub fn parse(s: &str) -> Option<Self> {
135        match s {
136            "low" => Some(Self::Low),
137            "medium" => Some(Self::Medium),
138            "high" => Some(Self::High),
139            "urgent" => Some(Self::Urgent),
140            _ => None,
141        }
142    }
143
144    pub fn all() -> [Priority; 4] {
145        [Self::Low, Self::Medium, Self::High, Self::Urgent]
146    }
147
148    /// daisyUI badge class mapping — kept in core so any future
149    /// read-only surface (email summary, future client, etc.) can
150    /// reuse the canonical severity palette.
151    pub fn badge_class(&self) -> &'static str {
152        match self {
153            Self::Low => "badge-ghost",
154            Self::Medium => "badge-info",
155            Self::High => "badge-warning",
156            Self::Urgent => "badge-error",
157        }
158    }
159}
160
161impl fmt::Display for Priority {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        f.write_str(self.as_str())
164    }
165}
166
167#[derive(Debug, Clone)]
168pub struct Issue {
169    pub id: String,
170    pub project_id: String,
171    pub author_id: String,
172    pub title: String,
173    pub description: String,
174    pub status: IssueStatus,
175    pub priority: Priority,
176    pub position: i64,
177    /// Effort estimate in story points. `None` means the issue has not
178    /// been estimated yet — this is the default for newly created
179    /// issues and for issues that existed before estimation was
180    /// introduced. The DB CHECK constraint guarantees `Some(n)` always
181    /// holds `n > 0`.
182    pub effort: Option<i64>,
183    /// User the issue is assigned to. `None` is "unassigned" — a
184    /// normal state for backlog items. The DB foreign key uses
185    /// `ON DELETE SET NULL` so removing a user does not cascade-delete
186    /// their issues; ownership simply returns to the pool.
187    pub assignee_id: Option<String>,
188    /// Parent issue id when this row is a sub-issue (Phase C
189    /// PR1, peisear-feature-spec-v2.1 §8.3). `None` means
190    /// "this is a top-level issue" — the common case.
191    ///
192    /// Constraints (enforced by triggers in migration 0015):
193    ///
194    /// - The parent must be top-level itself; sub-issues
195    ///   cannot have sub-issues (one level only).
196    /// - The parent must be in the same project.
197    /// - An issue cannot be its own parent, transitively or
198    ///   directly.
199    /// - An issue with existing children cannot be demoted
200    ///   into being a sub-issue itself (would create a 2-
201    ///   level chain).
202    ///
203    /// Sub-issues inherit nothing automatically: assignee,
204    /// status, priority, effort, and sprint membership are
205    /// all independently settable. The one effective tie is
206    /// "parent's sprint determines the sub-issue's sprint" —
207    /// a UI-level rule (sub-issues don't get their own sprint
208    /// row in the planning surface) rather than a schema
209    /// constraint.
210    pub parent_issue_id: Option<String>,
211    pub created_at: DateTime<Utc>,
212    pub updated_at: DateTime<Utc>,
213}
214
215impl Issue {
216    /// True if this issue is a sub-issue of another. Convenience
217    /// over checking the field directly so calling sites read
218    /// like prose ("if issue.is_sub_issue() { ... }").
219    pub fn is_sub_issue(&self) -> bool {
220        self.parent_issue_id.is_some()
221    }
222
223    /// True if this issue is at the top level of the
224    /// hierarchy. The complement of `is_sub_issue`. Top-level
225    /// issues are the only ones rendered on the kanban board,
226    /// the project list, and the sprint plan view; sub-issues
227    /// appear inline in their parent's detail page (§8.5).
228    pub fn is_top_level(&self) -> bool {
229        self.parent_issue_id.is_none()
230    }
231}
232
233/// Effort estimate presets shown in the UI.
234///
235/// The values follow the well-trodden Fibonacci-ish scale used in
236/// agile planning (1, 2, 3, 5, 8, 13). Any positive integer is valid
237/// in storage; these are just the suggested presets the form offers.
238pub const EFFORT_PRESETS: &[i64] = &[1, 2, 3, 5, 8, 13];
239
240/// One entry in the assignee `<select>` on the issue form.
241///
242/// Lives in `core` rather than `web` because the candidate set is a
243/// domain concept ("users who can be assigned to issues in this
244/// project"), not a presentation concept. When team / organisation
245/// support lands, the candidate set will broaden, but the shape of
246/// each option stays the same.
247#[derive(Debug, Clone)]
248pub struct AssigneeOption {
249    pub id: String,
250    pub display_name: String,
251}
252
253/// One row of the per-project workload report: how much in-flight
254/// effort a user is currently carrying versus their stated capacity.
255///
256/// "In-flight" today is the sum of `effort` over their assigned
257/// issues whose status is `open` or `in_progress`. When a future
258/// release introduces sprint / week / month periods, this struct's
259/// shape stays the same but the storage query that produces it will
260/// take an additional period filter — callers receive the same
261/// `UserLoad` either way.
262#[derive(Debug, Clone)]
263pub struct UserLoad {
264    pub user_id: String,
265    pub display_name: String,
266    /// Sum of effort over the user's assigned in-flight issues.
267    /// Issues with no effort estimate contribute 0.
268    pub in_flight_points: i64,
269    /// Stated capacity. `None` means the user has not set one yet —
270    /// in that case [`workload_state`] returns
271    /// [`WorkloadState::Unmonitored`] regardless of `in_flight_points`.
272    pub capacity_points: Option<i64>,
273    /// Number of in-flight issues this user is assigned to.
274    /// Useful when several issues lack effort estimates and the
275    /// points alone understate the load.
276    pub in_flight_issues: i64,
277}
278
279/// Coarse-grained workload classification, used for badge colouring
280/// and warning surfaces. Computed from a [`UserLoad`] via
281/// [`workload_state`].
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum WorkloadState {
284    /// User has no capacity set; do not warn or colour.
285    Unmonitored,
286    /// User is at most 80% of their capacity. Healthy.
287    Healthy,
288    /// User is between 80% and 100% of their capacity. Watch zone.
289    Strained,
290    /// User is over 100% of their capacity. Warn.
291    Overloaded,
292}
293
294impl WorkloadState {
295    /// daisyUI badge class for rendering. Kept in core so any future
296    /// read-only surface (email, CLI, alternate UI) can reuse the
297    /// canonical palette.
298    pub fn badge_class(&self) -> &'static str {
299        match self {
300            Self::Unmonitored => "badge-ghost",
301            Self::Healthy => "badge-success",
302            Self::Strained => "badge-warning",
303            Self::Overloaded => "badge-error",
304        }
305    }
306}
307
308/// Classify a workload snapshot. Pure function; no DB access.
309///
310/// Thresholds (80% / 100%) are deliberate and deliberately simple —
311/// they can become configurable in a future release without changing
312/// this function's signature.
313pub fn workload_state(load: &UserLoad) -> WorkloadState {
314    let Some(cap) = load.capacity_points else {
315        return WorkloadState::Unmonitored;
316    };
317    if cap == 0 {
318        return WorkloadState::Unmonitored;
319    }
320    if load.in_flight_points > cap {
321        WorkloadState::Overloaded
322    } else if load.in_flight_points * 5 >= cap * 4 {
323        // ratio >= 0.8 without floats
324        WorkloadState::Strained
325    } else {
326        WorkloadState::Healthy
327    }
328}
329
330/// Project the post-save workload for a "what if" preview on the
331/// issue form. Adds `delta` story points to the existing in-flight,
332/// then re-classifies.
333///
334/// Used for the "currently 8 pt → would become 11 pt" hint shown
335/// next to the assignee selector. `delta` is the new effort minus
336/// the old effort (or just the new effort when creating).
337pub fn projected_workload_state(load: &UserLoad, delta: i64) -> WorkloadState {
338    let projected = UserLoad {
339        in_flight_points: (load.in_flight_points + delta).max(0),
340        ..load.clone()
341    };
342    workload_state(&projected)
343}
344
345/// Compact view of the authenticated user attached to requests.
346#[derive(Debug, Clone)]
347pub struct CurrentUser {
348    pub id: String,
349    pub email: String,
350    pub display_name: String,
351}
352
353impl From<User> for CurrentUser {
354    fn from(u: User) -> Self {
355        Self {
356            id: u.id,
357            email: u.email,
358            display_name: u.display_name,
359        }
360    }
361}
362
363/// Coarse-grained classification used across the health / burnout
364/// indicator family. The same three-step palette applies whether the
365/// subject is a project, a user, or (in the future) a team —
366/// callers reuse this enum and its [`HealthIndicator::badge_class`]
367/// method instead of inventing parallel ones.
368///
369/// The semantics are deliberately fuzzy: "Watch" means "worth a
370/// glance, may or may not be a problem"; "Concern" means "human
371/// attention warranted". Concrete thresholds live with each specific
372/// indicator (e.g. [`project_health::classify_staleness`]).
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374pub enum HealthIndicator {
375    /// Not enough data to classify. Renders as a muted "—".
376    Insufficient,
377    /// Healthy / on-track.
378    Good,
379    /// Worth a glance. Borderline.
380    Watch,
381    /// Human attention warranted.
382    Concern,
383}
384
385impl HealthIndicator {
386    /// daisyUI badge class for rendering. The palette matches
387    /// [`WorkloadState::badge_class`] so the two indicator families
388    /// look visually coherent on the same page.
389    pub fn badge_class(&self) -> &'static str {
390        match self {
391            Self::Insufficient => "badge-ghost",
392            Self::Good => "badge-success",
393            Self::Watch => "badge-warning",
394            Self::Concern => "badge-error",
395        }
396    }
397}
398
399/// Project-level health indicators.
400///
401/// Lives in its own module to leave room for `user_burnout` (a
402/// planned future addition that will surface per-user fatigue
403/// signals such as sustained overload, stalled assigned work, or
404/// unbalanced load distribution). The two will share
405/// [`HealthIndicator`] for their classification output.
406pub mod project_health {
407    use super::HealthIndicator;
408
409    /// The window in days over which "recent activity" is counted.
410    /// Two weeks is long enough to cover sprint cadences and short
411    /// enough that genuine inactivity surfaces quickly.
412    ///
413    /// Configurability is a future refinement — for now this is a
414    /// project-wide constant. When user-level burnout indicators
415    /// land they will likely use a different window (a longer
416    /// rolling window for streak detection), at which point making
417    /// this configurable becomes useful.
418    pub const ACTIVITY_WINDOW_DAYS: i64 = 14;
419
420    /// Threshold (in days) for "long-stale" issues — in-flight
421    /// issues that have not seen movement in this many days. Reuses
422    /// the activity window for symmetry: an issue that nothing has
423    /// happened to during the project's activity window is the same
424    /// kind of "stuck" that the activity indicator measures the
425    /// inverse of.
426    pub const LONG_STALE_THRESHOLD_DAYS: i64 = ACTIVITY_WINDOW_DAYS;
427
428    /// Raw numeric inputs collected by storage, before classification
429    /// or scoring. Six fields, one per indicator; the first three
430    /// are the original 0.6.0 set, the last three are added in 0.7.0
431    /// to widen the health view.
432    ///
433    /// Kept as a struct of plain numbers so it stays cheap to store
434    /// in a future `metrics_snapshots` table for trend tracking
435    /// (Phase 2). Classification and scoring derive everything from
436    /// this struct via pure functions.
437    #[derive(Debug, Clone, Default)]
438    pub struct ProjectHealthRaw {
439        /// Total issues in the project (any status).
440        pub total_issues: i64,
441        /// Issues in `done`.
442        pub done_issues: i64,
443        /// Age in days of the oldest issue still in `open` or
444        /// `in_progress`. `None` means no in-flight issues exist.
445        pub oldest_in_flight_age_days: Option<i64>,
446        /// Number of issues created OR moved-to-done within the
447        /// activity window (see [`ACTIVITY_WINDOW_DAYS`]).
448        pub recent_activity_count: i64,
449        /// Issues currently in flight (open / in_progress).
450        pub in_flight_issues: i64,
451        /// Of those in-flight issues, how many are assigned to the
452        /// single most-loaded user. The Bus-Factor indicator
453        /// compares this against `in_flight_issues`.
454        pub top_assignee_in_flight_issues: i64,
455        /// Of in-flight issues, how many have not been touched in
456        /// at least [`LONG_STALE_THRESHOLD_DAYS`].
457        pub long_stale_in_flight_issues: i64,
458        /// Number of users whose current WIP exceeds their effective
459        /// WIP limit (project default or personal override).
460        pub wip_violators: i64,
461        /// Number of users with at least one in-flight issue
462        /// assigned. Denominator for the WIP-compliance ratio.
463        pub active_assignees: i64,
464    }
465
466    /// Backwards-compatible alias kept for the existing 0.6.0
467    /// surface. The 0.7.0 design promotes [`ProjectHealthRaw`] +
468    /// [`ProjectHealthReport`] as the main types, but
469    /// [`ProjectHealth`] still exists so the original three
470    /// classification functions keep working unchanged.
471    pub type ProjectHealth = ProjectHealthRaw;
472
473    // ------------------------------------------------------------
474    // Original three classifiers (0.6.0). Kept as-is so existing
475    // call sites continue to compile. The 0.7.0 path goes through
476    // [`compute_report`] below, which also calls these.
477    // ------------------------------------------------------------
478
479    /// Throughput classification: the share of issues that have
480    /// reached `done`.
481    pub fn classify_throughput(h: &ProjectHealthRaw) -> HealthIndicator {
482        if h.total_issues == 0 {
483            return HealthIndicator::Insufficient;
484        }
485        let pct = (h.done_issues * 100) / h.total_issues;
486        if pct >= 60 {
487            HealthIndicator::Good
488        } else if pct >= 30 {
489            HealthIndicator::Watch
490        } else {
491            HealthIndicator::Concern
492        }
493    }
494
495    /// Staleness classification: how old is the oldest in-flight
496    /// issue?
497    pub fn classify_staleness(h: &ProjectHealthRaw) -> HealthIndicator {
498        match h.oldest_in_flight_age_days {
499            None => HealthIndicator::Good,
500            Some(d) if d >= 28 => HealthIndicator::Concern,
501            Some(d) if d >= 14 => HealthIndicator::Watch,
502            Some(_) => HealthIndicator::Good,
503        }
504    }
505
506    /// Activity classification: did the project see work in the
507    /// activity window?
508    pub fn classify_activity(h: &ProjectHealthRaw) -> HealthIndicator {
509        if h.total_issues == 0 {
510            return HealthIndicator::Insufficient;
511        }
512        if h.recent_activity_count >= 5 {
513            HealthIndicator::Good
514        } else if h.recent_activity_count >= 1 {
515            HealthIndicator::Watch
516        } else {
517            HealthIndicator::Concern
518        }
519    }
520
521    // ------------------------------------------------------------
522    // Three new classifiers (0.7.0).
523    // ------------------------------------------------------------
524
525    /// Bus-factor classification: what share of in-flight work is
526    /// concentrated on the single most-loaded user? Concentration
527    /// is a single-point-of-failure risk.
528    ///
529    /// Sole-assignee projects (only one active assignee at all) are
530    /// trivially "100% concentrated" but classifying them as
531    /// `Concern` would just shout the obvious — solo work is
532    /// expected for solo projects. We classify these as `Watch`
533    /// instead so the chip says "yes, this is a one-person project,
534    /// add a teammate to reduce risk" without alarmism.
535    pub fn classify_bus_factor(h: &ProjectHealthRaw) -> HealthIndicator {
536        if h.in_flight_issues == 0 {
537            return HealthIndicator::Insufficient;
538        }
539        if h.active_assignees <= 1 {
540            return HealthIndicator::Watch;
541        }
542        let pct = (h.top_assignee_in_flight_issues * 100) / h.in_flight_issues;
543        if pct >= 80 {
544            HealthIndicator::Concern
545        } else if pct >= 60 {
546            HealthIndicator::Watch
547        } else {
548            HealthIndicator::Good
549        }
550    }
551
552    /// Long-stale classification: what share of in-flight issues
553    /// have not been touched in at least
554    /// [`LONG_STALE_THRESHOLD_DAYS`]?
555    pub fn classify_long_stale(h: &ProjectHealthRaw) -> HealthIndicator {
556        if h.in_flight_issues == 0 {
557            return HealthIndicator::Insufficient;
558        }
559        let pct = (h.long_stale_in_flight_issues * 100) / h.in_flight_issues;
560        if pct >= 40 {
561            HealthIndicator::Concern
562        } else if pct >= 20 {
563            HealthIndicator::Watch
564        } else {
565            HealthIndicator::Good
566        }
567    }
568
569    /// WIP-compliance classification: what share of active assignees
570    /// are over their WIP limit right now?
571    pub fn classify_wip_compliance(h: &ProjectHealthRaw) -> HealthIndicator {
572        if h.active_assignees == 0 {
573            return HealthIndicator::Insufficient;
574        }
575        let pct = (h.wip_violators * 100) / h.active_assignees;
576        if pct >= 50 {
577            HealthIndicator::Concern
578        } else if pct >= 1 {
579            HealthIndicator::Watch
580        } else {
581            HealthIndicator::Good
582        }
583    }
584
585    // ------------------------------------------------------------
586    // 0.7.0 scoring layer: normalisation, weighting, summary.
587    // ------------------------------------------------------------
588
589    /// Identifier for the indicator family, used for stable iteration
590    /// order and for matching specific indicators in tests / UI.
591    /// Adding a new variant is the canonical way to extend the
592    /// health view in future releases.
593    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
594    pub enum IndicatorKind {
595        Throughput,
596        Staleness,
597        Activity,
598        BusFactor,
599        LongStale,
600        WipCompliance,
601    }
602
603    impl IndicatorKind {
604        pub fn label(&self) -> &'static str {
605            match self {
606                Self::Throughput => "Throughput",
607                Self::Staleness => "Oldest in-flight",
608                Self::Activity => "Activity (14d)",
609                Self::BusFactor => "Bus factor",
610                Self::LongStale => "Long-stale",
611                Self::WipCompliance => "WIP compliance",
612            }
613        }
614
615        /// Short description shown as a tooltip / `<details>` body
616        /// to explain what the indicator measures.
617        pub fn description(&self) -> &'static str {
618            match self {
619                Self::Throughput => {
620                    "Share of issues that have reached Done."
621                }
622                Self::Staleness => {
623                    "Age of the oldest issue still Open or In Progress."
624                }
625                Self::Activity => {
626                    "Issues created or finished in the last 14 days."
627                }
628                Self::BusFactor => {
629                    "Concentration of in-flight work on a single user."
630                }
631                Self::LongStale => {
632                    "Share of in-flight issues untouched for over two weeks."
633                }
634                Self::WipCompliance => {
635                    "Share of active users currently over their WIP limit."
636                }
637            }
638        }
639    }
640
641    /// One slot in the health report. Storage produces a vector of
642    /// these via [`compute_report`]; UI iterates over them.
643    ///
644    /// The `Indicator` shape is deliberately uniform across every
645    /// kind. Adding a new metric in a future release means adding a
646    /// variant to [`IndicatorKind`], a normalisation case to
647    /// `compute_report`, and (optionally) an explanation arm in
648    /// [`Indicator::human_explanation`] — UI rendering itself is
649    /// shared.
650    #[derive(Debug, Clone)]
651    pub struct Indicator {
652        pub kind: IndicatorKind,
653        /// Display label, e.g. "Throughput".
654        pub label: &'static str,
655        /// Pre-formatted display value, e.g. "5 / 7 (71%)" or "8 d".
656        pub value_display: String,
657        /// Coarse-grained classification for badge colour /
658        /// accessibility text.
659        pub state: HealthIndicator,
660        /// Continuous quality on 0.0 (worst) – 1.0 (best). Score
661        /// computation uses this.
662        pub normalized: f64,
663        /// Weight contributed to the composite score.
664        pub weight: f64,
665    }
666
667    impl Indicator {
668        /// Plain-language explanation of the current state of this
669        /// indicator, suitable for direct display to a user.
670        ///
671        /// Phase B (peisear-feature-spec-v2.1 §5.2 / decision B-E5):
672        /// project-health explainability prefers human language
673        /// over numbers. The brief explicitly favours "what's
674        /// happening" over "score breakdown":
675        ///
676        /// > Don't show: 'long_stale_ratio = 0.30 (-15)'
677        /// > Show: '3 issues haven't moved in over two weeks'
678        ///
679        /// The text adapts to `state` so that:
680        ///
681        /// - `Good` returns `None` — no story to tell, the UI
682        ///   omits the row entirely. (Listing every Good
683        ///   indicator turns into self-congratulatory clutter.)
684        /// - `Watch` and `Concern` return a concrete sentence
685        ///   naming the underlying value (taken from
686        ///   `value_display`) and the indicator's domain, so
687        ///   the user can act on it.
688        /// - `Insufficient` returns `None` — the indicator
689        ///   doesn't have enough data to say anything about
690        ///   the project yet.
691        ///
692        /// The phrasing sticks to neutral, descriptive language.
693        /// We deliberately avoid evaluative words like "bad",
694        /// "concerning", or "you need to" — the user reads
695        /// these every day, and §0.2 wants the tool to be a
696        /// dashboard, not a coach.
697        pub fn human_explanation(&self) -> Option<String> {
698            // Good and Insufficient produce no row.
699            match self.state {
700                HealthIndicator::Good | HealthIndicator::Insufficient => return None,
701                HealthIndicator::Watch | HealthIndicator::Concern => {}
702            }
703
704            // The verbal frame ("currently ...") matches across
705            // indicators so the explanations read as a coherent
706            // list rather than a grab-bag of sentence shapes.
707            let value = &self.value_display;
708            Some(match self.kind {
709                IndicatorKind::Throughput => format!(
710                    "Throughput is {value} — fewer issues are reaching Done than the rest of the project's history."
711                ),
712                IndicatorKind::Staleness => format!(
713                    "The oldest in-flight issue has been open for {value}."
714                ),
715                IndicatorKind::Activity => format!(
716                    "Issue activity in the last two weeks is {value}."
717                ),
718                IndicatorKind::BusFactor => format!(
719                    "{value} of in-flight work is concentrated on one person."
720                ),
721                IndicatorKind::LongStale => format!(
722                    "{value} of in-flight issues haven't been touched in over two weeks."
723                ),
724                IndicatorKind::WipCompliance => format!(
725                    "{value} of active assignees are over their WIP limit."
726                ),
727            })
728        }
729    }
730    /// Per-indicator weights. Must sum (approximately) to 1.0.
731    ///
732    /// 0.7.0 ships a single fixed `DEFAULT` set. The roadmap notes
733    /// project-type-specific weights (new vs. maintenance, small vs.
734    /// large team) as a future refinement; that arrives as a
735    /// `weights_for(project_type)` lookup, not as a change to this
736    /// struct.
737    #[derive(Debug, Clone, Copy)]
738    pub struct HealthWeights {
739        pub throughput: f64,
740        pub staleness: f64,
741        pub activity: f64,
742        pub bus_factor: f64,
743        pub long_stale: f64,
744        pub wip_compliance: f64,
745    }
746
747    impl HealthWeights {
748        pub const DEFAULT: HealthWeights = HealthWeights {
749            throughput: 0.20,
750            staleness: 0.20,
751            activity: 0.15,
752            bus_factor: 0.15,
753            long_stale: 0.15,
754            wip_compliance: 0.15,
755        };
756
757        fn for_kind(&self, kind: IndicatorKind) -> f64 {
758            match kind {
759                IndicatorKind::Throughput => self.throughput,
760                IndicatorKind::Staleness => self.staleness,
761                IndicatorKind::Activity => self.activity,
762                IndicatorKind::BusFactor => self.bus_factor,
763                IndicatorKind::LongStale => self.long_stale,
764                IndicatorKind::WipCompliance => self.wip_compliance,
765            }
766        }
767    }
768
769    /// Trend direction for the composite score relative to the
770    /// previous snapshot. Phase 1 has no history — every report is
771    /// [`Trend::Unavailable`] until a `metrics_snapshots` table
772    /// arrives in Phase 2.
773    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
774    pub enum Trend {
775        Unavailable,
776        Up { delta: u8 },
777        Down { delta: u8 },
778        Flat,
779    }
780
781    /// Composite project-health score on 0–100, plus enough metadata
782    /// to render a single sentence summary above the indicator
783    /// breakdown.
784    #[derive(Debug, Clone)]
785    pub struct HealthScore {
786        pub value: u8,
787        pub state: HealthIndicator,
788        /// One- or two-sentence natural-language summary,
789        /// foregrounding the worst indicators. Produced by
790        /// [`summarize`].
791        pub summary: String,
792        pub trend: Trend,
793    }
794
795    /// Full report: composite score plus per-indicator breakdown.
796    /// This is what storage returns and the UI renders.
797    #[derive(Debug, Clone)]
798    pub struct ProjectHealthReport {
799        pub score: HealthScore,
800        pub indicators: Vec<Indicator>,
801        pub raw: ProjectHealthRaw,
802    }
803
804    /// Map a quality classification to a continuous 0–1 value used
805    /// for score weighting. The split-points reuse the
806    /// classifier thresholds where natural; intermediate values are
807    /// piecewise-linear interpolations so a project doesn't
808    /// rubber-band between scores when an issue ages by one day.
809    pub fn normalize(kind: IndicatorKind, raw: &ProjectHealthRaw) -> f64 {
810        match kind {
811            IndicatorKind::Throughput => normalize_throughput(raw),
812            IndicatorKind::Staleness => normalize_staleness(raw),
813            IndicatorKind::Activity => normalize_activity(raw),
814            IndicatorKind::BusFactor => normalize_bus_factor(raw),
815            IndicatorKind::LongStale => normalize_long_stale(raw),
816            IndicatorKind::WipCompliance => normalize_wip_compliance(raw),
817        }
818    }
819
820    fn normalize_throughput(h: &ProjectHealthRaw) -> f64 {
821        // Empty project → neutral 0.5, scoring shouldn't punish or
822        // reward a project that hasn't started. The classifier
823        // returns Insufficient and the UI hides such chips, but the
824        // score has to put *some* number in.
825        if h.total_issues == 0 {
826            return 0.5;
827        }
828        let pct = (h.done_issues * 100) / h.total_issues;
829        // 0% → 0.0, 30% → 0.5, 60% → 0.85, 100% → 1.0
830        let pct = pct as f64;
831        if pct >= 60.0 {
832            0.85 + (pct - 60.0) / 60.0 * 0.15
833        } else if pct >= 30.0 {
834            0.5 + (pct - 30.0) / 30.0 * 0.35
835        } else {
836            pct / 60.0
837        }
838        .clamp(0.0, 1.0)
839    }
840
841    fn normalize_staleness(h: &ProjectHealthRaw) -> f64 {
842        match h.oldest_in_flight_age_days {
843            None => 1.0,
844            Some(d) if d <= 7 => 1.0,
845            Some(d) if d < 14 => 0.85 - (d - 7) as f64 / 7.0 * 0.15,
846            Some(d) if d < 28 => 0.7 - (d - 14) as f64 / 14.0 * 0.5,
847            Some(_) => 0.0,
848        }
849        .clamp(0.0, 1.0)
850    }
851
852    fn normalize_activity(h: &ProjectHealthRaw) -> f64 {
853        if h.total_issues == 0 {
854            return 0.5;
855        }
856        match h.recent_activity_count {
857            n if n >= 5 => 1.0,
858            n if n >= 1 => 0.5 + (n - 1) as f64 / 4.0 * 0.4,
859            _ => 0.0,
860        }
861        .clamp(0.0, 1.0)
862    }
863
864    fn normalize_bus_factor(h: &ProjectHealthRaw) -> f64 {
865        if h.in_flight_issues == 0 {
866            return 0.5;
867        }
868        if h.active_assignees <= 1 {
869            // Solo work — neutral-low rather than zero. Solo
870            // projects shouldn't be punished out of all proportion;
871            // they just can't score well on this metric.
872            return 0.4;
873        }
874        let pct = (h.top_assignee_in_flight_issues * 100) / h.in_flight_issues;
875        let pct = pct as f64;
876        // 0–60% → great, 60–80% → linearly degrade, 80%+ → bad
877        if pct < 60.0 {
878            1.0
879        } else if pct < 80.0 {
880            1.0 - (pct - 60.0) / 20.0 * 0.7
881        } else {
882            (0.3 - (pct - 80.0) / 20.0 * 0.3).max(0.0)
883        }
884        .clamp(0.0, 1.0)
885    }
886
887    fn normalize_long_stale(h: &ProjectHealthRaw) -> f64 {
888        if h.in_flight_issues == 0 {
889            return 1.0;
890        }
891        let pct = (h.long_stale_in_flight_issues * 100) / h.in_flight_issues;
892        let pct = pct as f64;
893        if pct < 20.0 {
894            1.0 - pct / 20.0 * 0.15
895        } else if pct < 40.0 {
896            0.85 - (pct - 20.0) / 20.0 * 0.55
897        } else {
898            (0.3 - (pct - 40.0) / 60.0 * 0.3).max(0.0)
899        }
900        .clamp(0.0, 1.0)
901    }
902
903    fn normalize_wip_compliance(h: &ProjectHealthRaw) -> f64 {
904        if h.active_assignees == 0 {
905            return 1.0;
906        }
907        let pct = (h.wip_violators * 100) / h.active_assignees;
908        let pct = pct as f64;
909        if pct == 0.0 {
910            1.0
911        } else if pct < 50.0 {
912            0.85 - pct / 50.0 * 0.55
913        } else {
914            (0.3 - (pct - 50.0) / 50.0 * 0.3).max(0.0)
915        }
916        .clamp(0.0, 1.0)
917    }
918
919    /// Format the per-indicator value for display, e.g.
920    /// `"5 / 7 (71%)"`, `"8 d"`, `"85% on top assignee"`.
921    pub fn format_value(kind: IndicatorKind, raw: &ProjectHealthRaw) -> String {
922        match kind {
923            IndicatorKind::Throughput => {
924                if raw.total_issues == 0 {
925                    "—".to_string()
926                } else {
927                    let pct = (raw.done_issues * 100) / raw.total_issues;
928                    format!("{} / {} ({}%)", raw.done_issues, raw.total_issues, pct)
929                }
930            }
931            IndicatorKind::Staleness => match raw.oldest_in_flight_age_days {
932                None => "—".to_string(),
933                Some(d) => format!("{d} d"),
934            },
935            IndicatorKind::Activity => format!("{}", raw.recent_activity_count),
936            IndicatorKind::BusFactor => {
937                if raw.in_flight_issues == 0 {
938                    "—".to_string()
939                } else if raw.active_assignees <= 1 {
940                    "solo".to_string()
941                } else {
942                    let pct = (raw.top_assignee_in_flight_issues * 100) / raw.in_flight_issues;
943                    format!("{}% on top", pct)
944                }
945            }
946            IndicatorKind::LongStale => {
947                if raw.in_flight_issues == 0 {
948                    "—".to_string()
949                } else {
950                    format!(
951                        "{} / {}",
952                        raw.long_stale_in_flight_issues, raw.in_flight_issues
953                    )
954                }
955            }
956            IndicatorKind::WipCompliance => {
957                if raw.active_assignees == 0 {
958                    "—".to_string()
959                } else if raw.wip_violators == 0 {
960                    "all within".to_string()
961                } else {
962                    format!("{} over", raw.wip_violators)
963                }
964            }
965        }
966    }
967
968    /// Map a kind to its 0.6.0-style coarse classification. Kept as
969    /// a small dispatcher so all per-kind branching lives next to
970    /// the kind enum.
971    pub fn classify(kind: IndicatorKind, raw: &ProjectHealthRaw) -> HealthIndicator {
972        match kind {
973            IndicatorKind::Throughput => classify_throughput(raw),
974            IndicatorKind::Staleness => classify_staleness(raw),
975            IndicatorKind::Activity => classify_activity(raw),
976            IndicatorKind::BusFactor => classify_bus_factor(raw),
977            IndicatorKind::LongStale => classify_long_stale(raw),
978            IndicatorKind::WipCompliance => classify_wip_compliance(raw),
979        }
980    }
981
982    /// All indicator kinds in canonical order. The UI iterates this
983    /// list rather than hard-coding a sequence; new indicators
984    /// added here automatically appear in the report.
985    pub const ALL_INDICATORS: &[IndicatorKind] = &[
986        IndicatorKind::Throughput,
987        IndicatorKind::Staleness,
988        IndicatorKind::Activity,
989        IndicatorKind::BusFactor,
990        IndicatorKind::LongStale,
991        IndicatorKind::WipCompliance,
992    ];
993
994    /// Build the full health report from raw inputs.
995    pub fn compute_report(raw: ProjectHealthRaw) -> ProjectHealthReport {
996        compute_report_with_weights(raw, HealthWeights::DEFAULT)
997    }
998
999    /// Variant of [`compute_report`] that accepts custom weights —
1000    /// the entry point for the future per-project-type weight set.
1001    pub fn compute_report_with_weights(
1002        raw: ProjectHealthRaw,
1003        weights: HealthWeights,
1004    ) -> ProjectHealthReport {
1005        compute_report_full(raw, weights, &[])
1006    }
1007
1008    /// Variant of [`compute_report`] that classifies the trend
1009    /// against a slice of recent past `score_value`s. The trend is
1010    /// computed via [`classify_trend`]; an empty `past_scores`
1011    /// slice yields [`Trend::Unavailable`] so this function is
1012    /// safe to call on day one when no snapshots exist.
1013    pub fn compute_report_with_trend(
1014        raw: ProjectHealthRaw,
1015        past_scores: &[u8],
1016    ) -> ProjectHealthReport {
1017        compute_report_full(raw, HealthWeights::DEFAULT, past_scores)
1018    }
1019
1020    fn compute_report_full(
1021        raw: ProjectHealthRaw,
1022        weights: HealthWeights,
1023        past_scores: &[u8],
1024    ) -> ProjectHealthReport {
1025        let indicators: Vec<Indicator> = ALL_INDICATORS
1026            .iter()
1027            .map(|&kind| Indicator {
1028                kind,
1029                label: kind.label(),
1030                value_display: format_value(kind, &raw),
1031                state: classify(kind, &raw),
1032                normalized: normalize(kind, &raw),
1033                weight: weights.for_kind(kind),
1034            })
1035            .collect();
1036
1037        let score_value = composite_score(&indicators);
1038        let state = classify_score(score_value);
1039        let summary = summarize(&indicators);
1040        let trend = classify_trend(score_value, past_scores);
1041
1042        ProjectHealthReport {
1043            score: HealthScore {
1044                value: score_value,
1045                state,
1046                summary,
1047                trend,
1048            },
1049            indicators,
1050            raw,
1051        }
1052    }
1053
1054    /// Threshold (in points on the 0–100 score) below which a
1055    /// score change is reported as `Trend::Flat` rather than
1056    /// Up/Down. Five points is small enough not to swallow
1057    /// genuine improvement / decline signals, large enough that
1058    /// week-over-week noise from a single closed issue doesn't
1059    /// register as movement.
1060    pub const TREND_FLAT_THRESHOLD: u8 = 5;
1061
1062    /// Lower bound (days ago) of the trend's "past baseline"
1063    /// window. Snapshots more recent than this are excluded —
1064    /// week-over-week comparison would otherwise be polluted by
1065    /// the present; a Friday-vs-Thursday score drop is a
1066    /// weekend-pause artefact, not a real trend.
1067    pub const TREND_PAST_WINDOW_MIN_DAYS: i64 = 7;
1068
1069    /// Upper bound (days ago) of the trend's past baseline window.
1070    /// Snapshots older than this are excluded — they don't
1071    /// represent "how things are right now". Two weeks gives
1072    /// enough samples for a stable median while keeping the
1073    /// baseline timely.
1074    pub const TREND_PAST_WINDOW_MAX_DAYS: i64 = 14;
1075
1076    /// Classify a trend from the current score against a slice of
1077    /// past scores. The "past baseline" is the median of
1078    /// `past_scores`; comparing against the median rather than the
1079    /// mean keeps an outlier point from skewing the trend.
1080    ///
1081    /// Empty `past_scores` ⇒ `Trend::Unavailable`. A single past
1082    /// value works (it's its own median). Two values ⇒ average of
1083    /// the two (standard median behaviour).
1084    ///
1085    /// `delta` in `Up { delta }` / `Down { delta }` is `|current -
1086    /// median|` clamped to 0–100.
1087    pub fn classify_trend(current: u8, past_scores: &[u8]) -> Trend {
1088        if past_scores.is_empty() {
1089            return Trend::Unavailable;
1090        }
1091        let mut sorted: Vec<u8> = past_scores.to_vec();
1092        sorted.sort_unstable();
1093        let n = sorted.len();
1094        let median: u16 = if n % 2 == 1 {
1095            sorted[n / 2] as u16
1096        } else {
1097            // Average of the two middle elements; integer arithmetic
1098            // is fine since we floor to u8 anyway.
1099            (sorted[n / 2 - 1] as u16 + sorted[n / 2] as u16) / 2
1100        };
1101
1102        let diff = (current as i16) - (median as i16);
1103        if diff.unsigned_abs() < TREND_FLAT_THRESHOLD as u16 {
1104            Trend::Flat
1105        } else if diff > 0 {
1106            Trend::Up {
1107                delta: diff.min(100) as u8,
1108            }
1109        } else {
1110            Trend::Down {
1111                delta: (-diff).min(100) as u8,
1112            }
1113        }
1114    }
1115
1116    /// Weighted-sum 0–100 composite. Indicators with `Insufficient`
1117    /// state are excluded from both numerator and denominator so an
1118    /// empty project doesn't pull the score down with neutral 0.5
1119    /// fillers; the score reflects only the indicators that have
1120    /// real signal.
1121    fn composite_score(indicators: &[Indicator]) -> u8 {
1122        let mut weighted_sum = 0.0;
1123        let mut weight_total = 0.0;
1124        for ind in indicators {
1125            if matches!(ind.state, HealthIndicator::Insufficient) {
1126                continue;
1127            }
1128            weighted_sum += ind.normalized * ind.weight;
1129            weight_total += ind.weight;
1130        }
1131        if weight_total == 0.0 {
1132            return 50; // every indicator is Insufficient — neutral.
1133        }
1134        ((weighted_sum / weight_total) * 100.0).round().clamp(0.0, 100.0) as u8
1135    }
1136
1137    fn classify_score(value: u8) -> HealthIndicator {
1138        if value >= 75 {
1139            HealthIndicator::Good
1140        } else if value >= 50 {
1141            HealthIndicator::Watch
1142        } else {
1143            HealthIndicator::Concern
1144        }
1145    }
1146
1147    /// Build the natural-language summary for the score header.
1148    /// Foregrounds at most two indicators that are pulling the
1149    /// score down; ignores `Insufficient` ones.
1150    pub fn summarize(indicators: &[Indicator]) -> String {
1151        // Collect concerning + watch states, sorted by severity then
1152        // by weight (heavier indicators surface first).
1153        let mut bad: Vec<&Indicator> = indicators
1154            .iter()
1155            .filter(|i| {
1156                matches!(i.state, HealthIndicator::Concern | HealthIndicator::Watch)
1157            })
1158            .collect();
1159        bad.sort_by(|a, b| {
1160            // Concern outranks Watch, then heavier weight first.
1161            let order = |s| match s {
1162                HealthIndicator::Concern => 0,
1163                HealthIndicator::Watch => 1,
1164                _ => 2,
1165            };
1166            order(a.state)
1167                .cmp(&order(b.state))
1168                .then_with(|| {
1169                    b.weight
1170                        .partial_cmp(&a.weight)
1171                        .unwrap_or(std::cmp::Ordering::Equal)
1172                })
1173        });
1174
1175        if bad.is_empty() {
1176            return "Looking healthy.".to_string();
1177        }
1178
1179        let lead = bad[0];
1180        match (bad.len(), lead.state) {
1181            (1, HealthIndicator::Concern) => {
1182                format!("{} is a concern.", lead.label)
1183            }
1184            (1, _) => format!("{} is worth a glance.", lead.label),
1185            (_, HealthIndicator::Concern) => {
1186                let second = bad[1];
1187                format!(
1188                    "{} is a concern; {} also needs attention.",
1189                    lead.label, second.label
1190                )
1191            }
1192            _ => {
1193                let second = bad[1];
1194                format!("{} and {} are worth a glance.", lead.label, second.label)
1195            }
1196        }
1197    }
1198}
1199
1200// ============================================================
1201// Personal metrics (0.7.0).
1202// ============================================================
1203
1204/// Per-user metrics scoped to a single user, intended for that
1205/// user's personal dashboard. By design this is *not* exposed to
1206/// other users — see the brief in
1207/// `peisear-プロジェクトヘルス最適化とヒューマンバーンダウン防止機能向け拡張開発指示書.md`
1208/// (V2.1) §0.2 and §2.5: "個人活動の詳細は必要最小限だけ共有する".
1209///
1210/// In 0.7.0 every user sees only their own metrics; the planned
1211/// manager / neutral-third-party roles arrive with the Team feature.
1212pub mod personal_metrics {
1213    use super::HealthIndicator;
1214
1215    /// System-wide default WIP cap. Resolution order:
1216    /// 1. user.wip_limit if set, else
1217    /// 2. project.wip_limit_default if set, else
1218    /// 3. this constant.
1219    pub const DEFAULT_WIP_LIMIT: i64 = 3;
1220
1221    /// Window over which "I finished N issues" is counted on the
1222    /// personal throughput chip. Same window as
1223    /// [`super::project_health::ACTIVITY_WINDOW_DAYS`] for symmetry.
1224    pub const PERSONAL_ACTIVITY_WINDOW_DAYS: i64 =
1225        super::project_health::ACTIVITY_WINDOW_DAYS;
1226
1227    /// Snapshot of a single user's current load and recent rhythm,
1228    /// scoped to one project.
1229    ///
1230    /// "Scoped to one project" because today peisear has no
1231    /// cross-project notion of a user's total work — adding it is a
1232    /// future refinement that the V2.1 brief alludes to (§2.4
1233    /// "本人優先"). The struct shape stays the same when a global
1234    /// view arrives; the storage query becomes a
1235    /// `for_user_global(user_id)` sibling.
1236    #[derive(Debug, Clone)]
1237    pub struct PersonalMetrics {
1238        pub user_id: String,
1239        pub display_name: String,
1240        /// Resolved WIP limit for this user-in-this-project.
1241        pub effective_wip_limit: i64,
1242        /// Number of issues currently in `in_progress` assigned to
1243        /// this user. (Open issues that are not yet started don't
1244        /// count — the WIP framing is about *active* work.)
1245        pub current_wip: i64,
1246        /// Sum of effort over assigned in-flight issues
1247        /// (`open` + `in_progress`). Mirrors the load shown in
1248        /// [`super::UserLoad`] for parity with the project workload
1249        /// strip.
1250        pub in_flight_points: i64,
1251        /// User's optional capacity cap (story points). Mirrors
1252        /// [`super::UserLoad::capacity_points`].
1253        pub capacity_points: Option<i64>,
1254        /// Issues this user moved to `done` within
1255        /// [`PERSONAL_ACTIVITY_WINDOW_DAYS`].
1256        pub recent_done_count: i64,
1257        /// In-flight issues assigned to this user that have not
1258        /// been touched in at least [`PERSONAL_ACTIVITY_WINDOW_DAYS`].
1259        pub long_stale_count: i64,
1260        /// Crude estimation skew: average days-per-point spent on
1261        /// recently-completed estimated issues. `None` when no
1262        /// recent done-with-effort issues exist to fit a curve to.
1263        ///
1264        /// "Crude" because the source signal is
1265        /// `updated_at - created_at`, which conflates time-on-issue
1266        /// with calendar time during which the user wasn't even
1267        /// looking. Phase 2 (the planned `issue_events` table)
1268        /// replaces this with the actual `in_progress → done`
1269        /// elapsed time. The number is shown to the user as a soft
1270        /// reflection prompt, not as a performance indicator.
1271        pub estimation_skew_days_per_point: Option<f64>,
1272    }
1273
1274    /// Three-state classification of WIP usage. Mirrors the
1275    /// `HealthIndicator` palette used elsewhere on the page so the
1276    /// personal dashboard looks visually coherent with project
1277    /// health.
1278    pub fn classify_wip(m: &PersonalMetrics) -> HealthIndicator {
1279        if m.current_wip == 0 {
1280            return HealthIndicator::Good;
1281        }
1282        if m.current_wip > m.effective_wip_limit {
1283            HealthIndicator::Concern
1284        } else if m.current_wip == m.effective_wip_limit {
1285            HealthIndicator::Watch
1286        } else {
1287            HealthIndicator::Good
1288        }
1289    }
1290
1291    /// Classification of long-stale issues assigned to the user.
1292    /// "It's normal to have one stale issue you haven't picked
1293    /// up yet" → Watch above zero. Two or more is `Concern` because
1294    /// it usually means a backlog is forming, not just one stuck
1295    /// task.
1296    pub fn classify_long_stale(m: &PersonalMetrics) -> HealthIndicator {
1297        match m.long_stale_count {
1298            0 => HealthIndicator::Good,
1299            1 => HealthIndicator::Watch,
1300            _ => HealthIndicator::Concern,
1301        }
1302    }
1303}
1304
1305/// Per-user fatigue / burnout signals.
1306///
1307/// Sibling to [`personal_metrics`]. The two modules together back
1308/// the personal dashboard at `/me`: `personal_metrics` answers
1309/// "where are you right now?" while `user_burnout` answers "how
1310/// have you been recently, and is it sustainable?".
1311///
1312/// The framing is deliberate. V2.1 §0.2 forbids using these signals
1313/// for performance evaluation; §1.2 calls for self-reflection
1314/// support. Concretely this means:
1315///
1316/// - Indicators classify only as `Insufficient` / `Good` / `Watch`.
1317///   We never reach `Concern` here. A "concerning" framing in this
1318///   territory crosses into "you are burnt out" telling, which is a
1319///   call for the person to make about themselves, not for software
1320///   to assert.
1321/// - The summary text is a question or suggestion, not a diagnosis.
1322/// - Numbers come with units the user can sanity-check ("over
1323///   capacity for 4 of the last 4 snapshots").
1324///
1325/// Future indicators (estimation drift trend, cognitive switching)
1326/// will land additively as new fields on
1327/// [`UserBurnoutSignals`] and new chips in the dashboard. The
1328/// shape is uniform across all signals so adding one is a
1329/// localised change.
1330pub mod user_burnout {
1331    use super::HealthIndicator;
1332
1333    /// Window (in days) over which the estimation drift comparison
1334    /// is computed. Four weeks gives two two-week halves: "now"
1335    /// vs. "earlier", median over each half. Shorter and either
1336    /// half might be empty for a user who completes a few issues
1337    /// per fortnight; longer and "earlier" stops being earlier in
1338    /// any meaningful sense.
1339    pub const DRIFT_WINDOW_DAYS: i64 = 28;
1340
1341    /// Threshold (in ratio) below which the estimation drift is
1342    /// reported as `DriftDirection::Steady` rather than Up / Down.
1343    /// 25% movement is the "obviously different" line —  smaller
1344    /// is week-to-week noise from small samples. The number is
1345    /// dimensionless; computed as `(recent - older) / older`.
1346    pub const DRIFT_STEADY_THRESHOLD_RATIO: f64 = 0.25;
1347
1348    /// Window (in days) over which cognitive switching is
1349    /// observed. Two weeks captures the user's working pattern
1350    /// without mixing in stale rhythm; same window as other
1351    /// per-user reflective signals on the dashboard for symmetry.
1352    pub const SWITCHING_WINDOW_DAYS: i64 = 14;
1353
1354    /// Minimum number of `status_changed -> in_progress` events
1355    /// before the cognitive-switching pattern is meaningfully
1356    /// reportable. Two weeks of work with one or two transitions
1357    /// total is not enough data to characterise a "rhythm" — the
1358    /// number would just be noise. The UI hides the chip when
1359    /// `total_events_observed < SWITCHING_MIN_EVENTS`.
1360    pub const SWITCHING_MIN_EVENTS: i64 = 5;
1361
1362    /// Number of consecutive over-capacity snapshots before
1363    /// `overload_streak_days` graduates to `Watch`. At the
1364    /// default 6-hour snapshot interval, 8 ≈ 2 days. Exposed
1365    /// as a public constant so the notification edge-trigger
1366    /// logic (in `crate::notifications`) doesn't keep its own
1367    /// copy of the threshold.
1368    pub const OVERLOAD_STREAK_WATCH: i64 = 8;
1369
1370    /// Days a single in-flight assigned issue must be stalled
1371    /// before `stalled_assigned_max_days` reaches `Watch`.
1372    /// Same exposure rationale as `OVERLOAD_STREAK_WATCH`.
1373    pub const STALLED_WATCH_DAYS: i64 = 14;
1374
1375    /// Direction of an estimation drift. `Steady` is reported when
1376    /// the relative change is below `DRIFT_STEADY_THRESHOLD_RATIO`,
1377    /// or when there is insufficient data to compute one half.
1378    ///
1379    /// Note the deliberate absence of any "good" or "bad"
1380    /// connotation. Drift up means recent issues took longer per
1381    /// point than older ones — that *might* signal the user is
1382    /// hitting harder problems, taking on more thought-heavy work,
1383    /// or having a rough fortnight. None of these are necessarily
1384    /// bad and none of them are addressable by software. The
1385    /// dashboard surfaces the fact and trusts the user to
1386    /// contextualise it.
1387    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1388    pub enum DriftDirection {
1389        Up,
1390        Down,
1391        Steady,
1392    }
1393
1394    /// Computed estimation drift over the configured window. Built
1395    /// by `peisear-storage::user_burnout::estimation_drift_for_user`.
1396    ///
1397    /// Returns `None` from that function when there isn't enough
1398    /// completed work in either half of the window to compare; the
1399    /// UI hides the chip in that case rather than showing
1400    /// "insufficient data" prominently. We respect the user's
1401    /// time and don't surface non-information.
1402    #[derive(Debug, Clone)]
1403    pub struct EstimationDriftTrend {
1404        /// Median days-per-point across the recent half of the
1405        /// window. None of the entries inside that half are
1406        /// individually surfaced; the median is the only number
1407        /// that matters at this layer.
1408        pub recent_median_days_per_point: f64,
1409        /// Same, for the older half of the window.
1410        pub older_median_days_per_point: f64,
1411        /// Direction classification. See [`DriftDirection`].
1412        pub direction: DriftDirection,
1413        /// The window total — surfaced so the UI can phrase
1414        /// the message correctly ("over the last X days").
1415        pub window_days: i64,
1416    }
1417
1418    /// Cognitive-switching pattern over the configured window.
1419    /// Built by `peisear-storage::user_burnout::cognitive_switching_for_user`.
1420    ///
1421    /// "Switching" here is operationalised as the count of
1422    /// `status_changed -> in_progress` events for issues assigned
1423    /// to the user. Each such event is a "I'm picking this up
1424    /// now" moment; multiple per day is one signature of context
1425    /// switching. We don't try to detect *good* vs. *bad*
1426    /// switching — debugging sessions involve picking things up
1427    /// and putting them down too. The chip surfaces the rhythm
1428    /// without judgement.
1429    #[derive(Debug, Clone)]
1430    pub struct CognitiveSwitchingPattern {
1431        /// Median `-> in_progress` events per active day. "Active
1432        /// day" means a day on which any switching happened;
1433        /// quiet days don't count toward the median, which would
1434        /// otherwise dilute toward zero.
1435        pub switches_per_day_median: f64,
1436        /// Total events observed in the window. Surfaced so the
1437        /// UI can decline to render the chip if the data is too
1438        /// thin to characterise a rhythm.
1439        pub total_events_observed: i64,
1440        /// The window total — surfaced so the UI can phrase
1441        /// the message ("over the last X days").
1442        pub window_days: i64,
1443    }
1444
1445    /// Snapshot of one user's burnout signals at request time.
1446    ///
1447    /// Only used as a return value from
1448    /// `peisear-storage::user_burnout::for_user`; the storage layer
1449    /// fills it in from the snapshot and event tables.
1450    #[derive(Debug, Clone)]
1451    pub struct UserBurnoutSignals {
1452        /// Number of consecutive most-recent snapshots where the
1453        /// user was over their capacity. `0` means either no
1454        /// snapshots, or the most recent one was within capacity.
1455        ///
1456        /// "Snapshots" not "days" because the snapshot interval is
1457        /// finer than daily (every 6 hours by default). The UI
1458        /// labels this as "snapshots" or "ticks" rather than
1459        /// inflating it to a days estimate.
1460        pub overload_streak_days: i64,
1461
1462        /// For the user's oldest in-flight assigned issue, the
1463        /// days since its last status_changed event (or
1464        /// `updated_at` for legacy data). `0` if the user has no
1465        /// in-flight assigned work.
1466        pub stalled_assigned_max_days: i64,
1467
1468        /// The window over which streaks are measured, surfaced
1469        /// here so the UI can phrase the message correctly
1470        /// ("over capacity for X of the last Y").
1471        pub window_days: i64,
1472
1473        /// Estimation drift trend. `None` when the user does not
1474        /// have enough completed work in both halves of the
1475        /// drift window to compute a comparison. See
1476        /// [`EstimationDriftTrend`].
1477        ///
1478        /// New in 0.11.0.
1479        pub estimation_drift: Option<EstimationDriftTrend>,
1480
1481        /// Cognitive-switching pattern. `None` when the user's
1482        /// total switch events in the window are below
1483        /// `SWITCHING_MIN_EVENTS`. See [`CognitiveSwitchingPattern`].
1484        ///
1485        /// New in 0.11.0.
1486        pub cognitive_switching: Option<CognitiveSwitchingPattern>,
1487    }
1488
1489    /// Classify the overload-streak signal. Note the deliberate
1490    /// ceiling at `Watch` — see the module docstring.
1491    pub fn classify_overload_streak(s: &UserBurnoutSignals) -> HealthIndicator {
1492        // Consecutive snapshots ≥ OVERLOAD_STREAK_WATCH (≈ 2 days
1493        // at 6h interval) graduates from "busy week" to "worth
1494        // a glance". Below that, no signal.
1495        match s.overload_streak_days {
1496            n if n >= OVERLOAD_STREAK_WATCH => HealthIndicator::Watch,
1497            _ => HealthIndicator::Good,
1498        }
1499    }
1500
1501    /// Classify the stalled-assigned signal. Same `Watch` ceiling.
1502    pub fn classify_stalled(s: &UserBurnoutSignals) -> HealthIndicator {
1503        match s.stalled_assigned_max_days {
1504            d if d >= STALLED_WATCH_DAYS => HealthIndicator::Watch,
1505            _ => HealthIndicator::Good,
1506        }
1507    }
1508
1509    /// Classify a drift trend. Returns `Some(direction)` when the
1510    /// signal exists, `None` when there's no drift trend at all
1511    /// (insufficient data). Note this returns the direction, not
1512    /// a `HealthIndicator` — the panel renders drift as a neutral
1513    /// directional fact, not as a "good / watch" chip. There is
1514    /// no version of "drift up" that gets a warning palette.
1515    pub fn classify_drift(drift: &EstimationDriftTrend) -> DriftDirection {
1516        // Already pre-classified at storage time; this function
1517        // exists so future logic (e.g., considering both halves'
1518        // sample sizes) can centralise here.
1519        drift.direction
1520    }
1521
1522    /// Build the natural-language self-reflection prompt for the
1523    /// user's dashboard. Foregrounds whichever signals are at
1524    /// `Watch`; if all are `Good`, returns a brief encouraging
1525    /// note rather than alarmism by default.
1526    ///
1527    /// The text is pure data — no behaviour change in the app
1528    /// flows from it. The whole point of the API is that this
1529    /// function is a pure function over the signals.
1530    ///
1531    /// 0.11.0: drift and switching are deliberately *not* added
1532    /// to this summary. They are not warnings; they are facts.
1533    /// The summary line is for warnings ("here's what to glance
1534    /// at"); the pattern facts get their own distinct chips so
1535    /// they're visually separated from "this is something to
1536    /// notice".
1537    pub fn summarize(signals: &UserBurnoutSignals) -> String {
1538        let overload = classify_overload_streak(signals);
1539        let stalled = classify_stalled(signals);
1540        let any_watch = matches!(overload, HealthIndicator::Watch)
1541            || matches!(stalled, HealthIndicator::Watch);
1542
1543        if !any_watch {
1544            return "Steady so far.".to_string();
1545        }
1546
1547        let mut parts: Vec<String> = Vec::new();
1548        if matches!(overload, HealthIndicator::Watch) {
1549            parts.push(format!(
1550                "you've been over capacity for {} recent snapshots — \
1551                 consider whether some work can wait or move",
1552                signals.overload_streak_days
1553            ));
1554        }
1555        if matches!(stalled, HealthIndicator::Watch) {
1556            parts.push(format!(
1557                "an assigned issue has been stuck for {} days — \
1558                 worth a quick check whether it's blocked",
1559                signals.stalled_assigned_max_days
1560            ));
1561        }
1562        parts.join("; ")
1563    }
1564}
1565
1566/// Notification subsystem types.
1567///
1568/// Two concerns sit here:
1569///
1570/// 1. **Vocabulary** — the strings that identify notification
1571///    kinds, channels, and severity. Kept as a small set of
1572///    constants so all code references the same spellings and
1573///    typos surface as compile errors.
1574///
1575/// 2. **Domain types** — `Notification`, `Preference`,
1576///    `Severity`, `Channel`. The storage layer maps rows into
1577///    these; the dispatch layer consumes them; the web layer
1578///    renders them.
1579///
1580/// ## Design posture
1581///
1582/// V2.1 §1.4 says warnings should reach the user. The
1583/// notifications subsystem realises that by piping
1584/// `user_burnout` and `project_health` state transitions through
1585/// a structured pipeline. The posture inherits the rest of the
1586/// project: signals are *informational*, not evaluative; user
1587/// has full control over delivery; "all silent" is a respected
1588/// preference, not a sign of evasion.
1589///
1590/// ## Edge-triggered + cooldown
1591///
1592/// Notifications are produced when a tracked signal **transitions**
1593/// (e.g. burnout overload streak crosses from below the watch
1594/// threshold to at-or-above). The same transition does not
1595/// re-fire while it persists. A 24-hour cooldown on
1596/// `(user_id, kind)` further suppresses noisy ping-ponging
1597/// (state flapping around the threshold during a single day).
1598/// The cooldown is implemented at dispatch time via a query
1599/// against the `notifications` audit table; see
1600/// `peisear-web::notifications::dispatch`.
1601pub mod notifications {
1602    use serde::{Deserialize, Serialize};
1603
1604    /// Severity drives UI palette and the per-kind `min_severity`
1605    /// preference filter. The set is intentionally small —
1606    /// "info" and "watch" — because more granularity invites
1607    /// gaming-the-classifier behaviour we'd rather avoid.
1608    /// The same `HealthIndicator::Watch` ceiling that other
1609    /// surfaces honour applies here: `Concern` does not exist.
1610    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1611    #[serde(rename_all = "lowercase")]
1612    pub enum Severity {
1613        Info,
1614        Watch,
1615    }
1616
1617    impl Severity {
1618        pub fn as_str(self) -> &'static str {
1619            match self {
1620                Self::Info => "info",
1621                Self::Watch => "watch",
1622            }
1623        }
1624
1625        /// Parse from the storage-layer string. Unknown values
1626        /// default to `Info` rather than failing — a future
1627        /// downgrade that doesn't recognise a higher severity
1628        /// renders it as informational, which is safer than
1629        /// silently dropping the row.
1630        pub fn from_storage_str(s: &str) -> Self {
1631            match s {
1632                "watch" => Self::Watch,
1633                _ => Self::Info,
1634            }
1635        }
1636
1637        /// Used in the preference filter: `min_severity = watch`
1638        /// suppresses `info` notifications.
1639        pub fn meets_minimum(self, minimum: Severity) -> bool {
1640            match (self, minimum) {
1641                (Self::Watch, _) => true,
1642                (Self::Info, Self::Info) => true,
1643                (Self::Info, Self::Watch) => false,
1644            }
1645        }
1646    }
1647
1648    /// Channel identifiers. String-typed so the storage layer
1649    /// can store a comma-separated list without an enum<->string
1650    /// dance, but a small const set so callers can reference
1651    /// known values.
1652    pub mod channel {
1653        pub const IN_APP: &str = "in_app";
1654        pub const EMAIL: &str = "email";
1655        pub const WEBHOOK: &str = "webhook";
1656
1657        /// All channels known to this build, as a `&[&str]`
1658        /// slice for iteration in handlers / forms. The order
1659        /// determines how channels are rendered in the
1660        /// preferences UI (left to right: most recommended
1661        /// first).
1662        pub const ALL_CHANNELS: &[&str] = &[IN_APP, EMAIL, WEBHOOK];
1663
1664        /// Pretty name for UI labels.
1665        pub fn human_name(id: &str) -> &str {
1666            match id {
1667                IN_APP => "In-app",
1668                EMAIL => "Email",
1669                WEBHOOK => "Webhook",
1670                _ => id,
1671            }
1672        }
1673
1674        /// All channels known to this build. Used by the
1675        /// preferences page to render the full toggle list.
1676        pub fn all() -> &'static [&'static str] {
1677            ALL_CHANNELS
1678        }
1679    }
1680
1681    /// Notification kinds. Free-form strings in the database
1682    /// (so new kinds don't require a migration) but the constants
1683    /// here are the canonical spellings code uses.
1684    pub mod kind {
1685        /// Sentinel row in `notification_preferences` that
1686        /// records whether the user has been prompted for the
1687        /// first-login email opt-in. Not a real notification
1688        /// kind — never appears in the `notifications` table.
1689        pub const GLOBAL: &str = "_global";
1690
1691        pub const BURNOUT_OVERLOAD: &str = "burnout_overload";
1692        pub const BURNOUT_STALLED: &str = "burnout_stalled";
1693        pub const PROJECT_TREND_DECLINE: &str = "project_trend_decline";
1694
1695        /// Pretty label for the preferences UI. Unknown kinds
1696        /// render with their raw id.
1697        pub fn human_name(k: &str) -> &str {
1698            match k {
1699                BURNOUT_OVERLOAD => "Sustained over-capacity streak",
1700                BURNOUT_STALLED => "Long-stalled assigned work",
1701                PROJECT_TREND_DECLINE => "Project health decline",
1702                _ => k,
1703            }
1704        }
1705
1706        /// Canonical kinds shown on the preferences page (in
1707        /// this order). `GLOBAL` is intentionally absent.
1708        pub fn all_user_facing() -> &'static [&'static str] {
1709            &[BURNOUT_OVERLOAD, BURNOUT_STALLED, PROJECT_TREND_DECLINE]
1710        }
1711    }
1712
1713    /// One persisted notification, hydrated from storage.
1714    /// Visible to the user via the in-app inbox at
1715    /// `/notifications`; also serves as the audit log for what
1716    /// was dispatched (rows always exist; the
1717    /// `dispatched_via` field records which channels actually
1718    /// delivered).
1719    #[derive(Debug, Clone)]
1720    pub struct Notification {
1721        pub id: String,
1722        pub user_id: String,
1723        pub kind: String,
1724        pub severity: Severity,
1725        pub title: String,
1726        pub body: String,
1727        /// Free-form JSON payload. Application decodes per-kind.
1728        pub payload_json: Option<String>,
1729        pub created_at: chrono::DateTime<chrono::Utc>,
1730        pub read_at: Option<chrono::DateTime<chrono::Utc>>,
1731        /// Channel ids that successfully delivered.
1732        pub dispatched_via: Vec<String>,
1733    }
1734
1735    /// One per-user, per-kind preference row. Absent rows fall
1736    /// back to [`DEFAULT_PREFERENCES`].
1737    #[derive(Debug, Clone)]
1738    pub struct Preference {
1739        pub user_id: String,
1740        pub kind: String,
1741        /// Channel ids the user wants. Empty = silent.
1742        pub channels: Vec<String>,
1743        pub min_severity: Severity,
1744    }
1745
1746    /// Effective preferences (storage row OR fallback default).
1747    /// What dispatch consults to decide what to do with a fresh
1748    /// notification.
1749    pub struct EffectivePreference<'a> {
1750        pub channels: &'a [&'a str],
1751        pub min_severity: Severity,
1752    }
1753
1754    /// System default: in-app delivery for all kinds, all
1755    /// severities. Smart-defaults posture from design
1756    /// discussion (Q3=A): the first-time user has working
1757    /// notifications without configuring anything. They opt in
1758    /// to email/webhook explicitly.
1759    pub const DEFAULT_CHANNELS: &[&str] = &[channel::IN_APP];
1760    pub const DEFAULT_MIN_SEVERITY: Severity = Severity::Info;
1761
1762    /// Edge-triggered: a notification fires only when the
1763    /// underlying signal transitions across the threshold.
1764    /// Concrete transitions today:
1765    ///
1766    /// - `burnout_overload`: overload_streak_days transitions
1767    ///   from < OVERLOAD_STREAK_WATCH to ≥ OVERLOAD_STREAK_WATCH
1768    /// - `burnout_stalled`: stalled_assigned_max_days
1769    ///   transitions from < STALLED_WATCH_DAYS to
1770    ///   ≥ STALLED_WATCH_DAYS
1771    /// - `project_trend_decline`: composite_score median over
1772    ///   the past 7 days drops by ≥ 5 points compared to the
1773    ///   prior 7 days
1774    ///
1775    /// Returns true if the transition warrants a notification.
1776    /// Callers compare prior and current state through this.
1777    pub fn is_edge_into_watch_burnout_overload(
1778        prior_streak_days: i64,
1779        current_streak_days: i64,
1780    ) -> bool {
1781        let threshold = crate::user_burnout::OVERLOAD_STREAK_WATCH;
1782        prior_streak_days < threshold && current_streak_days >= threshold
1783    }
1784
1785    pub fn is_edge_into_watch_burnout_stalled(
1786        prior_max_days: i64,
1787        current_max_days: i64,
1788    ) -> bool {
1789        let threshold = crate::user_burnout::STALLED_WATCH_DAYS;
1790        prior_max_days < threshold && current_max_days >= threshold
1791    }
1792
1793    /// Cooldown window: the same `(user_id, kind)` will not
1794    /// fire twice within this many hours, regardless of
1795    /// edge-triggering. A safety net against threshold
1796    /// flapping; the bulk of suppression is done by the
1797    /// edge-trigger logic above.
1798    pub const COOLDOWN_HOURS: i64 = 24;
1799}
1800
1801/// Team / membership / role types (0.14.0).
1802///
1803/// Phase 1 ships flat teams; sub-teams (parent_team_id) are
1804/// reserved for Phase 2. See ROADMAP "Privacy & access control
1805/// evolution" for the privacy decisions deliberately left for
1806/// later.
1807///
1808/// ## Role semantics
1809///
1810/// - `Admin`: political role — manage members, edit team
1811///   settings, move projects in/out of the team.
1812/// - `Member`: full project participation in team-owned projects
1813///   (create / edit issues, be assigned).
1814/// - `Viewer`: read-only on team projects.
1815///
1816/// Per V2.1 §2.5, none of these roles read other users'
1817/// individual signals (burnout panel, personal dashboard).
1818/// Admin is a managerial role, not a surveilling one.
1819pub mod teams {
1820    use serde::{Deserialize, Serialize};
1821
1822    /// Per-team role. String-typed in storage; this enum is the
1823    /// canonical Rust representation.
1824    ///
1825    /// Future fixed roles (e.g. `Billing`, `SecurityManager`)
1826    /// can be added to this enum without breaking storage —
1827    /// the underlying TEXT column accepts any value the CHECK
1828    /// constraint allows. Custom (per-team named) roles would
1829    /// require a `team_roles` table; deferred to Phase 2.
1830    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1831    #[serde(rename_all = "lowercase")]
1832    pub enum TeamRole {
1833        Admin,
1834        Member,
1835        Viewer,
1836    }
1837
1838    impl TeamRole {
1839        pub fn as_str(self) -> &'static str {
1840            match self {
1841                Self::Admin => "admin",
1842                Self::Member => "member",
1843                Self::Viewer => "viewer",
1844            }
1845        }
1846
1847        pub fn human_name(self) -> &'static str {
1848            match self {
1849                Self::Admin => "Admin",
1850                Self::Member => "Member",
1851                Self::Viewer => "Viewer",
1852            }
1853        }
1854
1855        /// Parse from storage. Returns `None` for unknown
1856        /// values — the migration's CHECK should prevent these,
1857        /// but a future role addition might leave older code
1858        /// reading new values, and we want a clear "unknown"
1859        /// path rather than panicking.
1860        pub fn from_storage_str(s: &str) -> Option<Self> {
1861            match s {
1862                "admin" => Some(Self::Admin),
1863                "member" => Some(Self::Member),
1864                "viewer" => Some(Self::Viewer),
1865                _ => None,
1866            }
1867        }
1868
1869        /// Whether this role can write (create / edit issues,
1870        /// be assigned). Viewer is read-only; member and admin
1871        /// can write.
1872        pub fn can_write(self) -> bool {
1873            matches!(self, Self::Admin | Self::Member)
1874        }
1875
1876        /// Whether this role can manage the team itself
1877        /// (membership, settings). Admin only.
1878        pub fn can_manage_team(self) -> bool {
1879            matches!(self, Self::Admin)
1880        }
1881    }
1882
1883    /// One team. Renamable; the `slug` (URL identifier) is
1884    /// fixed at create time.
1885    #[derive(Debug, Clone)]
1886    pub struct Team {
1887        pub id: String,
1888        pub name: String,
1889        pub slug: String,
1890        pub description: Option<String>,
1891        pub created_at: chrono::DateTime<chrono::Utc>,
1892        /// Last mutation timestamp. Populated by the
1893        /// `teams_updated_at` trigger from migration 0014. Used
1894        /// by the optimistic-lock contract
1895        /// (peisear-feature-spec-v2.1 §21.4): handlers re-read
1896        /// this and compare against the form's
1897        /// `client_updated_at` before applying a write.
1898        pub updated_at: chrono::DateTime<chrono::Utc>,
1899    }
1900
1901    /// One row of `team_memberships`. Joined-on-demand with
1902    /// `users` for member listings.
1903    #[derive(Debug, Clone)]
1904    pub struct TeamMembership {
1905        pub team_id: String,
1906        pub user_id: String,
1907        pub role: TeamRole,
1908        pub joined_at: chrono::DateTime<chrono::Utc>,
1909        /// Last mutation timestamp (from migration 0014's
1910        /// trigger). The membership row mutates on role
1911        /// changes; this is the lock value for those.
1912        pub updated_at: chrono::DateTime<chrono::Utc>,
1913    }
1914
1915    /// Maximum slug length, enforced by the migration's CHECK.
1916    /// Exposed here so handler-level validation can refuse a
1917    /// too-long slug before hitting the database.
1918    pub const SLUG_MAX_LEN: usize = 64;
1919
1920    /// Generate a URL slug from a free-form display name.
1921    /// Lowercases, replaces non-alphanumeric runs with single
1922    /// hyphens, trims leading/trailing hyphens, and truncates
1923    /// to `SLUG_MAX_LEN`.
1924    ///
1925    /// Returns the empty string if the name has no
1926    /// alphanumeric characters at all (caller should
1927    /// reject — slugs cannot be empty per the schema CHECK).
1928    pub fn slugify(name: &str) -> String {
1929        let mut out = String::with_capacity(name.len());
1930        let mut last_was_hyphen = true; // suppress leading hyphens
1931        for ch in name.chars().flat_map(|c| c.to_lowercase()) {
1932            if ch.is_ascii_alphanumeric() {
1933                out.push(ch);
1934                last_was_hyphen = false;
1935            } else if !last_was_hyphen {
1936                out.push('-');
1937                last_was_hyphen = true;
1938            }
1939        }
1940        // Trim trailing hyphen.
1941        while out.ends_with('-') {
1942            out.pop();
1943        }
1944        if out.len() > SLUG_MAX_LEN {
1945            out.truncate(SLUG_MAX_LEN);
1946            // Avoid trailing hyphen after truncation.
1947            while out.ends_with('-') {
1948                out.pop();
1949            }
1950        }
1951        out
1952    }
1953}
1954
1955/// Sprint: a team-scoped, time-boxed unit of planning (0.15.0).
1956///
1957/// ## Posture
1958///
1959/// peisear's sprint model is informational, not evaluative.
1960/// V2.1 §0.2 (the non-evaluative stance) shapes the surface:
1961///
1962/// - We compute "completed work this period" but don't call it
1963///   `velocity`. The Jira-popularised term carries
1964///   "performance" connotations we don't want.
1965/// - We display burndown as a 2-line chart (cumulative
1966///   completed vs cumulative committed) without an "ideal"
1967///   line, without a predicted-finish line, and without a
1968///   completion-percentage readout. The chart is descriptive,
1969///   not prescriptive.
1970/// - "Carried over" issues (in flight at sprint end) are
1971///   reported as a fact, not a failure. They're shown next to
1972///   the completed bar so the user sees the full picture.
1973///
1974/// ## Lifecycle
1975///
1976/// `planned` → `active` (admin starts) → `completed` (admin
1977/// marks done). All transitions are explicit admin actions —
1978/// no time-based auto-promotion. The "click to start the
1979/// sprint" event is the signal we want a person to make
1980/// deliberately, not the calendar.
1981pub mod sprints {
1982    use serde::{Deserialize, Serialize};
1983
1984    /// Sprint lifecycle. Transitions are admin actions; see
1985    /// the storage layer for guards (e.g. you can only start a
1986    /// `planned` sprint, only complete an `active` sprint).
1987    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1988    #[serde(rename_all = "lowercase")]
1989    pub enum SprintStatus {
1990        Planned,
1991        Active,
1992        Completed,
1993    }
1994
1995    impl SprintStatus {
1996        pub fn as_str(self) -> &'static str {
1997            match self {
1998                Self::Planned => "planned",
1999                Self::Active => "active",
2000                Self::Completed => "completed",
2001            }
2002        }
2003
2004        pub fn human_name(self) -> &'static str {
2005            match self {
2006                Self::Planned => "Planned",
2007                Self::Active => "Active",
2008                Self::Completed => "Completed",
2009            }
2010        }
2011
2012        /// Parse from storage. Unknown values default to
2013        /// `Planned` rather than failing — defensive against
2014        /// future status additions read by older code.
2015        pub fn from_storage_str(s: &str) -> Self {
2016            match s {
2017                "active" => Self::Active,
2018                "completed" => Self::Completed,
2019                _ => Self::Planned,
2020            }
2021        }
2022    }
2023
2024    /// One sprint row.
2025    #[derive(Debug, Clone)]
2026    pub struct Sprint {
2027        pub id: String,
2028        pub team_id: String,
2029        pub name: String,
2030        pub goal: Option<String>,
2031        pub starts_on: chrono::NaiveDate,
2032        pub ends_on: chrono::NaiveDate,
2033        pub status: SprintStatus,
2034        pub started_at: Option<chrono::DateTime<chrono::Utc>>,
2035        pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
2036        pub created_at: chrono::DateTime<chrono::Utc>,
2037        /// Last mutation timestamp (from migration 0014's
2038        /// trigger). Used by the optimistic-lock contract
2039        /// (peisear-feature-spec-v2.1 §21.4) for sprint edit /
2040        /// start / complete / delete handlers.
2041        pub updated_at: chrono::DateTime<chrono::Utc>,
2042    }
2043
2044    /// Aggregate "what does the sprint look like *right now*?"
2045    /// computed on demand (no caching).
2046    ///
2047    /// Field semantics:
2048    /// - `committed_points` is the sum of `effort` across all
2049    ///   issues currently linked to the sprint, regardless of
2050    ///   their current status.
2051    /// - `completed_points` is the sum of `effort` across the
2052    ///   subset whose `status = 'done'`. The natural reading
2053    ///   is: "of what was committed, this much has finished."
2054    /// - `committed_count` and `completed_count` are the issue
2055    ///   counts behind the same numbers.
2056    /// - `carried_over_points` and `carried_over_count` are
2057    ///   non-zero only for *completed* sprints (`status =
2058    ///   Completed`); they record what was still in flight at
2059    ///   sprint completion. For `Planned` and `Active` sprints
2060    ///   these are 0 (the sprint isn't done yet, so nothing has
2061    ///   been "carried" anywhere).
2062    #[derive(Debug, Clone)]
2063    pub struct SprintSummary {
2064        pub sprint_id: String,
2065        pub committed_points: i64,
2066        pub completed_points: i64,
2067        pub committed_count: i64,
2068        pub completed_count: i64,
2069        pub carried_over_points: i64,
2070        pub carried_over_count: i64,
2071    }
2072
2073    /// One point on the burndown timeline. Cumulative numbers
2074    /// (so the chart can plot raw values).
2075    ///
2076    /// Two lines are drawn together:
2077    /// - "Committed" rises whenever issues are added to the
2078    ///   sprint mid-flight (or stays flat).
2079    /// - "Completed" rises whenever issues transition to
2080    ///   `done`. Stays flat on quiet days.
2081    ///
2082    /// The visual gap between the two lines is the work still
2083    /// in progress at that moment. We don't draw a third
2084    /// "ideal" line — that would be prescriptive.
2085    #[derive(Debug, Clone, Serialize)]
2086    pub struct BurndownPoint {
2087        pub day: chrono::NaiveDate,
2088        pub cumulative_committed: i64,
2089        pub cumulative_completed: i64,
2090    }
2091
2092    /// Number of recent completed sprints whose `completed_points`
2093    /// median is shown on the per-team velocity chart's
2094    /// reference line. Five is enough to make a single
2095    /// outlier sprint not dominate; small enough that "recent"
2096    /// still means recent.
2097    pub const VELOCITY_MEDIAN_WINDOW: usize = 5;
2098}