From cecbda26b01fd5e630b3a47e252b9fbdecf419c7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 16:35:06 +0200 Subject: [PATCH 1/2] Add date-based post scheduling and daily deploy cron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate blog listings/RSS on pubDate<=now (in addition to !draft) via a shared getPublishedPosts() helper, so a post set to draft:false with a future pubDate publishes itself once a build runs on that day. Dev shows everything for preview. Add .gitea/workflows/scheduled-deploy.yml: a daily cron that pings the Coolify deploy hook so the static site rebuilds and the date gate advances. No file mutation, no repo write access — needs a runner + COOLIFY_DEPLOY_HOOK secret. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/scheduled-deploy.yml | 49 +++++++++++++++++++++++++++ src/pages/blog/[...slug].astro | 5 +-- src/pages/blog/index.astro | 6 ++-- src/pages/index.astro | 5 ++- src/pages/rss.xml.js | 6 ++-- src/pages/tags/[tag].astro | 4 +-- src/utils/posts.ts | 24 +++++++++++++ 7 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 .gitea/workflows/scheduled-deploy.yml create mode 100644 src/utils/posts.ts diff --git a/.gitea/workflows/scheduled-deploy.yml b/.gitea/workflows/scheduled-deploy.yml new file mode 100644 index 0000000..a06cebc --- /dev/null +++ b/.gitea/workflows/scheduled-deploy.yml @@ -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=&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." diff --git a/src/pages/blog/[...slug].astro b/src/pages/blog/[...slug].astro index 11c3619..37ea0f1 100644 --- a/src/pages/blog/[...slug].astro +++ b/src/pages/blog/[...slug].astro @@ -1,10 +1,11 @@ --- -import { getCollection, render } from 'astro:content'; +import { render } from 'astro:content'; import BlogPost from '../../layouts/BlogPost.astro'; import type { GetStaticPaths } from 'astro'; +import { getPublishedPosts } from '../../utils/posts'; export const getStaticPaths = (async () => { - const posts = await getCollection('blog', ({ data }) => !data.draft); + const posts = await getPublishedPosts(); return posts.map((post) => ({ params: { slug: post.id }, props: { post }, diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro index 90bb741..11a2116 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/blog/index.astro @@ -1,11 +1,9 @@ --- import BaseLayout from '../../layouts/BaseLayout.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( - (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf() -); +const posts = await getPublishedPosts(); --- diff --git a/src/pages/index.astro b/src/pages/index.astro index b249893..5a0cfe5 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -3,10 +3,9 @@ import BaseLayout from '../layouts/BaseLayout.astro'; import { Icon } from 'astro-icon/components'; import { getCollection } from 'astro:content'; import { SITE } from '../consts'; +import { getPublishedPosts } from '../utils/posts'; -const posts = (await getCollection('blog', ({ data }) => !data.draft)) - .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()) - .slice(0, 4); +const posts = (await getPublishedPosts()).slice(0, 4); const fmt = (d: Date) => d.toLocaleDateString(SITE.lang, { year: 'numeric', month: 'short' }); diff --git a/src/pages/rss.xml.js b/src/pages/rss.xml.js index f84d00d..5d41551 100644 --- a/src/pages/rss.xml.js +++ b/src/pages/rss.xml.js @@ -1,11 +1,9 @@ import rss from '@astrojs/rss'; -import { getCollection } from 'astro:content'; import { SITE } from '../consts'; +import { getPublishedPosts } from '../utils/posts'; export async function GET(context) { - const posts = (await getCollection('blog', ({ data }) => !data.draft)).sort( - (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf() - ); + const posts = await getPublishedPosts(); return rss({ title: SITE.title, diff --git a/src/pages/tags/[tag].astro b/src/pages/tags/[tag].astro index 0a7dcbf..29d4dc4 100644 --- a/src/pages/tags/[tag].astro +++ b/src/pages/tags/[tag].astro @@ -1,11 +1,11 @@ --- import BaseLayout from '../../layouts/BaseLayout.astro'; import FormattedDate from '../../components/FormattedDate.astro'; -import { getCollection } from 'astro:content'; import type { GetStaticPaths } from 'astro'; +import { getPublishedPosts } from '../../utils/posts'; 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))]; return tags.map((tag) => ({ params: { tag }, diff --git a/src/utils/posts.ts b/src/utils/posts.ts new file mode 100644 index 0000000..a9aa71b --- /dev/null +++ b/src/utils/posts.ts @@ -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[]> { + 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()); +} -- 2.49.1 From 6b91ef1ab4ec090f36b5a7d2d8cb522b889b9296 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 16:35:06 +0200 Subject: [PATCH 2/2] Add six draft posts drawn from Calendula issues Technical, journey, and philosophy posts sourced from the Calendula issue threads, reviewed for source-accuracy, standards, ethics, and voice. All draft:true with staggered future pubDates; they stay hidden in prod until approved (draft:false) and due. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/content/blog/caldav-colors.md | 79 ++++++++++++++++ src/content/blog/recurring-event-edit.md | 75 +++++++++++++++ src/content/blog/the-feature-i-said-no-to.md | 74 +++++++++++++++ .../blog/the-translator-who-emailed-me.md | 76 +++++++++++++++ .../blog/when-it-stopped-being-mine.md | 88 ++++++++++++++++++ src/content/blog/widgets-remoteviews.md | 92 +++++++++++++++++++ 6 files changed, 484 insertions(+) create mode 100644 src/content/blog/caldav-colors.md create mode 100644 src/content/blog/recurring-event-edit.md create mode 100644 src/content/blog/the-feature-i-said-no-to.md create mode 100644 src/content/blog/the-translator-who-emailed-me.md create mode 100644 src/content/blog/when-it-stopped-being-mine.md create mode 100644 src/content/blog/widgets-remoteviews.md diff --git a/src/content/blog/caldav-colors.md b/src/content/blog/caldav-colors.md new file mode 100644 index 0000000..bb3c33b --- /dev/null +++ b/src/content/blog/caldav-colors.md @@ -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 diff --git a/src/content/blog/recurring-event-edit.md b/src/content/blog/recurring-event-edit.md new file mode 100644 index 0000000..55a825e --- /dev/null +++ b/src/content/blog/recurring-event-edit.md @@ -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 diff --git a/src/content/blog/the-feature-i-said-no-to.md b/src/content/blog/the-feature-i-said-no-to.md new file mode 100644 index 0000000..9b3c8e2 --- /dev/null +++ b/src/content/blog/the-feature-i-said-no-to.md @@ -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/ diff --git a/src/content/blog/the-translator-who-emailed-me.md b/src/content/blog/the-translator-who-emailed-me.md new file mode 100644 index 0000000..29f05ca --- /dev/null +++ b/src/content/blog/the-translator-who-emailed-me.md @@ -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).* diff --git a/src/content/blog/when-it-stopped-being-mine.md b/src/content/blog/when-it-stopped-being-mine.md new file mode 100644 index 0000000..5a30f44 --- /dev/null +++ b/src/content/blog/when-it-stopped-being-mine.md @@ -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. diff --git a/src/content/blog/widgets-remoteviews.md b/src/content/blog/widgets-remoteviews.md new file mode 100644 index 0000000..3164bef --- /dev/null +++ b/src/content/blog/widgets-remoteviews.md @@ -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 -- 2.49.1