Implemented entry creation, pagination. Some restyling and bugfixes.

- Bug fix in ts_api:list_entries/3. Case statement matching on atoms but
  input is a list (string).
- Bug fix in ts_api:put_entry/3. Was expecting the wrong result from
  ts_entry:new/1.
- Bug fix in ts_entry:list/4. Code crashed when the starting offset was
  greater than the total number of elements. Now returns [].
- Fixed ts_json:encode_datetime/1 and ts_json:decode_datetime/1 to handle
  millisecond values in the datetime string (per ISO standard).
- Broke out ``control-links`` style to a top-level class.
- Added showdown.js, a JS Markdown processor. Not hooked up to anything
  yet but intend to display entry notes with Markdown.
- Added code for entry pagination. Loads the most recent 20 entries and
  loads more upon demand in batches of 20.
- Fixed bug in login routine that kept the user edit fields from being
  pre-populated.
- Rewrote the loadEntries function to double for new entries and loading
  more existing entries.
- Commented displayEntries. Also refactored into displayNewerEntries,
  which pushed new entries on to the top of the stack, and
  displayOlderEntries, which tags them onto the bottom.
- Implemented hidden notes field for new entry input.
- Implemented new entry creation.
- Created a helper function to ISO format a Date object.
- Expanded entry template to show control links (edit, show notes, del).
- Activated the 'load more entries' button.
This commit is contained in:
Jonathan Bernard
2011-03-07 16:43:40 -06:00
parent c185c8cd81
commit 1b1e31059b
14 changed files with 1720 additions and 114 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7 -4
View File
@@ -392,13 +392,13 @@ list_entries(YArg, Username, TimelineId) ->
% read or default the Start
Start = case lists:keyfind("start", 1, QueryData) of
{start, StartVal} -> list_to_integer(StartVal);
{"start", StartVal} -> list_to_integer(StartVal);
false -> 0
end,
% read or default the Length
Length = case lists:keyfind("length", 1, QueryData) of
{length, LengthVal} ->
{"length", LengthVal} ->
erlang:min(list_to_integer(LengthVal), 500);
false -> 50
end,
@@ -443,7 +443,8 @@ put_entry(YArg, Username, TimelineId) ->
case ts_entry:new(NewRecord) of
% record created
ok -> [{status, 201}, make_json_200(YArg, NewRecord)];
{ok, CreatedRecord} ->
[{status, 201}, make_json_200(YArg, CreatedRecord)];
% will not create, record exists
{error, {record_exists, ExistingRecord}} ->
@@ -456,7 +457,9 @@ put_entry(YArg, Username, TimelineId) ->
{content, "application/json", JSONResponse};
_Error -> make_json_500(YArg)
OtherError ->
io:format("Could not create entry: ~p", [OtherError]),
make_json_500(YArg)
end.
post_entry(YArg, Username, TimelineId, EntryId) ->
+4 -1
View File
@@ -51,7 +51,10 @@ when is_integer(Start) and is_integer(Length) ->
% return only the range selected.
% TODO: can we do this without selecting all entries?
lists:sublist(SortedEntries, Start + 1, Length);
case length(SortedEntries) > Start of
true -> lists:sublist(SortedEntries, Start + 1, Length);
false -> []
end;
list({Username, Timeline}, StartDateTime, EndDateTime, OrderFun) ->
+6 -3
View File
@@ -40,8 +40,8 @@ record_to_ejson(Record=#ts_entry{}) ->
{notes, Record#ts_entry.notes}]}.
encode_datetime({{Year, Month, Day}, {Hour, Minute, Second}}) ->
lists:flatten(io_lib:format("~4.10.0B-~2.10.0B-~2.10.0BT~2.10.0B:~2.10.0B:~2.10.0BZ",
[Year, Month, Day, Hour, Minute, Second])).
lists:flatten(io_lib:format("~4.10.0B-~2.10.0B-~2.10.0BT~2.10.0B:~2.10.0B:~2.10.0B~3.10.0BZ",
[Year, Month, Day, Hour, Minute, Second, 000])).
ejson_to_record(_Empty=#ts_timeline{}, EJSON) ->
{struct, Fields} = EJSON,
@@ -71,7 +71,10 @@ decode_datetime(DateTimeString) ->
re:split(DateString, "-", [{return, list}]),
[HourString, MinuteString, SecondString] =
re:split(TimeString, ":", [{return, list}]),
case re:split(TimeString, "[:\\.]", [{return, list}]) of
[HS, MS, SS, _MSS] -> [HS, MS, SS];
[HS, MS, SS] -> [HS, MS, SS]
end,
Date = {list_to_integer(YearString), list_to_integer(MonthString),
list_to_integer(DayString)},
+94 -42
View File
@@ -43,6 +43,23 @@ body {
body {
width: 80%; } }
.control-links {
color: #c5c5b9;
float: right;
display: block;
height: 100%;
font-weight: bold;
font-size: smaller; }
.control-links:hover {
color: #252d42; }
.control-links a {
color: inherit;
text-decoration: none;
margin-right: 0.5em; }
.control-links a:hover {
color: #b34c2b;
text-decoration: underline; }
.bar {
font-family: Helvetica, sans-serif;
color: #252d42;
@@ -53,42 +70,39 @@ body {
border-bottom-width: 0;
padding: 0.1em 1em 0.1em 1em;
overflow: hidden; }
.bar div.control-links {
color: #979681;
float: right;
display: block;
height: 100%;
font-weight: bold;
font-size: smaller; }
.bar div.control-links:hover {
color: #252d42; }
.bar div.control-links a {
color: inherit;
text-decoration: none;
margin-right: 0.5em; }
.bar div.control-links a:hover {
color: #b34c2b;
text-decoration: underline; }
.last-bar {
border-bottom-width: 0.2em;
border-color: #979681;
border-style: solid;
border-width: 0.2em;
-moz-border-radius-bottomright: 0.5em;
-webkit-border-bottom-right-radius: 0.5em;
border-bottom-right-radius: 0.5em;
-moz-border-radius-bottomleft: 0.5em;
-webkit-border-bottom-left-radius: 0.5em;
border-bottom-left-radius: 0.5em; }
border-bottom-left-radius: 0.5em;
background-color: #e6dec7;
padding: 0.1em 1em 0.1em 1em;
overflow: hidden; }
#more-entries a {
border: 1px solid #979681;
background: #f6f3ea;
display: block;
color: #626150;
text-decoration: none;
font-size: smaller;
float: left; }
#more-entries a:hover {
color: #b34c2b; }
#more-entries {
overflow: hidden; }
#more-entries div {
float: right;
left: -50%;
position: relative; }
#more-entries div a {
position: relative;
float: right;
left: 50%;
border: 1px solid #979681;
padding: 0.1em;
background: #f6f3ea;
color: #626150;
text-decoration: none;
font-size: smaller; }
#more-entries div a:hover {
color: #b34c2b; }
.bar form {
border-top: solid 1px #979681;
@@ -158,17 +172,27 @@ body {
width: 100%;
float: left; }
#new-entry form {
border: 0;
margin: 0;
padding: 0; }
#new-entry input {
color: #626150; }
#new-entry #add-notes {
display: none;
padding: 0.5em 0 0.5em 2em; }
#new-entry #new-entry-input {
margin-right: 1em; }
#new-entry {
-moz-border-radius-bottomright: 0.5em;
-webkit-border-bottom-right-radius: 0.5em;
border-bottom-right-radius: 0.5em;
-moz-border-radius-bottomleft: 0.5em;
-webkit-border-bottom-left-radius: 0.5em;
border-bottom-left-radius: 0.5em;
border-bottom: solid #979681 0.2em;
margin-bottom: 1em; }
#new-entry form {
border: 0;
margin: 0;
padding: 0; }
#new-entry input {
color: #626150; }
#new-entry #add-notes {
display: none;
padding: 0.5em 0 0.5em 2em; }
#new-entry #new-entry-input {
margin-right: 1em;
width: 15em; }
.entry-bar {
background-color: #e6dec7;
@@ -176,9 +200,37 @@ body {
border-style: solid;
border-width: 0.2em;
border-bottom-width: 0;
padding: 0; }
.entry-bar .details .entry-notes {
display: none; }
padding: 0.1em 1em 0.1em 1em;
overflow: hidden; }
.entry-bar .id {
float: left;
-moz-border-radius: 0.5em;
-webkit-border-radius: 0.5em;
border-radius: 0.5em;
padding: 0 0.3em 0 0.3em;
background: #b34c2b;
color: #c5c5b9;
font-weight: bold;
min-width: 2em;
text-align: right; }
.entry-bar .details {
float: left; }
.entry-bar .details .entry-mark {
padding-left: 0.5em;
font-size: medium;
font-weight: bold;
font-family: Helvetica, sans-serif; }
.entry-bar .details .entry-notes {
display: none;
padding-left: 1.5em; }
.top-entry {
-moz-border-radius-topleft: 0.5em;
-webkit-border-top-left-radius: 0.5em;
border-top-left-radius: 0.5em;
-moz-border-radius-topright: 0.5em;
-webkit-border-top-right-radius: 0.5em;
border-top-right-radius: 0.5em; }
#login-dialog {
font-size: small; }
+88 -39
View File
@@ -52,6 +52,28 @@ body {
@media all and (max-device-width: 480) { body { width: 80%; }}
.control-links {
color: lighten($greyTxt, 40%);
float: right;
display: block;
height: 100%;
font-weight: bold;
font-size: smaller;
&:hover { color: $txt; }
a {
color: inherit; //lighten($greyTxt, 20%);
text-decoration: none;
margin-right: 0.5em;
&:hover {
color: $obor;
text-decoration: underline;
}
}
}
.bar {
font-family: Helvetica, sans-serif;
@@ -64,47 +86,44 @@ body {
padding: 0.1em 1em 0.1em 1em;
overflow: hidden;
div.control-links {
color: lighten($greyTxt, 20%);
float: right;
display: block;
height: 100%;
font-weight: bold;
font-size: smaller;
&:hover { color: $txt; }
a {
color: inherit; //lighten($greyTxt, 20%);
text-decoration: none;
margin-right: 0.5em;
&:hover {
color: $obor;
text-decoration: underline;
}
}
}
}
.last-bar {
border-bottom-width: $iBorWidth;
border-color: $bbor;
border-style: solid;
border-width: $iBorWidth;
@include rounded2(bottom, right, 0.5em);
@include rounded2(bottom, left, 0.5em);
background-color: $bbg;
padding: 0.1em 1em 0.1em 1em;
overflow: hidden;
}
#more-entries a {
border: 1px solid $bbor;
background: lighten($bbg, 10%);
display: block;
color: $greyTxt;
text-decoration: none;
font-size: smaller;
float: left;
#more-entries {
&:hover {
color: $obor;
overflow: hidden;
div {
float: right;
left: -50%;
position: relative;
a {
position: relative;
float: right;
left: 50%;
border: 1px solid $bbor;
padding: 0.1em;
background: lighten($bbg, 10%);
color: $greyTxt;
text-decoration: none;
font-size: smaller;
&:hover { color: $obor; }
}
}
}
@@ -125,9 +144,7 @@ body {
overflow: hidden;
}
input.text-input {
border: 1px solid $bbor;
}
input.text-input { border: 1px solid $bbor; }
.form-col {
overflow: hidden;
@@ -205,6 +222,12 @@ body {
#new-entry {
@include rounded2(bottom, right, 0.5em);
@include rounded2(bottom, left, 0.5em);
border-bottom: solid $bbor $iBorWidth;
margin-bottom: 1em;
form {
border: 0;
margin: 0;
@@ -218,7 +241,10 @@ body {
padding: 0.5em 0 0.5em 2em;
}
#new-entry-input { margin-right: 1em; }
#new-entry-input {
margin-right: 1em;
width: 15em;
}
}
.entry-bar {
@@ -228,20 +254,43 @@ body {
border-style: solid;
border-width: $iBorWidth;
border-bottom-width: 0;
padding: 0;
padding: 0.1em 1em 0.1em 1em;
overflow: hidden;
.id {
float: left;
@include rounded(0.5em);
padding: 0 0.3em 0 0.3em;
background: $obor;
color: lighten($greyTxt, 40%);
font-weight: bold;
min-width: 2em;
text-align: right;
}
.details {
.entry-mark { }
float: left;
.entry-mark {
padding-left: 0.5em;
font-size: medium;
font-weight: bold;
font-family: Helvetica, sans-serif;
}
.entry-notes {
display: none;
padding-left: 1.5em;
}
}
}
.top-entry {
@include rounded2(top, left, 0.5em);
@include rounded2(top, right, 0.5em);
}
#login-dialog {
font-size: small;
+1361
View File
File diff suppressed because it is too large Load Diff
+138 -15
View File
@@ -4,11 +4,17 @@ var activeTimeline = [];
var entries = [];
var newEntryBar;
var moreEntriesbar;
var currentEntryOffset = 0;
var loadLength = 20;
/* Setup after the document is ready for manipulation. */
$(document).ready(function(){
// looked up once and remembered
newEntryBar = $("#new-entry");
moreEntriesBar = $("#more-entries");
// wire the login dialog using jQuery UI
$("#login-dialog").dialog({
@@ -92,9 +98,8 @@ function loadUser(username) {
$("#username").text("- " + user.username);
// pre-populate the editable user-info fields
// TODO: not working
$("#fullname-input").text(user.name);
$("#email-input").text(user.email);
$("#fullname-input").val(user.name);
$("#email-input").val(user.email);
// set the active timeline to the first in the list
// TODO: implement a mechanism to remember the last used timeline
@@ -109,7 +114,7 @@ function loadUser(username) {
// choices
// load the entries for this timeline
loadEntries(user, activeTimeline)
loadEntries(user, activeTimeline, "new")
},
error: function(jqXHR, textStatus, error) {
@@ -120,19 +125,31 @@ function loadUser(username) {
});
}
/* Read the first 50 entries for a timeline. */
function loadEntries(user, timeline) {
/* Read entries for a timeline. */
function loadEntries(user, timeline, order) {
// call the API list_entries function via AJAX
$.ajax({
url: "/ts_api/entries/" + user.username + "/" + timeline.timeline_id + "?order=desc",
url: "/ts_api/entries/" + user.username + "/"
+ timeline.timeline_id + "?order=asc&start="
+ currentEntryOffset + "&length=" + loadLength,
type: "GET",
success: function(data, textStatus, jqXHR) {
entries = data.entries;
// push the entries onto the page
displayEntries(entries)
if (order == "new") {
// reverse the list so that the oldest are first
entries.reverse()
// push the entries onto the page
displayNewerEntries(entries)
} else {
displayOlderEntries(entries)
}
// update our current offset
currentEntryOffset += loadLength;
},
error: function(jqXHR, textStatus, error) {
@@ -141,11 +158,50 @@ function loadEntries(user, timeline) {
});
}
/* Push the entries onto the top of the entry display. */
function displayEntries(entries) {
/* Push the entries onto the top of the entry display. This function assumes
* that the entries are sorted by id, ascending (0, 1, 2). Each new entry goes
* on top, pushing down all existing entries. So the first entry in the
* argument list ends up at the bottom of the new entries. */
function displayNewerEntries(entries) {
// for each entry
_.each(entries, function(entry) {
newEntryBar.after(ich.entry(entry));
// remove the existing top-entry designation
$(".top-entry").removeClass("top-entry");
// create the new entry from the entry template (see ICanHas.js)
var entryElem = ich.entry(entry);
// mark the new entry as the current top-entry
entryElem.addClass("top-entry");
// push the entry on after the new-entry bar (on top of existing
// entries).
newEntryBar.after(entryElem);
// TODO: animate? If so, may need to pause after each to allow
// some animation to take place. Going for a continuous push-down
// stack effect.
});
}
/* Push the entries onto the bottom of the entry display. The function assumes
* that the entries are sorted by id, descending (2, 1, 0). Each new entry goes
* at the bottom of the stack of entries, extending the stack downwards. So the
* last entry in the list is at the very bottom of the stack. */
function displayOlderEntries(entries) {
// for each entry
_.each(entries, function(entry) {
// create the new entry from the template (ICanHas.js)
var entryElem = ich.entry(entry);
// place the entry on top of the bottom of the page
moreEntriesBar.before(entryElem);
// perhaps animate, see comments at the end of displayNewerEntries()
});
}
@@ -185,8 +241,75 @@ function updateTimeline(event) {
/* Show/hide the add notes panel. */
function showNewNotes(event) { $("#add-notes").slideToggle("slow"); }
/* Create a new entry based on the user's input in the new-entry panel. */
function newEntry(event) {
alert("TODO: create entry vi AJAX");
function toggleEntryNotes(event, entryId) {
var selector = "#" + entryId + " .entry-notes";
$(selector).slideToggle("slow");
event.preventDefault();
}
function editEntry(event, entryId) {
var selector = "#" + entryId;
alert("TODO: implement edit entry. Called for '" + selector + "'");
event.preventDefault();
}
/* Create a new entry based on the user's input in the new-entry panel. */
function newEntry(event) {
var mark = $("#new-entry-input").val();
var notes = $("#new-notes-input").val();
var timestamp = getUTCTimestamp(); // timestamp is handled on client.
// This is important. It means that the timestamps are completely in the
// hands of the user. Timestamps from this service can not be considered
// secure. This service is not a punch-card, but a time-tracker, voluntary
// and editable by the user.
var payload = JSON.stringify(
{ mark: mark, notes: notes, timestamp: timestamp });
$.ajax({url: "/ts_api/entries/" + user.username
+ "/" + activeTimeline.timeline_id,
type: "PUT",
data: payload,
error: function(jqXHR, textStatus, error) {
// TODO: better error handling.
alert("Error creating entry: \n" + jqXHR.responseText); },
success: function(data, textStatus, jqXHR) {
// TODO: check that data.status == "ok"
// grab the entry data
var newEntry = data.entry;
// push the entry on top of the stack
displayNewerEntries([newEntry]);
// clear the input
$("#new-entry-input").val("");
$("#new-notes-input").val("");
}});
event.preventDefault();
}
/* Delete an entry. */
function deleteEntry(event, entryId) {
}
/* Generate a UTC timestamp string in ISO format.
* Javascript 1.8.5 has Date.toJSON(), which is equivalent to this function,
* but is only supported in Firefox 4. */
function getUTCTimestamp() {
var d = new Date();
function pad(n){return n<10 ? '0'+n : n}
return d.getUTCFullYear()+'-'
+ pad(d.getUTCMonth()+1)+'-'
+ pad(d.getUTCDate())+'T'
+ pad(d.getUTCHours())+':'
+ pad(d.getUTCMinutes())+':'
+ pad(d.getUTCSeconds())+'Z';
}
+20 -8
View File
@@ -5,20 +5,30 @@
<title>TimeStamper - Simple Time Tracking</title>
<link rel="stylesheet" media="screen" href="/css/dot-luv/jquery-ui-1.8.10.custom.css" type="text/css"/>
<link rel="stylesheet" media="screen" href="/css/ts-screen.css" type="text/css"/>
<!-- Needed for IE, but I'm not going to support IE with this tool. -->
<!--<script type="text/javascript" src="/js/json2.js"></script>-->
<script type="text/javascript" src="/js/jquery-1.5.min.js"></script>
<script type="text/javascript" src="/js/jquery-ui-1.8.10.custom.min.js"></script>
<script type="text/javascript" src="/js/underscore-min.js"></script>
<script type="text/javascript" src="/js/ICanHaz.min.js"></script>
<script type="text/javascript" src="/js/ICanHaz.js"></script>
<script type="text/javascript" src="/js/ts.js"></script>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
<script id="entry" type="text/html">
<div class="entry-bar" id="entry-{{entry_id}}">
<span class="id">{{entry_id}}.</span>
<span class="details">
<span class="entry-mark">{{mark}}</span>
<span class="entry-notes">{{notes}}</span>
</span>
<span class="id">{{entry_id}}</span>
<div class="details">
<div class="entry-mark">{{mark}}</div>
<div class="entry-notes">{{notes}}</div>
</div>
<div class="control-links">
<a onclick="toggleEntryNotes(event, 'entry-{{entry_id}}')"
href="#">show notes</a>
<a onclick="editEntry(event, 'entry-{{entry_id}}')"
href="#">edit</a>
<a onclick="deleteEntry(event, 'entry-{{entry_id}}')"
href="#">del</a>
</div>
</div>
</script>
</head>
@@ -122,8 +132,10 @@
</form>
</div>
<div id="more-entries" class="last-bar">
<a href="#" onclick="loadMoreEntries();">load more entries</a>
<div id="more-entries" class="last-bar top-entry">
<div>
<a href="#" onclick="loadEntries(user, activeTimeline, 'old');event.preventDefault()">load more entries</a>
</div>
</div>
<div id="login-dialog" title="Login">