Skip to content

BLD-6: add web app manifest + offline service worker to the three web… - #55

Open
patrickunterwegs wants to merge 37 commits into
mainfrom
claude/spectacled-code-review-followup
Open

BLD-6: add web app manifest + offline service worker to the three web…#55
patrickunterwegs wants to merge 37 commits into
mainfrom
claude/spectacled-code-review-followup

Conversation

@patrickunterwegs

@patrickunterwegs patrickunterwegs commented Jul 17, 2026

Copy link
Copy Markdown
Member

Status: Open — not for merge yet. Kept open for future consideration. The PWA/offline work here is optional and non-urgent, and the service worker still needs a live browser pass to verify install + offline reload (see note below). For now spectacled stays a plain website; revisit this when there's time to test the offline behaviour properly before merging.


… apps

Makes the Journals/Notes/Tasks web targets installable PWAs with offline shell support, using the same webMain/resources serving mechanism DAT-6 already relies on.

Per app:

  • manifest.webmanifest: name/short_name, standalone display, start_url/scope '.' (relative, so it works under the /journals|/notes|/tasks deploy paths), the variant's brand theme_color (#006896 / #994c2c / #296f23), and a 512x512 icon copied from the app's existing ic_launcher-playstore.png.
  • service-worker.js: network-first for same-origin GETs of static shell assets, cache only as an offline fallback. Deliberately conservative - it never caches non-GET, cross-origin, or non-shell requests, so CalDAV/proxy traffic and the sql.js persistence worker are untouched, and nothing is served stale while online. Versioned cache, cleaned on activate.
  • index.html: , apple-touch-icon, theme-color meta, and a guarded service-worker registration (no-op where unsupported / on plain HTTP).

BLD-6 was blocked on DAT-6 (web persistence), which has landed. Needs one live browser pass to verify install + offline reload before calling it done - service workers, like DAT-6, tend to only reveal issues in a real browser.

Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN

claude and others added 30 commits July 10, 2026 17:28
CopyWebpackPlugin resolved '../../webWorker/spectacledSqlWorker.js'
against webpack's own build context, which isn't guaranteed to be 2
levels above the repo root - it only coincidentally worked for
node_modules because Kotlin/JS's npm tooling hoists/symlinks it nearby.
Resolve the worker file via path.resolve(__dirname, ...) instead, which
is always relative to this config file's real location, and pin the
copy destination for all three files explicitly via `to` so nothing
depends on the plugin's default flattening behavior.

Confirmed via the reported console error: the worker script itself
404'd (Worker failed to load), which is what actually caused the
WebWorkerException, not anything inside the worker.
…CopyWebpackPlugin

Two rounds of relative/absolute path guessing against CopyWebpackPlugin
both failed - the actual root cause was that Kotlin's Gradle plugin
merges every webpack.config.d/*.js fragment into one generated config
file under build/js/, so __dirname inside these fragments never pointed
at the real webpack.config.d directory in the first place. That's an
internal build-tool detail not worth depending on.

Moved spectacledSqlWorker.js into shared/src/webMain/resources instead.
Kotlin's Gradle plugin copies src/<sourceSet>/resources to the web root
for both the js() and wasmJs() browser targets automatically, with no
custom webpack config needed - this is the standard, documented KMP
mechanism other static web assets in this project already rely on, so
it also directly avoids inventing more hand-written build glue than
necessary.

Reverted the three webpack.config.d/sqljs-config.js files back to just
copying sql-wasm.wasm/sql-wasm.js (the part that was already known to
work before this feature).
…ommit

The previous commit only picked up the file rename because 'git add -A --'
aborted on a stale pathspec - these four files (the webpack.config.d
revert to just the two sql-wasm entries, and the updated comment in
DatabaseDriverFactory.web.kt) belong to that same change.
…ared's

shared/src/webMain/resources still 404'd - a Kotlin Multiplatform library
module's JS/Wasm resources don't get bundled into a consuming app's final
webpack output the same way an app module's own resources do. The
proven, already-working location for static web assets in this project
is each compose*App's own src/webMain/resources (that's exactly where
favicon.ico, index.html and styles.css already live and are correctly
served). Duplicated the worker script into all three app modules to
match that existing convention instead of inventing a new one.
Clarified comment about sqljs.worker.js location and persistence.
The sequential String.replace chain in parseProperty was not a correct
inverse of escapeIcsValue: escaping "C:\Users\name" yields
"C:\\Users\\name", whose middle "\\n" the replace("\\n", "\n") pass
misreads as an escaped newline - the text comes back from the server as
"C:\Users<newline>ame". Any value containing a literal backslash
followed by 'n', ',' or ';' was affected; found immediately by the new
round-trip test suite (QUA-1) before it was even finished.

Replaced with a single left-to-right scan (unescapeIcsValue) that can
never pair a backslash with a character produced by an earlier escape,
and that also accepts RFC 5545's uppercase "\N" newline form from
other producers. Verified against a Python port of both algorithms
across plain text, all reserved chars, doubled/trailing backslashes,
and the corrupting cases.
Covers the code that is testable without a network or database, with
the serialize->parse round trip through the real serializer and parser
as the centerpiece - the exact path every entry takes to a CalDAV
server and back:

- IcalEntryRoundTripTest: journal/task/multi-entry round trips (dates,
  status, classification, categories, color, sequence, URL), line
  folding survival, TZID handling incl. VTIMEZONE emission (DST and
  non-DST zones), RELATED-TO with and without RELTYPE, unknown
  properties preserved as extraProperties, URI and inline-Base64
  attachments (via a fake FileManager), VEVENT filtering, missing-UID
  skip, and server-style pre-folded input.
- IcsEscapingAndFoldingTest: escape/unescape round trips including the
  backslash cases the previous unescape corrupted, RFC "\N" support,
  fold/unfold inverses, space and tab continuations, LF-only input.
- IcsDateTimeFormatTest: all four value shapes (DATE, UTC, TZID,
  floating) in both directions, unknown-TZID fallback, and malformed
  values degrading to null instead of throwing.
- IcsDateTimeTest: effectiveZone priorities, asDateOnly across a
  zone-boundary date change, withZone wall-time preservation.
- Domain: IcalEntry progress/status mapping and share text, SyncState
  and CalendarSyncStatusType exhaustive state checks, Status per
  component, Calendar privilege helpers, CalendarSyncStatus JSON
  round trip with forward-compatibility, Attachment type detection.

Removes the template placeholder SharedCommonTest (assertEquals(3, 1+2)),
which the suite replaces.

String-level expectations (escape/fold pipelines) were validated
against a Python port of the exact algorithms, since no Kotlin
toolchain is available in this environment - the suite still needs one
local ./gradlew :shared:allTests run to confirm compilation.
…QUA-9)

The comma stays the separator - RFC 5545 defines it, and every other
CalDAV client (jtx Board, KOrganizer, Evolution) already writes
categories with "\," escaping, so a custom separator would break the
interop this app exists for. Instead the standard's own escape
mechanism is now applied on both sides:

- Serializer escapes each category individually, so a comma INSIDE a
  value ("\,") stays distinguishable from the separators between values.
- IcsProperty now also carries the still-escaped rawValue, because the
  list split has to happen BEFORE unescaping - afterwards an escaped
  comma is indistinguishable from a separator. Everything except
  CATEGORIES keeps using the unescaped value as before.
- New splitIcsList() splits a raw value on unescaped commas only, then
  unescapes each element; the parser now also flatMaps over ALL
  CATEGORIES properties instead of silently dropping all but the first
  (RFC allows several per entry, and some clients emit them that way).

While wiring this up it turned out the local database had the same bug
independently: IcalEntryMapper stored categories as a plain
comma-joined TEXT column, so a fixed wire format would still have been
re-split into fragments on the next local save/load. The DB layer
(mapper both directions, getAllCategories, updateCategory) now uses the
same escapeIcsValue/splitIcsList pair, keeping one canonical escaping
implementation for both layers. Existing rows without backslashes or
commas parse identically; per owner, existing data is tester-only.

Tests: splitIcsList unit tests (incl. a category ending in a backslash
directly before a separator), a full serialize->parse round trip with
comma/semicolon/backslash categories, and multi-line CATEGORIES merge.
…t it (QUA-1)

Closes QUA-1's remaining gap. The coordinator called the top-level
webdav *Multiplatform functions directly, so its conflict-resolution
logic - the most valuable untested code in the module - couldn't run
without a real server.

Refactor: extracted a WebDavRemoteDataSource interface wrapping the
seven server operations the coordinator needs, with a production
DefaultWebDavRemoteDataSource that just delegates to the existing
top-level functions. SyncCoordinator's primary constructor now takes
that interface; a secondary constructor keeps the exact 5-arg
(client, credentials) form every existing call site already uses, so
nothing else changed. Tests pass a fake and need no HttpClient at all.
The (previously public) client/credentials properties are gone - grep
confirmed nothing read them; all call sites construct-and-call inline.

Tests (17) drive the real syncCalendarWithSyncLock entry point with a
scriptable fake server and recording fake repositories, covering the
push state machine (LOCAL_MODIFIED put success/conflict-server-modified/
conflict-server-deleted/not-found/failed-retry, LOCAL_DELETED delete
success/conflict, USER_DECIDED_SERVER_WINS, and the SYNCED/CONFLICT
"do not push" guards), the apply-server-changes machine (insert new,
overwrite synced, local-modified->conflict, same-etag skip-without-
fetch, server-delete->trashbin, server-delete-of-local-modified->
conflict), sync-status mapping (NOT_AUTHORIZED, sync-token-failed
falling back to the tokenless REPORT), and the per-calendar lock
skipping a concurrent second sync. The fake server's mutating calls
default to throwing AssertionError, so any unexpected server call
fails the test loudly rather than being swallowed by sync()'s
catch(Exception).

Adds kotlinx-coroutines-test (same version as the pinned coroutines)
for runTest in commonTest.
Every DAV server call now goes through one of two injectable interfaces
instead of a direct top-level function call, so the whole DAV surface is
mockable and swappable - and there's a clean seam to lift into a
standalone DAV library later.

- WebDavRemoteCalendarDataSource: discovery (principals/home collections/
  calendars) + calendar create/update/delete.
- WebDavRemoteIcalEntryDataSource: entry sync (the two REPORTs, moved here
  from the calendar side where they never belonged - they enumerate a
  calendar's entries), single-entry fetch/put/get/delete, and attachment
  upload/download.

Both are stateless with respect to credentials - credentials are passed
per call - so the Default implementations hold only the transport (and a
FileManager for inline attachments) and are registered as app-wide Koin
singletons. This is what makes "inject once, call everywhere, construct
nowhere" actually hold: nothing news up a data source or calls a
*Multiplatform function except the two Default impls.

Consumers migrated to inject the interfaces: AccountListViewModel (drops
its HttpClient entirely), DetailsViewModel (keeps HttpClient only for the
Claude client + SyncCoordinator), and SyncCoordinator (primary
constructor now takes the IcalEntry data source + credentials; the 5-arg
production constructor is unchanged, so companion/SyncTrigger call sites
are untouched). Renamed discoverHomeCollections/discoverCalendars to the
*Multiplatform suffix for naming consistency and to avoid a member/
top-level clash inside the Default impl.

Tests: the SyncCoordinator fake now implements WebDavRemoteIcalEntryDataSource
(credentials-per-call); no behavioral change.
…ositories

Both Default DAV data-source constructors take only injectable params, so
they can use the same singleOf(::Ctor) { bind<Interface>() } form the
repository singletons already use, instead of a single<Interface> { ... }
lambda. (The HttpClient binding stays a single { } lambda since it is
built from a factory method, not a constructor.)
Adds ktor-client-mock as a commonTest dependency and a WebDavParsingTest
suite that drives the *Multiplatform DAV functions against scripted
MockEngine responses. These cover the parsing seam the fake-interface
SyncCoordinator tests deliberately skip:

- sync-collection: sync-token + etag map parsing, null-etag deletion
  signal, relative-href resolution, REPORT/Depth/Basic-auth request shape,
  and 404/401/500 status mapping
- multiget href report: 200-only propstat filtering
- principal discovery: current-user-principal href resolution and the
  all-403 -> NotAuthorized path
- home-collection discovery: home-set/displayname/address-set extraction
- calendar discovery: component/resourcetype filtering (VJOURNAL kept,
  VEVENT-only dropped) and home-collection privilege extraction

Also verifies credentials-per-call: a call with credentials sends Basic
auth, a call with null sends none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN
…ones

kotlinx-datetime on the js/wasmJs targets has no time-zone database, so
TimeZone.of("Europe/Vienna") throws IllegalTimeZoneException in the browser.
That made 20 ICS timezone tests fail under :shared:allTests (they pass under
:shared:jvmTest, which uses the JDK's zones), and it also degraded the web
runtime: the ICS parser silently fell back to UTC for zoned entries, and
TimeZoneSerializer.deserialize would throw outright when loading a stored
entry with a named zone.

Add the @js-joda/timezone npm dependency to the shared web source set - the
documented remedy - which registers the tz database with the js-joda backend
kotlinx-datetime uses on both js and wasmJs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN
Declaring the @js-joda/timezone npm dependency was not sufficient: kotlinx-datetime
only uses it if the module is genuinely imported, and its tz-database registration
runs as an import side effect that webpack strips when nothing references it. So the
IANA database was never loaded and TimeZone.of("Europe/Vienna") kept throwing
IllegalTimeZoneException under :shared:allTests (js and wasmJs browser targets),
while :shared:jvmTest stayed green via the JDK's zones.

Add an @jsmodule external declaration for the module in the jsTest and wasmJsTest
source sets (they differ: js needs @JsNonModule, wasmJs does not) and reference it
from a real test. The reference forces webpack to emit the require, whose side
effect registers the database at module load - before any commonTest timezone test
class is constructed - so the existing ICS/date-time suite can resolve named zones
on the browser targets. Each test also asserts the database is present, guarding
against regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN
Replaces the two throwing js/wasmJs guard tests with a single expect/actual
'ensureTimeZoneDatabaseLoaded()'. The web actuals hold the @jsmodule declaration
for @js-joda/timezone (js keeps @JsNonModule, wasmJs does not) and reference it so
the bundler emits the import whose registration side effect runs at module load;
the wasmJs actual wraps the reference in runCatching because materializing the
side-effect-only module as a value throws on wasmJs (the earlier guard test failed
on exactly that, even though the database had already loaded). JVM/Android/iOS
actuals are no-ops.

This also applies the two follow-ups:
- Production web fix: the function is called from TimeZoneSerializer (init) and
  from parseIcalEntries, so the @js-joda/timezone import is pulled into the app
  bundle and named TZIDs resolve on the web target instead of silently degrading
  to UTC (parser) or throwing while deserializing a stored zoned entry (serializer).
- Defense in depth: TimeZoneSerializer.deserialize now falls back to UTC for
  unknown/unresolvable zone ids via runCatching, mirroring the ICS parser, instead
  of throwing.

A single commonTest TimeZoneDatabaseTest asserts named zones resolve on every
target; on js/wasmJs its call to ensureTimeZoneDatabaseLoaded() is what pulls the
import into the test bundle, before any other timezone test class is constructed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN
…actual

Replaces the expect fun ensureTimeZoneDatabaseLoaded() and its five actuals
(three of them no-ops on JVM/Android/iOS) with a single web-only loader in
webMain: loadTimeZoneDatabase() plus the @jsmodule declaration for
@js-joda/timezone, shared by both js and wasmJs. Loading the IANA tz database is
purely a web concern, so it no longer leaks into common code.

Production triggers it once from SecureStorageReadyGate (the web-only startup
wrapper all three apps already use), so the @js-joda/timezone import lands in the
app bundle and named zones resolve at startup. The two web test triggers
(jsTest/wasmJsTest) call the same loader so the import lands in the test bundles
too, before any timezone test class is constructed - :shared:allTests does not run
the app startup path.

TimeZoneSerializer.deserialize keeps its runCatching-to-UTC fallback for
unknown/unresolvable ids. The temporary calls added to the serializer init and to
parseIcalEntries are removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN
…t bundle

A transitive dependency (the ktor JS client) references Node core modules os and
path in code paths the browser never runs; webpack 5 drops the old auto-polyfills
and prints 'Module not found' for them when bundling spectacled-shared-test. Add a
shared/webpack.config.d entry mapping os and path to false (empty module), the same
resolve.fallback mechanism the app modules already use for sql.js, merged into the
existing resolve config so Kotlin's aliases are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN
Move withContext(ioDispatcher) out of the ViewModel launch sites and down into
the layer that actually does the I/O, the way the repositories already self-
dispatch, so ViewModels no longer name a dispatcher for network work:

- Both DefaultWebDavRemote*DataSource impls wrap every call in
  withContext(ioDispatcher) (network, plus iCal parsing/serialization and inline
  attachment file I/O on the fetch/put paths). The Compose Desktop rationale -
  the Main dispatcher not resuming Ktor continuations, which was the motivating
  hang - now lives here, at the layer it applies to.
- KtorRemoteGitHub{Contributor,Release}DataSource and KtorRemoteClaudeDataSource
  self-dispatch their network + JSON work.
- CredentialStore (all four platform actuals) self-dispatches its KSafe reads/
  writes; already suspend, so no caller changes.

ViewModel launches that were purely network/credential/repository revert to a
plain launch { }: AccountListViewModel's delete/discover/create-calendar and
DetailsViewModel's Claude request. AccountListViewModel no longer imports
ioDispatcher at all.

Deliberately left dispatched (with updated comments explaining why): AboutViewModel
(inline libraries.json parse), DetailsViewModel's sync (SyncCoordinator still reads
attachment bytes off disk directly) and the three attachment file-I/O launches, and
ListViewModel (CPU-bound recompute()). These remain because FileManager and the
UserAppPreferencesStore property accessors are synchronous by design - used in the
pure, tested ICS mapper, a composable, and theme reads - so making them suspend is
a larger, separate change tracked as the QUA-12 follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN
patrickunterwegs and others added 7 commits July 17, 2026 13:20
…ktop

QUA-12 moved withContext(ioDispatcher) out of the ViewModel launches and into the
data sources + the KSafe-backed CredentialStore, so ViewModels could launch on Main.
It works on Android but breaks credential loading on iOS and Desktop: the app reads
credentials back as null, prompts to update the password, and the password entry is
then unusable.

Root cause is that the ioDispatcher-at-the-launch-site pattern was load-bearing for
reasons this refactor's premise ignored:
  - CredentialStore is KSafe, a stateful store with its own cache/coroutine
    machinery. It was proven working when called from inside an ioDispatcher launch;
    wrapping its suspend calls in a nested withContext(ioDispatcher) (and/or invoking
    them from a Main-dispatched coroutine after reverting the launches) misbehaves on
    iOS/Native and Desktop while Android tolerates it - reads come back as the default
    (null).
  - On Compose Desktop the Main dispatcher doesn't reliably resume suspended
    continuations, which is exactly why these launches were on ioDispatcher to begin
    with; letting ViewModels launch on Main reintroduces that fragility.

Reverts the squashed #52 wholesale, restoring the known-good state on all platforms
(the later string-resource and password-sheet commits are untouched). The one
genuinely good idea in there - the network data sources dispatching their own IO for
the future extractable DAV library - can be reintroduced on its own later, with the
ViewModel launches left on ioDispatcher and the KSafe store left exactly as it is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN
… apps

Makes the Journals/Notes/Tasks web targets installable PWAs with offline shell
support, using the same webMain/resources serving mechanism DAT-6 already relies on.

Per app:
  - manifest.webmanifest: name/short_name, standalone display, start_url/scope '.'
    (relative, so it works under the /journals|/notes|/tasks deploy paths), the
    variant's brand theme_color (#006896 / #994c2c / #296f23), and a 512x512 icon
    copied from the app's existing ic_launcher-playstore.png.
  - service-worker.js: network-first for same-origin GETs of static shell assets,
    cache only as an offline fallback. Deliberately conservative - it never caches
    non-GET, cross-origin, or non-shell requests, so CalDAV/proxy traffic and the
    sql.js persistence worker are untouched, and nothing is served stale while
    online. Versioned cache, cleaned on activate.
  - index.html: <link rel=manifest>, apple-touch-icon, theme-color meta, and a
    guarded service-worker registration (no-op where unsupported / on plain HTTP).

BLD-6 was blocked on DAT-6 (web persistence), which has landed. Needs one live
browser pass to verify install + offline reload before calling it done - service
workers, like DAT-6, tend to only reveal issues in a real browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xt7MRuFpW1jtcHX1MPC4EN
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants