aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md9
-rwxr-xr-xbin/calendar-sample.sh49
-rw-r--r--lib/card.lua1
-rw-r--r--lib/data.lua34
-rw-r--r--test/fixtures/calendar_cache3
-rw-r--r--test/test_data.lua16
-rw-r--r--widgets/calendar.lua41
7 files changed, 145 insertions, 8 deletions
diff --git a/README.md b/README.md
index 07171c2..e99ee68 100644
--- a/README.md
+++ b/README.md
@@ -131,6 +131,15 @@ every remaining day with an event while the rows stay the coming week. The card
of the week defeats its own purpose. A cell too short for even one row draws
the grid alone, without the legend, rather than a clipped half-row.
+**Tasks join the rows when a task vdir exists.** The sampler also reads
+open VTODOs with a due date from `~/.local/share/tasks/<list>/*.ics` (the
+layout a vdirsyncer `filesystem` storage writes; override the path with
+`UDT_TASKS_DIR`). They are merged into the rows by date, each with a checkbox
+before its title, coloured by urgency: overdue in `critical`, due today in
+`warning`, later in `label`. Overdue tasks stay listed until done, so they
+lead the table. Tasks never mark the grid, where colour means an appointment.
+Without the directory the card shows events only.
+
**Calendar colours come from khal's own config**, parsed out of the
`[calendars]` section by the sampler, then mapped onto the board's palette
roles in `COLOUR_ROLE` at the top of `widgets/calendar.lua`: green to `ok`,
diff --git a/bin/calendar-sample.sh b/bin/calendar-sample.sh
index a0fb87e..c24e756 100755
--- a/bin/calendar-sample.sh
+++ b/bin/calendar-sample.sh
@@ -57,6 +57,12 @@ EVENTS=$(khal list --day-format '' \
--format '{start-date-long}|{start-time}|{end-time}|{calendar}|{title}' \
--notstarted today "${DAYS}d" 2>/dev/null)
+# The task vdir is optional: without it the card shows events only.
+TASKS_DIR="${UDT_TASKS_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/tasks}"
+shopt -s nullglob
+TASK_FILES=("$TASKS_DIR"/*/*.ics)
+shopt -u nullglob
+
# khal's exit status is 0 even with no calendars configured, so emptiness is
# not distinguishable from failure by status alone. The widget treats a missing
# 'generated' key as "the sampler never ran" and an empty event list as "no
@@ -87,6 +93,49 @@ EVENTS=$(khal list --day-format '' \
if [ -n "$EVENTS" ]; then
printf '%s\n' "$EVENTS" | sed 's/^/event /'
fi
+
+ # Open tasks with a due date, from a vdirsyncer task vdir (one VTODO per
+ # .ics, one subdirectory per list). Read directly rather than through a
+ # CLI: the files are plain iCalendar and gawk starts in milliseconds.
+ # Written as 'task YYYY-MM-DD|HH:MM|title', ISO since we control it; the
+ # widget cuts the list to the week, keeping overdue ones.
+ #
+ # Lines are unfolded first (RFC 5545 continuation lines start with a
+ # space). A VALARM inside a VTODO carries its own DESCRIPTION/SUMMARY, so
+ # its properties are skipped. A UTC due time (...Z) is converted to local.
+ # ponytail: a DUE;TZID=... time is taken as local, not converted; fine
+ # while the task server and this desktop share a zone.
+ if [ ${#TASK_FILES[@]} -gt 0 ]; then
+ gawk '
+ function prop(line, name, val, p, d) {
+ p = index(line, ":"); if (!p) return
+ name = substr(line, 1, p - 1); val = substr(line, p + 1)
+ if (line == "BEGIN:VTODO") { todo = 1; alarm = 0; due = summary = status = ""; return }
+ if (line == "BEGIN:VALARM") { alarm = 1; return }
+ if (line == "END:VALARM") { alarm = 0; return }
+ if (line == "END:VTODO") {
+ if (todo && due != "" && status != "COMPLETED" && status != "CANCELLED")
+ print "task " due "|" summary
+ todo = 0; return
+ }
+ if (!todo || alarm) return
+ sub(/;.*/, "", name)
+ if (name == "STATUS") status = val
+ else if (name == "SUMMARY") {
+ gsub(/\\[nN]/, " ", val)
+ summary = gensub(/\\([,;\\])/, "\\1", "g", val)
+ } else if (name == "DUE" && match(val, /^([0-9]{4})([0-9]{2})([0-9]{2})(T([0-9]{2})([0-9]{2})([0-9]{2})(Z?))?/, d)) {
+ if (d[4] == "") due = d[1] "-" d[2] "-" d[3] "|"
+ else due = strftime("%Y-%m-%d|%H:%M",
+ mktime(d[1] " " d[2] " " d[3] " " d[5] " " d[6] " " d[7], d[8] == "Z"))
+ }
+ }
+ { sub(/\r$/, "") }
+ /^[ \t]/ { cur = cur substr($0, 2); next }
+ { prop(cur); cur = $0 }
+ ENDFILE { prop(cur); cur = "" }
+ ' "${TASK_FILES[@]}" 2>/dev/null
+ fi
} > "$TMP"
mv -f "$TMP" "$CACHE"
diff --git a/lib/card.lua b/lib/card.lua
index 221d8d2..bc61c31 100644
--- a/lib/card.lua
+++ b/lib/card.lua
@@ -230,6 +230,7 @@ M.ICON = {
week = '\u{F0A33}', -- calendar-week
details = '\u{F059D}', -- weather-windy
sky = '\u{F0594}', -- weather-night
+ task = '\u{F0131}', -- checkbox-blank-outline
}
-- The gap between a title's icon and its text. One constant so the measured
diff --git a/lib/data.lua b/lib/data.lua
index dca039a..75dedcd 100644
--- a/lib/data.lua
+++ b/lib/data.lua
@@ -36,7 +36,8 @@ end
-- The calendar cache written by bin/calendar-sample.sh.
--
-- Returns { today = { y, m, d }, colors = { <calendar> = <khal colour name> },
--- events = { { y, m, d, start, finish, calendar, title }, ... } }, or nil when
+-- events = { { y, m, d, start, finish, calendar, title }, ... },
+-- tasks = { { y, m, d, start, title, task = true }, ... } }, or nil when
-- the cache is absent or carries no 'generated' key. nil means "the sampler
-- never ran", which the card names; an empty events list means "nothing in the
-- next week", which is a different and equally valid state.
@@ -50,7 +51,7 @@ end
function M.calendar_parse(text)
if type(text) ~= 'string' then return nil end
- local out = { colors = {}, events = {} }
+ local out = { colors = {}, events = {}, tasks = {} }
local generated = false
for line in text:gmatch('[^\n]+') do
@@ -77,6 +78,16 @@ function M.calendar_parse(text)
title = title,
}
end
+ elseif kind == 'task' then
+ -- Written by the sampler from the task vdir, so the date is ISO: no
+ -- positional guessing as for khal's events.
+ local y, m, d, st, title = rest:match('^(%d+)-(%d+)-(%d+)|([^|]*)|(.*)$')
+ if y then
+ out.tasks[#out.tasks + 1] = {
+ y = tonumber(y), m = tonumber(m), d = tonumber(d),
+ start = st ~= '' and st or nil, title = title, task = true,
+ }
+ end
end
end
@@ -146,6 +157,25 @@ function M.calendar_upcoming(events, today, days)
return out
end
+-- The card's table: the week's events and the open tasks due in it, as one
+-- list by date. Overdue tasks pass calendar_upcoming's window (a negative day
+-- offset is under any limit), so they lead the list, which is where they
+-- belong. Within a day an all-day row ('' start) sorts before a timed one.
+function M.calendar_agenda(cal, today, days)
+ local rows = M.calendar_upcoming(cal.events, today, days)
+ for _, t in ipairs(M.calendar_upcoming(cal.tasks, today, days)) do
+ rows[#rows + 1] = t
+ end
+ -- The index breaks ties, since table.sort is not stable.
+ local key = {}
+ for i, e in ipairs(rows) do
+ key[e] = string.format('%04d%02d%02d %-5s %04d', e.y, e.m, e.d,
+ e.start or '', i)
+ end
+ table.sort(rows, function(a, b) return key[a] < key[b] end)
+ return rows
+end
+
-- Days in a month, and the weekday its 1st falls on.
--
-- os.time/os.date rather than a leap-year rule: the C library already knows,
diff --git a/test/fixtures/calendar_cache b/test/fixtures/calendar_cache
index c31ab94..161b091 100644
--- a/test/fixtures/calendar_cache
+++ b/test/fixtures/calendar_cache
@@ -8,3 +8,6 @@ event 18.09.2026|||birthdays|A birthday
event 24.09.2026|14:30|15:30|work|Team sync | notes
event 26.09.2026|||birthdays|Another birthday
event 02.10.2026|09:00|10:00|work|Review
+task 2026-09-16||Overdue chore
+task 2026-09-18|17:00|Task due today
+task 2026-10-30||Task far out
diff --git a/test/test_data.lua b/test/test_data.lua
index c33e5ae..9612c43 100644
--- a/test/test_data.lua
+++ b/test/test_data.lua
@@ -117,6 +117,22 @@ assert(#up == 2 and up[2].d == 25, 'eight days from the 18th end on the 25th')
assert(#data.calendar_upcoming({ { y = 2026, m = 11, d = 2 } },
{ 2026, 10, 28 }, 8) == 1, 'week crosses into next month')
+-- Tasks: separate from events so the grid stays appointments-only, merged
+-- with them by date for the table. An overdue task is kept (it sorts first),
+-- one past the week is cut like an event, and an all-day row sorts before a
+-- timed one on the same day, as khal orders them.
+assert(#cal.tasks == 3, 'task count, got ' .. tostring(#cal.tasks))
+assert(cal.tasks[1].task and cal.tasks[1].start == nil
+ and cal.tasks[2].start == '17:00', 'task fields')
+local ag = data.calendar_agenda(cal, { 2026, 9, 18 }, 8)
+local titles = {}
+for i, e in ipairs(ag) do titles[i] = e.title end
+assert(table.concat(titles, ',') == 'Overdue chore,A birthday,Dentist,'
+ .. 'Task due today,Team sync | notes',
+ 'agenda order, got ' .. table.concat(titles, ','))
+assert(#data.calendar_agenda(empty, { 2026, 9, 18 }, 8) == 0,
+ 'no events, no tasks: empty agenda')
+
-- month_shape: September 2026 has 30 days and starts on a Tuesday.
-- Monday = 1, so Tuesday = 2.
local ndays, first = data.month_shape(2026, 9)
diff --git a/widgets/calendar.lua b/widgets/calendar.lua
index 5e08a7b..e8f2685 100644
--- a/widgets/calendar.lua
+++ b/widgets/calendar.lua
@@ -1,4 +1,5 @@
--- Calendar: the current month, and the week's appointments below it.
+-- Calendar: the current month, and the week's appointments and due tasks
+-- below it. Tasks never mark the grid: colour there means an appointment.
--
-- Events come from a cache file written by bin/calendar-sample.sh, because
-- conky's Lua cannot run a process and khal costs ~200ms of Python startup,
@@ -48,8 +49,25 @@ end
-- Day/month, no year: the grid above already names the month, and the window
-- is a week, so a year would be three characters of the row spent on the one
-- field that cannot vary.
+--
+-- A task with no due time shows its date alone: 'all day' would say it
+-- occupies the day, which a task does not.
local function when(e)
- return string.format('%02d/%02d %s', e.d, e.m, e.start or 'all day')
+ return string.format('%02d/%02d %s', e.d, e.m,
+ e.start or (e.task and '' or 'all day'))
+end
+
+-- A row's code colour. Events take their calendar's; tasks take urgency, in
+-- the board's own language: overdue `critical`, due today `warning`, later
+-- `label`. The checkbox before the title is what tells a task from an event,
+-- since `critical` is also a calendar colour.
+local function row_colour(e, today, colors, khal_colors)
+ if not e.task then return cal_colour(e.calendar, colors, khal_colors) end
+ local due = e.y * 10000 + e.m * 100 + e.d
+ local now = today[1] * 10000 + today[2] * 100 + today[3]
+ if due < now then return colors.critical end
+ if due == now then return colors.warning end
+ return colors.label
end
-- The legend: one coloured dot and name per calendar, along the card's bottom.
@@ -209,8 +227,9 @@ function M.draw(cr, rect, colors)
local ey = grid_bottom + ev_size * 1.1
- -- The cache spans the month for the grid; the table is the week ahead.
- local events = data.calendar_upcoming(cal.events, today, 8)
+ -- The cache spans the month for the grid; the table is the week ahead,
+ -- with open tasks due in it (and overdue ones) merged in by date.
+ local events = data.calendar_agenda(cal, today, 8)
if #events == 0 then
card.font(cr, card.FONT_UI, ev_size, false)
@@ -267,7 +286,7 @@ function M.draw(cr, rect, colors)
for i = 1, shown do
local e = events[i]
if ey > floor_y then break end
- local colour = cal_colour(e.calendar, colors, cal.colors)
+ local colour = row_colour(e, today, colors, cal.colors)
card.font(cr, card.FONT_MONO, ev_size, false)
card.rgba(cr, colour)
@@ -276,9 +295,19 @@ function M.draw(cr, rect, colors)
-- The title in the body colour, not the calendar's: colour-coding every
-- glyph in the row makes three calendars read as three unrelated cards.
-- The time carries the code, the title stays readable.
+ local tx, chars = title_x, title_chars
+ if e.task then
+ -- The checkbox in the row's urgency colour, from the mono face since
+ -- the UI face has no Nerd Font glyphs.
+ local icon = card.ICON.task .. ' '
+ card.font(cr, card.FONT_MONO, ev_size, false)
+ card.text(cr, tx, ey, icon)
+ tx = tx + card.advance(cr, icon)
+ chars = math.max(4, chars - 2)
+ end
card.font(cr, card.FONT_UI, ev_size, false)
card.rgba(cr, colors.value)
- card.text(cr, title_x, ey, card.truncate(e.title, title_chars))
+ card.text(cr, tx, ey, card.truncate(e.title, chars))
ey = ey + ev_step
end