Compare commits

..
Author SHA1 Message Date
April John
f4fc50e4d5 fix shit 2026-08-28 17:24:09 +02:00
April John
0291a035e9 other announcement 2026-08-28 17:15:36 +02:00
April John
d336d02093 woop woop 2026-08-28 16:29:44 +02:00
35d35161a7 Merge branch 'production' into staging 2026-08-25 20:58:13 +02:00
3173d0766c Update .forgejo/workflows/deploy.yaml 2026-08-25 20:57:44 +02:00
2d688a14a3 Update .forgejo/workflows/deploy.yaml 2026-08-25 20:47:17 +02:00
4d692c5b48 Update .forgejo/workflows/deploy.yaml 2026-08-25 20:45:27 +02:00
746583df5b Merge pull request 'Fixes fuer unseren Kalender auf der website' (#57) from hauke/www:kalender into staging
Reviewed-on: cccb-website-team/www#57
2026-08-23 01:14:45 +02:00
6782da49d4 Move what the two calendar views share into one module
Both views read the same file and ask it the same question, only the
answer is presented differently: the start page lists the next few
occurrences, the calendar page marks the occurrences of one month. Since
they were taught to read a calendar properly they also carry the same
code for it, twice and word for word, some 130 lines of `eventUrl()`,
`exceptionsByUid()`, `iterationEnd()`, the fetch with its check of the
response and the walk over the events of the calendar. Every fix so far
had to be written twice, and the next one that is only written once
leaves the two views disagreeing about the same calendar.

Put it into `assets/js/events.js`, which offers what both need:

- `loadCalendar()` fetches and parses `/calendars/all.ics`
- `occurrencesBetween()` walks the occurrences that touch a window,
  expanding recurring events and resolving the ones that were modified
  on their own
- `eventUrl()` reads the URL of an event

`upcoming.js` and `calendar.js` keep what is really theirs, the shape of
their entries and how they are drawn, and both lose their own import of
ical.js: it is the concern of the module that reads the calendar now.
The two of them shrink from 259 and 549 lines to 105 and 406.

Hugo bundles the module into both scripts, so no shortcode changes and
no second request.

The walk keeps an occurrence when it starts at or before the end of the
window and ends after its start. That is the condition the start page
used; the calendar page compared against the start of the month with "<"
instead of "<=". Its window reaches two days past the month, so the day
this can differ on is nowhere near it.

No behaviour changes with this: the upcoming list over 120 days and
every day of the month view from 2025 to 2028, taken from
https://berlin.ccc.de/calendars/all.ics with the name, URL, start, end
and all day flag of each entry, are identical before and after, and so
are the results of the tests for moved occurrences, for modifications
between two series and for all day events in Berlin, Tokyo, Los Angeles
and Kiritimati.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-23 01:07:25 +02:00
75dd9ca997 Separate date and name in the upcoming events table
The two columns of the table on the start page touched each other, so the entry
read "Donnerstag, 27.08., 19:00 UhrClub Discordia".

The table is created with the classes "table table-condensed", which no
stylesheet of the site defines, and the table styling that the theme applies
inside prose addresses "tbody td". The table is delivered empty and its rows
are added through the DOM, where a tr appended to a table stays a direct child
instead of being put into a tbody the way the HTML parser would. The rows are
therefore outside of any tbody and the padding of the theme never applied.

Give the column holding the date its own padding, and keep the date on one
line, it is one piece of information and reads badly broken after the weekday.

The padding alone does not fit, though. The table stands in a prose column that
the theme limits to 65 characters so that running text stays readable, and a
date and the name of an event next to each other are wider than that, so the
names would be wrapped over several lines. Lift the limit off the column and
put it back on everything in it except the table, which leaves the table room
to grow while the heading and the paragraph around it keep their width.

That much space then has to be filled sensibly. The theme lays a table out as a
block, "table { display: block; overflow: auto }", so that a wide one can be
scrolled sideways, and a block fills its parent instead of shrinking to its
content the way a table does. Spanning the page the entries would all sit at
its left edge. Ask for the width of the content with fit-content, which the
automatic margins then centre.

Addressing the table by its id keeps all of this to the start page and takes
precedence over the theme, whose prose rules are written with :where() and
carry no specificity.

Measured in a browser at 1280, 768 and 500 pixels: date and name share one line
at the first two, the table is 602 pixels wide with the same distance left and
right, and at 500 the column is narrower than the table, so the table fills it
and only the longest name wraps. The page never scrolls sideways.

Fixes: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-23 01:07:25 +02:00
60ab628270 Parse the calendar with ical.js
The calendar page brought its own ICS parser and its own RRULE expansion. Both
only covered the cases that happened to be needed when they were written, and
the calendar has moved on since. Against the published calendar, for September
2026:

  Spieleabend  FREQ=WEEKLY;INTERVAL=2;BYDAY=SA  shown 05. 12. 19. 26., correct 05. 19.
  CCCB Plenum  FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2 shown 01.,               correct 08.
  CCCB Plenum  FREQ=MONTHLY;BYDAY=TU;BYSETPOS=4 shown 01.,               correct 22.

INTERVAL was only read for monthly rules, so the Spieleabend was shown twice as
often as it takes place. BYSETPOS was not implemented at all, and since
parseInt("TU") is NaN the fallback turned both Plenum rules into "first
Tuesday", putting two Plenums on a day without one and none on the two days
with one. UNTIL, COUNT, EXDATE, RECURRENCE-ID, BYMONTHDAY and a BYDAY listing
more than one weekday were not handled either.

The text was no better. Content lines longer than 75 characters are continued
on the next line, of which there are 477 in the calendar, and the parser did
not join them, so it cut values off in the middle of a word. It also split
every line at the first colon, which lands inside the parameter of
DESCRIPTION;ALTREP="data:text/html,...". And it never resolved the escaping, so
"\n" was shown as those two characters. 30 of 31 descriptions were wrong:

  before: "Der Club Discordia ist ein öffentliches Treffen in den Clubr"
  after:  "Der Club Discordia ist ein öffentliches Treffen in den Clubräumen des CCC Berlin"

Hand the parsing and the expansion to ical.js, which is vendored for the start
page anyway. The month view now asks the library for the occurrences that touch
the month, which removes the reimplementation along with all of the above.

While the events are being reduced to what the view needs:

- An event is entered on every day it covers, so the Amateurfunk trip from
  30.10. to 01.11. is no longer marked on 30.10. alone. The end of an event is
  not part of it, so one ending at midnight stays on the day before.
- A time that names a zone is converted to Europe/Berlin instead of being read
  off the digits of the ICS string. Every event currently carries
  TZID=Europe/Berlin, so the wall clock time shown does not change, but a UTC
  timestamp would have been shown in UTC. A date is a different matter, see
  further down.
- The URL of the event is used for the link in the detail panel. Events without
  one are shown without a link, as on the start page. This replaces
  createEventLink(), which guessed URLs from the title and was never called,
  and the panel no longer builds an <h> element, which is not an element.

Descriptions may contain line breaks, so keep them in the panel. The times of
an event are labelled in German like the rest of the page, "Beginn" and "Ende"
instead of "Start" and "End".

An all day event carries a date, and a date has neither a time nor a zone: its
digits are the day itself. `toJSDate()` reads them as midnight in the zone of
the browser, and deriving the Berlin day from that afterwards moves the event
by the offset between the two, so the promise of the same days everywhere would
have held for every event except the ones that consist of nothing but days. An
event on 30. and 31.08. would have been marked on:

  Berlin       30. 31.08.
  Los Angeles  30. 31.08. 01.09.
  Tokio        29. 30. 31.08.

Take the day from the ICAL time, which still knows whether it names a day or a
point in time, and convert only the latter. Stepping to the end of the event
moves by a day where it is made of days and by a second where it is not, which
also expresses "the end is not part of the event" in the terms of the event
itself. The occurrences of a day are sorted by their start, which for an all
day event is that same midnight, so order them before the timed events instead.

Resolving an occurrence that was modified on its own needs two more things to
be right. Unless it is told which modifications belong to an event,
`ICAL.Event` relates every VEVENT with a RECURRENCE-ID in the file to every
recurring event and keys them by the recurrence id alone; the UID is only
compared with `strictExceptions`, which then throws instead of skipping. A
modification would therefore also override the occurrence another series holds
at the same instant, so the detail panel of that day would show the wrong event
and the modified one twice. Group the modifications by the UID of the event
they belong to and hand each event its own.

And the expansion walks the unmodified recurrence times, so an occurrence
pulled forward into the month from a later one would never be reached: the walk
stops at its original time, and the month it was moved out of drops it because
it no longer falls into it, which loses it from the calendar altogether.
Iterate far enough that the largest move towards the past can still reach the
month. Moving a Plenum or a Club Discordia to the week before, out of the way
of a holiday, is exactly what produces such a modification. With a weekly
series whose occurrence of 07.09. is moved to 28.08.:

  before: August     03. 10. 17. 24. 31.
          September  14. 21. 28.
  after:  August     03. 10. 17. 24. 28. 31.
          September  14. 21. 28.

The URL ends up in the href of a link and the calendar is exported from a
CalDAV server, so whoever may write to it decides what that is;
`URL:javascript:alert(1)` on an event would run that script when a visitor
clicks the name. Pass on nothing but http and https.

The stylesheet of the page goes through `minify | fingerprint` while the script
next to it is rewritten, so both are delivered the way the assets of the start
page already are: smaller, under a name that carries their content hash, and
with an integrity hash in the tag.

The published calendar has neither all day events nor RECURRENCE-ID today, both
can be created in the CalDAV calendar the export comes from. Checked in Berlin,
Tokyo, Los Angeles and Kiritimati: an all day event over 30. and 31.08. is
marked on those two days in all four, one from 31.08. to 02.09. is marked
across the month boundary, and the month view of the published calendar is the
same in all of them.

Fixes: 4068fab565 ("improved calendar and fixed url temporarily")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-23 01:07:25 +02:00
a0e1ef046a Vendor ical.js instead of loading it from unpkg.com
The upcoming events table pulled its ICS parser straight from a CDN with
`import ICAL from "https://unpkg.com/ical.js/dist/ical.min.js"`. That sends
every visitor of the start page to unpkg.com, which hands their IP address and
user agent to a third party before any of our own code runs. The URL is not
even pinned to a version, so whatever ical.js publishes next is executed on our
site without anybody looking at it, and the start page silently breaks when the
CDN is unreachable.

Check the parser into `assets/js/vendor/` and let Hugo bundle it. This is what
`js.Build` is for: it runs the esbuild that is built into Hugo, so it resolves
the import at build time and needs no node_modules and no extra tooling in the
build environment. The result is minified and fingerprinted like the other
scripts of the site, and the script tag carries a subresource integrity hash.

Since the bundle now has a content hash in its name, its URL cannot be written
by hand in the markdown any more. Move the table and the script tag into an
`upcoming` shortcode, which is the same pattern `calendar.html` already uses,
and move `upcoming.js` from `static/` to `assets/` so Hugo can process it.

The vendored file is the unminified `dist/ical.js` of the pinned release: it
carries the MPL-2.0 header and is the source form of what we ship, and Hugo
minifies it for delivery anyway. `assets/js/vendor/README.md` records the
version, where it came from and how to update it.

The built bundle renders the same table as before, checked against
https://berlin.ccc.de/calendars/all.ics.

Fixes: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-23 01:07:25 +02:00
4545852327 Report errors while loading the calendar
The response of the fetch went to the parser without ever looking at it. When
the calendar could not be loaded the error page of the web server was parsed as
a calendar, which threw inside a promise nobody was waiting on. The result was
an empty table, an unhandled rejection in the console and an error message
about broken calendar syntax that says nothing about the actual problem, a
calendar that is not there.

Refuse a response that is not ok, naming the status, and log failures in the
chain, like the calendar page already does.

  before: Uncaught (in promise) Error: invalid line (no token ";" or ":")
                                       "<html>404 Not Found</html>"
  after:  Fehler beim Laden der Termine:
          Error: /calendars/all.ics: 404 Not Found

The table stays empty either way, there is nothing to show without a calendar.

Fixes: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-23 01:07:25 +02:00
cd39842ac0 Show event times in Berlin time
`toLocaleString()` was given the German locale but no time zone, so it printed
the time in whatever zone the browser of the visitor is set to. The events
happen in Berlin, so this is only correct for visitors who are in Berlin.
Somebody reading the start page from Sydney was told the Plenum of Tuesday
20:00 takes place on Wednesday at 04:00.

Format in Europe/Berlin explicitly. For an event that names a point in time
only the printing was wrong, picking and sorting work on absolute points in
time and were not affected.

An all day event carries a date, and a date has neither a time nor a zone: its
digits are the day itself. `toJSDate()` reads them as midnight in the zone of
the browser, so the point in time that is then printed in Berlin time is off by
the offset between the two, which puts a bogus time of day on the entry and,
far enough east or west, the wrong day. An event on 30.08. was announced as:

  Berlin       Sonntag, 30.08., 00:00 Uhr
  Los Angeles  Sonntag, 30.08., 09:00 Uhr
  Tokio        Samstag, 29.08., 17:00 Uhr

Keep the digits of the date and read them as UTC, which no browser setting
moves, and print an all day event in UTC and without a time of day. Berlin,
Tokyo, Los Angeles and Kiritimati all say "Sonntag, 30.08." now, while a timed
event keeps saying "Sonntag, 30.08., 19:00 Uhr" everywhere. Sorting improves
with it, an all day event no longer changes its place in the list depending on
where the visitor sits.

The published calendar has no all day events today, they can be created in the
CalDAV calendar the export comes from.

Drop the two replacements around the formatted date while touching it. The
first removes the comma after the weekday and the second puts it back, so they
cancel each other out:

  "Samstag, 22.08., 17:00" -> "Samstag 22.08., 17:00" -> "Samstag, 22.08., 17:00"

Fixes: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-23 01:07:25 +02:00
6d99a39190 Do not list a moved event occurrence twice
An occurrence of a recurring event that was changed on its own carries a
RECURRENCE-ID and is stored as an additional VEVENT next to the event it
belongs to. `getAllSubcomponents("vevent")` returns those components as well,
and since they have no RRULE of their own they were handled as separate single
events. The occurrence therefore ended up in the list twice: once from
expanding the recurring event, which resolves the modified time through the
exception, and once more from the extra VEVENT.

To make it worse the two rows disagreed, because name and URL were taken from
the recurring event while the time came from the modification, so the first row
showed the new time under the old name.

Skip components that are a recurrence exception, they are already covered by
the event they modify, and take name and URL from the occurrence details, which
point at the modification where there is one and at the event itself otherwise.
Events whose RECURRENCE-ID refers to an event that is not in the file are
dropped by this, which cannot happen in a full calendar export.

With a recurring Plenum whose 25.08. occurrence is moved two hours earlier and
renamed:

  before: 25.08. 18:00  CCCB Plenum
          25.08. 18:00  CCCB Plenum (verschoben)
  after:  25.08. 18:00  CCCB Plenum (verschoben)

Leaning on the resolution ical.js does here needs two more things to be right.

The first is which modifications an event is asked to resolve. Unless it is
told, `ICAL.Event` relates every VEVENT with a RECURRENCE-ID in the file to
every recurring event and keys them by the recurrence id alone; the UID is only
compared with `strictExceptions`, which then throws instead of skipping. A
modification would therefore also override the occurrence another series holds
at the same instant, so the wrong entry is shown and the modified one twice
over. Group the modifications by the UID of the event they belong to and hand
each event its own, which also turns the relating off for events that have
none. Recognise a modification on the component instead of on the event,
because relating exceptions to an exception throws. With two unrelated weekly
series that both meet on Thursday at 19:00, of which only A has its occurrence
of 03.09. moved to 17:00:

  before: 27.08. 19:00 Serie A          after: 27.08. 19:00 Serie A
          27.08. 19:00 Serie B                 27.08. 19:00 Serie B
          03.09. 17:00 Serie A (versch.)       03.09. 17:00 Serie A (versch.)
          03.09. 17:00 Serie A (versch.)       03.09. 19:00 Serie B

The second is how far the expansion has to run. It walks the unmodified
recurrence times and stopped at the end of the window, but the occurrence
handed to `getOccurrenceDetails()` may have been moved somewhere else entirely.
Both directions were wrong: an occurrence pulled forward from beyond the window
was never reached, because the walk had already stopped at its original time,
and one pushed out of the window was still listed, because only its original
time was ever compared against the window. Iterate far enough that the largest
move towards the past can still reach the window, and decide by the time the
occurrence really takes place at. The stop condition keeps using the raw
recurrence time, which stays monotonic, so the iteration still terminates. With
a weekly series whose occurrence of 05.10. is pulled forward to 25.08. and
whose occurrence of 31.08. is pushed to 02.11., seen from 23.08. through the 20
day window:

  before: 24.08. Serie                 after: 24.08. Serie
          31.08. Serie                        25.08. Serie (vorgezogen)
          07.09. Serie                        07.09. Serie

Reading the URL becomes a function of its own while name and URL move to the
occurrence details, and it now looks at what it reads. The calendar is exported
from a CalDAV server, so whoever may write to it decides what ends up in the
href of the link, and `URL:javascript:alert(1)` on an event would run that
script when a visitor clicks the entry. Pass on nothing but http and https; a
value that is not a URL at all no longer reaches the href either.

The published calendar contains no RECURRENCE-ID at all today, so nothing about
the modifications changes for it. It is exported from a CalDAV server though,
and moving a single Club Discordia or Plenum out of the way of a holiday is
exactly what creates such a modification. Checked against
https://berlin.ccc.de/calendars/all.ics: the list and the links of the 30
events that carry a URL are unchanged.

Fixes: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-23 01:07:25 +02:00
8b692a2e88 Keep running events in the upcoming list until they end
The "Nächste Veranstaltungen" table selected events with `start > now`, so an
event disappeared from the list the moment it began. Someone looking at the
start page at 20:30 no longer saw the Plenum that had started at 20:00 and ran
until 22:00, which is exactly when that information is most useful.

Select on the end of the event instead, so an event stays listed for as long as
it is still running. A currently running event sorts first, because the list is
ordered by start time.

For recurring events the end of the individual occurrence is needed, not the
end of the series, so go through `getOccurrenceDetails()`. That also resolves
occurrences overridden via RECURRENCE-ID. The chronological break out of the
iteration keeps using the raw occurrence time, which stays monotonic even when
an override moves a single occurrence. `ICAL.Event.endDate` falls back to
DURATION and, for all-day events, to the following day, so events without an
explicit DTEND keep working.

The `maxDays` window still applies to the start of an event, so a long running
event does not extend the window.

This is not a regression from the commit below, the Python generator it
replaced filtered on `dtstart >= start` in the same way.

Checked against https://berlin.ccc.de/calendars/all.ics: the Plenum (20:00 to
22:00) is now listed at 20:30 and 21:59 and gone at 22:01, and the multi-day
Amateurfunk trip (30.10. 12:00 to 01.11. 18:00) stays listed throughout.

Fixes: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-23 01:07:25 +02:00
b8ec457818 Link upcoming events to their ICS URL
The "Nächste Veranstaltungen" table read the event URL via `ICAL.Event.url`,
but ical.js does not expose a `url` getter on `ICAL.Event` (it only has uid,
summary, description, color, location, sequence, the dates, organizer and
attendees). `event.url` was therefore always `undefined` and the `?? ""`
fallback turned it into an empty string, so every row rendered as
`<a href="">`, a dead link that just reloads the start page.

Read the URL from the VEVENT component instead. This also picks up the
`URL;VALUE=URI:` form used by most events in the published calendar, which is
exported from a CalDAV client and does not use a bare `URL:` property.

Not every event has a URL, so only wrap the name in a link when one is
present and emit plain text otherwise.

Checked against https://berlin.ccc.de/calendars/all.ics (31 events, 1 of them
without a URL):

  before: <td><a href="">CCCB Plenum</a></td>
  after:  <td><a href="https://wiki.berlin.ccc.de/Plenum">CCCB Plenum</a></td>
  after:  <td>Aktionstag gegen Überwachung im Chaos Computer Club Berlin</td>

Fixes: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-23 01:07:25 +02:00
b11d80aac2 README: describe the calendar as it is built today
`build.sh` no longer post-processes anything: since the switch to ICS files it
just deletes `public/` and runs `hugo`. The `CALENDAR` placeholder, the Python
dependency on `icalendar`, `python-dateutil` and `pytz`, and the `de_DE.UTF-8`
locale requirement are all gone, and nothing in `packages.nix`, `devShells.nix`
or `flake.nix` pulls in Python any more. Drop those claims.

Describe instead where the two calendar views actually come from: the
"Nächste Veranstaltungen" table and the calendar page are rendered in the
browser from `/calendars/all.ics`, which is published next to the site and is
not built from this repository. That also replaces the old advice to preview
them via `./build.sh` plus a local HTTP server, which no longer helps.

Without that file the two of them stay empty. Hugo serves everything below
`static/` from the root of the site, so it can simply be put there, and the
command that downloads it is worth writing down. Checked from an empty state:
afterwards the file is served under `/calendars/all.ics` by `hugo serve` and the
table on the start page fills with the upcoming events. It is a copy of
published data and has no business in the repository, so ignore it.

Mention the per-section `.ics` feeds Hugo does generate, so the ICS templates
under `layouts/` are not mistaken for the source of `all.ics`.

Fixes: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-22 21:22:02 +02:00
9be6287a68 Delete .forgejo/workflows/test.yaml 2026-08-22 15:22:52 +02:00
df7add59ee Add .forgejo/workflows/test.yaml 2026-08-21 22:32:15 +02:00
864132202d Merge pull request 'Update .forgejo/workflows/deploy.yaml' (#55) from staging into production
Reviewed-on: cccb-website-team/www#55
2026-08-21 21:37:37 +02:00
e93b399854 Update .forgejo/workflows/deploy.yaml
Signed-off-by: xengi <cccb-git@xengi.de>
2026-08-21 21:36:22 +02:00
d0420d865d Merge pull request 'Now with external ICS files from dav.berlin.ccc.de' (#54) from staging into production
Reviewed-on: cccb-website-team/www#54
2026-08-12 10:46:01 +02:00
f6b5413d19 Delete .forgejo/workflows/miau.yaml 2026-08-12 02:26:23 +02:00
2696f0af4e python is not needed anymore
Signed-off-by: xengi <cccb-git@xengi.de>
2026-08-12 02:06:53 +02:00
b9739ca58c Merge branch 'production' into staging 2026-08-12 02:05:42 +02:00
facc7d15bf Merge pull request 'switch to ics files; make calendars work; fix some minor issues' (#53) from caldav into staging
Reviewed-on: cccb-website-team/www#53
2026-08-12 02:03:34 +02:00
c28f04c6e8
switch to ics files; make calendars work; fix some minor issues 2026-08-12 02:01:58 +02:00
1dcc6c473b Merge pull request 'staging' (#52) from staging into production
Reviewed-on: cccb-website-team/www#52
2026-08-11 23:32:50 +02:00
5368e75240 Add .forgejo/workflows/miau.yaml 2026-08-11 23:31:40 +02:00
b1ff3727c8 Update .forgejo/workflows/deploy.yaml
Signed-off-by: xengi <cccb-git@xengi.de>
2026-08-11 23:21:32 +02:00
4e2c358668 Merge pull request 'fu github pipelines' (#51) from staging into production
Reviewed-on: cccb-website-team/www#51
2026-08-11 23:15:28 +02:00
e690426b3f Merge branch 'production' into staging 2026-08-11 23:15:14 +02:00
7689abaf3e Update .forgejo/workflows/deploy.yaml 2026-08-11 23:13:51 +02:00
bafe2bcb81 Update .forgejo/workflows/deploy.yaml 2026-08-11 23:05:26 +02:00
bac896e60f Update .forgejo/workflows/deploy.yaml 2026-08-11 22:57:01 +02:00
87e1230301
unfix pipeline 2026-08-11 22:45:17 +02:00
f839b1b01a Update .forgejo/workflows/deploy.yaml
Signed-off-by: xengi <cccb-git@xengi.de>
2026-08-11 22:37:45 +02:00
2ba5b8cfa8
fix pipeline 2026-08-11 22:29:30 +02:00
96919a7188
fix pipeline 2026-08-11 22:20:30 +02:00
dfae28d575 Update .forgejo/workflows/deploy.yaml 2026-08-11 22:10:47 +02:00
c2a2f6e160 Update .forgejo/workflows/deploy.yaml 2026-08-11 22:07:01 +02:00
d3fd2ec8a7 Update .forgejo/workflows/deploy.yaml 2026-08-11 22:05:43 +02:00
c18cce53f8 Update .forgejo/workflows/deploy.yaml 2026-08-11 22:05:13 +02:00
9cca35810f Merge pull request 'staging' (#50) from staging into production
Reviewed-on: cccb-website-team/www#50
2026-08-11 21:42:16 +02:00
5f603574e3 :nerd: minor spelling mistake ❇️
Signed-off-by: aprl <aprl@noreply.git.berlin.ccc.de>
2026-08-11 21:41:01 +02:00
3afd801344 Merge pull request '2026_08_23_Aktionstag_gegen_Ueberwachung' (#49) from aprl/www:2026_08_23_Aktionstag_gegen_Ueberwachung into staging
Reviewed-on: cccb-website-team/www#49
2026-08-11 21:39:38 +02:00
April John
9b8fbc0106 Text natürlicher formuliert, Gedankenstriche entfernt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 21:34:13 +02:00
April John
8b4f6d3c95 Aktionstag gegen Überwachung 2026-08-23: Veranstaltung + Ankündigungspost
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 21:16:19 +02:00
43ae6bc869 Update .forgejo/workflows/deploy.yaml 2026-07-22 22:11:21 +02:00
c67560bd94 Merge pull request 'staging' (#48) from staging into production
Reviewed-on: cccb-website-team/www#48
2026-07-05 00:38:00 +02:00
42f999443f Merge pull request 'diday-end-recurrence' (#47) from hauke/www:diday-end-recurrence into staging
Reviewed-on: cccb-website-team/www#47
2026-07-05 00:18:48 +02:00
ba76280a3f Merge pull request 'staging' (#46) from staging into production
Reviewed-on: cccb-website-team/www#46
2026-07-04 23:40:44 +02:00
e2a5fce03b Merge pull request 'fix typo' (#45) from 2026_07_05_DiDay_im_Club into staging
Reviewed-on: cccb-website-team/www#45
2026-07-04 23:37:47 +02:00
Bruno Ranieri
0318a0496a fix typo 2026-07-04 23:37:19 +02:00
aaf48ee2f7 Merge pull request 'remove did-post and update did-page' (#44) from 2026_07_05_DiDay_im_Club into staging
Reviewed-on: cccb-website-team/www#44
2026-07-04 23:34:43 +02:00
Bruno Ranieri
bf1de4633b remove did-post and update did-page 2026-07-04 23:32:57 +02:00
11893e9cb1 Merge pull request 'staging' (#43) from staging into production
Reviewed-on: cccb-website-team/www#43
2026-07-04 22:27:08 +02:00
37 changed files with 10958 additions and 744 deletions

View file

@ -9,11 +9,6 @@ charset = utf-8
end_of_line = lf
insert_final_newline = true
[*.py]
indent_style = space
indent_size = 4
[*.yaml]
[*.{js,yaml}]
indent_style = space
indent_size = 2

View file

@ -1,4 +1,10 @@
name: deploy blog
# Create secrets with:
# KNOWN_HOST=$(ssh-keyscan -H www.berlin.ccc.de | grep ssh-ed25519 | base64 -w0)
# SSH_PRIVATE_KEY_PRODUCTION=$(agenix -d id_ed25519_www-production.age | base64 -w0)
# SSH_PRIVATE_KEY_STAGING=$(agenix -d id_ed25519_www-staging.age | base64 -w0)
on:
workflow_dispatch:
schedule:
@ -10,46 +16,44 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
runs-on: alpine-latest
steps:
- name: Install dependencies
run: apk --no-cache add hugo python3 py3-pip git openssh-client rsync
run: apk --no-cache add hugo git openssh-client rsync
- name: Check versions
run: |
cat /etc/os-release
git version
hugo version
python --version
rsync --version
ssh -V
- name: Set envionment vars
run: echo "GIT_BRANCH=${{ forgejo.event_name == 'schedule' && 'production' || forgejo.ref_name }}" >> "$FORGEJO_ENV"
- name: Checkout repository
run: |
git clone -b ${{ forgejo.ref_name }} --recursive https://git.berlin.ccc.de/cccb-website-team/www.git .
git clone -b $GIT_BRANCH --recursive https://git.berlin.ccc.de/cccb-website-team/www.git .
git status
- name: Install Python depenndencies
run: python -m pip install -r requirements.txt --break-system-packages
- name: Render site
run: ./build.sh
- name: Setup SSH
env:
SSH_PRIVATE_KEY: ${{ forgejo.ref_name == 'production' && secrets.SSH_PRIVATE_KEY_PRODUCTION || secrets.SSH_PRIVATE_KEY_STAGING }}
run: |
mkdir -p ~/.ssh
printf "%s" "${{ secrets.KNOWN_HOSTS }}" | base64 -d > ~/.ssh/known_hosts
printf "%s" "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
printf "%s" "${{ env.GIT_BRANCH == 'production' && secrets.SSH_PRIVATE_KEY_PRODUCTION || secrets.SSH_PRIVATE_KEY_STAGING }}" | base64 -d > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keygen -f ~/.ssh/id_ed25519 -y > ~/.ssh/id_ed25519.pub
cat ~/.ssh/id_ed25519.pub
- name: Rsync rendered site
env:
DEPLOY_DIR: ${{ forgejo.ref_name == 'production' && '/srv/http/www/' || '/srv/http/www-staging/' }}
# TODO: add --delete
run: rsync -var -e 'ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes' ./public/ deploy@www.berlin.ccc.de:$DEPLOY_DIR
run: rsync -var -e 'ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes' ./public/ deploy@www.berlin.ccc.de:${{ env.GIT_BRANCH == 'production' && '/srv/http/www/' || '/srv/http/www-staging/' }}
- name: Cleanup
if: ${{ always() }}
run: rm -rf ~/.ssh

1
.gitattributes vendored
View file

@ -1 +0,0 @@
*.ics text eol=crlf

138
.gitignore vendored
View file

@ -1,8 +1,9 @@
static/all.ics
# Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo,pycharm+all,vim,direnv
# Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos,hugo,pycharm+all,vim,direnv
### direnv ###
.direnv
.envrc
*.swp
# Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo
# Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos,hugo
### Hugo ###
# Generated files by hugo
@ -41,8 +42,7 @@ hugo.linux
.LSOverride
# Icon must end with two \r
Icon
Icon
# Thumbnails
._*
@ -67,6 +67,114 @@ Temporary Items
# iCloud generated files
*.icloud
### PyCharm+all ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### PyCharm+all Patch ###
# Ignore everything but code style settings and run configurations
# that are supposed to be shared within teams.
.idea/*
!.idea/codeStyles
!.idea/runConfigurations
### Vim ###
# Swap
[._]*.s[a-v][a-z]
!*.svg # comment out if you don't need vector files
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]
# Session
Session.vim
Sessionx.vim
# Temporary
.netrwhist
# Auto-generated tag files
tags
# Persistent undo
[._]*.un~
### Windows ###
# Windows thumbnail cache files
Thumbs.db
@ -93,17 +201,9 @@ $RECYCLE.BIN/
# Windows shortcuts
*.lnk
# End of https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo
# End of https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo,pycharm+all,vim,direnv
# NixOS:
.envrc
shell.nix
.direnv
# Python
.venv
# murmeldin
overview.md
/result
### CCCB ###
# The calendar the site reads its events from is published next to the site and
# only downloaded here to look at the site locally, see the README.
/static/calendars/

View file

@ -1 +0,0 @@
3.11

View file

@ -27,26 +27,36 @@ This is the website of the CCCB.
5. Point your browser to: <http://localhost:1313/>
Every change you make on the project will be reflected in your browser as long
as `hugo serve` is running.
Every change you make on the project will be reflected in your browser as long as `hugo serve` is running.
The *"Nächste Veranstaltungen"* table on the home page is generated by
post-processing in `./build.sh`, not by Hugo, so it is **not** visible under
`hugo serve`. To preview the fully-built site (including the home-page
calendar), or to ready the site for upload, run:
The *"Nächste Veranstaltungen"* table on the home page and the calendar under `/verein/calendar/` are rendered in the
browser by `assets/js/upcoming.js` and `assets/js/calendar.js`. Both fetch `/calendars/all.ics`, which is **not**
generated by Hugo and is not part of this repo — it is published separately on the web server. Without it both tables
stay empty. Download the published calendar into `static/`, which Hugo serves under the same path, and they work
locally, under `hugo serve` as well as in a built site:
```shell
mkdir -p static/calendars
curl -o static/calendars/all.ics https://berlin.ccc.de/calendars/all.ics
```
The file is only there to look at the site, it is not checked in. Download it again whenever you want the events that
are currently published.
Hugo does generate an `.ics` feed per section (for example `/veranstaltungen/index.ics`) from the `dtstart`, `dtend`
and `rrule` front matter of the pages in `content/veranstaltungen/`, using the `.ics` templates under `layouts/`.
The site must not make the browser load anything from an external server, so third party JavaScript is checked into
`assets/js/vendor/` and bundled in by Hugo instead of being pulled from a CDN. See the README there before updating it.
To build the site for upload, run:
```shell
./build.sh
python3 -m http.server -d public 1313
```
`build.sh` generates `all.ics` and replaces the `CALENDAR` placeholder in
`index.html` with the upcoming-events table. It depends on Python with the
`icalendar`, `python-dateutil`, and `pytz` packages, plus a `de_DE.UTF-8`
locale (used to format weekday names). Inside `nix develop` these are
provided automatically.
To build with *nix*: `nix build '.?submodules=1#production-content'`
This deletes `public/` and runs `hugo` with the parameters from `.hugo-params`. To build with *nix* instead:
`nix build '.?submodules=1#production-content'`
## Making a change
@ -55,18 +65,21 @@ To build with *nix*: `nix build '.?submodules=1#production-content'`
3. Commit (and push) your change.
4. ~~GitHub Actions is running the release workflow.~~
- If successful, check [Staging Website](https://staging.berlin.ccc.de/) if change is correct.
5. Create merge request to merge changes from `staging` to `production` branch. Ask somebody to check merge request or if small change, merge yourself.
5. Create a merge request to merge changes from `staging` to `production` branch. Ask somebody to check merge request or
if small change, merge yourself.
6. ~~GitHub Actions is running the release workflow.~~
- If successfull, check [Website](https://berlin.ccc.de/) if change is correct.
7. Profit!
## Nix stuff
- After entering the shell with `nix develop`, hugo is available and `hugo serve` should work
- Python including required packages will be available, so the `build.sh` should work without a venv
- You can build the staging and production builds with `nix build .#staging-content` and `nix build .#production-content`
- Do not update the nixpkgs branch - 25.05 contains a newer hugo version that is incompatible with the theme (last checked June 2025)
- After entering the shell with `nix develop`, hugo is available and `hugo serve` and `./build.sh` should work
- You can build the staging and production builds with `nix build .#staging-content` and
`nix build .#production-content`
- Do not update the nixpkgs branch - 25.05 contains a newer hugo version that is incompatible with the theme (last
checked June 2025)
---
Made with ❤️ and [Hugo](https://gohugo.io).

View file

@ -107,6 +107,10 @@
font-size: 0.9em;
margin-bottom: 5px;
}
.event-description {
/* Descriptions carry their own line breaks, keep them. */
white-space: pre-line;
}
.no-events {
font-style: italic;
color: var(--color-text-secondary);

36
assets/css/upcoming.css Normal file
View file

@ -0,0 +1,36 @@
/* The rows of the table are added by JavaScript and end up as direct children
of the table, so the table styling of the theme, which addresses tbody, does
not reach them. Separate the date from the name of the event ourselves, and
keep the date on one line, it is one piece of information and reads badly
broken after the weekday. */
#upcoming td:first-child {
padding-inline-end: 1em;
white-space: nowrap;
}
/* The table stands in a prose column, which the theme limits to 65 characters
so that running text stays readable. A date and the name of an event next to
each other do not fit into that, so the names were wrapped over several
lines. Lift the limit off the column and put it back on everything in it
except the table, which leaves the table room to grow while the text around
it keeps its width. */
section.prose:has(> #upcoming) {
max-width: none;
}
section.prose:has(> #upcoming) > :not(#upcoming) {
max-width: 65ch;
margin-inline: auto;
}
/* The theme lays a table out as a block, "table { display: block; overflow:
auto }", so that a wide one can be scrolled sideways. A block fills its
parent instead of shrinking to its content the way a table does, so across
the whole width of the page the entries would stay at its left edge.
fit-content asks for the width of the content, which the automatic margins
then centre, and it never exceeds the column, so a display too narrow for
the table still wraps the names instead of overflowing. */
#upcoming {
width: fit-content;
margin-inline: auto;
}

View file

@ -1,451 +1,406 @@
document.addEventListener('DOMContentLoaded', function() {
(function(){
let events = [];
let eventsByDate = {};
import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js";
// Funktion zum Parsen der ICS-Datei
function parseICS(icsText) {
let events = [];
let lines = icsText.split(/\r?\n/);
let event = null;
lines.forEach(line => {
if (line.startsWith("BEGIN:VEVENT")) {
event = {};
} else if (line.startsWith("END:VEVENT")) {
if (event) events.push(event);
event = null;
} else if (event) {
let colonIndex = line.indexOf(":");
if (colonIndex > -1) {
let key = line.substring(0, colonIndex);
let value = line.substring(colonIndex + 1);
// Handle properties with parameters (like TZID)
const baseKey = key.split(";")[0];
if (baseKey === "DTSTART") {
event.start = value;
event.startParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null;
} else if (baseKey === "DTEND") {
event.end = value;
event.endParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null;
} else if (baseKey === "SUMMARY") {
event.summary = value;
} else if (baseKey === "DESCRIPTION") {
event.description = value;
} else if (baseKey === "RRULE") {
event.rrule = value;
}
}
}
});
return events;
}
// The club is in Berlin, so the calendar shows Berlin days and Berlin times,
// no matter which time zone the browser of the visitor is set to.
const timeZone = "Europe/Berlin";
// Hilfsfunktion: Parst einen ICS-Datum-String ins Format "YYYY-MM-DD"
function parseDateString(icsDateStr) {
// Handle different date formats
if (!icsDateStr) return null;
// For basic date format: YYYYMMDD
if (icsDateStr.length === 8) {
let year = icsDateStr.substring(0, 4);
let month = icsDateStr.substring(4, 6);
let day = icsDateStr.substring(6, 8);
return `${year}-${month}-${day}`;
}
// For datetime formats: YYYYMMDDTHHmmssZ or YYYYMMDDTHHmmss
else if (icsDateStr.includes("T")) {
let year = icsDateStr.substring(0, 4);
let month = icsDateStr.substring(4, 6);
let day = icsDateStr.substring(6, 8);
return `${year}-${month}-${day}`;
}
return null;
}
const monthNames = [
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
];
// Extract date components from different date formats
function getDateComponents(icsDateStr) {
if (!icsDateStr) return null;
// Basic handling - extract YYYY, MM, DD regardless of format
const year = parseInt(icsDateStr.substring(0, 4));
const month = parseInt(icsDateStr.substring(4, 6)) - 1; // 0-based months
const day = parseInt(icsDateStr.substring(6, 8));
return { year, month, day };
}
function expandRecurringEvents(event, year, month) {
if (!event.rrule) return [event];
const rruleStr = event.rrule;
// Get start date components
const startComponents = getDateComponents(event.start);
if (!startComponents) return [event];
const startDate = new Date(
startComponents.year,
startComponents.month,
startComponents.day
);
const rangeStart = new Date(year, month, 1);
const rangeEnd = new Date(year, month + 1, 0);
const expandedEvents = [];
if (rruleStr.includes("FREQ=WEEKLY") && rruleStr.includes("BYDAY")) {
const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/);
if (bydayMatch) {
const dayCode = bydayMatch[1];
const dayMap = {
'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0
};
const targetDay = dayMap[dayCode];
if (targetDay !== undefined) {
// Create events for each matching day in the month
let day = 1;
while (day <= rangeEnd.getDate()) {
const testDate = new Date(year, month, day);
if (testDate.getDay() === targetDay && testDate >= startDate) {
const newEvent = {...event};
const eventDate = formatDateForICS(testDate);
// Preserve time portion from original event
const timePart = event.start.includes('T') ?
event.start.substring(event.start.indexOf('T')) : '';
const endTimePart = event.end.includes('T') ?
event.end.substring(event.end.indexOf('T')) : '';
newEvent.start = eventDate + timePart;
newEvent.end = eventDate + endTimePart;
expandedEvents.push(newEvent);
}
day++;
}
}
}
}
else if (rruleStr.includes("FREQ=MONTHLY") && rruleStr.includes("BYDAY")) {
const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/);
if (bydayMatch) {
const intervalMatch = rruleStr.match(/INTERVAL=(\d+)/);
const interval = intervalMatch ? parseInt(intervalMatch[1]) : 1;
const monthsFromStart = (year - startDate.getFullYear()) * 12 + (month - startDate.getMonth());
if (monthsFromStart < 0 || monthsFromStart % interval !== 0) {
return [];
}
const bydays = bydayMatch[1].split(',');
const dayMap = {
'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0
};
bydays.forEach(byday => {
const occurrence = parseInt(byday) || 1;
const dayCode = byday.slice(-2);
const dayIndex = dayMap[dayCode];
let day = 1;
let count = 0;
while (day <= rangeEnd.getDate()) {
const testDate = new Date(year, month, day);
if (testDate.getDay() === dayIndex) {
count++;
if (count === occurrence || (occurrence < 0 && day > rangeEnd.getDate() + occurrence * 7)) {
const newEvent = {...event};
const eventDate = new Date(year, month, day);
// Preserve time portion from original event
const timePart = event.start.includes('T') ?
event.start.substring(event.start.indexOf('T')) : '';
const endTimePart = event.end.includes('T') ?
event.end.substring(event.end.indexOf('T')) : '';
newEvent.start = formatDateForICS(eventDate) + timePart;
newEvent.end = formatDateForICS(eventDate) + endTimePart;
expandedEvents.push(newEvent);
}
}
day++;
}
});
}
}
return expandedEvents.length > 0 ? expandedEvents : [event];
}
// Kalender initialisieren
let currentYear, currentMonth;
const currentMonthElem = document.getElementById("current-month");
const calendarBody = document.getElementById("calendar-body");
const eventPanel = document.getElementById("event-panel");
const eventDateElem = document.getElementById("event-date");
const eventDetailsElem = document.getElementById("event-details");
document.getElementById("prev-month").addEventListener("click", function(){
currentMonth--;
if (currentMonth < 0) {
currentMonth = 11;
currentYear--;
}
updateEventsForMonth(currentYear, currentMonth);
const dayKeyFormat = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const timeOfDayFormat = new Intl.DateTimeFormat("de-DE", {
timeZone,
hour: "2-digit",
minute: "2-digit",
});
let calendar = null;
let eventsByDate = {};
let currentYear;
let currentMonth;
let currentMonthElem;
let calendarBody;
let eventPanel;
let eventDateElem;
let eventDetailsElem;
/**
* The day a point in time falls on in Berlin.
*
* @param {Date} date The point in time
* @returns {string} The day as "YYYY-MM-DD"
*/
function dayKey(date) {
const parts = {};
for (const part of dayKeyFormat.formatToParts(date)) {
parts[part.type] = part.value;
}
return `${parts.year}-${parts.month}-${parts.day}`;
}
/**
* The day an occurrence time falls on in Berlin.
*
* A date has neither a time nor a zone, its digits are the day itself.
* toJSDate() would read them as midnight in the zone of the browser, which
* far enough east or west of Berlin lands on the day before or after.
*
* @param {ICAL.Time} time The time
* @returns {string} The day as "YYYY-MM-DD"
*/
function timeDayKey(time) {
if (time.isDate) {
const month = String(time.month).padStart(2, "0");
const day = String(time.day).padStart(2, "0");
return `${time.year}-${month}-${day}`;
}
return dayKey(time.toJSDate());
}
/**
* The days an event covers, so that an event running over several days is
* shown on each of them and not only on the day it starts.
*
* @param {ICAL.Time} start Start of the event
* @param {ICAL.Time} end End of the event
* @returns {string[]} The days as "YYYY-MM-DD"
*/
function daysCovered(start, end) {
// The end is not part of the event: one ending at midnight belongs to the day
// before, and an all day event ends on the day before its DTEND.
const last = end.clone();
if (end.isDate) {
last.adjust(-1, 0, 0, 0);
} else {
last.adjust(0, 0, 0, -1);
}
const lastKey = timeDayKey(last);
const days = [];
let key = timeDayKey(start);
// The guard keeps a broken event from looping forever, a year of dots on the
// same event is well past the point where the calendar is still useful.
while (days.length <= 366) {
days.push(key);
// The keys sort as the days do, so this also stops an event whose end lies
// before its start after the day it starts on.
if (key >= lastKey) {
break;
}
// Step to noon UTC of the next day, which is inside the same Berlin day
// whether daylight saving time is in effect or not.
const [year, month, day] = key.split("-").map(Number);
key = dayKey(new Date(Date.UTC(year, month - 1, day + 1, 12)));
}
return days;
}
/**
* Reduce one occurrence of an event to what the calendar displays.
*
* @param {ICAL.Event} event The event the occurrence belongs to
* @param {ICAL.Time} startDate Start of this occurrence
* @param {ICAL.Time} endDate End of this occurrence
* @returns {{summary: string, description: string, url: string, start: Date, end: Date, allDay: boolean, days: string[]}}
*/
function toOccurrence(event, startDate, endDate) {
return {
summary: event.summary ?? "",
description: event.description ?? "",
url: eventUrl(event),
start: startDate.toJSDate(),
end: endDate.toJSDate(),
allDay: startDate.isDate,
// Taken from the ICAL times, which still know whether they name a day or a
// point in time; the JS dates above no longer do.
days: daysCovered(startDate, endDate),
};
}
/**
* Collect everything that takes place in the given month, keyed by day.
*
* @param {number} year The year of the month
* @param {number} month The month, January is 0
* @returns {Object<string, Array>} The occurrences per "YYYY-MM-DD"
*/
function occurrencesOfMonth(year, month) {
const byDate = {};
if (!calendar) {
return byDate;
}
// These bounds only limit how far the recurrences have to be expanded, which
// day an occurrence ends up on is decided by its Berlin day below. They are
// deliberately generous so that no occurrence is cut off at the edge of the
// month by a time zone difference.
const from = new Date(year, month, 1);
const to = new Date(year, month + 1, 1);
from.setDate(from.getDate() - 2);
to.setDate(to.getDate() + 2);
const monthPrefix = `${year}-${String(month + 1).padStart(2, "0")}-`;
const add = (occurrence) => {
for (const key of occurrence.days) {
if (!key.startsWith(monthPrefix)) {
continue;
}
if (!byDate[key]) {
byDate[key] = [];
}
byDate[key].push(occurrence);
}
};
for (const { event, startDate, endDate } of occurrencesBetween(calendar, from, to)) {
add(toOccurrence(event, startDate, endDate));
}
for (const occurrences of Object.values(byDate)) {
occurrences.sort((a, b) => {
// An all day event has no time of day to sort by, the JS date of its
// start is midnight in the zone of the browser. Put it first instead.
if (a.allDay !== b.allDay) {
return a.allDay ? -1 : 1;
}
return a.start - b.start;
});
}
return byDate;
}
function updateEventsForMonth(year, month) {
eventsByDate = occurrencesOfMonth(year, month);
renderCalendar(year, month);
}
function renderCalendar(year, month) {
// Setze die Monatsbeschriftung (in Deutsch)
currentMonthElem.textContent = monthNames[month] + " " + year;
calendarBody.innerHTML = "";
let firstDay = new Date(year, month, 1);
let firstDayIndex = (firstDay.getDay() + 6) % 7; // Montag = 0, Dienstag = 1, etc.
let daysInMonth = new Date(year, month + 1, 0).getDate();
let row = document.createElement("tr");
// Leere Zellen vor dem 1. Tag
for (let i = 0; i < firstDayIndex; i++) {
let cell = document.createElement("td");
row.appendChild(cell);
}
// Tage hinzufügen
for (let day = 1; day <= daysInMonth; day++) {
if (row.children.length === 7) {
calendarBody.appendChild(row);
row = document.createElement("tr");
}
let cell = document.createElement("td");
cell.innerHTML = "<strong>" + day + "</strong>";
let dayStr = day < 10 ? "0" + day : day;
let monthStr = (month + 1) < 10 ? "0" + (month + 1) : (month + 1);
let dateKey = year + "-" + monthStr + "-" + dayStr;
if (eventsByDate[dateKey]) {
let dotsContainer = document.createElement("div");
dotsContainer.className = "event-dots-container";
cell.classList.add("has-event");
// Gruppe Events nach Typ
const events = eventsByDate[dateKey];
const hasMembersOnly = events.some(e => e.summary.toLowerCase().includes("members only"));
const hasSubbotnik = events.some(e => e.summary.toLowerCase().includes("subbotnik"));
const hasBastelabend = events.some(e => e.summary.toLowerCase().includes("bastelabend"));
const hasSpieleabend = events.some(e => e.summary.toLowerCase().includes("spieleabend"));
const hasRegular = events.some(e => {
const title = e.summary.toLowerCase();
return !title.includes("members only") &&
!title.includes("subbotnik") &&
!title.includes("bastelabend") &&
!title.includes("spieleabend");
});
// Füge Dots entsprechend der Event-Typen hinzu
if (hasMembersOnly) {
let dot = document.createElement("div");
dot.className = "event-dot event-dot-red";
dotsContainer.appendChild(dot);
}
if (hasSubbotnik || hasBastelabend || hasSpieleabend) {
let dot = document.createElement("div");
dot.className = "event-dot event-dot-orange";
dotsContainer.appendChild(dot);
}
if (hasRegular) {
let dot = document.createElement("div");
dot.className = "event-dot event-dot-greenyellow";
dotsContainer.appendChild(dot);
}
cell.appendChild(dotsContainer);
cell.dataset.dateKey = dateKey;
cell.addEventListener("click", function() {
// Clear previous selections
document.querySelectorAll('.selected-day').forEach(el => {
el.classList.remove('selected-day');
});
document.getElementById("next-month").addEventListener("click", function(){
currentMonth++;
if (currentMonth > 11) {
currentMonth = 0;
currentYear++;
}
updateEventsForMonth(currentYear, currentMonth);
});
cell.classList.add('selected-day');
showEventDetails(dateKey);
});
}
row.appendChild(cell);
}
// Falls die letzte Zeile nicht komplett ist
while (row.children.length < 7) {
let cell = document.createElement("td");
row.appendChild(cell);
}
calendarBody.appendChild(row);
}
/**
* Build the entry of a single event in the detail panel.
*
* @param {Object} occurrence The occurrence to show
* @returns {HTMLElement} The entry
*/
function renderEventItem(occurrence) {
const item = document.createElement("div");
item.className = "event-item";
const title = document.createElement("div");
title.className = "event-title";
if (occurrence.url) {
const link = document.createElement("a");
link.href = occurrence.url;
link.textContent = occurrence.summary;
title.appendChild(link);
} else {
title.textContent = occurrence.summary;
}
item.appendChild(title);
const time = document.createElement("div");
time.className = "event-time";
time.textContent = formatTimeRange(occurrence);
item.appendChild(time);
if (occurrence.description) {
const description = document.createElement("div");
description.className = "event-description";
description.textContent = occurrence.description;
item.appendChild(description);
}
return item;
}
function showEventDetails(dateKey) {
const occurrences = eventsByDate[dateKey];
eventDateElem.textContent = formatDate(dateKey);
eventDetailsElem.innerHTML = "";
if (occurrences && occurrences.length > 0) {
for (const occurrence of occurrences) {
eventDetailsElem.appendChild(renderEventItem(occurrence));
}
} else {
let noEvents = document.createElement("div");
noEvents.className = "no-events";
noEvents.textContent = "Keine Veranstaltungen an diesem Tag.";
eventDetailsElem.appendChild(noEvents);
}
eventPanel.style.display = "block";
}
function formatDate(dateStr) {
// Convert YYYY-MM-DD to DD.MM.YYYY
const parts = dateStr.split("-");
return `${parts[2]}.${parts[1]}.${parts[0]}`;
}
/**
* Describe when an occurrence takes place, in Berlin time.
*
* @param {Object} occurrence The occurrence to describe
* @returns {string} The description
*/
function formatTimeRange(occurrence) {
if (occurrence.allDay) {
return "Ganztägig";
}
const start = timeOfDayFormat.format(occurrence.start);
const end = timeOfDayFormat.format(occurrence.end);
return `Beginn: ${start}, Ende: ${end}`;
}
document.addEventListener("DOMContentLoaded", function() {
currentMonthElem = document.getElementById("current-month");
calendarBody = document.getElementById("calendar-body");
eventPanel = document.getElementById("event-panel");
eventDateElem = document.getElementById("event-date");
eventDetailsElem = document.getElementById("event-details");
document.getElementById("prev-month").addEventListener("click", function() {
currentMonth--;
if (currentMonth < 0) {
currentMonth = 11;
currentYear--;
}
updateEventsForMonth(currentYear, currentMonth);
});
document.getElementById("next-month").addEventListener("click", function() {
currentMonth++;
if (currentMonth > 11) {
currentMonth = 0;
currentYear++;
}
updateEventsForMonth(currentYear, currentMonth);
});
// Show the grid of the current month right away, the events are filled in
// once the calendar has been loaded.
const today = new Date();
currentYear = today.getFullYear();
currentMonth = today.getMonth();
updateEventsForMonth(currentYear, currentMonth);
function updateEventsForMonth(year, month) {
// Clear existing events for this month view
eventsByDate = {};
// Process each event, expanding recurring ones
events.forEach(ev => {
if (ev.rrule) {
// For recurring events, expand them for current month
const expandedEvents = expandRecurringEvents(ev, year, month);
expandedEvents.forEach(expandedEv => {
let dateKey = parseDateString(expandedEv.start);
if (dateKey) {
if (!eventsByDate[dateKey]) {
eventsByDate[dateKey] = [];
}
eventsByDate[dateKey].push(expandedEv);
}
});
} else {
// For regular events, check if they fall in current month
let dateKey = parseDateString(ev.start);
if (dateKey) {
// Check if this event belongs to current month view
const eventYear = parseInt(dateKey.split('-')[0]);
const eventMonth = parseInt(dateKey.split('-')[1]) - 1;
if (eventYear === year && eventMonth === month) {
if (!eventsByDate[dateKey]) {
eventsByDate[dateKey] = [];
}
eventsByDate[dateKey].push(ev);
}
}
}
});
renderCalendar(year, month);
}
function renderCalendar(year, month) {
// Setze die Monatsbeschriftung (in Deutsch)
const monthNames = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
currentMonthElem.textContent = monthNames[month] + " " + year;
calendarBody.innerHTML = "";
let firstDay = new Date(year, month, 1);
let firstDayIndex = (firstDay.getDay() + 6) % 7; // Montag = 0, Dienstag = 1, etc.
let daysInMonth = new Date(year, month + 1, 0).getDate();
let row = document.createElement("tr");
// Leere Zellen vor dem 1. Tag
for (let i = 0; i < firstDayIndex; i++){
let cell = document.createElement("td");
row.appendChild(cell);
}
// Tage hinzufügen
for (let day = 1; day <= daysInMonth; day++){
if (row.children.length === 7) {
calendarBody.appendChild(row);
row = document.createElement("tr");
}
let cell = document.createElement("td");
cell.innerHTML = "<strong>" + day + "</strong>";
let dayStr = day < 10 ? "0" + day : day;
let monthStr = (month + 1) < 10 ? "0" + (month + 1) : (month + 1);
let dateKey = year + "-" + monthStr + "-" + dayStr;
if (eventsByDate[dateKey]) {
let dotsContainer = document.createElement("div");
dotsContainer.className = "event-dots-container";
cell.classList.add("has-event");
// Gruppe Events nach Typ
const events = eventsByDate[dateKey];
const hasMembersOnly = events.some(e => e.summary.toLowerCase().includes("members only"));
const hasSubbotnik = events.some(e => e.summary.toLowerCase().includes("subbotnik"));
const hasBastelabend = events.some(e => e.summary.toLowerCase().includes("bastelabend"));
const hasSpieleabend = events.some(e => e.summary.toLowerCase().includes("spieleabend"));
const hasRegular = events.some(e => {
const title = e.summary.toLowerCase();
return !title.includes("members only") &&
!title.includes("subbotnik") &&
!title.includes("bastelabend") &&
!title.includes("spieleabend");
});
// Füge Dots entsprechend der Event-Typen hinzu
if (hasMembersOnly) {
let dot = document.createElement("div");
dot.className = "event-dot event-dot-red";
dotsContainer.appendChild(dot);
}
if (hasSubbotnik || hasBastelabend || hasSpieleabend) {
let dot = document.createElement("div");
dot.className = "event-dot event-dot-orange";
dotsContainer.appendChild(dot);
}
if (hasRegular) {
let dot = document.createElement("div");
dot.className = "event-dot event-dot-greenyellow";
dotsContainer.appendChild(dot);
}
cell.appendChild(dotsContainer);
cell.dataset.dateKey = dateKey;
cell.addEventListener("click", function() {
// Clear previous selections
document.querySelectorAll('.selected-day').forEach(el => {
el.classList.remove('selected-day');
});
cell.classList.add('selected-day');
showEventDetails(dateKey);
});
}
row.appendChild(cell);
}
// Falls die letzte Zeile nicht komplett ist
while (row.children.length < 7) {
let cell = document.createElement("td");
row.appendChild(cell);
}
calendarBody.appendChild(row);
}
function createEventLink(eventTitle) {
if (eventTitle.startsWith("Datengarten")) {
// Extract the number after "Datengarten "
const match = eventTitle.match(/Datengarten\s+(\d+)/i);
if (match && match[1]) {
return `https://berlin.ccc.de/datengarten/${match[1]}/`;
}
}
// For other titles, convert to lowercase and use as path
const slug = eventTitle.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
return `https://berlin.ccc.de/page/${slug}/`;
}
function showEventDetails(dateKey) {
const events = eventsByDate[dateKey];
eventDateElem.textContent = formatDate(dateKey);
eventDetailsElem.innerHTML = "";
if (events && events.length > 0) {
events.forEach(ev => {
let eventItem = document.createElement("div");
eventItem.className = "event-item";
let eventTitle = document.createElement("div");
eventTitle.className = "event-title";
// Create a link for the event title
let titleLink = document.createElement("h");
titleLink.textContent = ev.summary;
titleLink.target = "_blank";
eventTitle.appendChild(titleLink);
eventItem.appendChild(eventTitle);
let eventTime = document.createElement("div");
eventTime.className = "event-time";
eventTime.textContent = `Start: ${formatTime(ev.start)}, End: ${formatTime(ev.end)}`;
eventItem.appendChild(eventTime);
if (ev.description) {
let eventDescription = document.createElement("div");
eventDescription.className = "event-description";
// Check if the description is a URL and make it a clickable link
if (ev.description.trim().startsWith('http')) {
let linkElement = document.createElement("a");
linkElement.href = ev.description.trim();
linkElement.textContent = ev.description.trim();
linkElement.target = "_blank";
eventDescription.innerHTML = '';
eventDescription.appendChild(linkElement);
} else {
eventDescription.textContent = ev.description;
}
eventItem.appendChild(eventDescription);
}
eventDetailsElem.appendChild(eventItem);
});
} else {
let noEvents = document.createElement("div");
noEvents.className = "no-events";
noEvents.textContent = "Keine Veranstaltungen an diesem Tag.";
eventDetailsElem.appendChild(noEvents);
}
eventPanel.style.display = "block";
}
function formatDate(dateStr) {
// Convert YYYY-MM-DD to DD.MM.YYYY
const parts = dateStr.split("-");
return `${parts[2]}.${parts[1]}.${parts[0]}`;
}
function formatTime(icsTimeStr) {
// Format time for display
if (!icsTimeStr) return "";
if (icsTimeStr.length === 8) {
// All-day event
return "Ganztägig";
} else if (icsTimeStr.includes("T")) {
// Time-specific event (with or without timezone)
const timeStart = icsTimeStr.indexOf("T") + 1;
const hour = icsTimeStr.substring(timeStart, timeStart + 2);
const minute = icsTimeStr.substring(timeStart + 2, timeStart + 4);
return `${hour}:${minute}`;
}
return icsTimeStr;
}
function formatDateForICS(date) {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}${month}${day}`;
}
// ICS-Datei abrufen und Events verarbeiten
fetch('/all.ics')
.then(response => response.text())
.then(data => {
events = parseICS(data);
// Initialize with current date
let today = new Date();
currentYear = today.getFullYear();
currentMonth = today.getMonth();
// Process events for current month
updateEventsForMonth(currentYear, currentMonth);
})
.catch(err => console.error('Fehler beim Laden der ICS-Datei:', err));
})();
loadCalendar()
.then(loaded => {
calendar = loaded;
updateEventsForMonth(currentYear, currentMonth);
})
.catch(err => console.error("Fehler beim Laden der ICS-Datei:", err));
});

View file

@ -9,11 +9,11 @@ function timeDistanceDE(pastDate) {
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days = 1) return `since ${days}d`
if (days === 1) return `since ${days}d`
if (days > 1) return `since ${days}d`
if (hours = 1) return `since ${hours}h`
if (hours === 1) return `since ${hours}h`
if (hours > 1) return `since ${hours}h`
if (minutes = 1) return `since ${minutes}min`
if (minutes === 1) return `since ${minutes}min`
if (minutes > 1) return `since ${minutes}min`
return `gerade eben`
}

194
assets/js/events.js Normal file
View file

@ -0,0 +1,194 @@
import ICAL from "./vendor/ical.js";
// The calendar is published next to the site and is not built from this
// repository, see the README.
const icsUrl = "/calendars/all.ics";
/**
* Read the URL of an event.
*
* ICAL.Event does not expose the URL property, so read it from the component.
*
* The value ends up in the href of a link, and the calendar is exported from a
* CalDAV server, so whoever may write to it decides what that value is. A
* "javascript:" URL there would run on our page as soon as a visitor clicks
* the event, so hand on nothing but http and https.
*
* @param {ICAL.Event} event The event to read the URL of
* @returns {string} The URL, empty when the event has none or it is not http(s)
*/
export function eventUrl(event) {
const url = event.component.getFirstPropertyValue("url") ?? "";
if (!url) {
return "";
}
try {
// A relative URL is resolved against the page and keeps its scheme.
const { protocol } = new URL(url, document.baseURI);
return protocol === "http:" || protocol === "https:" ? url : "";
} catch {
// Not a URL at all.
return "";
}
}
/**
* Group the occurrences that were modified on their own by the UID of the event
* they belong to.
*
* Unless it is told which exceptions belong to an event, ICAL.Event relates
* every VEVENT with a RECURRENCE-ID in the file to every recurring event, and
* it keys them by the recurrence id alone. Two series that meet at the same
* time would therefore take over each other's modifications.
*
* @param {ICAL.Component[]} components The VEVENTs of the calendar
* @returns {Map<string, ICAL.Component[]>} The exceptions per UID
*/
function exceptionsByUid(components) {
const exceptions = new Map();
for (const component of components) {
if (!component.hasProperty("recurrence-id")) {
continue;
}
const uid = component.getFirstPropertyValue("uid");
const ofEvent = exceptions.get(uid);
if (ofEvent) {
ofEvent.push(component);
} else {
exceptions.set(uid, [component]);
}
}
return exceptions;
}
/**
* How far the recurrences of an event have to be iterated.
*
* The iteration walks the unmodified recurrence times, so an occurrence that
* was moved to an earlier time is only reached through the time it originally
* had, which can lie past the end of the window. Keep going for as long as the
* largest move towards the past can still carry an occurrence into it.
*
* @param {ICAL.Event} event The event whose recurrences are iterated
* @param {Date} to End of the window
* @returns {Date} The recurrence time to stop at
*/
function iterationEnd(event, to) {
let last = to.getTime();
for (const exception of Object.values(event.exceptions)) {
const movedBy = exception.recurrenceId.toJSDate().getTime()
- exception.startDate.toJSDate().getTime();
if (movedBy > 0) {
last = Math.max(last, to.getTime() + movedBy);
}
}
return new Date(last);
}
/**
* Whether an occurrence touches a window.
*
* @param {ICAL.Time} startDate Start of the occurrence
* @param {ICAL.Time} endDate End of the occurrence
* @param {Date} from Start of the window
* @param {Date} to End of the window
* @returns {boolean} True when the two overlap
*/
function touches(startDate, endDate, from, to) {
return startDate.toJSDate() <= to && endDate.toJSDate() > from;
}
/**
* Load and parse the calendar of the club.
*
* @returns {Promise<ICAL.Component>} The calendar
*/
export async function loadCalendar() {
const response = await fetch(icsUrl);
// Without this an error page would be handed to the parser below, which then
// fails with a confusing complaint about the calendar syntax.
if (!response.ok) {
throw new Error(`${icsUrl}: ${response.status} ${response.statusText}`);
}
return new ICAL.Component(ICAL.parse(await response.text()));
}
/**
* Every occurrence of the calendar that touches the given window.
*
* A recurring event is expanded, and an occurrence that was modified on its own
* is reported with the time, the name and the URL of that modification.
*
* @param {ICAL.Component} calendar The parsed calendar
* @param {Date} from An occurrence has to still be running at this time
* @param {Date} to An occurrence has to have started by this time
* @yields {{event: ICAL.Event, startDate: ICAL.Time, endDate: ICAL.Time}}
*/
export function* occurrencesBetween(calendar, from, to) {
const components = calendar.getAllSubcomponents("vevent");
const exceptions = exceptionsByUid(components);
for (const component of components) {
// Occurrences modified via RECURRENCE-ID are reached through the event they
// belong to, handling them here as well would report them twice.
if (component.hasProperty("recurrence-id")) {
continue;
}
const event = new ICAL.Event(component, {
exceptions: exceptions.get(component.getFirstPropertyValue("uid")) ?? [],
});
if (!event.startDate) {
continue;
}
if (!event.isRecurring()) {
if (touches(event.startDate, event.endDate, from, to)) {
yield { event, startDate: event.startDate, endDate: event.endDate };
}
continue;
}
const iterator = event.iterator();
const iterateUntil = iterationEnd(event, to);
while (true) {
const occurrence = iterator.next();
// Recurrences are chronological, so we are done once one starts after the
// window, and after the occurrences a modification can still move back
// into it.
if (!occurrence || occurrence.toJSDate() > iterateUntil) {
break;
}
// Details resolve time, name and URL of an occurrence that was modified
// via RECURRENCE-ID.
const details = event.getOccurrenceDetails(occurrence);
// A modification may have moved the occurrence out of the window, so
// judge it by the time it really takes place at.
if (touches(details.startDate, details.endDate, from, to)) {
yield {
event: details.item,
startDate: details.startDate,
endDate: details.endDate,
};
}
}
}
}

105
assets/js/upcoming.js Normal file
View file

@ -0,0 +1,105 @@
import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js";
/**
* When an occurrence starts, as a point in time.
*
* A date has no time and no zone, its digits are the day itself. toJSDate()
* reads them as midnight in the zone of the browser, which moves an all day
* event by the offset that zone has to Berlin and, far enough east or west,
* onto the day before or after. Keep the digits and read them as UTC instead,
* the table prints an all day event in UTC as well.
*
* @param {ICAL.Time} time Start of the occurrence
* @returns {Date} The point in time to sort and print by
*/
function startOf(time) {
if (time.isDate) {
return new Date(Date.UTC(time.year, time.month - 1, time.day));
}
return time.toJSDate();
}
/**
* The upcoming occurrences of a calendar.
*
* @param {ICAL.Component} calendar The parsed calendar
* @param {Date} now Events must still be running at this date
* @param {number} maxEvents Maximum number of events to return
* @param {number} maxDays Maximum number of days into the future
* @returns {{start: Date, allDay: boolean, name: string, url: string}[]} url is empty when the event has no URL
*/
function getUpcomingEvents(calendar, now, maxEvents, maxDays) {
const end = new Date(now.getTime());
end.setDate(end.getDate() + maxDays);
const events = [];
// A running event stays listed until it is over, so the window starts at now
// and the walk keeps everything that has not ended yet.
for (const { event, startDate } of occurrencesBetween(calendar, now, end)) {
events.push({
start: startOf(startDate),
allDay: startDate.isDate,
name: event.summary ?? "",
url: eventUrl(event),
});
}
// We have occurrences from multiple events, so sort them
// before applying the maximum event count.
events.sort((a, b) => a.start - b.start);
return events.slice(0, maxEvents);
}
document.addEventListener("DOMContentLoaded", () => {
const max_days = 20;
const max_items = 5;
const now = new Date();
const table = document.getElementById("upcoming");
loadCalendar()
.then(calendar => {
getUpcomingEvents(calendar, now, max_items, max_days).forEach(event => {
const row = document.createElement("tr");
const colBegin = document.createElement("td");
// The events take place in Berlin, so name their time in Berlin time
// instead of in the time zone the visitor happens to be in. An all day
// event has no time of day and carries its date in UTC, see startOf().
const whenFormat = event.allDay
? { timeZone: "UTC" }
: { timeZone: "Europe/Berlin", hour: "2-digit", minute: "2-digit" };
const formattedStart = event.start.toLocaleString("de-DE", {
weekday: "long",
day: "2-digit",
month: "2-digit",
...whenFormat,
});
colBegin.innerText = event.allDay ? formattedStart : `${formattedStart} Uhr`;
row.appendChild(colBegin);
const colName = document.createElement("td");
if (event.url) {
const a = document.createElement("a");
a.href = event.url;
a.text = event.name;
colName.appendChild(a);
} else {
colName.innerText = event.name;
}
row.appendChild(colName);
table.appendChild(row);
});
})
.catch(err => console.error("Fehler beim Laden der Termine:", err));
});

19
assets/js/vendor/README.md vendored Normal file
View file

@ -0,0 +1,19 @@
# Vendored JavaScript
Third party code is checked in here instead of being loaded from a CDN, so that the website does not make the visitor's
browser fetch anything from an external server.
Hugo bundles and minifies these files into the scripts that reference them, so the unminified source is checked in.
## ical.js
- Version: 2.2.1
- Source: <https://unpkg.com/ical.js@2.2.1/dist/ical.js>
- Upstream: <https://github.com/kewisch/ical.js>
- License: MPL-2.0 (see the header of `ical.js`)
To update, download the `dist/ical.js` of the wanted release and replace the file, keeping the version above in sync:
```shell
curl -o assets/js/vendor/ical.js https://unpkg.com/ical.js@<version>/dist/ical.js
```

9732
assets/js/vendor/ical.js vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,7 @@
#!/bin/sh
set -e
set -x
set -ex
rm -rf public/ # delete old generated files
hugo $(cat .hugo-params)
./tools/merge_cals.py
upcoming="$(tools/gen_upcoming.py static/all.ics 20 5 | tr '\n' ' ')"
cp static/all.ics public/all.ics
sed -i "s#CALENDAR#$upcoming#g" public/index.html

View file

@ -28,7 +28,7 @@ filename = "sitemap.xml"
priority = 0.5
[permalinks]
post = "/post/:year/:month/:day/:title/"
post = "/post/:year/:month/:day/:slug/"
[outputs]
home = ["html", "rss", "json"]

View file

@ -25,6 +25,6 @@ description: "Startseite CCCB mit Kurzkalender"
### Nächste Veranstaltungen
CALENDAR
{{< upcoming >}}
Weitere Termine findest du im [Veranstaltungskalender](/verein/calendar/).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View file

@ -1,58 +0,0 @@
---
title: "Berlin #DIDit"
date: 2026-06-18T12:00:00+01:00
showHero: false
tags: ["di.day", "Ankündigung"]
---
{{< figure
src="di_day_logo.png"
title="DIDay"
alt="DIDay"
>}}
<!-- <img src="di_day_logo.png" alt="di.day Logo" height="50%"> -->
### Was?
Am Sonntag, dem 5. Juli, öffnet der Chaos Computer Club Berlin e.V. wieder seine Türen zum DID.
Zusammen mit dem [xHain hack+makespace](https://x-hain.de/) unterstützen wir auf dem Weg in die digitale Unabhängigkeit.
Gemeinsam arbeiten wir daran, unsere digitale Souveränität zurückzugewinnen.
Statt Abhängigkeiten von großen Plattformen setzen wir auf offene Alternativen und selbstbestimmte digitale Werkzeuge.
So holen wir uns Schritt für Schritt die Kontrolle über unser digitales Leben zurück und beschreiten den Weg zur digitalen Unabhängigkeit.
Ob du jetzt
- von WhatsApp zu einer datenschutzfreundlichen Alternative wechseln möchtest,
- dein Smartphone gerne ohne Google oder Apple betreiben willst,
- oder auf deinem Notebook lieber Linux anstelle von Windows benutzen würdest,
wir helfen euch dabei gerne. Bringt gerne auch eure Geräte dafür mit.
Natürlich kannst du auch einfach nur so vorbeikommen und uns Fragen stellen.
### Wo?
**Marienstraße 11, 10117 Berlin**
### Wann?
**Sonntag, 5. Juli 2026** \
**12:00 bis 16:00 Uhr**
Schaut aber auch gerne bei der
[Veranstaltung](tbd)
von [Digital-Zebra](https://termine.di.day/organisation/digitalzebra) und
der [c-base](https://termine.di.day/organisation/cbase) vorbei.
**Gemeinsam mehr erreichen** \
Diese Veranstaltung ist Teil eines Netzwerks aus mehreren Organisationen und Initiativen in Berlin.
Wir unterstützen uns gegenseitig mit Kompetenz, Erfahrung und praktischer Hilfe.
Uns alle eint der Wunsch, Wissen zu teilen, voneinander zu lernen und solidarisch zusammenzuarbeiten.
Der DI.Day bildet dabei den gemeinsamen Rahmen,
in dem wir uns für digitale Selbstbestimmung und nachhaltige digitale Strukturen einsetzen.
Schau deshalb gern auch bei den Veranstaltungen der anderen beteiligten Organisationen vorbei.
So entsteht ein vielfältiges Angebot, das Austausch, Vernetzung und gegenseitige Unterstützung lebendig macht.

View file

@ -0,0 +1,57 @@
---
title: "Aktionstag gegen Überwachung"
date: 2026-08-11T12:00:00+02:00
showHero: false
tags: ["Überwachung", "Ankündigung"]
---
### Was?
KI-gestützte Überwachungskameras sollen jetzt auch nach Berlin kommen.
Das [im Dezember 2025 verschärfte Berliner Polizeigesetz](https://www.beck-aktuell.de/heute-im-recht/rechtspolitik-gesetzgebung/berlin-verschaerfung-polizeigesetz-2025-12-04) erlaubt der Polizei, solche Kameras an öffentlichen Plätzen aufzuhängen. Erste Versuche laufen schon.
Darüber wollen wir reden.
Deshalb laden wir am 23. August zum Aktionstag gegen Überwachung in den Club ein.
### Worum geht es?
Die KI-Videoüberwachung soll [ein Jahr lang an sechs sogenannten „kriminalitätsbelasteten Orten" getestet werden](https://taz.de/KI-Videoueberwachung-in-Berlin/!6160851/), darunter am Alexanderplatz, am Roten Rathaus und am Alten Stadthaus. Vor dem Abgeordnetenhaus kommt mobile Technik dazu.
Diese Kameras zeichnen nicht nur auf. Sogenannte Verhaltensscanner sollen automatisch melden, wenn sich jemand „auffällig" verhält. Trainiert wird das Ganze [mit den Daten von Passant:innen, die einfach nur vorbeilaufen](https://netzpolitik.org/2026/ki-gestuetzte-videoueberwachung-in-berlin-so-wehrt-man-sich-gegen-verhaltensscanner/).
In Mannheim läuft so ein System seit 2018, in Hamburg seit 2023. Dort hält die Software [eine Umarmung schon mal für einen Ringkampf](https://taz.de/Kuenstliche-Intelligenz-im-Einsatz/!6099559/). In Mannheim gab es laut Polizei [jeden Tag Fehlalarme im „niedrigen zweistelligen Bereich"](https://netzpolitik.org/2023/intelligente-videoueberwachung-polizei-hamburg-will-ab-juli-verhalten-automatisch-scannen/). Wer sich hinlegt oder taumelt, löst Alarm aus. Was überhaupt als „auffällig" gilt, entscheidet der Algorithmus. Warum das [Menschen diskriminieren kann, erklärt der Verfassungsblog](https://verfassungsblog.de/berlin-asog-novelle-kbos/).
Die Kameras sind außerdem nur ein Teil der Gesetzesänderung. Das neue ASOG erlaubt auch [längere Datenspeicherung und weitere Überwachungsbefugnisse](https://www.telepolis.de/article/Berliner-Polizeigesetz-Koalition-weitet-Ueberwachung-aus-11083345.html). Die Berliner Datenschutzbeauftragte hat das kritisiert. Wir auch: Der CCC war dazu bereits im Innenausschuss des Abgeordnetenhauses.
Ihr braucht kein Vorwissen. Kommt einfach vorbei.
### Programm
**13:00 Uhr: Open Space** \
Wir fangen mit offenem Austausch an. Bringt eure Fragen mit, egal ob ihr euch schon lange mit dem Thema beschäftigt oder gerade zum ersten Mal davon hört.
**15:00 Uhr: Vortrag** \
Was in Berlin geplant ist, was die Technik wirklich kann und was das für uns alle heißt.
**16:00 Uhr: Podiumsdiskussion** \
Danach diskutieren wir auf dem Podium, wie wir uns gegen die KI-Videoüberwachung wehren können. Fragen aus dem Publikum sind ausdrücklich erwünscht.
### Wo?
📍 **Chaos Computer Club Berlin, Marienstraße 11, 10117 Berlin**
🚈 Etwa 5 Minuten ab dem S-Bahnhof Friedrichstraße
### Wann?
**📆 Sonntag, 23. August 2026** \
**ab 13:00 Uhr**
### Zum Weiterlesen
- taz: [KI-Videoüberwachung in Berlin: Verhaltensscanner bald auch vor dem Abgeordnetenhaus](https://taz.de/KI-Videoueberwachung-in-Berlin/!6160851/)
- netzpolitik.org: [So wehrt man sich gegen Verhaltensscanner](https://netzpolitik.org/2026/ki-gestuetzte-videoueberwachung-in-berlin-so-wehrt-man-sich-gegen-verhaltensscanner/) (mit Tools und Material für die Zivilgesellschaft)
- Verfassungsblog: [„Try harder hilft selten": die ASOG-Novelle verfassungsrechtlich eingeordnet](https://verfassungsblog.de/berlin-asog-novelle-kbos/)
- taz: [Reform des Berliner Polizeigesetzes: Riskantes Manöver](https://taz.de/Reform-des-Berliner-Polizeigesetzes/!6096087/)
- Telepolis: [Berliner Polizeigesetz: Koalition weitet Überwachung aus](https://www.telepolis.de/article/Berliner-Polizeigesetz-Koalition-weitet-Ueberwachung-aus-11083345.html)
- beck-aktuell: [Kameras und KI-Einsatz: Berlin verschärft Polizeigesetz](https://www.beck-aktuell.de/heute-im-recht/rechtspolitik-gesetzgebung/berlin-verschaerfung-polizeigesetz-2025-12-04)

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

View file

@ -0,0 +1,66 @@
---
title: "Linke und Grüne ziehen gegen das Berliner Polizeigesetz vor Gericht"
date: 2026-08-28T12:00:00+02:00
showHero: false
tags: ["Überwachung", "Polizeigesetz", "ASOG"]
---
{{< figure
src="feature-ki-kamera.jpg"
alt="KI-Überwachungskamera"
caption="KI-Überwachungskamera. Foto: netzpolitik.org, CC BY-NC-SA 4.0"
>}}
Als CDU und SPD Ende 2025 das Berliner Polizeigesetz verschärften, war die Kritik laut. Jetzt wird sie juristisch: Am 28. August 2026 haben Bündnis 90/Die Grünen und Die Linke beim Verfassungsgerichtshof des Landes Berlin einen Antrag auf Normenkontrolle eingereicht. Das höchste Berliner Gericht soll nun prüfen, ob die neuen Überwachungsbefugnisse mit der Landesverfassung vereinbar sind oder schlicht zu weit gehen.
### Vor fünf Tagen war das noch ein Thema im Club
Am 23. August fand unser [Aktionstag gegen Überwachung](/post/2026/08/11/aktionstag-gegen-überwachung/) statt. Rund 80 Leute kamen, um über KI-Kameras, Verhaltensscanner und das verschärfte Polizeigesetz zu reden. Auch Abgeordnete der Linksfraktion waren dabei und erzählten dort schon, dass sie gegen das Gesetz vorgehen wollen. Ein Normenkontrollantrag war im Gespräch.
Jetzt liegt er vor.
### Kurzer Rückblick
Im Dezember 2025 beschloss die schwarz-rote Koalition eine Novelle des Allgemeinen Sicherheits- und Ordnungsgesetzes (ASOG). Heraus kam ein Gesetzespaket voller neuer digitaler Befugnisse: von Kameras, die Verhalten automatisch analysieren, bis zu einer zentralen Plattform, auf der ganz unterschiedliche Polizeidaten zusammengeführt werden dürfen.
Die Berliner Datenschutzbeauftragte warnte, die Opposition warnte, unabhängige Sachverständige im Innenausschuss warnten. Passiert ist trotzdem wenig: die Kritik wurde im Verfahren nicht ausgeräumt. Genau hier setzt der Normenkontrollantrag an.
### Worum es konkret geht
Die Kanzlei KM8, die die Fraktionen vertritt, zählt in ihrer Begründung fünf Punkte auf, die besonders stechen:
1. Die Polizei darf künftig mit Spähsoftware auf Smartphones und Computer zugreifen und zwar nicht nur bei schwersten Straftaten, sondern schon zur Verhinderung vergleichsweise kleiner Delikte. Wer solche Software baut, braucht Sicherheitslücken. Das Gesetz setzt also einen Anreiz, Lücken offenzuhalten, statt sie zu stopfen.
2. Der überwachte öffentliche Raum soll automatisiert ausgewertet werden: Wer sich „auffällig" verhält, löst Alarm aus. Was auffällig ist, entscheidet der Algorithmus - ohne klare Einsatzregeln und ohne echten Schutz gegen Fehler, Diskriminierung oder Missbrauch.
3. Öffentlich zugängliche Bilder aus dem Internet darf die Polizei in großem Stil biometrisch auswerten. Die Kanzlei spricht von biometrischer Massenüberwachung und von einem Abschreckungseffekt, der vor allem diejenigen trifft, die demonstrieren wollen.
4. Polizeilich erhobene Daten sollen für einen komplett neuen Zweck weiterverwendet werden dürfen: das Training von KI-Systemen, teilweise sogar unter Weitergabe an private Firmen. Was genau mit welchen Daten passiert, lässt das Gesetz offen. Der Grundsatz, dass Daten nur für den Zweck genutzt werden, für den sie erhoben wurden, wird damit faktisch ausgehebelt.
5. Verschiedenste Bestände dürfen auf einer zentralen Analyseplattform zusammengeführt und automatisiert ausgewertet werden auch mit KI. Übrig bleibt eine Art Riesendatenbank, in der Daten aus völlig unterschiedlichen Zusammenhängen für neue Zwecke zur Verfügung stehen. Ausgeschlossen ist dabei auch der Einsatz von Palantir nicht.
### Was eine Normenkontrolle überhaupt ist
Anders als bei einer konkreten Klage geht es bei der abstrakten Normenkontrolle nicht um einen einzelnen Fall. Das Gericht schaut sich das Gesetz als solches an und prüft, ob es mit der Verfassung vereinbar ist. Antragsberechtigt ist unter anderem ein Viertel der Abgeordneten genau dieses Quorum bringen Die Linke und die Grünen zusammen. Hält das Gericht einzelne Regelungen für verfassungswidrig, erklärt es sie für nichtig.
### Was die Beteiligten sagen
Die innenpolitischen Sprecher Vasili Franco (Grüne) und Niklas Schrader (Die Linke) nennen die Novelle einen massiven Ausbau der Überwachung. Zusammen mit der automatisierten Auswertung von Polizeidaten könnte eine „riesige Superdatenbank" entstehen, die „jegliche Grenzen des Rechtsstaates sprengt". Und sie kritisieren: Es sei völlig unklar, welche Daten künftig wofür verarbeitet werden dürfen. Für ein Parlament, das die Polizei kontrollieren soll, sei das nicht hinnehmbar.
Ihr Kernargument: Polizeigesetze sind keine gewöhnlichen Gesetze. Sie entscheiden, was die Polizei in einem Rechtsstaat darf und das Gewaltmonopol des Staates muss sich an den Grundrechten messen lassen. „Verfassungswidrige Polizeigesetze sind keine guten Polizeigesetze." Deshalb halten sie eine verfassungsrechtliche Prüfung für unabdingbar.
Rechtsanwalt David Werdermann, der die Fraktionen vertritt, bringt es ähnlich auf den Punkt: Die Novelle bedeute einen grundlegenden Kurswechsel, mit Befugnissen, die aus mehreren Gründen gegen Grundrechte verstoßen.
### Warum das jetzt wichtig ist
Der Ball liegt beim Verfassungsgerichtshof. Dessen Entscheidung wird nicht nur über die einzelnen Punkte entscheiden, sondern grundsätzlich darüber, wie viel Überwachung die Landesverfassung überhaupt hergibt vom Verhaltensscanner im öffentlichen Raum bis zur zentralen Datenanalyse.
Wie das ausgeht, ist offen. Sicher ist nur: Der Streit um das Polizeigesetz wird nicht mehr allein politisch geführt, sondern jetzt auch vor Gericht.
### Zum Weiterlesen
- Linksfraktion Berlin: [Linke und Grüne erheben Normenkontrolle gegen verfassungswidrige Regelungen im schwarz-roten Polizeigesetz](https://www.linksfraktion.berlin/politik/presse/detail/news/linke-und-gruene-erheben-normenkontrolle-gegen-verfassungswidrige-regelungen-im-schwarz-roten-polizeigesetz/)
- Verfassungsblog: [„Try harder hilft selten": die ASOG-Novelle verfassungsrechtlich eingeordnet](https://verfassungsblog.de/berlin-asog-novelle-kbos/)
- taz: [KI-Videoüberwachung in Berlin: Verhaltensscanner bald auch vor dem Abgeordnetenhaus](https://taz.de/KI-Videoueberwachung-in-Berlin/!6160851/)
- netzpolitik.org: [So wehrt man sich gegen Verhaltensscanner](https://netzpolitik.org/2026/ki-gestuetzte-videoueberwachung-in-berlin-so-wehrt-man-sich-gegen-verhaltensscanner/)
- beck-aktuell: [Kameras und KI-Einsatz: Berlin verschärft Polizeigesetz](https://www.beck-aktuell.de/heute-im-recht/rechtspolitik-gesetzgebung/berlin-verschaerfung-polizeigesetz-2025-12-04)

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View file

@ -0,0 +1,85 @@
---
title: "Peter Thiel ante portas"
date: 2026-08-28T12:00:00+02:00
showHero: false
tags: ["Überwachung", "Palantir", "Ankündigung"]
---
{{< figure
src="feature-peter-thiel.jpg"
alt="Peter Thiel"
caption="Peter Thiel. Foto: Gage Skidmore, CC BY-SA 2.0"
>}}
### Was?
Am 24. September nimmt Peter Thiel in Berlin den Axel-Springer-Award entgegen. Drei Tage vorher, am 21. September, schauen wir uns im Club an, wer da eigentlich geehrt wird. Denn der Mann, den Axel Springer da auszeichnet, ist kein harmloser Tech-Unternehmer.
Peter Thiel hat PayPal mitgegründet, früh in Facebook investiert und mit Palantir eines der wichtigsten Überwachungsunternehmen der Welt aufgebaut. Er ist einer der reichsten und einflussreichsten Männer des Silicon Valley, und seine Vorstellungen davon, wie die Welt aussehen soll, sind radikal. Darüber wollen wir reden.
### Wer ist Peter Thiel?
Peter Thiel wurde 1967 in Frankfurt am Main geboren und wanderte als Kind mit seiner Familie in die USA aus. Er studierte Philosophie in Stanford, machte einen Abschluss in Jura und arbeitete kurz als Anwalt, bevor er in die Tech-Welt wechselte. 1998 gründete er mit Max Levchin das Unternehmen Confinity, aus dem später PayPal wurde. Als eBay PayPal 2002 für 1,5 Milliarden Dollar kaufte, war Thiel als Vorstandschef der große Gewinner.
Danach wurde er zu einer der zentralen Figuren des Silicon Valley. 2004 gab er als erster externer Investor eine halbe Million Dollar in ein damals unbekanntes Studentenprojekt namens Facebook. Mit seiner Wagniskapitalfirma Founders Fund steckte er früh Geld in Unternehmen wie SpaceX, Airbnb und die Rüstungsfirma Anduril. Und mit dem Thiel Fellowship bezahlt er seit 2011 junge Menschen dafür, ihr Studium abzubrechen und stattdessen Firmen zu gründen.
Vor allem aber steht sein Name für Palantir.
### Palantir
Palantir ist nach den Sehsteinen aus Tolkiens „Herr der Ringe" benannt, den Palantíri. Das passt, denn die Firma baut genau das: Software, die alles sehen soll. Ihre Werkzeuge durchforsten riesige Datenmengen nach Mustern, Verbindungen und Auffälligkeiten.
Die Kundschaft sind Geheimdienste, Militärs und Polizeibehörden. Palantir-Software half der US-Einwanderungsbehörde ICE dabei, Abschiebungen zu organisieren. Sie unterstützt Militärs bei der Auswahl von Zielen und steckt hinter polizeilichen Analyse- und Vorhersagesystemen. In Großbritannien hat sich Palantir einen umstrittenen Vertrag über die Gesundheitsdaten des staatlichen NHS gesichert.
Auch bei uns wird darum gestritten. Erst kürzlich haben wir [darüber geschrieben]({{< ref "post/2026-08-28-normenkontrolle-asog" >}}), dass im neuen Berliner Polizeigesetz der Einsatz von Palantir ausdrücklich nicht ausgeschlossen ist. Die zentrale Analyseplattform, die dieses Gesetz vorsieht, ist genau das Geschäftsmodell von Palantir.
### Thiels Weltbild
Thiel ist nicht nur Geschäftsmann. Er hat eine klare politische Agenda, und die ist erklärtermaßen antidemokratisch. 2009 schrieb er in einem Essay: „I no longer believe that freedom and democracy are compatible." Zu Deutsch: Ich glaube nicht mehr daran, dass Freiheit und Demokratie vereinbar sind. Im selben Text machte er das Wahlrecht für Frauen und den Sozialstaat dafür verantwortlich, dass die liberale Demokratie seiner Ansicht nach gescheitert ist.
Er steht dem Umfeld der sogenannten Neoreaktion nahe, einer Denkrichtung, die die Demokratie für gescheitert hält und stattdessen autoritäre Strukturen anstrebt. Ein Vordenker dieser Szene, Curtis Yarvin, träumt öffentlich von der Abschaffung des Parlamentarismus und von einem Staat, der wie ein Unternehmen geführt wird, mit einem Chef an der Spitze statt gewählter Vertreter:innen. Thiel hat Yarvin über Jahre gefördert und gepriesen.
Thiels Geld fließt auch in die Politik. Er unterstützte Donald Trump bereits 2016 und finanzierte 2022 mit 15 Millionen Dollar die Senatskampagne von JD Vance, dem heutigen Vizepräsidenten der USA. Es war damals die größte Einzelspende an einen Senatskandidaten. Und mit dem Seasteading Institute wollte er schwimmende Stadtstaaten errichten, die sich staatlicher Regeln komplett entziehen.
Kurz gesagt: Hier geht es um jemanden, der die Demokratie nicht nur kritisiert, sondern sie abschaffen will.
### Thiels Glaube und der Antichrist
Was viele bei Thiel übersehen: Seine Weltsicht ist tief religiös geprägt, allerdings auf eine sehr eigene Art. Thiel ist gläubiger Christ, aber weit entfernt vom Mainstream. Sein Denken ist stark von dem französischen Philosophen René Girard beeinflusst, dessen Theorie um Nachahmung, Sündenböcke und Apokalypse kreist. Thiel hat Girard über Jahre studiert und dessen apokalyptische Grundhaltung übernommen.
Das zeigt sich besonders bei einem Thema, über das Thiel immer wieder spricht: den Antichristen. Thiel nimmt das wörtlich. In Interviews beschreibt er den Antichristen als eine Figur, die einen weltweiten Einheitsstaat errichtet, einen falschen Frieden verspricht und dabei jede Freiheit abschafft. Zentrale Datenmacht, allgegenwärtige Überwachung und Dinge wie das chinesische Social-Credit-System ordnet er in dieses Endzeitszenario ein. Er beschreibt sich selbst als jemand, der die Zeichen dafür überall erkennt.
An dieser Stelle lohnt es sich, kurz innezuhalten. Denn hier steckt ein bemerkenswerter Widerspruch: Thiel warnt vor einem totalitären Überwachungsstaat als Vorboten des Antichristen, während er gleichzeitig mit Palantir das leistungsfähigste Überwachungsunternehmen der Welt aufbaut und an genau die Staaten verkauft, vor denen er angeblich warnt. Ob man das nun für aufrichtige Angst, für Zynismus oder für beides hält: Es sagt eine Menge über diesen Mann aus, dass sein Geschäft genau das herstellt, was sein Glaube verdammt.
### Der Axel-Springer-Award
Vor diesem Hintergrund ist es bemerkenswert, dass ausgerechnet Axel Springer diesen Mann auszeichnet. Axel Springer ist einer der größten Medienkonzerte Europas und gibt Blätter wie Bild und Welt heraus. Den Axel-Springer-Award verleiht der Konzern seit Jahren an Persönlichkeiten der Digitalwirtschaft, unter den bisherigen Preisträgern waren Mark Zuckerberg, Jeff Bezos und Elon Musk.
Mit Peter Thiel ehrt Axel Springer nun einen Mann, der offen erklärt hat, dass er Demokratie und Freiheit für unvereinbar hält. Die Verleihung am 24. September in Berlin ist eine Bühne. Wir finden: Sie sollte nicht unwidersprochen bleiben. Deshalb laden wir drei Tage vorher in den Club ein.
### Programm
**19:00 Uhr: Vortrag** \
Wer ist Peter Thiel? Was macht Palantir? Welche Netzwerke, welches Weltbild und welcher Glaube stecken dahinter? Und warum ist das alles gefährlich?
**Anschließend: Austausch** \
Diskussion und offener Austausch, offenes Ende.
Ihr braucht kein Vorwissen. Kommt einfach vorbei.
### Wo?
📍 **Chaos Computer Club Berlin, Marienstraße 11, 10117 Berlin**
🚈 Etwa 5 Minuten ab dem S-Bahnhof Friedrichstraße
### Wann?
**📆 Montag, 21. September 2026** \
**ab 19:00 Uhr**
### Zum Weiterlesen
- Wikipedia: [Peter Thiel](https://de.wikipedia.org/wiki/Peter_Thiel)
- Wikipedia: [Palantir Technologies](https://de.wikipedia.org/wiki/Palantir_Technologies)
- Wikipedia: [Axel Springer SE](https://de.wikipedia.org/wiki/Axel_Springer_SE)

View file

@ -1,5 +1,6 @@
---
title: "Der Neujahresempfang 2026 #NJE26"
slug: "neujahresempfang-2026"
date: 2026-03-05T12:00:00+01:00
showHero: false
tags: ["NJE", "Ankündigung"]

View file

@ -0,0 +1,23 @@
---
title: "Aktionstag gegen Überwachung"
subtitle: "KI-Überwachungskameras in Berlin: Vortrag, Podium und Austausch"
date: 2026-08-11T12:00:00+02:00
dtstart: 20260823T130000
dtend: 20260823T180000
menu:
main:
parent: "Veranstaltungen"
tag: ["Veranstaltung"]
---
**Am 23. August lädt der Chaos Computer Club Berlin zum Aktionstag gegen Überwachung.**
KI-gestützte Überwachungskameras sollen jetzt auch nach Berlin kommen. Das neue Berliner Polizeigesetz erlaubt es, sie an öffentlichen Plätzen aufzuhängen. Erste Versuche laufen schon, Verhaltenstracking inklusive. Darüber wollen wir reden.
Das Programm:
- **13:00 Uhr:** Open Space, offener Austausch untereinander
- **15:00 Uhr:** Vortrag zu den KI-Kameras und dem neuen Berliner Polizeigesetz
- **16:00 Uhr:** Podiumsdiskussion
📍 Chaos Computer Club Berlin, Marienstraße 11, 10117 Berlin

View file

@ -11,10 +11,12 @@ menu:
tag: ["Veranstaltung"]
---
**Jeden ersten Sonntag im Monat von 12:23 bis 17:42 Uhr** findet der Berliner Digital Independence Day statt. Der Veranstaltungsort wechselt monatlich zwischen den Räumen des CCCB (in den ungeraden Monaten Januar, März, Mai, …) und dem [xHain](https://x-hain.de/) (in den geraden Monaten).
In Berlin wird der [Digital Independence Day](https://diday.org) an verschiedenen Orten von einer breiten Zahl von Initiativen getragen.
Der CCCB und das xHain beteiligen sich gemeinsam an der internationalen [Digital Independence Day](https://diday.org)-Initiative. Diese Graswurzelbewegung ruft dazu auf, zu demokratiefreundlichen digitalen Alternativen zu wechseln, um die Abhängigkeit von großen Technologiekonzernen zu verringern.
Der CCCB beteiligt sich an dieser Graswurzelbewegung und ruft dazu auf, zu demokratiefreundlichen digitalen Alternativen zu wechseln, um die Abhängigkeit von großen Technologiekonzernen zu verringern.
Vor Ort gibt es praktische Hilfe beim Wechsel zu datenschutzfreundlichen Diensten, beim Einrichten von Linux oder beim Betrieb von Smartphones ohne große Tech-Konzerne. Egal ob Anfänger:in oder Fortgeschrittene:r. Kommt vorbei und bringt eure Geräte und Fragen mit.
Am ersten Sonntag im Monat gibt es praktische Hilfe beim Wechsel zu datenschutzfreundlichen Diensten, beim Einrichten von Linux oder beim Betrieb von Smartphones ohne große Tech-Konzerne. Egal, ob Anfänger:in oder Fortgeschrittene:r. Kommt vorbei und bringt eure Geräte und Fragen mit.
Weitere Informationen gibt es im [Ankündigungsbeitrag](https://berlin.ccc.de/post/2026/07/05/berlin-%23didit/).
Die Aktiven im CCCB unterstützen dabei auf verschiedenen Veranstaltungen in Berlin.
Weitere Informationen zu konkreten Veranstaltungen gibt es im Terminkalender unter [https://events.diday.org/](https://events.diday.org/) bzw [https://events.diday.org/organisation/cccb](https://events.diday.org/organisation/cccb).

View file

@ -10,4 +10,4 @@ menu:
tag: ["Veranstaltung"]
---
Am 23. Mai lädt der Chaos Computer Club Berlin zum Neujahresempfang. [Hier geht es zur Infoseite!](https://berlin.ccc.de/post/2026/03/05/der-neujahresempfang-2026-%23nje26/)
Am 23. Mai lädt der Chaos Computer Club Berlin zum Neujahresempfang. [Hier geht es zur Infoseite!](https://berlin.ccc.de/post/2026/03/05/neujahresempfang-2026/)

View file

@ -0,0 +1,22 @@
---
title: "Peter Thiel ante portas"
subtitle: "Vortrag und Austausch über Peter Thiel, Palantir und den Axel-Springer-Preis"
date: 2026-08-28T12:00:00+02:00
dtstart: 20260921T190000
dtend: 20260921T220000
menu:
main:
parent: "Veranstaltungen"
tag: ["Veranstaltung"]
---
**Am 21. September schauen wir uns an, wer da am 24. September in Berlin ausgezeichnet wird.**
Peter Thiel kommt nach Berlin, um den Axel-Springer-Award entgegenzunehmen. Er ist Mitgründer von PayPal und Palantir, früher Investor bei Facebook und einer der einflussreichsten Geldgeber der Tech-Rechten. Grund genug, sich ihn genauer anzusehen.
Das Programm:
- **19:00 Uhr:** Vortrag zu Peter Thiel, Palantir und seinen Netzwerken
- **anschließend:** Offener Austausch und Diskussion, offenes Ende
📍 Chaos Computer Club Berlin, Marienstraße 11, 10117 Berlin

View file

@ -12,4 +12,15 @@ herostyle: big
{{< calendar >}}
Keinen Termin mehr verpeilen? Einfach den [Veranstaltungskalender abonnieren](/all.ics)!
Keinen Termin mehr verpeilen? Einfach den [Veranstaltungskalender abonnieren](/calendars/all.ics)!
Oder willst du nur den Kalender von bestimmten Events?
- [Club Discordia](/calendars/Club_Discordia.ics)
- [Neujahresempfang](/calendars/Neujahresempfang.ics)
- [Datengarten](/calendars/Datengarten.ics)
- [Spieleabend](/calendars/Spieleabend.ics)
- [OpenWRT](/calendars/OpenWRT.ics)
- [Plenum](/calendars/Plenum.ics)
- [Amateurfunk](/calendars/Amateurfunk.ics)
- [Irreguläre Events](/calendars/Random.ics)

8
flake.lock generated
View file

@ -2,16 +2,16 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1748995628,
"narHash": "sha256-bFufQGSAEYQgjtc4wMrobS5HWN0hDP+ZX+zthYcml9U=",
"lastModified": 1786313170,
"narHash": "sha256-9BG7OgUWdu0ONDO5X2q6+K4bsuBITkX/3W4nNJu1Ito=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "8eb3b6a2366a7095939cd22f0dc0e9991313294b",
"rev": "fcb8fcd6bf2d0adecae5bd491afaaaf8311b758d",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-24.11",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}

View file

@ -2,7 +2,7 @@
description = "A flake containing a development environment for the CCCB website.";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11";
nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05";
};
outputs =
@ -34,7 +34,7 @@
devShells = forAllSystems (import ./devShells.nix);
formatter = forAllSystems ({ pkgs, ... }: pkgs.nixfmt-rfc-style);
formatter = forAllSystems ({ pkgs, ... }: pkgs.nixfmt-tree);
lib.mkWwwContent =
{ domain, system }:
@ -50,10 +50,6 @@
nativeBuildInputs = [
pkgs.hugo
pkgs.glibcLocales
(pkgs.python3.withPackages (python-pkgs: [
python-pkgs.icalendar
python-pkgs.pytz
]))
];
# LOCALE_ARCHIVE = builtins.trace pkgs.glibcLocales "${pkgs.glibcLocales}/lib/locale/locale-archive";
@ -62,11 +58,6 @@
buildPhase = ''
mkdir -p public
hugo --baseURL=https://${domain}/
python3 ./tools/merge_cals.py
upcoming="$(python3 tools/gen_upcoming.py static/all.ics 20 5 | tr '\n' ' ')"
cp static/all.ics public/all.ics
sed -i "s#CALENDAR#$upcoming#g" public/index.html
'';
# Install phase - copy the public directory to the output

View file

@ -1,12 +1,12 @@
{{ $js := resources.Get "js/calendar.js" }}
{{ $css := resources.Get "css/calendar.css" }}
{{ $js := resources.Get "js/calendar.js" | js.Build (dict "minify" true "format" "esm" "target" "es2020") | fingerprint }}
{{ $css := resources.Get "css/calendar.css" | minify | fingerprint }}
<div class="calendar-container">
{{ with $css }}
<link rel="stylesheet" href="{{ .RelPermalink }}">
<link rel="stylesheet" href="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}">
{{ end }}
{{ with $js }}
<script src="{{ .RelPermalink }}"></script>
<script type="module" src="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}"></script>
{{ end }}
<div id="calendar">

View file

@ -0,0 +1,5 @@
{{- $css := resources.Get "css/upcoming.css" | minify | fingerprint -}}
{{- $js := resources.Get "js/upcoming.js" | js.Build (dict "minify" true "format" "esm" "target" "es2020") | fingerprint -}}
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}">
<table id="upcoming" class="table table-condensed"></table>
<script type="module" src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}"></script>

View file

@ -1,2 +0,0 @@
icalendar==5.0.7
pytz

View file

@ -1,108 +0,0 @@
#!/usr/bin/env python3
import sys
import logging
import locale
from dateutil.parser import parse as _parse
from datetime import datetime, timedelta
from dateutil.rrule import rruleset, rrulestr
import icalendar
def parse(value):
# Some templates emit DTSTART with a trailing "Z" (e.g. 20260507T190000Z)
# which produces tz-aware datetimes, while others emit naive local times.
# Normalize to naive so comparisons and sorting don't mix the two.
dt = _parse(value)
if dt.tzinfo is not None:
dt = dt.replace(tzinfo=None)
return dt
def vevent_to_event(event, rrstart=None):
if rrstart == None:
begin = parse(event["DTSTART"].to_ical())
else:
begin = rrstart
return {
"name": event["SUMMARY"].to_ical(),
"url": event["URL"].to_ical(),
"begin": begin
}
def parse_single_event(event, start, end):
logging.info(f"Processing single event {event['SUMMARY'].to_ical().decode('utf-8')}")
dtstart = parse(event["DTSTART"].to_ical())
if dtstart >= start and dtstart < end:
return vevent_to_event(event)
def parse_recurring_event(event, start, end):
logging.info(f"Processing recurring event {event['SUMMARY'].to_ical().decode('utf-8')}")
dtstart = parse(event["DTSTART"].to_ical())
rs = rruleset()
# dtstart is normalized to naive local time above; pass ignoretz so a
# tz-aware UNTIL (RFC 5545 requires UTC for it) is treated as naive too,
# otherwise dateutil rejects the naive/aware mismatch.
rs.rrule(rrulestr(event["RRULE"].to_ical().decode("utf-8"), dtstart=dtstart, ignoretz=True))
if "EXDATE" in event.keys():
for exdate in event["EXDATE"]:
rs.exdate(parse(exdate.to_ical()))
events = []
for date in list(rs):
if date >= start and date < end:
events.append(vevent_to_event(event, date))
return events
def find_events(icsfilestr, start, end, num):
with open(icsfilestr, "r") as icsfile:
cal = icalendar.Calendar.from_ical(icsfile.read())
events = []
for event in cal.subcomponents:
if event.name == "VEVENT":
if "RRULE" in event.keys():
events.extend(parse_recurring_event(event, start, end))
else:
ev = parse_single_event(event, start, end)
if ev is not None:
events.append(ev)
events = sorted(events, key=lambda k: k["begin"])
events = events[0:num]
return events
def format_events(events):
print("<table class=\"table table-condensed\">")
for event in events:
print(
"<tr>"
f"<td>{event['begin'].strftime('%A, %d.%m um %H:%M Uhr')}</td>"
f"<td><a href=\"{event['url'].decode('utf-8')}\">{event['name'].decode('utf-8')}</a></td>"
"</tr>"
)
print("</table><!--/.table .table-condensed-->")
if __name__ == "__main__":
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} calendar max_days max_items")
sys.exit(-1)
locale.setlocale(locale.LC_TIME, "de_DE.UTF-8")
calendar = sys.argv[1]
max_days = int(sys.argv[2])
max_items = int(sys.argv[3])
now = datetime.now()
events = find_events(calendar, now, now + timedelta(days=max_days), max_items)
format_events(events)

View file

@ -1,31 +0,0 @@
#!/usr/bin/env python3
from glob import glob
import pytz
import icalendar
calendars = []
merged = icalendar.Calendar()
merged.add("prodid", "-//CCCB Calendar Generator//berlin.ccc.de//")
merged.add("version", "2.0")
for icsfilestr in glob("public/*/**/*.ics", recursive=True):
with open(icsfilestr, "r") as icsfile:
print(f"Importing {icsfilestr}")
calendars.append(icalendar.Calendar.from_ical(icsfile.read()))
for calendar in calendars:
for event in calendar.subcomponents:
if event.name != "VEVENT":
continue
if "DTSTART" not in event:
continue
merged.add_component(event)
outfile = "static/all.ics"
with open(outfile, "wb") as f:
print(f"writing to {outfile}...")
f.write(merged.to_ical())