import
[web.mtrack] / inc / milestone.php
1 <?php # vim:ts=2:sw=2:et:
2 /* For licensing and copyright terms, see the file named LICENSE */
3
4 class MTrackMilestone {
5   public $mid = null;
6   public $pmid = null;
7   public $name = null;
8   public $description = null;
9   public $duedate = null;
10   public $startdate = null;
11   public $deleted = null;
12   public $completed = null;
13   public $created = null;
14
15   static function loadByName($name)
16   {
17     foreach (MTrackDB::q('select mid from milestones where lower(name) = lower(?)', $name)
18         ->fetchAll() as $row) {
19       return new self($row[0]);
20     }
21     return null;
22   }
23
24   static function loadByID($id)
25   {
26     foreach (MTrackDB::q('select mid from milestones where mid = ?', $id)
27         ->fetchAll() as $row) {
28       return new self($row[0]);
29     }
30     return null;
31   }
32
33   static function enumMilestones($all = false)
34   {
35     if ($all) {
36       $q = MTrackDB::q('select mid, name from milestones where deleted != 1');
37     } else {
38       $q = MTrackDB::q('select mid, name from milestones where completed is null and deleted != 1');
39     }
40     $res = array();
41     foreach ($q->fetchAll(PDO::FETCH_NUM) as $row) {
42       $res[$row[0]] = $row[1];
43     }
44     return $res;
45   }
46
47   function __construct($id = null)
48   {
49     if ($id !== null) {
50       $this->mid = $id;
51
52       list($row) = MTrackDB::q('select * from milestones where mid = ?', $id)
53         ->fetchAll(PDO::FETCH_ASSOC);
54       foreach ($row as $k => $v) {
55         $this->$k = $v;
56       }
57     }
58     $this->deleted = false;
59   }
60
61   function save(MTrackChangeset $CS)
62   {
63     $this->updated = $CS->cid;
64
65     if ($this->mid === null) {
66       $this->created = $CS->cid;
67
68       MTrackDB::q('insert into milestones
69           (name, description, startdate, duedate, completed, created,
70             pmid, updated, deleted)
71           values (?, ?, ?, ?, ?, ?, ?, ?, ?)',
72         $this->name,
73         $this->description,
74         $this->startdate,
75         $this->duedate,
76         $this->completed,
77         $this->created,
78         $this->pmid,
79         $this->updated,
80         (int)$this->deleted);
81
82       $this->mid = MTrackDB::lastInsertId('milestones', 'mid');
83     } else {
84       list($old) = MTrackDB::q(
85           'select * from milestones where mid = ?', $this->mid)->fetchAll();
86       foreach ($old as $k => $v) {
87         if ($k == 'mid' || $k == 'created' || $k == 'updated') {
88           continue;
89         }
90         $CS->add("milestone:$this->mid:$k", $v, $this->$k);
91       }
92       MTrackDB::q('update milestones set name = ?,
93           description = ?, startdate = ?, duedate = ?, completed = ?,
94           updated = ?, deleted = ?, pmid = ?
95           WHERE mid = ?',
96         $this->name,
97         $this->description,
98         $this->startdate,
99         $this->duedate,
100         $this->completed,
101         $this->updated,
102         (int)$this->deleted,
103         $this->pmid,
104         $this->mid);
105     }
106   }
107
108   static function macro_BurnDown() {
109     global $ABSWEB;
110
111     $args = func_get_args();
112
113     if (!count($args) || (count($args) == 1 && $args[0] == '')) {
114       # Special case for allowing burndown to NOP in the milestone summary
115       return '';
116     }
117
118     $params = array(
119       'width' => '75%',
120       'height' => '250px',
121     );
122
123     foreach ($args as $arg) {
124       list($name, $value) = explode('=', $arg, 2);
125       $params[$name] = $value;
126     }
127
128     $m = MTrackMilestone::loadByName($params['milestone']);
129     if (!$m) {
130       return "BurnDown: milestone $params[milestone] is invalid<br>\n";
131     }
132     if (!MTrackACL::hasAllRights("milestone:" . $m->mid, 'read')) {
133       return "Not authorized to view milestone $name<br>\n";
134     }
135  
136     /* step 1: find all changes on this milestone and its children */
137     $effort = MTrackDB::q("
138       select expended, remaining, changedate
139       from
140         ticket_milestones tm
141       left join
142         effort e on (tm.tid = e.tid)
143       left join
144         changes c on (e.cid = c.cid)
145       where (mid = ? 
146         or (mid in (select mid from milestones where pmid = ?))
147       )
148          and c.changedate is not null
149       order by c.changedate",
150       $m->mid, $m->mid)->fetchAll(PDO::FETCH_NUM);
151
152     /* estimated hours by day */
153     $estimate_by_day = array();
154     /* accumulated work spent by day */
155     $accum_spent_by_day = array();
156     /* accumulated remaining hours by day */
157     $accum_remain_by_day = array();
158
159     $current_estimate = null;
160     $min_day = null;
161     $max_value = 0;
162     $total_exp = 0;
163
164     foreach ($effort as $info) {
165       list($exp, $rem, $date) = $info;
166       list($day, $rest) = explode('T', $date, 2);
167
168       /* previous day estimate carries over to today */
169       if (!isset($estimate_by_day[$day])) {
170         $estimate_by_day[$day] = $current_estimate;
171       }
172
173       /* previous accumulation carries over */
174       if (!isset($accum_spent_by_day[$day])) {
175         $accum_spent_by_day[$day] = $total_exp;
176       }
177
178       /* revise the estimate for today; also applies
179        * to the number we carry over to tomorrow */
180       if ($rem !== null) {
181         $estimate_by_day[$day] += $rem;
182         $current_estimate = $estimate_by_day[$day];
183       }
184
185       if ($exp !== null) {
186         if ($exp != 0 && $min_day === null) {
187           $min_day = strtotime($date);
188         }
189         $accum_spent_by_day[$day] += $exp;
190         $total_exp += $exp;
191       }
192       $accum_remain_by_day[$day] = $current_estimate - $total_exp;
193       $max_value = max($max_value, $current_estimate);
194     }
195
196     $init_estimate = 0;
197     foreach ($estimate_by_day as $v) {
198       if ($v) {
199         $init_estimate = $v;
200         break;
201       }
202     }
203
204     /* limit the view to the past 3 weeks */
205     $earliest = strtotime('-3 week');
206     if ($min_day < $earliest) {
207 //      $min_day = $earliest;
208     }
209     $min_day *= 1000;
210
211     if ($m->duedate) {
212       $maxday = strtotime($m->duedate);
213     } else {
214       $maxday = time();
215     }
216     $maxday = strtotime('1 week', $maxday) * 1000;
217
218     /* step 3: compute the day by day remaining value,
219      * and produce data series for remaining and expended time */
220
221     $js_remain = array();
222     $js_estimate = array();
223     $trend = array();
224     foreach ($accum_remain_by_day as $day => $remaining) {
225
226       /* compute javascript timestamp */
227       list($year, $month, $dayno) = explode('-', $day);
228       $ts = gmmktime(0, 0, 0, $month, $dayno, $year) * 1000;
229
230       $js_remain[] = "[$ts, $remaining]";
231       $est = (int)$estimate_by_day[$day];
232       $js_estimate[] = "[$ts, $est]";
233       $trend[$ts] = $remaining;
234     }
235
236     $js_remain = join(',', $js_remain);
237     $js_estimate = join(',', $js_estimate);
238
239     $flot = "bd_graph_" . sha1(join(':', $args) . time());
240
241     $max_value *= 1.2;
242
243     $height = (int)$params['height'];
244
245     $html = "
246 <div id='$flot' class='flotgraph'
247   style='width: $params[width]; height: $params[height];'></div>
248 <script id='source_$flot' language='javascript' type='text/javascript'>
249 \$(function () {
250   var p = \$('#$flot');
251   // Not sure what's up here, but somehow the height for the element
252   // shows up as 0 in safari, despite the style setting above... so let's
253   // just force the height here.
254   if (p.height() == 0) {
255     p.height($height);
256   }
257   \$.plot(p, [
258     { label: \"estimated\", data: [$js_estimate], yaxis: 1 },
259     { label: \"remaining\", data: [$js_remain] }
260     ], {
261      xaxis: {
262        mode: \"time\",
263        timeformat: '%b %d',
264        min: $min_day,
265        max: $maxday
266      },
267      yaxis: {
268       max: $max_value
269      },
270      legend: {
271       position: 'sw'
272      },
273      grid: {
274       hoverable: true
275      }
276     }
277   );
278 });
279 </script>
280 ";
281
282     $delta = $init_estimate - $total_exp;
283
284     return
285       "<div class='burndown'>Initial estimate: $init_estimate, Work expended: $total_exp<br>\n"
286       . $html . "</div>";
287   }
288
289   static function macro_MilestoneSummary($name) {
290     global $ABSWEB;
291
292     $m = self::loadByName($name);
293     if (!$m) {
294       return "milestone: " . htmlentities($name) . " not found<br>\n";
295     }
296
297     if (!MTrackACL::hasAllRights("milestone:" . $m->mid, 'read')) {
298       return "Not authorized to view milestone $name<br>\n";
299     }
300    
301     $completed = mtrack_date($m->completed);
302     $description = $m->description;
303     if (strpos($description, "[[BurnDown(") === false) {
304       $description = "[[BurnDown(milestone=$name,width=50%,height=150)]]\n" .
305         $description;
306     }
307     $desc = MTrackWiki::format_to_html($description);
308     $pname = $name;
309     if ($m->completed !== NULL) {
310       $pname = "<del>$name</del>";
311       $due = "Completed";
312     } elseif ($m->duedate) {
313       $due = "Due " . mtrack_date($m->duedate);
314     } else {
315       $due = null;
316     }
317
318     $watch = MTrackWatch::getWatchUI('milestone', $m->mid);
319
320     $html = <<<HTML
321 <div class="milestone">
322 <h2><a href="{$ABSWEB}milestone.php/$name">$pname</a></h2>
323 $watch
324 <div class="due">$due</div>
325 $desc<br/>
326 HTML;
327
328     $estimated = 0;
329     $remaining = 0;
330     $open = 0;
331     $total = 0;
332
333     foreach (MTrackDB::q('select status, estimated, estimated - spent as remaining from ticket_milestones tm left join tickets t on (tm.tid = t.tid) where mid = ?',
334         $m->mid)->fetchAll(PDO::FETCH_ASSOC) as $row) {
335       $total++;
336       if ($row['status'] != 'closed') {
337         $open++;
338       }
339       $estimated += $row['estimated'];
340       $remaining += $row['remaining'];
341     }
342
343     $closed = $total - $open;
344     if ($total) {
345       $apct = (int)($open / $total * 100);
346     } else {
347       $apct = 0;
348     }
349     $cpct = 100 - $apct;
350     $html .= <<<HTML
351 <table class='progress'>
352 <tr>
353   <td class='closed' style='width:$cpct%;'><a href='#'></a></td>
354 HTML;
355
356     if ($open) {
357       $html .= <<<HTML
358   <td class='open' style='width:$apct%;'><a href='#'> </a></td>
359 HTML;
360     }
361
362     $ms = urlencode($name);
363
364     $html .= <<<HTML
365 </tr>
366 </table>
367 <a href='{$ABSWEB}query.php?milestone=$ms&status!=closed'>$open open</a>,
368 <a href='{$ABSWEB}query.php?milestone=$ms&status=closed'>$closed closed</a>,
369 <a href='{$ABSWEB}query.php?milestone=$ms'>$total total</a> ($cpct % complete)
370 </div>
371 HTML;
372     return $html;
373   }
374 }
375
376 MTrackWiki::register_macro('MilestoneSummary',
377   array('MTrackMilestone', 'macro_MilestoneSummary'));
378
379 MTrackWiki::register_macro('BurnDown',
380   array('MTrackMilestone', 'macro_BurnDown'));
381
382 MTrackACL::registerAncestry('milestone', 'Roadmap');
383 MTrackWatch::registerEventTypes('milestone', array(
384   'ticket' => 'Tickets',
385   'changeset' => 'Code changes'
386 ));
387