Compare commits
10 Commits
ec8a8f38a0
...
docs-blog-
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c686a2e40 | |||
| 6776b1f337 | |||
| 5da1615fa8 | |||
| 6d8df36557 | |||
| 6a577c14ce | |||
| 8d1126b5dc | |||
| 6b91ef1ab4 | |||
| cecbda26b0 | |||
| d1acda5a93 | |||
| 108e790621 |
48
.gitea/workflows/scheduled-deploy.yml
Normal file
48
.gitea/workflows/scheduled-deploy.yml
Normal 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
117
docs/blog-workflow.md
Normal 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).
|
||||
@@ -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">
|
||||
|
||||
@@ -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' },
|
||||
|
||||
79
src/content/blog/caldav-colors.md
Normal file
79
src/content/blog/caldav-colors.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: Why your CalDAV events show sixty shades of the same blue
|
||||
description: >-
|
||||
Two open colour reports on Calendula come from one root — an overstuffed picker
|
||||
on CalDAV calendars and hard-to-read dark event titles — and both trace back to
|
||||
how event colour flows through Android's calendar provider.
|
||||
pubDate: 2026-07-08
|
||||
tags: [android, calendula, caldav, accessibility]
|
||||
draft: true
|
||||
---
|
||||
|
||||
Two colour reports landed on Calendula in the same week, and both are still open.
|
||||
One is a bug: the colour picker on a CalDAV calendar shows a full screen of
|
||||
colours, many of them near-duplicates ([#22]). The other is a request: make
|
||||
event titles readable on dark backgrounds so busy weeks don't turn into a smear
|
||||
([#21]). They look unrelated. They come from the same place — how event colour
|
||||
actually flows through Android's calendar provider — which is why I'm writing up
|
||||
the diagnosis before I write the fix.
|
||||
|
||||
## Palette vs. free-for-all
|
||||
|
||||
Android's
|
||||
[`CalendarContract`](https://developer.android.com/reference/android/provider/CalendarContract)
|
||||
has two ways to colour an event, and which one you get depends on the account.
|
||||
|
||||
Google and local calendars expose a **palette**: a small, indexed set of colours
|
||||
through
|
||||
[`CalendarContract.Colors`](https://developer.android.com/reference/android/provider/CalendarContract.Colors).
|
||||
An event stores a `color_key`, the provider maps it to a swatch, and a picker can
|
||||
show exactly those choices — a tidy dozen.
|
||||
|
||||
CalDAV is different. The iCalendar
|
||||
[`COLOR`](https://datatracker.ietf.org/doc/html/rfc7986#section-5.9) property is
|
||||
defined as a CSS3 colour *name* — a set of about 150, already far bigger than a
|
||||
palette — and in practice clients push arbitrary hex through vendor extensions
|
||||
like `X-APPLE-CALENDAR-COLOR`. Either way, by the time it reaches the provider
|
||||
there's no `color_key` and no shared index: the event just carries a raw
|
||||
`EVENT_COLOR`. So a picker that tries to *build* a palette from what it finds
|
||||
ends up gathering every distinct value that ever appeared, including a dozen
|
||||
blues that differ by a rounding error you can't see. That's the "too many
|
||||
colours" bug: it isn't showing junk, it's faithfully showing an *unbounded* space
|
||||
as though it were a fixed menu. The fix is to stop pretending CalDAV has a
|
||||
palette — offer a sane curated set and map to the nearest real colour, rather
|
||||
than enumerating every ghost.
|
||||
|
||||
## The contrast problem is the same problem
|
||||
|
||||
Once an event can be *any* colour, you can no longer assume the text on top is
|
||||
readable. A pale event with dark text is fine; a deep navy block with the same
|
||||
dark text is a smear. Right now Calendula draws event titles in a fixed near-
|
||||
black — which is exactly the assumption that breaks. Google Calendar flips to
|
||||
white text on dark fills, and [#21] asks for the same.
|
||||
|
||||
The rule for "is this colour dark" isn't the average of its channels — the eye
|
||||
isn't equally sensitive to red, green and blue. The
|
||||
[WCAG relative luminance](https://www.w3.org/TR/WCAG21/#dfn-relative-luminance)
|
||||
formula weights them the way perception does (green counts far more than blue),
|
||||
linearises each channel, and yields a single brightness figure. Pick a threshold,
|
||||
and below it the title renders white (or off-white), above it near-black. It's a
|
||||
few lines of maths applied to the same event colour the picker just handed you —
|
||||
which is the point. The overstuffed picker and the unreadable title are two ends
|
||||
of one pipe: *the moment colour stops being a fixed palette and becomes
|
||||
arbitrary, both the input and the output need taming.* One end is a bug to close;
|
||||
the other is a threshold to add. Same pipe.
|
||||
|
||||
## Standard first, taste second
|
||||
|
||||
The tidy path would be to store my own colour for every event and never touch the
|
||||
provider's mess. I don't — for the same reason Calendula owns no other part of
|
||||
your data. The `COLOR` on a CalDAV event belongs to the event, and the sync
|
||||
adapter carries it out to every other client that reads your calendar; if I
|
||||
overwrote it with a private value, that portability would be gone. My job isn't
|
||||
to replace the colour. It's to present a bounded, readable *view* of it — a
|
||||
curated picker on the way in, a luminance-aware title on the way out — while the
|
||||
value itself stays yours and portable. Two open issues, one fix: let you read
|
||||
your own calendar without taking it over.
|
||||
|
||||
[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
|
||||
[#22]: https://codeberg.org/jlmakiola/calendula/issues/22
|
||||
67
src/content/blog/no-internet-permission.md
Normal file
67
src/content/blog/no-internet-permission.md
Normal 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.
|
||||
56
src/content/blog/open-standards.md
Normal file
56
src/content/blog/open-standards.md
Normal 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.
|
||||
75
src/content/blog/recurring-event-edit.md
Normal file
75
src/content/blog/recurring-event-edit.md
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: "\"This event only\": the edit that quietly did nothing"
|
||||
description: >-
|
||||
Editing a single instance of a recurring event is one of the sharpest edges in
|
||||
the calendar model. A bug in Calendula, and why recurrence makes "just save
|
||||
it" surprisingly hard.
|
||||
pubDate: 2026-07-05
|
||||
tags: [android, calendula, caldav]
|
||||
draft: true
|
||||
---
|
||||
|
||||
A recurring event isn't a list of events. It's *one* row plus a rule. "Every
|
||||
Tuesday at 10:00" is a single event with an
|
||||
[`RRULE`](https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.5.3), and
|
||||
the individual Tuesdays don't exist as stored rows — they're *expanded* from the
|
||||
rule on demand. That single fact is why editing a recurring event is one of the
|
||||
sharpest edges in the whole calendar model, and why Calendula had a bug ([#16])
|
||||
where choosing "this event only" and saving bounced you back to the edit screen
|
||||
without changing anything.
|
||||
|
||||
## Three answers to one save
|
||||
|
||||
Tap save on a recurring event and every calendar app asks the same question:
|
||||
**this event, this and following, or all events?** They're three genuinely
|
||||
different operations against the data model:
|
||||
|
||||
- **All events** edits the master row and its rule. Easy — it's the thing that
|
||||
actually exists.
|
||||
- **This and following** splits the series: the old rule gets an end, a new
|
||||
event picks up from the edit point with the changes.
|
||||
- **This event only** is the awkward one. You're editing an instance that has no
|
||||
row of its own. To change it, you have to *create* one.
|
||||
|
||||
## What "this event only" actually does
|
||||
|
||||
Under the hood, "this event only" doesn't edit anything — it manufactures an
|
||||
**exception**. The iCalendar model calls it a
|
||||
[`RECURRENCE-ID`](https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.4.4)
|
||||
override: a new component that says "for the occurrence that *would* have landed
|
||||
at this original time, use these values instead." Android's
|
||||
[`CalendarContract`](https://developer.android.com/reference/android/provider/CalendarContract)
|
||||
exposes this through the exceptions URI and an `ORIGINAL_INSTANCE_TIME` — you
|
||||
insert a one-off event pinned to the timestamp of the occurrence you're
|
||||
replacing, and the provider excludes the original instance from the expansion in
|
||||
its place.
|
||||
|
||||
So the operation is: identify the exact original instance time, insert an
|
||||
exception bound to it, and let the provider stop expanding the rule at that slot.
|
||||
Get the original-time bookkeeping wrong and the provider has nothing valid to
|
||||
attach the exception to — the write doesn't land, and the UI, having nothing to
|
||||
show for the save, falls back to the edit screen. Which is exactly the shape of
|
||||
[#16]: the popup appeared correctly, the other two options worked, and "this
|
||||
event only" silently did nothing. It wasn't the save logic — it was the one
|
||||
branch that has to fabricate a row instead of updating one.
|
||||
|
||||
## Why I let the provider handle it
|
||||
|
||||
It would be tempting to sidestep all of this by keeping my own event table and
|
||||
doing the expansion myself. I deliberately don't. Calendula reads and writes
|
||||
through the system calendar provider, and recurrence exceptions are precisely
|
||||
the kind of thing the provider — and the CalDAV sync adapter behind it — already
|
||||
knows how to round-trip. An exception I write lands as a proper `RECURRENCE-ID`
|
||||
override: DAVx5 pushes it to your server, and every other client that reads the
|
||||
calendar sees the same override. If I expanded rules into my own private rows,
|
||||
I'd own every one of these edge cases forever, and my "edits" would be invisible
|
||||
to everything else that reads your calendar.
|
||||
|
||||
The hard part stays hard either way. Recurrence has been accreting rules and
|
||||
corrections in [RFC 5545] for two decades, and there's no shortcut through it.
|
||||
The trade is that by living inside the standard, the difficulty is *shared*: when
|
||||
I get the original-instance bookkeeping right, the exception is correct
|
||||
everywhere, not just in my app.
|
||||
|
||||
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
||||
[RFC 5545]: https://datatracker.ietf.org/doc/html/rfc5545
|
||||
74
src/content/blog/the-feature-i-said-no-to.md
Normal file
74
src/content/blog/the-feature-i-said-no-to.md
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: The feature I said no to (and the one I found instead)
|
||||
description: >-
|
||||
A user asked Calendula to sync itself to a plain .ics file. Talking it through
|
||||
in the open turned a "no" into a better, smaller feature — and clarified what
|
||||
the app is actually for.
|
||||
pubDate: 2026-07-10
|
||||
tags: [open-source, calendula, open-standards]
|
||||
draft: true
|
||||
---
|
||||
|
||||
One of the more useful things that has happened to Calendula wasn't a feature I
|
||||
added. It was one I turned down — in public, in a comment thread, with the
|
||||
person who asked for it ([#7]).
|
||||
|
||||
The request: let Calendula save its calendar state to a file. Prompt for an
|
||||
`.ics` on startup, write back on every change, so events are never "trapped" in
|
||||
the app. Reasonable on its face. My answer was no — but the interesting part is
|
||||
how we got from there to something I *did* ship.
|
||||
|
||||
## Why it was a no
|
||||
|
||||
Calendula doesn't own your data. It's a client for Android's system calendar
|
||||
provider, and the syncing is left to whatever CalDAV adapter you've already set
|
||||
up — DAVx5, SmoothSync, whatever moves your events today. A "write to an .ics on
|
||||
every change" feature quietly reintroduces the exact thing the app exists to
|
||||
avoid: it starts handling sync itself.
|
||||
|
||||
And live file sync is genuinely hard, for a reason that has nothing to do with
|
||||
effort. The moment two sides can edit the same event — a local change and a
|
||||
synced-in change landing on the same slot — *something* has to decide which wins.
|
||||
CalDAV has machinery for that. The `.ics` format doesn't; it was designed for
|
||||
backups and for handing events from one place to another, not for being written
|
||||
to continuously. Build the feature and you inherit a merge engine you now have to
|
||||
get right forever. As I put it in the thread: as a developer you want a database
|
||||
to talk to, not to hand-roll storage and eat all the hiccups that come with it.
|
||||
|
||||
## Why saying no in the open mattered
|
||||
|
||||
I could have just closed the issue with "out of scope." Instead we talked it
|
||||
through, and the request opened up into something more specific than the first
|
||||
sentence. What the reporter was after wasn't live bidirectional sync — it was a
|
||||
guarantee that their events could exist as a file that isn't hostage to one app.
|
||||
Their words, as we got there: "just a way to automatically write and read into a
|
||||
file without it ever being trapped in the application."
|
||||
|
||||
That's a completely different, and much smaller, feature. Not a sync engine —
|
||||
a **periodical auto-export**: set an interval, and at that interval your
|
||||
calendar is written out to an `.ics`. No merge problem, because it's one
|
||||
direction. No philosophy problem, because export *is* what the format is for.
|
||||
That became [#8], and it satisfied the actual need — "That satisfies me!" was
|
||||
how the thread closed.
|
||||
|
||||
I also pointed them at [ICSx5] and mentioned a separate app, the DAVx5 pattern,
|
||||
would be the honest home for true file sync — because a real solution to their
|
||||
original idea deserves to exist, just not bolted into a calendar client.
|
||||
|
||||
## What the thread was really for
|
||||
|
||||
The trade I keep making is that
|
||||
[open standards are a boundary, not a checkbox](/blog/open-standards): the
|
||||
`.ics` request sat just outside that boundary, and holding the line kept the app
|
||||
simple enough to stay trustworthy. But the lesson wasn't "learn to say no." It
|
||||
was that a good "no" is a conversation. The user leaves with something that
|
||||
works, I leave with a sharper sense of what the app is *for*, and the thread
|
||||
stays public so the next person can read the whole reasoning instead of a closed
|
||||
issue with one terse label.
|
||||
|
||||
You don't get that from a roadmap. You get it from taking the request seriously
|
||||
enough to argue with it.
|
||||
|
||||
[#7]: https://codeberg.org/jlmakiola/calendula/issues/7
|
||||
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
|
||||
[ICSx5]: https://icsx5.bitfire.at/
|
||||
76
src/content/blog/the-translator-who-emailed-me.md
Normal file
76
src/content/blog/the-translator-who-emailed-me.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: The translator who emailed me
|
||||
description: >-
|
||||
I stood up a whole self-hosted Weblate instance to invite translators. A
|
||||
missing email setting silently blocked the first one who showed up — until he
|
||||
emailed me directly. By the next morning: Italian at 40%.
|
||||
pubDate: 2026-07-06
|
||||
tags: [open-source, calendula, localization]
|
||||
draft: true
|
||||
---
|
||||
|
||||
If you want your app translated, the conventional wisdom is: lower the barrier.
|
||||
Don't make people file pull requests against a strings file. Give them a web UI
|
||||
where they can see the English, type the translation, and submit — no git, no
|
||||
build, no friction. So I did the thorough thing and stood up my own
|
||||
[Weblate](https://weblate.org/) instance, self-hosted next to everything else I
|
||||
run, wired to Calendula's repo. The pipeline was ready. Then it sat there, empty,
|
||||
because building the invitation is not the same as being invited.
|
||||
|
||||
## The bug that only a person could find
|
||||
|
||||
The first translator didn't come through the polished pipeline. He came through
|
||||
my inbox.
|
||||
|
||||
Roberto — Italian, and generous enough to lead with why rather than what. He'd
|
||||
been waiting years, he said, for a well-made Android calendar that was open
|
||||
source and privacy-focused, and he wanted to help translate it.
|
||||
|
||||
But he couldn't register on my Weblate. The registration page told him there
|
||||
were problems with the server. He'd signed up fine on the public Weblate site,
|
||||
so he rightly suspected the fault was on my self-hosted end and asked whether it
|
||||
could be fixed.
|
||||
|
||||
It was, and the cause was almost funny in how mundane it was: I'd never
|
||||
configured email for the instance. Weblate sends a verification message on
|
||||
registration; with no mail server behind it, that step failed silently, for
|
||||
everyone. My own testing never caught it because I was already an admin. The
|
||||
carefully-built front door had no doorbell, and I had no way of knowing until
|
||||
someone stood outside it and told me.
|
||||
|
||||
Same day, I set up mail, verified that registration worked end to end, and wrote
|
||||
back. His reply, once he was in, was simply that it was working — he'd started
|
||||
translating.
|
||||
|
||||
## What the infrastructure couldn't do
|
||||
|
||||
Here's the part I keep thinking about. I'd invested in the *scalable* solution —
|
||||
a hosted platform anyone in the world could use without talking to me. And its
|
||||
first real outcome was a silent failure that turned every prospective
|
||||
contributor away, invisibly, until one person chose the *un*-scalable path of
|
||||
emailing a stranger to say "your thing is broken, I'd like to help anyway."
|
||||
|
||||
The tooling is still worth it. But the tooling didn't get Calendula translated —
|
||||
a person who cared did, and who cared enough to push past a broken sign-up
|
||||
instead of shrugging and closing the tab. No form submission would have told me
|
||||
the form was broken.
|
||||
|
||||
## By morning
|
||||
|
||||
I fixed the mail config in the evening. By the next morning the Italian
|
||||
translation was at **40%** and Spanish had appeared at **34%** — two languages
|
||||
moving, from basically nothing, within a day of the sign-up actually working.
|
||||
|
||||
That's the whole arc of a small open-source project in miniature: you build the
|
||||
proper infrastructure because it's the right thing to do, it fails in some dumb
|
||||
invisible way, someone who genuinely wants the thing to exist reaches through the
|
||||
gap to tell you, you fix it in an evening, and suddenly your calendar speaks
|
||||
Italian. Not because the pipeline was clever. Because Roberto wrote an email.
|
||||
|
||||
If you're the kind of person who emails the developer instead of quietly giving
|
||||
up: thank you. You are worth more than the analytics.
|
||||
|
||||
---
|
||||
|
||||
*Want to help translate [Calendula](/work/calendula)? The door works now —
|
||||
[weblate.dev.jeanlucmakiola.de](https://weblate.dev.jeanlucmakiola.de).*
|
||||
88
src/content/blog/when-it-stopped-being-mine.md
Normal file
88
src/content/blog/when-it-stopped-being-mine.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: The week my calendar app stopped being just mine
|
||||
description: >-
|
||||
Calendula was a thing I built for myself. Then strangers started relying on it,
|
||||
filing issues, debating design in the comments — and it turned into something
|
||||
with a small community around it.
|
||||
pubDate: 2026-07-03
|
||||
tags: [open-source, calendula]
|
||||
draft: true
|
||||
---
|
||||
|
||||
I built Calendula for myself. I wanted a fast, good-looking, privacy-respecting
|
||||
calendar that didn't own my data, couldn't find one, and so I made one. For a
|
||||
while that was all it was: my app, my phone, my taste.
|
||||
|
||||
Then people showed up. Not many, not all at once — but enough, in about two
|
||||
weeks, that the project stopped feeling like a thing I *have* and started
|
||||
feeling like a thing I'm *responsible for*. This is a note about that shift,
|
||||
because nobody tells you how quickly it happens.
|
||||
|
||||
## From requests to a rhythm
|
||||
|
||||
The first issues were classic solo-dev fare: someone asked for a setting to
|
||||
choose which view the app opens on. I shipped it in
|
||||
[v2.9.0](https://codeberg.org/jlmakiola/calendula/issues/1) within hours, and
|
||||
"that was quick!" came back. Then a tappable month grid; then a Saturday
|
||||
week-start — each one small, each one turned around fast. Then: limit the agenda
|
||||
view to today, or this week, or the next 30 days — and here the exchange got
|
||||
interesting, because I stopped just building the literal request and started
|
||||
asking *what options would you actually want*, and we designed it together in the
|
||||
thread.
|
||||
|
||||
That rhythm — report, discuss, ship, "thank you" — is the engine of a small open
|
||||
project. It's also a trap if you let it: not every request should be built. The
|
||||
hard skill isn't saying yes fast, it's
|
||||
[saying no well](/blog/the-feature-i-said-no-to), and keeping the app inside its
|
||||
[lane](/blog/open-standards) while the person still leaves happy.
|
||||
|
||||
## The moment it gets real
|
||||
|
||||
There's a specific sentence that changes how you feel about a side project. Mine
|
||||
arrived on an issue about registering an intent filter, from someone who'd hit a
|
||||
papercut on GrapheneOS:
|
||||
|
||||
> I have been looking out for a well-designed and fast calendar since switching
|
||||
> to Android four years ago and your software seems to be on point.
|
||||
|
||||
Four years of waiting. That's the kind of line that reframes a side project —
|
||||
someone who's been holding out for exactly this and is ready to depend on it. The
|
||||
same thread is where
|
||||
I admitted a limitation honestly (there's no Android API to set yourself as the
|
||||
default calendar, so no in-app button is possible — only the system picker), and
|
||||
that honesty landed better than a workaround would have. Relied-upon software is
|
||||
built on trust, and trust is mostly just not overpromising.
|
||||
|
||||
## When users become co-designers
|
||||
|
||||
The real tell that a community is forming isn't traffic — it's when people start
|
||||
doing the *thinking* with you, not just the reporting.
|
||||
|
||||
On a request to make the view quick-switch button configurable, the conversation
|
||||
turned into an actual design debate: should reordering live in a settings tab, or
|
||||
should you long-press and drag items in place? Would drag-in-place cause
|
||||
accidental reordering? We went back and forth on the interaction, not the
|
||||
feature. On the contact-birthdays feature, a user who *wasn't even the original
|
||||
reporter* created test contacts — one Google, one CardDAV — to help me isolate
|
||||
why some birthdays showed up and others didn't. That's not filing a bug. That's
|
||||
debugging *with* me.
|
||||
|
||||
I don't have a big community. I have a handful of people who care about a good
|
||||
calendar as much as I do, and who've started treating its rough edges as *our*
|
||||
problem. That's the shift.
|
||||
|
||||
## The unglamorous parts, on purpose
|
||||
|
||||
None of this is a highlight reel. In the same fortnight I shipped a release that
|
||||
mangled the month widget's switching buttons — a regression a user caught and I
|
||||
had to go dig out of a recent change. The birthday feature still doesn't handle
|
||||
every account type. Writing this, I could have quietly left those out. But the
|
||||
point of doing this in the open, on a public tracker, is that the reasoning and
|
||||
the mistakes are all readable — the terse "closed" label replaced by an actual
|
||||
conversation anyone can follow later.
|
||||
|
||||
A project stops being *just yours* the moment someone else would notice if it
|
||||
broke. Calendula crossed that line quietly, in a week of issue threads, and it
|
||||
changed how I write every commit since. The question stopped being "does this
|
||||
please me" and became "would I want to explain this decision to the people in the
|
||||
comments." The second one is the one worth answering.
|
||||
92
src/content/blog/widgets-remoteviews.md
Normal file
92
src/content/blog/widgets-remoteviews.md
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: Compose for widgets, RemoteViews underneath
|
||||
description: >-
|
||||
Jetpack Glance lets you write home-screen widgets in Compose. But it compiles
|
||||
down to RemoteViews — so the old constraints still bite through the nice API.
|
||||
Three Calendula bugs that proved it.
|
||||
pubDate: 2026-07-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
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
138
src/pages/datenschutz.astro
Normal 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. 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. 25,
|
||||
91710 Gunzenhausen, Deutschland, angemietet ist. Hetzner verarbeitet
|
||||
die nachfolgend genannten Daten als Auftragsverarbeiter auf Grundlage eines
|
||||
Vertrags zur Auftragsverarbeitung gemäß Art. 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. 6 Abs. 1 lit. 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. 6 Abs. 1 lit. 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. 6 Abs. 1 lit. f DSGVO (Beantwortung Ihrer
|
||||
Anfrage) bzw. Art. 6 Abs. 1 lit. 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. 15 DSGVO),</li>
|
||||
<li>Berichtigung unrichtiger Daten (Art. 16 DSGVO),</li>
|
||||
<li>Löschung Ihrer Daten (Art. 17 DSGVO),</li>
|
||||
<li>Einschränkung der Verarbeitung (Art. 18 DSGVO),</li>
|
||||
<li>Datenübertragbarkeit (Art. 20 DSGVO),</li>
|
||||
<li>
|
||||
Widerspruch gegen die Verarbeitung, soweit diese auf
|
||||
Art. 6 Abs. 1 lit. f DSGVO beruht
|
||||
(Art. 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. 77 DSGVO).
|
||||
</p>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
91
src/pages/impressum.astro
Normal file
91
src/pages/impressum.astro
Normal 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>
|
||||
@@ -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' });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
75
src/pages/uses.astro
Normal 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
24
src/utils/posts.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { getCollection, type CollectionEntry } from 'astro:content';
|
||||
|
||||
/**
|
||||
* Published blog posts, newest first.
|
||||
*
|
||||
* A post is published when it is not a draft AND its `pubDate` has arrived.
|
||||
* That makes `pubDate` the single source of truth for scheduling: set a post
|
||||
* to `draft: false` with a future date and it goes live on its own once a
|
||||
* build runs on or after that day (see the daily deploy cron).
|
||||
*
|
||||
* In dev the gate is lifted entirely so drafts and future-scheduled posts can
|
||||
* be previewed locally; production applies both the draft and date gates.
|
||||
*/
|
||||
export async function getPublishedPosts(): Promise<CollectionEntry<'blog'>[]> {
|
||||
const now = Date.now();
|
||||
const posts = await getCollection('blog', ({ data }) => {
|
||||
// Dev server: show everything, including drafts and not-yet-due posts.
|
||||
if (import.meta.env.DEV) return true;
|
||||
if (data.draft) return false;
|
||||
if (data.pubDate.valueOf() > now) return false;
|
||||
return true;
|
||||
});
|
||||
return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
}
|
||||
Reference in New Issue
Block a user