Merge pull request 'Plan and run an ordered mobile Today queue' (#280) from timmy/279-mobile-today-queue into main
This commit is contained in:
commit
1fdf6f417d
|
|
@ -82,8 +82,9 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.later-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.later-actions button { min-height:44px; width:100%; }
|
||||
.today-actions button { min-height:44px; width:100%; }
|
||||
.detail-defer { grid-column:1/-1; max-width:100%; }
|
||||
.detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
|
|
@ -315,6 +316,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
</div>
|
||||
<div class="work-filters" aria-label="Filter My Work">
|
||||
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
|
||||
<button class="work-filter" data-work-filter="today" aria-pressed="false">Today <span data-work-count="today">0</span></button>
|
||||
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
|
||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||||
|
|
@ -762,6 +764,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
<script src="static/authored-outbox.js"></script>
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/today-work.js"></script>
|
||||
<script src="static/later-work.js"></script>
|
||||
<script src="static/detail-defer.js"></script>
|
||||
<script src="static/pick-work.js"></script>
|
||||
|
|
@ -804,7 +807,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal'));
|
||||
function openMobileWork() {
|
||||
const counts = countMyWork(activeMyWork);
|
||||
const filter = counts.attention ? 'attention' : 'all';
|
||||
const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all');
|
||||
qs('[data-work-filter="' + filter + '"]').click();
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
qs('#my-work').focus();
|
||||
|
|
@ -844,7 +847,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
let selectedWorkMilestone = 'all';
|
||||
try {
|
||||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||||
if (['all', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
||||
if (['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
||||
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
|
||||
if (savedMilestone) selectedWorkMilestone = savedMilestone;
|
||||
} catch (e) {
|
||||
|
|
@ -886,6 +889,11 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
let activeFlushLogin = '';
|
||||
let activeMyWork = [];
|
||||
let laterMyWork = [];
|
||||
let todayMyWork = [];
|
||||
const todayWork = createTodayWork({
|
||||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
});
|
||||
const laterWork = createLaterWork({
|
||||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
|
|
@ -1246,8 +1254,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
}
|
||||
|
||||
const workSession = createWorkSession({
|
||||
getItems: () => activeMyWork,
|
||||
getFilter: () => selectedWorkFilter,
|
||||
getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork,
|
||||
getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter,
|
||||
getMilestone: () => selectedWorkMilestone,
|
||||
onOpen: openWorkSessionItem,
|
||||
onProgress: state => {
|
||||
|
|
@ -1370,7 +1378,11 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
});
|
||||
activeMyWork = partitioned.active;
|
||||
laterMyWork = partitioned.later;
|
||||
todayMyWork = todayWork.reconcile(lastMyWork, {
|
||||
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
|
||||
});
|
||||
const counts = countMyWork(activeMyWork);
|
||||
counts.today = todayMyWork.length;
|
||||
counts.later = laterMyWork.length;
|
||||
counts.draft = lastDrafts.length;
|
||||
Object.entries(counts).forEach(([filter, count]) => {
|
||||
|
|
@ -1402,6 +1414,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
}
|
||||
|
||||
function activeWorkStreams() {
|
||||
if (selectedWorkFilter === 'today') return ['issue', 'pull', 'review'];
|
||||
if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review'];
|
||||
if (selectedWorkFilter === 'issue') return ['issue'];
|
||||
if (selectedWorkFilter === 'pull') return ['pull'];
|
||||
|
|
@ -1515,7 +1528,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
qs('#load-more-notifications').hidden = true;
|
||||
return;
|
||||
}
|
||||
const visible = selectedWorkFilter === 'later' ?
|
||||
const visible = selectedWorkFilter === 'today' ?
|
||||
filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ?
|
||||
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) :
|
||||
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
|
||||
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||||
|
|
@ -1539,16 +1553,22 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
const laterActions = selectedWorkFilter === 'later' ?
|
||||
'<div class="later-actions"><button type="button" data-later-restore data-work-index="' + index + '">Bring back now</button></div>' :
|
||||
'<div class="later-actions" aria-label="Defer this work"><button type="button" data-later-preset="today" data-work-index="' + index + '">Later today</button><button type="button" data-later-preset="tomorrow" data-work-index="' + index + '">Tomorrow</button></div>';
|
||||
const alreadyToday = todayWork.contains(item);
|
||||
const todayPosition = todayWork.position(item);
|
||||
const todayActions = selectedWorkFilter === 'today' ?
|
||||
'<div class="today-actions" aria-label="Reorder Today"><button type="button" data-today-move="up" data-work-index="' + index + '"' + (todayPosition.can_up ? '' : ' disabled') + '>Move up</button><button type="button" data-today-move="down" data-work-index="' + index + '"' + (todayPosition.can_down ? '' : ' disabled') + '>Move down</button><button type="button" data-today-remove data-work-index="' + index + '">Remove from Today</button></div>' :
|
||||
'<div class="today-actions"><button type="button" data-today-add data-work-index="' + index + '"' + (alreadyToday ? ' disabled' : '') + '>' + (alreadyToday ? 'Added to Today' : 'Add to Today') + '</button></div>';
|
||||
const planningActions = todayActions + laterActions;
|
||||
if (item.is_review) {
|
||||
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + laterActions + '</article>';
|
||||
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||
}
|
||||
if (item.kind === 'issue') {
|
||||
return '<article class="my-work-card"><a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + laterActions + '</article>';
|
||||
return '<article class="my-work-card"><a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||
}
|
||||
if (item.kind === 'pull') {
|
||||
return '<article class="my-work-card"><a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + laterActions + '</article>';
|
||||
return '<article class="my-work-card"><a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||
}
|
||||
return '<article class="my-work-card"><a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + laterActions + '</article>';
|
||||
return '<article class="my-work-card"><a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + planningActions + '</article>';
|
||||
}).join('') : '<div class="muted">' + (incomplete ?
|
||||
'More work is available. Load the next page.' :
|
||||
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '</div>';
|
||||
|
|
@ -1599,6 +1619,29 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
refreshMyWorkView();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-today-add]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const result = todayWork.add(lastMyWork[Number(button.dataset.workIndex)]);
|
||||
qs('#my-work-action-status').textContent = result === 'full' ?
|
||||
'Today is limited to 5 items. Remove one before adding more.' :
|
||||
(result === 'added' ? 'Added to Today without changing Gitea.' : 'This item is already in Today.');
|
||||
refreshMyWorkView();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-today-remove]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
todayWork.remove(lastMyWork[Number(button.dataset.workIndex)]);
|
||||
qs('#my-work-action-status').textContent = 'Removed from Today without changing Gitea.';
|
||||
refreshMyWorkView();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-today-move]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
todayWork.move(lastMyWork[Number(button.dataset.workIndex)], button.dataset.todayMove);
|
||||
refreshMyWorkView();
|
||||
document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus();
|
||||
});
|
||||
});
|
||||
const allIds = notificationIds(visible);
|
||||
const ids = allIds.slice(0, Math.min(lastNotifications.length, 50));
|
||||
const bulkBar = qs('#bulk-mark-read-bar');
|
||||
|
|
@ -3485,7 +3528,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
|
||||
qs('#refresh').addEventListener('click', load);
|
||||
qs('#start-work-session').addEventListener('click', () => {
|
||||
if (!filterMyWork(lastMyWork, selectedWorkFilter).length) {
|
||||
const sessionItems = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter);
|
||||
if (!sessionItems.length) {
|
||||
qs('#my-work-action-status').textContent = 'No visible work to start.';
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v19';
|
||||
const CACHE = 'stackchain-dashboard-shell-v20';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const SHELL = [
|
||||
BASE,
|
||||
|
|
@ -18,6 +18,7 @@ const SHELL = [
|
|||
BASE + 'static/authored-outbox.js',
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/later-work.js',
|
||||
BASE + 'static/detail-defer.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
|
|
|
|||
91
frontend/today-work.js
Normal file
91
frontend/today-work.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
const prefix = 'stackchain.today-work.v1.';
|
||||
|
||||
function identity(item) {
|
||||
if (!item) return '';
|
||||
const kind = item.is_review ? 'review' : (item.kind || 'work');
|
||||
const number = Number.isInteger(item.number) ? item.number : '';
|
||||
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
|
||||
return [kind, item.repository || '', number, notification].join(':');
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function read() {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return [];
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || '[]');
|
||||
return Array.isArray(value) ? value.filter(id => typeof id === 'string' && id) : [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(ids) {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return false;
|
||||
try {
|
||||
if (ids.length) storage.setItem(key, JSON.stringify(ids));
|
||||
else storage.removeItem(key);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function add(item) {
|
||||
if (!storageKey()) return 'unavailable';
|
||||
const id = identity(item);
|
||||
if (!id) return 'unavailable';
|
||||
const ids = read();
|
||||
if (ids.includes(id)) return 'exists';
|
||||
if (ids.length >= limit) return 'full';
|
||||
ids.push(id);
|
||||
return write(ids) ? 'added' : 'unavailable';
|
||||
}
|
||||
|
||||
function remove(item) {
|
||||
const id = identity(item);
|
||||
const ids = read();
|
||||
const next = ids.filter(candidate => candidate !== id);
|
||||
return next.length !== ids.length && write(next);
|
||||
}
|
||||
|
||||
function move(item, direction) {
|
||||
const ids = read();
|
||||
const index = ids.indexOf(identity(item));
|
||||
const target = direction === 'up' ? index - 1 : direction === 'down' ? index + 1 : -1;
|
||||
if (index < 0 || target < 0 || target >= ids.length) return false;
|
||||
[ids[index], ids[target]] = [ids[target], ids[index]];
|
||||
return write(ids);
|
||||
}
|
||||
|
||||
function reconcile(items, { pruneMissing = false } = {}) {
|
||||
const available = new Map((items || []).map(item => [identity(item), item]));
|
||||
const ids = read();
|
||||
const retained = pruneMissing ? ids.filter(id => available.has(id)) : ids;
|
||||
if (retained.length !== ids.length) write(retained);
|
||||
return retained.flatMap(id => available.has(id) ? [available.get(id)] : []);
|
||||
}
|
||||
|
||||
function contains(item) {
|
||||
return read().includes(identity(item));
|
||||
}
|
||||
|
||||
function position(item) {
|
||||
const ids = read();
|
||||
const index = ids.indexOf(identity(item));
|
||||
return {
|
||||
can_up: index > 0,
|
||||
can_down: index >= 0 && index < ids.length - 1,
|
||||
};
|
||||
}
|
||||
|
||||
return { identity, add, remove, move, reconcile, contains, position, limit };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
||||
|
|
@ -2989,13 +2989,14 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
|||
|
||||
assert '.work-filters { display:flex; gap:8px; flex-wrap:wrap; }' in html
|
||||
assert 'data-work-count="all"' in html
|
||||
assert 'data-work-count="today"' in html
|
||||
assert 'data-work-count="attention"' in html
|
||||
assert 'data-work-count="issue"' in html
|
||||
assert 'data-work-count="pull"' in html
|
||||
assert 'data-work-count="review"' in html
|
||||
assert 'data-work-count="update"' in html
|
||||
assert 'data-work-count="later"' in html
|
||||
assert "['all', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
||||
assert "['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
||||
assert 'data-work-count="draft"' in html
|
||||
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
||||
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
||||
|
|
|
|||
|
|
@ -91,10 +91,11 @@ async function dispatchNotificationClick(route) {{
|
|||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_deadline_attention_flow_ships_in_a_new_shell_cache():
|
||||
def test_today_queue_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v19" in source
|
||||
assert "stackchain-dashboard-shell-v20" in source
|
||||
assert "BASE + 'static/today-work.js'" in source
|
||||
|
||||
|
||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||
|
|
@ -194,6 +195,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/authored-outbox.js",
|
||||
"/dashboard/static/offline-work.js",
|
||||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/today-work.js",
|
||||
"/dashboard/static/later-work.js",
|
||||
"/dashboard/static/detail-defer.js",
|
||||
"/dashboard/static/pick-work.js",
|
||||
|
|
|
|||
96
tests/test_today_work.py
Normal file
96
tests/test_today_work.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
|
||||
|
||||
def test_today_queue_is_account_scoped_ordered_unique_and_bounded():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem: key => values.has(key) ? values.get(key) : null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
let login = 'timmy';
|
||||
const queue = createTodayWork({{storage, getLogin: () => login, limit: 3}});
|
||||
const issue = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
||||
const first = queue.add(issue(1));
|
||||
const duplicate = queue.add(issue(1));
|
||||
queue.add(issue(2));
|
||||
queue.add(issue(3));
|
||||
const full = queue.add(issue(4));
|
||||
queue.move(issue(3), 'up');
|
||||
queue.move(issue(3), 'up');
|
||||
const timmy = queue.reconcile([issue(1), issue(2), issue(3), issue(4)]).map(item => item.number);
|
||||
const persisted = createTodayWork({{storage, getLogin: () => login, limit: 3}})
|
||||
.reconcile([issue(1), issue(2), issue(3)]).map(item => item.number);
|
||||
login = 'alexander';
|
||||
const isolated = queue.reconcile([issue(1), issue(2), issue(3)]).map(item => item.number);
|
||||
process.stdout.write(JSON.stringify({{first, duplicate, full, timmy, persisted, isolated}}));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"first": "added",
|
||||
"duplicate": "exists",
|
||||
"full": "full",
|
||||
"timmy": [3, 1, 2],
|
||||
"persisted": [3, 1, 2],
|
||||
"isolated": [],
|
||||
}
|
||||
|
||||
|
||||
def test_today_queue_exposes_reorder_boundaries_for_touch_controls():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem: key => values.get(key) || null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
const queue = createTodayWork({{storage, getLogin: () => 'timmy'}});
|
||||
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number}});
|
||||
queue.add(item(1)); queue.add(item(2)); queue.add(item(3));
|
||||
process.stdout.write(JSON.stringify([
|
||||
queue.position(item(1)), queue.position(item(2)), queue.position(item(3)), queue.position(item(9))
|
||||
]));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == [
|
||||
{"can_up": False, "can_down": True},
|
||||
{"can_up": True, "can_down": True},
|
||||
{"can_up": True, "can_down": False},
|
||||
{"can_up": False, "can_down": False},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/today-work.js"></script>' in html
|
||||
assert 'data-work-filter="today"' in html
|
||||
assert 'data-work-count="today"' in html
|
||||
assert "const todayWork = createTodayWork({" in html
|
||||
assert "const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all');" in html
|
||||
assert "selectedWorkFilter === 'today' ? todayMyWork : activeMyWork" in html
|
||||
assert 'data-today-add' in html
|
||||
assert 'data-today-remove' in html
|
||||
assert 'data-today-move="up"' in html
|
||||
assert 'data-today-move="down"' in html
|
||||
assert 'Today is limited to 5 items' in html
|
||||
assert '.today-actions button' in html and 'min-height:44px' in html
|
||||
Loading…
Reference in New Issue
Block a user