12 Commits

Author SHA1 Message Date
bf40e27820 Switch the contact address to business@ 2026-07-28 19:01:29 +02:00
4181ef35ba Add the Calendula app privacy policy 2026-07-28 19:01:29 +02:00
97ff76b21f Merge pull request 'Publish two overdue posts (caldav-colors, the-feature-i-said-no-to)' (#5) from publish-overdue-posts into main
All checks were successful
scheduled-deploy / trigger-deploy (push) Successful in 25s
2026-07-25 07:58:22 +00:00
0a9ef90160 Publish two overdue posts (flip draft: false)
Both reached their pubDate but were still draft: true, so every daily
scheduled-deploy rebuild skipped them — the gate in src/utils/posts.ts
checks draft before pubDate, so the flag outranks the date indefinitely.
The Jul 6 flip (c321728) covered only the posts due that day; these two
were not yet due and were never revisited:

- caldav-colors            (2026-07-08, 17 days overdue)
- the-feature-i-said-no-to (2026-07-10, 15 days overdue)

Verified with a production build: both render and appear in the blog
index, RSS feed, and sitemap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 09:57:45 +02:00
c321728b7f Publish three due posts (flip draft: false)
All checks were successful
scheduled-deploy / trigger-deploy (push) Successful in 25s
These posts had reached their pubDate but were still draft: true, so the
daily scheduled-deploy rebuild left them hidden — the publish gate in
src/utils/posts.ts checks draft before pubDate. Flip them to draft: false
so the date gate can release them:

- when-it-stopped-being-mine (2026-07-03)
- recurring-event-edit       (2026-07-05)
- the-translator-who-emailed-me (2026-07-06)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:34:23 +02:00
3c8e996b3e Merge pull request 'Document blog authoring + release workflow' (#4) from docs-blog-workflow into main
All checks were successful
scheduled-deploy / trigger-deploy (push) Successful in 23s
2026-07-01 19:54:18 +00:00
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
12 changed files with 354 additions and 16 deletions

View File

@@ -7,9 +7,8 @@
# pings the deploy hook.
#
# Setup (once):
# - A Gitea Actions runner must be registered and online for this repo/org,
# advertising a label that matches `runs-on` below (default act_runner maps
# `ubuntu-latest`; change it if your runner uses a different label).
# - 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).
@@ -31,7 +30,7 @@ on:
jobs:
trigger-deploy:
runs-on: ubuntu-latest
runs-on: docker
steps:
- name: Ping Coolify deploy hook
env:

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

@@ -16,7 +16,7 @@ export const LEGAL = {
person: SITE.author, // natural person responsible — § 18 Abs. 2 MStV
street: 'Mahlerstraße 10',
city: '14772 Brandenburg an der Havel',
email: 'mail@jeanlucmakiola.de',
email: 'business@jeanlucmakiola.de',
phone: '',
} as const;
@@ -24,5 +24,5 @@ export const LEGAL = {
export const SOCIALS: { label: string; href: string; icon: string }[] = [
{ label: 'Gitea', href: 'https://gitea.jeanlucmakiola.de/makiolaj', icon: 'simple-icons:gitea' },
{ label: 'Ko-fi', href: 'https://ko-fi.com/jeanlucmakiola', icon: 'simple-icons:kofi' },
{ label: 'Email', href: 'mailto:mail@jeanlucmakiola.de', icon: 'mdi:email-outline' },
{ label: 'Email', href: `mailto:${LEGAL.email}`, icon: 'mdi:email-outline' },
];

View File

@@ -6,7 +6,7 @@ description: >-
how event colour flows through Android's calendar provider.
pubDate: 2026-07-08
tags: [android, calendula, caldav, accessibility]
draft: true
draft: false
---
Two colour reports landed on Calendula in the same week, and both are still open.

View File

@@ -6,7 +6,7 @@ description: >-
it" surprisingly hard.
pubDate: 2026-07-05
tags: [android, calendula, caldav]
draft: true
draft: false
---
A recurring event isn't a list of events. It's *one* row plus a rule. "Every

View File

@@ -6,7 +6,7 @@ description: >-
the app is actually for.
pubDate: 2026-07-10
tags: [open-source, calendula, open-standards]
draft: true
draft: false
---
One of the more useful things that has happened to Calendula wasn't a feature I

View File

@@ -6,7 +6,7 @@ description: >-
emailed me directly. By the next morning: Italian at 40%.
pubDate: 2026-07-06
tags: [open-source, calendula, localization]
draft: true
draft: false
---
If you want your app translated, the conventional wisdom is: lower the barrier.

View File

@@ -6,7 +6,7 @@ description: >-
with a small community around it.
pubDate: 2026-07-03
tags: [open-source, calendula]
draft: true
draft: false
---
I built Calendula for myself. I wanted a fast, good-looking, privacy-respecting

View File

@@ -4,9 +4,9 @@ description: >-
Jetpack Glance lets you write home-screen widgets in Compose. But it compiles
down to RemoteViews — so the old constraints still bite through the nice API.
Three Calendula bugs that proved it.
pubDate: 2026-07-02
pubDate: 2026-07-01
tags: [android, architecture, calendula]
draft: true
draft: false
---
Calendula's home-screen widgets are written in

View File

@@ -0,0 +1,221 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { LEGAL } from '../../consts';
// App privacy policy for Calendula — required as a public URL by the Google
// Play Console (mandatory for every app) and linked from the app's About card.
// Deliberately separate from /datenschutz, which covers this website only.
//
// Kept in English because SITE.lang is 'en' and Play's default store listing is
// en-US; /datenschutz and /impressum stay German for legal reasons.
//
// Source of truth for the facts below: the Calendula repository. If the app's
// permissions or data paths change, update this page and the "Last updated"
// date. The claim "no INTERNET permission" is verifiable via
// `aapt2 dump permissions` on any released APK.
const lastUpdated = '28 July 2026';
---
<BaseLayout
title="Calendula — Privacy Policy"
description="Privacy policy for the Calendula Android calendar app. No data collection, no tracking, no internet permission."
width="narrow"
>
<article class="prose">
<h1 class="page-title">Privacy Policy — Calendula</h1>
<p>
<strong>Last updated:</strong> {lastUpdated}<br />
Applies to the Android app <strong>Calendula</strong>
(package <code>de.jeanlucmakiola.calendula</code>), all versions and all
distribution channels.
</p>
<h2>In short</h2>
<p>
Calendula collects nothing, sends nothing, and has no user accounts.
It has <strong>no internet permission at all</strong> — the app is
technically incapable of transmitting your data anywhere. Everything it
shows you is read from the calendars that already exist on your device.
</p>
<h2>1. Controller</h2>
<p>
{LEGAL.business}<br />
{LEGAL.street}<br />
{LEGAL.city}<br />
Email: <a href={`mailto:${LEGAL.email}`}>{LEGAL.email}</a>
</p>
<h2>2. No data collection</h2>
<p>
Calendula contains <strong>no analytics, no tracking, no advertising, no
crash-reporting SDK and no third-party services of any kind</strong>. No
user profile is created, no identifier is generated, and no data is shared
with or sold to anyone.
</p>
<p>
The app does not request the <code>android.permission.INTERNET</code> permission.
Without it, Android prevents the app from opening any network connection.
This is verifiable: inspect the permission list of any released APK, or read
the <a href="https://codeberg.org/jlmakiola/calendula" rel="noopener">source code</a>.
</p>
<h2>3. Data Calendula accesses on your device</h2>
<p>
All of the following is processed <strong>locally on your device only</strong>.
None of it is transmitted, and none of it is stored by the developer.
</p>
<h3>Calendar data</h3>
<p>
Calendula is a viewer and editor for the calendars Android already manages.
It reads and writes events, reminders and calendar settings through
Android's system calendar provider. The app keeps <strong>no database of
its own</strong> — your events live in the system calendar, exactly where
they lived before you installed Calendula, and they remain there if you
uninstall it.
</p>
<p>
Note: if one of those system calendars is itself synchronised with an
online account (for example a Google account, or a CalDAV server via
DAVx5), that synchronisation is performed by Android and that other app —
not by Calendula. The privacy policy of the respective provider applies
to it.
</p>
<h3>Contacts (optional)</h3>
<p>
The “Contact special dates” feature reads birthdays and anniversaries from
your contacts and mirrors them one-way into a local calendar, so they
appear alongside your other events. This feature is switched off by
default, the contacts permission is requested only when you enable it, and
it is never requested at startup. Contacts are only ever read, never
modified, and the data does not leave your device.
</p>
<h3>Notifications</h3>
<p>
Reminders are displayed as local notifications on your device. Nothing is
sent to a push service.
</p>
<h3>Files</h3>
<p>
When you import or export an ICS file, Calendula reads or writes exactly
the file you select through Android's system file picker. The optional
automatic backup writes an ICS export to the folder you choose. The app has
no access to other files.
</p>
<h3>App settings</h3>
<p>
Your preferences (view options, theme, reminder defaults and similar) are
stored locally on your device and are removed when you uninstall the app.
</p>
<h2>4. Crash reports — the only case where data can leave your device</h2>
<p>
If Calendula crashes, it offers to report the problem. Nothing is sent
automatically. The report is copied to your clipboard and your browser is
opened with the project's issue tracker, the text
pre-filled. <strong>You see the full content, you decide whether to submit
it, and you can edit or discard it.</strong>
</p>
<p>Such a report contains:</p>
<ul>
<li>app version,</li>
<li>Android version,</li>
<li>device manufacturer and model,</li>
<li>your device language,</li>
<li>the timestamp,</li>
<li>and the technical stack trace.</li>
</ul>
<p>
It contains <strong>no</strong> event data, <strong>no</strong> contacts
and <strong>no</strong> personal identifiers.
</p>
<p>
If you choose to submit it, the report becomes a public issue on the
project's issue tracker at Codeberg, operated by Codeberg&nbsp;e.&nbsp;V.
Their privacy policy then applies to that submission.
</p>
<h2>5. External links</h2>
<p>
The settings screen contains links to the source code, the licence, the
issue tracker and a voluntary donation page (Ko-fi). Following one of these
links opens your browser and leaves the app; the privacy policy of the
respective website then applies. Calendula transmits no data of yours in
the process — it only opens the address.
</p>
<h2>6. Permissions and why they exist</h2>
<ul>
<li>
<code>READ_CALENDAR</code>, <code>WRITE_CALENDAR</code> — display and
edit your events; the core function.
</li>
<li><code>POST_NOTIFICATIONS</code> — show reminders.</li>
<li>
<code>READ_CONTACTS</code> — optional, only for the “Contact special
dates” feature.
</li>
<li>
<code>USE_EXACT_ALARM</code>, <code>SCHEDULE_EXACT_ALARM</code> — deliver
reminders at the exact time, including after snoozing.
</li>
<li>
<code>RECEIVE_BOOT_COMPLETED</code> — re-register pending reminders after
a restart.
</li>
<li>
<code>REQUEST_IGNORE_BATTERY_OPTIMIZATIONS</code> — only to open the
system dialog for the “Reliable delivery” setting.
</li>
<li>
<code>WAKE_LOCK</code>, <code>FOREGROUND_SERVICE</code>, <code>ACCESS_NETWORK_STATE</code>
— required by the Android system component used for scheduled background
work (WorkManager).
</li>
</ul>
<p>
<code>ACCESS_NETWORK_STATE</code> allows reading <em>whether</em> a network
connection exists — it does <strong>not</strong> permit using one.
Without <code>INTERNET</code>, no connection is possible.
</p>
<h2>7. Distribution channels</h2>
<p>
Calendula is distributed via F-Droid, Obtainium, a self-hosted repository
and, where applicable, the Google Play Store. When you download or update
the app, the operator of that channel processes data (such as your IP
address) under their own privacy policy. This is outside the developer's
control and unrelated to the app's own behaviour.
</p>
<h2>8. Children</h2>
<p>
Calendula is suitable for all ages. Since it collects no data at all, no
data of children is processed either.
</p>
<h2>9. Your rights</h2>
<p>
Because the developer processes no personal data of yours, there is no
stored data to which rights of access, rectification, erasure,
restriction, data portability or objection (Art.&nbsp;1521 GDPR) could
apply. You may nevertheless contact the address above at any time with any
question. You also have the right to lodge a complaint with a supervisory
authority.
</p>
<h2>10. Changes to this policy</h2>
<p>
Should the app's functionality change in a way that affects data
processing, this policy will be updated and the date at the top adjusted.
The version history is publicly traceable in the project's source
repository.
</p>
</article>
</BaseLayout>

View File

@@ -2,7 +2,7 @@
import BaseLayout from '../layouts/BaseLayout.astro';
import { Icon } from 'astro-icon/components';
import { getCollection } from 'astro:content';
import { SITE } from '../consts';
import { SITE, LEGAL } from '../consts';
import { getPublishedPosts } from '../utils/posts';
const posts = (await getPublishedPosts()).slice(0, 4);
@@ -33,7 +33,7 @@ const projects = (await getCollection('projects')).sort(
<div class="hero__actions">
<a class="btn btn--filled" href="#work">View work</a>
<a class="text-link" href="/blog">Read the blog <span class="dot">→</span></a>
<a class="text-link" href="mailto:mail@jeanlucmakiola.de">
<a class="text-link" href={`mailto:${LEGAL.email}`}>
<Icon name="mdi:email-outline" /> Get in touch
</a>
</div>

View File

@@ -1,5 +1,6 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { LEGAL } from '../consts';
// 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,
@@ -69,7 +70,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
<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>.
<a href={`mailto:${LEGAL.email}`}>Get in touch</a>.
</p>
</article>
</BaseLayout>