From c39361726fa9cf1623d49ea1b9b2dd7e43f67622 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 14 Nov 2026 11:27:41 +0100 Subject: feat(calendar): list due tasks from a task vdir beside the events The sampler reads open VTODOs with a due date from ~/.local/share/tasks//*.ics (a vdirsyncer filesystem storage, override with UDT_TASKS_DIR) and writes them to the cache as ISO 'task' lines. The card merges them into the week's rows by date, with a checkbox before the title and an urgency colour: overdue critical, due today warning, later label. Overdue tasks stay until done, so they lead the table. Tasks never mark the month grid. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 ++++++++ bin/calendar-sample.sh | 49 ++++++++++++++++++++++++++++++++++++++++++++ lib/card.lua | 1 + lib/data.lua | 34 ++++++++++++++++++++++++++++-- test/fixtures/calendar_cache | 3 +++ test/test_data.lua | 16 +++++++++++++++ widgets/calendar.lua | 41 ++++++++++++++++++++++++++++++------ 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//*.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 = { = }, --- 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 -- cgit v1.2.3