Register a detail view for the chat module so clicking a conversation
in the workbench opens an inline overlay with the full message thread
and input area. Reuses the shared sendAndStream() completion service.
- ListView: decrypt conversations + messages, add "Neuer Chat" button,
click opens detail overlay with sibling navigation, context menu
- DetailView: message bubbles, streaming indicator, auto-scroll,
Enter to send / Shift+Enter for newline
- App registry: add detail view loader + paramKey 'conversationId'
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the placeholder stub with a real streaming SSE connection
to mana-api at /api/v1/chat/completions/stream. Extracts the
send-and-stream cycle into a shared services/completion.ts helper
so both the route page and workbench overlay can reuse it.
- Streams assistant response chunks into a live bubble
- Shows thinking dots (●●●) while waiting for first token
- Handles 402 (insufficient credits) with German error message
- Auto-titles conversation from first user message
- Persists final assistant text to IndexedDB with encryption
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add drag-and-drop zone and upload button to the picture module's
workbench page. Uploads go to mana-media, then insert a LocalImage
record via imagesStore.insert() with encryption. Shows thumbnail
previews with upload status (spinner/check/error).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract the duplicated STT fetch logic from 5 module stores
(dreams, memoro, notes, todo, habits) into a single
$lib/voice/transcribe.ts helper. Returns text, language,
durationSeconds, and the model identifier from mana-stt.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a "Gegenstand hinzufügen" button that expands into an inline form
with a collection picker dropdown and name input. Items are created
directly via itemsStore.create() without leaving the workbench panel.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pre-researched dossiers (37 JSON files, DE+EN) replace the old
personality strings as the source of truth for the Who guessing game.
A strong cloud LLM (Gemini 2.5 Flash) generates structured facts per
character — voice, values, achievements, anecdotes, relationships,
forbidden-early-words, and three-stage hints — so the small runtime
model (gemma3:4b) gets only what it needs per turn instead of raw
personality text that leaks the identity immediately.
- dossier-types.ts: Zod schema + TS types for CharacterDossier
- dossier-loader.ts: boot-time loader with validation + coverage report
- generate-who-dossiers.ts: one-shot generator script (Google Gemini
or local mana-llm fallback, idempotent, --force/--id flags)
- 37 dossier JSON files in data/dossiers/
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a compact create form with live gradient preview, name input,
color pickers (add/remove, max 8), and animation type dropdown.
New moods are written via moodsStore.createMood() to IndexedDB.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Embed PlayView directly in the ListView so games can be started
and played without navigating away from the workbench. PlayView
now accepts an onBack callback instead of hardcoded goto('/who').
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Events ListView and DetailView now use the standard ViewProps interface
(navigate/goBack/params) instead of the custom onOpenEvent callback.
Adds paramKey to the events app registration so the workbench overlay
knows which param carries the event ID. Clicking an event card now
opens the detail overlay with prev/next sibling navigation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a "Neues Dokument" button and an "Alle Dokumente" link in the
toolbar. Document rows are now clickable <a> tags linking to the
detail page instead of static divs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The save button was permanently disabled for users with no exercises
because the disabled gate required newSelected.size > 0. Now only
the name is required; an inline "add exercise" flow lets users create
exercises directly from the routine form. Also removed the redundant
h1 + subtitle since the workbench shell already renders the title.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comprehensive warning sweep across 128 files that brings svelte-check
from 270 warnings → 0 (plus 3 new errors from concurrent upstream
changes fixed inline).
Final state: 6473 files, 0 errors, 0 warnings, 0 files with problems.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Vault unlock errors were silently swallowed, causing encrypted content
(enc:1:...) to render as ciphertext in the UI. Now logs each step of
the unlock flow and shows an error toast when the vault fails to unlock.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The media schema/tables were never created on fresh deploys because
mana-media only shipped a `db:push` script and nothing ever ran it
in the container. Result: every upload returned 500 the moment a
new environment came up (just hit prod again on mana.how).
- Add `db:generate` + `db:migrate` scripts and a migrate.ts runner
- Generate the initial migration covering media/media_references/
media_thumbnails (matches what was already on local + prod, which
were stamped manually so the migrator skips on existing deploys)
- Call runMigrations() at startup in src/index.ts so future fresh
containers self-bootstrap. Idempotent — drizzle tracks state in
drizzle.__drizzle_migrations.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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>