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