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}