1 <?php # vim:ts=2:sw=2:et:
2 /* For licensing and copyright terms, see the file named LICENSE */
4 class MTrackMilestone {
8 public $description = null;
9 public $duedate = null;
10 public $startdate = null;
11 public $deleted = null;
12 public $completed = null;
13 public $created = null;
15 static function loadByName($name)
17 foreach (MTrackDB::q('select mid from milestones where lower(name) = lower(?)', $name)
18 ->fetchAll() as $row) {
19 return new self($row[0]);
24 static function loadByID($id)
26 foreach (MTrackDB::q('select mid from milestones where mid = ?', $id)
27 ->fetchAll() as $row) {
28 return new self($row[0]);
33 static function enumMilestones($all = false)
36 $q = MTrackDB::q('select mid, name from milestones where deleted != 1');
38 $q = MTrackDB::q('select mid, name from milestones where completed is null and deleted != 1');
41 foreach ($q->fetchAll(PDO::FETCH_NUM) as $row) {
42 $res[$row[0]] = $row[1];
47 function __construct($id = null)
52 list($row) = MTrackDB::q('select * from milestones where mid = ?', $id)
53 ->fetchAll(PDO::FETCH_ASSOC);
54 foreach ($row as $k => $v) {
58 $this->deleted = false;
61 function save(MTrackChangeset $CS)
63 $this->updated = $CS->cid;
65 if ($this->mid === null) {
66 $this->created = $CS->cid;
68 MTrackDB::q('insert into milestones
69 (name, description, startdate, duedate, completed, created,
70 pmid, updated, deleted)
71 values (?, ?, ?, ?, ?, ?, ?, ?, ?)',
82 $this->mid = MTrackDB::lastInsertId('milestones', 'mid');
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') {
90 $CS->add("milestone:$this->mid:$k", $v, $this->$k);
92 MTrackDB::q('update milestones set name = ?,
93 description = ?, startdate = ?, duedate = ?, completed = ?,
94 updated = ?, deleted = ?, pmid = ?
108 static function macro_BurnDown() {
111 $args = func_get_args();
113 if (!count($args) || (count($args) == 1 && $args[0] == '')) {
114 # Special case for allowing burndown to NOP in the milestone summary
123 foreach ($args as $arg) {
124 list($name, $value) = explode('=', $arg, 2);
125 $params[$name] = $value;
128 $m = MTrackMilestone::loadByName($params['milestone']);
130 return "BurnDown: milestone $params[milestone] is invalid<br>\n";
132 if (!MTrackACL::hasAllRights("milestone:" . $m->mid, 'read')) {
133 return "Not authorized to view milestone $name<br>\n";
136 /* step 1: find all changes on this milestone and its children */
137 $effort = MTrackDB::q("
138 select expended, remaining, changedate
142 effort e on (tm.tid = e.tid)
144 changes c on (e.cid = c.cid)
146 or (mid in (select mid from milestones where pmid = ?))
148 and c.changedate is not null
149 order by c.changedate",
150 $m->mid, $m->mid)->fetchAll(PDO::FETCH_NUM);
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();
159 $current_estimate = null;
164 foreach ($effort as $info) {
165 list($exp, $rem, $date) = $info;
166 list($day, $rest) = explode('T', $date, 2);
168 /* previous day estimate carries over to today */
169 if (!isset($estimate_by_day[$day])) {
170 $estimate_by_day[$day] = $current_estimate;
173 /* previous accumulation carries over */
174 if (!isset($accum_spent_by_day[$day])) {
175 $accum_spent_by_day[$day] = $total_exp;
178 /* revise the estimate for today; also applies
179 * to the number we carry over to tomorrow */
181 $estimate_by_day[$day] += $rem;
182 $current_estimate = $estimate_by_day[$day];
186 if ($exp != 0 && $min_day === null) {
187 $min_day = strtotime($date);
189 $accum_spent_by_day[$day] += $exp;
192 $accum_remain_by_day[$day] = $current_estimate - $total_exp;
193 $max_value = max($max_value, $current_estimate);
197 foreach ($estimate_by_day as $v) {
204 /* limit the view to the past 3 weeks */
205 $earliest = strtotime('-3 week');
206 if ($min_day < $earliest) {
207 // $min_day = $earliest;
212 $maxday = strtotime($m->duedate);
216 $maxday = strtotime('1 week', $maxday) * 1000;
218 /* step 3: compute the day by day remaining value,
219 * and produce data series for remaining and expended time */
221 $js_remain = array();
222 $js_estimate = array();
224 foreach ($accum_remain_by_day as $day => $remaining) {
226 /* compute javascript timestamp */
227 list($year, $month, $dayno) = explode('-', $day);
228 $ts = gmmktime(0, 0, 0, $month, $dayno, $year) * 1000;
230 $js_remain[] = "[$ts, $remaining]";
231 $est = (int)$estimate_by_day[$day];
232 $js_estimate[] = "[$ts, $est]";
233 $trend[$ts] = $remaining;
236 $js_remain = join(',', $js_remain);
237 $js_estimate = join(',', $js_estimate);
239 $flot = "bd_graph_" . sha1(join(':', $args) . time());
243 $height = (int)$params['height'];
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'>
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) {
258 { label: \"estimated\", data: [$js_estimate], yaxis: 1 },
259 { label: \"remaining\", data: [$js_remain] }
282 $delta = $init_estimate - $total_exp;
285 "<div class='burndown'>Initial estimate: $init_estimate, Work expended: $total_exp<br>\n"
289 static function macro_MilestoneSummary($name) {
292 $m = self::loadByName($name);
294 return "milestone: " . htmlentities($name) . " not found<br>\n";
297 if (!MTrackACL::hasAllRights("milestone:" . $m->mid, 'read')) {
298 return "Not authorized to view milestone $name<br>\n";
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" .
307 $desc = MTrackWiki::format_to_html($description);
309 if ($m->completed !== NULL) {
310 $pname = "<del>$name</del>";
312 } elseif ($m->duedate) {
313 $due = "Due " . mtrack_date($m->duedate);
318 $watch = MTrackWatch::getWatchUI('milestone', $m->mid);
321 <div class="milestone">
322 <h2><a href="{$ABSWEB}milestone.php/$name">$pname</a></h2>
324 <div class="due">$due</div>
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) {
336 if ($row['status'] != 'closed') {
339 $estimated += $row['estimated'];
340 $remaining += $row['remaining'];
343 $closed = $total - $open;
345 $apct = (int)($open / $total * 100);
351 <table class='progress'>
353 <td class='closed' style='width:$cpct%;'><a href='#'></a></td>
358 <td class='open' style='width:$apct%;'><a href='#'> </a></td>
362 $ms = urlencode($name);
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)
376 MTrackWiki::register_macro('MilestoneSummary',
377 array('MTrackMilestone', 'macro_MilestoneSummary'));
379 MTrackWiki::register_macro('BurnDown',
380 array('MTrackMilestone', 'macro_BurnDown'));
382 MTrackACL::registerAncestry('milestone', 'Roadmap');
383 MTrackWatch::registerEventTypes('milestone', array(
384 'ticket' => 'Tickets',
385 'changeset' => 'Code changes'