File Coverage

blib/lib/Health/BladderDiary/GenTable.pm
Criterion Covered Total %
statement 70 147 47.6
branch 34 104 32.6
condition 7 37 18.9
subroutine 6 6 100.0
pod 1 1 100.0
total 118 295 40.0


line stmt bran cond sub pod time code
1             package Health::BladderDiary::GenTable;
2              
3             our $AUTHORITY = 'cpan:PERLANCAR'; # AUTHORITY
4             our $DATE = '2020-12-10'; # DATE
5             our $DIST = 'Health-BladderDiary-GenTable'; # DIST
6             our $VERSION = '0.006'; # VERSION
7              
8 1     1   118600 use 5.010001;
  1         16  
9 1     1   6 use strict;
  1         2  
  1         21  
10 1     1   5 use warnings;
  1         2  
  1         26  
11              
12 1     1   5 use Exporter qw(import);
  1         1  
  1         1865  
13             our @EXPORT_OK = qw(gen_bladder_diary_table_from_entries);
14              
15             our %SPEC;
16              
17             $SPEC{gen_bladder_diary_table_from_entries} = {
18             v => 1.1,
19             summary => 'Create bladder diary table from bladder diary entries',
20             args => {
21             entries => {
22             schema => 'str*',
23             req => 1,
24             pos => 0,
25             cmdline_src => 'stdin_or_file',
26             },
27             yesterday_last_urination_entry => {
28             schema => 'str*',
29             cmdline_aliases => {y=>{}},
30             },
31             date => {
32             schema => ['date*', 'x.perl.coerce_to' => 'DateTime'],
33             },
34             },
35             };
36             sub gen_bladder_diary_table_from_entries {
37 2     2 1 27244 my %args = @_;
38              
39 2         6 my @unparsed_entries;
40             SPLIT_ENTRIES: {
41 2 100       5 if ($args{entries} =~ /\S\R\R+\S/) {
  2         25  
42             # there is a blank line between non-blanks, assume entries are
43             # written in paragraphs
44 1         9 @unparsed_entries = split /\R\R+/, $args{entries};
45 1         4 for (@unparsed_entries) {
46 3         9 s/\R+/ /g;
47 3         14 s/\s+\z//;
48             }
49             } else {
50             # there are no blank lines, assume entries are written as individual
51             # lines
52 1         5 @unparsed_entries = split /^/, $args{entries};
53             }
54 2         7 for (@unparsed_entries) {
55 6         19 s/\R+/ /g;
56 6         22 s/\s+\z//;
57             }
58             } # SPLIT_ENTRIES
59              
60             my $code_parse_entry = sub {
61 6     6   17 my ($uentry, $label) = @_;
62 6         11 my $uentry0 = $uentry;
63 6         26 my @warnings;
64              
65 6 50       36 $uentry =~ s/\A(\d\d)[:.]?(\d\d)(?:-(\d\d)[:.]?(\d\d))?\s*//
66             or return [400, "Entry $label: invalid time, please start with hhmm or hh:mm: $uentry0"];
67 6         28 my ($h, $m, $h2, $m2) = ($1, $2, $3, $4);
68 6 50       29 $uentry =~ s/(\w+):?\s*//
69             or return [400, "Entry $label: event (e.g. drink, urinate) expected: $uentry"];
70 6         14 my $event = $1;
71 6 100 66     37 if ($event eq 'u' || $event eq 'urin') { $event = 'urinate' }
  2 100       4  
    50          
72 2         5 elsif ($event eq 'd') { $event = 'drink' }
73 0         0 elsif ($event eq 'c') { $event = 'comment' }
74 6 50       27 $event =~ /\A(drink|eat|poop|urinate|comment)\z/
75             or return [400, "Entry $label: unknown event '$event', please choose eat|drink|poop|urinate|comment"];
76              
77 6         55 my $parsed_entry = {
78             # XXX check that time is monotonically increasing
79             time => sprintf("%02d.%02d", $h, $m),
80             _event => $event,
81             _h => $h,
82             _m => $m,
83             _time => $h*60 + $m,
84             _raw => $uentry0,
85             };
86              
87             # scrape key-value pairs from unparsed entry
88 6         12 my %kv;
89 6         28 while ($uentry =~ /(\w+)=(.+?)(?=[,.]?\s+\w+=|[.]?\s*\z)/g) {
90 6         40 $kv{$1} = $2;
91             }
92             #use DD; dd \%kv;
93 6         23 for my $k (sort keys %kv) {
94 6 50       23 unless ($k =~ /\A(vol|type|comment|urgency|color)\z/) {
95 0         0 push @warnings, "Entry $label: unknown key '$k'";
96             }
97             }
98              
99 6         13 for my $k (qw/vol type comment urgency color/) {
100 30 100       65 if (defined $kv{$k}) {
101 6         15 $parsed_entry->{$k} = $kv{$k};
102             }
103             }
104              
105 6 100 33     46 $uentry =~ /\b(\d+)ml\b/ and $parsed_entry->{vol} //= $1;
106 6 50 0     19 $uentry =~ /\bv(\d+)\b/ and $parsed_entry->{vol} //= $1;
107 6 100 33     28 $uentry =~ /\bu([0-9]|10)\b/ and $parsed_entry->{urgency} //= $1;
108 6 100 33     26 $uentry =~ /\bc([0-6](?:\.5)?)\b/ and $parsed_entry->{color} //= do {
109 2 50       7 if ($1 == 0) { '0/6 clear' } # very good
  2 0       8  
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
110 0         0 elsif ($1 == 0.5) { '0-1/6 clear to light yellow' } # good
111 0         0 elsif ($1 == 1) { '1/6 light yellow' } # good
112 0         0 elsif ($1 == 1.5) { '1-2/6 light yellow to yellow' } # good
113 0         0 elsif ($1 == 2) { '2/6 yellow' } # fair
114 0         0 elsif ($1 == 2.5) { '2-3/6 yellow to dark yellow' } # fair
115 0         0 elsif ($1 == 3) { '3/6 dark yellow' } # light dehydrated
116 0         0 elsif ($1 == 4) { '4/6 amber' } # dehydrated
117 0         0 elsif ($1 == 5) { '5/6 brown' } # very dehydrated
118 0         0 elsif ($1 == 6) { '6/6 red' } # severe dehydrated
119             };
120              
121 6 100       24 if ($event eq 'drink') {
    50          
    50          
122             return [400, "Entry $label: please specify volume for $event"]
123 4 50       12 unless defined $parsed_entry->{vol};
124 4   100     16 $parsed_entry->{type} //= "water";
125             } elsif ($event eq 'eat') {
126 0   0     0 $parsed_entry->{type} //= "food";
127             } elsif ($event eq 'urinate') {
128             return [400, "Entry $label: please specify volume for $event"]
129 2 50       7 unless defined $parsed_entry->{vol};
130             $parsed_entry->{"ucomment"} = "poop" .
131 2 50       8 ($parsed_entry->{comment} ? ": $parsed_entry->{comment}" : "");
132             }
133              
134 6         32 [200, "OK", $parsed_entry, {'func.warnings'=>\@warnings}];
135 2         18 }; # code_parse_entry
136              
137 2         6 my @urinations;
138             my @intakes;
139             PARSE_ENTRIES: {
140 2         4 my $i = 0;
  2         5  
141 2         6 for my $uentry (@unparsed_entries) {
142 6         14 $i++;
143 6         20 my $res = $code_parse_entry->($uentry, "#$i");
144 6 50       19 return $res unless $res->[0] == 200;
145              
146 6         9 my $parsed_entry = $res->[2];
147 6         14 my $event = delete $parsed_entry->{_event};
148 6 100       19 if ($event eq 'drink') {
    50          
    50          
149 4         16 push @intakes, $parsed_entry;
150             } elsif ($event eq 'eat') {
151 0         0 push @intakes, $parsed_entry;
152             } elsif ($event eq 'urinate') {
153 2         6 push @urinations, $parsed_entry;
154             }
155             }
156             } # PARSE_ENTRIES
157              
158 2 50       7 if ($args{_raw}) {
159 2         63 return [200, "OK", {
160             intakes => \@intakes,
161             urinations => \@urinations,
162             }];
163             }
164              
165 0           my $yesterday_last_urination_parsed_entry;
166 0 0         if ($args{yesterday_last_urination_entry}) {
167             my $res = $code_parse_entry->(
168 0           $args{yesterday_last_urination_entry}, "yesterday's urination");
169 0 0         return $res unless $res->[0] == 200;
170 0           $yesterday_last_urination_parsed_entry = $res->[2];
171 0           my $event = delete $yesterday_last_urination_parsed_entry->{_event};
172 0 0         unless ($event eq 'urinate') {
173 0           return [400, "Yesterday's urination event must be 'urinate', not $event"];
174             }
175             }
176              
177 0           my @rows;
178 0           my $ivol_cum = 0;
179 0           my $uvol_cum = 0;
180             my $prev_utime = $yesterday_last_urination_parsed_entry ?
181 0 0         $yesterday_last_urination_parsed_entry->{_time} : undef;
182 0           my $num_drink = 0;
183 0           my $num_urinate = 0;
184             GROUP_INTO_HOURS: {
185 0           my $h = do {
  0            
186 0 0         my $hi = @intakes ? $intakes[0]{_h} : undef;
187 0 0         my $hu = @urinations ? $urinations[0]{_h} : undef;
188 0   0       my $h = $hi // $hu;
189 0 0 0       $h = $hi if defined $hi && $hi < $h;
190 0 0 0       $h = $hu if defined $hu && $hu < $h;
191 0           $h;
192             };
193 0           while (1) {
194 0 0 0       last unless @intakes || @urinations;
195              
196 0           my @hour_rows;
197 0 0         push @hour_rows, {time => sprintf("%02d.00-%02d.00", $h, $h+1 <= 23 ? $h+1 : 0)};
198              
199 0           my $j = 0;
200 0   0       while (@intakes && $intakes[0]{_h} == $h) {
201 0           my $entry = shift @intakes;
202 0           $hour_rows[$j]{"intake type"} = $entry->{type};
203 0           $hour_rows[$j]{itime} = $entry->{time};
204 0           $hour_rows[$j]{"icomment"} = $entry->{comment};
205 0 0         if (defined $entry->{vol}) {
206 0           $num_drink++;
207 0           $hour_rows[$j]{"ivol (ml)"} = $entry->{vol};
208 0           $ivol_cum += $entry->{vol};
209 0           $hour_rows[$j]{"ivol cum"} = $ivol_cum;
210             }
211 0           $j++;
212             }
213              
214 0           $j = 0;
215 0   0       while (@urinations && $urinations[0]{_h} == $h) {
216 0           my $entry = shift @urinations;
217 0           $hour_rows[$j]{"urin/defec time"} = $entry->{time};
218 0           $hour_rows[$j]{"color (0-6)"} = $entry->{color};
219 0           $hour_rows[$j]{"ucomment"} = $entry->{comment};
220 0           $hour_rows[$j]{"urgency (0-10)"} = $entry->{urgency};
221 0 0         if (defined $entry->{vol}) {
222 0           $num_urinate++;
223 0           $hour_rows[$j]{"uvol (ml)"} = $entry->{vol};
224 0           $uvol_cum += $entry->{vol};
225 0           $hour_rows[$j]{"uvol cum"} = $uvol_cum;
226 0           my $mins_diff;
227 0 0         if (defined $prev_utime) {
228 0 0         $mins_diff = $prev_utime > $entry->{_time} ? (24*60+$entry->{_time} - $prev_utime) : ($entry->{_time} - $prev_utime);
229             }
230             #$hour_rows[$j]{"utimediff"} = $mins_diff;
231             $hour_rows[$j]{"urate (ml/h)"} = defined($prev_utime) ?
232 0 0         sprintf("%.0f", $entry->{vol} / $mins_diff * 60) : undef;
233             }
234 0           $j++;
235              
236 0           $prev_utime = $entry->{_time};
237             }
238 0           push @rows, @hour_rows;
239 0           $h++;
240 0 0         $h = 0 if $h >= 24;
241             }
242             } # GROUP_INTO_HOURS
243              
244             ADD_SUMMARY_ROWS: {
245 0           push @rows, {};
  0            
246              
247 0           push @rows, {
248             time => 'freq drink/urin',
249             'itime' => $num_drink,
250             'urin/defec time' => $num_urinate,
251             };
252 0 0         push @rows, {
    0          
253             time => 'avg (ml)',
254             'ivol (ml)' => sprintf("%.0f", $num_drink ? $ivol_cum / $num_drink : 0),
255             'uvol (ml)' => sprintf("%.0f", $num_urinate ? $uvol_cum / $num_urinate : 0),
256             };
257             }
258              
259             # return result
260              
261 0           [200, "OK", \@rows, {
262             'table.fields' => [
263             'time',
264             'intake type',
265             'itime',
266             'ivol (ml)',
267             'ivol cum',
268             'icomment', # intake comment
269             'urin/defec time',
270             'uvol (ml)',
271             'uvol cum',
272             'urate (ml/h)',
273             'color (0-6)',
274             'urgency (0-10)',
275             'ucomment', # urinate comment
276             ],
277             'table.field_aligns' => [
278             'left', #'time',
279             'left', #'intake type',
280             'left', #'itime',
281             'right', #'ivol (ml)',
282             'right', #'ivol cum',
283             'left', #'icomment',
284             'left', #'urin/defec time',
285             'right', #'uvol (ml)',
286             'right', #'uvol cum',
287             'right', #'urate (ml/h)',
288             'left', #'color (0-6)',
289             'left', #'urgency (0-10)',
290             'left', #'ucomment',
291             ],
292             }];
293             }
294              
295             1;
296             # ABSTRACT: Create bladder diary table from entries
297              
298             __END__
299              
300             =pod
301              
302             =encoding UTF-8
303              
304             =head1 NAME
305              
306             Health::BladderDiary::GenTable - Create bladder diary table from entries
307              
308             =head1 VERSION
309              
310             This document describes version 0.006 of Health::BladderDiary::GenTable (from Perl distribution Health-BladderDiary-GenTable), released on 2020-12-10.
311              
312             =head1 SYNOPSIS
313              
314             Your bladder entries e.g. in `bd-entry1.txt` (I usually write in Org document):
315              
316             0730 drink: 300ml type=water
317              
318             0718 urinate: 250ml
319              
320             0758 urinate: 100ml
321              
322             0915 drink 300ml
323              
324             1230 drink: 600ml, note=thirsty
325              
326             1245 urinate: 200ml
327              
328             From the command-line (I usually run the script from inside Emacs):
329              
330             % gen-bladder-diary-table-from-entries < bd-entry1.txt
331             | time | intake type | itime | ivol (ml) | ivol cum | icomment | urination time | uvol (ml) | uvol cum | urgency (0-3) | ucolor (0-3) | ucomment |
332             |----------+-------------+-------+-----------+----------+----------+----------------+-----------+----------+---------------+--------------+----------+
333             | 07-08.00 | water | 07.30 | 300 | 300 | | 07.18 | 250 | 250 | | | |
334             | | | | | | | 07.58 | 100 | 350 | | | |
335             | 08-09.00 | | | | | | | | | | | |
336             | 09-10.00 | water | 09.15 | 300 | 600 | | | | | | | |
337             | 10-11.00 | | | | | | | | | | | |
338             | 12-13.00 | water | 12.30 | 600 | 1200 | thirsty | 12.45 | 200 | | | | |
339             | | | | | | | | | | | | |
340             | total | | | 1200 | | | | 550 | | | | |
341             | freq | | | 3 | | | | 3 | | | | |
342             | avg | | | 400 | | | | 183 | | | | |
343              
344             Produce CSV instead:
345              
346             % gen-bladder-diary-table-from-entries --format csv < bd-entry1.txt > bd-entry1.csv
347              
348             =head1 DESCRIPTION
349              
350             This module can be used to visualize bladder diary entries (which is more
351             comfortable to type in) into table form (which is more comfortable to look at).
352              
353             =head2 Diary entries
354              
355             The input to the module is bladder diary entries in the form of text. The
356             entries should be written in paragraphs, chronologically, each separated by a
357             blank line. If there is no blank line, then entries are assumed to be written in
358             single lines.
359              
360             The format of an entry is:
361              
362             <TIME> ("-" <TIME2>)? WS EVENT (":")? WS EXTRA
363              
364             It is designed to be easy to write. Time can be written as C<hh:mm> or just
365             C<hhmm> in 24h format.
366              
367             Event can be one of C<drink> (or C<d> for short), C<eat>, C<urinate> (or C<u> or
368             C<urin> for short), C<poop>, or C<comment> (or C<c> for short).
369              
370             Extra is a free-form text, but you can use C<word>=C<text> syntax to write
371             key-value pairs. Some recognized keys are: C<vol>, C<comment>, C<type>,
372             C<urgency>, C<color>.
373              
374             Some other information are scraped for writing convenience:
375              
376             /\b(\d+)ml\b/ for volume
377             /\bv(\d+)\b/ for volume
378             /\bu([0-9]|10)\b/ for urgency (1-10)
379             /\bc([0-6])\b/ for clear to dark orange color (0=clear, 1=light yellow, 2=yellow, 3=dark yellow, 4=amber, 5=brown, 6=red)
380              
381             Example C<drink> entry (all are equivalent):
382              
383             07:30 drink: vol=300ml
384             0730 drink 300ml
385             0730 d 300ml
386              
387             Example C<urinate> entry (all are equivalent):
388              
389             07:45 urinate: vol=200ml urgency=4 color=light yellow comment=at home
390             0745 urin 200ml urgency=4 color=light yellow comment=at home
391             0745 u 200ml u4 c1 comment=at home
392              
393             =head3 Urination entries
394              
395             A urination entry is an entry with event C<urination> (can be written as just
396             C<u> or C<urin>). At least volume is required, can be written in ml unit e.g.
397             C<300ml>, or using C<vNUMBER> e.g. C<v300>, or using C<vol> key, e.g.
398             C<vol=300>. Example:
399              
400             1230 u 200ml
401              
402             You can also enter color, using C<color=NAME> or C<c0>..C<c6> for short. These
403             colors from 7-color-in-test-tube urine color chart is recommended:
404             L<https://www.dreamstime.com/urine-color-chart-test-tubes-medical-vector-illustration-image163017644>
405             or
406             L<https://stock.adobe.com/images/urine-color-chart-urine-in-test-tubes-medical-vector/299230365>:
407              
408             0 - clear
409             1 - light yellow
410             2 - yellow
411             3 - dark yellow
412             4 - amber
413             5 - brown
414             6 - red
415              
416             Example:
417              
418             1230 u 200ml c2
419              
420             You can also enter urgency information using C<urgency=NUMBER> or C<u0>..C<u10>,
421             which is a number from 0 (not urgent at all) to 10 (most urgent). Example:
422              
423             1230 u 200ml c2 u4
424              
425             =head2 Drink (fluid intake) entries
426              
427             A drink (fluid intake) entry is an entry with event C<drink> (can be written as
428             just C<d>). At least volume is required, can be written in ml unit e.g.
429             C<300ml>, or using C<vNUMBER> e.g. C<v300>, or using C<vol> key, e.g.
430             C<vol=300>. Example:
431              
432             1300 d 300ml
433              
434             You can also input the kind of drink using C<type=NAME>. If type is not
435             specified, C<water> is assumed. Example:
436              
437             1300 d 300ml type=coffee
438              
439             =head2 Eat (food intake) entries
440              
441             The diary can also contain food intake entries. Currently volume or weight of
442             food (or volume of fluid, by percentage of food volume) is not measured or
443             displayed. You can put comments here for more detailed information. The table
444             generator will create a row for each food intake, but will just display the
445             time, type ("food"), and comment columns.
446              
447             =head1 KEYWORDS
448              
449             voiding diary, bladder diary
450              
451             =head1 FUNCTIONS
452              
453              
454             =head2 gen_bladder_diary_table_from_entries
455              
456             Usage:
457              
458             gen_bladder_diary_table_from_entries(%args) -> [status, msg, payload, meta]
459              
460             Create bladder diary table from bladder diary entries.
461              
462             This function is not exported by default, but exportable.
463              
464             Arguments ('*' denotes required arguments):
465              
466             =over 4
467              
468             =item * B<date> => I<date>
469              
470             =item * B<entries>* => I<str>
471              
472             =item * B<yesterday_last_urination_entry> => I<str>
473              
474              
475             =back
476              
477             Returns an enveloped result (an array).
478              
479             First element (status) is an integer containing HTTP status code
480             (200 means OK, 4xx caller error, 5xx function error). Second element
481             (msg) is a string containing error message, or 'OK' if status is
482             200. Third element (payload) is optional, the actual result. Fourth
483             element (meta) is called result metadata and is optional, a hash
484             that contains extra information.
485              
486             Return value: (any)
487              
488             =head1 HOMEPAGE
489              
490             Please visit the project's homepage at L<https://metacpan.org/release/Health-BladderDiary-GenTable>.
491              
492             =head1 SOURCE
493              
494             Source repository is at L<https://github.com/perlancar/perl-Health-BladderDiary-GenTable>.
495              
496             =head1 BUGS
497              
498             Please report any bugs or feature requests on the bugtracker website L<https://github.com/perlancar/perl-Health-BladderDiary-GenTable/issues>
499              
500             When submitting a bug or request, please include a test-file or a
501             patch to an existing test-file that illustrates the bug or desired
502             feature.
503              
504             =head1 SEE ALSO
505              
506             =head1 AUTHOR
507              
508             perlancar <perlancar@cpan.org>
509              
510             =head1 COPYRIGHT AND LICENSE
511              
512             This software is copyright (c) 2020 by perlancar@cpan.org.
513              
514             This is free software; you can redistribute it and/or modify it under
515             the same terms as the Perl 5 programming language system itself.
516              
517             =cut