Compare commits
3 Commits
0060cd11bb
...
v1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 119b8a0205 | |||
| 0ed1bc4a0e | |||
| c015e5663b |
@@ -9,7 +9,7 @@ A small, self-hosted to-do app for one person. It works in a desktop browser and
|
|||||||
- **Projects** group the tasks of a bigger goal and have their own deadline, notes and progress bar. Today shows each active project's **next step**, how much time is left, and a warning if it's falling behind or nothing was done on it in over a week.
|
- **Projects** group the tasks of a bigger goal and have their own deadline, notes and progress bar. Today shows each active project's **next step**, how much time is left, and a warning if it's falling behind or nothing was done on it in over a week.
|
||||||
- **Recurring tasks** (every N days/weeks/months/years): completing one creates the next occurrence.
|
- **Recurring tasks** (every N days/weeks/months/years): completing one creates the next occurrence.
|
||||||
- When you pick a date and time, the task form lists what else is planned that day and warns about overlaps.
|
- When you pick a date and time, the task form lists what else is planned that day and warns about overlaps.
|
||||||
- Views: **Today**, **Upcoming**, **Calendar** (day timeline with week strip; tap an hour to add a task there), **Backlog**, **All open**, **Done**, plus one view per project and per category.
|
- Views: **Today**, **Upcoming**, **Calendar** (week, day and month views; tap an hour to add a task there), **Backlog**, **All open**, **Done**, plus one view per project and per category.
|
||||||
- You can rename, recolor, reorder, add, or delete categories in **Settings**. It starts with Work, Life, Personal, YouTube.
|
- You can rename, recolor, reorder, add, or delete categories in **Settings**. It starts with Work, Life, Personal, YouTube.
|
||||||
- **Export / import** of all data as JSON, from **Settings**.
|
- **Export / import** of all data as JSON, from **Settings**.
|
||||||
- Login with a single password.
|
- Login with a single password.
|
||||||
@@ -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)
|
||||||
|
|||||||
+211
-35
@@ -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: '🗓️' },
|
||||||
@@ -11,12 +12,16 @@ const VIEWS = [
|
|||||||
const PRIORITY_LABELS = ['', 'Low', 'Medium', 'High'];
|
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 CAL_MODES = ['day', 'week', 'month'];
|
||||||
|
const MONTH_CELL_ITEMS = 4; // lines per day in the month view, the last one becomes "+n more"
|
||||||
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, calMode: 'week', showDoneProjects: false,
|
||||||
};
|
};
|
||||||
let editing = null; // task open in the dialog; null when creating
|
let editing = null; // task open in the dialog; null when creating
|
||||||
let editingProject = null;
|
let editingProject = null;
|
||||||
@@ -198,10 +203,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, calMode: state.calMode, v: PREFS_VERSION,
|
||||||
}));
|
}));
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
@@ -212,6 +219,9 @@ 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;
|
||||||
|
if (CAL_MODES.includes(p.calMode)) state.calMode = p.calMode;
|
||||||
|
// 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 +299,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 +343,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 +356,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 +425,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 +461,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 +503,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,17 +646,37 @@ function layoutBlocks(items) {
|
|||||||
return sorted;
|
return sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
function weekStrip(day) {
|
const mondayOf = (day) => addDays(day, -((parseDate(day).getDay() + 6) % 7));
|
||||||
const d = parseDate(day);
|
// Within one day: timed tasks in order, then the rest by priority.
|
||||||
const monday = addDays(day, -((d.getDay() + 6) % 7));
|
const cmpDay = (a, b) => {
|
||||||
|
if (a.task.start_time !== b.task.start_time) {
|
||||||
|
if (!a.task.start_time) return 1;
|
||||||
|
if (!b.task.start_time) return -1;
|
||||||
|
return a.task.start_time < b.task.start_time ? -1 : 1;
|
||||||
|
}
|
||||||
|
return (b.task.priority - a.task.priority) || (a.task.id - b.task.id);
|
||||||
|
};
|
||||||
|
// Deadlines that fall on this day but are planned for another day, and project deadlines.
|
||||||
|
const deadlinesOn = (day) => ({
|
||||||
|
tasks: state.tasks.filter((t) => t.due_date === day && t.planned_date && t.planned_date !== day),
|
||||||
|
projects: state.projects.filter((p) => !p.done && p.due_date === day),
|
||||||
|
});
|
||||||
|
|
||||||
|
// With a gutter the strip lines up with the day columns of the week view.
|
||||||
|
function weekStrip(day, gutter) {
|
||||||
|
const monday = mondayOf(day);
|
||||||
const t = todayStr();
|
const t = todayStr();
|
||||||
return h('div', { class: 'week-strip' }, Array.from({ length: 7 }, (_, i) => {
|
return h('div', { class: 'week-strip' + (gutter ? ' week-row' : '') },
|
||||||
|
gutter ? h('span', {}) : null,
|
||||||
|
Array.from({ length: 7 }, (_, i) => {
|
||||||
const date = addDays(monday, i);
|
const date = addDays(monday, i);
|
||||||
const n = itemsOn(date).filter((it) => !it.task.done).length;
|
const n = itemsOn(date).filter((it) => !it.task.done).length;
|
||||||
return h('button', {
|
return h('button', {
|
||||||
type: 'button',
|
type: 'button',
|
||||||
class: 'week-day' + (date === day ? ' active' : '') + (date === t ? ' today' : ''),
|
class: 'week-day' + (date === day ? ' active' : '') + (date === t ? ' today' : ''),
|
||||||
onclick: () => setCalDate(date),
|
title: gutter && date === day ? 'Open this day' : null,
|
||||||
|
// In the week view a second click on the selected day opens it.
|
||||||
|
onclick: () => (gutter && date === day ? setCalMode('day', date) : setCalDate(date)),
|
||||||
},
|
},
|
||||||
h('span', { class: 'wd' }, parseDate(date).toLocaleDateString(undefined, { weekday: 'short' })),
|
h('span', { class: 'wd' }, parseDate(date).toLocaleDateString(undefined, { weekday: 'short' })),
|
||||||
h('span', { class: 'dn' }, parseDate(date).getDate()),
|
h('span', { class: 'dn' }, parseDate(date).getDate()),
|
||||||
@@ -635,16 +684,8 @@ function weekStrip(day) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCalendar() {
|
// Hours the timeline has to cover: 7 to 22, stretched to fit every timed item.
|
||||||
const day = state.calDate || todayStr();
|
function hourRange(timed) {
|
||||||
$('#cal-date').value = day;
|
|
||||||
const items = itemsOn(day);
|
|
||||||
const timed = items.filter((it) => it.task.start_time);
|
|
||||||
const untimed = items.filter((it) => !it.task.start_time).map((it) => it.task).sort(cmpOpen);
|
|
||||||
// Deadlines that fall on this day but are planned for another day, and project deadlines.
|
|
||||||
const deadlines = state.tasks.filter((t) => t.due_date === day && t.planned_date && t.planned_date !== day);
|
|
||||||
const projectDeadlines = state.projects.filter((p) => !p.done && p.due_date === day);
|
|
||||||
|
|
||||||
let startHour = 7;
|
let startHour = 7;
|
||||||
let endHour = 22;
|
let endHour = 22;
|
||||||
for (const it of timed) {
|
for (const it of timed) {
|
||||||
@@ -652,8 +693,16 @@ function renderCalendar() {
|
|||||||
startHour = Math.min(startHour, Math.floor(a / 60));
|
startHour = Math.min(startHour, Math.floor(a / 60));
|
||||||
endHour = Math.max(endHour, Math.ceil(b / 60));
|
endHour = Math.max(endHour, Math.ceil(b / 60));
|
||||||
}
|
}
|
||||||
|
return [startHour, endHour];
|
||||||
|
}
|
||||||
|
|
||||||
const grid = h('div', { class: 'timeline', style: { height: `${(endHour - startHour) * HOUR_PX}px` } });
|
// One day's schedule. Compact is a week column: no hour labels, blocks use the full width.
|
||||||
|
function timeline(day, timed, startHour, endHour, compact) {
|
||||||
|
const gutter = compact ? 2 : 52;
|
||||||
|
const grid = h('div', {
|
||||||
|
class: 'timeline' + (compact ? ' compact' : '') + (compact && day === todayStr() ? ' today' : ''),
|
||||||
|
style: { height: `${(endHour - startHour) * HOUR_PX}px` },
|
||||||
|
});
|
||||||
for (let hr = startHour; hr < endHour; hr++) {
|
for (let hr = startHour; hr < endHour; hr++) {
|
||||||
grid.append(h('div', {
|
grid.append(h('div', {
|
||||||
class: 'hour', style: { top: `${(hr - startHour) * HOUR_PX}px`, height: `${HOUR_PX}px` },
|
class: 'hour', style: { top: `${(hr - startHour) * HOUR_PX}px`, height: `${HOUR_PX}px` },
|
||||||
@@ -661,7 +710,7 @@ function renderCalendar() {
|
|||||||
onclick: () => openTask(null, {
|
onclick: () => openTask(null, {
|
||||||
planned_date: day, start_time: fromMin(hr * 60), end_time: fromMin(Math.min(hr * 60 + 60, 23 * 60 + 59)),
|
planned_date: day, start_time: fromMin(hr * 60), end_time: fromMin(Math.min(hr * 60 + 60, 23 * 60 + 59)),
|
||||||
}),
|
}),
|
||||||
}, h('span', { class: 'hour-label' }, `${pad(hr)}:00`)));
|
}, compact ? null : h('span', { class: 'hour-label' }, `${pad(hr)}:00`)));
|
||||||
}
|
}
|
||||||
for (const it of layoutBlocks(timed)) {
|
for (const it of layoutBlocks(timed)) {
|
||||||
const t = it.task;
|
const t = it.task;
|
||||||
@@ -671,11 +720,11 @@ function renderCalendar() {
|
|||||||
grid.append(h('button', {
|
grid.append(h('button', {
|
||||||
type: 'button',
|
type: 'button',
|
||||||
class: 'block' + (t.done ? ' done' : '') + (it.projected ? ' projected' : '') + (height < 40 ? ' short' : ''),
|
class: 'block' + (t.done ? ' done' : '') + (it.projected ? ' projected' : '') + (height < 40 ? ' short' : ''),
|
||||||
title: it.projected ? 'Repeat of a recurring task (opens the current occurrence)' : t.title,
|
title: it.projected ? 'Repeat of a recurring task (opens the current occurrence)' : `${timeText(t)} ${t.title}`,
|
||||||
style: {
|
style: {
|
||||||
top: `${top}px`, height: `${height}px`,
|
top: `${top}px`, height: `${height}px`,
|
||||||
left: `calc(52px + (100% - 56px) * ${it.col / it.cols})`,
|
left: `calc(${gutter}px + (100% - ${gutter + 4}px) * ${it.col / it.cols})`,
|
||||||
width: `calc((100% - 56px) / ${it.cols} - 3px)`,
|
width: `calc((100% - ${gutter + 4}px) / ${it.cols} - ${compact ? 1 : 3}px)`,
|
||||||
borderLeftColor: cat ? cat.color : 'var(--accent)',
|
borderLeftColor: cat ? cat.color : 'var(--accent)',
|
||||||
},
|
},
|
||||||
onclick: () => openTask(t),
|
onclick: () => openTask(t),
|
||||||
@@ -690,30 +739,154 @@ function renderCalendar() {
|
|||||||
grid.append(h('div', { class: 'now-line', style: { top: `${((mins - startHour * 60) / 60) * HOUR_PX}px` } }));
|
grid.append(h('div', { class: 'now-line', style: { top: `${((mins - startHour * 60) / 60) * HOUR_PX}px` } }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-line task for the cramped cells of the week and month views.
|
||||||
|
function miniItem(it, { deadline = false, time = false } = {}) {
|
||||||
|
const t = it.task;
|
||||||
|
const cat = catById(t.category_id);
|
||||||
|
return h('button', {
|
||||||
|
type: 'button',
|
||||||
|
class: 'mini' + (t.done ? ' done' : '') + (it.projected ? ' projected' : ''),
|
||||||
|
title: [deadline ? 'Deadline:' : timeText(t), t.title].filter(Boolean).join(' '),
|
||||||
|
style: { borderLeftColor: cat ? cat.color : 'var(--accent)' },
|
||||||
|
onclick: (e) => { e.stopPropagation(); openTask(t); },
|
||||||
|
}, deadline ? '📅 ' : '', time && t.start_time ? h('span', { class: 'block-time' }, t.start_time, ' ') : null, t.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
function miniProject(p) {
|
||||||
|
return h('button', {
|
||||||
|
type: 'button', class: 'mini projected', title: `Project deadline: ${p.name}`,
|
||||||
|
onclick: (e) => { e.stopPropagation(); setView('project', { projectId: p.id }); },
|
||||||
|
}, '🏁 ', p.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalDay(day) {
|
||||||
|
const items = itemsOn(day);
|
||||||
|
const timed = items.filter((it) => it.task.start_time);
|
||||||
|
const untimed = items.filter((it) => !it.task.start_time).map((it) => it.task).sort(cmpOpen);
|
||||||
|
const deadlines = deadlinesOn(day);
|
||||||
|
|
||||||
const sections = [weekStrip(day)];
|
const sections = [weekStrip(day)];
|
||||||
if (deadlines.length || projectDeadlines.length) {
|
if (deadlines.tasks.length || deadlines.projects.length) {
|
||||||
sections.push(h('section', { class: 'group' },
|
sections.push(h('section', { class: 'group' },
|
||||||
h('h3', { class: 'group-title' }, 'Deadlines this day'),
|
h('h3', { class: 'group-title' }, 'Deadlines this day'),
|
||||||
projectDeadlines.map((p) => h('button', {
|
deadlines.projects.map((p) => h('button', {
|
||||||
type: 'button', class: 'deadline-line', onclick: () => setView('project', { projectId: p.id }),
|
type: 'button', class: 'deadline-line', onclick: () => setView('project', { projectId: p.id }),
|
||||||
}, '🏁 ', h('strong', {}, p.name), ' (project)')),
|
}, '🏁 ', h('strong', {}, p.name), ' (project)')),
|
||||||
deadlines.map(taskRow)));
|
deadlines.tasks.map(taskRow)));
|
||||||
}
|
}
|
||||||
if (untimed.length) {
|
if (untimed.length) {
|
||||||
sections.push(h('section', { class: 'group' },
|
sections.push(h('section', { class: 'group' },
|
||||||
h('h3', { class: 'group-title' }, 'No time set', h('span', { class: 'muted' }, ` ${untimed.length}`)),
|
h('h3', { class: 'group-title' }, 'No time set', h('span', { class: 'muted' }, ` ${untimed.length}`)),
|
||||||
untimed.map(taskRow)));
|
untimed.map(taskRow)));
|
||||||
}
|
}
|
||||||
sections.push(h('section', { class: 'group' }, h('h3', { class: 'group-title' }, 'Schedule'), grid));
|
sections.push(h('section', { class: 'group' },
|
||||||
|
h('h3', { class: 'group-title' }, 'Schedule'), timeline(day, timed, ...hourRange(timed))));
|
||||||
$('#list').replaceChildren(...sections);
|
$('#list').replaceChildren(...sections);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCalWeek(day) {
|
||||||
|
const monday = mondayOf(day);
|
||||||
|
const days = Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const date = addDays(monday, i);
|
||||||
|
const items = itemsOn(date);
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
timed: items.filter((it) => it.task.start_time),
|
||||||
|
untimed: items.filter((it) => !it.task.start_time).sort(cmpDay),
|
||||||
|
deadlines: deadlinesOn(date),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const [startHour, endHour] = hourRange(days.flatMap((d) => d.timed));
|
||||||
|
|
||||||
|
const sections = [weekStrip(day, true)];
|
||||||
|
if (days.some((d) => d.untimed.length || d.deadlines.tasks.length || d.deadlines.projects.length)) {
|
||||||
|
sections.push(h('div', { class: 'week-row all-day' }, h('span', {}),
|
||||||
|
days.map((d) => h('div', { class: 'day-cell' },
|
||||||
|
d.deadlines.projects.map(miniProject),
|
||||||
|
d.deadlines.tasks.map((t) => miniItem({ task: t }, { deadline: true })),
|
||||||
|
d.untimed.map((it) => miniItem(it))))));
|
||||||
|
}
|
||||||
|
const labels = h('div', { class: 'hour-gutter' });
|
||||||
|
for (let hr = startHour; hr < endHour; hr++) {
|
||||||
|
labels.append(h('span', { class: 'hour-label', style: { top: `${(hr - startHour) * HOUR_PX + 3}px` } }, `${pad(hr)}:00`));
|
||||||
|
}
|
||||||
|
sections.push(h('div', { class: 'week-row week-grid' }, labels,
|
||||||
|
days.map((d) => timeline(d.date, d.timed, startHour, endHour, true))));
|
||||||
|
$('#list').replaceChildren(...sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalMonth(day) {
|
||||||
|
const first = `${day.slice(0, 8)}01`;
|
||||||
|
const start = mondayOf(first);
|
||||||
|
const weeks = Math.ceil(dayDiff(start, addMonths(first, 1)) / 7);
|
||||||
|
const t = todayStr();
|
||||||
|
const cells = Array.from({ length: weeks * 7 }, (_, i) => {
|
||||||
|
const date = addDays(start, i);
|
||||||
|
const deadlines = deadlinesOn(date);
|
||||||
|
const minis = [
|
||||||
|
...deadlines.projects.map(miniProject),
|
||||||
|
...deadlines.tasks.map((x) => miniItem({ task: x }, { deadline: true })),
|
||||||
|
...itemsOn(date).sort(cmpDay).map((it) => miniItem(it, { time: true })),
|
||||||
|
];
|
||||||
|
const shown = minis.length > MONTH_CELL_ITEMS ? minis.slice(0, MONTH_CELL_ITEMS - 1) : minis;
|
||||||
|
return h('div', {
|
||||||
|
class: 'month-cell' + (date.slice(0, 7) !== day.slice(0, 7) ? ' other' : '')
|
||||||
|
+ (date === day ? ' active' : '') + (date === t ? ' today' : ''),
|
||||||
|
onclick: () => setCalDate(date),
|
||||||
|
},
|
||||||
|
h('button', {
|
||||||
|
type: 'button', class: 'month-num', title: 'Open this day',
|
||||||
|
onclick: (e) => { e.stopPropagation(); setCalMode('day', date); },
|
||||||
|
}, parseDate(date).getDate()),
|
||||||
|
shown,
|
||||||
|
minis.length > shown.length ? h('button', {
|
||||||
|
type: 'button', class: 'month-more',
|
||||||
|
onclick: (e) => { e.stopPropagation(); setCalMode('day', date); },
|
||||||
|
}, `+${minis.length - shown.length} more`) : null);
|
||||||
|
});
|
||||||
|
$('#list').replaceChildren(
|
||||||
|
h('h3', { class: 'group-title month-title' },
|
||||||
|
parseDate(first).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })),
|
||||||
|
h('div', { class: 'month-grid month-head' }, Array.from({ length: 7 }, (_, i) => h('span', {},
|
||||||
|
parseDate(addDays(start, i)).toLocaleDateString(undefined, { weekday: 'short' })))),
|
||||||
|
h('div', { class: 'month-grid' }, cells));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalendar() {
|
||||||
|
const day = state.calDate || todayStr();
|
||||||
|
$('#cal-date').value = day;
|
||||||
|
for (const btn of $('#cal-mode').querySelectorAll('button')) {
|
||||||
|
btn.classList.toggle('active', btn.dataset.mode === state.calMode);
|
||||||
|
}
|
||||||
|
$('#cal-prev').setAttribute('aria-label', `Previous ${state.calMode}`);
|
||||||
|
$('#cal-next').setAttribute('aria-label', `Next ${state.calMode}`);
|
||||||
|
if (state.calMode === 'day') renderCalDay(day);
|
||||||
|
else if (state.calMode === 'month') renderCalMonth(day);
|
||||||
|
else renderCalWeek(day);
|
||||||
|
}
|
||||||
|
|
||||||
function setCalDate(day) {
|
function setCalDate(day) {
|
||||||
state.calDate = day;
|
state.calDate = day;
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setCalMode(mode, day) {
|
||||||
|
state.calMode = mode;
|
||||||
|
if (day) state.calDate = day;
|
||||||
|
savePrefs();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step back or forward by one day, week or month, whichever is showing.
|
||||||
|
function stepCal(dir) {
|
||||||
|
const day = state.calDate || todayStr();
|
||||||
|
if (state.calMode === 'month') setCalDate(addMonths(day, dir));
|
||||||
|
else setCalDate(addDays(day, state.calMode === 'week' ? 7 * dir : dir));
|
||||||
|
}
|
||||||
|
|
||||||
// --- Actions ------------------------------------------------------------------
|
// --- Actions ------------------------------------------------------------------
|
||||||
|
|
||||||
function setView(view, { categoryId = null, projectId = null } = {}) {
|
function setView(view, { categoryId = null, projectId = null } = {}) {
|
||||||
@@ -759,7 +932,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;
|
||||||
@@ -1160,8 +1333,11 @@ function bind() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calendar
|
// Calendar
|
||||||
$('#cal-prev').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), -1)));
|
$('#cal-prev').addEventListener('click', () => stepCal(-1));
|
||||||
$('#cal-next').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), 1)));
|
$('#cal-next').addEventListener('click', () => stepCal(1));
|
||||||
|
for (const btn of $('#cal-mode').querySelectorAll('button')) {
|
||||||
|
btn.addEventListener('click', () => setCalMode(btn.dataset.mode));
|
||||||
|
}
|
||||||
$('#cal-today').addEventListener('click', () => setCalDate(todayStr()));
|
$('#cal-today').addEventListener('click', () => setCalDate(todayStr()));
|
||||||
$('#cal-date').addEventListener('change', (e) => { if (e.target.value) setCalDate(e.target.value); });
|
$('#cal-date').addEventListener('change', (e) => { if (e.target.value) setCalDate(e.target.value); });
|
||||||
|
|
||||||
|
|||||||
@@ -54,9 +54,14 @@
|
|||||||
</form>
|
</form>
|
||||||
<div id="cal-nav" class="cal-nav" hidden>
|
<div id="cal-nav" class="cal-nav" hidden>
|
||||||
<button id="cal-prev" class="btn" type="button" aria-label="Previous day">‹</button>
|
<button id="cal-prev" class="btn" type="button" aria-label="Previous day">‹</button>
|
||||||
<input id="cal-date" type="date" aria-label="Day">
|
<input id="cal-date" type="date" aria-label="Date">
|
||||||
<button id="cal-next" class="btn" type="button" aria-label="Next day">›</button>
|
<button id="cal-next" class="btn" type="button" aria-label="Next day">›</button>
|
||||||
<button id="cal-today" class="btn" type="button">Today</button>
|
<button id="cal-today" class="btn" type="button">Today</button>
|
||||||
|
<div id="cal-mode" class="segmented" role="group" aria-label="Calendar view">
|
||||||
|
<button type="button" data-mode="day">Day</button>
|
||||||
|
<button type="button" data-mode="week">Week</button>
|
||||||
|
<button type="button" data-mode="month">Month</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="list" class="list"></div>
|
<div id="list" class="list"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+59
-1
@@ -251,7 +251,9 @@ input:disabled { opacity: 0.5; }
|
|||||||
.day-preview li.clash .day-time { color: inherit; }
|
.day-preview li.clash .day-time { color: inherit; }
|
||||||
|
|
||||||
/* Calendar */
|
/* Calendar */
|
||||||
.cal-nav { display: flex; gap: 8px; padding: 0 24px 8px; align-items: center; }
|
.cal-nav { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 24px 8px; align-items: center; }
|
||||||
|
.cal-nav .segmented { margin-left: auto; }
|
||||||
|
.cal-nav .segmented button { padding: 6px 12px; }
|
||||||
.cal-nav input[type="date"] { max-width: 180px; }
|
.cal-nav input[type="date"] { max-width: 180px; }
|
||||||
.week-strip { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; margin-top: 6px; }
|
.week-strip { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; margin-top: 6px; }
|
||||||
.week-day {
|
.week-day {
|
||||||
@@ -297,10 +299,64 @@ input:disabled { opacity: 0.5; }
|
|||||||
border-radius: 50%; background: var(--overdue);
|
border-radius: 50%; background: var(--overdue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Week view: the strip, the untimed row and the day columns share one grid */
|
||||||
|
.week-row {
|
||||||
|
display: grid; grid-template-columns: 36px repeat(7, minmax(0, 1fr)); gap: 6px; margin-top: 6px;
|
||||||
|
}
|
||||||
|
.all-day .day-cell { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.hour-gutter { position: relative; }
|
||||||
|
.hour-gutter .hour-label { left: auto; right: 0; }
|
||||||
|
.timeline.compact { border-radius: 10px; }
|
||||||
|
.timeline.compact.today { border-color: var(--accent); }
|
||||||
|
.timeline.compact .block { padding: 2px 4px; font-size: 12px; border-left-width: 3px; }
|
||||||
|
.timeline.compact .block-title { overflow-wrap: anywhere; }
|
||||||
|
.timeline.compact .block.short .block-time { display: none; }
|
||||||
|
.timeline.compact .now-line { left: 0; }
|
||||||
|
.mini {
|
||||||
|
display: block; width: 100%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
text-align: left; padding: 2px 5px; font-size: 12px; line-height: 1.3; cursor: pointer;
|
||||||
|
background: var(--accent-weak); border: 1px solid var(--border); border-left: 3px solid var(--accent);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.mini:hover { border-color: var(--accent); }
|
||||||
|
.mini.done { opacity: 0.55; text-decoration: line-through; }
|
||||||
|
.mini.projected { background: transparent; border-style: dashed; border-left-style: solid; }
|
||||||
|
|
||||||
|
/* Month view */
|
||||||
|
.month-title { margin-top: 10px; }
|
||||||
|
.month-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; }
|
||||||
|
.month-head { margin-bottom: 4px; font-size: 12px; color: var(--muted); text-align: center; }
|
||||||
|
.month-cell {
|
||||||
|
display: flex; flex-direction: column; gap: 2px; min-height: 104px; min-width: 0; padding: 4px;
|
||||||
|
background: var(--panel); border: 1px solid var(--border); border-radius: 8px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.month-cell:hover, .month-cell.active { border-color: var(--accent); }
|
||||||
|
.month-cell.active { box-shadow: inset 0 0 0 1px var(--accent); }
|
||||||
|
.month-cell.other { background: none; }
|
||||||
|
.month-cell.other .month-num { color: var(--muted); font-weight: 400; }
|
||||||
|
.month-num {
|
||||||
|
align-self: flex-start; min-width: 24px; padding: 1px 4px; border: none; background: none;
|
||||||
|
border-radius: 12px; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
.month-num:hover { background: var(--panel-2); }
|
||||||
|
.month-cell.today .month-num { background: var(--accent); color: var(--accent-text); }
|
||||||
|
.month-more { border: none; background: none; padding: 0 2px; text-align: left; font-size: 11px; color: var(--muted); cursor: pointer; }
|
||||||
|
.month-more:hover { color: var(--text); }
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.cal-nav { padding: 0 16px 8px; }
|
.cal-nav { padding: 0 16px 8px; }
|
||||||
.cal-nav input[type="date"] { max-width: none; flex: 1; }
|
.cal-nav input[type="date"] { max-width: none; flex: 1; }
|
||||||
|
.cal-nav .segmented { flex-basis: 100%; }
|
||||||
.week-strip { gap: 4px; }
|
.week-strip { gap: 4px; }
|
||||||
|
.week-row { grid-template-columns: 28px repeat(7, minmax(0, 1fr)); gap: 3px; }
|
||||||
|
.hour-gutter .hour-label { font-size: 10px; }
|
||||||
|
.timeline.compact .block { padding: 1px 2px; font-size: 11px; }
|
||||||
|
.timeline.compact .block-time { display: none; }
|
||||||
|
.timeline.compact .block-title { overflow-wrap: normal; }
|
||||||
|
.mini { padding: 1px 3px; font-size: 10px; border-left-width: 2px; }
|
||||||
|
.month-grid { gap: 2px; }
|
||||||
|
.month-cell { min-height: 72px; padding: 2px; border-radius: 6px; }
|
||||||
}
|
}
|
||||||
.block.short .block-time { order: 2; }
|
.block.short .block-time { order: 2; }
|
||||||
|
|
||||||
@@ -324,6 +380,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
@@ -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[*]}"
|
||||||
|
|||||||
Reference in New Issue
Block a user