Add date-based post scheduling + daily deploy cron, and six draft posts #1
49
.gitea/workflows/scheduled-deploy.yml
Normal file
49
.gitea/workflows/scheduled-deploy.yml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Daily rebuild so date-scheduled posts go live on their pubDate.
|
||||||
|
#
|
||||||
|
# The site is static, so "now" is frozen at build time. This cron triggers a
|
||||||
|
# fresh Coolify deploy once a day; the rebuild re-evaluates the pubDate gate in
|
||||||
|
# src/utils/posts.ts, and any post whose date has arrived (and draft: false)
|
||||||
|
# appears. No file is mutated and no repo write access is needed — the job only
|
||||||
|
# pings the deploy hook.
|
||||||
|
#
|
||||||
|
# Setup (once):
|
||||||
|
# - A Gitea Actions runner must be registered and online for this repo/org,
|
||||||
|
# advertising a label that matches `runs-on` below (default act_runner maps
|
||||||
|
# `ubuntu-latest`; change it if your runner uses a different label).
|
||||||
|
# - Add a repo secret COOLIFY_DEPLOY_HOOK = the app's deploy URL from Coolify
|
||||||
|
# (app → Webhooks → "Deploy Webhook", e.g.
|
||||||
|
# https://coolify.example.com/api/v1/deploy?uuid=<uuid>&force=false).
|
||||||
|
# - Add a repo secret COOLIFY_TOKEN = a Coolify API token (Bearer) with deploy
|
||||||
|
# permission. Required for the /api/v1/deploy endpoint.
|
||||||
|
#
|
||||||
|
# Your normal push-to-main deploy still runs independently; this only covers the
|
||||||
|
# "a new day rolled over" case. Trigger it by hand from the Actions tab (this
|
||||||
|
# workflow has workflow_dispatch) to smoke-test before relying on the cron.
|
||||||
|
|
||||||
|
name: scheduled-deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# 06:15 Europe/Berlin daily. Posts are eligible from 00:00 UTC on their
|
||||||
|
# pubDate; this morning rebuild publishes them. Adjust the time to taste.
|
||||||
|
- cron: 'TZ=Europe/Berlin 15 6 * * *'
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
trigger-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Ping Coolify deploy hook
|
||||||
|
env:
|
||||||
|
COOLIFY_DEPLOY_HOOK: ${{ secrets.COOLIFY_DEPLOY_HOOK }}
|
||||||
|
COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$COOLIFY_DEPLOY_HOOK" ]; then
|
||||||
|
echo "COOLIFY_DEPLOY_HOOK secret is not set." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Coolify's /api/v1/deploy endpoint is a GET authenticated with a
|
||||||
|
# Bearer token. -f makes curl exit non-zero on HTTP errors.
|
||||||
|
curl -fsS -X GET "$COOLIFY_DEPLOY_HOOK" \
|
||||||
|
${COOLIFY_TOKEN:+-H "Authorization: Bearer $COOLIFY_TOKEN"}
|
||||||
|
echo "Deploy triggered."
|
||||||
79
src/content/blog/caldav-colors.md
Normal file
79
src/content/blog/caldav-colors.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
title: Why your CalDAV events show sixty shades of the same blue
|
||||||
|
description: >-
|
||||||
|
Two open colour reports on Calendula come from one root — an overstuffed picker
|
||||||
|
on CalDAV calendars and hard-to-read dark event titles — and both trace back to
|
||||||
|
how event colour flows through Android's calendar provider.
|
||||||
|
pubDate: 2026-07-08
|
||||||
|
tags: [android, calendula, caldav, accessibility]
|
||||||
|
draft: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Two colour reports landed on Calendula in the same week, and both are still open.
|
||||||
|
One is a bug: the colour picker on a CalDAV calendar shows a full screen of
|
||||||
|
colours, many of them near-duplicates ([#22]). The other is a request: make
|
||||||
|
event titles readable on dark backgrounds so busy weeks don't turn into a smear
|
||||||
|
([#21]). They look unrelated. They come from the same place — how event colour
|
||||||
|
actually flows through Android's calendar provider — which is why I'm writing up
|
||||||
|
the diagnosis before I write the fix.
|
||||||
|
|
||||||
|
## Palette vs. free-for-all
|
||||||
|
|
||||||
|
Android's
|
||||||
|
[`CalendarContract`](https://developer.android.com/reference/android/provider/CalendarContract)
|
||||||
|
has two ways to colour an event, and which one you get depends on the account.
|
||||||
|
|
||||||
|
Google and local calendars expose a **palette**: a small, indexed set of colours
|
||||||
|
through
|
||||||
|
[`CalendarContract.Colors`](https://developer.android.com/reference/android/provider/CalendarContract.Colors).
|
||||||
|
An event stores a `color_key`, the provider maps it to a swatch, and a picker can
|
||||||
|
show exactly those choices — a tidy dozen.
|
||||||
|
|
||||||
|
CalDAV is different. The iCalendar
|
||||||
|
[`COLOR`](https://datatracker.ietf.org/doc/html/rfc7986#section-5.9) property is
|
||||||
|
defined as a CSS3 colour *name* — a set of about 150, already far bigger than a
|
||||||
|
palette — and in practice clients push arbitrary hex through vendor extensions
|
||||||
|
like `X-APPLE-CALENDAR-COLOR`. Either way, by the time it reaches the provider
|
||||||
|
there's no `color_key` and no shared index: the event just carries a raw
|
||||||
|
`EVENT_COLOR`. So a picker that tries to *build* a palette from what it finds
|
||||||
|
ends up gathering every distinct value that ever appeared, including a dozen
|
||||||
|
blues that differ by a rounding error you can't see. That's the "too many
|
||||||
|
colours" bug: it isn't showing junk, it's faithfully showing an *unbounded* space
|
||||||
|
as though it were a fixed menu. The fix is to stop pretending CalDAV has a
|
||||||
|
palette — offer a sane curated set and map to the nearest real colour, rather
|
||||||
|
than enumerating every ghost.
|
||||||
|
|
||||||
|
## The contrast problem is the same problem
|
||||||
|
|
||||||
|
Once an event can be *any* colour, you can no longer assume the text on top is
|
||||||
|
readable. A pale event with dark text is fine; a deep navy block with the same
|
||||||
|
dark text is a smear. Right now Calendula draws event titles in a fixed near-
|
||||||
|
black — which is exactly the assumption that breaks. Google Calendar flips to
|
||||||
|
white text on dark fills, and [#21] asks for the same.
|
||||||
|
|
||||||
|
The rule for "is this colour dark" isn't the average of its channels — the eye
|
||||||
|
isn't equally sensitive to red, green and blue. The
|
||||||
|
[WCAG relative luminance](https://www.w3.org/TR/WCAG21/#dfn-relative-luminance)
|
||||||
|
formula weights them the way perception does (green counts far more than blue),
|
||||||
|
linearises each channel, and yields a single brightness figure. Pick a threshold,
|
||||||
|
and below it the title renders white (or off-white), above it near-black. It's a
|
||||||
|
few lines of maths applied to the same event colour the picker just handed you —
|
||||||
|
which is the point. The overstuffed picker and the unreadable title are two ends
|
||||||
|
of one pipe: *the moment colour stops being a fixed palette and becomes
|
||||||
|
arbitrary, both the input and the output need taming.* One end is a bug to close;
|
||||||
|
the other is a threshold to add. Same pipe.
|
||||||
|
|
||||||
|
## Standard first, taste second
|
||||||
|
|
||||||
|
The tidy path would be to store my own colour for every event and never touch the
|
||||||
|
provider's mess. I don't — for the same reason Calendula owns no other part of
|
||||||
|
your data. The `COLOR` on a CalDAV event belongs to the event, and the sync
|
||||||
|
adapter carries it out to every other client that reads your calendar; if I
|
||||||
|
overwrote it with a private value, that portability would be gone. My job isn't
|
||||||
|
to replace the colour. It's to present a bounded, readable *view* of it — a
|
||||||
|
curated picker on the way in, a luminance-aware title on the way out — while the
|
||||||
|
value itself stays yours and portable. Two open issues, one fix: let you read
|
||||||
|
your own calendar without taking it over.
|
||||||
|
|
||||||
|
[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
|
||||||
|
[#22]: https://codeberg.org/jlmakiola/calendula/issues/22
|
||||||
75
src/content/blog/recurring-event-edit.md
Normal file
75
src/content/blog/recurring-event-edit.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
title: "\"This event only\": the edit that quietly did nothing"
|
||||||
|
description: >-
|
||||||
|
Editing a single instance of a recurring event is one of the sharpest edges in
|
||||||
|
the calendar model. A bug in Calendula, and why recurrence makes "just save
|
||||||
|
it" surprisingly hard.
|
||||||
|
pubDate: 2026-07-05
|
||||||
|
tags: [android, calendula, caldav]
|
||||||
|
draft: true
|
||||||
|
---
|
||||||
|
|
||||||
|
A recurring event isn't a list of events. It's *one* row plus a rule. "Every
|
||||||
|
Tuesday at 10:00" is a single event with an
|
||||||
|
[`RRULE`](https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.5.3), and
|
||||||
|
the individual Tuesdays don't exist as stored rows — they're *expanded* from the
|
||||||
|
rule on demand. That single fact is why editing a recurring event is one of the
|
||||||
|
sharpest edges in the whole calendar model, and why Calendula had a bug ([#16])
|
||||||
|
where choosing "this event only" and saving bounced you back to the edit screen
|
||||||
|
without changing anything.
|
||||||
|
|
||||||
|
## Three answers to one save
|
||||||
|
|
||||||
|
Tap save on a recurring event and every calendar app asks the same question:
|
||||||
|
**this event, this and following, or all events?** They're three genuinely
|
||||||
|
different operations against the data model:
|
||||||
|
|
||||||
|
- **All events** edits the master row and its rule. Easy — it's the thing that
|
||||||
|
actually exists.
|
||||||
|
- **This and following** splits the series: the old rule gets an end, a new
|
||||||
|
event picks up from the edit point with the changes.
|
||||||
|
- **This event only** is the awkward one. You're editing an instance that has no
|
||||||
|
row of its own. To change it, you have to *create* one.
|
||||||
|
|
||||||
|
## What "this event only" actually does
|
||||||
|
|
||||||
|
Under the hood, "this event only" doesn't edit anything — it manufactures an
|
||||||
|
**exception**. The iCalendar model calls it a
|
||||||
|
[`RECURRENCE-ID`](https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.4.4)
|
||||||
|
override: a new component that says "for the occurrence that *would* have landed
|
||||||
|
at this original time, use these values instead." Android's
|
||||||
|
[`CalendarContract`](https://developer.android.com/reference/android/provider/CalendarContract)
|
||||||
|
exposes this through the exceptions URI and an `ORIGINAL_INSTANCE_TIME` — you
|
||||||
|
insert a one-off event pinned to the timestamp of the occurrence you're
|
||||||
|
replacing, and the provider excludes the original instance from the expansion in
|
||||||
|
its place.
|
||||||
|
|
||||||
|
So the operation is: identify the exact original instance time, insert an
|
||||||
|
exception bound to it, and let the provider stop expanding the rule at that slot.
|
||||||
|
Get the original-time bookkeeping wrong and the provider has nothing valid to
|
||||||
|
attach the exception to — the write doesn't land, and the UI, having nothing to
|
||||||
|
show for the save, falls back to the edit screen. Which is exactly the shape of
|
||||||
|
[#16]: the popup appeared correctly, the other two options worked, and "this
|
||||||
|
event only" silently did nothing. It wasn't the save logic — it was the one
|
||||||
|
branch that has to fabricate a row instead of updating one.
|
||||||
|
|
||||||
|
## Why I let the provider handle it
|
||||||
|
|
||||||
|
It would be tempting to sidestep all of this by keeping my own event table and
|
||||||
|
doing the expansion myself. I deliberately don't. Calendula reads and writes
|
||||||
|
through the system calendar provider, and recurrence exceptions are precisely
|
||||||
|
the kind of thing the provider — and the CalDAV sync adapter behind it — already
|
||||||
|
knows how to round-trip. An exception I write lands as a proper `RECURRENCE-ID`
|
||||||
|
override: DAVx5 pushes it to your server, and every other client that reads the
|
||||||
|
calendar sees the same override. If I expanded rules into my own private rows,
|
||||||
|
I'd own every one of these edge cases forever, and my "edits" would be invisible
|
||||||
|
to everything else that reads your calendar.
|
||||||
|
|
||||||
|
The hard part stays hard either way. Recurrence has been accreting rules and
|
||||||
|
corrections in [RFC 5545] for two decades, and there's no shortcut through it.
|
||||||
|
The trade is that by living inside the standard, the difficulty is *shared*: when
|
||||||
|
I get the original-instance bookkeeping right, the exception is correct
|
||||||
|
everywhere, not just in my app.
|
||||||
|
|
||||||
|
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
||||||
|
[RFC 5545]: https://datatracker.ietf.org/doc/html/rfc5545
|
||||||
74
src/content/blog/the-feature-i-said-no-to.md
Normal file
74
src/content/blog/the-feature-i-said-no-to.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
title: The feature I said no to (and the one I found instead)
|
||||||
|
description: >-
|
||||||
|
A user asked Calendula to sync itself to a plain .ics file. Talking it through
|
||||||
|
in the open turned a "no" into a better, smaller feature — and clarified what
|
||||||
|
the app is actually for.
|
||||||
|
pubDate: 2026-07-10
|
||||||
|
tags: [open-source, calendula, open-standards]
|
||||||
|
draft: true
|
||||||
|
---
|
||||||
|
|
||||||
|
One of the more useful things that has happened to Calendula wasn't a feature I
|
||||||
|
added. It was one I turned down — in public, in a comment thread, with the
|
||||||
|
person who asked for it ([#7]).
|
||||||
|
|
||||||
|
The request: let Calendula save its calendar state to a file. Prompt for an
|
||||||
|
`.ics` on startup, write back on every change, so events are never "trapped" in
|
||||||
|
the app. Reasonable on its face. My answer was no — but the interesting part is
|
||||||
|
how we got from there to something I *did* ship.
|
||||||
|
|
||||||
|
## Why it was a no
|
||||||
|
|
||||||
|
Calendula doesn't own your data. It's a client for Android's system calendar
|
||||||
|
provider, and the syncing is left to whatever CalDAV adapter you've already set
|
||||||
|
up — DAVx5, SmoothSync, whatever moves your events today. A "write to an .ics on
|
||||||
|
every change" feature quietly reintroduces the exact thing the app exists to
|
||||||
|
avoid: it starts handling sync itself.
|
||||||
|
|
||||||
|
And live file sync is genuinely hard, for a reason that has nothing to do with
|
||||||
|
effort. The moment two sides can edit the same event — a local change and a
|
||||||
|
synced-in change landing on the same slot — *something* has to decide which wins.
|
||||||
|
CalDAV has machinery for that. The `.ics` format doesn't; it was designed for
|
||||||
|
backups and for handing events from one place to another, not for being written
|
||||||
|
to continuously. Build the feature and you inherit a merge engine you now have to
|
||||||
|
get right forever. As I put it in the thread: as a developer you want a database
|
||||||
|
to talk to, not to hand-roll storage and eat all the hiccups that come with it.
|
||||||
|
|
||||||
|
## Why saying no in the open mattered
|
||||||
|
|
||||||
|
I could have just closed the issue with "out of scope." Instead we talked it
|
||||||
|
through, and the request opened up into something more specific than the first
|
||||||
|
sentence. What the reporter was after wasn't live bidirectional sync — it was a
|
||||||
|
guarantee that their events could exist as a file that isn't hostage to one app.
|
||||||
|
Their words, as we got there: "just a way to automatically write and read into a
|
||||||
|
file without it ever being trapped in the application."
|
||||||
|
|
||||||
|
That's a completely different, and much smaller, feature. Not a sync engine —
|
||||||
|
a **periodical auto-export**: set an interval, and at that interval your
|
||||||
|
calendar is written out to an `.ics`. No merge problem, because it's one
|
||||||
|
direction. No philosophy problem, because export *is* what the format is for.
|
||||||
|
That became [#8], and it satisfied the actual need — "That satisfies me!" was
|
||||||
|
how the thread closed.
|
||||||
|
|
||||||
|
I also pointed them at [ICSx5] and mentioned a separate app, the DAVx5 pattern,
|
||||||
|
would be the honest home for true file sync — because a real solution to their
|
||||||
|
original idea deserves to exist, just not bolted into a calendar client.
|
||||||
|
|
||||||
|
## What the thread was really for
|
||||||
|
|
||||||
|
The trade I keep making is that
|
||||||
|
[open standards are a boundary, not a checkbox](/blog/open-standards): the
|
||||||
|
`.ics` request sat just outside that boundary, and holding the line kept the app
|
||||||
|
simple enough to stay trustworthy. But the lesson wasn't "learn to say no." It
|
||||||
|
was that a good "no" is a conversation. The user leaves with something that
|
||||||
|
works, I leave with a sharper sense of what the app is *for*, and the thread
|
||||||
|
stays public so the next person can read the whole reasoning instead of a closed
|
||||||
|
issue with one terse label.
|
||||||
|
|
||||||
|
You don't get that from a roadmap. You get it from taking the request seriously
|
||||||
|
enough to argue with it.
|
||||||
|
|
||||||
|
[#7]: https://codeberg.org/jlmakiola/calendula/issues/7
|
||||||
|
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
|
||||||
|
[ICSx5]: https://icsx5.bitfire.at/
|
||||||
76
src/content/blog/the-translator-who-emailed-me.md
Normal file
76
src/content/blog/the-translator-who-emailed-me.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
title: The translator who emailed me
|
||||||
|
description: >-
|
||||||
|
I stood up a whole self-hosted Weblate instance to invite translators. A
|
||||||
|
missing email setting silently blocked the first one who showed up — until he
|
||||||
|
emailed me directly. By the next morning: Italian at 40%.
|
||||||
|
pubDate: 2026-07-06
|
||||||
|
tags: [open-source, calendula, localization]
|
||||||
|
draft: true
|
||||||
|
---
|
||||||
|
|
||||||
|
If you want your app translated, the conventional wisdom is: lower the barrier.
|
||||||
|
Don't make people file pull requests against a strings file. Give them a web UI
|
||||||
|
where they can see the English, type the translation, and submit — no git, no
|
||||||
|
build, no friction. So I did the thorough thing and stood up my own
|
||||||
|
[Weblate](https://weblate.org/) instance, self-hosted next to everything else I
|
||||||
|
run, wired to Calendula's repo. The pipeline was ready. Then it sat there, empty,
|
||||||
|
because building the invitation is not the same as being invited.
|
||||||
|
|
||||||
|
## The bug that only a person could find
|
||||||
|
|
||||||
|
The first translator didn't come through the polished pipeline. He came through
|
||||||
|
my inbox.
|
||||||
|
|
||||||
|
Roberto — Italian, and generous enough to lead with why rather than what. He'd
|
||||||
|
been waiting years, he said, for a well-made Android calendar that was open
|
||||||
|
source and privacy-focused, and he wanted to help translate it.
|
||||||
|
|
||||||
|
But he couldn't register on my Weblate. The registration page told him there
|
||||||
|
were problems with the server. He'd signed up fine on the public Weblate site,
|
||||||
|
so he rightly suspected the fault was on my self-hosted end and asked whether it
|
||||||
|
could be fixed.
|
||||||
|
|
||||||
|
It was, and the cause was almost funny in how mundane it was: I'd never
|
||||||
|
configured email for the instance. Weblate sends a verification message on
|
||||||
|
registration; with no mail server behind it, that step failed silently, for
|
||||||
|
everyone. My own testing never caught it because I was already an admin. The
|
||||||
|
carefully-built front door had no doorbell, and I had no way of knowing until
|
||||||
|
someone stood outside it and told me.
|
||||||
|
|
||||||
|
Same day, I set up mail, verified that registration worked end to end, and wrote
|
||||||
|
back. His reply, once he was in, was simply that it was working — he'd started
|
||||||
|
translating.
|
||||||
|
|
||||||
|
## What the infrastructure couldn't do
|
||||||
|
|
||||||
|
Here's the part I keep thinking about. I'd invested in the *scalable* solution —
|
||||||
|
a hosted platform anyone in the world could use without talking to me. And its
|
||||||
|
first real outcome was a silent failure that turned every prospective
|
||||||
|
contributor away, invisibly, until one person chose the *un*-scalable path of
|
||||||
|
emailing a stranger to say "your thing is broken, I'd like to help anyway."
|
||||||
|
|
||||||
|
The tooling is still worth it. But the tooling didn't get Calendula translated —
|
||||||
|
a person who cared did, and who cared enough to push past a broken sign-up
|
||||||
|
instead of shrugging and closing the tab. No form submission would have told me
|
||||||
|
the form was broken.
|
||||||
|
|
||||||
|
## By morning
|
||||||
|
|
||||||
|
I fixed the mail config in the evening. By the next morning the Italian
|
||||||
|
translation was at **40%** and Spanish had appeared at **34%** — two languages
|
||||||
|
moving, from basically nothing, within a day of the sign-up actually working.
|
||||||
|
|
||||||
|
That's the whole arc of a small open-source project in miniature: you build the
|
||||||
|
proper infrastructure because it's the right thing to do, it fails in some dumb
|
||||||
|
invisible way, someone who genuinely wants the thing to exist reaches through the
|
||||||
|
gap to tell you, you fix it in an evening, and suddenly your calendar speaks
|
||||||
|
Italian. Not because the pipeline was clever. Because Roberto wrote an email.
|
||||||
|
|
||||||
|
If you're the kind of person who emails the developer instead of quietly giving
|
||||||
|
up: thank you. You are worth more than the analytics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Want to help translate [Calendula](/work/calendula)? The door works now —
|
||||||
|
[weblate.dev.jeanlucmakiola.de](https://weblate.dev.jeanlucmakiola.de).*
|
||||||
88
src/content/blog/when-it-stopped-being-mine.md
Normal file
88
src/content/blog/when-it-stopped-being-mine.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
title: The week my calendar app stopped being just mine
|
||||||
|
description: >-
|
||||||
|
Calendula was a thing I built for myself. Then strangers started relying on it,
|
||||||
|
filing issues, debating design in the comments — and it turned into something
|
||||||
|
with a small community around it.
|
||||||
|
pubDate: 2026-07-03
|
||||||
|
tags: [open-source, calendula]
|
||||||
|
draft: true
|
||||||
|
---
|
||||||
|
|
||||||
|
I built Calendula for myself. I wanted a fast, good-looking, privacy-respecting
|
||||||
|
calendar that didn't own my data, couldn't find one, and so I made one. For a
|
||||||
|
while that was all it was: my app, my phone, my taste.
|
||||||
|
|
||||||
|
Then people showed up. Not many, not all at once — but enough, in about two
|
||||||
|
weeks, that the project stopped feeling like a thing I *have* and started
|
||||||
|
feeling like a thing I'm *responsible for*. This is a note about that shift,
|
||||||
|
because nobody tells you how quickly it happens.
|
||||||
|
|
||||||
|
## From requests to a rhythm
|
||||||
|
|
||||||
|
The first issues were classic solo-dev fare: someone asked for a setting to
|
||||||
|
choose which view the app opens on. I shipped it in
|
||||||
|
[v2.9.0](https://codeberg.org/jlmakiola/calendula/issues/1) within hours, and
|
||||||
|
"that was quick!" came back. Then a tappable month grid; then a Saturday
|
||||||
|
week-start — each one small, each one turned around fast. Then: limit the agenda
|
||||||
|
view to today, or this week, or the next 30 days — and here the exchange got
|
||||||
|
interesting, because I stopped just building the literal request and started
|
||||||
|
asking *what options would you actually want*, and we designed it together in the
|
||||||
|
thread.
|
||||||
|
|
||||||
|
That rhythm — report, discuss, ship, "thank you" — is the engine of a small open
|
||||||
|
project. It's also a trap if you let it: not every request should be built. The
|
||||||
|
hard skill isn't saying yes fast, it's
|
||||||
|
[saying no well](/blog/the-feature-i-said-no-to), and keeping the app inside its
|
||||||
|
[lane](/blog/open-standards) while the person still leaves happy.
|
||||||
|
|
||||||
|
## The moment it gets real
|
||||||
|
|
||||||
|
There's a specific sentence that changes how you feel about a side project. Mine
|
||||||
|
arrived on an issue about registering an intent filter, from someone who'd hit a
|
||||||
|
papercut on GrapheneOS:
|
||||||
|
|
||||||
|
> I have been looking out for a well-designed and fast calendar since switching
|
||||||
|
> to Android four years ago and your software seems to be on point.
|
||||||
|
|
||||||
|
Four years of waiting. That's the kind of line that reframes a side project —
|
||||||
|
someone who's been holding out for exactly this and is ready to depend on it. The
|
||||||
|
same thread is where
|
||||||
|
I admitted a limitation honestly (there's no Android API to set yourself as the
|
||||||
|
default calendar, so no in-app button is possible — only the system picker), and
|
||||||
|
that honesty landed better than a workaround would have. Relied-upon software is
|
||||||
|
built on trust, and trust is mostly just not overpromising.
|
||||||
|
|
||||||
|
## When users become co-designers
|
||||||
|
|
||||||
|
The real tell that a community is forming isn't traffic — it's when people start
|
||||||
|
doing the *thinking* with you, not just the reporting.
|
||||||
|
|
||||||
|
On a request to make the view quick-switch button configurable, the conversation
|
||||||
|
turned into an actual design debate: should reordering live in a settings tab, or
|
||||||
|
should you long-press and drag items in place? Would drag-in-place cause
|
||||||
|
accidental reordering? We went back and forth on the interaction, not the
|
||||||
|
feature. On the contact-birthdays feature, a user who *wasn't even the original
|
||||||
|
reporter* created test contacts — one Google, one CardDAV — to help me isolate
|
||||||
|
why some birthdays showed up and others didn't. That's not filing a bug. That's
|
||||||
|
debugging *with* me.
|
||||||
|
|
||||||
|
I don't have a big community. I have a handful of people who care about a good
|
||||||
|
calendar as much as I do, and who've started treating its rough edges as *our*
|
||||||
|
problem. That's the shift.
|
||||||
|
|
||||||
|
## The unglamorous parts, on purpose
|
||||||
|
|
||||||
|
None of this is a highlight reel. In the same fortnight I shipped a release that
|
||||||
|
mangled the month widget's switching buttons — a regression a user caught and I
|
||||||
|
had to go dig out of a recent change. The birthday feature still doesn't handle
|
||||||
|
every account type. Writing this, I could have quietly left those out. But the
|
||||||
|
point of doing this in the open, on a public tracker, is that the reasoning and
|
||||||
|
the mistakes are all readable — the terse "closed" label replaced by an actual
|
||||||
|
conversation anyone can follow later.
|
||||||
|
|
||||||
|
A project stops being *just yours* the moment someone else would notice if it
|
||||||
|
broke. Calendula crossed that line quietly, in a week of issue threads, and it
|
||||||
|
changed how I write every commit since. The question stopped being "does this
|
||||||
|
please me" and became "would I want to explain this decision to the people in the
|
||||||
|
comments." The second one is the one worth answering.
|
||||||
92
src/content/blog/widgets-remoteviews.md
Normal file
92
src/content/blog/widgets-remoteviews.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
title: Compose for widgets, RemoteViews underneath
|
||||||
|
description: >-
|
||||||
|
Jetpack Glance lets you write home-screen widgets in Compose. But it compiles
|
||||||
|
down to RemoteViews — so the old constraints still bite through the nice API.
|
||||||
|
Three Calendula bugs that proved it.
|
||||||
|
pubDate: 2026-07-02
|
||||||
|
tags: [android, architecture, calendula]
|
||||||
|
draft: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Calendula's home-screen widgets are written in
|
||||||
|
[Jetpack Glance](https://developer.android.com/develop/ui/compose/glance) — the
|
||||||
|
Compose-style API for widgets. You declare a `GlanceAppWidget`, write something
|
||||||
|
that looks like a composable, and attach behaviour with familiar-feeling
|
||||||
|
modifiers. It's a genuine relief compared to hand-assembling widget layouts.
|
||||||
|
|
||||||
|
But Glance isn't a new widget runtime. It's a translation layer: your
|
||||||
|
composition is compiled down to
|
||||||
|
[`RemoteViews`](https://developer.android.com/reference/android/widget/RemoteViews)
|
||||||
|
— a serialized description of a layout that *another* process (the launcher)
|
||||||
|
inflates and renders. Your app isn't running while the widget is on screen, and
|
||||||
|
it never gets the touch events. Glance hides that, but it can't repeal it. Three
|
||||||
|
Calendula widget bugs in a row were really the same lesson: the RemoteViews
|
||||||
|
constraints reach up through the Compose gloss and bite anyway.
|
||||||
|
|
||||||
|
## There's no click handler, only an action
|
||||||
|
|
||||||
|
In a real Compose UI you'd write `Modifier.clickable { doThing() }` and `doThing`
|
||||||
|
runs in your process. In Glance you write
|
||||||
|
`GlanceModifier.clickable(actionStartActivity<…>())` — and that difference is
|
||||||
|
the whole story. There's no lambda that runs on tap, because there's no code of
|
||||||
|
yours running on the far side. Glance only lets you attach an **action** —
|
||||||
|
`actionStartActivity` to launch something, `actionRunCallback` to fire a
|
||||||
|
registered callback — and those compile down to the `PendingIntent`s RemoteViews
|
||||||
|
has always required. Taps don't call a function; they launch something.
|
||||||
|
|
||||||
|
That reframes every "make X tappable" request. When the **month widget's**
|
||||||
|
arrows and header did nothing ([#18]), it wasn't a broken handler — there was no
|
||||||
|
handler to break, and no action wired to the view either, so the launcher had
|
||||||
|
nothing to fire. The fix isn't "handle the tap," it's "attach the action": one
|
||||||
|
that advances the month, one that opens the app in month view. Same shape in
|
||||||
|
[#20] — the agenda widget's header should open your default view, again a
|
||||||
|
missing action, not a bug in one.
|
||||||
|
|
||||||
|
And because every day cell needs to open *its own* date, each one carries its
|
||||||
|
own action. Glance's `clickable` makes that look like an ordinary per-item
|
||||||
|
modifier, but underneath it's still one addressed intent per cell — a month grid
|
||||||
|
is a grid of them. An earlier round ([#2]) fixed exactly this: the month grid
|
||||||
|
wasn't interactive at all until each day was made to launch itself.
|
||||||
|
|
||||||
|
## The far side has to be told what "back" means
|
||||||
|
|
||||||
|
Because a tap launches an intent rather than navigating, the app has to
|
||||||
|
reconstruct context on arrival. Tapping a day in the **agenda** widget and
|
||||||
|
tapping a day in the **month** widget both open a day — but where should *Back*
|
||||||
|
take you? The widget knows; the freshly-started activity does not, unless the
|
||||||
|
action says so.
|
||||||
|
|
||||||
|
So `actionStartActivity` carries not just "open this day" but "you came from the
|
||||||
|
agenda context," and the app rebuilds a back stack from that: day → agenda →
|
||||||
|
your default view, or day → month → default. It looks like normal navigation to
|
||||||
|
the user. Under the hood it's the app trusting a breadcrumb the widget packed
|
||||||
|
into the launch, because a widget can't hand over a live navigation state —
|
||||||
|
Glance composition or not, all it can serialize is data.
|
||||||
|
|
||||||
|
## "Upcoming" has to actually mean upcoming
|
||||||
|
|
||||||
|
The last one wasn't about interaction. The agenda widget is titled **Upcoming**,
|
||||||
|
yet it listed every event of the day, finished ones included ([#12]). In a
|
||||||
|
normal Compose list you'd just filter as the list recomposes. A widget can't —
|
||||||
|
what the launcher renders is baked in when the composition is snapshotted to
|
||||||
|
RemoteViews, not recomputed as you scroll. So "don't show past events" becomes a
|
||||||
|
property of the data you build *before* Glance serializes it, exposed as a
|
||||||
|
`PastEventDisplay` preference: hide finished events outright, or dim them in
|
||||||
|
place for people who still want them visible.
|
||||||
|
|
||||||
|
## The lesson
|
||||||
|
|
||||||
|
Glance is a real improvement — declaring widgets in Compose beats the old
|
||||||
|
ceremony. But it's a nicer handle on the same box. There's still no process of
|
||||||
|
yours, still no live view tree, still only data you snapshot and actions you
|
||||||
|
pre-address, rendered by someone else. Once I stopped thinking "how do I handle
|
||||||
|
this tap" and started thinking "what action should this fire, and what does the
|
||||||
|
far side need to know," the three bugs stopped looking separate. A widget can't
|
||||||
|
*do* anything. It can only describe what should happen — and Compose syntax
|
||||||
|
doesn't change that, it just makes the describing pleasant.
|
||||||
|
|
||||||
|
[#2]: https://codeberg.org/jlmakiola/calendula/issues/2
|
||||||
|
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
|
||||||
|
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
||||||
|
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
---
|
---
|
||||||
import { getCollection, render } from 'astro:content';
|
import { render } from 'astro:content';
|
||||||
import BlogPost from '../../layouts/BlogPost.astro';
|
import BlogPost from '../../layouts/BlogPost.astro';
|
||||||
import type { GetStaticPaths } from 'astro';
|
import type { GetStaticPaths } from 'astro';
|
||||||
|
import { getPublishedPosts } from '../../utils/posts';
|
||||||
|
|
||||||
export const getStaticPaths = (async () => {
|
export const getStaticPaths = (async () => {
|
||||||
const posts = await getCollection('blog', ({ data }) => !data.draft);
|
const posts = await getPublishedPosts();
|
||||||
return posts.map((post) => ({
|
return posts.map((post) => ({
|
||||||
params: { slug: post.id },
|
params: { slug: post.id },
|
||||||
props: { post },
|
props: { post },
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import FormattedDate from '../../components/FormattedDate.astro';
|
import FormattedDate from '../../components/FormattedDate.astro';
|
||||||
import { getCollection } from 'astro:content';
|
import { getPublishedPosts } from '../../utils/posts';
|
||||||
|
|
||||||
const posts = (await getCollection('blog', ({ data }) => !data.draft)).sort(
|
const posts = await getPublishedPosts();
|
||||||
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
|
|
||||||
);
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="Blog" description="Writing and notes by Jean-Luc Makiola." width="narrow">
|
<BaseLayout title="Blog" description="Writing and notes by Jean-Luc Makiola." width="narrow">
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import { SITE } from '../consts';
|
import { SITE } from '../consts';
|
||||||
|
import { getPublishedPosts } from '../utils/posts';
|
||||||
|
|
||||||
const posts = (await getCollection('blog', ({ data }) => !data.draft))
|
const posts = (await getPublishedPosts()).slice(0, 4);
|
||||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
|
|
||||||
.slice(0, 4);
|
|
||||||
|
|
||||||
const fmt = (d: Date) =>
|
const fmt = (d: Date) =>
|
||||||
d.toLocaleDateString(SITE.lang, { year: 'numeric', month: 'short' });
|
d.toLocaleDateString(SITE.lang, { year: 'numeric', month: 'short' });
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import rss from '@astrojs/rss';
|
import rss from '@astrojs/rss';
|
||||||
import { getCollection } from 'astro:content';
|
|
||||||
import { SITE } from '../consts';
|
import { SITE } from '../consts';
|
||||||
|
import { getPublishedPosts } from '../utils/posts';
|
||||||
|
|
||||||
export async function GET(context) {
|
export async function GET(context) {
|
||||||
const posts = (await getCollection('blog', ({ data }) => !data.draft)).sort(
|
const posts = await getPublishedPosts();
|
||||||
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
|
|
||||||
);
|
|
||||||
|
|
||||||
return rss({
|
return rss({
|
||||||
title: SITE.title,
|
title: SITE.title,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import FormattedDate from '../../components/FormattedDate.astro';
|
import FormattedDate from '../../components/FormattedDate.astro';
|
||||||
import { getCollection } from 'astro:content';
|
|
||||||
import type { GetStaticPaths } from 'astro';
|
import type { GetStaticPaths } from 'astro';
|
||||||
|
import { getPublishedPosts } from '../../utils/posts';
|
||||||
|
|
||||||
export const getStaticPaths = (async () => {
|
export const getStaticPaths = (async () => {
|
||||||
const posts = await getCollection('blog', ({ data }) => !data.draft);
|
const posts = await getPublishedPosts();
|
||||||
const tags = [...new Set(posts.flatMap((p) => p.data.tags))];
|
const tags = [...new Set(posts.flatMap((p) => p.data.tags))];
|
||||||
return tags.map((tag) => ({
|
return tags.map((tag) => ({
|
||||||
params: { tag },
|
params: { tag },
|
||||||
|
|||||||
24
src/utils/posts.ts
Normal file
24
src/utils/posts.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { getCollection, type CollectionEntry } from 'astro:content';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Published blog posts, newest first.
|
||||||
|
*
|
||||||
|
* A post is published when it is not a draft AND its `pubDate` has arrived.
|
||||||
|
* That makes `pubDate` the single source of truth for scheduling: set a post
|
||||||
|
* to `draft: false` with a future date and it goes live on its own once a
|
||||||
|
* build runs on or after that day (see the daily deploy cron).
|
||||||
|
*
|
||||||
|
* In dev the gate is lifted entirely so drafts and future-scheduled posts can
|
||||||
|
* be previewed locally; production applies both the draft and date gates.
|
||||||
|
*/
|
||||||
|
export async function getPublishedPosts(): Promise<CollectionEntry<'blog'>[]> {
|
||||||
|
const now = Date.now();
|
||||||
|
const posts = await getCollection('blog', ({ data }) => {
|
||||||
|
// Dev server: show everything, including drafts and not-yet-due posts.
|
||||||
|
if (import.meta.env.DEV) return true;
|
||||||
|
if (data.draft) return false;
|
||||||
|
if (data.pubDate.valueOf() > now) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user