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>
This commit is contained in:
Till JS 2026-04-09 20:25:08 +02:00
parent c31ce4448f
commit 80b23dd9ff
33 changed files with 110 additions and 66 deletions

View file

@ -48,7 +48,7 @@ const BRANDING_ONLY = new Set([
describe('app registry ↔ MANA_APPS consistency', () => {
it('every workbench-registry app has a MANA_APPS entry or is in WORKBENCH_ONLY', () => {
const brandingIds = new Set(MANA_APPS.map((a) => a.id));
const brandingIds = new Set<string>(MANA_APPS.map((a) => a.id));
const unaccounted = getAllApps()
.map((a) => a.id)
.filter((id) => !brandingIds.has(id) && !WORKBENCH_ONLY.has(id));

View file

@ -14,11 +14,13 @@
};
});
function checkSession() {
async function checkSession() {
if (!authStore.isAuthenticated) return;
// Try to get token expiry from JWT
const token = authStore.getAccessTokenSync?.();
// Pull the latest access token. The wrapper doesn't expose a sync
// variant — getAccessToken() reads from the storage adapter behind
// a Promise, which is fine for a polling-style warning.
const token = await authStore.getAccessToken();
if (!token) return;
try {
@ -40,7 +42,11 @@
async function handleRefresh() {
try {
await authStore.refreshToken?.();
// getValidToken() returns the current token if still valid, or
// triggers a refresh under the hood when it isn't. Same effect
// as the old `refreshToken()` call site, with the wrapper's
// existing public surface.
await authStore.getValidToken();
showWarning = false;
} catch {
// Refresh failed, user will be logged out

View file

@ -57,7 +57,7 @@
{widget.id} fehlgeschlagen
</p>
<p class="mb-3 text-xs text-red-500 dark:text-red-500/70">
{error?.message || 'Unbekannter Fehler'}
{(error as Error | undefined)?.message || 'Unbekannter Fehler'}
</p>
<button
type="button"

View file

@ -20,7 +20,7 @@
Heart,
Lightning,
Clock,
Activity,
Pulse,
} from '@mana/shared-icons';
import { getIconComponent } from '@mana/shared-icons';
import { formatDistanceToNow } from 'date-fns';
@ -64,7 +64,7 @@
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<Activity size={20} />
<Pulse size={20} />
{$_('dashboard.widgets.activity_feed.title', { default: 'Aktivität' })}
</h3>
</div>
@ -77,7 +77,7 @@
</div>
{:else if items.length === 0}
<div class="py-6 text-center">
<Activity size={32} class="mx-auto mb-2 text-muted-foreground" />
<Pulse size={32} class="mx-auto mb-2 text-muted-foreground" />
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.activity_feed.empty', { default: 'Noch keine Aktivität' })}
</p>

View file

@ -11,14 +11,15 @@
const DAY_NAMES = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
function formatRepeatDays(days?: number[]): string {
function formatRepeatDays(days?: number[] | null): string {
if (!days || days.length === 0) return 'Einmalig';
if (days.length === 7) return 'Täglich';
if (days.length === 5 && !days.includes(0) && !days.includes(6)) return 'Werktags';
return days.map((d) => DAY_NAMES[d]).join(', ');
}
function formatRemaining(seconds: number): string {
function formatRemaining(seconds: number | null | undefined): string {
if (seconds == null) return '—';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;

View file

@ -9,7 +9,8 @@
const docs = useRecentDocuments(5);
const spaces = useSpaces();
function getSpaceName(spaceId: string): string {
function getSpaceName(spaceId: string | null | undefined): string {
if (!spaceId) return '';
const space = (spaces.value ?? []).find((s) => s.id === spaceId);
return space?.name ?? '';
}

View file

@ -12,9 +12,16 @@
interface Props {
onComplete: () => void;
/**
* Optional callback fired when the user explicitly skips the wizard
* (vs. completing it normally). Layout consumers use it to track
* skip-rate analytics; the default skip path still calls onComplete
* so the modal closes either way.
*/
onSkip?: () => void;
}
let { onComplete }: Props = $props();
let { onComplete, onSkip }: Props = $props();
// Reference to profile name for auto-save on step transition
let profileNameRef = $state('');
@ -60,6 +67,7 @@
function handleSkip() {
onboardingStore.skip();
onSkip?.();
onComplete();
}

View file

@ -30,7 +30,7 @@ describe('Mana Help Content', () => {
expect(content.features).toBeDefined();
expect(content.contact).toBeDefined();
expect(content.contact.supportEmail).toBe('support@mana.how');
expect(content.contact!.supportEmail).toBe('support@mana.how');
});
it('returns valid English content', () => {

View file

@ -30,7 +30,7 @@
let calendarId = $state(event?.calendarId || '');
let recurrenceRule = $state(event?.recurrenceRule || '');
let selectedTagIds = $state<string[]>(
((event as Record<string, unknown>)?.tagIds as string[]) ?? []
((event as unknown as Record<string, unknown>)?.tagIds as string[]) ?? []
);
const allTags = useAllTags();

View file

@ -2,7 +2,7 @@
* Context module barrel exports.
*/
export { contextSpaceTable, contextDocumentTable, CONTEXT_GUEST_SEED } from './collections';
export { contextSpaceTable, documentTable, CONTEXT_GUEST_SEED } from './collections';
export * from './queries';
export type {
LocalContextSpace,

View file

@ -5,6 +5,12 @@
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
// `useAllTags` re-exports the shared-tags hook below; the actual tag
// objects flowing through this module are the shared shape, not the
// memoro/types.Tag declared next to LocalMemoTag. Importing the shared
// type here keeps `getTagsForMemo` and friends in sync with what the
// hook actually returns.
import type { Tag } from '@mana/shared-tags';
import type {
LocalMemo,
LocalMemory,

View file

@ -8,6 +8,7 @@
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { memosStore } from '../stores/memos.svelte';
import { llmQueueDb } from '$lib/llm-queue';
import type { QueuedTask } from '@mana/shared-llm';
import type { LlmTier } from '@mana/shared-llm';
import { PushPin } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry';
@ -97,19 +98,16 @@
// Reactive lookup of any LLM queue task tagged with this memo, so the
// UI can show "Titel wird generiert..." while a generateTitleTask is
// pending or running. Returns the most recent task row (any state).
const titleQueueRow = useLiveQueryWithDefault(
async () => {
if (!memoId) return null;
const rows = await llmQueueDb.tasks
.where('[refType+refId]')
.equals(['memo', memoId])
.and((t) => t.taskName === 'common.generateTitle')
.reverse()
.sortBy('enqueuedAt');
return rows[0] ?? null;
},
null as Awaited<ReturnType<typeof llmQueueDb.tasks.toArray>>[number] | null
);
const titleQueueRow = useLiveQueryWithDefault<QueuedTask | null>(async () => {
if (!memoId) return null;
const rows = await llmQueueDb.tasks
.where('[refType+refId]')
.equals(['memo', memoId])
.and((t) => t.taskName === 'common.generateTitle')
.reverse()
.sortBy('enqueuedAt');
return rows[0] ?? null;
}, null);
const titleIsGenerating = $derived(
titleQueueRow.value?.state === 'pending' || titleQueueRow.value?.state === 'running'

View file

@ -43,7 +43,14 @@ export const imagesStore = {
async toggleFavorite(id: string) {
error = null;
try {
await toggleField(imageTable(), id, 'isFavorite');
// Cast: toggleField expects a string-keyed Table, but db.table()
// returns the generic IndexableType-keyed shape. The runtime keys
// for `images` are all strings (UUIDs).
await toggleField(
imageTable() as unknown as Parameters<typeof toggleField>[0],
id,
'isFavorite'
);
PictureEvents.imageFavorited();
return { success: true };
} catch (e) {

View file

@ -69,7 +69,11 @@
onConfirmDelete={() =>
detail.deleteWithUndo({
label: 'Präsentation gelöscht',
delete: () => decksStore.deleteDeck(deckId),
// deleteDeck returns Promise<boolean>; the deleteWithUndo helper
// expects Promise<void>, so we discard the result.
delete: async () => {
await decksStore.deleteDeck(deckId);
},
goBack,
})}
>

View file

@ -2,12 +2,7 @@
* Questions module barrel exports.
*/
export {
questionCollectionTable,
questionTable,
answerTable,
QUESTIONS_GUEST_SEED,
} from './collections';
export { qCollectionTable, questionTable, answerTable, QUESTIONS_GUEST_SEED } from './collections';
export * from './queries';
export type {
LocalCollection,

View file

@ -4,7 +4,7 @@
import { timeEntryTable } from '$lib/modules/times/collections';
import { formatDurationCompact } from '$lib/modules/times/queries';
import { CurrencyDollar } from '@mana/shared-icons';
import type { TimeEntry, Project, Client } from '$lib/modules/times/types';
import type { TimeEntry, Project, Client, LocalTimeEntry } from '$lib/modules/times/types';
import { ConfirmationModal } from '@mana/shared-ui';
let {
@ -46,7 +46,7 @@
let saveDebounce: ReturnType<typeof setTimeout> | null = null;
function autoSave(updates: Record<string, unknown>) {
function autoSave(updates: Partial<LocalTimeEntry>) {
if (saveDebounce) clearTimeout(saveDebounce);
saveDebounce = setTimeout(async () => {
await timeEntryTable.update(entry.id, updates);

View file

@ -10,11 +10,12 @@
import type { ParsedTask } from '../utils/task-parser';
import type { TaskTag } from '../types';
import { getPriorityColor } from '../queries';
import type { ParserLocale } from '@mana/shared-utils';
import { Plus, CalendarBlank, Flag, ArrowsClockwise, Timer, Tag, Info } from '@mana/shared-icons';
interface Props {
labels?: TaskTag[];
locale?: string;
locale?: ParserLocale;
onShowSyntaxHelp?: () => void;
}

View file

@ -285,7 +285,9 @@
<TaskItem
{task}
compact={false}
onOpen={onOpenTask ? () => onOpenTask(task) : undefined}
onToggleComplete={() => tasksStore.toggleComplete(task.id)}
onClick={() => onOpenTask?.(task)}
onContextMenu={() => {}}
/>
</div>
{/each}

View file

@ -25,8 +25,11 @@
let quoteText = $derived(quotesStore.getText(quote));
let showBio = $state(false);
// Get author bio in current language
let authorBioText = $derived(() => {
// Get author bio in current language. `$derived.by` is the variant
// that takes a thunk; plain `$derived(expr)` would have stored the
// arrow function itself, making `authorBioText` always truthy and
// the {#if} below dead.
let authorBioText = $derived.by(() => {
if (!quote.authorBio) return '';
const lang = quotesStore.language === 'original' ? 'de' : quotesStore.language;
return quote.authorBio[lang] || quote.authorBio.de || '';

View file

@ -293,7 +293,7 @@
// ── Sync ────────────────────────────────────────────────
const SYNC_SERVER_URL =
(typeof window !== 'undefined' &&
(window as Record<string, unknown>).__PUBLIC_SYNC_SERVER_URL__) ||
(window as unknown as Record<string, unknown>).__PUBLIC_SYNC_SERVER_URL__) ||
import.meta.env.PUBLIC_SYNC_SERVER_URL ||
'http://localhost:3050';
let unifiedSync: ReturnType<typeof createUnifiedSync> | null = null;

View file

@ -388,14 +388,7 @@
<div class="mb-6">
<label for="rateLimit" class="block text-sm font-medium mb-2">Rate Limit</label>
<div class="flex items-center gap-2">
<Input
type="number"
id="rateLimit"
bind:value={newKeyRateLimit}
min={1}
max={1000}
class="w-24"
/>
<Input type="number" id="rateLimit" bind:value={newKeyRateLimit} class="w-24" />
<span class="text-sm text-muted-foreground">requests per minute</span>
</div>
<p class="mt-1 text-xs text-muted-foreground">

View file

@ -11,7 +11,7 @@
const cityCtx = getContext<{ value: LocalCity | undefined }>('currentCity');
let city = $derived(cityCtx.value);
let citySlug = $derived($page.params.slug ?? '');
let locId = $derived(locId ?? '');
let locId = $derived($page.params.id ?? '');
let loading = $state(true);
let name = $state('');

View file

@ -70,7 +70,7 @@
twitter: contact.twitter,
instagram: contact.instagram,
github: contact.github,
tagIds: ((contact as Record<string, unknown>).tagIds as string[]) ?? [],
tagIds: ((contact as unknown as Record<string, unknown>).tagIds as string[]) ?? [],
};
isEditing = true;
}

View file

@ -437,7 +437,7 @@
<div class="mb-6 rounded-lg bg-surface p-4 text-center">
<p class="text-sm text-muted-foreground">Verfügbare Credits</p>
<p class="text-2xl font-bold text-primary">
{formatCredits(balance.balance + balance.freeCreditsRemaining)}
{formatCredits(balance.balance + (balance.freeCreditsRemaining ?? 0))}
</p>
</div>
{/if}

View file

@ -6,7 +6,7 @@
import { collectionsStore } from '$lib/modules/inventory/stores/collections.svelte';
import { getCollectionById } from '$lib/modules/inventory/queries';
import type { Collection } from '$lib/modules/inventory/queries';
import type { CollectionSchema } from '$lib/modules/inventory/constants';
import type { CollectionSchema, FieldDefinition } from '$lib/modules/inventory/constants';
import SchemaEditor from '$lib/modules/inventory/components/fields/SchemaEditor.svelte';
const collectionsCtx: { readonly value: Collection[] } = getContext('collections');
@ -25,7 +25,11 @@
name = collection.name;
description = collection.description || '';
icon = collection.icon || '';
schema = { fields: [...collection.schema.fields] };
// `collection.schema.fields` round-trips through JSON in the
// Dexie row, which widens `type` to plain `string`. Cast back
// to the FieldDefinition union — the runtime values match the
// FieldType union, the loss is purely at the type layer.
schema = { fields: [...collection.schema.fields] as FieldDefinition[] };
loaded = true;
}
});

View file

@ -67,7 +67,12 @@
);
async function enqueueTaskNow(task: typeof extractDateTask | typeof summarizeTextTask) {
queueLastEnqueuedId = await llmTaskQueue.enqueue(task, { text: queueInput });
// The two LlmTask shapes have different output types but identical
// {text: string} input, so we widen `task` to `any` for the
// enqueue call — TypeScript can't unify the union arg with the
// generic without specializing per task.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queueLastEnqueuedId = await llmTaskQueue.enqueue(task as any, { text: queueInput });
}
// --- Router tab state ---

View file

@ -10,7 +10,8 @@
formatDuration,
getStatusLabel,
} from '$lib/modules/memoro/queries';
import type { Memo, Tag, LocalMemoTag } from '$lib/modules/memoro/types';
import type { Memo, LocalMemoTag } from '$lib/modules/memoro/types';
import type { Tag } from '@mana/shared-tags';
import {
Plus,
MagnifyingGlass,

View file

@ -11,7 +11,8 @@
formatDuration,
getStatusLabel,
} from '$lib/modules/memoro/queries';
import type { Memo, Memory, Tag, LocalMemoTag } from '$lib/modules/memoro/types';
import type { Memo, Memory, LocalMemoTag } from '$lib/modules/memoro/types';
import type { Tag } from '@mana/shared-tags';
import {
ArrowLeft,
Trash,

View file

@ -314,7 +314,8 @@
</div>
{:else}
<div class="card-grid">
{#each ranked as { article } (article.id)}
{#each ranked as scored (scored.article.id)}
{@const article = scored.article}
{@const isSaved = interestedIds.has(article.id)}
<article class="card">
{#if article.imageUrl}

View file

@ -402,7 +402,7 @@
visible={showShare}
onClose={() => (showShare = false)}
url={typeof window !== 'undefined' ? `${window.location.origin}/presi/deck/${deckId}` : ''}
title={currentDeck?.name ?? 'Presentation'}
title={currentDeck?.title ?? 'Presentation'}
source="presi"
description={currentDeck?.description ?? ''}
/>

View file

@ -21,8 +21,10 @@
let searchTerm = $state('');
let sortBy = $state<'default' | 'author'>('default');
// Filtered and sorted quotes
let displayedQuotes = $derived<Quote[]>(() => {
// Filtered and sorted quotes — `$derived.by` is the variant that
// takes a thunk; plain `$derived(expr)` only takes a single
// expression.
let displayedQuotes = $derived.by<Quote[]>(() => {
let filtered = quotes;
// Filter by search

View file

@ -6,7 +6,9 @@
import { authStore } from '$lib/stores/auth.svelte';
async function handleForgotPassword(email: string) {
return authStore.forgotPassword(email);
// resetPassword is the wrapper's "send-the-email" entry point.
// resetPasswordWithToken is the actually-perform-the-reset step.
return authStore.resetPassword(email);
}
</script>

View file

@ -1,6 +1,9 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
// `defineConfig` from vitest/config is a superset of vite's that
// recognizes the `test:` block. Without this, the inline test exclude
// below trips a "test does not exist on UserConfigExport" type error.
import { defineConfig } from 'vitest/config';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import { createPWAConfig } from '@mana/shared-pwa';
import { MANA_SHARED_PACKAGES, getBuildDefines } from '@mana/shared-vite-config';