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>
This commit is contained in:
Till JS 2026-04-09 18:48:28 +02:00
parent 05d9d1962c
commit 697d96daec
6 changed files with 35 additions and 50 deletions

View file

@ -9,23 +9,23 @@
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let loadState = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<CreditBalance | null>(null);
let error = $state<string | null>(null);
let retrying = $state(false);
async function load() {
if (!data) state = 'loading';
if (!data) loadState = 'loading';
retrying = true;
try {
const balance = await creditsService.getBalance();
data = balance;
state = 'success';
loadState = 'success';
} catch (e) {
if (!data) {
error = e instanceof Error ? e.message : 'Failed to load credits';
state = 'error';
loadState = 'error';
}
} finally {
retrying = false;
@ -45,9 +45,9 @@
{$_('dashboard.widgets.credits.title')}
</h3>
{#if state === 'loading'}
{#if loadState === 'loading'}
<WidgetSkeleton lines={3} />
{:else if state === 'error'}
{:else if loadState === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if data}
<div class="space-y-3">

View file

@ -152,14 +152,9 @@
</span>
{/if}
</div>
{#if task.dueTime || getSubtaskProgress(task)}
{#if getSubtaskProgress(task)}
<div class="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
{#if task.dueTime}
<span>{task.dueTime}</span>
{/if}
{#if getSubtaskProgress(task)}
<span>{getSubtaskProgress(task)}</span>
{/if}
<span>{getSubtaskProgress(task)}</span>
</div>
{/if}
</div>

View file

@ -9,22 +9,22 @@
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let loadState = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<CreditTransaction[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
async function load() {
state = 'loading';
loadState = 'loading';
retrying = true;
try {
const transactions = await creditsService.getTransactions(5);
data = transactions;
state = 'success';
loadState = 'success';
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load transactions';
state = 'error';
loadState = 'error';
} finally {
retrying = false;
}
@ -63,9 +63,9 @@
</a>
</div>
{#if state === 'loading'}
{#if loadState === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
{:else if loadState === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if data.length === 0}
<p class="py-4 text-center text-sm text-muted-foreground">

View file

@ -3,23 +3,17 @@
import { buildAchievementStatus, getAchievementStats } from '../stores/achievements.svelte';
import { Trophy, Lightning, Target, Fire, Medal } from '@mana/shared-icons';
// Reactive live queries
// Reactive live queries — useLiveQueryWithDefault wraps Dexie's
// liveQuery and exposes a `.value` getter backed by $state, so we
// just read it inside $derived without manual subscribe plumbing.
const allSkills = useAllSkills();
const allActivities = useAllActivities();
const allAchievementsRaw = useAllAchievements();
let skills = $state<import('../types').Skill[]>([]);
let activities = $state<import('../types').Activity[]>([]);
let achievementsRaw = $state<import('../types').LocalAchievement[]>([]);
$effect(() => {
allSkills.subscribe((v) => (skills = v ?? []));
allActivities.subscribe((v) => (activities = v ?? []));
allAchievementsRaw.subscribe((v) => (achievementsRaw = v ?? []));
});
const userStats = $derived(computeUserStats(skills, activities));
const achievementStats = $derived(getAchievementStats(buildAchievementStatus(achievementsRaw)));
const userStats = $derived(computeUserStats(allSkills.value, allActivities.value));
const achievementStats = $derived(
getAchievementStats(buildAchievementStatus(allAchievementsRaw.value))
);
</script>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">

View file

@ -15,7 +15,9 @@
let creating = $state(false);
let newKeyName = $state('');
let newKeyScopes = $state<{ stt: boolean; tts: boolean }>({ stt: true, tts: true });
let newKeyRateLimit = $state(60);
// Stored as string because the shared <Input> component binds to a
// string value; we parseInt at submit time.
let newKeyRateLimit = $state('60');
let createdKey = $state<ApiKeyWithSecret | null>(null);
let copied = $state(false);
@ -62,21 +64,17 @@
const result = await apiKeysService.create({
name: newKeyName.trim(),
scopes,
rateLimitRequests: newKeyRateLimit,
rateLimitRequests: parseInt(newKeyRateLimit, 10) || 60,
});
if (result.error) {
error = result.error;
} else if (result.data) {
createdKey = result.data;
// Add to list (without the secret key)
apiKeys = [
...apiKeys,
{
...result.data,
key: undefined as unknown as string, // Remove secret from local state
},
];
// Add to list — strip the secret `key` field that only appears on
// creation responses, so the local list matches the ApiKey shape.
const { key: _omit, ...withoutSecret } = result.data;
apiKeys = [...apiKeys, withoutSecret];
}
creating = false;
@ -110,7 +108,7 @@
createdKey = null;
newKeyName = '';
newKeyScopes = { stt: true, tts: true };
newKeyRateLimit = 60;
newKeyRateLimit = '60';
copied = false;
}
@ -186,8 +184,8 @@
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium">{key.name}</span>
<Badge variant="secondary">{key.scopes.join(', ')}</Badge>
<Badge variant="outline">{key.rateLimitRequests}/min</Badge>
<Badge variant="default">{key.scopes.join(', ')}</Badge>
<Badge variant="info">{key.rateLimitRequests}/min</Badge>
</div>
<div
class="flex items-center gap-4 mt-1 text-sm text-muted-foreground flex-wrap"
@ -200,7 +198,7 @@
</div>
</div>
<Button
variant="destructive"
variant="danger"
size="sm"
loading={revoking === key.id}
onclick={() => handleRevoke(key.id)}
@ -240,7 +238,7 @@
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium line-through">{key.name}</span>
<Badge variant="destructive">Revoked</Badge>
<Badge variant="danger">Revoked</Badge>
</div>
<div class="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
<code class="bg-muted px-2 py-0.5 rounded font-mono text-xs"

View file

@ -41,7 +41,7 @@
loading = true;
try {
const result = await authStore.resetPassword(token, password);
const result = await authStore.resetPasswordWithToken(token, password);
if (!result.success) {
error = result.error || 'Failed to reset password';
@ -116,7 +116,6 @@
autocomplete="new-password"
placeholder="Enter new password"
required
minlength={12}
bind:value={password}
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
@ -138,7 +137,6 @@
autocomplete="new-password"
placeholder="Confirm new password"
required
minlength={12}
bind:value={confirmPassword}
/>
</div>