Compare commits

..

2 Commits

Author SHA1 Message Date
goyban 0ed1bc4a0e Publish images under the git version tag too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:48:56 +02:00
goyban c015e5663b Add an Overview start page combining today and the coming week
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:45:49 +02:00
4 changed files with 52 additions and 14 deletions
+2 -1
View File
@@ -39,7 +39,7 @@ docker compose down # stop
docker compose pull && docker compose up -d # update to the newest published version docker compose pull && docker compose up -d # update to the newest published version
``` ```
To pin a version, set `WHATTODO_TAG=<commit>` in `.env` (default: `latest`). To pin a version, set `WHATTODO_TAG=<version>` in `.env`, e.g. `v1.0` or a commit (default: `latest`).
## Develop (build from source) ## Develop (build from source)
@@ -58,6 +58,7 @@ docker compose run --rm test # run the test suite (in Docker)
`./publish.sh` builds the last commit (uncommitted changes are left out) and pushes it to `./publish.sh` builds the last commit (uncommitted changes are left out) and pushes it to
`git.goyban.com/goyban/whattodo` as `:latest` and `:<commit>`. `./publish.sh <tag|branch|commit>` publishes another version. `git.goyban.com/goyban/whattodo` as `:latest` and `:<commit>`. `./publish.sh <tag|branch|commit>` publishes another version.
To release a numbered version, tag the commit first: `git tag v1.1 && ./publish.sh v1.1` also pushes the image as `:v1.1`.
Log in once first with `docker login git.goyban.com`, using a Gitea token with package write access as the password. Log in once first with `docker login git.goyban.com`, using a Gitea token with package write access as the password.
### Testing on your phone (same Wi-Fi) ### Testing on your phone (same Wi-Fi)
+34 -8
View File
@@ -1,6 +1,7 @@
'use strict'; 'use strict';
const VIEWS = [ const VIEWS = [
{ id: 'overview', label: 'Overview', icon: '🧭' },
{ id: 'today', label: 'Today', icon: '☀️' }, { id: 'today', label: 'Today', icon: '☀️' },
{ id: 'upcoming', label: 'Upcoming', icon: '📅' }, { id: 'upcoming', label: 'Upcoming', icon: '📅' },
{ id: 'calendar', label: 'Calendar', icon: '🗓️' }, { id: 'calendar', label: 'Calendar', icon: '🗓️' },
@@ -12,10 +13,12 @@ const PRIORITY_LABELS = ['', 'Low', 'Medium', 'High'];
const REPEAT_UNITS = { daily: 'day', weekly: 'week', monthly: 'month', yearly: 'year' }; const REPEAT_UNITS = { daily: 'day', weekly: 'week', monthly: 'month', yearly: 'year' };
const HOUR_PX = 48; const HOUR_PX = 48;
const BACKLOG_SUGGESTIONS = 2; const BACKLOG_SUGGESTIONS = 2;
const DEFAULT_VIEW = 'overview';
const OVERVIEW_DAYS = 7; // days after today that the overview lists one by one
const state = { const state = {
tasks: [], categories: [], projects: [], tasks: [], categories: [], projects: [],
view: 'today', categoryId: null, projectId: null, view: DEFAULT_VIEW, categoryId: null, projectId: null,
search: '', calDate: null, showDoneProjects: false, search: '', calDate: null, showDoneProjects: false,
}; };
let editing = null; // task open in the dialog; null when creating let editing = null; // task open in the dialog; null when creating
@@ -198,10 +201,12 @@ async function act(fn) {
// --- Persisted UI prefs ------------------------------------------------------- // --- Persisted UI prefs -------------------------------------------------------
const PREFS_VERSION = 2;
function savePrefs() { function savePrefs() {
try { try {
localStorage.setItem('whattodo.view', JSON.stringify({ localStorage.setItem('whattodo.view', JSON.stringify({
view: state.view, categoryId: state.categoryId, projectId: state.projectId, view: state.view, categoryId: state.categoryId, projectId: state.projectId, v: PREFS_VERSION,
})); }));
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@@ -212,6 +217,8 @@ function loadPrefs() {
state.view = p.view; state.view = p.view;
state.categoryId = p.categoryId ?? null; state.categoryId = p.categoryId ?? null;
state.projectId = p.projectId ?? null; state.projectId = p.projectId ?? null;
// Today used to be the start page; move people who were left on it to the overview once.
if (!p.v && p.view === 'today') state.view = DEFAULT_VIEW;
} }
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@@ -289,6 +296,16 @@ function buildGroups(tasks) {
{ title: 'From your backlog', tasks: state.search ? [] : backlogSuggestions(), hint: 'No date yet. Plan one for today?' }, { title: 'From your backlog', tasks: state.search ? [] : backlogSuggestions(), hint: 'No date yet. Plan one for today?' },
]; ];
} }
if (state.view === 'overview') {
const shown = new Set(open.filter((x) => overdue(x) || today(x)).map((x) => x.id));
const days = Array.from({ length: OVERVIEW_DAYS }, (_, i) => addDays(t, i + 1));
return [
group('Overdue', overdue),
group('Today', today),
group('High priority, not planned', (x) => x.priority === 3 && !shown.has(x.id) && !x.planned_date),
...days.map((day) => group(dayName(day), (x) => later(x) && whenDate(x) === day)),
];
}
if (state.view === 'upcoming') { if (state.view === 'upcoming') {
return [ return [
group('Tomorrow', (x) => later(x) && whenDate(x) === tomorrow), group('Tomorrow', (x) => later(x) && whenDate(x) === tomorrow),
@@ -323,7 +340,9 @@ function counts() {
const t = todayStr(); const t = todayStr();
const open = state.tasks.filter((x) => !x.done); const open = state.tasks.filter((x) => !x.done);
const overdue = open.filter((x) => isOverdue(x, t)).length; const overdue = open.filter((x) => isOverdue(x, t)).length;
const laterThan = addDays(t, OVERVIEW_DAYS);
const c = { const c = {
later: open.filter((x) => !isOverdue(x, t) && !isForToday(x, t) && whenDate(x) > laterThan).length,
today: open.filter((x) => isOverdue(x, t) || isForToday(x, t) || (x.priority === 3 && !x.planned_date)).length, today: open.filter((x) => isOverdue(x, t) || isForToday(x, t) || (x.priority === 3 && !x.planned_date)).length,
upcoming: open.filter((x) => !isOverdue(x, t) && !isForToday(x, t) && whenDate(x)).length, upcoming: open.filter((x) => !isOverdue(x, t) && !isForToday(x, t) && whenDate(x)).length,
backlog: open.filter(isBacklog).length, backlog: open.filter(isBacklog).length,
@@ -334,6 +353,7 @@ function counts() {
cats: {}, cats: {},
projects: {}, projects: {},
}; };
c.overview = c.today;
for (const x of open) { for (const x of open) {
c.cats[x.category_id || 0] = (c.cats[x.category_id || 0] || 0) + 1; c.cats[x.category_id || 0] = (c.cats[x.category_id || 0] || 0) + 1;
if (x.project_id) c.projects[x.project_id] = (c.projects[x.project_id] || 0) + 1; if (x.project_id) c.projects[x.project_id] = (c.projects[x.project_id] || 0) + 1;
@@ -402,7 +422,7 @@ function navItem({ active, icon, label, count, onclick, color, alert, muted }) {
function renderNav(c) { function renderNav(c) {
$('#views').replaceChildren(...VIEWS.map((v) => navItem({ $('#views').replaceChildren(...VIEWS.map((v) => navItem({
active: state.view === v.id, icon: v.icon, label: v.label, count: c[v.id], active: state.view === v.id, icon: v.icon, label: v.label, count: c[v.id],
onclick: () => setView(v.id), alert: v.id === 'today' && c.overdue > 0, onclick: () => setView(v.id), alert: ['overview', 'today'].includes(v.id) && c.overdue > 0,
}))); })));
const projects = sortedProjects(); const projects = sortedProjects();
@@ -438,11 +458,12 @@ function renderNav(c) {
} }
function render() { function render() {
if (state.view === 'cat' && state.categoryId && !catById(state.categoryId)) state.view = 'today'; if (state.view === 'cat' && state.categoryId && !catById(state.categoryId)) state.view = DEFAULT_VIEW;
if (state.view === 'project' && !projectById(state.projectId)) state.view = 'today'; if (state.view === 'project' && !projectById(state.projectId)) state.view = DEFAULT_VIEW;
if (!['cat', 'project'].includes(state.view) && !VIEWS.some((v) => v.id === state.view)) state.view = 'today'; if (!['cat', 'project'].includes(state.view) && !VIEWS.some((v) => v.id === state.view)) state.view = DEFAULT_VIEW;
renderNav(counts()); const c = counts();
renderNav(c);
let title = (VIEWS.find((v) => v.id === state.view) || {}).label; let title = (VIEWS.find((v) => v.id === state.view) || {}).label;
if (state.view === 'cat') title = state.categoryId ? catById(state.categoryId).name : 'No category'; if (state.view === 'cat') title = state.categoryId ? catById(state.categoryId).name : 'No category';
@@ -479,11 +500,16 @@ function render() {
const msg = state.search ? 'No tasks match your search.' const msg = state.search ? 'No tasks match your search.'
: state.view === 'done' ? 'Nothing completed yet.' : state.view === 'done' ? 'Nothing completed yet.'
: state.view === 'today' ? 'Nothing planned for today. 🎉' : state.view === 'today' ? 'Nothing planned for today. 🎉'
: state.view === 'overview' ? 'Nothing planned for today or the coming week. 🎉'
: state.view === 'backlog' ? 'Backlog is empty: everything has a date.' : state.view === 'backlog' ? 'Backlog is empty: everything has a date.'
: state.view === 'project' ? 'No tasks in this project yet. Add the first step above.' : state.view === 'project' ? 'No tasks in this project yet. Add the first step above.'
: 'No tasks here.'; : 'No tasks here.';
sections.push(h('div', { class: 'empty' }, msg)); sections.push(h('div', { class: 'empty' }, msg));
} }
if (state.view === 'overview' && !state.search && c.later) {
sections.push(h('button', { type: 'button', class: 'more-link', onclick: () => setView('upcoming') },
`${c.later} more planned later →`));
}
$('#list').replaceChildren(...sections); $('#list').replaceChildren(...sections);
} }
@@ -759,7 +785,7 @@ function viewDefaults() {
d.project_id = p.id; d.project_id = p.id;
if (p.category_id) d.category_id = p.category_id; if (p.category_id) d.category_id = p.category_id;
} }
if (state.view === 'today') d.planned_date = todayStr(); if (['overview', 'today'].includes(state.view)) d.planned_date = todayStr();
if (state.view === 'upcoming') d.planned_date = addDays(todayStr(), 1); if (state.view === 'upcoming') d.planned_date = addDays(todayStr(), 1);
if (state.view === 'calendar') d.planned_date = state.calDate || todayStr(); if (state.view === 'calendar') d.planned_date = state.calDate || todayStr();
return d; return d;
+2
View File
@@ -324,6 +324,8 @@ input:disabled { opacity: 0.5; }
.link-chip { cursor: pointer; } .link-chip { cursor: pointer; }
.link-chip:hover { color: var(--accent); } .link-chip:hover { color: var(--accent); }
.plan-btn { align-self: center; font-size: 16px; } .plan-btn { align-self: center; font-size: 16px; }
.more-link { display: block; margin: 18px auto 0; padding: 8px 12px; border: 0; background: none; color: var(--muted); font: inherit; font-size: 14px; cursor: pointer; }
.more-link:hover { color: var(--accent); }
.group-hint { margin: -4px 0 8px; font-size: 13px; color: var(--muted); } .group-hint { margin: -4px 0 8px; font-size: 13px; color: var(--muted); }
/* Projects */ /* Projects */
+14 -5
View File
@@ -5,6 +5,9 @@
# ./publish.sh # builds HEAD # ./publish.sh # builds HEAD
# ./publish.sh v1.2 # builds a tag/branch/commit # ./publish.sh v1.2 # builds a tag/branch/commit
# #
# The image is tagged with the commit and `latest`, plus the version when the commit has a git tag
# like v1.2. To release a new version: git tag v1.2 && ./publish.sh v1.2
#
# One-time setup: docker login git.goyban.com (password = a Gitea token with package write access) # One-time setup: docker login git.goyban.com (password = a Gitea token with package write access)
set -euo pipefail set -euo pipefail
@@ -15,9 +18,15 @@ REF="${1:-HEAD}"
cd "$(dirname "$0")" cd "$(dirname "$0")"
SHA="$(git rev-parse --short "$REF^{commit}")" SHA="$(git rev-parse --short "$REF^{commit}")"
echo "Building $IMAGE from $REF ($SHA)" # Newest version tag (v1.2, v1.2.3) on this commit, if any.
git archive --format=tar "$REF" | docker build --target runtime -t "$IMAGE:$SHA" -t "$IMAGE:latest" - VERSION="$(git tag --points-at "$SHA" --list 'v[0-9]*' --sort=-v:refname | head -n 1)"
TAGS=("$SHA" latest)
if [ -n "$VERSION" ]; then TAGS+=("$VERSION"); fi
docker push "$IMAGE:$SHA" echo "Building $IMAGE from $REF ($SHA${VERSION:+, $VERSION})"
docker push "$IMAGE:latest" BUILD_TAGS=()
echo "Pushed $IMAGE:$SHA and $IMAGE:latest" for t in "${TAGS[@]}"; do BUILD_TAGS+=(-t "$IMAGE:$t"); done
git archive --format=tar "$REF" | docker build --target runtime "${BUILD_TAGS[@]}" -
for t in "${TAGS[@]}"; do docker push "$IMAGE:$t"; done
echo "Pushed $IMAGE with tags: ${TAGS[*]}"