--- /dev/null
+package SL::DB::Helper::AttrDuration;
+
+use strict;
+
+use parent qw(Exporter);
+our @EXPORT = qw(attr_duration);
+
+use Carp;
+
+sub attr_duration {
+ my ($package, @attributes) = @_;
+
+ _make($package, $_) for @attributes;
+}
+
+sub _make {
+ my ($package, $attribute) = @_;
+
+ no strict 'refs';
+
+ *{ $package . '::' . $attribute . '_as_hours' } = sub {
+ my ($self, $value) = @_;
+
+ $self->$attribute(int($value) + ($self->$attribute - int($self->$attribute))) if @_ > 1;
+ return int($self->$attribute // 0);
+ };
+
+ *{ $package . '::' . $attribute . '_as_minutes' } = sub {
+ my ($self, $value) = @_;
+
+ $self->$attribute(int($self->$attribute) * 1.0 + ($value // 0) / 60.0) if @_ > 1;
+ return int(($self->$attribute // 0) * 60.0 + 0.5) % 60;
+ };
+
+ *{ $package . '::' . $attribute . '_as_duration_string' } = sub {
+ my ($self, $value) = @_;
+
+ $self->$attribute(defined($value) ? $::form->parse_amount(\%::myconfig, $value) * 1 : undef) if @_ > 1;
+ return defined($self->$attribute) ? $::form->format_amount(\%::myconfig, $self->$attribute // 0, 2) : undef;
+ };
+
+ *{ $package . '::' . $attribute . '_as_man_days' } = sub {
+ my ($self, $value) = @_;
+
+ if (@_ > 1) {
+ return undef if !defined $value;
+ $self->$attribute($value);
+ }
+ $value = $self->$attribute // 0;
+ return $value >= 8.0 ? $value / 8.0 : $value;
+ };
+
+ *{ $package . '::' . $attribute . '_as_man_days_unit' } = sub {
+ my ($self, $unit) = @_;
+
+ if (@_ > 1) {
+ return undef if !defined $unit;
+ croak "Unknown unit '${unit}'" if $unit !~ m/^(?:h|hour|man_day)$/;
+ $self->$attribute(($self->$attribute // 0) * 8.0) if $unit eq 'man_day';
+ }
+
+ return ($self->$attribute // 0) >= 8.0 ? 'man_day' : 'h'
+ };
+
+ *{ $package . '::' . $attribute . '_as_man_days_string' } = sub {
+ my ($self, $value) = @_;
+ my $method = "${attribute}_as_man_days";
+
+ if (@_ > 1) {
+ return undef if !defined $value;
+ $self->$method($::form->parse_amount(\%::myconfig, $value));
+ }
+
+ return $::form->format_amount(\%::myconfig, $self->$method // 0, 2);
+ };
+}
+
+1;
+__END__
+
+=pod
+
+=encoding utf8
+
+=head1 NAME
+
+SL::DB::Helper::AttrDuration - Attribute helper for duration stored in
+numeric columns
+
+=head1 SYNOPSIS
+
+ # In a Rose model:
+ use SL::DB::Helper::AttrDuration;
+ __PACKAGE__->attr_duration('time_estimation');
+
+ # Read access:
+ print "Minutes: " . $obj->time_estimation_as_minutes . " hours: " . $obj->time_estimation_as_hours . "\n";
+
+ # Use formatted strings in input fields in templates:
+ <form method="post">
+ ...
+ [% L.input_tag('time_estimation_as_duration_string', SELF.obj.time_estimation_as_duration_string) %]
+ </form>
+
+=head1 OVERVIEW
+
+This is a helper for columns that store a duration as a numeric or
+floating point number representing a number of hours. So the value
+1.75 would stand for "1 hour, 45 minutes".
+
+The helper methods created are:
+
+=over 4
+
+=item C<attribute_as_minutes [$new_value]>
+
+Access only the minutes. Return values are in the range [0 - 59].
+
+=item C<attribute_as_hours [$new_value]>
+
+Access only the hours. Returns an integer value.
+
+=item C<attribute_as_duration_string [$new_value]>
+
+Access the full value as a formatted string according to the user's
+locale settings.
+
+=item C<attribute_as_man_days [$new_value]>
+
+Access the attribute as a number of man days which are assumed to be 8
+hours long. If the underlying attribute is less than 8 then the value
+itself will be returned. Otherwise the value divided by 8 is returned.
+
+If used as a setter then the underlying attribute is simply set to
+ C<$new_value>. Intentional use is to set the man days first and the
+ unit later, e.g.
+
+ $obj->attribute_as_man_days($::form->{attribute_as_man_days});
+ $obj->attribute_as_man_days_unit($::form->{attribute_as_man_days_unit});
+
+Note that L<SL::DB::Object/assign_attributes> is aware of this and
+handles this case correctly.
+
+=item C<attribute_as_man_days_unit [$new_unit]>
+
+Returns the unit that the number returned by L</attribute_as_man_days>
+represents. This can be either C<h> if the underlying attribute is
+less than 8 and C<man_day> otherwise.
+
+If used as a setter then the underlying attribute is multiplied by 8
+if C<$new_unit> equals C<man_day>. Otherwise the underlying attribute
+is not modified. Intentional use is to set the man days first and the
+unit later, e.g.
+
+ $obj->attribute_as_man_days($::form->{attribute_as_man_days});
+ $obj->attribute_as_man_days_unit($::form->{attribute_as_man_days_unit});
+
+Note that L<SL::DB::Object/assign_attributes> is aware of this and
+handles this case correctly.
+
+=back
+
+=head1 FUNCTIONS
+
+=over 4
+
+=item C<attr_duration @attributes>
+
+Package method. Call with the names of attributes for which the helper
+methods should be created.
+
+=back
+
+=head1 BUGS
+
+Nothing here yet.
+
+=head1 AUTHOR
+
+Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
+
+=cut
--- /dev/null
+package AttrDurationTestDummy;
+
+use base qw(SL::DB::Object);
+
+__PACKAGE__->meta->setup(
+ table => 'dummy',
+ columns => [ dummy => { type => 'numeric', precision => 2, scale => 12 }, ]
+);
+
+use SL::DB::Helper::AttrDuration;
+
+__PACKAGE__->attr_duration('dummy');
+
+package main;
+
+use Test::More tests => 83;
+use Test::Exception;
+
+use strict;
+
+use lib 't';
+use utf8;
+
+use Data::Dumper;
+use Support::TestSetup;
+
+sub new_item {
+ return AttrDurationTestDummy->new(@_);
+}
+
+Support::TestSetup::login();
+my $item;
+
+# Wenn das Attribut undef ist:
+is(new_item->dummy, undef, 'uninitialized: raw');
+is(new_item->dummy_as_hours, 0, 'uninitialized: as_hours');
+is(new_item->dummy_as_minutes, 0, 'uninitialized: as_minutes');
+is(new_item->dummy_as_duration_string, undef, 'uninitialized: as_duration_string');
+is(new_item->dummy_as_man_days, 0, 'uninitialized: as_man_days');
+is(new_item->dummy_as_man_days_unit, 'h', 'uninitialized: as_man_days_unit');
+is(new_item->dummy_as_man_days_string, '0,00', 'uninitialized: as_man_days_string');
+
+# Auslesen kleiner 8 Stunden:
+is(new_item(dummy => 2.75)->dummy, 2.75, 'initialized < 8: raw');
+is(new_item(dummy => 2.75)->dummy_as_hours, 2, 'initialized < 8: as_hours');
+is(new_item(dummy => 2.75)->dummy_as_minutes, 45, 'initialized < 8: as_minutes');
+is(new_item(dummy => 2.75)->dummy_as_duration_string, '2,75', 'initialized < 8: as_duration_string');
+is(new_item(dummy => 2.75)->dummy_as_man_days, 2.75, 'initialized < 8: as_man_days');
+is(new_item(dummy => 2.75)->dummy_as_man_days_unit, 'h', 'initialized < 8: as_man_days_unit');
+is(new_item(dummy => 2.75)->dummy_as_man_days_string, '2,75', 'initialized < 8: as_man_days_string');
+
+# Auslesen größer 8 Stunden:
+is(new_item(dummy => 12.5)->dummy, 12.5, 'initialized > 8: raw');
+is(new_item(dummy => 12.5)->dummy_as_hours, 12, 'initialized > 8: as_hours');
+is(new_item(dummy => 12.5)->dummy_as_minutes, 30, 'initialized > 8: as_minutes');
+is(new_item(dummy => 12.5)->dummy_as_duration_string, '12,50', 'initialized > 8: as_duration_string');
+is(new_item(dummy => 12.5)->dummy_as_man_days, 1.5625, 'initialized > 8: as_man_days');
+is(new_item(dummy => 12.5)->dummy_as_man_days_unit, 'man_day', 'initialized > 8: as_man_days_unit');
+is(new_item(dummy => 12.5)->dummy_as_man_days_string, '1,56', 'initialized > 8: as_man_days_string');
+
+$item = new_item(dummy => 2.25); $item->dummy_as_duration_string(undef);
+is($item->dummy, undef, 'write as_duration_string undef read raw');
+is($item->dummy_as_minutes, 0, 'write as_duration_string undef read as_minutes');
+is($item->dummy_as_hours, 0, 'write as_duration_string undef read as_hours');
+is($item->dummy_as_duration_string, undef, 'write as_duration_string undef read as_duration_string');
+
+$item = new_item(dummy => 2.25); $item->dummy_as_duration_string("4,80");
+is($item->dummy, 4.8, 'write as_duration_string 4,80 read raw');
+is($item->dummy_as_minutes, 48, 'write as_duration_string 4,80 read as_minutes');
+is($item->dummy_as_hours, 4, 'write as_duration_string 4,80 read as_hours');
+is($item->dummy_as_duration_string, "4,80", 'write as_duration_string 4,80 read as_duration_string');
+
+$item = new_item(dummy => 2.25); $item->dummy_as_minutes(12);
+is($item->dummy, 2.2, 'write as_minutes 12 read raw');
+is($item->dummy_as_minutes, 12, 'write as_minutes 12 read as_minutes');
+is($item->dummy_as_hours, 2, 'write as_minutes 12 read as_hours');
+is($item->dummy_as_duration_string, "2,20", 'write as_minutes 12 read as_duration_string');
+
+$item = new_item(dummy => 2.25); $item->dummy_as_hours(5);
+is($item->dummy, 5.25, 'write as_hours 5 read raw');
+is($item->dummy_as_minutes, 15, 'write as_hours 5 read as_minutes');
+is($item->dummy_as_hours, 5, 'write as_hours 5 read as_hours');
+is($item->dummy_as_duration_string, "5,25", 'write as_hours 5 read as_duration_string');
+
+$item = new_item(dummy => undef);
+is($item->dummy, undef, 'write raw undef read raw');
+is($item->dummy_as_man_days, 0, 'write raw undef read as_man_days');
+is($item->dummy_as_man_days_unit, 'h', 'write raw undef read as_man_days_unit');
+is($item->dummy_as_man_days_string, '0,00', 'write raw undef read as_man_days_string');
+
+$item = new_item(dummy => 4);
+is($item->dummy, 4, 'write raw 4 read raw');
+is($item->dummy_as_man_days, 4, 'write raw 4 read as_man_days');
+is($item->dummy_as_man_days_unit, 'h', 'write raw 4 read as_man_days_unit');
+is($item->dummy_as_man_days_string, '4,00', 'write raw 4 read as_man_days_string');
+
+$item = new_item(dummy => 18);
+is($item->dummy, 18, 'write raw 18 read raw');
+is($item->dummy_as_man_days, 2.25, 'write raw 18 read as_man_days');
+is($item->dummy_as_man_days_unit, 'man_day', 'write raw 18 read as_man_days_unit');
+is($item->dummy_as_man_days_string, '2,25', 'write raw 18 read as_man_days_string');
+
+$item = new_item(dummy => 4);
+is($item->dummy, 4, 'should not change anything when writing undef: write raw 4 read raw');
+is($item->dummy_as_man_days(undef), undef, 'should not change anything when writing undef: write as_man_days undef return undef');
+is($item->dummy, 4, 'should not change anything when writing undef: read raw 2');
+is($item->dummy_as_man_days_unit(undef), undef, 'should not change anything when writing undef: write as_man_days_unit undef return undef');
+is($item->dummy, 4, 'should not change anything when writing undef: read raw 3');
+is($item->dummy_as_man_days_string(undef), undef, 'should not change anything when writing undef: write as_man_days_string undef return undef');
+is($item->dummy, 4, 'should not change anything when writing undef: read raw 4');
+
+
+$item = new_item;
+is($item->dummy(2), 2, 'parse less than a man day: write raw 2 read raw');
+is($item->dummy_as_man_days(0.75), 0.75, 'parse less than a man day: write as_man_days 0.75 read as_man_days');
+is($item->dummy_as_man_days_string('0,5'), '0,50', 'parse less than a man day: write as_man_days_string 0,5 read read as_man_days_string');
+
+$item = new_item;
+is($item->dummy(12), 12, 'parse more than a man day: write raw 12 read raw');
+is($item->dummy_as_man_days(13.25), 1.65625, 'parse more than a man day: write as_man_days 13.25 read as_man_days');
+is($item->dummy_as_man_days_string('13,5'), '1,69', 'parse more than a man day: write as_man_days_string 13,5 read read as_man_days_string');
+
+$item = new_item;
+is($item->dummy(3.25), 3.25, 'parse less than a man day with unit h: write raw 3.25 read raw');
+is($item->dummy_as_man_days_unit('h'), 'h', 'parse less than a man day with unit h: write as_man_days_unit h read as_man_days_unit');
+is($item->dummy, 3.25, 'parse less than a man day with unit h: read raw');
+
+$item = new_item;
+is($item->dummy(3.25), 3.25, 'parse less than a man day with unit hour: write raw 3.25 read raw');
+is($item->dummy_as_man_days_unit('hour'), 'h', 'parse less than a man day with unit hour: write as_man_days_unit hour read as_man_days_unit');
+is($item->dummy, 3.25, 'parse less than a man day with unit hour: read raw');
+
+$item = new_item;
+is($item->dummy(3.25), 3.25, 'parse more than a man day with unit man_day: write raw 3.25 read raw');
+is($item->dummy_as_man_days_unit('man_day'), 'man_day', 'parse more than a man day with unit man_day: write as_man_days_unit man_day read as_man_days_unit');
+is($item->dummy, 26, 'parse more than a man day with unit man_day: read raw');
+
+is(new_item->assign_attributes(dummy_as_man_days => 3, dummy_as_man_days_unit => 'h')->dummy, 3, 'assign_attributes hash 3h');
+is(new_item->assign_attributes(dummy_as_man_days_unit => 'h', dummy_as_man_days => 3 )->dummy, 3, 'assign_attributes hash h3');
+
+is(new_item->assign_attributes(dummy_as_man_days => 3, dummy_as_man_days_unit => 'man_day')->dummy, 24, 'assign_attributes hash 3man_day');
+is(new_item->assign_attributes(dummy_as_man_days_unit => 'man_day', dummy_as_man_days => 3 )->dummy, 24, 'assign_attributes hash man_day3');
+
+is(new_item->assign_attributes('dummy_as_man_days', 3, 'dummy_as_man_days_unit', 'h')->dummy, 3, 'assign_attributes array 3h');
+is(new_item->assign_attributes('dummy_as_man_days_unit', 'h', 'dummy_as_man_days', 3 )->dummy, 3, 'assign_attributes array h3');
+
+is(new_item->assign_attributes('dummy_as_man_days', 3, 'dummy_as_man_days_unit', 'man_day')->dummy, 24, 'assign_attributes array 3man_day');
+is(new_item->assign_attributes('dummy_as_man_days_unit', 'man_day', 'dummy_as_man_days', 3 )->dummy, 24, 'assign_attributes array man_day3');
+
+# Parametervalidierung
+throws_ok { new_item()->dummy_as_man_days_unit('invalid') } qr/unknown.*unit/i, 'unknown unit';
+lives_ok { new_item()->dummy_as_man_days_unit('h') } 'known unit h';
+lives_ok { new_item()->dummy_as_man_days_unit('hour') } 'known unit hour';
+lives_ok { new_item()->dummy_as_man_days_unit('man_day') } 'known unit man_day';
+
+done_testing();