김창근 2014-07-02
add duedate in issue
@134d0391d0af29713d8bd5bb55d325338607f0b3
app/assets/stylesheets/less/_page.less
--- app/assets/stylesheets/less/_page.less
+++ app/assets/stylesheets/less/_page.less
@@ -3195,6 +3195,9 @@
              width: 100%;
            }
        }
+       span {
+           &.over { color:#f36c22; }
+       }
 }
 .label-editor {
     padding-top:10px;
@@ -3457,6 +3460,8 @@
 
                 .bar { height:100%; }
             }
+
+            &.over { color:#f36c22; }
         }
     }
 }
@@ -3482,19 +3487,18 @@
 }
 
 .mileston-tag {
-    margin-top:7px;
-    margin-right: 25px;
     font-weight: bold;
     max-width: 135px;
     text-overflow: ellipsis;
     white-space: nowrap;
     overflow: hidden;
     color: darken(@secondary, 10%);
-    padding: 3px 10px;
-    background: #f7f7f7;
     font-size:11px;
     .border-radius(6px);
-    .box-shadow(1px 1px 1px rgba(0,0,0,0.15));
+}
+
+.overdue {
+    color:@yobi-red;
 }
 
 .pullRequest-branchInfo {
app/controllers/IssueApp.java
--- app/controllers/IssueApp.java
+++ app/controllers/IssueApp.java
@@ -352,6 +352,11 @@
                 }
             }
 
+            if (issueMassUpdate.isDueDateChanged) {
+                issue.dueDate = issueMassUpdate.dueDate;
+                issue.expandDueDate();
+            }
+
             issue.updatedDate = JodaDateUtil.now();
             issue.update();
             updatedItems++;
@@ -408,6 +413,7 @@
         addLabels(newIssue, request());
         setMilestone(issueForm, newIssue);
 
+        newIssue.expandDueDate();
         newIssue.save();
 
         attachUploadFilesToPost(newIssue.asResource());
@@ -497,6 +503,7 @@
         final Issue issue = issueForm.get();
         removeAnonymousAssignee(issue);
         setMilestone(issueForm, issue);
+        issue.expandDueDate();
 
         final Issue originalIssue = Issue.findByNumber(project, number);
 
app/models/Issue.java
--- app/models/Issue.java
+++ app/models/Issue.java
@@ -32,14 +32,19 @@
 import models.enumeration.State;
 import models.resource.Resource;
 import models.support.SearchCondition;
+import org.apache.commons.lang3.time.DateUtils;
+import org.joda.time.*;
+import org.joda.time.DateTime;
+import play.data.format.Formats;
+import play.i18n.Messages;
 import utils.JodaDateUtil;
 
 import javax.persistence.*;
 
-import org.apache.commons.lang3.StringUtils;
-
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.lang.Boolean;
+import java.text.SimpleDateFormat;
 import java.util.*;
 import java.util.regex.Pattern;
 import com.avaje.ebean.Page;
@@ -61,6 +66,9 @@
     public static final Pattern ISSUE_PATTERN = Pattern.compile("#\\d+");
 
     public State state;
+
+    @Formats.DateTime(pattern = "yyyy-MM-dd")
+    public Date dueDate;
 
     public static List<State> availableStates = new ArrayList<>();
     static {
@@ -146,6 +154,14 @@
         }
         if(!updateProps.isEmpty()) {
             Ebean.update(this, updateProps);
+        }
+    }
+
+    public void expandDueDate() {
+        if (dueDate != null) {
+            dueDate = DateUtils.setHours(dueDate, 23);
+            dueDate = DateUtils.setMinutes(dueDate, 59);
+            dueDate = DateUtils.setSeconds(dueDate, 59);
         }
     }
 
@@ -439,4 +455,32 @@
     public boolean isVotedBy(User user) {
         return this.voters.contains(user);
     }
+
+    public String getDueDateString() {
+        if (dueDate == null) {
+            return null;
+        }
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+        return sdf.format(this.dueDate);
+    }
+
+    public Boolean isOverDueDate(){
+        return (JodaDateUtil.ago(dueDate).getMillis() > 0);
+    }
+
+    public String until(){
+        if (dueDate == null) {
+            return null;
+        }
+
+        Date now = JodaDateUtil.now();
+
+        if (DateUtils.isSameDay(now, dueDate)) {
+            return Messages.get("common.time.today");
+        } else if (isOverDueDate()) {
+            return Messages.get("common.time.default.day", JodaDateUtil.localDaysBetween(dueDate, now));
+        } else {
+            return Messages.get("common.time.default.day", JodaDateUtil.localDaysBetween(now, dueDate));
+        }
+    }
 }
app/models/IssueMassUpdate.java
--- app/models/IssueMassUpdate.java
+++ app/models/IssueMassUpdate.java
@@ -21,8 +21,10 @@
 package models;
 
 import models.enumeration.State;
+import play.data.format.Formats;
 import play.data.validation.Constraints;
 
+import java.util.Date;
 import java.util.List;
 
 public class IssueMassUpdate {
@@ -30,6 +32,9 @@
     public User assignee;
     public Milestone milestone;
     public boolean delete;
+    @Formats.DateTime(pattern = "yyyy-MM-dd")
+    public Date dueDate;
+    public boolean isDueDateChanged;
 
     @Constraints.Required
     public List<Issue> issues;
app/models/support/SearchCondition.java
--- app/models/support/SearchCondition.java
+++ app/models/support/SearchCondition.java
@@ -27,13 +27,13 @@
 import models.enumeration.State;
 import org.apache.commons.collections.CollectionUtils;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.time.DateUtils;
+import play.data.format.Formats;
 import utils.LabelSearchUtil;
 
 import javax.persistence.Transient;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
+import java.text.SimpleDateFormat;
+import java.util.*;
 
 import static models.enumeration.ResourceType.*;
 
@@ -49,6 +49,9 @@
     public Project project;
 
     public Long mentionId;
+
+    @Formats.DateTime(pattern = "yyyy-MM-dd")
+    public Date dueDate;
 
     /**
      * This doesn't copy {@code pageNum}, because it is safe when changing tabs with page parameter.
@@ -67,6 +70,7 @@
         one.authorId = this.authorId;
         one.assigneeId = this.assigneeId;
         one.mentionId = this.mentionId;
+        one.dueDate = this.dueDate;
         return one;
     }
 
@@ -199,6 +203,10 @@
             el.orderBy(orderBy + " " + orderDir);
         }
 
+        if (dueDate != null) {
+            el.lt("dueDate", DateUtils.addDays(dueDate, 1));
+        }
+
         return el;
     }
 
@@ -301,6 +309,18 @@
             el.orderBy(orderBy + " " + orderDir);
         }
 
+        if (dueDate != null) {
+            el.lt("dueDate", DateUtils.addDays(dueDate, 1));
+        }
+
         return el;
     }
+
+    public String getDueDateString() {
+        if (dueDate == null) {
+            return null;
+        }
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+        return sdf.format(this.dueDate);
+    }
 }
app/utils/JodaDateUtil.java
--- app/utils/JodaDateUtil.java
+++ app/utils/JodaDateUtil.java
@@ -83,4 +83,7 @@
         return moment.invoke("fromNow");
     }
 
+    public static int localDaysBetween(Date from, Date to) {
+        return Days.daysBetween(new DateTime(from).toLocalDate(), new DateTime(to).toLocalDate()).getDays();
+    }
 }
 
app/views/common/calendar.scala.html (added)
+++ app/views/common/calendar.scala.html
@@ -0,0 +1,24 @@
+@**
+* Yobi, Project Hosting SW
+*
+* Copyright 2014 NAVER Corp.
+* http://yobi.io
+*
+* @Author Changgun Kim
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*   http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+**@
+
+<script type="text/javascript" src="@routes.Assets.at("javascripts/lib/moment-with-langs.min.js")"></script>
+<script type="text/javascript" src="@routes.Assets.at("javascripts/lib/pikaday/pikaday.js")"></script>
+<script type="text/javascript" src="@routes.Assets.at("javascripts/common/yobi.ui.Calendar.js")"></script>
app/views/issue/create.scala.html
--- app/views/issue/create.scala.html
+++ app/views/issue/create.scala.html
@@ -106,6 +106,16 @@
                         </dl>
                     }
 
+                    <dl class="issue-option">
+                        <dt>@Messages("issue.dueDate")</dt>
+                        <dd>
+                            <div class="search search-bar">
+                                <input type="text" id="issueDueDate" data-toggle="calendar" name="dueDate" class="textbox full">
+                                <button type="button" class="search-btn"><i class="yobicon-calendar2"></i></button>
+                            </div>
+                        </dd>
+                    </dl>
+
                     <dl id="labels" class="issue-option">
                         <dt>
                             @Messages("label")
@@ -127,25 +137,26 @@
 @common.markdown(project)
 @common.issueLabel()
 @common.select2()
+@common.calendar()
 <link rel="stylesheet" type="text/css" media="screen" href="@routes.Assets.at("javascripts/lib/atjs/jquery.atwho.css")">
 <script type="text/javascript" src="@routes.Assets.at("javascripts/lib/atjs/jquery.caret.min.js")"></script>
 <script type="text/javascript" src="@routes.Assets.at("javascripts/lib/atjs/jquery.atwho.js")"></script>
 <script type="text/javascript">
-	$(document).ready(function(){
-	    // yobi.Label
+    $(document).ready(function(){
+        // yobi.Label
         yobi.Label.init({
             "sURLLabels": "@routes.IssueLabelApp.labels(project.owner, project.name)",
             "sURLPost"  : "@routes.IssueLabelApp.newLabel(project.owner, project.name)"
         });
 
         // yobi.issue.Write
-		$yobi.loadModule("issue.Write", {
-			"sMode"		   : "new",
+        $yobi.loadModule("issue.Write", {
+            "sMode": "new",
             "sIssueFormURL": "@routes.IssueApp.newIssueForm(project.owner, project.name)",
-			"sIssueListURL": "@routes.IssueApp.issues(project.owner, project.name)",
-            "elTextarea"   : 'textarea[data-editor-mode="content-body"]',
-            "elMilestoneRefresh": $("#milestoneOption .yobicon-refresh")
-		});
+            "sIssueListURL": "@routes.IssueApp.issues(project.owner, project.name)",
+            "elTextarea": 'textarea[data-editor-mode="content-body"]',
+            "elMilestoneRefresh": $("#milestoneOption .yobicon-refresh"),
+        });
 
         // yobi.Mention
         yobi.Mention({
app/views/issue/edit.scala.html
--- app/views/issue/edit.scala.html
+++ app/views/issue/edit.scala.html
@@ -144,6 +144,16 @@
                     </dl>
                     }
 
+                    <dl class="issue-option">
+                        <dt>@Messages("issue.dueDate")</dt>
+                        <dd>
+                            <div class="search search-bar">
+                                <input type="text" id="issueDueDate" data-toggle="calendar" name="dueDate" class="textbox full" value="@issue.getDueDateString">
+                                <button type="button" class="search-btn"><i class="yobicon-calendar2"></i></button>
+                            </div>
+                        </dd>
+                    </dl>
+
                     <dl id="labels" class="issue-option">
                         <dt>
                             @Messages("label")
@@ -165,6 +175,7 @@
 @common.markdown(project)
 @common.issueLabel()
 @common.select2()
+@common.calendar()
 <link rel="stylesheet" type="text/css" media="screen" href="@routes.Assets.at("javascripts/lib/atjs/jquery.atwho.css")">
 <script type="text/javascript" src="@routes.Assets.at("javascripts/lib/atjs/jquery.caret.min.js")"></script>
 <script type="text/javascript" src="@routes.Assets.at("javascripts/lib/atjs/jquery.atwho.js")"></script>
app/views/issue/list.scala.html
--- app/views/issue/list.scala.html
+++ app/views/issue/list.scala.html
@@ -29,6 +29,7 @@
     </div>
 </div>
 @common.select2()
+@common.calendar()
 <script type="text/javascript">
     $(document).ready(function(){
         var htPjaxOptions = {
app/views/issue/my_partial_list.scala.html
--- app/views/issue/my_partial_list.scala.html
+++ app/views/issue/my_partial_list.scala.html
@@ -32,7 +32,7 @@
 @for(issue <- issueList){
 @defining(User.findByLoginId(issue.authorLoginId)){ user =>
     <li class="post-item" id="issue-item-@issue.id">
-        <div class="span9">
+        <div class="span10">
             <a href="@routes.UserApp.userInfo(user.loginId)" class="avatar-wrap mlarge" data-toggle="tooltip" data-placement="top" title="@user.loginId">
                 @if(user.avatarUrl == UserApp.DEFAULT_AVATAR_URL){
                     <img src="@urlToPicture(user.email, 32)">
@@ -62,6 +62,14 @@
                     @agoOrDateString(issue.createdDate)
                 </span>
 
+                @if(issue.milestone != null && issue.project != null && issue.project.menuSetting != null && issue.project.menuSetting.milestone) {
+                <span class="infos-item mileston-tag">
+                    <a href="@routes.MilestoneApp.milestone(issue.project.owner, issue.project.name, issue.milestone.id)" data-toggle="tooltip" data-placement="top" title="@Messages("milestone")">
+                        @issue.milestone.title
+                    </a>
+                </span>
+                }
+
                 @if(issue.comments.size()>0 || issue.voters.size()>0) {
                 <span class="infos-item item-count-groups">
                     @if(issue.comments.size()>0){
@@ -78,7 +86,7 @@
                 }
             </div>
         </div>
-        <div class="span3">
+        <div class="span2">
             <div class="mt5 pull-right">
                 @if(issue.assigneeName != null) {
                     <a href="@routes.UserApp.userInfo(issue.assignee.user.loginId)" class="avatar-wrap assinee" data-toggle="tooltip" data-placement="top" title="@Messages("issue.assignee"): @issue.assigneeName">
@@ -88,15 +96,16 @@
                    <div class="empty-avatar-wrap">&nbsp;</div>
                 }
             </div>
-            <div class="pull-right">
-                @if(issue.milestone != null && issue.project != null && issue.project.menuSetting != null && issue.project.menuSetting.milestone) {
-                <div class="mileston-tag">
-                    <a href="@routes.MilestoneApp.milestone(issue.project.owner, issue.project.name, issue.milestone.id)" data-toggle="tooltip" data-placement="top" title="@Messages("milestone")">
-                    @issue.milestone.title
-                    </a>
-                </div>
+            @if(issue.dueDate != null) {
+            <div class="mr20 mt10 pull-right @if(issue.isOverDueDate) {overdue}" data-toggle="tooltip" data-placement="top" title="@issue.getDueDateString">
+                <i class="yobicon-clock2"></i> 
+                @if(issue.isOverDueDate) {
+                    @Messages("issue.dueDate.overdue")
+                } else {
+                    @issue.until
                 }
             </div>
+            }
         </div>
     </li>
 }
app/views/issue/my_partial_search.scala.html
--- app/views/issue/my_partial_search.scala.html
+++ app/views/issue/my_partial_search.scala.html
@@ -109,6 +109,7 @@
 		<div class="filter-wrap small-heights">
                 @if(currentPage.getList.size() > 1){
 			<div class="filters pull-right">
+                @makeFilterLink("dueDate", param.orderBy, param.orderDir, Messages("common.order.dueDate"))
 				@makeFilterLink("updatedDate", param.orderBy, param.orderDir, Messages("common.order.updatedDate"))
 				@makeFilterLink("createdDate", param.orderBy, param.orderDir, Messages("common.order.date"))
 				@makeFilterLink("numOfComments", param.orderBy, param.orderDir, Messages("common.order.comments"))
app/views/issue/partial_list.scala.html
--- app/views/issue/partial_list.scala.html
+++ app/views/issue/partial_list.scala.html
@@ -65,6 +65,14 @@
                         @agoOrDateString(issue.createdDate)
                     </span>
 
+                    @if(project.menuSetting.milestone && issue.milestone != null) {
+                    <span class="infos-item mileston-tag">
+                        <a href="@routes.MilestoneApp.milestone(project.owner, project.name, issue.milestone.id)" data-toggle="tooltip" data-placement="top" title="@Messages("milestone")">
+                            @issue.milestone.title
+                        </a>
+                    </span>
+                    }
+
                     @if(issue.comments.size()>0 || issue.voters.size()>0) {
                     <span class="infos-item item-count-groups">
                     @if(issue.comments.size()>0){
@@ -92,15 +100,16 @@
                    <div class="empty-avatar-wrap">&nbsp;</div>
                 }
             </div>
-            <div class="pull-right">
-                @if(project.menuSetting.milestone && issue.milestone != null) {
-                    <div class="mileston-tag">
-                        <a href="@routes.MilestoneApp.milestone(project.owner, project.name, issue.milestone.id)" data-toggle="tooltip" data-placement="top" title="@Messages("milestone")">
-                        @issue.milestone.title
-                    </a>
-                    </div>
+            @if(issue.dueDate != null) {
+            <div class="mr20 mt10 pull-right @if(issue.isOverDueDate) {overdue}" data-toggle="tooltip" data-placement="top" title="@issue.getDueDateString">
+                <i class="yobicon-clock2"></i> 
+                @if(issue.isOverDueDate) {
+                    @Messages("issue.dueDate.overdue")
+                } else {
+                    @issue.until
                 }
             </div>
+            }       
         </div>
     </li>
     }
app/views/issue/partial_search.scala.html
--- app/views/issue/partial_search.scala.html
+++ app/views/issue/partial_search.scala.html
@@ -177,6 +177,14 @@
                         </dd>
                     </dl>
                     }
+
+                    <dl class="issue-option">
+                        <dt>@Messages("issue.dueDate")</dt>
+                        <dd class="search search-bar">
+                            <input type="text" id="issueDueDate" data-toggle="calendar" name="dueDate" class="textbox full" value="@param.getDueDateString">
+                            <button type="button" class="search-btn"><i class="yobicon-calendar2"></i></button>
+                        </dd>
+                    </dl>
                 </div>
 
                 <hr>
@@ -216,6 +224,7 @@
 
                 @if(currentPage.getList.size() > 1){
                 <div class="filters pull-right">
+                    @makeFilterLink("dueDate", param.orderBy, param.orderDir, Messages("common.order.dueDate"))
                     @makeFilterLink("updatedDate", param.orderBy, param.orderDir, Messages("common.order.updatedDate"))
                     @makeFilterLink("createdDate", param.orderBy, param.orderDir, Messages("common.order.date"))
                     @makeFilterLink("numOfComments", param.orderBy, param.orderDir, Messages("common.order.comments"))
@@ -243,7 +252,7 @@
         <script type="text/javascript">
             $(document).ready(function(){
                 // Label
-				        yobi.Label.init({
+                yobi.Label.init({
                     "bRefresh"     : true,
                     "sURLLabels"   : "@routes.IssueLabelApp.labels(project.owner, project.name)",
                     "sURLPost"     : "@routes.IssueLabelApp.newLabel(project.owner, project.name)",
@@ -252,12 +261,13 @@
 
                 // issue.List
                 $yobi.loadModule("issue.List", {
-                    "welSearchOrder": $("a[orderBy]"),
-                    "welSearchState": $("a[state]"),
-                    "welSearchForm" : $("form[name='search']"),
-                    "welFilter"     : $("a[pjax-filter]"),
-                    "elPagination"  : $("#pagination"),
-                    "nTotalPages" : @currentPage.getTotalPageCount
+                    "welSearchOrder"  : $("a[orderBy]"),
+                    "welSearchState"  : $("a[state]"),
+                    "welSearchForm"   : $("form[name='search']"),
+                    "welFilter"       : $("a[pjax-filter]"),
+                    "elPagination"    : $("#pagination"),
+                    "nTotalPages"     : @currentPage.getTotalPageCount,
+                    "welIssueDueDate" : $("#issueDueDate")
                 });
 
                 // ShortcutKey
app/views/issue/view.scala.html
--- app/views/issue/view.scala.html
+++ app/views/issue/view.scala.html
@@ -164,7 +164,11 @@
                             <dt>@Messages("milestone")</dt>
                             <dd style="padding:5px 10px;">
                             @if(Milestone.findByProjectId(project.id).isEmpty()){
+                                @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE)) {
                                 <a href="@routes.MilestoneApp.newMilestoneForm(project.owner, project.name)" target="_blank" class="ybtn ybtn-success ybtn-small">@Messages("milestone.menu.new")</a>
+                                } else {
+                                    @Messages("issue.noMilestone")
+                                }
                             } else {
                                 @defining(issue.milestone != null) { hasMilestone =>
                                     @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE)) {
@@ -208,6 +212,35 @@
                         </dl>
                         }
                         @**<!-- // -->**@
+
+                        <dl class="issue-option">
+                            <dt>
+                                @Messages("issue.dueDate")
+                                @if(issue.dueDate != null) {
+                                <span class="@if(issue.isOverDueDate) {overdue}">
+                                    @if(issue.isOverDueDate) {
+                                        (@Messages("issue.dueDate.overdue"))
+                                    } else {
+                                        (@issue.until)
+                                    }
+                                </span>
+                                }
+                            </dt>
+                            <dd style="padding:5px 10px;">
+                                @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE)) {
+                                <div class="search search-bar">
+                                    <input type="text" id="issueDueDate" data-toggle="calendar" data-oDueDate="@issue.getDueDateString" name="dueDate" class="textbox full" value="@issue.getDueDateString">
+                                    <button type="button" class="search-btn"><i class="yobicon-calendar2"></i></button>
+                                </div>
+                                } else {
+                                    @if(issue.dueDate != null) {
+                                        @issue.getDueDateString
+                                    } else {
+                                        @Messages("issue.noDuedate")
+                                    }
+                                }
+                            </dd>
+                        </dl>
 
                         @**<!-- labels -->**@
                         @if(!IssueLabel.findByProject(project).isEmpty){
@@ -343,6 +376,7 @@
 @common.markdown(project)
 @common.commentDeleteModal()
 @common.select2()
+@common.calendar()
 
 <link rel="stylesheet" type="text/css" media="screen" href="@routes.IssueLabelApp.labelStyles(project.owner, project.name)">
 <link rel="stylesheet" type="text/css" media="screen" href="@routes.Assets.at("javascripts/lib/atjs/jquery.atwho.css")">
@@ -363,7 +397,8 @@
             "sIssueCheckBoxesSelector": "[type=checkbox][name=checked-issue]",
             "sNextState"    : "@issue.nextState().toString.toLowerCase",
             "sNextStateUrl" : "@routes.IssueApp.nextState(project.owner, project.name, issue.getNumber)",
-            "sCommentWithStateUrl": "@routes.IssueApp.newComment(project.owner, project.name, issue.getNumber)"
+            "sCommentWithStateUrl": "@routes.IssueApp.newComment(project.owner, project.name, issue.getNumber)",
+            "welDueDate": $("#issueDueDate")
         });
 
         // yobi.ShortcutKey
app/views/layout.scala.html
--- app/views/layout.scala.html
+++ app/views/layout.scala.html
@@ -31,6 +31,7 @@
 <link rel="stylesheet" type="text/css" media="all" href="@routes.Assets.at("bootstrap/css/bootstrap.css")">
 <link rel="stylesheet" type="text/css" media="all" href="@routes.Assets.at("stylesheets/yobicon/style.css")">
 <link rel="stylesheet" type="text/css" media="all" href="@routes.Assets.at("javascripts/lib/select2/select2.css")"/>
+<link rel="stylesheet" type="text/css" media="all" href="@routes.Assets.at("javascripts/lib/pikaday/pikaday.css")" />
 <link rel="stylesheet" type="text/css" media="all" href="@routes.Assets.at("stylesheets/yobi.css")">
 
 <script type="text/javascript" src="@routes.Assets.at("javascripts/lib/jquery/jquery-1.9.0.js")"></script>
app/views/user/partial_issues.scala.html
--- app/views/user/partial_issues.scala.html
+++ app/views/user/partial_issues.scala.html
@@ -30,7 +30,7 @@
 }
 @defining(User.findByLoginId(issue.authorLoginId)){ user =>
 <li class="post-item " id="issue-item-@issue.id">
-    <div class="span9">
+    <div class="span10">
         <a href="@routes.ProjectApp.project(project.owner, project.name)" class="avatar-wrap mlarge">
             <img src="@urlToProjectLogo(project)" alt="@project.owner / @project.name">
         </a>
@@ -56,6 +56,14 @@
                 @agoOrDateString(issue.createdDate)
             </span>
 
+            @if(project.menuSetting.milestone && issue.milestone != null) {
+            <span class="infos-item mileston-tag">
+                <a href="@routes.MilestoneApp.milestone(issue.project.owner, issue.project.name, issue.milestone.id)" data-toggle="tooltip" data-placement="top" title="@Messages("milestone")">
+                    @issue.milestone.title
+                </a>
+            </span>
+            }
+
             @if(issue.comments.size()>0 || issue.voters.size()>0) {
             <span class="infos-item item-count-groups">
             @if(issue.comments.size()>0){
@@ -72,7 +80,7 @@
             }
         </div>
     </div>
-    <div class="span3">
+    <div class="span2">
         <div class="mt5 pull-right">
             @if(issue.assigneeName != null) {
                 <a href="@routes.UserApp.userInfo(issue.assignee.user.loginId)" class="avatar-wrap assinee" data-toggle="tooltip" data-placement="top" title="@Messages("issue.assignee"): @issue.assigneeName">
@@ -83,14 +91,17 @@
             }
         </div>
         <div class="pull-right">
-            @if(project.menuSetting.milestone && issue.milestone != null) {
-                <div class="mileston-tag">
-                    <a href="@routes.MilestoneApp.milestone(issue.project.owner, issue.project.name, issue.milestone.id)" data-toggle="tooltip" data-placement="top" title="@Messages("milestone")">
-                    @issue.milestone.title
-                </a>
-                </div>
-            }
+            @if(issue.dueDate != null) {
+            <div class="mr20 mt10 pull-right @if(issue.isOverDueDate) {overdue}" data-toggle="tooltip" data-placement="top" title="@issue.getDueDateString">
+                <i class="yobicon-clock2"></i>
+                @if(issue.isOverDueDate) {
+                    @Messages("issue.dueDate.overdue")
+                } else {
+                    @issue.until
+                }
+            </div>
         </div>
+        }
     </div>
 </li>
 }
 
conf/evolutions/default/85.sql (added)
+++ conf/evolutions/default/85.sql
@@ -0,0 +1,6 @@
+# --- !Ups
+
+ALTER TABLE issue add column due_date timestamp;
+
+# --- !Downs
+ALTER TABLE issue drop column due_date;
conf/messages
--- conf/messages
+++ conf/messages
@@ -142,17 +142,18 @@
 common.none = None
 common.noAuthor = No author
 common.order.all = All
-common.order.comments = Order by comments
-common.order.completionRate = Order by completion Rate
-common.order.date = Order by date
-common.order.dueDate = Order by due date
-common.order.name = Order by name
-common.order.recent = Order by creation date
-common.order.updatedDate = Order by updated date
+common.order.comments = comments
+common.order.completionRate = completion Rate
+common.order.date = created date
+common.order.dueDate = due date
+common.order.name = name
+common.order.recent = creation date
+common.order.updatedDate = updated date
 common.time.after = After {0} days
 common.time.before = Before {0} days
 common.time.day = {0} days ago
 common.time.days = {0} days ago
+common.time.default.day = {0} days
 common.time.hour = {0} hours ago
 common.time.hours = {0} hours ago
 common.time.just = Just now
@@ -217,6 +218,8 @@
 issue.createdDate = Created date
 issue.delete = Delete issue
 issue.downloadAsExcel = Download as Excel file
+issue.dueDate = Due date
+issue.dueDate.overdue = Overdue
 issue.error.beforeunload = Issue has not saved yet. Would you like to exit this page without saving?
 issue.error.emptyBody = Issue description is a required field.
 issue.error.emptyTitle = Issue title is a required field.
@@ -238,6 +241,7 @@
 issue.noAssignee = No assignee
 issue.noAuthor = No author
 issue.noMilestone = No milestone
+issue.noDuedate = No due date
 issue.search = SEARCH
 issue.state = Status
 issue.state.all = All
@@ -250,6 +254,7 @@
 issue.update.assignee = Update assignee
 issue.update.attachLabel = Attach label
 issue.update.detachLabel = Detach label
+issue.update.duedate = Update due date
 issue.update.label = Update label
 issue.update.milestone = Update milestone
 issue.update.state = Update status
conf/messages.ko
--- conf/messages.ko
+++ conf/messages.ko
@@ -153,6 +153,7 @@
 common.time.before = {0}일 전
 common.time.day = {0}일 전
 common.time.days = {0}일 전
+common.time.default.day = {0}일
 common.time.hour = {0}시간 전
 common.time.hours = {0}시간 전
 common.time.just = 방금 전
@@ -217,6 +218,8 @@
 issue.createdDate = 작성일
 issue.delete = 이슈 삭제
 issue.downloadAsExcel = 엑셀파일로 다운받기
+issue.dueDate= 목표 완료일
+issue.dueDate.overdue= 기한지남
 issue.error.beforeunload = 아직 이슈를 저장하지 않았습니다. 저장하지 않은 채로 다른 페이지로 이동하시겠습니까?
 issue.error.emptyBody = 이슈 내용을 입력해주세요
 issue.error.emptyTitle = 이슈 제목을 입력해주세요
@@ -238,6 +241,7 @@
 issue.noAssignee = 담당자 없음
 issue.noAuthor = 작성자 없음
 issue.noMilestone = 마일스톤 없음
+issue.noDuedate = 목표 완료일 없음
 issue.search = 검색
 issue.state = 상태
 issue.state.all = 전체
@@ -250,6 +254,7 @@
 issue.update.assignee = 담당자 변경
 issue.update.attachLabel = 라벨 추가
 issue.update.detachLabel = 라벨 제거
+issue.update.duedate = 목표완료일 변경
 issue.update.label = 라벨 변경
 issue.update.milestone = 마일스톤 변경
 issue.update.state = 상태 변경
 
public/javascripts/common/yobi.ui.Calendar.js (added)
+++ public/javascripts/common/yobi.ui.Calendar.js
@@ -0,0 +1,87 @@
+/**
+ * Yobi, Project Hosting SW
+ *
+ * Copyright 2014 NAVER Corp.
+ * http://yobi.io
+ *
+ * @Author Changgun Kim
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/**
+ * yobi.ui.Calendar
+ *
+ * @requires Pikaday.js (https://github.com/dbushell/Pikaday/)
+ */
+
+(function(ns){
+    var oNS = $yobi.createNamespace(ns);
+    oNS.container[oNS.name] = function(elInput, htOptions){
+        var welInput = $(elInput);
+
+        var htVar = {
+            "sDateFormat": "YYYY-MM-DD",
+            "rxDateFormat": /\d{4}-\d{2}-\d{2}/
+        };
+
+        var htElement = {};
+
+        function _init(htOptions){
+            _initElement(htOptions);
+        }
+
+        function _initElement(htOptions) {
+            if (!welInput.data("pickaday")) {
+                htVar.oPicker = new Pikaday({
+                    "format": htVar.sDateFormat,
+                    "field": welInput.get(0),
+                    "setDefaultDate": true,
+                    "defaultDate": welInput.val()
+                });
+                welInput.data("pickaday", true);
+
+                htElement.welBtn = welInput.next();
+
+                htElement.welBtn.on("click", function() {
+                    htVar.oPicker.show();
+                });
+
+                if(welInput.val().length > 0){
+                    htVar.oPicker.setDate(welInput.val());
+                }
+            }
+        }
+
+        function _getDate() {
+            return htVar.oPicker.getDate();
+        }
+
+        function _setDate(dateStr) {
+            return htVar.oPicker.setDate(dateStr);
+        }
+
+        _init(htOptions);
+
+        return {
+            "getDate": _getDate,
+            "setDate": _setDate
+        };
+    };
+
+})("yobi.ui.Calendar");
+
+$(function(){
+    $('[data-toggle="calendar"]').each(function(i, el){
+        yobi.ui.Calendar(el);
+    });
+});
public/javascripts/lib/pikaday/pikaday.js
--- public/javascripts/lib/pikaday/pikaday.js
+++ public/javascripts/lib/pikaday/pikaday.js
@@ -1,450 +1,564 @@
 /*!
  * Pikaday
- * Copyright © 2012 David Bushell | BSD & MIT license | http://dbushell.com/
+ *
+ * Copyright © 2014 David Bushell | BSD & MIT license | https://github.com/dbushell/Pikaday
  */
 
-(function(window, document, undefined)
+(function (root, factory)
+{
+    'use strict';
+
+    var moment;
+    if (typeof exports === 'object') {
+        // CommonJS module
+        // Load moment.js as an optional dependency
+        try { moment = require('moment'); } catch (e) {}
+        module.exports = factory(moment);
+    } else if (typeof define === 'function' && define.amd) {
+        // AMD. Register as an anonymous module.
+        define(function (req)
+        {
+            // Load moment.js as an optional dependency
+            var id = 'moment';
+            moment = req.defined && req.defined(id) ? req(id) : undefined;
+            return factory(moment);
+        });
+    } else {
+        root.Pikaday = factory(root.moment);
+    }
+}(this, function (moment)
 {
     'use strict';
 
     /**
      * feature detection and helper functions
      */
-    var hasMoment = typeof window.moment === 'function',
+    var hasMoment = typeof moment === 'function',
 
-    hasEventListeners = !!window.addEventListener,
+        hasEventListeners = !!window.addEventListener,
 
-    sto = window.setTimeout,
+        document = window.document,
 
-    addEvent = function(el, e, callback, capture)
-    {
-        if (hasEventListeners) {
-            el.addEventListener(e, callback, !!capture);
-        } else {
-            el.attachEvent('on' + e, callback);
-        }
-    },
+        sto = window.setTimeout,
 
-    removeEvent = function(el, e, callback, capture)
-    {
-        if (hasEventListeners) {
-            el.removeEventListener(e, callback, !!capture);
-        } else {
-            el.detachEvent('on' + e, callback);
-        }
-    },
-
-    trim = function(str)
-    {
-        return str.trim ? str.trim() : str.replace(/^\s+|\s+$/g,'');
-    },
-
-    hasClass = function(el, cn)
-    {
-        return (' ' + el.className + ' ').indexOf(' ' + cn + ' ') !== -1;
-    },
-
-    addClass = function(el, cn)
-    {
-        if (!hasClass(el, cn)) {
-            el.className = (el.className === '') ? cn : el.className + ' ' + cn;
-        }
-    },
-
-    removeClass = function(el, cn)
-    {
-        el.className = trim((' ' + el.className + ' ').replace(' ' + cn + ' ', ' '));
-    },
-
-    isArray = function(obj)
-    {
-        return (/Array/).test(Object.prototype.toString.call(obj));
-    },
-
-    isDate = function(obj)
-    {
-        return (/Date/).test(Object.prototype.toString.call(obj)) && !isNaN(obj.getTime());
-    },
-
-    isLeapYear = function(year)
-    {
-        // solution by Matti Virkkunen: http://stackoverflow.com/a/4881951
-        return year % 4 === 0 && year % 100 !== 0 || year % 400 === 0;
-    },
-
-    getDaysInMonth = function(year, month)
-    {
-        return [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month];
-    },
-
-    compareDates = function(a,b)
-    {
-        // weak date comparison (use date.setHours(0,0,0,0) to ensure correct result)
-        return a.getTime() === b.getTime();
-    },
-
-    extend = function(to, from, overwrite)
-    {
-        var prop, hasProp;
-        for (prop in from) {
-            hasProp = to[prop] !== undefined;
-            if (hasProp && typeof from[prop] === 'object' && from[prop].nodeName === undefined) {
-                if (isDate(from[prop])) {
-                    if (overwrite) {
-                        to[prop] = new Date(from[prop].getTime());
-                    }
-                }
-                else if (isArray(from[prop])) {
-                    if (overwrite) {
-                        to[prop] = from[prop].slice(0);
-                    }
-                } else {
-                    to[prop] = extend({}, from[prop], overwrite);
-                }
-            } else if (overwrite || !hasProp) {
-                to[prop] = from[prop];
+        addEvent = function(el, e, callback, capture)
+        {
+            if (hasEventListeners) {
+                el.addEventListener(e, callback, !!capture);
+            } else {
+                el.attachEvent('on' + e, callback);
             }
-        }
-        return to;
-    },
-
-
-    /**
-     * defaults and localisation
-     */
-    defaults = {
-
-        // bind the picker to a form field
-        field: null,
-
-        // automatically show/hide the picker on `field` focus (default `true` if `field` is set)
-        bound: undefined,
-
-        // the default output format for `.toString()` and `field` value
-        format: 'YYYY-MM-DD',
-
-        // the initial date to view when first opened
-        defaultDate: null,
-
-        // make the `defaultDate` the initial selected value
-        setDefaultDate: false,
-
-        // first day of week (0: Sunday, 1: Monday etc)
-        firstDay: 0,
-
-        // the minimum/earliest date that can be selected
-        minDate: null,
-        // the maximum/latest date that can be selected
-        maxDate: null,
-
-        // number of years either side, or array of upper/lower range
-        yearRange: 10,
-
-        // used internally (don't config outside)
-        minYear: 0,
-        maxYear: 9999,
-        minMonth: undefined,
-        maxMonth: undefined,
-
-        isRTL: false,
-
-        // how many months are visible (not implemented yet)
-        numberOfMonths: 1,
-
-        // internationalization
-        i18n: {
-
-                months        : ['January','February','March','April','May','June','July','August','September','October','November','December'],
-                //monthsShort   : ['Jan_Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
-                weekdays      : ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'],
-                weekdaysShort : ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
         },
 
-        // callback function
-        onSelect: null,
-        onOpen: null,
-        onClose: null
-    },
-
-
-    /**
-     * templating functions to abstract HTML rendering
-     */
-    renderDayName = function(opts, day, abbr)
-    {
-        day += opts.firstDay;
-        while (day >= 7) {
-            day -= 7;
-        }
-        return abbr ? opts.i18n.weekdaysShort[day] : opts.i18n.weekdays[day];
-    },
-
-    renderDay = function(i, isSelected, isToday, isDisabled, isEmpty)
-    {
-        if (isEmpty) {
-            return '<td class="is-empty"></td>';
-        }
-        var arr = [];
-        if (isDisabled) {
-            arr.push('is-disabled');
-        }
-        if (isToday) {
-            arr.push('is-today');
-        }
-        if (isSelected) {
-            arr.push('is-selected');
-        }
-        return '<td data-day="' + i + '" class="' + arr.join(' ') + '"><button class="pika-button" type="button">' + i + '</button>' + '</td>';
-    },
-
-    renderRow = function(days, isRTL)
-    {
-        return '<tr>' + (isRTL ? days.reverse() : days).join('') + '</tr>';
-    },
-
-    renderBody = function(rows)
-    {
-        return '<tbody>' + rows.join('') + '</tbody>';
-    },
-
-    renderHead = function(opts)
-    {
-        var i, arr = [];
-        for (i = 0; i < 7; i++) {
-            arr.push('<th scope="col"><abbr title="' + renderDayName(opts, i) + '">' + renderDayName(opts, i, true) + '</abbr></th>');
-        }
-        return '<thead>' + (opts.isRTL ? arr.reverse() : arr).join('') + '</thead>';
-    },
-
-    renderTitle = function(instance)
-    {
-        var i, j, arr,
-            opts = instance._o,
-            month = instance._m,
-            year  = instance._y,
-            isMinYear = year === opts.minYear,
-            isMaxYear = year === opts.maxYear,
-            html = '<div class="pika-title">',
-            prev = true,
-            next = true;
-
-        for (arr = [], i = 0; i < 12; i++) {
-            arr.push('<option value="' + i + '"' +
-                (i === month ? ' selected': '') +
-                ((isMinYear && i < opts.minMonth) || (isMaxYear && i > opts.maxMonth) ? 'disabled' : '') + '>' +
-                opts.i18n.months[i] + '</option>');
-        }
-        html += '<div class="pika-label">' + opts.i18n.months[month] + '<select class="pika-select pika-select-month">' + arr.join('') + '</select></div>';
-
-        if (isArray(opts.yearRange)) {
-            i = opts.yearRange[0];
-            j = opts.yearRange[1] + 1;
-        } else {
-            i = year - opts.yearRange;
-            j = 1 + year + opts.yearRange;
-        }
-
-        for (arr = []; i < j && i <= opts.maxYear; i++) {
-            if (i >= opts.minYear) {
-                arr.push('<option value="' + i + '"' + (i === year ? ' selected': '') + '>' + (i) + '</option>');
-            }
-        }
-        html += '<div class="pika-label">' + year + '<select class="pika-select pika-select-year">' + arr.join('') + '</select></div>';
-
-        if (isMinYear && (month === 0 || opts.minMonth >= month)) {
-            prev = false;
-        }
-
-        if (isMaxYear && (month === 11 || opts.maxMonth <= month)) {
-            next = false;
-        }
-
-        html += '<button class="pika-prev' + (prev ? '' : ' is-disabled') + '" type="button">Previous Month</button>';
-        html += '<button class="pika-next' + (next ? '' : ' is-disabled') + '" type="button">Next Month</button>';
-
-        return html += '</div>';
-    },
-
-    renderTable = function(opts, data)
-    {
-        return '<table cellpadding="0" cellspacing="0" class="pika-table">' + renderHead(opts) + renderBody(data) + '</table>';
-    };
-
-
-    /**
-     * Pikaday constructor
-     */
-    window.Pikaday = function(options)
-    {
-        var self = this,
-            opts = self.config(options);
-
-        self._onMouseDown = function(e)
+        removeEvent = function(el, e, callback, capture)
         {
-            if (!self._v) {
-                return;
+            if (hasEventListeners) {
+                el.removeEventListener(e, callback, !!capture);
+            } else {
+                el.detachEvent('on' + e, callback);
             }
-            e = e || window.event;
-            var target = e.target || e.srcElement;
-            if (!target) {
-                return;
-            }
+        },
 
-            if (!hasClass(target, 'is-disabled')) {
-                if (hasClass(target, 'pika-button') && !hasClass(target, 'is-empty')) {
-                    self.setDate(new Date(self._y, self._m, parseInt(target.innerHTML, 10)));
-                    if (opts.bound) {
-                        sto(function() {
-                            self.hide();
-                        }, 100);
+        fireEvent = function(el, eventName, data)
+        {
+            var ev;
+
+            if (document.createEvent) {
+                ev = document.createEvent('HTMLEvents');
+                ev.initEvent(eventName, true, false);
+                ev = extend(ev, data);
+                el.dispatchEvent(ev);
+            } else if (document.createEventObject) {
+                ev = document.createEventObject();
+                ev = extend(ev, data);
+                el.fireEvent('on' + eventName, ev);
+            }
+        },
+
+        trim = function(str)
+        {
+            return str.trim ? str.trim() : str.replace(/^\s+|\s+$/g,'');
+        },
+
+        hasClass = function(el, cn)
+        {
+            return (' ' + el.className + ' ').indexOf(' ' + cn + ' ') !== -1;
+        },
+
+        addClass = function(el, cn)
+        {
+            if (!hasClass(el, cn)) {
+                el.className = (el.className === '') ? cn : el.className + ' ' + cn;
+            }
+        },
+
+        removeClass = function(el, cn)
+        {
+            el.className = trim((' ' + el.className + ' ').replace(' ' + cn + ' ', ' '));
+        },
+
+        isArray = function(obj)
+        {
+            return (/Array/).test(Object.prototype.toString.call(obj));
+        },
+
+        isDate = function(obj)
+        {
+            return (/Date/).test(Object.prototype.toString.call(obj)) && !isNaN(obj.getTime());
+        },
+
+        isLeapYear = function(year)
+        {
+            // solution by Matti Virkkunen: http://stackoverflow.com/a/4881951
+            return year % 4 === 0 && year % 100 !== 0 || year % 400 === 0;
+        },
+
+        getDaysInMonth = function(year, month)
+        {
+            return [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month];
+        },
+
+        setToStartOfDay = function(date)
+        {
+            if (isDate(date)) date.setHours(0,0,0,0);
+        },
+
+        compareDates = function(a,b)
+        {
+            // weak date comparison (use setToStartOfDay(date) to ensure correct result)
+            return a.getTime() === b.getTime();
+        },
+
+        extend = function(to, from, overwrite)
+        {
+            var prop, hasProp;
+            for (prop in from) {
+                hasProp = to[prop] !== undefined;
+                if (hasProp && typeof from[prop] === 'object' && from[prop].nodeName === undefined) {
+                    if (isDate(from[prop])) {
+                        if (overwrite) {
+                            to[prop] = new Date(from[prop].getTime());
+                        }
                     }
+                    else if (isArray(from[prop])) {
+                        if (overwrite) {
+                            to[prop] = from[prop].slice(0);
+                        }
+                    } else {
+                        to[prop] = extend({}, from[prop], overwrite);
+                    }
+                } else if (overwrite || !hasProp) {
+                    to[prop] = from[prop];
+                }
+            }
+            return to;
+        },
+
+        adjustCalendar = function(calendar) {
+            if (calendar.month < 0) {
+                calendar.year -= Math.ceil(Math.abs(calendar.month)/12);
+                calendar.month += 12;
+            }
+            if (calendar.month > 11) {
+                calendar.year += Math.floor(Math.abs(calendar.month)/12);
+                calendar.month -= 12;
+            }
+            return calendar;
+        },
+
+        /**
+         * defaults and localisation
+         */
+        defaults = {
+
+            // bind the picker to a form field
+            field: null,
+
+            // automatically show/hide the picker on `field` focus (default `true` if `field` is set)
+            bound: undefined,
+
+            // position of the datepicker, relative to the field (default to bottom & left)
+            // ('bottom' & 'left' keywords are not used, 'top' & 'right' are modifier on the bottom/left position)
+            position: 'bottom left',
+
+            // the default output format for `.toString()` and `field` value
+            format: 'YYYY-MM-DD',
+
+            // the initial date to view when first opened
+            defaultDate: null,
+
+            // make the `defaultDate` the initial selected value
+            setDefaultDate: false,
+
+            // first day of week (0: Sunday, 1: Monday etc)
+            firstDay: 0,
+
+            // the minimum/earliest date that can be selected
+            minDate: null,
+            // the maximum/latest date that can be selected
+            maxDate: null,
+
+            // number of years either side, or array of upper/lower range
+            yearRange: 10,
+
+            // show week numbers at head of row
+            showWeekNumber: false,
+
+            // used internally (don't config outside)
+            minYear: 0,
+            maxYear: 9999,
+            minMonth: undefined,
+            maxMonth: undefined,
+
+            isRTL: false,
+
+            // Additional text to append to the year in the calendar title
+            yearSuffix: '',
+
+            // Render the month after year in the calendar title
+            showMonthAfterYear: false,
+
+            // how many months are visible
+            numberOfMonths: 1,
+
+            // when numberOfMonths is used, this will help you to choose where the main calendar will be (default `left`, can be set to `right`)
+            // only used for the first display or when a selected date is not visible
+            mainCalendar: 'left',
+
+            // Specify a DOM element to render the calendar in
+            container: undefined,
+
+            // internationalization
+            i18n: {
+                previousMonth : 'Previous Month',
+                nextMonth     : 'Next Month',
+                months        : ['January','February','March','April','May','June','July','August','September','October','November','December'],
+                weekdays      : ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'],
+                weekdaysShort : ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
+            },
+
+            // callback function
+            onSelect: null,
+            onOpen: null,
+            onClose: null,
+            onDraw: null
+        },
+
+
+        /**
+         * templating functions to abstract HTML rendering
+         */
+        renderDayName = function(opts, day, abbr)
+        {
+            day += opts.firstDay;
+            while (day >= 7) {
+                day -= 7;
+            }
+            return abbr ? opts.i18n.weekdaysShort[day] : opts.i18n.weekdays[day];
+        },
+
+        renderDay = function(d, m, y, isSelected, isToday, isDisabled, isEmpty)
+        {
+            if (isEmpty) {
+                return '<td class="is-empty"></td>';
+            }
+            var arr = [];
+            if (isDisabled) {
+                arr.push('is-disabled');
+            }
+            if (isToday) {
+                arr.push('is-today');
+            }
+            if (isSelected) {
+                arr.push('is-selected');
+            }
+            return '<td data-day="' + d + '" class="' + arr.join(' ') + '">' +
+                '<button class="pika-button pika-day" type="button" ' +
+                'data-pika-year="' + y + '" data-pika-month="' + m + '" data-pika-day="' + d + '">' +
+                d +
+                '</button>' +
+                '</td>';
+        },
+
+        renderWeek = function (d, m, y) {
+            // Lifted from http://javascript.about.com/library/blweekyear.htm, lightly modified.
+            var onejan = new Date(y, 0, 1),
+                weekNum = Math.ceil((((new Date(y, m, d) - onejan) / 86400000) + onejan.getDay()+1)/7);
+            return '<td class="pika-week">' + weekNum + '</td>';
+        },
+
+        renderRow = function(days, isRTL)
+        {
+            return '<tr>' + (isRTL ? days.reverse() : days).join('') + '</tr>';
+        },
+
+        renderBody = function(rows)
+        {
+            return '<tbody>' + rows.join('') + '</tbody>';
+        },
+
+        renderHead = function(opts)
+        {
+            var i, arr = [];
+            if (opts.showWeekNumber) {
+                arr.push('<th></th>');
+            }
+            for (i = 0; i < 7; i++) {
+                arr.push('<th scope="col"><abbr title="' + renderDayName(opts, i) + '">' + renderDayName(opts, i, true) + '</abbr></th>');
+            }
+            return '<thead>' + (opts.isRTL ? arr.reverse() : arr).join('') + '</thead>';
+        },
+
+        renderTitle = function(instance, c, year, month, refYear)
+        {
+            var i, j, arr,
+                opts = instance._o,
+                isMinYear = year === opts.minYear,
+                isMaxYear = year === opts.maxYear,
+                html = '<div class="pika-title">',
+                monthHtml,
+                yearHtml,
+                prev = true,
+                next = true;
+
+            for (arr = [], i = 0; i < 12; i++) {
+                arr.push('<option value="' + (year === refYear ? i - c : 12 + i - c) + '"' +
+                    (i === month ? ' selected': '') +
+                    ((isMinYear && i < opts.minMonth) || (isMaxYear && i > opts.maxMonth) ? 'disabled' : '') + '>' +
+                    opts.i18n.months[i] + '</option>');
+            }
+            monthHtml = '<div class="pika-label">' + opts.i18n.months[month] + '<select class="pika-select pika-select-month">' + arr.join('') + '</select></div>';
+
+            if (isArray(opts.yearRange)) {
+                i = opts.yearRange[0];
+                j = opts.yearRange[1] + 1;
+            } else {
+                i = year - opts.yearRange;
+                j = 1 + year + opts.yearRange;
+            }
+
+            for (arr = []; i < j && i <= opts.maxYear; i++) {
+                if (i >= opts.minYear) {
+                    arr.push('<option value="' + i + '"' + (i === year ? ' selected': '') + '>' + (i) + '</option>');
+                }
+            }
+            yearHtml = '<div class="pika-label">' + year + opts.yearSuffix + '<select class="pika-select pika-select-year">' + arr.join('') + '</select></div>';
+
+            if (opts.showMonthAfterYear) {
+                html += yearHtml + monthHtml;
+            } else {
+                html += monthHtml + yearHtml;
+            }
+
+            if (isMinYear && (month === 0 || opts.minMonth >= month)) {
+                prev = false;
+            }
+
+            if (isMaxYear && (month === 11 || opts.maxMonth <= month)) {
+                next = false;
+            }
+
+            if (c === 0) {
+                html += '<button class="pika-prev' + (prev ? '' : ' is-disabled') + '" type="button">' + opts.i18n.previousMonth + '</button>';
+            }
+            if (c === (instance._o.numberOfMonths - 1) ) {
+                html += '<button class="pika-next' + (next ? '' : ' is-disabled') + '" type="button">' + opts.i18n.nextMonth + '</button>';
+            }
+
+            return html += '</div>';
+        },
+
+        renderTable = function(opts, data)
+        {
+            return '<table cellpadding="0" cellspacing="0" class="pika-table">' + renderHead(opts) + renderBody(data) + '</table>';
+        },
+
+
+        /**
+         * Pikaday constructor
+         */
+        Pikaday = function(options)
+        {
+            var self = this,
+                opts = self.config(options);
+
+            self._onMouseDown = function(e)
+            {
+                if (!self._v) {
                     return;
                 }
-                else if (hasClass(target, 'pika-prev')) {
-                    self.prevMonth();
+                e = e || window.event;
+                var target = e.target || e.srcElement;
+                if (!target) {
+                    return;
                 }
-                else if (hasClass(target, 'pika-next')) {
-                    self.nextMonth();
+
+                if (!hasClass(target, 'is-disabled')) {
+                    if (hasClass(target, 'pika-button') && !hasClass(target, 'is-empty')) {
+                        self.setDate(new Date(target.getAttribute('data-pika-year'), target.getAttribute('data-pika-month'), target.getAttribute('data-pika-day')));
+                        if (opts.bound) {
+                            sto(function() {
+                                self.hide();
+                                if (opts.field) {
+                                    opts.field.blur();
+                                }
+                            }, 100);
+                        }
+                        return;
+                    }
+                    else if (hasClass(target, 'pika-prev')) {
+                        self.prevMonth();
+                    }
+                    else if (hasClass(target, 'pika-next')) {
+                        self.nextMonth();
+                    }
                 }
-            }
-            if (!hasClass(target, 'pika-select')) {
-                if (e.preventDefault) {
-                    e.preventDefault();
+                if (!hasClass(target, 'pika-select')) {
+                    if (e.preventDefault) {
+                        e.preventDefault();
+                    } else {
+                        e.returnValue = false;
+                        return false;
+                    }
                 } else {
-                    return e.returnValue = false;
+                    self._c = true;
                 }
-            } else {
-                self._c = true;
-            }
-        };
+            };
 
-        self._onChange = function(e)
-        {
-            e = e || window.event;
-            var target = e.target || e.srcElement;
-            if (!target) {
-                return;
-            }
-            if (hasClass(target, 'pika-select-month')) {
-                self.gotoMonth(target.value);
-            }
-            else if (hasClass(target, 'pika-select-year')) {
-                self.gotoYear(target.value);
-            }
-        };
+            self._onChange = function(e)
+            {
+                e = e || window.event;
+                var target = e.target || e.srcElement;
+                if (!target) {
+                    return;
+                }
+                if (hasClass(target, 'pika-select-month')) {
+                    self.gotoMonth(target.value);
+                }
+                else if (hasClass(target, 'pika-select-year')) {
+                    self.gotoYear(target.value);
+                }
+            };
 
-        self._onInputChange = function(e)
-        {
-            if (hasMoment) {
-                self.setDate(window.moment(opts.field.value, opts.format).toDate());
-            }
-            else {
-                var date = new Date(Date.parse(opts.field.value));
+            self._onInputChange = function(e)
+            {
+                var date;
+
+                if (e.firedBy === self) {
+                    return;
+                }
+                if (hasMoment) {
+                    date = moment(opts.field.value, opts.format);
+                    date = (date && date.isValid()) ? date.toDate() : null;
+                }
+                else {
+                    date = new Date(Date.parse(opts.field.value));
+                }
                 self.setDate(isDate(date) ? date : null);
-            }
-            if (!self._v) {
-                self.show();
-            }
-        };
-
-        self._onInputFocus = function(e)
-        {
-            self.show();
-        };
-
-        self._onInputClick = function(e)
-        {
-            self.show();
-        };
-
-        self._onInputBlur = function(e)
-        {
-            if (!self._c) {
-                self._b = sto(function() {
-                    self.hide();
-                }, 50);
-            }
-            self._c = false;
-        };
-
-        self._onClick = function(e)
-        {
-            e = e || window.event;
-            var target = e.target || e.srcElement,
-                pEl = target;
-            if (!target) {
-                return;
-            }
-            if (!hasEventListeners && hasClass(target, 'pika-select')) {
-                if (!target.onchange) {
-                    target.setAttribute('onchange', 'return;');
-                    addEvent(target, 'change', self._onChange);
+                if (!self._v) {
+                    self.show();
                 }
-            }
-            do {
-                if (hasClass(pEl, 'pika-single')) {
+            };
+
+            self._onInputFocus = function()
+            {
+                self.show();
+            };
+
+            self._onInputClick = function()
+            {
+                self.show();
+            };
+
+            self._onInputBlur = function()
+            {
+                if (!self._c) {
+                    self._b = sto(function() {
+                        self.hide();
+                    }, 50);
+                }
+                self._c = false;
+            };
+
+            self._onClick = function(e)
+            {
+                e = e || window.event;
+                var target = e.target || e.srcElement,
+                    pEl = target;
+                if (!target) {
                     return;
                 }
+                if (!hasEventListeners && hasClass(target, 'pika-select')) {
+                    if (!target.onchange) {
+                        target.setAttribute('onchange', 'return;');
+                        addEvent(target, 'change', self._onChange);
+                    }
+                }
+                do {
+                    if (hasClass(pEl, 'pika-single')) {
+                        return;
+                    }
+                }
+                while ((pEl = pEl.parentNode));
+                if (self._v && target !== opts.trigger) {
+                    self.hide();
+                }
+            };
+
+            self.el = document.createElement('div');
+            self.el.className = 'pika-single' + (opts.isRTL ? ' is-rtl' : '');
+
+            addEvent(self.el, 'mousedown', self._onMouseDown, true);
+            addEvent(self.el, 'change', self._onChange);
+
+            if (opts.field) {
+                if (opts.container) {
+                    opts.container.appendChild(self.el);
+                } else if (opts.bound) {
+                    document.body.appendChild(self.el);
+                } else {
+                    opts.field.parentNode.insertBefore(self.el, opts.field.nextSibling);
+                }
+                addEvent(opts.field, 'change', self._onInputChange);
+
+                if (!opts.defaultDate) {
+                    if (hasMoment && opts.field.value) {
+                        opts.defaultDate = moment(opts.field.value, opts.format).toDate();
+                    } else {
+                        opts.defaultDate = new Date(Date.parse(opts.field.value));
+                    }
+                    opts.setDefaultDate = true;
+                }
             }
-            while ((pEl = pEl.parentNode));
-            if (self._v && target !== opts.field) {
-                self.hide();
+
+            var defDate = opts.defaultDate;
+
+            if (isDate(defDate)) {
+                if (opts.setDefaultDate) {
+                    self.setDate(defDate, true);
+                } else {
+                    self.gotoDate(defDate);
+                }
+            } else {
+                self.gotoDate(new Date());
+            }
+
+            if (opts.bound) {
+                this.hide();
+                self.el.className += ' is-bound';
+                addEvent(opts.trigger, 'click', self._onInputClick);
+                addEvent(opts.trigger, 'focus', self._onInputFocus);
+                addEvent(opts.trigger, 'blur', self._onInputBlur);
+            } else {
+                this.show();
             }
         };
-
-        self.el = document.createElement('div');
-        self.el.className = 'pika-single' + (opts.isRTL ? ' is-rtl' : '');
-
-        addEvent(self.el, 'mousedown', self._onMouseDown, true);
-        addEvent(self.el, 'change', self._onChange);
-
-        if (opts.field) {
-            if (opts.bound) {
-                document.body.appendChild(self.el);
-            } else {
-                opts.field.parentNode.insertBefore(self.el, opts.field.nextSibling);
-            }
-            addEvent(opts.field, 'change', self._onInputChange);
-            
-            if (!opts.defaultDate) {
-                if (hasMoment && opts.field.value) {
-                    opts.defaultDate = window.moment(opts.field.value, opts.format).toDate();
-                } else {
-                    opts.defaultDate = new Date(Date.parse(opts.field.value));
-                }
-                opts.setDefaultDate = true;
-            }
-        }
-
-        var defDate = opts.defaultDate;
-
-        if (isDate(defDate)) {
-            if (opts.setDefaultDate) {
-                self.setDate(defDate);
-            } else {
-                self.gotoDate(defDate);
-            }
-        } else {
-            self.gotoDate(new Date());
-        }
-
-        if (opts.bound) {
-            this.hide();
-            self.el.className += ' is-bound';
-            addEvent(opts.field, 'click', self._onInputClick);
-            addEvent(opts.field, 'focus', self._onInputFocus);
-            addEvent(opts.field, 'blur', self._onInputBlur);
-        } else {
-            this.show();
-        }
-
-    };
 
 
     /**
      * public Pikaday API
      */
-    window.Pikaday.prototype = {
+    Pikaday.prototype = {
 
 
         /**
@@ -464,6 +578,8 @@
 
             opts.bound = !!(opts.bound !== undefined ? opts.field && opts.bound : opts.field);
 
+            opts.trigger = (opts.trigger && opts.trigger.nodeName) ? opts.trigger : opts.field;
+
             var nom = parseInt(opts.numberOfMonths, 10) || 1;
             opts.numberOfMonths = nom > 4 ? 4 : nom;
 
@@ -477,11 +593,13 @@
                 opts.maxDate = opts.minDate = false;
             }
             if (opts.minDate) {
-                opts.minYear = opts.minDate.getFullYear();
+                setToStartOfDay(opts.minDate);
+                opts.minYear  = opts.minDate.getFullYear();
                 opts.minMonth = opts.minDate.getMonth();
             }
             if (opts.maxDate) {
-                opts.maxYear = opts.maxDate.getFullYear();
+                setToStartOfDay(opts.maxDate);
+                opts.maxYear  = opts.maxDate.getFullYear();
                 opts.maxMonth = opts.maxDate.getMonth();
             }
 
@@ -504,7 +622,7 @@
          */
         toString: function(format)
         {
-            return !isDate(this._d) ? '' : hasMoment ? window.moment(this._d).format(format || this._o.format) : this._d.toDateString();
+            return !isDate(this._d) ? '' : hasMoment ? moment(this._d).format(format || this._o.format) : this._d.toDateString();
         },
 
         /**
@@ -512,7 +630,17 @@
          */
         getMoment: function()
         {
-            return hasMoment ? window.moment(this._d) : null;
+            return hasMoment ? moment(this._d) : null;
+        },
+
+        /**
+         * set the current selection from a Moment.js object (if available)
+         */
+        setMoment: function(date, preventOnSelect)
+        {
+            if (hasMoment && moment.isMoment(date)) {
+                this.setDate(date.toDate(), preventOnSelect);
+            }
         },
 
         /**
@@ -526,7 +654,7 @@
         /**
          * set the current selection
          */
-        setDate: function(date)
+        setDate: function(date, preventOnSelect)
         {
             if (!date) {
                 this._d = null;
@@ -549,13 +677,14 @@
             }
 
             this._d = new Date(date.getTime());
-            this._d.setHours(0,0,0,0);
+            setToStartOfDay(this._d);
             this.gotoDate(this._d);
 
             if (this._o.field) {
                 this._o.field.value = this.toString();
+                fireEvent(this._o.field, 'change', { firedBy: this });
             }
-            if (typeof this._o.onSelect === 'function') {
+            if (!preventOnSelect && typeof this._o.onSelect === 'function') {
                 this._o.onSelect.call(this, this.getDate());
             }
         },
@@ -565,11 +694,43 @@
          */
         gotoDate: function(date)
         {
+            var newCalendar = true;
+
             if (!isDate(date)) {
                 return;
             }
-            this._y = date.getFullYear();
-            this._m = date.getMonth();
+
+            if (this.calendars) {
+                var firstVisibleDate = new Date(this.calendars[0].year, this.calendars[0].month, 1),
+                    lastVisibleDate = new Date(this.calendars[this.calendars.length-1].year, this.calendars[this.calendars.length-1].month, 1),
+                    visibleDate = date.getTime();
+                // get the end of the month
+                lastVisibleDate.setMonth(lastVisibleDate.getMonth()+1);
+                lastVisibleDate.setDate(lastVisibleDate.getDate()-1);
+                newCalendar = (visibleDate < firstVisibleDate.getTime() || lastVisibleDate.getTime() < visibleDate);
+            }
+
+            if (newCalendar) {
+                this.calendars = [{
+                    month: date.getMonth(),
+                    year: date.getFullYear()
+                }];
+                if (this._o.mainCalendar === 'right') {
+                    this.calendars[0].month += 1 - this._o.numberOfMonths;
+                }
+            }
+
+            this.adjustCalendars();
+        },
+
+        adjustCalendars: function() {
+            this.calendars[0] = adjustCalendar(this.calendars[0]);
+            for (var c = 1; c < this._o.numberOfMonths; c++) {
+                this.calendars[c] = adjustCalendar({
+                    month: this.calendars[0].month + c,
+                    year: this.calendars[0].year
+                });
+            }
             this.draw();
         },
 
@@ -583,28 +744,22 @@
          */
         gotoMonth: function(month)
         {
-            if (!isNaN( (month = parseInt(month, 10)) )) {
-                this._m = month < 0 ? 0 : month > 11 ? 11 : month;
-                this.draw();
+            if (!isNaN(month)) {
+                this.calendars[0].month = parseInt(month, 10);
+                this.adjustCalendars();
             }
         },
 
         nextMonth: function()
         {
-            if (++this._m > 11) {
-                this._m = 0;
-                this._y++;
-            }
-            this.draw();
+            this.calendars[0].month++;
+            this.adjustCalendars();
         },
 
         prevMonth: function()
         {
-            if (--this._m < 0) {
-                this._m = 11;
-                this._y--;
-            }
-            this.draw();
+            this.calendars[0].month--;
+            this.adjustCalendars();
         },
 
         /**
@@ -613,9 +768,25 @@
         gotoYear: function(year)
         {
             if (!isNaN(year)) {
-                this._y = parseInt(year, 10);
-                this.draw();
+                this.calendars[0].year = parseInt(year, 10);
+                this.adjustCalendars();
             }
+        },
+
+        /**
+         * change the minDate
+         */
+        setMinDate: function(value)
+        {
+            this._o.minDate = value;
+        },
+
+        /**
+         * change the maxDate
+         */
+        setMaxDate: function(value)
+        {
+            this._o.maxDate = value;
         },
 
         /**
@@ -630,7 +801,8 @@
                 minYear = opts.minYear,
                 maxYear = opts.maxYear,
                 minMonth = opts.minMonth,
-                maxMonth = opts.maxMonth;
+                maxMonth = opts.maxMonth,
+                html = '';
 
             if (this._y <= minYear) {
                 this._y = minYear;
@@ -645,21 +817,73 @@
                 }
             }
 
-            this.el.innerHTML = renderTitle(this) + this.render(this._y, this._m);
+            for (var c = 0; c < opts.numberOfMonths; c++) {
+                html += '<div class="pika-lendar">' + renderTitle(this, c, this.calendars[c].year, this.calendars[c].month, this.calendars[0].year) + this.render(this.calendars[c].year, this.calendars[c].month) + '</div>';
+            }
+
+            this.el.innerHTML = html;
 
             if (opts.bound) {
-                var pEl  = opts.field,
-                    left = pEl.offsetLeft,
-                    top  = pEl.offsetTop + pEl.offsetHeight;
+                if(opts.field.type !== 'hidden') {
+                    sto(function() {
+                        opts.trigger.focus();
+                    }, 1);
+                }
+            }
+
+            if (typeof this._o.onDraw === 'function') {
+                var self = this;
+                sto(function() {
+                    self._o.onDraw.call(self);
+                }, 0);
+            }
+        },
+
+        adjustPosition: function()
+        {
+            if (this._o.container) return;
+            var field = this._o.trigger, pEl = field,
+                width = this.el.offsetWidth, height = this.el.offsetHeight,
+                viewportWidth = window.innerWidth || document.documentElement.clientWidth,
+                viewportHeight = window.innerHeight || document.documentElement.clientHeight,
+                scrollTop = window.pageYOffset || document.body.scrollTop || document.documentElement.scrollTop,
+                left, top, clientRect;