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