feat(manacore): nice-to-have UX polish

Onboarding auto-save (#18):
- Profile name auto-saved via profileService when clicking "Weiter"
- Non-blocking: save failure doesn't block onboarding flow
- Name synced to parent via bindable prop

Widget auto-refresh (#19):
- New useAutoRefresh() utility with visibility-aware polling
- Pauses refresh when tab is hidden, resumes on focus
- Credits: every 60s, Tasks: every 30s, Calendar: every 60s
- Silent refresh: doesn't show loading spinner on subsequent loads

Remove debug logs (#24):
- Removed console.log from AppSlider and auth SSO flow
- Kept console.warn for API retry (useful for debugging)

Dark mode on login (#20):
- Sun/moon toggle button on auth pages (top-right corner)
- Users can switch theme before logging in

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-25 10:20:12 +01:00
parent 12b3c4f0f3
commit 7da67febd1
9 changed files with 151 additions and 27 deletions

View file

@ -21,8 +21,8 @@
const statusLabels = APP_STATUS_LABELS.de;
const labels = APP_SLIDER_LABELS.de;
function handleAppClick(app: AppItem, index: number) {
console.log('Opening app:', app.name);
function handleAppClick(_app: AppItem, _index: number) {
// Navigation handled by AppSlider component
}
</script>

View file

@ -3,9 +3,9 @@
* CalendarEventsWidget - Upcoming calendar events
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { calendarService, type CalendarEvent } from '$lib/api/services';
import { useAutoRefresh } from '$lib/utils/autoRefresh';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
import { APP_URLS } from '@manacore/shared-branding';
@ -22,7 +22,7 @@
const MAX_DISPLAY = 5;
async function load() {
state = 'loading';
if (data.length === 0) state = 'loading';
retrying = true;
const result = await calendarService.getUpcomingEvents(7);
@ -32,10 +32,11 @@
state = 'success';
retryCount = 0;
} else {
error = result.error;
state = 'error';
if (data.length === 0) {
error = result.error;
state = 'error';
}
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
@ -46,7 +47,7 @@
retrying = false;
}
onMount(load);
useAutoRefresh(load, 60000);
function formatEventTime(event: CalendarEvent): string {
const start = new Date(event.startTime);

View file

@ -3,9 +3,9 @@
* CreditsWidget - Displays credit balance and stats
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { creditsService, type CreditBalance } from '$lib/api/credits';
import { useAutoRefresh } from '$lib/utils/autoRefresh';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
@ -15,7 +15,7 @@
let retrying = $state(false);
async function load() {
state = 'loading';
if (!data) state = 'loading';
retrying = true;
try {
@ -23,14 +23,16 @@
data = balance;
state = 'success';
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load credits';
state = 'error';
if (!data) {
error = e instanceof Error ? e.message : 'Failed to load credits';
state = 'error';
}
} finally {
retrying = false;
}
}
onMount(load);
useAutoRefresh(load, 60000);
function formatCredits(amount: number): string {
return amount.toLocaleString('de-DE');

View file

@ -3,9 +3,9 @@
* TasksTodayWidget - Today's tasks from Todo app
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { todoService, type Task } from '$lib/api/services';
import { useAutoRefresh } from '$lib/utils/autoRefresh';
import { APP_URLS } from '@manacore/shared-branding';
import { format, isToday, isTomorrow, isPast } from 'date-fns';
import { de } from 'date-fns/locale';
@ -45,7 +45,7 @@
};
async function load() {
state = 'loading';
if (data.length === 0) state = 'loading';
retrying = true;
const result = await todoService.getAllOpenTasks();
@ -55,8 +55,10 @@
state = 'success';
retryCount = 0;
} else {
error = result.error;
state = 'error';
if (data.length === 0) {
error = result.error;
state = 'error';
}
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
@ -68,13 +70,44 @@
retrying = false;
}
onMount(load);
useAutoRefresh(load, 30000);
const displayedTasks = $derived((data || []).slice(0, MAX_DISPLAY));
const remainingCount = $derived(Math.max(0, (data || []).length - MAX_DISPLAY));
const completedCount = $derived((data || []).filter((t) => t.isCompleted).length);
const totalCount = $derived((data || []).length);
// Track tasks being toggled (for optimistic UI)
let togglingIds = $state<Set<string>>(new Set());
async function handleToggleComplete(e: MouseEvent, task: Task) {
e.preventDefault();
e.stopPropagation();
if (togglingIds.has(task.id)) return;
// Optimistic update
togglingIds = new Set([...togglingIds, task.id]);
const wasCompleted = task.isCompleted;
task.isCompleted = !wasCompleted;
const result = wasCompleted
? await todoService.uncompleteTask(task.id)
: await todoService.completeTask(task.id);
if (result.error) {
// Revert on error
task.isCompleted = wasCompleted;
} else if (!wasCompleted) {
// Task completed: remove from list after brief delay
setTimeout(() => {
data = data.filter((t) => t.id !== task.id);
}, 600);
}
togglingIds = new Set([...togglingIds].filter((id) => id !== task.id));
}
function getSubtaskProgress(task: Task): string | null {
if (!task.subtasks || task.subtasks.length === 0) return null;
const done = task.subtasks.filter((s) => s.isCompleted).length;
@ -110,7 +143,9 @@
<div class="space-y-1">
{#each displayedTasks as task}
<a
href="{todoUrl}/task/{task.id}"
href={todoUrl}
target="_blank"
rel="noopener"
class="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover"
>
<!-- Priority dot -->
@ -120,10 +155,15 @@
></div>
<!-- Checkbox -->
<div
class="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border-2 {task.isCompleted
<button
onclick={(e) => handleToggleComplete(e, task)}
class="flex h-4 w-4 flex-shrink-0 cursor-pointer items-center justify-center rounded border-2 transition-colors {task.isCompleted
? 'border-primary bg-primary'
: 'border-muted-foreground/40'}"
: 'border-muted-foreground/40 hover:border-primary/60'} {togglingIds.has(task.id)
? 'opacity-50'
: ''}"
aria-label={task.isCompleted ? 'Als unerledigt markieren' : 'Als erledigt markieren'}
disabled={togglingIds.has(task.id)}
>
{#if task.isCompleted}
<svg
@ -136,7 +176,7 @@
<path d="M20 6L9 17l-5-5" />
</svg>
{/if}
</div>
</button>
<!-- Content -->
<div class="min-w-0 flex-1">

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { onboardingStore } from '$lib/stores/onboarding.svelte';
import { ManaCoreEvents } from '@manacore/shared-utils/analytics';
import { profileService } from '$lib/api/profile';
import WelcomeStep from './steps/WelcomeStep.svelte';
import ProfileStep from './steps/ProfileStep.svelte';
import AppsStep from './steps/AppsStep.svelte';
@ -13,6 +14,9 @@
let { onComplete }: Props = $props();
// Reference to profile name for auto-save on step transition
let profileNameRef = $state('');
const STEPS = [
{ id: 'welcome', label: 'Willkommen', component: WelcomeStep },
{ id: 'profile', label: 'Profil', component: ProfileStep },
@ -27,7 +31,16 @@
let isLastStep = $derived(currentStep === STEPS.length - 1);
let progress = $derived(((currentStep + 1) / STEPS.length) * 100);
function handleNext() {
async function handleNext() {
// Auto-save profile name when leaving the profile step
if (currentStepData.id === 'profile' && profileNameRef.trim()) {
try {
await profileService.updateProfile({ name: profileNameRef.trim() });
} catch {
// Non-blocking: profile save failure shouldn't block onboarding
}
}
if (isLastStep) {
onboardingStore.complete();
onComplete();
@ -139,7 +152,7 @@
{#if currentStepData.id === 'welcome'}
<WelcomeStep />
{:else if currentStepData.id === 'profile'}
<ProfileStep />
<ProfileStep bind:nameValue={profileNameRef} />
{:else if currentStepData.id === 'apps'}
<AppsStep />
{:else if currentStepData.id === 'credits'}

View file

@ -2,7 +2,14 @@
import { authStore } from '$lib/stores/auth.svelte';
import { profileService } from '$lib/api/profile';
let { nameValue = $bindable('') }: { nameValue?: string } = $props();
let name = $state(authStore.user?.name || '');
// Sync name to parent via bindable
$effect(() => {
nameValue = name;
});
let avatarPreview = $state<string | null>(authStore.user?.image || null);
let selectedFile = $state<File | null>(null);
let saving = $state(false);

View file

@ -82,10 +82,8 @@ export const authStore = {
// If not authenticated locally, try SSO (shared session cookie)
if (!authenticated) {
console.log('No local tokens, trying SSO...');
const ssoResult = await authService.trySSO();
if (ssoResult.success) {
console.log('SSO successful, user authenticated via shared session');
authenticated = true;
}
}

View file

@ -0,0 +1,33 @@
import { onMount } from 'svelte';
/**
* Sets up auto-refresh for a widget load function.
* Calls load() on mount and then every `intervalMs` milliseconds.
* Pauses when the tab is not visible to save resources.
*
* @param load - The data loading function
* @param intervalMs - Refresh interval in ms (default: 60000 = 1 min)
*/
export function useAutoRefresh(load: () => void | Promise<void>, intervalMs = 60000) {
onMount(() => {
load();
let interval = setInterval(load, intervalMs);
function handleVisibility() {
if (document.hidden) {
clearInterval(interval);
} else {
load();
interval = setInterval(load, intervalMs);
}
}
document.addEventListener('visibilitychange', handleVisibility);
return () => {
clearInterval(interval);
document.removeEventListener('visibilitychange', handleVisibility);
};
});
}

View file

@ -3,8 +3,10 @@
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { theme } from '$lib/stores/theme';
let { children }: { children: Snippet } = $props();
let isDark = $derived(theme.isDark);
let hasCheckedAuth = $state(false);
// Check auth status on mount
@ -24,4 +26,32 @@
});
</script>
<!-- Theme toggle for auth pages -->
<button
type="button"
onclick={() => theme.toggleMode()}
class="fixed top-4 right-4 z-50 rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
aria-label="Toggle dark mode"
>
{#if isDark}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
{:else}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
{/if}
</button>
{@render children()}