@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Make date control include times

Summary:
See discussion in T404. Basically, the problem with date-only controls is that they may behave unpredictably in the presence of timezones. When you say "This needs to be done by Oct 23", you probably mean "Oct 23 5PM PST" or something like that, but someone in China may see the "Oct 24" and hit the deadline in good faith but be 10 hours too late. T404 has more discussion and examples. There are ways to fake this, but they get more complicated if the guy in China needs to move the date forward 24 hours.

I think the best solution to this is to not have date-only controls, and always display the time. This makes it absolutley unambiguous what something means, because the guy in the US will set "Oct 23 5PM" and the guy in China will see that accurately in local time.

The downside is that it's slightly more visual clutter and work for the user to specify things precisely, but I added some hints (start/end of day, start/end of business) that will hopefully let us pick the right default in most cases.

Test Plan:
Set some dates.

{F21956}

This has a couple of edge case issues on resize and some not-so-edge-case issues on mobile, but should be good to build T407 on without API changes.

Reviewers: btrahan

Reviewed By: btrahan

CC: aran

Maniphest Tasks: T404, T407

Differential Revision: https://secure.phabricator.com/D3793

+173 -62
+15 -10
src/applications/uiexample/examples/PhabricatorFormExample.php
··· 30 30 $request = $this->getRequest(); 31 31 $user = $request->getUser(); 32 32 33 - $date = id(new AphrontFormDateControl()) 33 + $start_time = id(new AphrontFormDateControl()) 34 34 ->setUser($user) 35 - ->setName('date') 36 - ->setLabel('Date'); 35 + ->setName('start') 36 + ->setLabel('Start') 37 + ->setInitialTime(AphrontFormDateControl::TIME_START_OF_BUSINESS); 38 + $start_value = $start_time->readValueFromRequest($request); 37 39 38 - $date->readValueFromRequest($request); 40 + $end_time = id(new AphrontFormDateControl()) 41 + ->setUser($user) 42 + ->setName('end') 43 + ->setLabel('End') 44 + ->setInitialTime(AphrontFormDateControl::TIME_END_OF_BUSINESS); 45 + $end_value = $end_time->readValueFromRequest($request); 39 46 40 47 $form = id(new AphrontFormView()) 41 48 ->setUser($user) 42 - ->appendChild($date) 49 + ->setFlexible(true) 50 + ->appendChild($start_time) 51 + ->appendChild($end_time) 43 52 ->appendChild( 44 53 id(new AphrontFormSubmitControl()) 45 54 ->setValue('Submit')); 46 55 47 - $panel = new AphrontPanelView(); 48 - $panel->setHeader('Form'); 49 - $panel->appendChild($form); 50 - 51 - return $panel; 56 + return $form; 52 57 } 53 58 }
+115 -28
src/view/form/control/AphrontFormDateControl.php
··· 19 19 final class AphrontFormDateControl extends AphrontFormControl { 20 20 21 21 private $user; 22 + private $initialTime; 22 23 23 - public function setUser($user) { 24 + private $valueDay; 25 + private $valueMonth; 26 + private $valueYear; 27 + private $valueTime; 28 + 29 + const TIME_START_OF_DAY = 'start-of-day'; 30 + const TIME_END_OF_DAY = 'end-of-day'; 31 + const TIME_START_OF_BUSINESS = 'start-of-business'; 32 + const TIME_END_OF_BUSINESS = 'end-of-business'; 33 + 34 + public function setUser(PhabricatorUser $user) { 24 35 $this->user = $user; 36 + return $this; 37 + } 38 + 39 + public function setInitialTime($time) { 40 + $this->initialTime = $time; 25 41 return $this; 26 42 } 27 43 28 44 public function readValueFromRequest(AphrontRequest $request) { 45 + $user = $this->user; 46 + if (!$this->user) { 47 + throw new Exception( 48 + "Call setUser() before readValueFromRequest()!"); 49 + } 50 + 51 + $user_zone = $user->getTimezoneIdentifier(); 52 + $zone = new DateTimeZone($user_zone); 29 53 30 54 $day = $request->getInt($this->getDayInputName()); 31 55 $month = $request->getInt($this->getMonthInputName()); 32 56 $year = $request->getInt($this->getYearInputName()); 57 + $time = $request->getStr($this->getTimeInputName()); 33 58 34 59 $err = $this->getError(); 35 60 36 - if ($day || $month || $year) { 61 + if ($day || $month || $year || $time) { 62 + $this->valueDay = $day; 63 + $this->valueMonth = $month; 64 + $this->valueYear = $year; 65 + $this->valueTime = $time; 37 66 38 67 // Assume invalid. 39 68 $err = 'Invalid'; 40 69 41 - $tz = new DateTimeZone('UTC'); 42 70 try { 43 - $date = new DateTime("{$year}-{$month}-{$day} 12:00:00 AM", $tz); 44 - $value = $date->format('Y-m-d'); 45 - if ($value) { 46 - $this->setValue($value); 47 - $err = null; 48 - } 71 + $date = new DateTime("{$year}-{$month}-{$day} {$time}", $zone); 72 + $value = $date->format('U'); 73 + } catch (Exception $ex) { 74 + $value = null; 75 + } 76 + 77 + if ($value) { 78 + $this->setValue($value); 79 + $err = null; 80 + } else { 81 + $this->setValue(null); 82 + } 83 + } else { 84 + // TODO: We could eventually allow these to be customized per install or 85 + // per user or both, but let's wait and see. 86 + switch ($this->initialTime) { 87 + case self::TIME_START_OF_DAY: 88 + default: 89 + $time = '12:00 AM'; 90 + break; 91 + case self::TIME_START_OF_BUSINESS: 92 + $time = '9:00 AM'; 93 + break; 94 + case self::TIME_END_OF_BUSINESS: 95 + $time = '5:00 PM'; 96 + break; 97 + case self::TIME_END_OF_DAY: 98 + $time = '11:59 PM'; 99 + break; 100 + } 101 + 102 + $today = $this->formatTime(time(), 'Y-m-d'); 103 + try { 104 + $date = new DateTime("{$today} {$time}", $zone); 105 + $value = $date->format('U'); 49 106 } catch (Exception $ex) { 50 - // Ignore, already handled. 107 + $value = null; 108 + } 109 + 110 + if ($value) { 111 + $this->setValue($value); 112 + } else { 113 + $this->setValue(null); 51 114 } 52 115 } 53 116 54 117 $this->setError($err); 55 118 56 - return $err; 119 + return $this->getValue(); 57 120 } 58 121 59 - public function getValue() { 60 - if (!parent::getValue()) { 61 - $this->setValue($this->formatTime(time(), 'Y-m-d')); 62 - } 63 - return parent::getValue(); 122 + protected function getCustomControlClass() { 123 + return 'aphront-form-control-date'; 64 124 } 65 125 126 + public function setValue($epoch) { 127 + $result = parent::setValue($epoch); 66 128 67 - protected function getCustomControlClass() { 68 - return 'aphront-form-control-date'; 129 + if ($epoch === null) { 130 + return; 131 + } 132 + 133 + $readable = $this->formatTime($epoch, 'Y!m!d!g:i A'); 134 + $readable = explode('!', $readable, 4); 135 + 136 + $this->valueYear = $readable[0]; 137 + $this->valueMonth = $readable[1]; 138 + $this->valueDay = $readable[2]; 139 + $this->valueTime = $readable[3]; 140 + 141 + return $result; 69 142 } 70 143 71 144 private function getMinYear() { ··· 87 160 } 88 161 89 162 private function getDayInputValue() { 90 - return (int)idx(explode('-', $this->getValue()), 2); 163 + return $this->valueDay; 91 164 } 92 165 93 166 private function getMonthInputValue() { 94 - return (int)idx(explode('-', $this->getValue()), 1); 167 + return $this->valueMonth; 95 168 } 96 169 97 170 private function getYearInputValue() { 98 - return (int)idx(explode('-', $this->getValue()), 0); 171 + return $this->valueYear; 172 + } 173 + 174 + private function getTimeInputValue() { 175 + return $this->valueTime; 99 176 } 100 177 101 178 private function formatTime($epoch, $fmt) { ··· 115 192 116 193 private function getYearInputName() { 117 194 return $this->getName().'_y'; 195 + } 196 + 197 + private function getTimeInputName() { 198 + return $this->getName().'_t'; 118 199 } 119 200 120 201 protected function renderInput() { ··· 175 256 ), 176 257 ''); 177 258 178 - $id = celerity_generate_unique_node_id(); 259 + $time_sel = phutil_render_tag( 260 + 'input', 261 + array( 262 + 'name' => $this->getTimeInputName(), 263 + 'sigil' => 'time-input', 264 + 'value' => $this->getTimeInputValue(), 265 + 'type' => 'text', 266 + 'class' => 'aphront-form-date-time-input', 267 + ), 268 + ''); 179 269 180 - Javelin::initBehavior( 181 - 'fancy-datepicker', 182 - array( 183 - 'root' => $id, 184 - )); 270 + Javelin::initBehavior('fancy-datepicker', array()); 185 271 186 272 return javelin_render_tag( 187 273 'div', 188 274 array( 189 - 'id' => $id, 190 275 'class' => 'aphront-form-date-container', 276 + 'sigil' => 'phabricator-date-control', 191 277 ), 192 278 self::renderSingleView( 193 279 array( ··· 195 281 $months_sel, 196 282 $years_sel, 197 283 $cal_icon, 284 + $time_sel, 198 285 ))); 199 286 } 200 287
+13 -10
webroot/rsrc/css/aphront/form-view.css
··· 194 194 } 195 195 196 196 .calendar-button { 197 - padding: 11px; 198 - right: -30px; 199 - top: -3px; 200 - 197 + display: inline; 201 198 background: url(/rsrc/image/icon/fatcow/calendar_edit.png) 202 199 no-repeat center center; 203 - z-index: 2; 204 - position: absolute; 200 + padding: 8px 12px; 201 + margin: 2px 8px 2px 2px; 202 + position: relative; 203 + z-index: 8; 205 204 border: 1px solid transparent; 206 205 } 207 206 ··· 210 209 display: inline; 211 210 } 212 211 213 - .aphront-form-date-container select{ 212 + .aphront-form-date-container select { 214 213 margin: 2px; 214 + display: inline; 215 + } 216 + 217 + .aphront-form-date-container input.aphront-form-date-time-input { 218 + width: 7em; 219 + display: inline; 215 220 } 216 221 217 222 .fancy-datepicker { 218 223 position: absolute; 219 - top: -10px; 220 - right: -8px; 221 224 width: 240px; 222 - padding-bottom: 6em; 225 + z-index: 7; 223 226 } 224 227 225 228 .fancy-datepicker-core {
+30 -14
webroot/rsrc/js/application/core/behavior-fancy-datepicker.js
··· 3 3 * @requires javelin-behavior 4 4 * javelin-util 5 5 * javelin-dom 6 + * javelin-stratcom 7 + * javelin-vector 6 8 */ 7 9 8 10 JX.behavior('fancy-datepicker', function(config) { 9 11 10 12 var picker; 13 + var button; 14 + var root; 11 15 12 16 var value_y; 13 17 var value_m; ··· 20 24 // without writing the change. 21 25 22 26 if (picker) { 23 - onclose(e); 24 - return; 27 + if (root == e.getNode('phabricator-date-control')) { 28 + // If the user clicked the same control, just close it. 29 + onclose(e); 30 + return; 31 + } else { 32 + // If the user clicked a different control, close the old one but then 33 + // open the new one. 34 + onclose(e); 35 + } 25 36 } 26 37 38 + 39 + root = e.getNode('phabricator-date-control'); 40 + 27 41 picker = JX.$N( 28 42 'div', 29 - {className: 'fancy-datepicker'}, 43 + {className: 'fancy-datepicker', sigil: 'phabricator-datepicker'}, 30 44 JX.$N('div', {className: 'fancy-datepicker-core'})); 31 - root.appendChild(picker); 45 + document.body.appendChild(picker); 46 + 47 + var button = e.getNode('calendar-button'); 48 + var p = JX.$V(button); 49 + var d = JX.Vector.getDim(picker); 50 + 51 + picker.style.left = (p.x - d.x + 2) + 'px'; 52 + picker.style.top = (p.y - 10) + 'px'; 32 53 33 54 JX.DOM.alterClass(root, 'picker-open', true); 34 55 ··· 45 66 picker = null; 46 67 JX.DOM.alterClass(root, 'picker-open', false); 47 68 e.kill(); 69 + 70 + root = null; 48 71 }; 49 72 50 73 var get_inputs = function() { ··· 176 199 }; 177 200 178 201 179 - var root = JX.$(config.root); 202 + JX.Stratcom.listen('click', 'calendar-button', onopen); 180 203 181 - JX.DOM.listen( 182 - root, 204 + JX.Stratcom.listen( 183 205 'click', 184 - 'calendar-button', 185 - onopen); 186 - 187 - JX.DOM.listen( 188 - root, 189 - 'click', 190 - 'tag:td', 206 + ['phabricator-datepicker', 'tag:td'], 191 207 function(e) { 192 208 e.kill(); 193 209