diff --git a/src/js/src/tracker/app.css b/src/js/src/tracker/app.css
new file mode 100644
index 0000000..3d26a29
--- /dev/null
+++ b/src/js/src/tracker/app.css
@@ -0,0 +1,43 @@
+#left {
+ display: inline-block;
+}
+
+
+thead tr, tr:nth-child(2n) {
+ background-color: #cccccc;
+}
+
+form[name="one_event"] {
+ width: 95%;
+}
+
+fieldset input[name="command_line"],fieldset input[name="event_memo"] {
+ width:100%;
+}
+
+form[name="one_event"] label.short-control {
+ width: fit-content;
+}
+
+#event-logs table {
+ width:95%;
+}
+
+#event-logs th.col-position-* {
+ width:100px;
+}
+
+#event-logs th.col-interval {
+ width:100px;
+}
+
+
+#event-logs th.col-action {
+ width:100px;
+}
+
+#event-logs .button-small {
+ font-size:85%;
+}
+
+
diff --git a/src/js/src/tracker/app.html b/src/js/src/tracker/app.html
new file mode 100644
index 0000000..8fad546
--- /dev/null
+++ b/src/js/src/tracker/app.html
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+ Solitude/Tracker
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | アクション |
+ メモ |
+ ラップタイム |
+ 開始 |
+ 終了 |
+
+
+
+
+ |
+ 読み込まれました |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/js/src/tracker/app.js b/src/js/src/tracker/app.js
new file mode 100644
index 0000000..2af77f5
--- /dev/null
+++ b/src/js/src/tracker/app.js
@@ -0,0 +1,208 @@
+main(window);
+
+function main(global) {
+ const tracker = {};
+ global.app = tracker;
+ global.addEventListener("load", initialize);
+ global.addEventListener("keydown", checkShortKeys);
+
+ function q(element, selector) {
+ // 引数一つはdocumentを起点に検索する
+ if (arguments.length <= 1)
+ return q(global.document, element);
+
+ /*
+ * 任意のデータをクエリと解釈する。
+ * 不適切なクエリの検索結果は空配列とした。
+ * エラーのほうが適切なのかな……?
+ */
+ var result = []
+ if (typeof selector == 'string') {
+ result = element.querySelectorAll(selector);
+ if(result.length == 1) {
+ result = result[0];
+ }
+ }
+ return result;
+ }
+
+ function initialize() {
+ /*
+ * アプリケーションオブジェクトの設定
+ * 後でdefinePropertiesでゲッターを設定する
+ */
+ const ui = {
+ now: q("#now"),
+ accesskey: document.forms.one_event.elements.accesskey,
+ command: document.forms.one_event.elements.command_line,
+ memo: document.forms.one_event.elements.event_memo,
+ time: document.forms.one_event.elements.event_time,
+ addEvent: document.forms.one_event.elements.event_add,
+ initRec: q("#initial_record"),
+ get active() {
+ return document.activeElement;
+ },
+ eventLogs: q("#event-logs"),
+ tmpl: {
+ eventRec: q("#event_record"),
+ }
+ }
+
+ const command = {
+ readyCommand: readyCommand,
+ readyMemo: readyMemo,
+ addEvent: addEvent,
+ getNow: getNow,
+ }
+
+ const data = {
+ domain: {
+ latest: Date.now()
+ },
+
+ control: {
+ get accesskey() {
+ return tracker.ui.accesskey.checked;
+ }
+ },
+ }
+
+ const key = {
+ //特殊文字
+ enter: "Enter",
+ shift: "Shift",
+
+ //通常文字
+ c : "C",
+ }
+ Object.defineProperties(tracker, {
+ ui: {value: ui},
+ command: {value: command},
+ key: {value: key},
+ data: {value: data},
+ });
+
+ // 初期行の変更
+ const start = new Date();
+ const time = start.toLocaleTimeString();
+ q(tracker.ui.initRec, "td.col-position-start").textContent = time;
+
+ // 現在時刻の初回呼び出し、後は自動で1秒ごとに更新。
+ updateNow(1000);
+
+ /*
+ * コントローラの設定
+ */
+ tracker.ui.addEvent.addEventListener('click', (event) => {
+ addEvent(tracker.ui.memo.value, tracker.ui.time.value);
+ })
+
+ }
+
+ function addEvent(memo, datetime) {
+
+ const newTime = datetime || tracker.command.getNow();
+ const newMemo = memo.toString();
+ const newEvent = q(tracker.ui.tmpl.eventRec.content.cloneNode(true), "tr");
+ let tr = q(tracker.ui.eventLogs, "tbody tr ");
+
+ q(newEvent, "td.col-position-start").textContent = newTime;
+ q(newEvent, "td.col-memo").textContent = newMemo;
+ if (tr instanceof Element) { // i.e. 行数1行
+ insertNewEvent(newEvent, tr, newTime);
+ } else if (tr.length > 0) { // i.e. 行数2行以上
+ insertNewEvent(newEvent, tr[0], newTime);
+ } else { // i.e. 行数0行
+ q(tracker.ui.eventLogs, "tbody").appendChild(newEvent);
+ }
+
+ function insertNewEvent(first, second, endTime) {
+ // second行の開始は作成時に入っている。
+ const startTime = q(second, "td.col-position-start").textContent;
+ q(second, "td.col-position-end").textContent = endTime;
+ q(second, "td.col-interval").textContent = getInterval(startTime, endTime);
+
+ q(tracker.ui.eventLogs, "tbody").insertBefore(first, second);
+ }
+
+
+ function getInterval(start, end) {
+ const old = tracker.data.domain.latest
+ tracker.data.domain.latest = Date.now();
+ let interval = tracker.data.domain.latest - old;
+ //秒単位にする。ミリ秒以下の数字を先に0にすることで割り切れるようにしてる。
+ interval = ( interval - (interval % 1000) ) / 1000
+ /*
+ const date = (new Date()).toLocaleDateString("ja-JP");
+ const before = new Date(date.replaceAll('/', '-') + "T" + start);
+ const after = new Date(date.replaceAll('/', '-') + "T" + end);
+ // Dateの起算が1970/1/1/ 00:00:00 (UTC)のため、時刻として時間を求められる。
+ const interval = before.getTime() - after.getTime();
+ */
+ return interval;
+ }
+ }
+
+ function getNow() {
+ return tracker.ui.now.textContent.replace(/^.* /, "");
+ }
+
+ function readyCommand() {
+ readyOf(tracker.ui.command);
+ }
+
+ function readyMemo() {
+ readyOf(tracker.ui.memo);
+ }
+
+ function readyOf(ui) {
+ if(tracker.ui.active == ui) {
+ console.log("do nothing");
+ return;
+ }
+
+ neutral();
+ ui.focus();
+ }
+
+ function neutral() {
+ for ( elem in tracker.ui) {
+ if((elem instanceof Element) && (elem.hasFocus())) {
+ elem.blur();
+ }
+ }
+ }
+
+ function checkShortKeys(event) {
+ if((!event.shiftKey) || (!tracker.data.control.accesskey)) {
+ return;
+ }
+ const binds = tracker.key
+
+ switch (event.key) {
+ case binds.c:
+ if( tracker.ui.command !== tracker.ui.active ) {
+ tracker.command.readyCommand();
+ } else {
+ console.log("do nothing");
+ }
+ break;
+
+ case binds.enter:
+ tracker.command.readyMemo();
+ break;
+
+ }
+
+ }
+
+
+ function updateNow(interval) {
+ const start = new Date();
+ const time = start.toLocaleDateString() + " " + start.toLocaleTimeString();
+ tracker.ui.now.textContent = time;
+
+ setTimeout(updateNow, interval, interval);
+ }
+
+}