10 Commits

Author SHA1 Message Date
0c686a2e40 Document how to add and release a blog post
docs/blog-workflow.md: frontmatter/voice conventions, the draft+pubDate gate,
the daily deploy cron, publish checklists (scheduled vs immediate), and gotchas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:44:12 +02:00
6776b1f337 Merge pull request 'Publish first post: Compose for widgets, RemoteViews underneath' (#3) from release-widgets-post into main 2026-07-01 19:39:21 +00:00
5da1615fa8 Publish "Compose for widgets, RemoteViews underneath"
First post released through the scheduling pipeline: draft:false with pubDate
today so the date gate passes. The other five stay draft/future-dated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:39:10 +02:00
6d8df36557 Merge pull request 'Target shared docker runners in scheduled-deploy' (#2) from fix-runner-label into main
Reviewed-on: #2
2026-07-01 19:32:38 +00:00
6a577c14ce Target shared docker runners in scheduled-deploy
The instance runners advertise `dind`/`docker` labels, not `ubuntu-latest`, so
the job sat queued unmatched. Point runs-on at `docker`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:31:11 +02:00
8d1126b5dc Merge pull request 'Add date-based post scheduling + daily deploy cron, and six draft posts' (#1) from blog-release-pipeline into main 2026-07-01 14:42:28 +00:00
6b91ef1ab4 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) <noreply@anthropic.com>
2026-07-01 16:35:06 +02:00
cecbda26b0 Add date-based post scheduling and daily deploy cron
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) <noreply@anthropic.com>
2026-07-01 16:35:06 +02:00
d1acda5a93 Add Datenschutzerklärung, two blog posts, and a /uses colophon
- datenschutz.astro: DSGVO privacy policy (server logfiles, Hetzner as
  Auftragsverarbeiter per Art. 28, cookieless Umami, self-hosted fonts,
  data-subject rights, supervisory-authority complaint).
- consts.ts: extract shared LEGAL entity data; Impressum now reads from it.
- blog: "Why my calendar app has no internet permission" and
  "Open standards as a constraint, not a checkbox".
- uses.astro: colophon of the site stack, Floret apps, and self-hosted infra.
- Footer: add Uses + Datenschutz links alongside Impressum.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:54:49 +02:00
108e790621 Add Impressum page (§ 5 DDG) and footer link
Legally required Impressum for the Einzelunternehmen (Kleinunternehmer,
no VAT ID, not in Handelsregister). Covers § 5 DDG contact data,
§ 18 Abs. 2 MStV responsible person, § 36 VSBG, and liability/copyright
disclaimers. Linked from the footer on every page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:20:43 +02:00
21 changed files with 1127 additions and 16 deletions

View File

@@ -0,0 +1,48 @@
# 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):
# - Uses the shared `docker`-labelled runners. If those labels change, update
# `runs-on` below to match a runner that is online.
# - 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: docker
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."

117
docs/blog-workflow.md Normal file
View File

@@ -0,0 +1,117 @@
# Writing and releasing a blog post
How posts live, how they're scheduled, and how they go live. The short version:
**a post is public when it is `draft: false` *and* its `pubDate` has arrived**
and a daily job rebuilds the site so "arrived" keeps advancing on its own.
## 1. Add the post
Create a Markdown file in `src/content/blog/`, e.g.
`src/content/blog/my-post.md`. The filename (minus `.md`) is the URL slug:
`/blog/my-post/`.
Frontmatter is validated at build time against `src/content.config.ts`:
```markdown
---
title: Short, specific title
description: >-
One or two sentences. Shown in listings, the RSS feed, and social previews.
pubDate: 2026-07-08 # ISO date. The publish gate (see below).
# updatedDate: 2026-07-10 # optional; shown as "updated"
tags: [android, calendula] # see the tag vocabulary below
draft: true # true = never public; flip to false when ready
---
Body in Markdown. ~80-char wrapped to match the other posts.
```
Voice conventions (match the existing posts): first-person, "constraint as
design", link to standards/APIs inline, one punchy closing line. Reference other
posts with root-relative links like `/blog/open-standards`.
Established **tag vocabulary** — reuse these rather than inventing one-offs:
`android`, `architecture`, `calendula`, `caldav`, `open-standards`,
`open-source`, `accessibility`, `localization`, `meta`.
## 2. The three states
The gate lives in `src/utils/posts.ts` (`getPublishedPosts()`), which every
listing, the tag pages, the post routes, and `rss.xml` use.
| `draft` | `pubDate` | Dev server | Production |
| --- | --- | --- | --- |
| `true` | any | visible (preview) | **hidden** |
| `false` | future | visible (preview) | **hidden until the date** |
| `false` | today / past | visible | **live** |
So:
- **`draft: true`** — work in progress or not yet approved. Never public.
- **`draft: false` + future `pubDate`** — approved and *scheduled*. Publishes
itself on that day (see §3).
- **`draft: false` + past `pubDate`** — live now.
Preview anything (including drafts and scheduled posts) locally with
`npm run dev` — the dev server lifts the gate entirely.
## 3. How release actually happens
The site is static, so "now" is frozen at each build. Two things trigger a
rebuild:
1. **Push to `main`** → Coolify redeploys (the normal deploy).
2. **The daily cron**`.gitea/workflows/scheduled-deploy.yml` runs at
**06:15 Europe/Berlin**, pings the Coolify deploy hook, and the rebuild
re-evaluates the date gate. This is what makes a future-dated post appear on
its day without anyone touching the repo.
The cron runs on the shared **`docker`** runner and needs two repo secrets set
once (Gitea → Settings → Actions → Secrets): `COOLIFY_DEPLOY_HOOK` (the app's
deploy URL) and `COOLIFY_TOKEN` (a Coolify API token, Bearer).
## 4. Publish checklist
To schedule a post for its date (the normal path):
1. Set `draft: false`, leave the intended future `pubDate`.
2. Commit and get it onto `main` (branch → PR → merge; PRs via `tea pr create`
/ `tea pr merge`). Merging deploys, but the post stays hidden until its date.
3. Done — the daily cron publishes it on `pubDate`.
To release a post **immediately**:
1. Set `draft: false` **and** `pubDate` to today (or a past date), so the gate
passes now.
2. Merge to `main`.
3. Trigger a rebuild right away instead of waiting for the cron:
```sh
tea actions workflows dispatch scheduled-deploy.yml --ref main
```
(`tea` may print `unexpected end of JSON input` — that's it mis-reading
Gitea's empty success response; the run still starts. Check it with
`tea actions runs list`.)
4. Coolify rebuilds; confirm at `https://jeanlucmakiola.de/blog/<slug>/`.
## 5. Handy commands
```sh
npm run dev # preview everything locally
npm run build # production build (applies the gate)
tea actions workflows list # see the deploy workflow
tea actions workflows dispatch scheduled-deploy.yml --ref main # manual deploy
tea actions runs list # watch run status/conclusion
```
## 6. Gotchas
- **Don't** put non-post `.md` files under `src/content/blog/` — the collection
glob will try to parse them as posts and fail the build. Docs like this one
live in `docs/`.
- `pubDate` with no time resolves to `00:00 UTC` that day, so a post becomes
eligible at UTC midnight; the morning cron then publishes it. Adjust the cron
time in the workflow if you want a different local hour.
- Timezone for the cron is pinned in the workflow (`TZ=Europe/Berlin`); the
gate comparison itself uses the build machine's clock (UTC in CI).

View File

@@ -6,7 +6,10 @@ const year = new Date().getFullYear();
<footer class="site-footer">
<div class="site-footer__inner">
<p>© {year} {SITE.author}</p>
<p>
© {year} {SITE.author} · <a href="/uses">Uses</a> ·
<a href="/impressum">Impressum</a> · <a href="/datenschutz">Datenschutz</a>
</p>
{
SOCIALS.length > 0 && (
<nav aria-label="Social" class="socials">

View File

@@ -8,6 +8,18 @@ export const SITE = {
locale: 'en_US',
} as const;
// Legal entity details — single source of truth for the Impressum and
// Datenschutzerklärung. Registered Einzelunternehmen (Kleinunternehmer per
// § 19 UStG; no VAT ID, not in the Handelsregister). `phone` renders only if set.
export const LEGAL = {
business: 'IT-Dienstleister | Jean-Luc Makiola', // full Gewerbe name (§ 5 DDG)
person: SITE.author, // natural person responsible — § 18 Abs. 2 MStV
street: 'Mahlerstraße 10',
city: '14772 Brandenburg an der Havel',
email: 'mail@jeanlucmakiola.de',
phone: '',
} as const;
// Social / external links shown in the footer. `icon` is an Iconify name.
export const SOCIALS: { label: string; href: string; icon: string }[] = [
{ label: 'Gitea', href: 'https://gitea.jeanlucmakiola.de/makiolaj', icon: 'simple-icons:gitea' },

View 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

View File

@@ -0,0 +1,67 @@
---
title: Why my calendar app has no internet permission
description: >-
Calendula can't talk to the network — and that's the whole design. A look at
building on Android's CalendarContract instead of reinventing a sync stack.
pubDate: 2026-06-28
tags: [android, architecture, calendula]
draft: false
---
Open Calendula's manifest and you'll notice something missing: there is no
`android.permission.INTERNET`. The app physically *cannot* reach the network.
For a calendar — a category of app practically synonymous with cloud accounts —
that sounds like a missing feature. It's the opposite. It's the design.
## The usual shape of a calendar app
Most calendar apps own their data. They sign you into an account, pull events
down over the provider's API, cache them in a private database, and reconcile
changes with their own sync engine. That sync stack is the hard part: conflict
resolution, recurring-event expansion, time zones, retries, token refresh. It's
also the part that locks you in — your events live in *their* schema, reachable
only through *their* app.
## The other option Android already gives you
Android ships a system calendar database, exposed through
[`CalendarContract`](https://developer.android.com/reference/android/provider/CalendarContract).
Anything synced to your device lands there: a CalDAV account via
[DAVx5](https://www.davx5.com/), your Google calendar, a local on-device
calendar, a read-only WebCal subscription. They all show up through the same
content provider, with the same columns.
Calendula is a pure front-end over that provider. It reads events through
`CalendarContract`, and when you create or edit something, it writes straight
back. Whatever sync adapter put the calendar on your device picks the change up
and pushes it out. There is **no own database and no reinvented sync stack**
so there is nothing for the app to phone home about.
## What you get for free
Dropping the network permission isn't a sacrifice; it's what falls out of the
architecture:
- **Your data stays yours, and stays portable.** Events live in the platform's
store and in your CalDAV account — not in a schema only Calendula understands.
- **Privacy is structural, not a promise.** Zero telemetry and zero analytics
are easy to claim. *No internet permission* is enforced by the OS: even if I
wanted to exfiltrate your schedule, the app couldn't.
- **Reminders still work** — Calendula delivers them itself as notifications,
because Android delegates reminder delivery to the installed calendar app.
- **Any account "just appears."** Add a new CalDAV account in DAVx5 and it
surfaces in Calendula with no integration work, because the integration point
is the OS, not a vendor API.
## The trade-off, stated honestly
A front-end can only be as good as the provider beneath it. Calendula doesn't
add its own server-side features, and it relies on a sync adapter like DAVx5
being installed to actually move bytes. That's a deliberate line: I'd rather put
a thoughtful Material 3 Expressive interface on an open protocol than own a sync
stack I'd inevitably get subtly wrong.
The same idea drives the rest of the [Floret family](/work) — Agendula is the
exact same bet, made on the OpenTasks provider instead of the calendar one.
Different content, identical philosophy: build the part that's worth building,
and let open standards carry the rest.

View File

@@ -0,0 +1,56 @@
---
title: Open standards as a constraint, not a checkbox
description: >-
CalDAV, iCalendar, OpenTasks, DecSync — why I treat open standards as a hard
boundary for what the Floret apps are allowed to do.
pubDate: 2026-06-27
tags: [open-standards, caldav, android]
draft: false
---
"Supports open standards" usually means a feature in a list — an export button,
an import dialog, an optional CalDAV setting buried three screens deep. For the
[Floret apps](/work) it's the other way round: open standards are the boundary,
and everything that would step outside them is simply out of scope.
## The lane
The standards are deliberately boring and well-proven:
- **[CalDAV](https://datatracker.ietf.org/doc/html/rfc4791)** and
**[iCalendar](https://datatracker.ietf.org/doc/html/rfc5545)** for calendar
events — the protocol and the data format the rest of the ecosystem already
speaks.
- **[OpenTasks](https://github.com/dmfs/opentasks)' `TaskContract` provider**
for tasks, which stores CalDAV VTODOs on the device.
- **[DecSync](https://github.com/39aldo39/DecSync)** for peer-to-peer sync
without a server in the middle.
Calendula reads and writes events through Android's `CalendarContract`; Agendula
does the same over the OpenTasks provider. Neither app owns a database. The sync
adapter you already trust — DAVx5, SmoothSync, DecSync — moves the bytes.
## Why make it a hard boundary
Treating the standard as a constraint changes which decisions are even on the
table. A proprietary task service with a slick API is permanently off the
roadmap — not because it's bad, but because integrating it would mean owning a
sync stack and tying your data to one vendor's schema. The moment an app starts
reconciling its own copy of your data against someone's cloud, the simplicity
that made it trustworthy is gone.
The constraint also keeps the apps **reproducible**. There are no secret API
keys to embed, no SDKs that pull in closed dependencies, nothing that would stop
an app from building clean for [F-Droid](https://f-droid.org/). What goes in is
exactly what you can read in the source.
## The cost, and why it's worth paying
Living inside the standard means some things are genuinely harder. Provider APIs
have rough edges; recurrence rules and time zones in iCalendar are a deep well;
and you inherit whatever the underlying sync adapter does or doesn't support.
You don't get to paper over those gaps with a server you control.
That's the right trade. An app built on an open standard is one you can leave
without losing anything — your events and tasks were never hostage to it in the
first place. The interface is mine to get right; the data was always yours.

View 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

View 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/

View 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).*

View 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.

View 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-01
tags: [android, architecture, calendula]
draft: false
---
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

View File

@@ -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 },

View File

@@ -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();
---
<BaseLayout title="Blog" description="Writing and notes by Jean-Luc Makiola." width="narrow">

138
src/pages/datenschutz.astro Normal file
View File

@@ -0,0 +1,138 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { SITE, LEGAL } from '../consts';
// Contact data is shared with the Impressum via the LEGAL object in consts.ts.
// Hosting provider (Auftragsverarbeiter): Hetzner Online GmbH — an AVV per
// Art. 28 DSGVO must be concluded (Hetzner provides one in the customer panel).
---
<BaseLayout
title="Datenschutzerklärung"
description={`Datenschutzerklärung nach DSGVO für ${SITE.url}.`}
width="narrow"
>
<article class="prose">
<h1 class="page-title">Datenschutzerklärung</h1>
<h2>1. Verantwortlicher</h2>
<p>
Verantwortlicher im Sinne der Datenschutz-Grundverordnung (DSGVO) ist:
</p>
<p>
{LEGAL.business}<br />
{LEGAL.street}<br />
{LEGAL.city}<br />
E-Mail: <a href={`mailto:${LEGAL.email}`}>{LEGAL.email}</a>
</p>
<h2>2. Grundsätzliches</h2>
<p>
Der Schutz Ihrer personenbezogenen Daten ist mir wichtig. Diese Website ist
bewusst datensparsam aufgebaut: Es werden keine Cookies gesetzt, keine
Inhalte von Drittanbietern (z.&nbsp;B. Google Fonts, CDNs oder Werbenetze)
nachgeladen und keine geräteübergreifenden Profile gebildet. Schriftarten
werden vom eigenen Server ausgeliefert, sodass beim Seitenaufruf keine
Verbindung zu Dritten entsteht.
</p>
<h2>3. Hosting und Server-Logfiles</h2>
<p>
Diese Website wird auf selbst verwalteter Infrastruktur betrieben, die bei
der <strong>Hetzner Online GmbH</strong>, Industriestr.&nbsp;25,
91710&nbsp;Gunzenhausen, Deutschland, angemietet ist. Hetzner verarbeitet
die nachfolgend genannten Daten als Auftragsverarbeiter auf Grundlage eines
Vertrags zur Auftragsverarbeitung gemäß Art.&nbsp;28 DSGVO. Die Server
stehen in Rechenzentren innerhalb der Europäischen Union (Deutschland bzw.
Finnland); eine Datenübermittlung in ein Drittland findet nicht statt.
</p>
<p>
Beim Aufruf der Seiten erhebt und speichert der Server automatisch
Informationen in sogenannten Server-Logfiles, die Ihr Browser übermittelt:
</p>
<ul>
<li>IP-Adresse des anfragenden Geräts,</li>
<li>Datum und Uhrzeit des Zugriffs,</li>
<li>Name und URL der abgerufenen Datei,</li>
<li>übertragene Datenmenge sowie Meldung über erfolgreichen Abruf,</li>
<li>verwendeter Browsertyp und dessen Version,</li>
<li>Betriebssystem sowie die zuvor besuchte Seite (Referrer), sofern übermittelt.</li>
</ul>
<p>
Diese Verarbeitung erfolgt zum Zweck der technischen Bereitstellung,
Stabilität und Sicherheit der Website. Rechtsgrundlage ist das berechtigte
Interesse an einem fehlerfreien und sicheren Betrieb gemäß
Art.&nbsp;6&nbsp;Abs.&nbsp;1&nbsp;lit.&nbsp;f DSGVO. Die Logfiles werden
nicht mit anderen Datenquellen zusammengeführt und nach kurzer Zeit
gelöscht, soweit sie nicht zur Aufklärung eines konkreten
Missbrauchsfalls benötigt werden.
</p>
<h2>4. Webanalyse mit Umami</h2>
<p>
Zur Reichweitenmessung nutze ich <strong>Umami</strong>, eine
datenschutzfreundliche Analyse-Software, die ich selbst hoste. Umami
arbeitet <strong>ohne Cookies</strong> und ohne über mehrere Websites
hinweg wiedererkennbare Kennungen. Erhoben werden ausschließlich
aggregierte, anonymisierte Nutzungsdaten etwa aufgerufene Seiten,
ungefähre Herkunft (Land), Gerätetyp und Verweisquelle. Es werden keine
personenbezogenen Profile gebildet und keine Daten an Dritte weitergegeben.
</p>
<p>
Rechtsgrundlage ist das berechtigte Interesse an einer datensparsamen
Auswertung der Websitenutzung gemäß
Art.&nbsp;6&nbsp;Abs.&nbsp;1&nbsp;lit.&nbsp;f DSGVO. Da keine Cookies oder
vergleichbaren Technologien zum Einsatz kommen und keine
personenbeziehbaren Daten gespeichert werden, ist hierfür keine
Einwilligung erforderlich.
</p>
<h2>5. Kontaktaufnahme</h2>
<p>
Wenn Sie mich per E-Mail kontaktieren, werden die von Ihnen mitgeteilten
Daten (Ihre E-Mail-Adresse sowie der Inhalt Ihrer Nachricht) zur
Bearbeitung Ihrer Anfrage verarbeitet und gespeichert. Rechtsgrundlage ist
Art.&nbsp;6&nbsp;Abs.&nbsp;1&nbsp;lit.&nbsp;f DSGVO (Beantwortung Ihrer
Anfrage) bzw. Art.&nbsp;6&nbsp;Abs.&nbsp;1&nbsp;lit.&nbsp;b DSGVO, sofern
die Anfrage auf den Abschluss eines Vertrags gerichtet ist. Die Daten
werden gelöscht, sobald sie für die Bearbeitung nicht mehr erforderlich
sind und keine gesetzlichen Aufbewahrungspflichten entgegenstehen.
</p>
<h2>6. SSL-/TLS-Verschlüsselung</h2>
<p>
Diese Seite nutzt aus Sicherheitsgründen eine SSL-/TLS-Verschlüsselung. Eine
verschlüsselte Verbindung erkennen Sie am Schloss-Symbol in der
Adresszeile Ihres Browsers und am Präfix „https://“.
</p>
<h2>7. Ihre Rechte</h2>
<p>Ihnen stehen nach der DSGVO gegenüber dem Verantwortlichen folgende Rechte zu:</p>
<ul>
<li>Auskunft über die zu Ihrer Person gespeicherten Daten (Art.&nbsp;15 DSGVO),</li>
<li>Berichtigung unrichtiger Daten (Art.&nbsp;16 DSGVO),</li>
<li>Löschung Ihrer Daten (Art.&nbsp;17 DSGVO),</li>
<li>Einschränkung der Verarbeitung (Art.&nbsp;18 DSGVO),</li>
<li>Datenübertragbarkeit (Art.&nbsp;20 DSGVO),</li>
<li>
Widerspruch gegen die Verarbeitung, soweit diese auf
Art.&nbsp;6&nbsp;Abs.&nbsp;1&nbsp;lit.&nbsp;f DSGVO beruht
(Art.&nbsp;21 DSGVO).
</li>
</ul>
<p>
Zur Ausübung Ihrer Rechte genügt eine formlose Nachricht an die oben
genannte E-Mail-Adresse.
</p>
<h2>8. Beschwerderecht bei der Aufsichtsbehörde</h2>
<p>
Unbeschadet eines anderweitigen verwaltungsrechtlichen oder gerichtlichen
Rechtsbehelfs steht Ihnen das Recht auf Beschwerde bei einer
Datenschutz-Aufsichtsbehörde zu, insbesondere in dem Mitgliedstaat Ihres
Aufenthaltsorts, Ihres Arbeitsplatzes oder des Orts des mutmaßlichen
Verstoßes, wenn Sie der Ansicht sind, dass die Verarbeitung Ihrer Daten
gegen die DSGVO verstößt (Art.&nbsp;77 DSGVO).
</p>
</article>
</BaseLayout>

91
src/pages/impressum.astro Normal file
View File

@@ -0,0 +1,91 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { SITE, LEGAL as IMPRESSUM } from '../consts';
---
<BaseLayout
title="Impressum"
description={`Impressum und rechtliche Angaben für ${SITE.url}.`}
width="narrow"
>
<article class="prose">
<h1 class="page-title">Impressum</h1>
<h2>Angaben gemäß § 5 DDG</h2>
<p>
{IMPRESSUM.business}<br />
{IMPRESSUM.street}<br />
{IMPRESSUM.city}
</p>
<h2>Kontakt</h2>
<p>
{IMPRESSUM.phone && (<>Telefon: {IMPRESSUM.phone}<br /></>)}
E-Mail: <a href={`mailto:${IMPRESSUM.email}`}>{IMPRESSUM.email}</a>
</p>
<h2>Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV</h2>
<p>
{IMPRESSUM.person}<br />
{IMPRESSUM.street}<br />
{IMPRESSUM.city}
</p>
<h2>Verbraucherstreitbeilegung / Universalschlichtungsstelle</h2>
<p>
Ich bin nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren
vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG).
</p>
<h2>Haftung für Inhalte</h2>
<p>
Als Diensteanbieter bin ich gemäß § 7 Abs. 1 DDG für eigene Inhalte auf
diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis
10 DDG bin ich als Diensteanbieter jedoch nicht verpflichtet, übermittelte
oder gespeicherte fremde Informationen zu überwachen oder nach Umständen
zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.
</p>
<p>
Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen
nach den allgemeinen Gesetzen bleiben hiervon unberührt. Eine
diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer
konkreten Rechtsverletzung möglich. Bei Bekanntwerden von entsprechenden
Rechtsverletzungen werde ich diese Inhalte umgehend entfernen.
</p>
<h2>Haftung für Links</h2>
<p>
Mein Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte
ich keinen Einfluss habe. Deshalb kann ich für diese fremden Inhalte auch
keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets
der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die
verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche
Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der
Verlinkung nicht erkennbar.
</p>
<p>
Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch
ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei
Bekanntwerden von Rechtsverletzungen werde ich derartige Links umgehend
entfernen.
</p>
<h2>Urheberrecht</h2>
<p>
Die durch den Seitenbetreiber erstellten Inhalte und Werke auf diesen
Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung,
Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen
des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen
Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind nur für den
privaten, nicht kommerziellen Gebrauch gestattet.
</p>
<p>
Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden,
werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte
Dritter als solche gekennzeichnet. Sollten Sie trotzdem auf eine
Urheberrechtsverletzung aufmerksam werden, bitte ich um einen
entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werde ich
derartige Inhalte umgehend entfernen.
</p>
</article>
</BaseLayout>

View File

@@ -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' });

View File

@@ -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,

View File

@@ -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 },

75
src/pages/uses.astro Normal file
View File

@@ -0,0 +1,75 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
// A /uses colophon — the stack behind this site and the Floret apps.
// Add a "Desk / hardware" section here if you'd like to list your machine,
// editor, and peripherals — left out for now to avoid guessing.
---
<BaseLayout
title="Uses"
description="The stack behind this site, the Floret apps, and the infrastructure they run on."
width="narrow"
>
<article class="prose">
<h1 class="page-title">Uses</h1>
<p>
A running colophon of the tools and stack behind this site, the
<a href="/work">Floret apps</a>, and the infrastructure they run on. The
throughline: open, self-hostable standards, and as little reliance on
third-party services as I can manage.
</p>
<h2>This website</h2>
<ul>
<li>
Built with <a href="https://astro.build" rel="noopener">Astro</a> — a
static site, no client-side framework, content authored as Markdown in
the repo.
</li>
<li>
Typefaces are <a href="https://rsms.me/inter/" rel="noopener">Inter</a>
and <a href="https://www.jetbrains.com/lp/mono/" rel="noopener">JetBrains
Mono</a>, self-hosted via Fontsource — no Google Fonts CDN, so no
third-party request on page load.
</li>
<li>
Analytics is self-hosted <a href="https://umami.is" rel="noopener">Umami</a>:
cookieless, no cross-site tracking, no personal profiles. See the
<a href="/datenschutz">Datenschutzerklärung</a> for what that means.
</li>
<li>Icons from <a href="https://iconify.design" rel="noopener">Iconify</a> (Material Design Icons + Simple Icons).</li>
</ul>
<h2>The Floret apps</h2>
<ul>
<li><strong>Kotlin</strong> and <strong>Jetpack Compose</strong>, designed in <strong>Material 3 Expressive</strong>.</li>
<li>
No reinvented sync stack — each app is a front-end over a platform
provider (<code>CalendarContract</code>, the OpenTasks
<code>TaskContract</code>) and open standards like CalDAV, iCalendar, and
DecSync.
</li>
<li>
A shared design system, <a href="/work/floret-kit">floret-kit</a>, wired
in as a git submodule via a Gradle composite build — no published
artifacts, so every app stays reproducible.
</li>
<li>Released on <a href="https://f-droid.org" rel="noopener">F-Droid</a>, MIT-licensed, zero telemetry.</li>
</ul>
<h2>Self-hosted infrastructure</h2>
<p>Code, translations, builds, and this site all run on infrastructure I host myself.</p>
<ul>
<li><a href="https://gitea.jeanlucmakiola.de/makiolaj" rel="noopener">Gitea</a> for source hosting and as the home for smaller experiments.</li>
<li><a href="https://weblate.org" rel="noopener">Weblate</a> for community translations of the apps.</li>
<li><a href="https://coolify.io" rel="noopener">Coolify</a> to build and deploy this site.</li>
<li>Umami for the privacy-respecting analytics above.</li>
</ul>
<p class="muted">
This list grows as the stack does. Spotted something you'd ask about?
<a href="mailto:mail@jeanlucmakiola.de">Get in touch</a>.
</p>
</article>
</BaseLayout>

24
src/utils/posts.ts Normal file
View 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());
}