Repository navigation
Expand file tree
/
Copy pathapp.js
More file actions
799 lines (725 loc) · 37.8 KB
/
Copy pathapp.js
File metadata and controls
799 lines (725 loc) · 37.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
/*
* Application controller: wires DOM events to state operations, renders, and persists.
* All listeners are registered once in init() using event delegation.
*
* Destructive actions are not confirmed up front. They run immediately and show an Undo toast,
* which restores a snapshot of the whole state taken just before the action.
*/
(function (global) {
'use strict';
const { State, Storage, Ui, Render, Dnd } = global.StackTask;
const IDS = [
'taskInput', 'addTaskBtn', 'popButton', 'taskStack', 'taskPanel', 'archivedTasks', 'completedTasks',
'hero', 'heroEmpty', 'heroTask', 'heroText', 'heroClock', 'heroStatus', 'heroTally', 'dial', 'dialArc',
'timerButton', 'timerLabel', 'thenLine', 'thenLink', 'thenNone', 'stackRest', 'emptyTitle', 'emptySub',
'archivedCount', 'completedCount', 'tabsList', 'addCategoryBtn', 'renameCategoryBtn', 'deleteCategoryBtn',
'exportBtn', 'importBtn', 'importFile', 'liveRegion', 'toast', 'toastMessage', 'toastAction', 'toastClose',
'dialog', 'dialogForm', 'dialogTitle', 'dialogMessage', 'dialogField', 'dialogLabel', 'dialogInput',
'dialogError', 'dialogOk', 'dialogCancel'
];
const BASE_TITLE = 'One Tomato';
// A tomato noticed later than this after it ripened (a background tab) gets a visible toast, not only a spoken note
const LATE_NOTICE_MS = 5000;
// One run of a timer: the same task started again is a new run
function timerKey(task) {
return `${task.id}|${task.timerStartedAt}`;
}
function shorten(text) {
const flat = String(text).replace(/\s+/g, ' ').trim();
return flat.length > 40 ? flat.slice(0, 39) + '…' : flat;
}
function quote(text) {
return `"${shorten(text)}"`;
}
class TaskStackApp {
constructor(win) {
this.win = win;
this.doc = win.document;
this.startupNotices = [];
this.notes = [];
this.edit = null;
// Timer runs this page has seen running: past their boundary they ripened at it, however late a tick comes
this.watching = new Set();
// In this session only (never stored): task id -> timeSpent of a timer stopped on load past its boundary,
// with all its time kept. It shows as ripe until its time changes.
this.ripeAt = new Map();
this.lateNotice = false;
// A ripe note that came while the page was hidden: shown when the user is back
this.pendingNotice = null;
this.storage = this.openStorage();
this.canSave = true;
this.saveErrorShown = false;
this.init();
}
openStorage() {
try {
const ls = this.win.localStorage;
if (ls) return ls;
} catch (e) {
// Access can throw when storage is blocked
}
this.startupNotices.push('Browser storage is unavailable. Tasks will not be kept after closing this page.');
return Storage.createMemoryStorage();
}
init() {
this.refs = {};
IDS.forEach(id => { this.refs[id] = this.doc.getElementById(id); });
this.refs.stackCount = this.doc.querySelector('.stack-count');
this.refs.stackLabel = this.doc.querySelector('.stack-label');
this.ui = Ui.create(this.win, this.refs);
this.renderer = Render.create(this.doc, this.refs, { isRipe: task => this.isShownRipe(task) });
this.loadFromStorage();
// A timer that ran past its tomato while the page was closed is stopped now, with its time kept;
// one still running in another category opens that category, so the first screen shows it
if (this.reconcile(Date.now())) this.save();
this.startupNotices.push(...this.notes.splice(0));
this.lateNotice = false;
this.bindEvents();
Dnd.attach(this.refs, {
onReorderTasks: (draggedId, targetId, above) => {
if (State.reorderTasks(this.state, draggedId, targetId, above).ok) this.commit();
},
onReorderCategories: (dragged, target, before) => {
if (State.reorderCategories(this.state, dragged, target, before).ok) this.commit();
}
});
this.render();
this.focusInputOnDesktop();
this.startupNotices.forEach(message => this.ui.notify(message, { kind: 'error' }));
}
// A keyboard is at hand on desktop, so start in the input; on touch that would only raise the on-screen keyboard
focusInputOnDesktop() {
try {
if (this.win.matchMedia('(hover: hover) and (pointer: fine)').matches) this.refs.taskInput.focus();
} catch (e) {
// No matchMedia: leave focus alone
}
}
// ---- Persistence -------------------------------------------------
loadFromStorage() {
const loaded = Storage.load(this.storage);
this.state = State.createState(loaded.data);
this.canSave = loaded.canSave;
if (loaded.warnings.length > 0) this.startupNotices.push(loaded.warnings.join('\n'));
}
save() {
if (!this.canSave) return;
const result = Storage.save(this.storage, this.state);
if (!result.ok) {
console.error('Failed to save tasks:', result.error);
if (!this.saveErrorShown) {
this.saveErrorShown = true;
this.ui.notify('Could not save your tasks (browser storage is full or blocked). Export a backup to keep them.', { kind: 'error' });
}
}
}
// Re-renders while keeping keyboard focus on a sensible element.
render() {
// A rename lives in the row it started in; if that row is about to be rebuilt, save it first
if (this.edit && !(this.edit.hero && State.sameId(this.edit.id, this.topId()))) this.edit.finish(true);
const token = this.captureFocus();
this.renderer.renderAll(this.state);
this.applyOpenRow();
this.restoreFocus(token);
this.syncTicker();
this.updateTitle(Date.now());
}
// On touch the row actions are folded away; tapping a row opens it (one at a time)
toggleRow(item) {
this.openId = State.sameId(this.openId, item.dataset.id) ? null : item.dataset.id;
this.applyOpenRow();
}
applyOpenRow() {
[this.refs.heroTask, ...this.itemsOf(this.refs.taskStack)].forEach(node => {
node.classList.toggle('open', this.openId != null && State.sameId(node.dataset.id, this.openId));
});
}
commit() {
this.reconcile(Date.now());
this.ui.hideUndo();
this.render();
this.save();
}
// ---- Timer rules ---------------------------------------------------
// Applied before anything is shown or saved, always with the same `now`:
// - a timer past its tomato boundary is stopped (never completed). One this page was running is
// recorded at exactly the boundary; one met for the first time keeps all the time it logged
// - a running timer whose task is no longer on top of its category is paused
// - a running timer is always on the first screen: when the user switches to another category
// (options.switched) it is paused; otherwise (a load, another tab, an import) the view follows it
// options.completing: the id of a task about to be done, whose own note would only repeat what Done says.
// Returns true when it changed something; what to tell the user is collected in this.notes.
reconcile(now, options) {
const opts = options || {};
const ripened = State.settleRipe(this.state, now, task => this.watching.has(timerKey(task)));
const paused = State.pauseBuried(this.state, now);
const view = State.keepTimerInView(this.state, now, !opts.switched);
ripened.forEach(({ task, late, kept }) => {
if (kept) this.ripeAt.set(String(task.id), task.timeSpent);
if (opts.completing != null && State.sameId(task.id, opts.completing)) return;
if (kept) {
this.notes.push(`${quote(task.text)} ran past its tomato while you were away. Timer stopped; all ${State.formatDuration(State.elapsedMs(task))} kept.`);
} else if (late > LATE_NOTICE_MS) {
this.lateNotice = true;
this.notes.push(`Ripe! ${quote(task.text)} — 25 minutes done. The timer stopped at 25:00.`);
} else {
if (this.doc.hidden) this.lateNotice = true;
this.notes.push(`Ripe! ${quote(task.text)} — 25 minutes done.`);
}
});
paused.forEach(task => this.notes.push(`Paused ${quote(task.text)} — new thing on top.`));
view.paused.forEach(task => this.notes.push(opts.switched
? `Paused ${quote(task.text)} — switched to ${this.state.currentCategory}.`
: `Paused ${quote(task.text)} — one timer at a time.`));
this.state.tasks.forEach(task => { if (State.isRunning(task)) this.watching.add(timerKey(task)); });
return view.followed || ripened.length + paused.length + view.paused.length > 0;
}
// Paused on a boundary, or stopped past one on load in this session (with its time unchanged since)
isShownRipe(task) {
if (State.isRunning(task)) return false;
const id = String(task.id);
return State.isRipe(task) || (this.ripeAt.has(id) && this.ripeAt.get(id) === task.timeSpent);
}
// Announces a message together with any timer notes collected since the last one.
// A tomato that ripened unseen (a background tab) is shown in a toast as well, once the page is visible,
// and so is anything with `toast` set (news about a task the first screen no longer shows).
say(message, toast) {
const extra = this.notes.splice(0).join(' ');
const text = extra ? `${message} ${extra}`.trim() : message;
const show = this.lateNotice || !!toast;
this.lateNotice = false;
if (!text) return;
if (show && !this.doc.hidden) {
this.ui.notify(text);
return;
}
if (show) this.pendingNotice = text;
this.ui.announce(text);
}
// The countdown while a timer runs; "Ripe!" while the top task waits after its tomato, for a user in another tab
updateTitle(now) {
const running = State.runningTask(this.state);
if (running) {
this.doc.title = `${State.formatRemaining(State.remainingMs(running, now))} · ${shorten(running.text)}`;
return;
}
const index = State.topTaskIndex(this.state);
const top = index === -1 ? null : this.state.tasks[index];
this.doc.title = top && this.isShownRipe(top) ? `Ripe! · ${shorten(top.text)}` : BASE_TITLE;
}
// ---- Timer ticking -------------------------------------------------
// The running task's clock is updated in place once a second; nothing else is re-rendered.
syncTicker() {
const running = State.runningTask(this.state);
if (running && !this.ticker && typeof this.win.setInterval === 'function') {
this.ticker = this.win.setInterval(() => this.tick(), 1000);
} else if (!running && this.ticker) {
this.win.clearInterval(this.ticker);
this.ticker = null;
}
}
tick() {
const now = Date.now();
if (this.reconcile(now)) {
// A tomato just ripened (or the tab woke up late): redraw the hero and say so once
this.render();
this.save();
this.say('');
return;
}
if (!State.runningTask(this.state)) { this.syncTicker(); return; }
this.renderer.paintDial(this.state, now);
this.updateTitle(now);
}
toggleTimer(id) {
const before = this.findTask(this.state.tasks, id);
if (!before) return;
const wasRunning = State.isRunning(before);
const now = Date.now();
this.reconcile(now);
const task = this.findTask(this.state.tasks, id);
if (wasRunning && !State.isRunning(task)) {
// The timer ripened before this click was handled: it is already stopped, do not start it again
this.commit();
this.say('');
return;
}
if (!State.toggleTimer(this.state, id, now).ok) return;
this.commit();
const spent = State.elapsedMs(this.findTask(this.state.tasks, id), now);
this.say(wasRunning
? `Timer paused for ${quote(task.text)} at ${State.formatDuration(spent)}.`
: `Timer started for ${quote(task.text)}.`);
}
// Applies `change` (a State operation) and offers to undo it.
withUndo(message, change) {
// Settle first, so the snapshot (and the action) see the timers as they really are now
this.reconcile(Date.now());
const snapshot = State.clone(this.state);
if (!change().ok) return false;
this.commit();
const extra = this.notes.splice(0).join(' ');
this.lateNotice = false;
this.ui.notify(extra ? `${message} ${extra}` : message, {
actionLabel: 'Undo',
onAction: () => {
this.state = snapshot;
this.commit();
this.say('Undone.');
}
});
return true;
}
// Shows an operation's error message, if it has one.
report(result) {
if (!result.ok && result.error) this.ui.notify(result.error, { kind: 'error' });
return result.ok;
}
// ---- Focus -------------------------------------------------------
captureFocus() {
const { refs } = this;
const active = this.doc.activeElement;
if (!active || active === this.doc.body || typeof active.closest !== 'function') return null;
if (refs.heroTask.contains(active)) return { type: 'hero', node: active, keyboard: this.isKeyboardFocus(active) };
if (refs.tabsList.contains(active)) return { type: 'tab' };
for (const list of [refs.taskStack, refs.archivedTasks, refs.completedTasks]) {
if (!list.contains(active)) continue;
const item = active.closest('.task-item, .history-item');
if (!item) return null;
return {
type: 'item',
list,
id: item.dataset.id,
index: this.itemsOf(list).indexOf(item),
key: active.dataset.action || active.className,
keyboard: this.isKeyboardFocus(active)
};
}
return null;
}
isKeyboardFocus(element) {
try {
return element.matches(':focus-visible');
} catch (e) {
return true;
}
}
itemsOf(list) {
return Array.from(list.children).filter(node => node.dataset && node.dataset.id !== undefined);
}
restoreFocus(token) {
if (!token) return;
const { refs } = this;
if (token.type === 'tab') {
const tab = refs.tabsList.querySelector('.tab-active .tab-button');
if (tab) tab.focus();
} else if (token.type === 'hero') {
// The hero's buttons never leave the page, so focus normally stays put. It is moved on only
// when its button went away (the last task is done) or is disabled now.
const usable = !refs.heroTask.hidden && !token.node.disabled;
if (usable) {
if (this.doc.activeElement !== token.node) token.node.focus();
} else if (token.keyboard) {
const fallback = [refs.popButton, refs.timerButton].find(button => !button.disabled && !refs.heroTask.hidden);
(fallback || refs.taskInput).focus();
}
} else {
const items = this.itemsOf(token.list);
let item = items.find(node => State.sameId(node.dataset.id, token.id));
// A row that was moved to the top is now the hero: focus follows it there
if (!item && token.list === refs.taskStack && !refs.heroTask.hidden && State.sameId(refs.heroTask.dataset.id, token.id)) {
item = refs.heroTask;
}
// The item itself is gone: only a keyboard user needs to be moved on to its neighbour
if (!item && token.keyboard) item = items[Math.min(token.index, items.length - 1)];
if (item) {
// The same button if it is still enabled; otherwise the nearest enabled one after it
const all = Array.from(item.querySelectorAll('button'));
const enabled = all.filter(button => !button.disabled);
const keyOf = button => button.dataset.action || button.className;
let target = enabled.find(button => keyOf(button) === token.key);
if (!target) {
const at = all.findIndex(button => keyOf(button) === token.key);
target = enabled.find(button => all.indexOf(button) > at)
|| enabled.filter(button => all.indexOf(button) < at).pop()
|| enabled[0];
}
if (target) target.focus();
} else if (token.keyboard) {
const section = token.list.closest('.collapsible-section');
const toggle = section && section.querySelector('.section-toggle');
(toggle || refs.taskInput).focus();
}
}
}
// ---- Events ------------------------------------------------------
bindEvents() {
const { refs } = this;
refs.taskInput.addEventListener('keydown', event => {
// isComposing / keyCode 229: Enter that confirms an IME composition is not a submit
if (event.key !== 'Enter' || event.isComposing || event.keyCode === 229) return;
this.submitInput();
});
refs.addTaskBtn.addEventListener('click', () => this.submitInput());
refs.popButton.addEventListener('click', () => this.popTask());
refs.addCategoryBtn.addEventListener('click', () => this.promptNewCategory());
refs.renameCategoryBtn.addEventListener('click', () => this.promptRenameCategory(this.state.currentCategory));
refs.deleteCategoryBtn.addEventListener('click', () => this.deleteCategory(this.state.currentCategory));
refs.tabsList.addEventListener('click', event => {
const tab = event.target.closest('.tab');
if (tab) this.switchCategory(tab.dataset.category);
});
refs.tabsList.addEventListener('dblclick', event => {
const tab = event.target.closest('.tab');
if (tab) this.promptRenameCategory(tab.dataset.category);
});
refs.tabsList.addEventListener('keydown', event => this.onTabKeydown(event));
// The hero (top task) and the list below it share the same row behaviour
[refs.heroTask, refs.taskStack].forEach(container => {
container.addEventListener('click', event => {
const button = event.target.closest('.task-action-btn, .hero-btn');
if (!button) {
const item = event.target.closest('.task-item, .top-task');
if (item && !(event.detail > 1) && !item.classList.contains('editing') && !event.target.closest('button, input')) this.toggleRow(item);
return;
}
const id = button.dataset.id;
switch (button.dataset.action) {
case 'timer': this.toggleTimer(id); break;
case 'up': this.moveTask(id, 'up'); break;
case 'down': this.moveTask(id, 'down'); break;
case 'archive': this.archiveTask(id); break;
case 'remove': this.removeTask(id); break;
default: break;
}
});
// Double-click (or F2 with the keyboard) edits a task's text in place
container.addEventListener('dblclick', event => {
const item = event.target.closest && event.target.closest('.task-item, .top-task');
if (item && !event.target.closest('button, input')) this.startEdit(item, false);
});
container.addEventListener('keydown', event => {
if (event.key !== 'F2') return;
const item = event.target.closest && event.target.closest('.task-item, .top-task');
if (!item || event.target.closest('input')) return;
event.preventDefault();
this.startEdit(item, true);
});
});
refs.archivedTasks.addEventListener('click', event => {
const restore = event.target.closest('.restore-btn');
const del = event.target.closest('.delete-archived-btn');
if (restore) this.restoreArchived(restore.dataset.id);
else if (del) this.deleteArchived(del.dataset.id);
});
refs.completedTasks.addEventListener('click', event => {
const restore = event.target.closest('.restore-completed-btn');
const del = event.target.closest('.delete-completed-btn');
if (restore) this.restoreCompleted(restore.dataset.id);
else if (del) this.deleteCompleted(del.dataset.id);
});
this.doc.querySelectorAll('.collapsible-section .section-toggle').forEach(toggle => {
toggle.addEventListener('click', () => {
const section = toggle.closest('.collapsible-section');
const collapsed = section.classList.toggle('collapsed');
toggle.setAttribute('aria-expanded', String(!collapsed));
});
});
refs.exportBtn.addEventListener('click', () => this.exportData());
refs.importBtn.addEventListener('click', () => refs.importFile.click());
refs.importFile.addEventListener('change', event => this.importData(event));
// Ctrl/Cmd+Z undoes the last destructive action, except while typing in a field
this.doc.addEventListener('keydown', event => {
if (!(event.ctrlKey || event.metaKey) || event.shiftKey || String(event.key || '').toLowerCase() !== 'z') return;
const tag = event.target && event.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (event.target && event.target.isContentEditable)) return;
if (this.ui.hasUndo()) {
event.preventDefault();
this.ui.runToastAction();
}
});
// Back from another tab: a tomato that ripened while ticks were throttled or frozen is shown at once
this.doc.addEventListener('visibilitychange', () => {
if (this.doc.hidden) return;
if (this.pendingNotice) {
this.ui.notify(this.pendingNotice);
this.pendingNotice = null;
}
this.tick();
});
// Another tab changed the data: pick it up instead of overwriting it later
this.win.addEventListener('storage', event => {
if (event.storageArea === this.storage && (event.key === Storage.KEY || event.key === null)) {
this.ui.hideUndo();
this.loadFromStorage();
const changed = this.reconcile(Date.now());
this.render();
if (changed) this.save();
this.notes.length = 0;
this.lateNotice = false;
}
});
}
// Arrow keys / Home / End move between categories; Alt+Arrow moves the category itself.
onTabKeydown(event) {
const tab = event.target.closest && event.target.closest('.tab');
if (!tab) return;
const names = this.state.categories;
const index = names.indexOf(tab.dataset.category);
const count = names.length;
const left = event.key === 'ArrowLeft';
const right = event.key === 'ArrowRight';
if (event.altKey && (left || right)) {
event.preventDefault();
const neighbour = names[index + (right ? 1 : -1)];
if (neighbour && State.reorderCategories(this.state, names[index], neighbour, left).ok) {
this.commit();
this.say(`Moved category ${quote(tab.dataset.category)} ${right ? 'right' : 'left'}.`);
}
return;
}
let next = -1;
if (right) next = (index + 1) % count;
else if (left) next = (index - 1 + count) % count;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = count - 1;
if (next === -1) return;
event.preventDefault();
this.switchCategory(names[next]);
}
// ---- Actions -----------------------------------------------------
submitInput() {
const input = this.refs.taskInput;
if (this.addTask(input.value)) input.value = '';
input.focus();
}
addTask(text) {
if (!text.trim()) return false;
const result = State.push(this.state, text);
if (!this.report(result)) return false;
this.commit();
const count = State.inCategory(this.state.tasks, this.state.currentCategory).length;
this.say(`Added ${quote(result.task.text)} to ${this.state.currentCategory}. ${count} ${count === 1 ? 'task' : 'tasks'} in the stack.`);
return true;
}
popTask() {
const now = Date.now();
// A tomato that ripens on this very click needs no "Ripe!" of its own: Done says its time
this.reconcile(now, { completing: this.topId() });
const index = State.topTaskIndex(this.state);
if (index === -1) return;
const task = this.state.tasks[index];
const spent = State.elapsedMs(task, now);
const detail = spent > 0 ? ` in ${State.formatDuration(spent)}` : '';
const more = State.inCategory(this.state.tasks, this.state.currentCategory).length > 1;
this.withUndo(`Done ${quote(task.text)}${detail}. ${more ? 'Next tomato!' : "Plate's clean!"}`, () => State.pop(this.state));
}
topId() {
const index = State.topTaskIndex(this.state);
return index === -1 ? null : this.state.tasks[index].id;
}
findTask(list, id) {
return list.find(task => State.sameId(task.id, id));
}
moveTask(id, direction) {
const task = this.findTask(this.state.tasks, id);
if (!task || !State.moveTask(this.state, id, direction).ok) return;
this.commit();
const total = State.inCategory(this.state.tasks, task.category).length;
this.say(`Moved ${quote(task.text)} ${direction}. Position ${State.positionFromTop(this.state, id)} of ${total}.`);
}
// Swaps the row's text for an input; Enter or leaving the field saves, Escape cancels.
startEdit(item, viaKeyboard) {
if (item.querySelector('.task-edit')) return;
const task = this.findTask(this.state.tasks, item.dataset.id);
const textNode = item.querySelector('.task-text');
const isHero = item === this.refs.heroTask;
if (!task || !textNode) return;
const input = this.doc.createElement('input');
input.type = 'text';
input.className = 'task-edit';
input.value = task.text;
input.maxLength = Math.max(State.MAX_TASK_LENGTH, task.text.length);
input.setAttribute('aria-label', `Edit task "${task.text}"`);
input.setAttribute('autocomplete', 'off');
input.setAttribute('enterkeyhint', 'done');
item.classList.add('editing');
// The hero keeps its text in place (hidden by CSS, not removed) so nothing below it shifts
// between the mouse going down on Done and coming up
if (!isHero) textNode.hidden = true;
textNode.parentNode.insertBefore(input, textNode);
let done = false;
const finish = save => {
if (done) return;
done = true;
this.edit = null;
const value = input.value;
if (input.parentNode) input.parentNode.removeChild(input);
textNode.hidden = false;
item.classList.remove('editing');
let row = item;
if (save && value.trim() && value.trim() !== task.text) {
const result = State.renameTask(this.state, task.id, value);
if (this.report(result)) {
this.commit();
this.say(`Renamed to ${quote(result.task.text)}.`);
row = [this.refs.heroTask, ...this.itemsOf(this.refs.taskStack)].find(node => !node.hidden && State.sameId(node.dataset.id, task.id));
}
}
if (viaKeyboard && row) {
// Back to where F2 was pressed: the task's own text in the hero, the first button in a row
const target = row === this.refs.heroTask
? this.refs.heroText
: Array.from(row.querySelectorAll('button')).find(b => !b.disabled);
if (target) target.focus();
}
};
this.edit = { id: task.id, hero: isHero, finish };
input.addEventListener('keydown', event => {
if (event.isComposing || event.keyCode === 229) return;
if (event.key === 'Enter') { event.preventDefault(); finish(true); }
else if (event.key === 'Escape') { event.preventDefault(); finish(false); }
});
input.addEventListener('blur', () => finish(true));
input.focus();
input.select();
}
removeTask(id) {
const task = this.findTask(this.state.tasks, id);
if (task) this.withUndo(`Deleted ${quote(task.text)}.`, () => State.removeTask(this.state, id));
}
archiveTask(id) {
const task = this.findTask(this.state.tasks, id);
if (task) this.withUndo(`Archived ${quote(task.text)}.`, () => State.archiveTask(this.state, id));
}
restoreArchived(id) {
const task = this.findTask(this.state.archived, id);
if (task) this.withUndo(`Restored ${quote(task.text)} to the bottom of the stack.`, () => State.restoreArchived(this.state, id));
}
deleteArchived(id) {
const task = this.findTask(this.state.archived, id);
if (task) this.withUndo(`Deleted ${quote(task.text)}.`, () => State.deleteArchived(this.state, id));
}
restoreCompleted(id) {
const task = this.findTask(this.state.completed, id);
if (task) this.withUndo(`Restored ${quote(task.text)} to the bottom of the stack.`, () => State.restoreCompleted(this.state, id));
}
deleteCompleted(id) {
const task = this.findTask(this.state.completed, id);
if (task) this.withUndo(`Deleted ${quote(task.text)}.`, () => State.deleteCompleted(this.state, id));
}
categoryError(name, ignoring) {
const checked = State.validateCategoryName(this.state, name, ignoring);
return checked.ok ? '' : checked.error;
}
async promptNewCategory() {
const name = await this.ui.askText({
title: 'New category',
label: 'Category name',
confirmLabel: 'Add',
maxLength: State.MAX_CATEGORY_LENGTH,
validate: value => this.categoryError(value, null)
});
if (name) this.createCategory(name);
}
async promptRenameCategory(oldName) {
const newName = await this.ui.askText({
title: 'Rename category',
label: 'Category name',
value: oldName,
confirmLabel: 'Rename',
maxLength: State.MAX_CATEGORY_LENGTH,
validate: value => this.categoryError(value, oldName)
});
if (newName) this.renameCategory(oldName, newName);
}
// A new category is opened at once, so a running timer is left behind: it is paused, as on a tab switch
createCategory(name) {
const running = State.runningTask(this.state);
if (this.report(State.createCategory(this.state, name))) {
this.reconcile(Date.now(), { switched: true });
this.commit();
this.say(`Category ${quote(this.state.currentCategory)} added.`, !!running);
}
}
renameCategory(oldName, newName) {
if (this.report(State.renameCategory(this.state, oldName, newName))) this.commit();
}
deleteCategory(name) {
if (this.state.categories.length === 1) {
this.ui.notify('Cannot delete the last category!', { kind: 'error' });
return;
}
const count = State.countInCategory(this.state, name);
const detail = count > 0 ? ` and its ${count} ${count === 1 ? 'task' : 'tasks'}` : '';
this.withUndo(`Deleted category ${quote(name)}${detail}.`, () => State.deleteCategory(this.state, name));
}
// A running timer stays on the first screen: switching away from its task pauses it (or ripens it, if its
// tomato is already up), and a toast says so. Start on the way back carries on from the time kept.
switchCategory(name) {
if (name === this.state.currentCategory) return;
const running = State.runningTask(this.state);
if (!State.switchCategory(this.state, name).ok) return;
this.reconcile(Date.now(), { switched: true });
this.commit();
if (running) this.say('', true);
}
// ---- Export / import ----------------------------------------------
exportData() {
try {
const blob = new this.win.Blob([Storage.serialize(this.state)], { type: 'application/json' });
const url = this.win.URL.createObjectURL(blob);
const link = this.doc.createElement('a');
link.href = url;
link.download = Storage.exportFilename();
link.style.display = 'none';
this.doc.body.appendChild(link);
link.click();
this.doc.body.removeChild(link);
this.win.setTimeout(() => this.win.URL.revokeObjectURL(url), 1000);
this.ui.announce('Backup downloaded.');
} catch (e) {
console.error('Failed to export data:', e);
this.ui.notify('Failed to export data.', { kind: 'error' });
}
}
async importData(event) {
const input = event.target;
const file = input.files[0];
if (!file) return;
try {
const result = Storage.parseImport(await file.text());
if (!result.ok) {
this.ui.notify(`Import failed: ${result.error} Your current tasks were not changed.`, { kind: 'error' });
return;
}
const confirmed = await this.ui.askConfirm({
title: 'Import tasks',
message: `Replace your current tasks with this file (${Storage.summarize(result.data)})? You can undo this right afterwards.`,
confirmLabel: 'Replace',
danger: true
});
if (!confirmed) return;
const imported = result.data;
this.withUndo('Import complete.', () => {
this.state = State.createState(imported);
this.canSave = true;
return { ok: true };
});
} catch (e) {
console.error('Failed to import data:', e);
this.ui.notify('Import failed: the file could not be read. Your current tasks were not changed.', { kind: 'error' });
} finally {
input.value = '';
}
}
}
function start() {
new TaskStackApp(global);
}
if (global.document.readyState === 'loading') {
global.document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})(window);