tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
/// One Capacity tab row, from the shared calculation (WP06 T030).
///
/// The title is the DTO's `scope.display` — **which scope the figures belong to**, so
/// a name and its stable id are visibly one scope — and the subtitle is the compact
/// half of the fields `tix capacity calculate` prints. An absent value is `-`, the
/// crate's convention, never `0`.
pub(in crate::app) fn capacity_row(
    calculation: &crate::capacity::calculate::CapacityCalculation,
) -> crate::ui::schedule::CapacityRow {
    let unit = calculation.unit.as_deref();
    crate::ui::schedule::CapacityRow {
        title: calculation.scope.display.clone(),
        subtitle: format!(
            "{kind} · {state} · committed {committed} of {capacity} · {utilization} · \
             overflow {overflow}",
            kind = calculation.scope.kind.as_str(),
            state = calculation.state().as_str(),
            committed = capacity_with_unit(calculation.committed, unit),
            capacity = capacity_with_unit(calculation.capacity, unit),
            utilization = capacity_utilization(calculation.utilization),
            overflow = capacity_with_unit(calculation.overflow, unit),
        ),
        warned: !calculation.diagnostics.is_empty(),
        overflowing: calculation.is_over_capacity(),
    }
}

/// The selected scope's detail pane, from the shared calculation and — for a cycle —
/// its recorded close evidence (WP06 T030/T033).
///
/// `stored` is the refresh's one listing of the snapshot store, keyed by stable cycle
/// identity; the caller reads it once rather than letting every cycle scope list it.
pub(in crate::app) fn capacity_detail(
    index: &crate::cycle_schedule::identity::ScheduleIndex,
    calculation: &crate::capacity::calculate::CapacityCalculation,
    stored: &std::collections::BTreeMap<String, crate::cycle_schedule::snapshot::CycleSnapshot>,
) -> crate::ui::schedule::CapacityDetail {
    crate::ui::schedule::CapacityDetail {
        heading: calculation.scope.display.clone(),
        fields: capacity_fields(calculation),
        diagnostics: calculation
            .diagnostics
            .iter()
            .map(crate::capacity::warning::CapacityWarning::render)
            .collect(),
        snapshot: capacity_snapshot_view(index, calculation, stored),
    }
}

/// The calculation's own fields, in `tix capacity calculate`'s order and spelling.
pub(in crate::app) fn capacity_fields(
    calculation: &crate::capacity::calculate::CapacityCalculation,
) -> Vec<(String, String)> {
    vec![
        (
            "scope".to_string(),
            calculation.scope.kind.as_str().to_string(),
        ),
        ("reference".to_string(), calculation.scope.reference.clone()),
        ("display".to_string(), calculation.scope.display.clone()),
        (
            "state".to_string(),
            calculation.state().as_str().to_string(),
        ),
        (
            "capacity".to_string(),
            capacity_number(calculation.capacity),
        ),
        (
            "unit".to_string(),
            calculation.unit.clone().unwrap_or_else(|| "-".to_string()),
        ),
        (
            "committed".to_string(),
            capacity_number(calculation.committed),
        ),
        (
            "completed".to_string(),
            capacity_number(calculation.completed),
        ),
        (
            "remaining".to_string(),
            capacity_number(calculation.remaining),
        ),
        (
            "overflow".to_string(),
            capacity_number(calculation.overflow),
        ),
        (
            "utilization".to_string(),
            capacity_utilization(calculation.utilization),
        ),
        (
            "counted_issues".to_string(),
            capacity_number(calculation.counted_issues),
        ),
        (
            "uncounted_issues".to_string(),
            capacity_number(calculation.uncounted_issues),
        ),
        (
            "unestimated_issues".to_string(),
            capacity_number(calculation.unestimated_issues),
        ),
        (
            "source_issues".to_string(),
            calculation.source.issues.to_string(),
        ),
        (
            "source_units".to_string(),
            capacity_joined(&calculation.source.units),
        ),
        (
            "diagnostics".to_string(),
            calculation.diagnostics.len().to_string(),
        ),
    ]
}

/// A cycle scope's close evidence, or an explicit statement that there is none
/// (WP06 T033).
///
/// `None` for a non-cycle scope: a team or an assignee has no close and therefore no
/// snapshot. For a cycle, the label is the **current-vs-historical** statement — the
/// stored evidence is immutable, and a reopened (or edited) cycle's live values above
/// it are the current reading. The reader is the CLI's own
/// ([`crate::capacity::snapshot::CapacitySnapshot::from_cycle_snapshot`]), so a
/// payload-less legacy snapshot is a value rather than an error.
pub(in crate::app) fn capacity_snapshot_view(
    index: &crate::cycle_schedule::identity::ScheduleIndex,
    calculation: &crate::capacity::calculate::CapacityCalculation,
    stored: &std::collections::BTreeMap<String, crate::cycle_schedule::snapshot::CycleSnapshot>,
) -> Option<crate::ui::schedule::CapacitySnapshotView> {
    use crate::capacity::snapshot::CapacitySnapshot;
    use crate::cycle_schedule::identity::{CycleReference, GeneratedCycleState};

    if calculation.scope.kind != crate::capacity::config::CapacityScopeKind::Cycle {
        return None;
    }
    let CycleReference::Generated { id, .. } =
        crate::capacity::resolve_cycle(index, &calculation.scope.reference)
    else {
        return Some(crate::ui::schedule::CapacitySnapshotView {
            label: "none (a hand-written cycle has no generated snapshot)".to_string(),
            fields: Vec::new(),
        });
    };
    let evidence = stored
        .get(id.as_str())
        .and_then(|stored| CapacitySnapshot::from_cycle_snapshot(stored).ok().flatten());
    match evidence {
        Some(evidence) => {
            let historical = index
                .cycle(&id)
                .is_some_and(|cycle| cycle.state != GeneratedCycleState::Closed);
            let label = if historical {
                "immutable (this cycle has been reopened; the values above are the current \
                 reading)"
            } else {
                "immutable (captured at close)"
            };
            Some(crate::ui::schedule::CapacitySnapshotView {
                label: label.to_string(),
                fields: capacity_snapshot_fields(&evidence),
            })
        }
        None => Some(crate::ui::schedule::CapacitySnapshotView {
            label: "none (no close snapshot recorded for this cycle)".to_string(),
            fields: Vec::new(),
        }),
    }
}

/// The stored evidence's own fields, in `tix capacity snapshot`'s order and spelling.
pub(in crate::app) fn capacity_snapshot_fields(
    evidence: &crate::capacity::snapshot::CapacitySnapshot,
) -> Vec<(String, String)> {
    vec![
        ("snapshot_id".to_string(), evidence.snapshot_id.clone()),
        ("closed_at".to_string(), evidence.closed_at.clone()),
        ("closed_by".to_string(), evidence.closed_by.clone()),
        (
            "scope".to_string(),
            evidence.scope.kind.as_str().to_string(),
        ),
        ("reference".to_string(), evidence.scope.reference.clone()),
        ("display".to_string(), evidence.scope.display.clone()),
        ("state".to_string(), evidence.state.as_str().to_string()),
        ("capacity".to_string(), capacity_number(evidence.capacity)),
        (
            "unit".to_string(),
            evidence.unit.clone().unwrap_or_else(|| "-".to_string()),
        ),
        ("committed".to_string(), capacity_number(evidence.committed)),
        ("completed".to_string(), capacity_number(evidence.completed)),
        ("remaining".to_string(), capacity_number(evidence.remaining)),
        ("overflow".to_string(), capacity_number(evidence.overflow)),
        (
            "utilization".to_string(),
            capacity_utilization(evidence.utilization),
        ),
        (
            "counted_issues".to_string(),
            capacity_number(evidence.counted_issues),
        ),
        (
            "uncounted_issues".to_string(),
            capacity_number(evidence.uncounted_issues),
        ),
        (
            "unestimated_issues".to_string(),
            capacity_number(evidence.unestimated_issues),
        ),
        (
            "source_issues".to_string(),
            evidence.source.issues.to_string(),
        ),
        (
            "source_units".to_string(),
            capacity_joined(&evidence.source.units),
        ),
        (
            "diagnostics".to_string(),
            evidence.diagnostics.len().to_string(),
        ),
    ]
}

/// The board's compact cell for one cycle scope (WP06 T032), or the empty string for a
/// scope with no ceiling in force — there is nothing to say beyond the header it
/// already has, and a fabricated cell would be a TUI-only number.
pub(in crate::app) fn capacity_cell(
    calculation: &crate::capacity::calculate::CapacityCalculation,
) -> String {
    if calculation.capacity.is_none() {
        return String::new();
    }
    let unit = calculation.unit.as_deref();
    let mut cell = format!(
        "{committed}/{capacity} {unit} · {utilization}",
        committed = capacity_number(calculation.committed),
        capacity = capacity_number(calculation.capacity),
        unit = unit.unwrap_or("-"),
        utilization = capacity_utilization(calculation.utilization),
    );
    if calculation.is_over_capacity() {
        cell.push_str(" · over");
    }
    cell
}

/// The issue's own contribution to one scope's calculation (WP06 T031).
///
/// Its raw estimate and, when it converts, the value it contributes in the scope's
/// unit. An issue that states no estimate is said to state none — never shown as a
/// zero — and one whose estimate cannot be read in the unit is said so.
pub(in crate::app) fn issue_estimate_line(
    estimate: Option<&crate::capacity::estimate::EstimateValue>,
    unit: Option<&str>,
) -> String {
    let unit = unit.unwrap_or("-");
    let Some(estimate) = estimate else {
        return "not in the capacity snapshot".to_string();
    };
    if estimate.is_unestimated() {
        return "states no estimate — unestimated, counted as nothing (never 0)".to_string();
    }
    match (estimate.raw(), estimate.converted_value) {
        (Some(raw), Some(value)) => format!("estimate {raw} → contributes {value}{unit}"),
        (Some(raw), None) => format!("estimate {raw} → cannot be counted in {unit}"),
        (None, _) => "states no estimate".to_string(),
    }
}

/// A DTO number with its unit, or `-` when the value is absent (never `0`).
pub(in crate::app) fn capacity_with_unit(value: Option<u64>, unit: Option<&str>) -> String {
    value.map_or_else(
        || "-".to_string(),
        |value| match unit {
            Some(unit) => format!("{value}{unit}"),
            None => value.to_string(),
        },
    )
}

/// A DTO number, or `-` when it is absent — the CLI's own convention.
pub(in crate::app) fn capacity_number<T: std::fmt::Display>(value: Option<T>) -> String {
    value.map_or_else(|| "-".to_string(), |value| value.to_string())
}

/// A utilization percentage as the DTO's `u32`, or `-` when the ceiling was zero or
/// absent — the CLI's own policy, so the two surfaces round and spell alike.
pub(in crate::app) fn capacity_utilization(value: Option<u32>) -> String {
    value.map_or_else(|| "-".to_string(), |value| format!("{value}%"))
}

/// A unit list as one cell; an empty list is `-`.
pub(in crate::app) fn capacity_joined(units: &[String]) -> String {
    if units.is_empty() {
        "-".to_string()
    } else {
        units.join(", ")
    }
}