App-karr
view release on metacpan or search on metacpan
lib/App/karr/Cmd/Metrics.pm view on Meta::CPAN
"Note: %d task%s carr%s a timestamp karr could not use -- unreadable, a\n"
. "start that precedes the card's own creation, or a completion that\n"
. "precedes that start -- and %s left out of the averages that need it.\n",
$count, ( $count == 1 ? '' : 's' ), ( $count == 1 ? 'ies' : 'y' ),
( $count == 1 ? 'is' : 'are' );
}
sub _count_label {
my ($count) = @_;
return sprintf '%d task%s', $count, ( $count == 1 ? '' : 's' );
}
sub _average_label {
my ( $hours, $samples ) = @_;
return '--' unless defined $hours;
return sprintf '%s (over %d task%s)', _format_duration($hours), $samples,
( $samples == 1 ? '' : 's' );
}
sub _duration_or_dash {
my ($hours) = @_;
return defined $hours ? _format_duration($hours) : '--';
}
sub _percent_or_dash {
my ($ratio) = @_;
return defined $ratio ? sprintf( '%.1f%%', $ratio * 100 ) : '--';
}
# kanban-md's FormatDuration (internal/output/table.go): days and hours once
# there is a whole day, hours and minutes below that, truncated rather than
# rounded. The sign is handled here and not there, because a hand-edited card
# can carry a completion older than its creation, and Perl's % on a negative
# operand would turn that into a nonsense figure rather than a negative one.
sub _format_duration {
my ($hours) = @_;
my $sign = $hours < 0 ? '-' : '';
$hours = abs $hours;
my $whole_hours = int $hours;
my $days = int( $whole_hours / 24 );
my $rest_hours = $whole_hours % 24;
return sprintf '%s%dd %dh', $sign, $days, $rest_hours if $days > 0;
return sprintf '%s%dh %dm', $sign, $rest_hours, int( $hours * 60 ) % 60;
}
sub _round {
my ( $value, $places ) = @_;
# Through sprintf and back into a number, so --json carries 2.34 rather than
# 2.3399999999999999 and the same run always prints the same digits.
return 0 + sprintf( "%.${places}f", $value );
}
# Every timestamp shape a karr board can hold, as epoch seconds, or undef when
# the value is none of them.
#
# * karr's own writer: 2026-08-12T09:41:03Z
# * kanban-md's: 2026-08-12T11:41:03.449764553+02:00 (RFC3339Nano,
# local offset, sub-second precision)
# * a pre-0.403 karr `started`, and any hand-written date: 2026-08-12
#
# App::karr::Role::ClaimTimeout parses the first two for claim expiry and has
# the long-form reasoning; this is the same grammar with the bare date added,
# which that role deliberately does not accept (a claim stamp is never one).
# Duplicated rather than shared because the role's parser is private to it and
# answers a different question -- see the report on ticket #126.
#
# Sub-second precision is dropped: nothing here is measured in less than a
# minute. An offset-less stamp is read as UTC, which is what karr's own writer
# means by it.
sub _epoch {
my ($stamp) = @_;
return undef unless defined $stamp && length $stamp;
my ( $date, $time, $offset ) = $stamp =~ m{
\A (\d{4}-\d{2}-\d{2}) # calendar date
(?: T (\d{2}:\d{2}:\d{2}) # civil time, optional
(?: \. \d+ )? # fractional seconds, dropped
( Z | [+-]\d{2}:?\d{2} )? # UTC offset, or none
)?
\z
}x or return undef;
$time //= '00:00:00';
# Time::Piece's %z wants +hhmm and does not know "Z" at all.
$offset = '+0000' if !defined $offset || uc($offset) eq 'Z';
$offset =~ s/://;
my $parsed =
eval { Time::Piece->strptime( "${date}T${time}${offset}", '%Y-%m-%dT%H:%M:%S%z' ) };
return defined $parsed ? $parsed->epoch : undef;
}
1;
__END__
=pod
=encoding UTF-8
=head1 NAME
App::karr::Cmd::Metrics - Show flow metrics: throughput, lead/cycle time, flow efficiency, aging work
=head1 VERSION
version 0.601
=head1 SYNOPSIS
karr metrics
karr metrics --since 2026-01-01
karr metrics --compact
karr metrics --json
=head1 DESCRIPTION
Reports the board's flow metrics: how much work finished lately, how long it
took, how much of that time the work was actually started, and what has been
in flight too long. Archived tasks are excluded from every figure, the way
they are excluded from C<list> and C<board>.
Like the other read commands (C<board>, C<list>, C<show>, C<log>, C<context>)
this one does not sync first, and it refuses a repository that holds no board
( run in 3.186 seconds using v1.01-cache-2.11-cpan-85d3896f969 )