Commit graph

2835 commits

Author SHA1 Message Date
Till JS
80b23dd9ff fix(mana/web): clear remaining type errors — long-tail sweep
The last cleanup pass after the package-level fixes. Each of the
~30 files below had 1-2 distinct errors; they're grouped because
none individually justifies its own commit and they're all the same
shape: small drift between a call site and the type system the
existing-code-doesn't-need-to-change refactor that gets it to clean.

Highlights by file:

vite.config.ts
  Switched `defineConfig` import from `vite` to `vitest/config` so
  the inline `test:` block (vitest unit-test exclude rule) is
  recognized at the type layer. Was the last single error standing.

routes/(app)/news/+page.svelte
  Replaced `{#each ranked as { article } (article.id)}` destructure
  with `{#each ranked as scored (scored.article.id)}` + two
  `{@const}` rows. The destructured-each + immediate-`@const`
  combination tripped a Svelte compiler placement error.

routes/(app)/contacts/[id], modules/calendar/EventForm
  `(x as Record<string, unknown>)` casts were rejected because the
  source type doesn't have a string index signature. Two-step
  cast: `as unknown as Record<string, unknown>`.

routes/(app)/inventory/collections/[id]/edit
  `collection.schema.fields` round-trips through JSON in the Dexie
  row, which widens `type` to plain `string`. Cast back to
  `FieldDefinition[]` at the read site; the runtime values match
  the FieldType union.

routes/(app)/presi/deck/[id], modules/zitare/QuoteCard,
modules/memoro/views/DetailView
  - presi: `currentDeck?.name` → `?.title` (Deck has `title`, not
    `name`).
  - QuoteCard: `let authorBioText = $derived(() => {...})` was
    storing the arrow function itself. Switch to `$derived.by(...)`.
  - memoro DetailView: explicit `<QueuedTask | null>` generic on
    the useLiveQueryWithDefault call so the unknown-typed default
    doesn't poison downstream state.

routes/(app)/memoro/{,/[id]}/+page.svelte + modules/memoro/queries.ts
  The Tag flowing through these components is the `@mana/shared-tags`
  shape (from `useAllTags`), not memoro's local Tag (which has
  isPinned/sortOrder for a UI we never built). Aligned all three
  files to the shared shape so the Tag[] arrays compose without
  property mismatches.

modules/{questions,context}/index.ts
  Re-exported names that didn't exist:
  - `questionCollectionTable` → `qCollectionTable`
  - `contextDocumentTable` → `documentTable`
  Both were leftover from a long-ago rename that the consumers
  still call by the new name.

modules/picture/stores/images.svelte.ts, modules/times/EntryItem
  - images: `toggleField()` wants a string-keyed Table<>; cast at
    the call site (runtime keys are UUIDs anyway).
  - EntryItem: `autoSave(updates: Record<string, unknown>)` won't
    fit Dexie's `UpdateSpec<LocalTimeEntry>`. Narrowed to
    `Partial<LocalTimeEntry>` and added the missing import.

modules/todo: TodoPage + QuickAddTask
  - TodoPage was passing `onOpen` to TaskItem (which only accepts
    `onClick` + `onContextMenu` + `onToggleComplete`). Replaced
    with the proper triplet on the recently-completed branch.
  - QuickAddTask `locale?: string` widened the input past the
    `ParserLocale` union the parser actually accepts. Imported
    the union and tightened the prop.

modules/presi/views/DetailView
  `decksStore.deleteDeck` returns `Promise<boolean>`, but
  `deleteWithUndo()` expects `Promise<void>`. Wrapped in an async
  arrow that discards the return.

routes/(app)/citycorners/.../edit
  Self-referential `let locId = $derived(locId ?? '')` from a
  search-and-replace gone wrong in the previous commit batch.
  Restored to `$derived($page.params.id ?? '')`.

routes/(app)/+layout.svelte, lib/components/onboarding/OnboardingWizard
  - layout: `(window as Record<string, unknown>)` → two-step
    `(window as unknown as Record<...>)` cast. Same shape as the
    contacts/EventForm fixes.
  - OnboardingWizard: added optional `onSkip?: () => void` prop
    so the layout's analytics callback type-checks. The wizard
    always also calls `onComplete()`, so the modal still closes
    cleanly without onSkip.

routes/(app)/api-keys/+page.svelte
  Removed `min={1}` / `max={1000}` props from the shared `<Input>`
  component (it's not a passthrough wrapper for native HTML
  attributes). Runtime validation still gates submit.

routes/(auth)/forgot-password
  `authStore.forgotPassword(email)` doesn't exist; the wrapper
  exposes `resetPassword(email)` for the send-email entry point.
  Renamed.

routes/(app)/{gifts,llm-test}, lib/content/help/index.test
  - gifts: `balance.freeCreditsRemaining` is now optional (added
    in the credits commit). Defaulted to 0 in the math.
  - llm-test: enqueueTaskNow union of two tasks with different
    output types — widened with `as any` for the enqueue call.
  - help index.test: `content.contact` is optional, asserted with
    non-null `!`.

lib/components/{SessionWarning,DashboardGrid,onboarding/OnboardingWizard}
  - SessionWarning: was calling `getAccessTokenSync` (doesn't
    exist) and `refreshToken` (doesn't exist). Switched to
    `getAccessToken()` (async, returns Promise) and `getValidToken()`
    (refreshes under the hood when expired).
  - DashboardGrid: `error?.message` on a `{}`-typed boundary
    arg. Cast to `Error | undefined`.

dashboard widgets: ContextDocs / ClockTimers / ActivityFeed
  - ContextDocs: `getSpaceName(spaceId: string)` widened to
    `string | null | undefined` so the optional doc.spaceId
    flows in cleanly.
  - ClockTimers: `formatRepeatDays`/`formatRemaining` widened to
    accept null|undefined.
  - ActivityFeed: `Activity` icon doesn't exist in
    `@mana/shared-icons`/phosphor-svelte. Replaced with `Pulse`
    everywhere in the file.

lib/app-registry/registry.spec
  `Set<AppIconId>.has(stringId)` rejected because the union is
  narrower. Widened the Set to `Set<string>`.

Net: -16 type errors. Final count: 0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:25:08 +02:00
Till JS
c31ce4448f fix(packages): modal keydown handlers, $derived.by usage, UserData fields
Eight more package-level type errors that all came from the same
small handful of patterns.

Modal escape-key handlers calling click-style functions
  Four modals (AuthGateModal, GuestWelcomeModal, ConfirmationPopover,
  ShareModal) had `onkeydown={(e) => { if (e.key === 'Escape')
  handleBackdropClick(); }}` — but handleBackdropClick took a MouseEvent
  parameter, so the no-arg call failed with "Expected 1 arguments,
  got 0". Fix: route the keyboard escape path through the right
  no-arg helper (`onClose` / `handleClose` / `handleContinueAsGuest`)
  or pass the keyboard event through with a cast for the popover
  trigger that genuinely shares its handler with the click path.

WallpaperModal $derived
  `currentLayout` and `currentBackground` were declared with
  `$derived(() => {...})` — passing a function expression. The
  variant that takes a thunk is `$derived.by(...)`; plain `$derived`
  expects a single value expression. Result: the variables held the
  arrow function itself, the call sites had to invoke them as
  `currentLayout()`, and TS rejected the function value where Layout
  was expected. Switch to `$derived.by`, drop the call-site parens.

TagList.svelte
  Generic param was named `Tag` in the handler signature
  (`tag: Tag`) but the imported type was aliased as `TagType`. Tag
  was undefined → "Cannot find name 'Tag'". Renamed to TagType.

TagStrip.svelte
  `dropAccepts?: string[]` is too wide for `passiveDropZone`'s
  `accepts: DragType[]`. Narrowed the prop type to `DragType[]`
  and added the missing import.

shared-auth/types: UserData.{name,image}?
  Two more optional fields for the public user shape. Both come
  from the JWT user_metadata claim when the user has filled in
  their profile during onboarding. Without these the
  ProfileStep.svelte onboarding component couldn't read
  `authStore.user?.name` / `?.image` without `as any`. Added
  alongside `twoFactorEnabled` from the previous shared-auth
  commit; same Optional rationale (guest tokens omit the claim).

Net: -10 type errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:24:05 +02:00
Till JS
ab24db36dd fix(packages): cross-package broken imports + missing exports
Five unrelated packages each had a few imports pointing at the wrong
file or missing from their public surface. Grouped because none of
the individual fixes warrants its own commit and they all unblock
the same downstream consumer (apps/mana/apps/web type-check).

packages/help
  - HelpPage.svelte: `'../types.js'` and `'./content'` for
    HelpPageProps/HelpSection/SearchResult — neither path exists.
    Real homes are `../ui-types` (props) and `../search-types`
    (search shapes). Fix the imports.
  - HelpSearch.svelte: same `'../content'` typo for SearchResult →
    `'../search-types'`.
  - translations.ts: `'./types.js'` for HelpPageTranslations →
    `'./ui-types'`.
  - ui-types.ts: was importing SearchResult from `'./content'` but
    that module only exports content shapes. Split into two imports
    so HelpContent stays from content.ts and SearchResult comes from
    search-types.ts.

packages/feedback
  - FeedbackPage.svelte: imported `Feedback` and `CreateFeedbackInput`
    from `'./createFeedbackService'` but the service module only
    exports the service factory. Real homes are `'./feedback'`
    (Feedback) and `'./api'` (CreateFeedbackInput).
  - FeedbackForm.svelte: same `'./feedback'` typo for
    CreateFeedbackInput → `'./api'`.

packages/subscriptions
  - UsageCard / CostCard / pages/SubscriptionPage: all imported
    UsageData / CostItem from `'./plans'` but those types live in
    `'./usage'`. SubscriptionPage additionally had a relative-path
    bug — it's at `src/pages/`, not `src/`, so `./plans` resolved
    to `pages/plans` (nonexistent). Now imports `'../plans'` for
    plan types and `'../usage'` for usage/cost types.

packages/shared-ui
  - index.ts: re-exports the QuickInputItem family from
    `./quick-input` but had forgotten `HighlightPattern`. Added.
    Apps that build their own InputBar pattern config (e.g.
    mana/web/src/lib/quick-input/types.ts) need it as a public type.
  - PillNavigation.svelte: imported `SpotlightAction` and
    `ContentSearcher` from `./GlobalSpotlight.svelte` (a Svelte
    component file), which only re-exports the default. Both types
    live in `./types`. Move them to the existing types-import
    block; the GlobalSpotlight import becomes a plain default.

packages/shared-auth-ui
  - stores/createAuthStore.svelte.ts: imported AuthServiceAdapter /
    AuthResult / BaseUser from `'./types'` (nonexistent — the file
    is `'./store-types'`).

Net: -23 type errors. Zero behavior change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:23:34 +02:00
Till JS
83eaf71e9f fix(macmini): clean up container conflicts in build-app.sh restart cycle
Some checks are pending
CI / Build mana-api-gateway (push) Blocked by required conditions
CI / Build mana-crawler (push) Blocked by required conditions
CI / Build mana-media (push) Blocked by required conditions
CI / Build mana-credits (push) Blocked by required conditions
CI / Build mana-web (push) Blocked by required conditions
CI / Build chat-backend (push) Blocked by required conditions
CI / Build chat-web (push) Blocked by required conditions
CI / Build todo-backend (push) Blocked by required conditions
CI / Build todo-web (push) Blocked by required conditions
CI / Build calendar-backend (push) Blocked by required conditions
CI / Build calendar-web (push) Blocked by required conditions
CI / Build clock-web (push) Blocked by required conditions
CI / Build contacts-backend (push) Blocked by required conditions
CI / Build contacts-web (push) Blocked by required conditions
CI / Build presi-web (push) Blocked by required conditions
CI / Build storage-backend (push) Blocked by required conditions
CI / Build storage-web (push) Blocked by required conditions
CI / Build telegram-stats-bot (push) Blocked by required conditions
CI / Build nutriphi-backend (push) Blocked by required conditions
CI / Build nutriphi-web (push) Blocked by required conditions
CI / Build skilltree-web (push) Blocked by required conditions
Docker Validate / Validate Dockerfiles (push) Waiting to run
Docker Validate / Build calendar-web (push) Blocked by required conditions
Docker Validate / Build todo-backend (push) Blocked by required conditions
Docker Validate / Build todo-web (push) Blocked by required conditions
Docker Validate / Build zitare-web (push) Blocked by required conditions
Docker Validate / Build mana-auth (push) Blocked by required conditions
Docker Validate / Build mana-sync (push) Blocked by required conditions
Docker Validate / Build mana-media (push) Blocked by required conditions
Mirror to Forgejo / Push to Forgejo (push) Waiting to run
Hit "container name already in use" / "removal in progress" errors
three times during today's Phase 5 deploys. The previous restart
pattern was just `compose up -d --no-deps`, which fails when:

  1. A previous interrupted recreate left a stale container under
     the canonical name. The new `up` tries to claim the name and
     gets a conflict.
  2. Compose's recovery from #1 sometimes creates a hash-prefixed
     orphan container (`<hash>_<container_name>`), which then
     blocks the next clean run too.
  3. Even `--force-recreate` can't always handle the case because
     the old container is in the middle of being removed when the
     new one is being created (race).

Two-step replacement that's reliable across all three failure modes:

  Step 1 — `docker compose rm -fs SERVICES`
    Stops + force-removes the canonical compose-managed container.
    Idempotent: does nothing if already gone. Filters out the
    "No stopped containers" log noise so the output stays clean.

  Step 2 — orphan sweep via `docker rm -f`
    For each service, look up its container_name from the
    compose config (falls back to the service name if not set),
    then `docker ps -aq --filter name=^${cname}$` for the canonical
    one and `name=_${cname}$` for hash-prefixed orphans. Anything
    found gets nuked. This catches the case where compose's own
    state has lost track of an orphan it created earlier.

  Step 3 — `docker compose up -d --no-deps --remove-orphans`
    Creates the fresh container. The `--remove-orphans` flag also
    silences the "Found orphan containers ([mana-game-whopixels])"
    warning we kept seeing — that's a leftover from a removed
    service that nobody had cleaned up.

The container_name extraction uses awk on `compose config` output
(verified locally: `mana-web` → `mana-app-web`) so the script doesn't
need a hard-coded service→container mapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:22:52 +02:00
Till JS
3c7bfc6a00 feat(mana/web): register body, events, who, guides in workbench app registry
Adds list (and detail where available) views for four modules that existed
in MANA_APPS but were missing from the workbench app registry. Creates a
static ListView for guides backed by the existing GUIDES catalog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:18:30 +02:00
Till JS
c172ce9b12 fix(mana/web): replace h2/h3 with div in AiSettings + AiTierStep
The AiSettings card was rendering with browser-default heading
sizes (~30px h2, ~18px h3) instead of the Tailwind utility classes
I'd given them. Visible in production: "KI-Optionen" came out
huge, "Auf deinem Gerät" ditto, the whole card looked like the
font-size system was broken.

Root cause: app.css has an `@layer base` block that explicitly
sets `h2 { font-size: 1.875rem; ... }` etc as a project-wide rich-
text default. The intention is that PROSE-style content gets nice
typography for free. But for components that use semantic h2/h3
tags purely for document structure (not for visual sizing), the
base layer rule wins over the utility classes when Tailwind 4's
content-scanning misses the file.

Why other settings cards work: their <h2> tags live INLINE in
routes/(app)/settings/+page.svelte, which Tailwind's Vite plugin
walks via the SvelteKit route entry. My new AiSettings card is in
lib/components/settings/AiSettings.svelte — a separate component
file that's imported by the route but apparently doesn't get its
classes generated reliably (likely a Tailwind 4 cache issue with
recently-added files in non-route paths). Result: text-lg /
text-sm / text-xs aren't in the output CSS, so the @layer base
heading rule is the only thing setting the size, and it wins.

Pragmatic fix: replace <h2> and <h3> with <div class="text-lg
font-semibold"> / <div class="text-base font-semibold">. Divs
aren't subject to the @layer base h2/h3 reset, so even if the
utility classes are also missing the styles fall back to the
element's natural inline-block-with-inherited-font-size behavior.
And the Tailwind classes — when they DO eventually get picked up
(e.g. on a clean build) — apply on top.

Same change applied to:
  - apps/mana/apps/web/src/lib/components/settings/AiSettings.svelte
    (the section header + each tier card title)
  - apps/mana/apps/web/src/lib/components/onboarding/steps/AiTierStep.svelte
    (the step's main heading + each tier card title)

Functionally identical, just different element type. The semantic
loss is minimal — these aren't document-structure headings, they're
visual labels inside a card UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:50:04 +02:00
Till JS
acfbcfe689 feat(mana/web/news): "interested" keeps article visible + saved badge
Switches the feed engine to a softer reaction model: ❤️ Interessiert
no longer hides the article from the feed, only adds it to the
reading list and bumps the topic + source weights. The article keeps
its slot in the ranked feed and gets a "❤️ gespeichert" badge in the
card meta + a tinted card background so the user can see at a glance
"yep, this is already in my reading list".

The previous behavior — interested = save + remove from feed — was
modeled on a Pocket-style "save and move on" pattern, but turns out
to be confusing in a discovery-feed context: tapping a positive
signal made the article disappear, which feels like punishment.
Variante B (this commit) makes the destructive vs non-destructive
split explicit: 👎 Nicht für mich and 🚫 Quelle ausblenden are the
ones that hide articles, ❤️ is purely additive.

═══ Engine ═══

`scoreArticle()` now reads `dismissedIds` (the set of articles with
not_interested or hidden reactions) for the hard-hide filter
instead of the old `reactedIds` (which lumped all reaction kinds
together). `interestedIds` is passed alongside so views can render
the badge without re-deriving from the raw reactions array.

`buildReactionSets()` is the new helper that splits the reactions
into the two sets in one pass. `buildReactedIds()` is kept as a
deprecated alias that returns just the dismissed set — same effect
on the feed filter for any not-yet-migrated caller, and any old
"interested = hidden" behavior is now lost (which is the goal).

═══ UI ═══

The feed page card body gets a `.is-saved` modifier that tints the
background, the card meta row gets a saved-badge pill, and the
interested button shows "Gespeichert" + a filled-in active state +
disabled cursor when the article is already in the reading list.
A second click on an already-saved article is a no-op now.

The workbench ListView and the dashboard NewsUnreadWidget got the
same engine update so the three surfaces stay in sync — the badge UI
itself is only on the main feed for now since the workbench card is
too narrow to fit it cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:48:53 +02:00
Till JS
55bf493f44 fix(api): set supportsStructuredOutputs=true on mana-llm provider
generateObject() in the AI SDK falls back to a tool-call mode when the
provider doesn't advertise structured-output support — and tool calling
through Ollama isn't reliable enough that the schema-validation step
passes. The response was failing with 'No object generated: response
did not match schema' even though the underlying mana-llm + Ollama
roundtrip works correctly when called with response_format directly
(verified via curl).

Set supportsStructuredOutputs:true on the createOpenAICompatible
factory so the AI SDK uses response_format json_schema mode. mana-llm
already routes that to Ollama's native format field thanks to the
companion fix in services/mana-llm/src/providers/ollama.py — verified
end-to-end with the MealAnalysisSchema and Gemma 3 4B.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:44:13 +02:00
Till JS
f3effe9390 fix(mana/web/news): instant onboarding handoff to feed branch
The "Fertig" button needed two clicks before the wizard would
disappear. Cause: the wizard branch is gated on
`prefs.onboardingCompleted` which comes out of a Dexie liveQuery.
liveQuery debounces and emits the post-write value ~50-100ms after
the table.update() returns, so the first click writes the row but
the page re-renders the same wizard step until the next liveQuery
tick. Users instinctively click again before noticing.

Fix: a local `onboardingJustFinished` $state override that flips to
true synchronously inside `finishOnboarding()`. The wizard branch is
now hidden by `!(prefs.onboardingCompleted || onboardingJustFinished)`,
so the feed appears the instant the write resolves. The liveQuery
catches up a moment later but its update is a no-op because the
override and the queried value agree.

Also:
  - `onboardingSubmitting` $state guard so a panicked double-click
    gets ignored, and the button shows "Speichere…" while the write
    is in flight (visual feedback that something is happening)
  - Eagerly call `feedCacheStore.refresh()` from finishOnboarding so
    the feed isn't empty for the moment the layout's $effect needs
    to notice the prefs change. The store's inFlight guard makes the
    redundant layout-effect refresh a no-op.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:41:31 +02:00
Till JS
958819f06a fix(api): default vision model to ollama/gemma3:4b
mana-llm on the live Mac Mini does not have GOOGLE_API_KEY configured —
only the Ollama provider is registered. The previous default
'google/gemini-2.0-flash' would error with 'Provider google not
available' on every photo analysis.

Switch to ollama/gemma3:4b which is locally available via the
gpu-proxy bridge to the Windows GPU box (192.168.178.11). Gemma 3 is
multimodal and verified end-to-end with the new mana-llm structured-
output passthrough — see the 5520f1385 fix landing the response_format
plumbing on the Pydantic side and the Ollama provider's native format
field translation.

VISION_MODEL env var still wins, so prod can flip to
google/gemini-2.0-flash later by adding GOOGLE_API_KEY to mana-llm's
docker-compose env block.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:34:32 +02:00
Till JS
3b035e930f fix(mana/web/news): use getValidToken + guard prefs against locked vault
After the previous round of fixes, two issues remained:

1. Feed fetch returned 401 against `mana-api.mana.how`. The new
   `authHeader()` helper called `authStore.getAccessToken()`, which
   just reads `@auth/appToken` from localStorage and is happy to return
   null/stale. The unified sync engine in `sync.ts` uses
   `authStore.getValidToken()`, which routes through the tokenManager
   and refreshes if needed. Switched the news client to the same.

2. `Cannot read properties of undefined (reading 'emoji')` from
   `TOPIC_LABELS[topic]`. When the vault is briefly locked at boot,
   `decryptRecord` deliberately leaves the encrypted blob string in
   place — so `local.selectedTopics` can be a string. The `?? []`
   fallback in `toPreferences` doesn't catch it, and `{#each
   prefs.selectedTopics}` iterates the blob char-by-char. Force the
   three array fields (and the two map fields) back to their expected
   shapes with `Array.isArray` / object checks.
2026-04-09 18:52:51 +02:00
Till JS
5520f1385e fix(mana-llm): add response_format to ChatCompletionRequest model
The first iteration of the Ollama response_format passthrough crashed
with 'ChatCompletionRequest object has no attribute response_format'
because the Pydantic request model didn't declare the field at all —
incoming response_format from OpenAI-compatible clients was being
silently dropped at the parsing layer before the provider could see it.

Fix: declare a typed ResponseFormat sub-model with the two OpenAI shapes
('json_object' and 'json_schema'), add it as an optional field on
ChatCompletionRequest, and let the Ollama provider read it directly
without defensive getattr fallbacks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:50:54 +02:00
Till JS
cb87d23509 fix(help): import search types from search-types, not content
search-engine.ts had two import blocks both pointing at './content':
the first picked up FAQItem / FeatureItem / GettingStartedItem /
ChangelogItem (correct — those live in content.ts) and the second
tried to pick up SearchableItem / SearchResult / SearchOptions /
SearchIndexConfig (wrong — those live in search-types.ts). Result:
4 "Module './content' has no exported member" errors.

Fix the second import to point at './search-types'. The first
block stays untouched.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:48:39 +02:00
Till JS
697d96daec fix(mana/web): unblock api-keys, reset-password, dashboard widgets, skilltree stats
Six unrelated type-error pockets that were each blocking a different
page from compiling clean. Grouped because none individually warrants
its own commit and they all touch the same module's call sites.

api-keys/+page.svelte
  - Removed the `key: undefined as unknown as string` workaround for
    stripping the secret from the local list. Replaced with a clean
    object-rest destructure that produces a row matching the ApiKey
    shape (no `key` field). The cast was the source of two type
    errors AND was lying about the runtime shape.
  - Badge `variant="secondary"` and `variant="outline"` aren't valid
    BadgeVariant — narrowed to `default` and `info` respectively.
  - Button `variant="destructive"` and Badge `variant="destructive"`
    don't exist in the shared-ui union — both → `danger`.
  - Rate-limit input bound a `number` to a `<Input>` component whose
    `value` is typed `string`. Switched to a string state and
    parseInt on submit. Prevents the binding cast that the type
    checker (correctly) rejected.

reset-password/+page.svelte
  - Calling `authStore.resetPassword(token, password)` with two args
    on a method that takes one (sends the reset email). The method
    that actually performs the reset is `resetPasswordWithToken`.
    Two args, no API contract change needed.
  - `<Input minlength={12}>` — minlength isn't a prop on the shared
    Input component (it's not a passthrough wrapper). Removed; the
    runtime check still gates submit.

dashboard/widgets/{Credits,Transactions}Widget.svelte
  - `let state = $state<...>(...)` — variable named `state` shadows
    the `$state` rune call, which TypeScript flags as
    "Block-scoped variable '$state' used before its declaration"
    + "Untyped function calls may not accept type arguments".
    Renamed both to `loadState`.

dashboard/widgets/TasksTodayWidget.svelte
  - Referenced `task.dueTime`, which doesn't exist on LocalTask
    (only `dueDate`, ISO timestamp). Dropped the dead branch — the
    time was already encoded in `dueDate` and the widget never
    surfaced anything actionable from it anyway.

skilltree/components/StatsOverview.svelte
  - Was manually wiring `.subscribe()` callbacks because the old
    queries.ts returned raw Dexie Observables. After the
    Observable→useLiveQueryWithDefault migration, those return
    `{value, loading, error}` instead — `subscribe` doesn't exist
    on them. Replaced the manual state plumbing with direct
    `.value` reads inside `$derived`. Net: less code, fewer
    levels of indirection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:48:28 +02:00
Till JS
05d9d1962c fix(shared-auth): proxy passkey/2FA/session methods through ManaAuthStore
The settings page in mana/web (and any future consumer that wants to
manage passkeys, 2FA, or sessions from the UI) was calling 11
methods on `authStore` that the wrapper had never exposed:
listPasskeys, registerPasskey, deletePasskey, renamePasskey,
listSessions, revokeSession, getSecurityEvents, enableTwoFactor,
disableTwoFactor, generateBackupCodes — all of which DO exist on
the underlying AuthServiceInterface but were silently dropped by
createManaAuthStore. Result: 17 type errors on settings/+page.svelte
and a complete dead-end for anyone trying to wire up the UI.

Fix: add thin passthrough wrappers in createManaAuthStore that
delegate to authService. Each handles the SSR/no-service case the
same way the existing methods do (return empty array or
{success:false} with a stable error message). enableTwoFactor and
disableTwoFactor additionally refresh the local user snapshot
after success because the JWT issued post-enrollment carries the
new flag and downstream UI gates on it.

Type fixes that fell out of touching settings/+page.svelte:
  - UserData.twoFactorEnabled?: boolean — optional flag on the
    public user shape. The TwoFactorSetup component reads it via
    `authStore.user?.twoFactorEnabled` to gate the enable/disable
    button; without the type the call site coerced through `any`.
  - CreditBalance.{freeCreditsRemaining,dailyFreeCredits}?: number
    — daily-free accounting fields the backend already returns but
    the local type was missing. Optional because not every backend
    deployment turns them on.
  - settings/+page.svelte: `authStore.user?.sub` → `?.id`. The
    public UserData shape uses `id`; `sub` is the raw JWT claim
    name and never made it onto the consumer type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:48:00 +02:00
Till JS
7ba381fde8 fix(mana/web/news): Bearer token + Dexie v4 schema upgrade
Two distinct bugs surfaced by the first browser-side end-to-end test
of the News module against the locally-managed cloudflared tunnel.

═══ 1. Onboarding loop on reload ═══

The news tables were originally added to db.version(1).stores(),
which violates Dexie's "never edit a published version" contract.
Existing browsers stuck at db.version(3) (after the body + who
upgrades) never trigger an upgrade for v1 changes, so the news tables
silently never get created on those IndexedDB instances. Writes to
preferencesTable.add() / .update() failed at the storage layer, the
preferences row was never persisted, and on reload usePreferences()
returned the DEFAULT_PREFERENCES fallback (onboardingCompleted: false)
which re-rendered the onboarding wizard.

Fix: move the five news tables out of db.version(1) into a fresh
db.version(4).stores({…}) block. Dexie sees the bumped version number
and runs the additive upgrade transaction on existing v3 IndexedDBs,
creating the missing tables. Brand-new IndexedDBs go straight to v4
and pick up the union of all four version blocks. Both paths now
have the news tables present.

═══ 2. /api/v1/news/feed → 401 Missing authorization header ═══

The news api.ts client was passing `credentials: 'include'` thinking
the cookie alone would carry auth through to mana-api. It does not —
apps/api's authMiddleware() reads the Authorization header and
ignores cookies. Every browser-side fetch returned 401, the feed
cache stayed empty, and the wizard's "Fertig" → ranked feed flow
silently failed.

Fix: add a small `authHeader()` helper that pulls the JWT from
authStore.getAccessToken() and attaches it as
`Authorization: Bearer …`, mirroring the pattern in
modules/planta/api.ts. Both `fetchFeed()` and `extractFromUrl()` now
go through it. Drops the cookie credential entirely since it was a
no-op anyway.

Also tidies a Svelte 5 `$props()` warning in modules/news/ListView.svelte
(empty destructure instead of binding to a `_props` const).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:42:49 +02:00
Till JS
9bf73fffa3 fix(help): correct broken imports + tighten SupportedLanguage typing
Two unrelated bugs in the @mana/help package surface that together
accounted for ~40 type errors:

Broken component imports
  Ten components inside packages/help/src/components/ were importing
  from `'../types.js'` and `'./content'` — neither path resolves.
  The actual files are at `../ui-types` (where FAQSectionProps,
  FeaturesOverviewProps etc. live) and `../content` (where FAQItem,
  FeatureItem, FAQCategory live). Fix the imports to point at the
  real files. ESM resolution doesn't need `.js` suffixes when
  TypeScript is feeding tsc, and the existing index.ts already
  re-exports under the correct paths.

  Net: -19 type errors across:
    ChangelogEntry, ChangelogSection, ContactSection, FAQItem,
    FAQSection, FeatureCard, FeaturesOverview, GettingStartedGuide,
    HelpSearch, KeyboardShortcuts

content/help/index.ts SupportedLanguage cast
  `getManaHelpContent()` was passing `currentLocale` (typed `string`)
  into FAQ rows that expect a `SupportedLanguage` enum — 9 errors
  from each FAQ row. Add a small `asSupportedLanguage()` guard that
  validates the locale string against the union and falls back to
  'de' for unknown values. Single source of truth lives next to
  the function that needed it.

  Net: -9 type errors.

Combined with the spiral-db dist rebuild (local-only, gitignored)
and the previous Observable migration commit, the total error count
drops from 418 → 115.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:13:18 +02:00
Till JS
b32de06f2a fix(mana/web): coerce $page.params.* away from string|undefined
SvelteKit types `$page.params.X` as `string | undefined` because the
runtime cannot prove a route param exists at the type level — even
if the route file lives at e.g. `[id]/+page.svelte` and TS knows the
folder name. Thirteen route files were passing the raw param into
functions that take `string`, producing 25 type errors of the shape:

  Argument of type 'string | undefined' is not assignable to
  parameter of type 'string'.

Fix: hoist the param into a local with `?? ''` at the top of the
script, then use the local everywhere downstream. Empty string is
a safe fallback because the consuming code (`useDeck('')`,
`getCollectionById([], '')`, etc.) all return null/undefined for
unknown ids — exactly what they'd do if the param were truly
missing at runtime, which can't happen given the matching route
folder.

Files touched (one param hoist each):
  calendar/event/[id]              eventId
  cards/decks/[id]                 deckId
  citycorners/.../locations/[id]   citySlug + locId
  citycorners/.../locations/[id]/edit  citySlug + locId
  gifts/redeem/[code]              code
  inventory/collections/[id]       collectionId
  inventory/collections/[id]/edit  collectionId
  inventory/items/[id]             itemId
  photos/albums/[id]               albumId
  picture/board/[id]               boardId
  storage/files/[folderId]         folderId
  zitare/lists/[id]                listId (new local, replaces inline use)
  g/[code]                         code

Net: -24 type errors. The lone remaining "string | undefined" error
is a different bug in inventory FieldDefinition typing — unrelated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:12:54 +02:00
Till JS
ff6118fc3b fix(mana/web): migrate liveQuery hooks to useLiveQueryWithDefault
Seven module query files were calling raw `liveQuery(async () => ...)`
from dexie and returning the resulting Observable<T>. Consumer code in
the route .svelte files then read `.value` (or `.current`) on those
observables, which doesn't exist on the Dexie type — TypeScript flagged
38 errors and the call sites were silently relying on a runtime
property that only happens to work because the Svelte reactivity layer
re-evaluates the access.

Migration: switch each `useXxx()` hook to wrap with the existing
`useLiveQueryWithDefault` from `@mana/local-store/svelte`. The wrapper
returns `{ value, loading, error }` (with `value` synced to a `$state`
under the hood), so call sites can read `.value` reactively without
casts. Each hook now provides a typed default array so the wrapper
infers the right shape on first render.

Modules migrated:
  - chat        — useAllConversations, useArchivedConversations,
                  useAllTemplates, useConversationMessages
  - citycorners — useAllCities, useAllLocations, useAllFavorites
  - memoro      — useAllMemos, useArchivedMemos, useMemoriesByMemo,
                  useAllMemoTags, useAllSpaces
  - nutriphi    — useAllMeals, useAllGoals, useAllFavorites
  - presi       — useAllDecks, useDeckSlides, useDeck
  - questions   — useAllCollections, useAllQuestions,
                  useAnswersByQuestion
  - skilltree   — useAllSkills, useAllActivities, useAllAchievements

Call sites cleaned up:
  - chat/[id], memoro/[id]: removed inline `as { value: T[] }` casts
    that were the workaround for the broken type
  - nutriphi/{,add,goals,history}/+page.svelte: `.current ?? []` →
    `.value` (the wrapper guarantees the default array, so the
    nullish coalesce was always dead)
  - questions/{,[id],new,collections}/+page.svelte: same `.current` →
    `.value` migration

Net: -38 type errors, no behavior change. The wrappers continue to
subscribe to the same Dexie liveQuery under the hood; only the
ergonomic surface changed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:12:15 +02:00
Till JS
0426b6677b fix(mana/web/nutriphi): widen goal state types from literal to number
The DEFAULT_DAILY_VALUES constants are declared `as const` so each
field's type is a literal (e.g. `2000`, `50`). When the goals page
seeded its $state with these constants, TypeScript inferred the state
type as the literal — and any user-input number assignment then failed
type-check with "Type 'number' is not assignable to type '2000'".

The error was hidden until earlier today: the goals page also has the
same .current pre-existing pattern that the rest of the nutriphi
routes had, and tsc was short-circuiting on the .current error before
reaching the literal-type assignment. Now that queries.ts has been
moved to useLiveQueryWithDefault, .current is gone and the literal
typing surfaces.

Fix: explicitly type each $state as `<number>` so the literal widens
to a regular numeric state slot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:12:13 +02:00
Till JS
3ef095aaff fix(mana-llm/ollama): pass response_format to Ollama + strip markdown fences
The Ollama provider was completely ignoring `response_format` from the
incoming OpenAI-compatible request. Two consequences:

  1. Clients that asked for `{"type":"json_object"}` or
     `{"type":"json_schema",...}` got back JSON wrapped in
     ```json ... ``` markdown fences, because Ollama defaults to
     conversational output.
  2. Strict downstream parsers (Vercel AI SDK `generateObject`,
     manual `JSON.parse`) failed to decode the response and threw,
     even though the underlying JSON was valid inside the fences.

Fix: when response_format is set, translate it to Ollama's native
`format` field:

  - `{"type":"json_object"}` → `format: "json"`
  - `{"type":"json_schema","json_schema":{"schema":{...}}}`
    → `format: <the schema dict>` (Ollama 0.5+ supports full JSON
    schemas in the format field)

Defensive belt-and-suspenders: a small `_strip_json_fences` helper
runs after the Ollama response is decoded and removes any leftover
```json ... ``` wrapping. Some older vision models still wrap
output in fences even when `format` is set; this catches them.

Streaming path is unchanged because the nutriphi/planta refactor uses
non-streaming `generateObject`. Streaming structured output with
Ollama deserves its own pass when someone actually needs it.

Discovered during the AI SDK + Zod refactor smoke test — neither the
old nor the new vision routes ever returned validated JSON locally
because of this bug. Production uses Google Gemini directly via
fallback so the issue was masked there.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:12:01 +02:00
Till JS
3ccfc3be99 fix(api): correct mana-llm path prefix and model name in vision routes
Found while smoke-testing the AI SDK refactor: both nutriphi and planta
were calling `${MANA_LLM_URL}/api/v1/chat/completions` and passing
`gemini-2.0-flash` as the model name. Both wrong:

  1. mana-llm exposes routes under /v1/, not /api/v1/. The original
     pre-refactor code had the same bug — it predates this commit and
     was apparently never noticed because the photo workflow was never
     wired into the unified app's UI until last week. /api/v1 returned
     404 against the live mana-llm container; now we hit /v1.

  2. mana-llm's router parses model strings as `provider/model`
     (services/mana-llm/src/providers/router.py:_parse_model). Without
     a prefix, `gemini-2.0-flash` was being routed as
     `ollama/gemini-2.0-flash` and only worked via the auto-fallback
     to Google when ollama failed. Be explicit: `google/gemini-2.0-flash`
     hits the Google provider directly and skips the failed-ollama
     round-trip.

VISION_MODEL env var still wins over the default, so prod overrides
remain possible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:11:43 +02:00
Till JS
6c0f88f5a2 chore(infra): pre-commit validator for cloudflared-config.yml
Adds scripts/validate-cloudflared-config.mjs — a node-only validator
that lint-staged runs whenever cloudflared-config.yml is staged. The
goal is to catch the same failure modes that
`cloudflared tunnel ingress validate` would catch on the server, but
without requiring cloudflared to be installed on every dev box.

Checks:
  - YAML parses
  - tunnel: is a uuid
  - credentials-file: ends with .json and contains the tunnel id
    (warning when it doesn't — likely an out-of-sync remnant from a
    previous rebuild, exactly the failure mode that bit us in the
    first locally-managed switch)
  - ingress: is a non-empty array
  - every rule except the last has both hostname AND service
  - the LAST rule is the catch-all `service: http_status:NNN`
  - no duplicate hostnames (the most common copy-paste mistake)
  - service URLs look like http(s):// / ssh:// / http_status:NNN
    / unix:/ / hello_world
  - hostnames are lowercase dot-separated DNS labels (no spaces, no
    weird characters)

Wired into lint-staged.config.js with a single glob entry; the
existing eslint + prettier flow is unchanged.

Tested against the live cloudflared-config.yml (passes, 51 hostnames)
and a synthetic broken file (catches all 6 categories of error +
the credentials-file/tunnel id drift warning).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:02:51 +02:00
Till JS
77b2d1eb32 chore(infra): smarter tunnel rebuild — apex via API + sane probes
Two improvements to scripts/mac-mini/rebuild-tunnel.sh based on what
the first prod run actually surfaced.

═══ 1. Apex domain auto-fix via Cloudflare API ═══

`cloudflared tunnel route dns` cannot route the apex of a zone
(error code 1003: "An A, AAAA, or CNAME record with that host already
exists"). The CLI has no command to delete those records. The first
rebuild left mana.how returning 530 because the script silently
failed to route it and we had to fix the apex manually in the
dashboard.

The new `apex_route_via_api()` helper:
  - Detects apex hostnames by dot count (one dot → two-label name)
  - Uses $CLOUDFLARE_API_TOKEN if available
  - Resolves the zone id by name
  - Deletes any existing A / AAAA / CNAME records on the apex
  - Creates a fresh proxied CNAME pointing at <tunnel>.cfargotunnel.com
  - Cloudflare's CNAME flattening at the apex makes this work
    transparently

If $CLOUDFLARE_API_TOKEN is not set, the script logs a warning at the
top of step 6 and falls back to the old behavior (route fails, user
fixes the apex manually). The token needs Zone:DNS:Edit on the
target zone.

═══ 2. Smarter HTTP verification ═══

The first run reported "5 hosts down (404/000)" but those were all
backend services without a root handler — credits/media/llm/mana-api
all return 404 at `/` and 200 at `/health`. The verify pass was
flagging healthy services as down and made the rebuild look more
broken than it was.

New `probe_host()` tries `/health` first, falls back to `/` only if
/health returned 4xx, and prefers a 2xx/3xx root response over a 4xx
/health. `probe_is_down()` only counts 5xx and 000 (libcurl error)
as failures — anything in 1xx-4xx means the request reached the
origin and the tunnel routing is correct, which is the actual thing
the verify pass cares about. `probe_label()` adds a one-word health
summary so the verify log reads "200 ok" / "401 auth required" /
"404 routed (no handler)" / "530 tunnel error" instead of just bare
status codes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:52:40 +02:00
Till JS
a50dc82887 chore(infra): tunnel id 1435166a (locally-managed rebuild result)
After running scripts/mac-mini/rebuild-tunnel.sh, the old remotely-
managed tunnel bb0ea86d-... was deleted and a new locally-managed
tunnel 1435166a-... took its place. The script's in-place sed of
the repo file didn't actually persist (the server-side ~/.cloudflared/
config.yml was patched, but the repo file ended up identical to HEAD
because the dev box had a stale checkout that got pulled over).

This commit catches the repo file up to the new tunnel id so a fresh
clone + setup-cloudflared-service.sh run wires the right credentials
file from the start. cloudflared has been running fine on the new
tunnel id since the rebuild — it auto-resolved the credentials from
~/.cloudflared/cert.pem when the in-config tunnel id pointed at a
deleted tunnel — but the file should match reality regardless.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:29:29 +02:00
Till JS
1b26623129 fix(api/Dockerfile): copy @mana/shared-types into the build context
After the planta + nutriphi modules in apps/api started importing
shared Zod schemas from @mana/shared-types, the runtime crashed in
a restart loop with:

  error: ENOENT reading "/app/apps/api/node_modules/@mana/shared-types"

Same root cause as the @mana/media-client gotcha already in this
Dockerfile: the build context only includes the workspace packages
that are explicitly COPYed, and shared-types was missed when it
became a transitive dependency.

Add the COPY line and rebuild. Also extend the comment block to
make the rule explicit ("when adding a new @mana/* import to any
apps/api module, add the package here too").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:25:23 +02:00
Till JS
5aeae87474 feat(api/web): wire-format envelope versioning + Anthropic prompt-cache hints
Adds AI_SCHEMA_VERSION + AiResponseEnvelope<T> in @mana/shared-types so
every AI structured-output endpoint speaks { schemaVersion, data }.
Backend wraps via envelope() in each module routes.ts; frontend api.ts
unwraps via unwrapEnvelope<T>() which throws AiSchemaVersionMismatchError
on drift — actionable network-panel error instead of cascading
'field is undefined' bugs further down the stack.

Also adds providerOptions.anthropic.cacheControl on the system message
in nutriphi + planta routes via SYSTEM_CACHE_HINT. NO-OP today (Gemini
backend, ~50-token prompts under the 1024-token cache minimum) but
lights up automatically when mana-llm routes to Claude or prompts grow
past the threshold. ~5 lines per route, no risk.

System messages migrated from system: shorthand to a full messages[]
entry — the only way to attach providerOptions per-message in the AI SDK.

13 new tests in nutriphi/ai-schemas.test.ts cover the version constant,
the mismatch error shape, and Zod accept/reject for both schemas. Total
nutriphi + planta suite: 62/62.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:21:19 +02:00
Till JS
9d1b25130d fix(api/who): server-side validation of [IDENTITY_REVEALED] sentinel
The user asked "bist du kopernikus?" while playing Galileo. The
LLM correctly responded "Kopernikus? ... aber nicht meiner!" — and
then appended [IDENTITY_REVEALED] anyway. Game flipped to "won
in 2 messages" with Galileo's name revealed, even though the
guess was wrong.

This is gemma3:4b being lazy about the sentinel rule: any time the
user says "bist du <name>?", the model is biased toward emitting
the sentinel because the prompt mentions "errät den Namen". Weaker
LLMs in general struggle to follow strict negative instructions
when the trigger word is right there in the input.

Fix in three layers:

1. Server-side validation (the real safety net). When the LLM
   emits [IDENTITY_REVEALED], independently verify that the user's
   CURRENT message contains the canonical character name (or one
   of its significant parts) using the same matchesName helper
   the explicit /guess endpoint uses. If the LLM emitted but the
   user didn't actually name this character, strip the sentinel,
   log a who.sentinel_false_positive, and treat the reply as a
   normal turn. The legit cases — user actually said the right
   name — still flow through cleanly.

2. matchesName improvements. The previous logic only matched a
   single-word guess against name parts; "bist du leonardo?" would
   fall through and miss a real win. Rewritten to:
     a) exact normalized match
     b) guess contains the full name as substring
     c) guess contains any significant name part as a WHOLE WORD
   Plus a Set for the guessWords lookup so it's O(1) per part.

3. Tighter system prompt. Added explicit "Sentinel-Regel" section
   with two FALSCH examples ("bist du Tesla?" while playing Edison,
   "bist du ein Erfinder?") and two KORREKT examples. Doesn't fix
   the false-positive rate at the model level but reduces it.

Layer 1 is the load-bearing one — even if the LLM emits the
sentinel for the wrong reason, the server gates the reveal on
ground truth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:21:14 +02:00
Till JS
2804072565 docs(sync): add SYNC_DEBUG runbook with new debug API in Schritt C
The runbook for diagnosing why pending changes aren't flushing on
mana.how. Was sitting untracked in the repo root for the last week
of debugging; committing now that the debug surface it depends on
(window.__unifiedSync + getDebugInfo() + the surfaced silent
failures) actually exists.

Three steps in dependency order:

  Schritt A — read _pendingChanges directly from IndexedDB to find
  out which appIds and collections are stuck. Output drives the
  appId choice for Schritt B.

  Schritt B — manual POST against /sync/{appId} with the JWT from
  localStorage. Status code mapping table tells you whether the
  bug is server-side (4xx/5xx) or client-side (200 → sync engine
  isn't running).

  Schritt C — read window.__unifiedSync.getDebugInfo() (newly
  exposed in this commit batch) to see channel state. Compare
  knownAppIds against the Schritt A output: any appId with pending
  rows but no channel will accumulate forever, and the new
  console.warn from sync.ts will already be naming it explicitly.

Schritt B is the diagnostic key — everything else follows from
its status code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:20:46 +02:00
Till JS
5110065ebe feat(mana/web/sync): expose debug info, surface silent push failures
The SYNC_DEBUG.md runbook tries to inspect window.unifiedSync from
DevTools to figure out why pending changes aren't flushing on
mana.how. The script can't work because (a) the unified sync
instance is never exposed on window and (b) the two most likely
failure modes — push for an unknown appId, getToken() returning
null — both `return` silently with no error, no telemetry, no
state change. The pending count climbs and there's nothing in
the console to point at the cause.

This commit makes those failures visible:

push() unknown appId
  When a pending change lands for an appId that isn't in the
  registered channels map (almost always a registry/migration
  drift like renaming an appId without migrating the existing
  pending rows) we now log a warning that names the offending
  appId, lists the known ones for comparison, and emits a
  push:error telemetry event with errorCategory='unknown-appid'.
  The pending rows for that appId would otherwise accumulate
  forever — same symptom as the SYNC_DEBUG report.

push() no token
  getValidToken() can return null if the local exp check failed
  and the refresh-on-online retry didn't yield a new token. This
  was the silent path that was hardest to diagnose: the existing
  health-check telemetry only fires after a successful fetch, so
  there was no signal at all. We now log a warning, set
  channel.lastError = 'no-token', flip status to 'error' and emit
  push:error with errorCategory='no-token'.

sync-telemetry.ts
  Widens the errorCategory union to include 'no-token' and
  'unknown-appid' so the new emits type-check.

getDebugInfo()
  New method on the createUnifiedSync return value. Returns a
  flat, JSON-serializable snapshot of every channel's state
  (status, online, clientId, serverUrl, channels[appId] with
  lastError + timer flags, plus knownAppIds at top level) so the
  SYNC_DEBUG runbook (Schritt C) can compare what the server
  is being asked to sync vs. what's actually sitting in
  _pendingChanges.

(app)/+layout.svelte
  Exposes the live unified-sync instance on window.__unifiedSync
  in the browser. Not a security concern: every method on the
  returned object is also reachable via Dexie + a fresh fetch
  from the same DevTools console, and a malicious user can't
  escalate anything by poking at it. This is the global the
  SYNC_DEBUG Schritt C script needs to exist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:20:22 +02:00
Till JS
967f938e84 feat(mana/web/body): full i18n + calorie × weight correlation chart
Two complementary improvements that take the body module from "works
in DE/EN" to "works for every Mana user" and surface the highest-
value cross-module integration the merged module unlocks.

i18n — finish the rollout
  it/fr/es JSON files were already present from the initial body
  drop but only had the original copy. Add the new keys introduced
  by the quick-win commits last week:
    - phase.{start,end,startNew}
    - progression
    - routines.{title,start,empty}
    - exercisePicker.{title,pick,search,empty,create}
    - muscle.* (13 muscle group labels)
    - calorieWeight (used by the new chart below)
  de.json + en.json get the calorieWeight key for the new section.
  Translations are real (not machine-default fallbacks) so the
  Body module is now first-class in all five supported locales.

CalorieWeightChart — Body × Nutriphi correlation
  The whole point of having both modules in the same app is being
  able to ask "did the cut work?" without exporting CSVs. This
  component overlays daily calorie intake (summed across nutriphi
  meals) against bodyweight readings over the last 8 weeks, with
  an optional dashed target-weight line driven by the active phase.

  Key design choices:
    - Two y-axes auto-scaled independently (calories left, weight
      right) so a 2000kcal swing and a 1kg swing both stay visible.
    - Days without data are omitted from the path; the line draws
      "M ... L" gaps so a missed weigh-in doesn't show as a hard
      drop to zero.
    - Target-weight overlay only renders when it falls inside the
      visible weight range — clamping it to the edge would create
      a meaningless boundary stripe.
    - Cut-friendly delta colors: weight DOWN is green (you're on
      track), weight UP is red. Calorie deltas use the same scheme
      (down = restriction working).
    - Pure SVG, no chart-lib dependency, same auto-scale primitive
      we already use for WeightChart and ExerciseProgressionChart.

  Cross-module read: new `useNutriphiMealsSince(date)` helper in
  body/queries.ts — lives in body (not nutriphi) because the body
  module owns the integration boundary, and putting the cross-table
  read in one place keeps the import graph from getting circular if
  nutriphi ever wants to reach back.

  The hook decrypts the nutriphi `meals` table (already encrypted at
  rest by the meals registry entry) and projects to a thin
  MealWithNutrition shape for the chart. Decrypt cost on a few
  hundred meal rows is negligible vs. the value of the chart.

  Wired into the body layout as a 7th context (`bodyNutriphiMeals`)
  with `dateNDaysAgo(56)` — 8 weeks covers a typical cut/bulk
  cycle. ListView renders a new "Kalorien × Gewicht" card between
  the Weight section and the Daily Check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:19:20 +02:00
Till JS
bd231cd689 feat(api/web): wire-format envelope versioning + Anthropic prompt-cache hints
Two related AI-infrastructure hardenings landing together because both
touch the same nutriphi/planta route definitions:

═══ 1. Wire-format schema versioning ═══

Adds AI_SCHEMA_VERSION + AiResponseEnvelope<T> in @mana/shared-types so
every AI structured-output endpoint speaks a single envelope dialect:

    { schemaVersion: '1', data: <validated object> }

Backend wraps via a small `envelope()` helper in each module's routes.ts;
frontend api.ts unwraps via `unwrapEnvelope<T>()` which throws an
AiSchemaVersionMismatchError if the server returns a version this
client wasn't compiled against.

Why this matters before launch:
  - Catches stale-cache scenarios immediately ("client v1 talking to
    server v2") with an actionable error in the network panel, not a
    cascade of "field is undefined" bugs further down the stack
  - Forces explicit version bumps when we make non-additive schema
    changes — the bump rules are documented inline next to the constant
  - Cheap to remove if it ever feels overkill: drop the envelope() call
    on the backend and the unwrapEnvelope on the frontend, ~10 lines

═══ 2. Anthropic prompt-caching directive (forward-compat) ═══

Adds `providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }`
on the system message in nutriphi + planta routes via a SYSTEM_CACHE_HINT
constant. This is a NO-OP today because:
  - mana-llm currently routes to Gemini, not Claude
  - Our system prompts are ~50 tokens, well under Anthropic's 1024-token
    cache minimum

Kept anyway because it's ~5 lines per route and lights up automatically
when either condition flips (e.g. when we add per-user dietary preferences
as system context, pushing prompts past the threshold). The day we point
mana-llm at Claude Sonnet, every existing call site already has caching
enabled — no scavenger hunt through the routes.

System messages had to migrate from the `system:` shorthand to a full
messages[] entry to attach providerOptions, which is a tiny readability
loss but the only way to get per-message metadata into the AI SDK.

═══ Tests ═══

13 new cases in apps/mana/apps/web/.../nutriphi/ai-schemas.test.ts cover:
  - AI_SCHEMA_VERSION presence + AiSchemaVersionMismatchError shape
  - MealAnalysisSchema acceptance/rejection (confidence bounds, missing
    nutrients, optional food fields, default empty arrays)
  - PlantIdentificationSchema (every-field-optional design, defaults,
    confidence range)

(Test file lives in the web app rather than packages/shared-types
because the latter has no test runner configured — adding vitest there
just for these would be overkill.)

Total nutriphi + planta suite: 62/62 passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:17:18 +02:00
Till JS
4fab323234 fix(mana/web/news): use client-side API URL + snapshot $state arrays
Onboarding's "Fertig" button was failing with two distinct errors:

1. Feed fetch hit `http://mana-api:3060/api/v1/news/feed` (the SSR-only
   internal Docker hostname) and was blocked by CSP. The news client was
   reading `$env/dynamic/public.PUBLIC_MANA_API_URL`, which on the client
   resolves to whatever the SSR process had — i.e. the internal hostname.
   Switched to the existing `getManaApiUrl()` helper, which on the client
   reads `window.__PUBLIC_MANA_API_URL__` (set from
   `PUBLIC_MANA_API_URL_CLIENT` = `https://mana-api.mana.how`).

2. `completeOnboarding` passed Svelte 5 `$state` proxy arrays directly
   into the preferences store, which then handed them to Dexie's update
   hook → `_pendingChanges.add` → `DataCloneError`. The picked arrays
   are now snapshotted with `$state.snapshot()` at the call site, and
   the store-side setters defensively spread their inputs so any future
   caller is safe by default.
2026-04-09 17:16:44 +02:00
Till JS
59b5114348 fix(mana/web/who): surface guestPrompt on JWT expiry
When the access token had aged out mid-game and the silent refresh
failed (auth.mana.how/api/v1/auth/refresh → 401), the who store
threw a raw "not authenticated" error and the PlayView showed a
gibberish red banner. Confusing because the navbar still shows the
user as logged in — the session cookie is intact, only the JWT is
gone — so the user has no clue what to do.

Match the base-client.ts pattern: when getAccessToken() returns
null OR the upstream returns 401, fire guestPrompt.requireAccount()
to surface the standard "Sitzung abgelaufen, neu anmelden" prompt
in the bottom-bar slot, then throw a German error string so the
inline error banner reads as "Sitzung abgelaufen — bitte neu
anmelden" instead of "not authenticated".

Hit by the developer mid-test on the first end-to-end live game on
production: the chat had been working for ~5 messages, then the
JWT expired and the game appeared to "die" with a cryptic message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:10:03 +02:00
Till JS
f9b83990c6 refactor(mana/web): consume shared AI Zod schemas via z.infer
Drops the hand-written MealAnalysisResult / AnalyzedFood / NutritionData
interfaces in nutriphi/{api,types}.ts and the IdentifyResult interface
in planta/api.ts. They are now type aliases that re-export the inferred
types from @mana/shared-types — same types the backend validates against
at the boundary, so frontend and backend can no longer drift.

Net result is end-to-end type safety: a field rename in the shared
schema lights up red in both apps/api routes and apps/mana/apps/web
consumers in the same tsc pass. No more interface duplication, no more
manual sync.

Storage shapes (LocalMeal, LocalGoal, LocalFavorite) stay module-local
because they compose the shared NutritionData / AnalyzedFood with
storage-specific BaseRecord fields (id, userId, _fieldTimestamps,
deletedAt, etc.) that have no place in the wire format.

Tests: 29/29 nutriphi + 20/20 planta still green — the shapes are
identical, only the source of the type aliases changed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:00:13 +02:00
Till JS
0c0e31d2f3 refactor(api): use Vercel AI SDK + Zod for nutriphi/planta vision routes
Replaces hand-rolled fetch + JSON.parse + cast-to-any with generateObject
from the AI SDK. The model is constrained to the shared Zod schemas in
@mana/shared-types, so the response is validated at the boundary instead
of trusting Gemini to emit the right shape.

Routes refactored:
  - nutriphi/analysis/photo  (image_url → multimodal `image:` content)
  - nutriphi/analysis/text   (free-text meal description)
  - planta/analysis/identify (plant photo identification)

Why this is materially better than the old code:

  - Runtime validation: if Gemini drifts, the AI SDK throws before the
    response leaves the route. Frontend never sees malformed payloads.
  - Provider-portable: createOpenAICompatible({ baseURL: MANA_LLM_URL })
    keeps mana-llm as the central routing/auth/observability point. The
    AI SDK speaks the OpenAI dialect to mana-llm. If we ever swap the
    backend (e.g. claude-sonnet-4-6 for plant ID), it's a one-line model
    name change.
  - System prompts moved from a multi-line example-laden string to a
    short instruction. The schema itself (with .describe() field hints)
    now carries the structural contract that the JSON-by-example
    paragraph used to encode. Token cost goes down, accuracy goes up.
  - Drops manual fetch error handling (status checks, JSON.parse, cast)
    in favour of try/catch around generateObject. Errors are typed.

mana-llm itself is unchanged — it's still the OpenAI-compatible proxy
in front of Gemini Vision. The AI SDK just gives us a typed client and
a schema-aware decoder on top of it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:59:51 +02:00
Till JS
c2a75bb8e1 feat(shared-types): add Zod schemas for AI structured outputs
Introduces packages/shared-types/src/ai-schemas.ts as the single source
of truth for the wire format between mana-api and the unified Mana app.

Two schemas:
  - MealAnalysisSchema (foods, totalNutrition, description, confidence,
    warnings, suggestions) — consumed by nutriphi /analysis/photo and
    /analysis/text routes
  - PlantIdentificationSchema (scientificName, commonNames, confidence,
    health/watering/light advice, generalTips) — consumed by planta
    /analysis/identify

Both schemas include .describe() annotations on every field. The Vercel
AI SDK passes these through to the model as part of the structured-output
prompt, which materially improves accuracy on Gemini Vision (the model
sees both the field name AND the German-language hint about what to put
there).

Schemas use plain .optional() rather than .nullable() because
generateObject() guides the model with strict schema adherence — it
won't emit JSON null for missing fields, just omit them.

Deps wired up:
  - apps/api: + ai@6, + @ai-sdk/openai-compatible@2, + @mana/shared-types
  - apps/mana/apps/web: + zod (for z.infer of the shared schemas)
  - packages/shared-types: + zod (for the schema definitions themselves)

All three on zod ^3.23 to stay in lockstep with the existing
apps/api zod usage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:59:28 +02:00
Till JS
63e1ef8233 fix(mana/web/who): chat bubble Tailwind classes — v3 → v4 syntax
The NPC reply rendered as a fully-white bubble with invisible
white-on-white text. Three bugs in the message-bubble markup,
all from copy-pasting Tailwind v3 patterns into a v4 codebase:

  1. text-white-90 is not a valid class name in any Tailwind
     version. The opacity goes after a slash: text-white/90.
  2. bg-white + bg-opacity-5 is the v3 pattern. v4 dropped
     bg-opacity-* and folded opacity into the color via
     bg-white/5. Without it the bubble was solid white.
  3. Combining 1 and 2: solid white background + invalid text
     color → text inherited the parent's white → invisible.

Plus a Svelte-specific gotcha: class:bg-emerald-500/10={cond}
doesn't parse because Svelte's class: directive treats `/` as a
token. Use a class={...} string interpolation instead, which is
how the result banner now picks between the won and surrendered
backgrounds.

Also: rewrote the message bubble loop with an explicit
{#if msg.sender === 'user'}/{:else} branch instead of stacking
class:* directives. Less clever, more legible, and dodges the
class: + slash issue at the source.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:56:34 +02:00
Till JS
56130cd3f7 test(mana/web/body): integration tests for bodyStore mutations
11 vitest cases covering the load-bearing parts of bodyStore that
would otherwise rot silently because they only fire on edge paths
(re-tap, phase switch, double-start). Same harness as
nutriphi/mutations.test.ts: fake-indexeddb + a MemoryKeyProvider
seeded with a fresh master key, plus mocks for the browser-only
globals the Dexie hooks reach for (funnel-tracking, triggers,
inline-suggest).

Coverage:

  Encryption (registry round-trip)
    - Exercise: name + notes wrapped, muscleGroup + equipment +
      isPreset stay plaintext for the index/picker layer
    - Set: weight + reps wrapped (numeric values get JSON-stringified
      before encryption), workoutId + exerciseId + order + isWarmup
      stay plaintext

  upsertCheck idempotency
    - Re-tapping the same date updates the existing row instead of
      creating a second one (the bug this guards against would have
      filled bodyChecks with one row per dot-tap on a slow day)
    - Partial updates preserve prior fields when callers pass
      undefined for the others
    - Different dates get different rows

  startPhase auto-close
    - Opening a second phase closes the previous one's endDate
      (so the "active phase" view always sees ≤ 1 open row)
    - endPhase stamps endDate without soft-deleting the row

  startWorkout single-active guard
    - Returns the existing open workout instead of starting a
      second one (would have silently double-tracked sets)
    - After finishWorkout, a fresh start works again

  logSet ordering
    - Assigns sequential order indices within a workout
  deleteWorkout cascade
    - Soft-deletes the workout AND all its sets in one go

All 11 pass against the v2 schema (bodyExercises / bodyWorkouts /
bodySets / bodyChecks / bodyPhases) plus the registry encryption
allowlist landed in the previous body commits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:56:17 +02:00
Till JS
b2f3b313bb feat(mana/web/body): exercise picker, routines, phases, progression chart
Five quick-win UI upgrades that take the body module from "skeleton
ListView" to "actually usable for daily training":

1. ExercisePicker modal (replaces the previous bare <select> in
   WorkoutLogger). Search by name, filter chips per muscle group,
   inline create-new-exercise. The big win is the per-row "Last:
   80kg × 8 · vor 3 Tagen" hint — progressive overload becomes
   "look at the number, add 2.5kg" instead of digging through
   workout history. Recently-trained exercises bubble to the top
   so the picker matches what you actually do most days.

2. RoutineManager. Three seed routines added to BODY_GUEST_SEED
   (Full Body Starter, Upper Day, Lower Day) so a fresh user has
   a one-tap "start" path. Inline form to save custom routines as
   chips of selected exercises. Archive button per routine; edit
   is deferred. Routines start a workout via the existing
   bodyStore.startWorkout({ routineId, title }) shape.

3. PhaseManager replaces the previously read-only header pill with
   a clickable control. Three states: idle (start button), opening
   (kind picker + start/target weight inputs), active (color-coded
   summary card with end button). The auto-close-on-switch logic
   was already in bodyStore.startPhase, so this is pure UI plumbing.

4. ExerciseProgressionChart. Same auto-scaled SVG approach as
   WeightChart but plots best estimated 1RM (Epley) per day for
   one exercise. Falls back to the most-recently-trained exercise
   when no explicit id is pinned, so the chart is never empty on
   first open.

5. New query helpers feeding the above: getLastSetByExercise,
   getE1rmTimeline (collapses multiple working sets in one session
   to the daily best so the chart isn't noisy), and a coarse
   relativeDays formatter for the picker's "vor 3 Tagen" hints.

ListView re-composed: removed the dead phase-pill CSS, added
PhaseManager + RoutineManager + ExerciseProgressionChart sections,
left WorkoutLogger / WeightChart / DailyCheckCard / RecentWorkouts
in place. i18n keys for the new copy added to body/de.json and
body/en.json (it/fr/es fall back to the components' inline default
strings until translated).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:55:51 +02:00
Till JS
77ad48972e fix(mana/web/who): set createdAt + use simple gameId index for messages
Two related bugs that caused user messages to disappear into the
ether: optimistic insert succeeds but neither the user message nor
the NPC reply ever shows up in PlayView, and no errors hit the
console because nothing actually throws.

Bug 1 — createdAt was never set
-------------------------------
The Dexie creating-hook in apps/mana/apps/web/src/lib/data/database.ts
auto-stamps userId and __fieldTimestamps but does NOT auto-stamp
createdAt. Module stores have to set it themselves. Chat gets away
with it because its query uses a simple conversationId index and
the type converter falls back to "now" — but I had the who store
omit createdAt entirely.

Bug 2 — composite index hides rows with undefined createdAt
-----------------------------------------------------------
queries.ts used .where('[gameId+createdAt]').between(...) against
the [gameId+createdAt] composite. Dexie does NOT index rows where
any compound key component is undefined, so even though the insert
succeeded and the row was physically in the table, the range query
returned an empty list. The liveQuery effect re-fired but found
nothing → no UI update. Same issue inside sendMessage's history-
fetch step.

Fix:
  1. Set createdAt explicitly on insert in whoGamesStore (both
     user message and NPC reply, +1ms on the reply so it sorts
     strictly after even when both inserts land in the same ms)
  2. Switch queries to .where('gameId').equals(id) and sort in JS
     — same pattern as chat's useConversationMessages, robust
     against missing createdAt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:41:36 +02:00
Till JS
92f8221bfd docs(shared-llm): correct the mana-server tier topology in code + CLAUDE.md
In commit c9e16243c (the gemma3:4b → gemma4:e4b switch) I sloppily
wrote in the ManaServerBackend docstring that mana-llm "routes them
to the local Ollama instance on the Mac Mini (running on the M4's
Metal GPU)". That is wrong AND it's the exact misconception I had
to debug-out-of earlier the same day.

The actual topology — already documented correctly in
docs/MAC_MINI_SERVER.md and docs/WINDOWS_GPU_SERVER_SETUP.md, I
just didn't read those before writing the docstring:

  mana-llm container's OLLAMA_URL points at host.docker.internal:13434
  → ~/gpu-proxy.py (Python TCP forwarder, LaunchAgent on Mac Mini)
  → 192.168.178.11:11434 (LAN)
  → Ollama on the Windows GPU server (RTX 3090, 24 GB VRAM)
  → Inference

The Mac Mini's brew-installed Ollama binary is NOT on the inference
path. It's just a CLI for inspecting the proxied daemon. Today's
"why does the Mac Mini still have Ollama 0.15.4" puzzle has the
answer "because nothing on the Mac Mini actually runs inference, the
binary version was never load-bearing".

Two doc fixes:

1. packages/shared-llm/src/backends/mana-server.ts
   Replace the lying docstring with the real topology, including a
   pointer to the two MAC_MINI_SERVER.md / WINDOWS_GPU_SERVER_SETUP.md
   sections that document it. Also note that gemma4:e4b is a
   reasoning model that emits message.reasoning when given enough
   tokens (cross-reference to remote.ts's fallback parser).

2. packages/local-llm/CLAUDE.md
   Add a paragraph at the top explaining the difference between
   "@mana/local-llm" (browser tier, on-device) and the @mana/shared-llm
   "mana-server" / "cloud" tiers (services/mana-llm proxy → gpu-proxy.py
   → RTX 3090). This was implicit before — "not related to
   services/mana-llm" — but didn't say where mana-server actually
   goes. Future me reading the doc would still have to dig through
   the docker-compose env to find out.

No code changes — only docstring + markdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:40:34 +02:00
Till JS
3993400013 chore(infra): make cloudflared-config.yml the single source of truth
Reconciles the in-repo cloudflared-config.yml with the actually-loaded
ingress map on the Mac Mini production tunnel — the previous repo file
was missing 30+ hostnames (per-app subdomains, mana-api, sync, llm,
media, credits, subscriptions, etc.) because it was last updated
before the unified Mana web app rollout. Adds the new mana-api.mana.how
ingress for apps/api on port 3060 so the unified backend has a public
client URL for the SvelteKit web app's PUBLIC_MANA_API_URL_CLIENT.

Drops the dead matrix.mana.how / element.mana.how routes — the matrix
subsystem was removed in 2514831a3 and those services no longer exist.

Adds scripts/mac-mini/sync-tunnel-config.sh — the one-command flow for
shipping a tunnel-config change: pull on the server, validate the
yaml, kickstart cloudflared via launchctl. setup-cloudflared-service.sh
already wires the launchd plist with --config <repo-path> pointing at
this file, so a fresh Mac Mini install + setup script + sync script
gives you a fully reproducible tunnel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:37:21 +02:00
Till JS
8adef1b39c fix(shared-llm): fall back to message.reasoning when content is empty
Reasoning-style models (Gemma 4 E4B is the first one we use, but
DeepSeek R1, Gemini 2.5 thinking, etc. behave the same way) split
their output into two fields:
  - message.content   — the final answer
  - message.reasoning — the chain-of-thought leading up to it

When the model is given too few max_tokens to finish reasoning AND
emit content, the response comes back with content="" and reasoning
populated with the half-finished thought. Verified empirically with
gemma4:e4b and `max_tokens: 10` on a "Sage Hi auf Deutsch in einem
Wort" prompt — content was "" while reasoning had "Here's a
thinking process to..." (cut off mid-thought).

For the title task this rarely matters because the system prompt is
directive enough to skip the thinking phase (verified: same gemma4:
e4b returns clean 7-token titles like "Sonnenstrahlen genießen
heute" with the standard system prompt + max_tokens 32). But it's
a real failure mode for any future task that uses a less-directive
prompt or hits a longer reasoning chain.

Defensive fix: prefer message.content first, fall back to
message.reasoning if content is empty. The fallback is a string-or-
nothing operation, no semantic interpretation — if the reasoning
field happens to contain a usable answer fragment, the caller's
cleanup chain (e.g. generateTitleTask's strip-quotes-and-dots
pipeline) will normalize it. If it's truly half-finished thought,
the caller's runRules fallback still kicks in via the existing
empty-result detection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:29:22 +02:00
Till JS
b2db42bb26 feat(mana/web/body): UI components, route, i18n, dashboard widget
Builds the user-facing surface on top of the data layer landed in the
previous commit. After this commit the Body module is reachable at
/body and surfaces an at-a-glance tile on the customizable dashboard.

Components (lib/modules/body/components/):
  - SetRow — inline editable set with weight/reps/RPE/warmup/delete.
    Local $state mirrors the prop and re-syncs via $effect when the
    parent re-emits the row through liveQuery.
  - WorkoutLogger — active-session console. Groups sets by exercise,
    pre-fills the next-set form from the most recent working set on
    the same exercise so progressive overload is one tap.
  - MeasurementForm — quick-log with type picker; unit auto-follows
    (kg for weight/muscle, % for body fat, cm for circumferences).
  - WeightChart — pure SVG line chart, no chart-lib dependency.
    Auto-scales the y-axis with padding so flat-line periods don't
    collapse to a single horizontal line.
  - DailyCheckCard — 1-5 dot buttons for energy/sleep/soreness/mood,
    upserts to bodyChecks per day so re-tapping overwrites today.
  - RecentWorkouts — finished sessions with set count, total volume,
    duration.

ListView.svelte composes everything into the main view: active
workout console when running (otherwise a "start" CTA), weight
chart + measurement form, today's daily check card, recent
workouts. Phase pill in the header (Cut/Bulk/Maintenance) with
color-coded background.

Route (routes/(app)/body/):
  - +layout.svelte sets seven contexts via the useAllBody*() hooks
    so child pages get observable streams without prop drilling.
  - +page.svelte renders ListView.

i18n (lib/i18n/locales/body/):
  - de/en/it/fr/es JSON files with title, subtitle, workout state,
    measurement.* (10 types), check.* (4 fields), phase.* (4 kinds),
    log/finish/start strings.
  - Registered in lib/i18n/index.ts alongside the other module dicts.

Dashboard widget (lib/modules/body/widgets/BodyStatsWidget.svelte):
  - Surfaces latest weight + delta vs the previous reading, plus
    either the active workout (with today's set count + volume) or
    a "start workout" CTA when idle.
  - Reads bodyMeasurements / bodyWorkouts / bodySets directly via
    liveQuery + decryptRecords (same pattern as NewsUnreadWidget).
  - Wired into widget-registry.ts as 'body-stats', registered in
    types/dashboard.ts WIDGET_REGISTRY with 💪 icon and the new
    'body' requiredBackend tier.
  - Strings added under dashboard.widgets.body_stats.* in all five
    locales.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:28:55 +02:00
Till JS
a412ccc6fb feat(mana/web/body): new module — combined fitness training + body comp tracking
Adds the unified Body module that merges what would otherwise be two
separate apps (fitness + bodylog) into one. The value lives in their
intersection: tracking lifts alongside bodyweight is what enables
real progressive-overload + recomp insights, and shared primitives
(charts, time series, units, photos) avoid duplicating UI surface.

This commit lands only the data layer + module registration so the
follow-up UI / route / dashboard widget can build on a stable
foundation.

Tables (db.version(2), already in place):
  bodyExercises    — exercise library (Squat, Bench, Deadlift, OHP,
                     Row, Pull-Up seeded as presets)
  bodyRoutines     — saved workout templates
  bodyWorkouts     — one logged training session
  bodySets         — set rows inside a workout, indexed [workoutId+order]
  bodyMeasurements — weight + measurements over time, indexed [type+date]
  bodyChecks       — daily energy/sleep/soreness/mood self-rating,
                     upserted per day
  bodyPhases       — cut/bulk/maintenance/recomp phase markers, with
                     auto-close on phase change so the "active phase"
                     view always has at most one open row

Encryption (registry.ts): all 7 tables flipped to enabled. Health
data is GDPR Art. 9 special-category, so user-typed text + the
sensitive numeric fields (weight, reps, value, startWeight,
targetWeight, energy/sleep/soreness/mood) are wrapped. Indexed
columns (ids, FKs, ordering, dates, kind/type/equipment enums)
stay plaintext so the existing query layer keeps working without
decrypt-on-every-row.

Module wiring:
  - bodyModuleConfig added to module-registry.ts
  - Body app entry registered in shared-branding mana-apps.ts
    (red→orange icon to set it apart from the green health-adjacent
    modules and the pink cycles icon)
  - APP_ICONS.body added (dumbbell + heart-pulse hybrid SVG)

Also captures the broader module-ideas brainstorm in
docs/future/MODULE_IDEAS.md and marks fitness + bodylog as merged
into the new body module.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:28:19 +02:00
Till JS
52159ee07a fix(news-ingester): disable Readability fallback to break crash loop
JSDOM throws CSS / parser errors from detached parse5 callbacks that
escape every try/catch in the call stack and even bun's
process.on('uncaughtException') handlers — leaving the daemon stuck
crash-looping past the first bad page in source #4 (heise) without
ever making forward progress.

Set FULL_TEXT_THRESHOLD_WORDS = 0 so we never call into Readability.
Sources that ship full RSS bodies (Tagesschau, Spiegel, BBC, …) are
unaffected. Title-only sources (Hacker News) keep the row with an
empty content field; the reader already falls back to "Original
öffnen ↗" in that case.

Re-enabling extraction in a worker thread is left for a follow-up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:21:09 +02:00
Till JS
422eb9f61b fix(mana/web/who): log sendMessage failures to console
The PlayView's send() catch sets a local `error` state which renders
as a small banner near the input — easy to miss when the chat area
is the first thing the eye looks at after pressing send. Add an
explicit console.error so the next time something goes wrong end
to end, the actual exception is one DevTools tab away instead of
"my message disappeared and I have no idea why".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:19:19 +02:00
Till JS
dad174a631 fix(news-ingester): silence JSDOM CSS errors + add process-level safety net
JSDOM's CSS parser throws on plenty of real-world pages and the error
escapes every try/catch in the buildRow → ingestSource chain because
it fires from a parse5 callback that runs after JSDOM has returned.
In the prod container this killed the process on the first bad page,
docker restarted it, and it crash-looped on the same first source
forever — no progress past tech.

Two-layer fix: a silent VirtualConsole on every JSDOM instance to
swallow CSS / resource errors at the source, plus process-level
uncaughtException + unhandledRejection handlers that log and continue
so any future async escape can't kill the daemon either.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:15:46 +02:00
Till JS
bd748b0a14 feat(mana/web/nutriphi): context menu quick-action for "Neue Mahlzeit"
Adds a contextMenuActions entry to the nutriphi registerApp() block
matching the convention todo / calendar / contacts / habits / notes /
dreams / cycles all use: a Plus-icon "Neue Mahlzeit" action that
dispatches a window CustomEvent('mana:quick-action', { app: 'nutriphi',
action: 'new' }).

Note: there is currently no registered listener for mana:quick-action
in the codebase — every existing module dispatches it but nothing
consumes it yet (presumably waiting for a central handler in the
workbench shell). Adding the entry now keeps nutriphi consistent with
the convention so it will light up automatically once the listener
lands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:14:50 +02:00