LP#1705524: Honor timezone of the acting library where appropriate
authorMike Rylander <mrylander@gmail.com>
Wed, 21 Jun 2017 18:03:29 +0000 (14:03 -0400)
committerMike Rylander <mrylander@gmail.com>
Thu, 20 Jul 2017 15:20:53 +0000 (11:20 -0400)
This is a followup to the work done in bug 1485374, where we added the ability
for the client to specify a timezone in which timestamps should be interpreted
in business logic and the database.

Most specifically, this work focuses on circulation due dates and the closed
date editor. Due dates, where displayed using stock templates (including
receipt templates) and used for fine calculation, are now manipulated in the
library's configured timezone. This is controlled by the new 'lib.timezone'
YAOUS, loaded from the server when required. Additionally, closings are
recorded in the library's timezone so that so that due date calculation is more
accurate. The closed date editor is also taught how to display closings in the
closed library's timezone. Closed date entries also explicitly record if they
are a full day closing, or a multi-day closing. This significantly simplifies
the editor, and may be useful in other contexts.

To accomplish this, we use the moment.js library and the moment-timezone addon.
This is necessary because the stock AngularJS date filter does not understand
locale-aware timezone values, which are required to support DST. A simple
mapper translates the differences in format values from AngularJS date to
moment.js.

Of special note are a set of new filters used for formatting timestamps under
certain circumstances. The new egOrgDateInContext, egOrgDate, and egDueDate
filters provide the functionality, and autogrid is enhanced to make use of
these where applicable. egGrid and egGridField are also taught to accept
default and field-specific options for applying date filters. These filters may
be useful in other or related contexts.

The egDueDate filter, used for all existing displays of due date via Angular
code, intentionally interprets timestamps in two different ways WRT timezone,
based on the circulation duration. If the duration is day-granular (that is,
the number of seconds in the duration is divisible by 86,400, or 24 hours worth
of seconds) then the date is interpreted as being in the circulation library's
timezone. If it is an hourly loan (any duration that does not meet the
day-granular criterium) then it is instead displayed in the client's timezone,
just as all other timestamps currently are, because of the work in 1485374.

The OPAC is adjusted to always display the due date in the circulating
library's timezone. Because the OPAC displays only the date portion of the due
date field, this difference is currently considered acceptable. If this proves
to be a problem in the future, a minor adjustment can be made to match the
egDueDate filter logic.

This work, as with 1485374 was funded by SITKA, and we thank them for their
partnership in making this happen!

Signed-off-by: Mike Rylander <mrylander@gmail.com>
35 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/CircCommon.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/CDBI/actor.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Util.pm
Open-ILS/src/sql/Pg/005.schema.actors.sql
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/upgrade/XXXX.data.tz_org_setting.sql [new file with mode: 0644]
Open-ILS/src/templates/opac/myopac/circ_history.tt2
Open-ILS/src/templates/opac/myopac/circs.tt2
Open-ILS/src/templates/opac/myopac/main.tt2
Open-ILS/src/templates/opac/parts/record/copy_table.tt2
Open-ILS/src/templates/staff/base_js.tt2
Open-ILS/src/templates/staff/cat/item/t_circ_list_pane.tt2
Open-ILS/src/templates/staff/cat/item/t_list.tt2
Open-ILS/src/templates/staff/cat/item/t_summary_pane.tt2
Open-ILS/src/templates/staff/circ/checkin/t_checkin_table.tt2
Open-ILS/src/templates/staff/circ/patron/t_bills_list.tt2
Open-ILS/src/templates/staff/circ/patron/t_checkout.tt2
Open-ILS/src/templates/staff/circ/patron/t_items_out.tt2
Open-ILS/src/templates/staff/circ/patron/t_xact_details.tt2
Open-ILS/src/templates/staff/circ/renew/t_renew.tt2
Open-ILS/src/templates/staff/share/print_templates/t_checkout.tt2
Open-ILS/src/templates/staff/share/print_templates/t_items_out.tt2
Open-ILS/src/templates/staff/share/print_templates/t_renew.tt2
Open-ILS/src/templates/staff/share/t_autogrid.tt2
Open-ILS/web/js/ui/default/staff/Gruntfile.js
Open-ILS/web/js/ui/default/staff/admin/workstation/app.js
Open-ILS/web/js/ui/default/staff/cat/item/app.js
Open-ILS/web/js/ui/default/staff/circ/services/circ.js
Open-ILS/web/js/ui/default/staff/package.json
Open-ILS/web/js/ui/default/staff/services/grid.js
Open-ILS/web/js/ui/default/staff/services/ui.js
Open-ILS/xul/staff_client/server/admin/closed_dates.js
Open-ILS/xul/staff_client/server/admin/closed_dates.xhtml

index eaaa780..bb6e50e 100644 (file)
@@ -3203,6 +3203,8 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
                        <field name="id" reporter:datatype="id" />
                        <field name="org_unit" reporter:datatype="org_unit"/>
                        <field name="reason" reporter:datatype="text"/>
+                       <field name="full_day" reporter:datatype="bool"/>
+                       <field name="multi_day" reporter:datatype="bool"/>
                </fields>
                <links>
                        <link field="org_unit" reltype="has_a" key="id" map="" class="aou"/>
index bcfc81b..9a24589 100644 (file)
@@ -2062,7 +2062,7 @@ sub basic_opac_copy_query {
                 {column => 'id', alias => 'call_number'},
                 {column => 'owning_lib', alias => 'call_number_owning_lib'}
             ],
-            circ => ['due_date'],
+            circ => ['due_date',{column => 'circ_lib', alias => 'circ_circ_lib'}],
             acnp => [
                 {column => 'label', alias => 'call_number_prefix_label'},
                 {column => 'id', alias => 'call_number_prefix'}
index 1675712..171b313 100644 (file)
@@ -576,6 +576,9 @@ sub generate_fines {
                 $c->$circ_lib_method, 'circ.fines.truncate_to_max_fine');
             $truncate_to_max_fine = $U->is_true($truncate_to_max_fine);
 
+            my $tz = $U->ou_ancestor_setting_value(
+                $c->$circ_lib_method, 'lib.timezone') || 'local';
+
             my ($latest_billing_ts, $latest_amount) = ('',0);
             for (my $bill = 1; $bill <= $pending_fine_count; $bill++) {
     
@@ -592,8 +595,8 @@ sub generate_fines {
                     last;
                 }
                 
-                # XXX Use org time zone (or default to 'local') once we have the ou setting built for that
-                my $billing_ts = DateTime->from_epoch( epoch => $last_fine, time_zone => 'local' );
+                # Use org time zone (or default to 'local')
+                my $billing_ts = DateTime->from_epoch( epoch => $last_fine, time_zone => $tz );
                 my $current_bill_count = $bill;
                 while ( $current_bill_count ) {
                     $billing_ts->add( seconds_to_interval_hash( $fine_interval ) );
index 4572dcc..a4047f7 100644 (file)
@@ -100,7 +100,7 @@ use base qw/actor/;
 
 __PACKAGE__->table( 'actor_org_unit_closed' );
 __PACKAGE__->columns( Primary => qw/id/);
-__PACKAGE__->columns( Essential => qw/org_unit close_start close_end reason/);
+__PACKAGE__->columns( Essential => qw/org_unit close_start close_end reason full_day multi_day/);
 
 
 #-------------------------------------------------------------------------------
index cf1f7e9..199ff7a 100644 (file)
@@ -240,6 +240,7 @@ sub init_ro_object_cache {
     # turns an ISO date into something TT can understand
     $locale_subs->{parse_datetime} = sub {
         my $date = shift;
+        my $context_org = shift; # optional, for setting timezone via YAOUS
 
         # Calling parse_datetime() with empty $date will lead to Internal Server Error
         return '' if (!defined($date) or $date eq '');
@@ -259,6 +260,11 @@ sub init_ro_object_cache {
         my $cleansed_date = cleanse_ISO8601($date);
 
         $date = DateTime::Format::ISO8601->new->parse_datetime($cleansed_date);
+        if ($context_org) {
+            $context_org = $context_org->id if ref($context_org);
+            my $tz = $locale_subs->{get_org_setting}->($context_org,'lib.timezone');
+            $date->set_time_zone($tz) if ($tz);
+        }
         return sprintf(
             "%0.2d:%0.2d:%0.2d %0.2d-%0.2d-%0.4d",
             $date->hour,
index 90a351d..d96f83d 100644 (file)
@@ -483,6 +483,8 @@ CREATE TABLE actor.org_unit_closed (
        org_unit        INT                             NOT NULL REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED,
        close_start     TIMESTAMP WITH TIME ZONE        NOT NULL,
        close_end       TIMESTAMP WITH TIME ZONE        NOT NULL,
+    full_day    BOOLEAN                     NOT NULL DEFAULT FALSE,
+    multi_day   BOOLEAN                     NOT NULL DEFAULT FALSE,
        reason          TEXT
 );
 
index ffa6732..7052d43 100644 (file)
@@ -16876,3 +16876,16 @@ INSERT into config.org_unit_setting_type (
     )
     ,'string'
 );
+
+INSERT into config.org_unit_setting_type
+( name, grp, label, description, datatype ) VALUES
+
+( 'lib.timezone', 'lib',
+    oils_i18n_gettext('lib.timezone',
+        'Library time zone',
+        'coust', 'label'),
+    oils_i18n_gettext('lib.timezone',
+        'Define the time zone in which a library physically resides',
+        'coust', 'description'),
+    'string');
+
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.tz_org_setting.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.tz_org_setting.sql
new file mode 100644 (file)
index 0000000..53b1b1a
--- /dev/null
@@ -0,0 +1,27 @@
+BEGIN;
+
+INSERT into config.org_unit_setting_type
+( name, grp, label, description, datatype ) VALUES
+
+( 'lib.timezone', 'lib',
+    oils_i18n_gettext('lib.timezone',
+        'Library time zone',
+        'coust', 'label'),
+    oils_i18n_gettext('lib.timezone',
+        'Define the time zone in which a library physically resides',
+        'coust', 'description'),
+    'string');
+
+ALTER TABLE actor.org_unit_closed ADD COLUMN full_day BOOLEAN DEFAULT FALSE;
+ALTER TABLE actor.org_unit_closed ADD COLUMN multi_day BOOLEAN DEFAULT FALSE;
+
+UPDATE actor.org_unit_closed SET multi_day = TRUE
+  WHERE close_start::DATE <> close_end::DATE;
+
+UPDATE actor.org_unit_closed SET full_day = TRUE
+  WHERE close_start::DATE = close_end::DATE
+        AND SUBSTRING(close_start::time::text FROM 1 FOR 8) = '00:00:00'
+        AND SUBSTRING(close_end::time::text FROM 1 FOR 8) = '23:59:59';
+
+COMMIT;
+
index 8e989c4..7aa8d72 100644 (file)
                             [% date.format(ctx.parse_datetime(circ.circ.xact_start),DATE_FORMAT); %]
                         </td>
                         <td>
-                            [% date.format(ctx.parse_datetime(circ.circ.due_date),DATE_FORMAT); %]
+                            [% date.format(ctx.parse_datetime(circ.circ.due_date, circ.circ.circ_lib),DATE_FORMAT); %]
                         </td>
                         <td>
                             [% IF circ.circ.checkin_time;
index bd93d7b..02e8124 100644 (file)
                             [% circ.circ.renewal_remaining %]
                         </td>
                         [%
-                            due_date = ctx.parse_datetime(circ.circ.due_date);
+                            due_date = ctx.parse_datetime(circ.circ.due_date, circ.circ.circ_lib);
                             due_class = (date.now > date.format(due_date, '%s')) ? 'error' : '';
                         %]
                         <td name="due_date" class='[% due_class %]'>
index 9dc672e..91133fc 100644 (file)
@@ -72,8 +72,9 @@
                     </td>
                     <td name='myopac_circ_trans_due'>
                         [% ts = f.xact.circulation.due_date || f.xact.reservation.end_time || 0;
+                           due_org = f.xact.circulation.circ_lib || f.xact.reservation.pickup_lib;
                         IF ts;
-                            date.format(ctx.parse_datetime(ts), DATE_FORMAT);
+                            date.format(ctx.parse_datetime(ts, due_org), DATE_FORMAT);
                         END %]
                     </td>
                     <td name='myopac_circ_trans_finished'>
index ec8dda4..6bf316c 100644 (file)
@@ -73,7 +73,7 @@ IF has_copies;
     <td>[% bib.target_copy.barcode | html %]</td>
     <td>[% copy_info.copy_location | html %]</td>
     <td>[% copy_info.copy_status | html %]</td>
-    <td>[% copy_info.due_date | html %]</td>
+    <td>[% date.format(ctx.parse_datetime(copy_info.due_date, copy_info.circ_circ_lib),DATE_FORMAT) %]</td>
 </tr>
    [%- END; # FOREACH peer
 END; # FOREACH bib
@@ -215,7 +215,7 @@ END; # FOREACH bib
             <td>[%
                 IF copy_info.due_date;
                     date.format(
-                        ctx.parse_datetime(copy_info.due_date),
+                        ctx.parse_datetime(copy_info.due_date, copy_info.circ_circ_lib),
                         DATE_FORMAT
                     );
                 ELSE;
index 82b662e..1880f66 100644 (file)
@@ -19,6 +19,8 @@
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/iframeResizer.min.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/ng-order-object-by.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/lovefield.min.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/moment-with-locales.min.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/moment-timezone-with-data.min.js"></script>
 
 <!-- IDL / opensrf (network) -->
 <script src="[% ctx.media_prefix %]/js/dojo/opensrf/JSON_v1.js"></script>
index 3cab22b..422830f 100644 (file)
@@ -39,7 +39,7 @@
     <div class="flex-cell">[% l('Check Out Date') %]</div>
     <div class="flex-cell well">{{circ.xact_start() | date:egDateAndTimeFormat}}</div>
     <div class="flex-cell">[% l('Due Date') %]</div>
-    <div class="flex-cell well">{{circ.due_date() | date:egDateAndTimeFormat}}</div>
+    <div class="flex-cell well">{{circ.due_date() | egDueDate:egDateAndTimeFormat:circ.circ_lib():circ.duration()}}</div>
     <div class="flex-cell">[% l('Stop Fines Time') %]</div>
     <div class="flex-cell well">{{circ.stop_fines_time() | date:egDateAndTimeFormat}}</div>
     <div class="flex-cell">[% l('Checkin Time') %]</div>
index 176527c..7cfb627 100644 (file)
@@ -62,7 +62,7 @@
   <eg-grid-field label="[% l('Alert Message') %]"  path='alert_message' visible></eg-grid-field>
   <eg-grid-field label="[% l('Barcode') %]"        path='barcode' visible></eg-grid-field>
   <eg-grid-field label="[% l('Call Number') %]"    path="call_number.label" visible></eg-grid-field>
-  <eg-grid-field label="[% l('Due Date') %]"       path="_circ.due_date" datatype="timestamp" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Due Date') %]"       path="_circ.due_date" datecontext="_circ_lib" dateonlyinterval="_duration" datatype="timestamp" visible></eg-grid-field>
 
   <eg-grid-field label="[% l('Location') %]"       path="location.name" visible></eg-grid-field>
   <eg-grid-field label="[% l('Copy Status') %]"    path="status.name" visible></eg-grid-field>
index 9c60191..4cc77e4 100644 (file)
@@ -34,7 +34,7 @@
     <div class="flex-cell well">{{copy.call_number().label()}}</div>
 
     <div class="flex-cell">[% l('Due Date') %]</div>
-    <div class="flex-cell well">{{circ.due_date() | date:egDateAndTimeFormat}}</div>
+    <div class="flex-cell well">{{circ.due_date() | egDueDate:egDateAndTimeFormat:circ.circ_lib():circ.duration()}}</div>
   </div>
 
   <div class="flex-row">
index e7d809b..2ceeeaf 100644 (file)
@@ -70,7 +70,7 @@
   </eg-grid-field>
 
   <eg-grid-field label="[% l('Due Date') %]"    
-    path='circ.due_date' datatype="timestamp" hidden></eg-grid-field>
+    path='circ.due_date' dateonlyinterval="duration" datecontext="circ_lib" datatype="timestamp" hidden></eg-grid-field>
 
   <eg-grid-field label="[% l('Author') %]"      
     path="author" hidden></eg-grid-field>
index 6d176be..7d71951 100644 (file)
     name="payment_pending"></eg-grid-field>
 
   <!-- import all circ fields, hidden by default -->
+  <eg-grid-field path='circulation.circ_lib' required hidden></eg-grid-field>
+  <eg-grid-field path='circulation.duration' required hidden></eg-grid-field>
+  <eg-grid-field path='circulation.due_date' required hidden>
+    {{ circulation.due_date | egDueDate:$root.egDateAndTimeFormat:circulation.circ_lib:circulation.duration}}
+  </eg-grid-field>
   <eg-grid-field path='circulation.*' hidden> </eg-grid-field>
 
   <eg-grid-field path='circulation.target_copy.*' hidden> </eg-grid-field>
index 1abdf45..71fcd78 100644 (file)
@@ -88,7 +88,7 @@
     path="acn.label"></eg-grid-field>
 
   <eg-grid-field label="[% l('Due Date') %]"    
-    path='circ.due_date' datatype="timestamp"></eg-grid-field>
+    path='circ.due_date' datecontext="circ_lib" dateonlyinterval="duration" datatype="timestamp"></eg-grid-field>
 
   <eg-grid-field label="[% l('Family Name') %]"    
     path='au.family_name'></eg-grid-field>
index f7b4020..03f3f7b 100644 (file)
@@ -38,7 +38,7 @@
   <eg-grid-field label="[% l('Item Type') %]" path='item_type.name'></eg-grid-field>
   <eg-grid-field label="[% l('Checkout Library') %]" path='circ_lib.shortname'></eg-grid-field>
   <eg-grid-field label="[% l('Checkout Date') %]" path='circ_time' datatype="timestamp"></eg-grid-field>
-  <eg-grid-field label="[% l('Due Date') %]" path='duedate' datatype="timestamp"></eg-grid-field>
+  <eg-grid-field label="[% l('Due Date') %]" path='duedate' dateformat="shortDate" datatype="timestamp"></eg-grid-field>
   <eg-grid-field label="[% l('Checkout Staff') %]" path='staff.usrname'></eg-grid-field>
 </eg-grid>
 
@@ -80,7 +80,7 @@
       {{item.target_copy().barcode()}}
     </a>
   </eg-grid-field>
-  <eg-grid-field label="[% l('Due Date') %]" path='due_date' datatype="timestamp"></eg-grid-field>
+  <eg-grid-field label="[% l('Due Date') %]" path='due_date' datefilter="egDueDate" dateonlyinterval="duration" datecontext="circ_lib" datatype="timestamp"></eg-grid-field>
   <eg-grid-field label="[% l('Workstation') %]" path='workstation.name'></eg-grid-field>
   <eg-grid-field label="[% l('Checkin Workstation') %]" path='checkin_workstation.name'></eg-grid-field>
   <eg-grid-field label="[% l('Checkout / Renewal Library') %]" path='circ_lib.shortname'></eg-grid-field>
index 5f13cc7..032a726 100644 (file)
@@ -25,7 +25,7 @@
   <div class="col-md-2 strong-text">[% l('Total Billed') %]</div>
   <div class="col-md-2">{{xact.summary().balance_owed() | currency}}</div>
   <div class="col-md-2 strong-text">[% l('Due Date') %]</div>
-  <div class="col-md-2">{{xact.circulation().due_date() | date:$root.egDateAndTimeFormat}}</div>
+  <div class="col-md-2">{{xact.circulation().due_date() | egDueDate:$root.egDateAndTimeFormat:xact.circulation().circ_lib():xact.circulation().duration()}}</div>
 </div>
 <div class="row">
   <div class="col-md-2 strong-text">[% l('Finish') %]</div>
index 2a7871e..326fe22 100644 (file)
@@ -91,7 +91,7 @@
     path="acn.label"></eg-grid-field>
 
   <eg-grid-field label="[% l('Due Date') %]"    
-    path='circ.due_date' datatype="timestamp"></eg-grid-field>
+    path='circ.due_date' datecontext="circ_lib" dateonlyinterval="duration" datatype="timestamp"></eg-grid-field>
 
   <eg-grid-field label="[% l('Family Name') %]"    
     path='au.family_name'></eg-grid-field>
index 808f52c..327a05e 100644 (file)
@@ -18,7 +18,7 @@ Template for printing checkout receipts; fields available include:
       <div>{{checkout.title}}</div>
       <div>[% l('Barcode: [_1] Due: [_2]', 
         '{{checkout.copy.barcode}}',
-        '{{checkout.circ.due_date | date:$root.egDateAndTimeFormat}}') %]</div>
+        '{{checkout.circ.due_date | egDueDate:$root.egDateAndTimeFormat:checkout.circ.circ_lib:checkout.circ.duration}}') %]</div>
     </li>
   </ol>
   <hr/>
index 91e565a..9d49e15 100644 (file)
@@ -18,7 +18,7 @@ Fields include:
       <div>{{checkout.title}}</div>
       <div>[% l('Barcode: [_1] Due: [_2]', 
         '{{checkout.copy.barcode}}',
-        '{{checkout.circ.due_date | date:$root.egDateAndTimeFormat}}') %]</div>
+        '{{checkout.circ.due_date | egDueDate:$root.egDateAndTimeFormat:checkout.circ.circ_lib:checkout.circ.duration}}') %]</div>
     </li>
   </ol>
   <hr/>
index 3e17647..9d4510f 100644 (file)
@@ -17,7 +17,7 @@ Template for printing a renewal receipt. Fields include:
       <div>{{renewal.title}}</div>
       <div>[% l('Barcode: [_1] Due: [_2]', 
         '{{renewal.copy.barcode}}',
-        '{{renewal.circ.due_date | date:$root.egDateAndTimeFormat}}') %]</div>
+        '{{renewal.circ.due_date | egDueDate:$root.egDateAndTimeFormat:renewal.circ.circ_lib:renewal.circ.duration}}') %]</div>
     </li>
   </ol>
   <hr/>
index cf5ac46..0942d1c 100644 (file)
           <!-- otherwise, simply display the item value, which may 
                pass through datatype-specific filtering. -->
           <span ng-if="!col.template" style="padding-left:5px; padding-right:10px;">
-            {{itemFieldValue(item, col) | egGridValueFilter:col}}
+            {{itemFieldValue(item, col) | egGridValueFilter:col:item}}
           </span>
       </div>
     </div>
index e594196..8a885a1 100644 (file)
@@ -38,7 +38,9 @@ module.exports = function(grunt) {
             'node_modules/iframe-resizer/js/iframeResizer.contentWindow.min.js',
             'node_modules/angular-order-object-by/src/ng-order-object-by.js',
             'node_modules/lovefield/dist/lovefield.min.js',
-            'node_modules/lovefield/dist/lovefield.min.js.map'
+            'node_modules/lovefield/dist/lovefield.min.js.map',
+            'node_modules/moment/min/moment-with-locales.min.js',
+            'node_modules/moment-timezone/builds/moment-timezone-with-data.min.js'
           ]
         }]
       },
@@ -145,6 +147,8 @@ module.exports = function(grunt) {
             'build/js/angular-tree-control.js',
             'build/js/ngToast.min.js',
             'build/js/lovefield.min.js',
+            'bulid/js/moment-with-locales.min.js',
+            'build/js/moment-timezone-with-data.min.js',
             // NOTE: OpenSRF must be installed
             // XXX: Should not be hard-coded
             '/openils/lib/javascript/JSON_v1.js',
index 15c1fcb..aaa3b34 100644 (file)
@@ -449,6 +449,8 @@ function($scope , $q , egCore , ngToast) {
         checkins : [
             {
                 due_date : new Date().toISOString(),
+                circ_lib : 1,
+                duration : '7 days',
                 target_copy : seed_copy,
                 copy_barcode : seed_copy.barcode,
                 call_number : seed_copy.call_number,
@@ -460,6 +462,8 @@ function($scope , $q , egCore , ngToast) {
             {
                 circ : {
                     due_date : new Date().toISOString(),
+                    circ_lib : 1,
+                    duration : '7 days'
                 },
                 copy : seed_copy,
                 title : seed_record.title,
index 002061e..f0faad5 100644 (file)
@@ -179,6 +179,8 @@ function(egCore , egCirc , $uibModal , $q , $timeout , $window , egConfirmDialog
                 if (copyData.circ) {
                     flatCopy._circ = egCore.idl.toHash(copyData.circ, true);
                     flatCopy._circ_summary = egCore.idl.toHash(copyData.circ_summary, true);
+                    flatCopy._circ_lib = copyData.circ.circ_lib();
+                    flatCopy._duration = copyData.circ.duration();
                 }
                 flatCopy.index = service.index++;
                 service.copies.unshift(flatCopy);
index c217f25..4b70bf1 100644 (file)
@@ -273,6 +273,9 @@ function($uibModal , $q , egCore , egAlertDialog , egConfirmDialog,
         data.isbn = final_resp.evt[0].isbn;
         data.route_to = final_resp.evt[0].route_to;
 
+        if (payload.circ) data.duration = payload.circ.duration();
+        if (payload.circ) data.circ_lib = payload.circ.circ_lib();
+
         // for checkin, the mbts lives on the main circ
         if (payload.circ && payload.circ.billable_transaction())
             data.mbts = payload.circ.billable_transaction().summary();
index 91c88fa..cd3a9bb 100644 (file)
@@ -17,6 +17,8 @@
     "angular-tree-control": "~0.2.28",
     "angular-order-object-by": "rxfork/ngOrderObjectBy#npm",
     "lovefield": "*",
+    "moment": "*",
+    "moment-timezone": "*",
     "bootstrap": "~3.3.6",
     "grunt": "~0.4.4",
     "grunt-cli": "^0.1.13",
index 5fe4355..3b8ae90 100644 (file)
@@ -64,6 +64,9 @@ angular.module('egGridMod',
             menuLabel : '@',
 
             dateformat : '@', // optional: passed down to egGridValueFilter
+            datecontext: '@', // optional: passed down to egGridValueFilter to choose TZ
+            datefilter: '@', // optional: passed down to egGridValueFilter to choose specialized date filters
+            dateonlyinterval: '@', // optional: passed down to egGridValueFilter to choose a "better" format
 
             // Hash of control functions.
             //
@@ -182,7 +185,10 @@ angular.module('egGridMod',
                     defaultToHidden : (features.indexOf('-display') > -1),
                     defaultToNoSort : (features.indexOf('-sort') > -1),
                     defaultToNoMultiSort : (features.indexOf('-multisort') > -1),
-                    defaultDateFormat : $scope.dateformat
+                    defaultDateFormat : $scope.dateformat,
+                    defaultDateContext : $scope.datecontext,
+                    defaultDateFilter : $scope.datefilter,
+                    defaultDateOnlyInterval : $scope.dateonlyinterval
                 });
                 $scope.canMultiSelect = (features.indexOf('-multiselect') == -1);
 
@@ -986,7 +992,7 @@ angular.module('egGridMod',
                             // bare value
                             var val = grid.dataProvider.itemFieldValue(item, col);
                             // filtered value (dates, etc.)
-                            val = $filter('egGridValueFilter')(val, col);
+                            val = $filter('egGridValueFilter')(val, col, item);
                             csvStr += grid.csvDatum(val);
                             csvStr += ',';
                         }
@@ -1089,6 +1095,9 @@ angular.module('egGridMod',
             flex  : '@',  // optional; default flex width
             align  : '@',  // optional; default alignment, left/center/right
             dateformat : '@', // optional: passed down to egGridValueFilter
+            datecontext: '@', // optional: passed down to egGridValueFilter to choose TZ
+            datefilter: '@', // optional: passed down to egGridValueFilter to choose specialized date filters
+            dateonlyinterval: '@', // optional: passed down to egGridValueFilter to choose a "better" format
 
             // if a field is part of an IDL object, but we are unable to
             // determine the class, because it's nested within a hash
@@ -1174,6 +1183,7 @@ angular.module('egGridMod',
         cols.defaultToNoSort = args.defaultToNoSort;
         cols.defaultToNoMultiSort = args.defaultToNoMultiSort;
         cols.defaultDateFormat = args.defaultDateFormat;
+        cols.defaultDateContext = args.defaultDateContext;
 
         // resets column width, visibility, and sort behavior
         // Visibility resets to the visibility settings defined in the 
@@ -1379,6 +1389,9 @@ angular.module('egGridMod',
                 multisortable    : colSpec.multisortable,
                 nonmultisortable : colSpec.nonmultisortable,
                 dateformat       : colSpec.dateformat,
+                datecontext      : colSpec.datecontext,
+                datefilter      : colSpec.datefilter,
+                dateonlyinterval : colSpec.dateonlyinterval,
                 parentIdlClass   : colSpec.parentIdlClass
             };
         }
@@ -1422,6 +1435,18 @@ angular.module('egGridMod',
                 column.dateformat = cols.defaultDateFormat;
             }
 
+            if (cols.defaultDateOnlyInterval && ! column.dateonlyinterval) {
+                column.dateonlyinterval = cols.defaultDateOnlyInterval;
+            }
+
+            if (cols.defaultDateContext && ! column.datecontext) {
+                column.datecontext = cols.defaultDateContext;
+            }
+
+            if (cols.defaultDateFilter && ! column.datefilter) {
+                column.datefilter = cols.defaultDateFilter;
+            }
+
             cols.columns.push(column);
 
             // Track which columns are visible by default in case we
@@ -1829,12 +1854,12 @@ angular.module('egGridMod',
  *    value.  (Though we could manually translate instead..)
  * Others likely to follow...
  */
-.filter('egGridValueFilter', ['$filter', function($filter) {                         
-    return function(value, column) {                                             
-        switch(column.datatype) {                                                
-            case 'bool':                                                       
+.filter('egGridValueFilter', ['$filter','egCore', function($filter,egCore) {
+    var GVF = function(value, column, item) {
+        switch(column.datatype) {
+            case 'bool':
                 switch(value) {
-                    // Browser will translate true/false for us                    
+                    // Browser will translate true/false for us
                     case 't' : 
                     case '1' :  // legacy
                     case true:
@@ -1846,16 +1871,26 @@ angular.module('egGridMod',
                     // value may be null,  '', etc.
                     default : return '';
                 }
-            case 'timestamp':                                                  
-                // canned angular date filter FTW                              
-                if (!column.dateformat) 
-                    column.dateformat = 'shortDate';
-                return $filter('date')(value, column.dateformat);
-            case 'money':                                                  
+            case 'timestamp':
+                var interval = angular.isFunction(item[column.dateonlyinterval])
+                    ? item[column.dateonlyinterval]()
+                    : item[column.dateonlyinterval];
+
+                var context = angular.isFunction(item[column.datecontext])
+                    ? item[column.datecontext]()
+                    : item[column.datecontext];
+
+                var date_filter = column.datefilter || 'egOrgDateInContext';
+
+                return $filter(date_filter)(value, column.dateformat, context, interval);
+            case 'money':
                 return $filter('currency')(value);
-            default:                                                           
-                return value;                                                  
-        }                                                                      
-    }                                                                          
+            default:
+                return value;
+        }
+    };
+
+    GVF.$stateful = true;
+    return GVF;
 }]);
 
index dfa1a1a..25f650b 100644 (file)
@@ -84,6 +84,135 @@ function($timeout , $parse) {
     };
 })
 
+// 'egOrgDate' filter
+// Uses moment.js and moment-timezone.js to put dates into the most appropriate
+// timezone for a given (optional) org unit based on its lib.timezone setting
+.filter('egOrgDate',['egCore',
+             function(egCore) {
+
+    var formatMap = {
+        short  : 'l LT',
+        medium : 'lll',
+        long   : 'LLL',
+        full   : 'LLLL',
+
+        shortDate  : 'l',
+        mediumDate : 'll',
+        longDate   : 'LL',
+        fullDate   : 'LL',
+
+        shortTime  : 'LT',
+        mediumTime : 'LTS'
+    };
+
+    var formatReplace = [
+        [ /yyyy/g, 'YYYY' ],
+        [ /yy/g,   'YY'   ],
+        [ /y/g,    'Y'    ],
+        [ /ww/g,   'WW'   ],
+        [ /w/g,    'W'    ],
+        [ /dd/g,   'DD'   ],
+        [ /d/g,    'D'    ],
+        [ /sss/g,  'SSS'  ],
+        [ /EEEE/g, 'dddd' ],
+        [ /EEE/g,  'ddd'  ],
+        [ /Z/g,    'ZZ'   ]
+    ];
+
+    var tzcache = {'*':null};
+
+    function eg_date_filter (date, format, ouID) {
+        var fmt = formatMap[format] || format;
+        angular.forEach(formatReplace, function (r) {
+            fmt = fmt.replace(r[0],r[1]);
+        });
+
+        var d;
+        if (ouID) {
+            if (angular.isObject(ouID)) {
+                if (angular.isFunction(ouID.id)) {
+                    ouID = ouID.id();
+                } else {
+                    ouID = ouID.id;
+                }
+            }
+    
+            if (tzcache[ouID] && tzcache[ouID] !== '-') {
+                d = moment(date).tz(tzcache[ouID]);
+            } else {
+    
+                if (!tzcache[ouID]) {
+                    tzcache[ouID] = '-';
+
+                    egCore.org.settings('lib.timezone', ouID)
+                    .then(function(s) {
+                        tzcache[ouID] = s['lib.timezone'] || OpenSRF.tz;
+                    });
+                }
+
+                d = moment(date);
+            }
+        } else {
+            d = moment(date);
+        }
+
+        return d.isValid() ? d.format(fmt) : '';
+    }
+
+    eg_date_filter.$stateful = true;
+
+    return eg_date_filter;
+}])
+
+// 'egOrgDateInContext' filter
+// Uses the egOrgDate filter to make time and date location aware, and further
+// modifies the format if one of [short, medium, long, full] to show only the
+// date if the optional interval parameter is day-granular.  This is
+// particularly useful for due dates on circulations.
+.filter('egOrgDateInContext',['$filter','egCore',
+                      function($filter , egCore) {
+
+    function eg_context_date_filter (date, format, orgID, interval) {
+        var fmt = format;
+        if (!fmt) fmt = 'shortDate';
+
+        // if this is a simple, one-word format, and it doesn't say "Date" in it...
+        if (['short','medium','long','full'].filter(function(x){return fmt == x}).length > 0 && interval) {
+            var secs = egCore.date.intervalToSeconds(interval);
+            if (secs !== null && secs % 86400 == 0) fmt += 'Date';
+        }
+
+        return $filter('egOrgDate')(date, fmt, orgID);
+    }
+
+    eg_context_date_filter.$stateful = true;
+
+    return eg_context_date_filter;
+}])
+
+// 'egDueDate' filter
+// Uses the egOrgDateInContext filter to make time and date location aware, but
+// only if the supplied interval is day-granular.  This is as wrapper for
+// egOrgDateInContext to be used for circulation due date /only/.
+.filter('egDueDate',['$filter','egCore',
+                      function($filter , egCore) {
+
+    function eg_context_due_date_filter (date, format, orgID, interval) {
+        if (interval) {
+            var secs = egCore.date.intervalToSeconds(interval);
+            if (secs === null || secs % 86400 != 0) {
+                orgID = null;
+                interval = null;
+            }
+        }
+        return $filter('egOrgDateInContext')(date, format, orgID, interval);
+    }
+
+    eg_context_due_date_filter.$stateful = true;
+
+    return eg_context_due_date_filter;
+}])
+
 /**
  * Progress Dialog. 
  *
index ced1577..c9815d0 100644 (file)
@@ -1,3 +1,8 @@
+dojo.require('fieldmapper.AutoIDL');
+dojo.require('fieldmapper.Fieldmapper');
+dojo.require('fieldmapper.OrgUtils');
+dojo.require('openils.Event');
+
 var myPackageDir = 'open_ils_staff_client'; var IAMXUL = true; var g = {};
 var FETCH_CLOSED_DATES    = 'open-ils.actor:open-ils.actor.org_unit.closed.retrieve.all';
 var FETCH_CLOSED_DATE    = 'open-ils.actor:open-ils.actor.org_unit.closed.retrieve';
@@ -10,6 +15,7 @@ var cdAllMultiDayTemplate;
 
 var cdTbody;
 var cdDateCache = {};
+var orgTZ = {};
 
 var selectedStart;
 var selectedEnd;
@@ -157,24 +163,51 @@ function cdBuild(r) {
     removeChildren(cdTbody);
     for( var d = 0; d < dates.length; d++ ) {
         var date = dates[d];
-        var row = cdBuildRow( date );
-        cdTbody.appendChild(row);
+        // super-closure!
+        (function (date) {
+            cdGetTZ(date.org_unit(), function () {
+                var row = cdBuildRow( date );
+                cdTbody.appendChild(row);
+            })
+        })(date);
     }
 }
 
-function cdDateToHours(date) {
-    var date_obj = new Date(Date.parse(date));
-    var hrs = date_obj.getHours();
-    var mins = date_obj.getMinutes();
+function cdDateToHours(date, org) {
+    var date_obj = moment(date).tz(orgTZ[org]);
+    var hrs = date_obj.hours();
+    var mins = date_obj.minutes();
     // wee, strftime
     if (hrs < 10) hrs = '0' + hrs;
     if (mins < 10) mins = '0' + mins;
     return hrs + ':' + mins;
 }
 
-function cdDateToDate(date) {
-    var date_obj = new Date(Date.parse(date));
-    return date_obj.toLocaleDateString();
+function cdDateToDate(date, org) {
+    var date_obj = moment(date).tz(orgTZ[org]);
+    return date_obj.format('YYYY-MM-DD');
+}
+
+function cdGetTZ(org, callback) {
+    if (orgTZ[org]) {
+        if (callback) callback();
+        return;
+    }
+
+    fieldmapper.standardRequest(
+        [   'open-ils.actor',
+            'open-ils.actor.ou_setting.ancestor_default.batch'],
+        {   async: true,
+            params: [org, ['lib.timezone'], SESSION],
+            oncomplete: function(r) {
+                var data = r.recv().content();
+                if(e = openils.Event.parse(data))
+                    return alert(e);
+                orgTZ[org] = data['lib.timezone'].value || OpenSRF.tz;
+                if (callback) callback();
+            }
+        }
+    );
 }
 
 
@@ -183,17 +216,17 @@ function cdBuildRow( date ) {
 
     cdDateCache[date.id()] = date;
 
-    var sh = cdDateToHours(date.close_start());
-    var sd = cdDateToDate(date.close_start());
-    var eh = cdDateToHours(date.close_end());
-    var ed = cdDateToDate(date.close_end());
+    var sh = cdDateToHours(date.close_start(), date.org_unit());
+    var sd = cdDateToDate(date.close_start(), date.org_unit());
+    var eh = cdDateToHours(date.close_end(), date.org_unit());
+    var ed = cdDateToDate(date.close_end(), date.org_unit());
 
     var row;
     var flesh = false;
 
-    if( sh == '00:00' && eh == '23:59' ) {
+    if( isTrue(date.full_day()) ) {
 
-        if( sd == ed ) {
+        if( !isTrue(date.multi_day()) ) {
             row = cdAllDayTemplate.cloneNode(true);
             $n(row, 'start_date').appendChild(text(sd));
 
@@ -220,10 +253,10 @@ function cdBuildRow( date ) {
 }
 
 function cdEditFleshRow(row, date) {
-    $n(row, 'start_time').appendChild(text(cdDateToHours(date.close_start())));
-    $n(row, 'start_date').appendChild(text(cdDateToDate(date.close_start())));
-    $n(row, 'end_time').appendChild(text(cdDateToHours(date.close_end())));
-    $n(row, 'end_date').appendChild(text(cdDateToDate(date.close_end())));
+    $n(row, 'start_time').appendChild(text(cdDateToHours(date.close_start(), date.org_unit())));
+    $n(row, 'start_date').appendChild(text(cdDateToDate(date.close_start(), date.org_unit())));
+    $n(row, 'end_time').appendChild(text(cdDateToHours(date.close_end(), date.org_unit())));
+    $n(row, 'end_date').appendChild(text(cdDateToDate(date.close_end(), date.org_unit())));
 }
 
 
@@ -267,10 +300,28 @@ function cdVerifyTime(t) {
     return t && t.match(/\d{2}:\d{2}:\d{2}/);
 }
 
-function cdDateStrToDate( str ) {
+function cdDateStrToDate( str, org, callback ) {
+    if (!org) org = cdCurrentOrg();
 
-    var date = new Date();
-    var data = str.split(/ /);
+    if (callback) { // async mode
+        if (!orgTZ[org]) { // fetch then call again
+            return cdGetTZ(org, function () {
+                cdDateStrToDate( str, org, callback );
+            });
+        } else {
+            var d = cdDateStrToDate( str, org );
+            return callback(d);
+        }
+    }
+
+    var date;
+    if (orgTZ[org]) {
+        date = moment(new Date()).tz(orgTZ[org]);
+    } else {
+         date = moment(new Date());
+    }
+
+    var data = str.replace(/\s+/, 'T').split(/T/);
 
     var year = data[0];
     var time = data[1];
@@ -284,15 +335,15 @@ function cdDateStrToDate( str ) {
     /*  seed the date with day = 1, which is a valid day for any month.  
         this prevents automatic date correction by the date code for days that 
         fall outside of the current or target month */
-    date.setDate(1);
+    date.date(1);
 
-    date.setFullYear(new Number(yeardata[0]));
-    date.setMonth(new Number(yeardata[1]) - 1);
-    date.setDate(new Number(yeardata[2]));
+    date.year(new Number(yeardata[0]));
+    date.month(new Number(yeardata[1]) - 1);
+    date.date(new Number(yeardata[2]));
 
-    date.setHours(new Number(timedata[0]));
-    date.setMinutes(new Number(timedata[1]));
-    date.setSeconds(new Number(timedata[2]));
+    date.hour(new Number(timedata[0]));
+    date.minute(new Number(timedata[1]));
+    date.second(new Number(timedata[2]));
 
     return date;
 }
@@ -301,20 +352,25 @@ function cdNew() {
 
     var start;
     var end;
+    var full_day = 0;
+    var multi_day = 0;
 
     if( ! $('cd_edit_allday_row').className.match(/hide_me/) ) {
 
         var date = $('cd_edit_allday_start_date').value;
 
-        start = cdDateStrToDate(date + ' 00:00:00');
-        end = cdDateStrToDate(date + ' 23:59:59');
+        start = cdDateStrToDate(date + 'T00:00:00');
+        end = cdDateStrToDate(date + 'T23:59:59');
+        full_day = 1;
 
     } else if( ! $('cd_edit_allmultiday_row').className.match(/hide_me/) ) {
 
         var sdate = $('cd_edit_allmultiday_start_date').value;
         var edate = $('cd_edit_allmultiday_end_date').value;
-        start = cdDateStrToDate(sdate + ' 00:00:00');
-        end = cdDateStrToDate(edate + ' 23:59:59');
+        start = cdDateStrToDate(sdate + 'T00:00:00');
+        end = cdDateStrToDate(edate + 'T23:59:59');
+        full_day = 1;
+        multi_day = 1;
 
     } else {
 
@@ -338,30 +394,30 @@ function cdNew() {
             etime += ':00';
         }
 
-        start = cdDateStrToDate(sdate + ' ' + stime);
-        end = cdDateStrToDate(edate + ' ' + etime);
+        start = cdDateStrToDate(sdate + 'T' + stime);
+        end = cdDateStrToDate(edate + 'T' + etime);
     }
 
-    if (end.getTime() < start.getTime()) {
+    if (end.unix() < start.unix()) {
         alertId('cd_invalid_date_span');
         return;
     }
 
-    cdCreate(start, end, $('cd_edit_note').value);
+    cdCreate(start, end, $('cd_edit_note').value, full_day, multi_day);
 }
 
-function cdCreate(start, end, note) {
+function cdCreate(start, end, note, full_day, multi_day) {
 
     if( $('cd_apply_all').checked ) {
         var list = cdGetOrgList();
         for( var o = 0; o < list.length; o++ ) {
             var id = list[o].id();
-            cdCreateOne( id, start, end, note, (id == cdCurrentOrg()) );
+            cdCreateOne( id, start, end, note, full_day, multi_day, (id == cdCurrentOrg()) );
         }
 
     } else {
 
-        cdCreateOne( cdCurrentOrg(), start, end, note, true );
+        cdCreateOne( cdCurrentOrg(), start, end, note, full_day, multi_day, true );
     }
 }
 
@@ -386,25 +442,33 @@ function cdGetOrgList(org) {
     return list;
 }
 
-
-function cdCreateOne( org, start, end, note, refresh ) {
+function cdCreateOne( org, start, end, note, full_day, multi_day, refresh ) {
     var date = new aoucd();
 
-    date.close_start(start.toISOString());
-    date.close_end(end.toISOString());
-    date.org_unit(org);
-    date.reason(note);
+    // force TZ normalization
+    cdDateStrToDate(start.format('YYYY-MM-DD HH:mm:ss'), org, function (s) {
+        start = s;
+        cdDateStrToDate(end.format('YYYY-MM-DD HH:mm:ss'), org, function (e) {
+            end = e;
+
+            date.close_start(start.toISOString());
+            date.close_end(end.toISOString());
+            date.org_unit(org);
+            date.reason(note);
+            date.full_day(full_day);
+            date.multi_day(multi_day);
+        
+            var req = new Request(CREATE_CLOSED_DATE, SESSION, date);
+            req.callback(
+                function(r) {
+                    var res = r.getResultObject();
+                    if( checkILSEvent(res) ) alertILSEvent(res);
+                    if(refresh) cdDrawRange(selectedStart, selectedEnd, true);
+                }
+            );
+            req.send();
+        });
+    });
 
-    var req = new Request(CREATE_CLOSED_DATE, SESSION, date);
-    req.callback(
-        function(r) {
-            var res = r.getResultObject();
-            if( checkILSEvent(res) ) alertILSEvent(res);
-            if(refresh) cdDrawRange(selectedStart, selectedEnd, true);
-        }
-    );
-    req.send();
 }
 
-
-
index 8eb074e..0368fbc 100644 (file)
 
     <head>
         <title>&staff.server.admin.closed_dates.title;</title>
+
+        <!-- welp, hope nobody uses media_prefix... -->
+        <script src="/js/ui/default/staff/build/js/moment-with-locales.min.js"></script>
+        <script src="/js/ui/default/staff/build/js/moment-timezone-with-data.min.js"></script>
+
         <script type="text/javascript" djConfig="parseOnLoad: true,isDebug:false" src="/js/dojo/dojo/dojo.js"></script>
         <script type="text/javascript" djConfig="parseOnLoad: true,isDebug:false" src="/js/dojo/dojo/openils_dojo.js"></script>
         <script type='text/javascript' src='/opac/common/js/utils.js'> </script>