feat(settings): add device-specific settings storage

Implement per-device settings sync via mana-core-auth. Settings are now
stored both locally (localStorage) and in the cloud, with each device
(desktop, mobile, tablet) maintaining its own configuration.

Changes:
- Add deviceSettings JSONB column to user_settings table
- Add device API endpoints (GET/PATCH/DELETE /settings/device/:id/:app)
- Extend user-settings-store with device ID generation and detection
- Integrate calendar settings with cloud sync per device
- Remove todos from calendar header row (sidebar + grid only)
- Add hours dropdown to CalendarHeader for time range configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-11 23:49:18 +01:00
parent 5921cfd257
commit c6f8b9f87c
11 changed files with 863 additions and 416 deletions

View file

@ -5,6 +5,7 @@
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
import type { CalendarViewType } from '@calendar/shared';
import { PillTimeRangeSelector, PillViewSwitcher } from '@manacore/shared-ui';
// View type labels
const viewLabels: Record<CalendarViewType, string> = {
@ -29,6 +30,22 @@
'year',
];
// Convert to ViewOptions for PillViewSwitcher
const viewOptions = visibleViews.map((type) => ({
id: type,
label: viewLabels[type],
title: viewLabels[type],
}));
// Hours change handlers
function handleStartHourChange(hour: number) {
settingsStore.set('dayStartHour', hour);
}
function handleEndHourChange(hour: number) {
settingsStore.set('dayEndHour', hour);
}
// Format title based on view type
let title = $derived.by(() => {
const date = viewStore.currentDate;
@ -70,18 +87,24 @@
}
});
function handleViewChange(type: CalendarViewType) {
viewStore.setViewType(type);
function handleViewChange(type: string) {
viewStore.setViewType(type as CalendarViewType);
}
</script>
<header class="calendar-header" class:nav-collapsed={$isNavCollapsed}>
<div class="header-left">
<button class="today-btn" onclick={() => viewStore.goToToday()}> Heute </button>
<button
class="pill glass-pill today-btn"
onclick={() => viewStore.goToToday()}
title="Zum heutigen Tag springen"
>
Heute
</button>
<div class="nav-buttons">
<div class="nav-buttons glass-pill">
<button class="nav-btn" onclick={() => viewStore.goToPrevious()} aria-label="Zurück">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -90,8 +113,9 @@
/>
</svg>
</button>
<div class="nav-divider"></div>
<button class="nav-btn" onclick={() => viewStore.goToNext()} aria-label="Weiter">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
@ -101,11 +125,11 @@
</div>
<div class="header-right">
<!-- Filter toggles -->
<div class="filter-toggles">
<!-- Filter toggles as pills -->
<div class="filter-pills">
<!-- Weekdays only toggle -->
<button
class="filter-toggle"
class="pill glass-pill filter-pill"
class:active={settingsStore.showOnlyWeekdays}
onclick={() => settingsStore.set('showOnlyWeekdays', !settingsStore.showOnlyWeekdays)}
title="Nur Wochentage anzeigen (Mo-Fr)"
@ -113,28 +137,40 @@
Mo-Fr
</button>
<!-- Filter hours toggle -->
<!-- Hours filter toggle -->
<button
class="filter-toggle"
class="pill glass-pill filter-pill"
class:active={settingsStore.filterHoursEnabled}
onclick={() => settingsStore.set('filterHoursEnabled', !settingsStore.filterHoursEnabled)}
title="Stunden filtern ({settingsStore.dayStartHour}-{settingsStore.dayEndHour} Uhr)"
title="Stundenfilter ein/aus"
>
{settingsStore.dayStartHour}-{settingsStore.dayEndHour}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<!-- Hours time range selector -->
<PillTimeRangeSelector
startHour={settingsStore.dayStartHour}
endHour={settingsStore.dayEndHour}
onStartHourChange={handleStartHourChange}
onEndHourChange={handleEndHourChange}
direction="down"
/>
</div>
<div class="view-selector">
{#each visibleViews as type}
<button
class="view-btn"
class:active={viewStore.viewType === type}
onclick={() => handleViewChange(type)}
>
{viewLabels[type]}
</button>
{/each}
</div>
<!-- View selector -->
<PillViewSwitcher
options={viewOptions}
value={viewStore.viewType}
onChange={handleViewChange}
primaryColor="#3b82f6"
/>
</div>
</header>
@ -144,9 +180,10 @@
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: hsl(var(--color-background));
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
background: transparent;
transition: padding-left 300ms ease;
gap: 1rem;
flex-wrap: wrap;
}
.calendar-header.nav-collapsed {
@ -156,132 +193,188 @@
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.today-btn {
padding: 0.25rem 0.625rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-foreground));
cursor: pointer;
transition: all 150ms ease;
}
.today-btn:hover {
background: hsl(var(--color-muted));
}
.nav-buttons {
display: flex;
gap: 0.125rem;
}
.nav-btn {
padding: 0.25rem;
border: none;
background: transparent;
border-radius: var(--radius-sm);
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 150ms ease;
display: flex;
align-items: center;
justify-content: center;
}
.nav-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
gap: 0.75rem;
}
.header-title {
font-size: 1.25rem;
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0;
white-space: nowrap;
}
.header-right {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.view-selector {
/* Glass pill base styles */
.pill {
display: flex;
background: hsl(var(--color-muted));
border-radius: var(--radius-md);
padding: 0.125rem;
}
.view-btn {
padding: 0.25rem 0.625rem;
border: none;
background: transparent;
border-radius: var(--radius-sm);
font-size: 0.75rem;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
white-space: nowrap;
text-decoration: none;
transition: all 0.2s;
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.view-btn:hover {
color: hsl(var(--color-foreground));
.glass-pill {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
color: #374151;
}
.view-btn.active {
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
:global(.dark) .glass-pill {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #f3f4f6;
}
.filter-toggles {
.glass-pill:hover {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
:global(.dark) .glass-pill:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
}
/* Today button */
.today-btn {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
/* Navigation buttons group */
.nav-buttons {
display: flex;
gap: 0.25rem;
align-items: center;
padding: 0;
gap: 0;
}
.filter-toggle {
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--color-border));
.nav-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0.5rem;
background: transparent;
border-radius: var(--radius-sm);
font-size: 0.6875rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
border: none;
cursor: pointer;
transition: all 150ms ease;
color: inherit;
transition: background 0.2s;
}
.filter-toggle:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
.nav-btn:first-child {
border-radius: 9999px 0 0 9999px;
}
.filter-toggle.active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
.nav-btn:last-child {
border-radius: 0 9999px 9999px 0;
}
@media (max-width: 640px) {
.nav-btn:hover {
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .nav-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.nav-icon {
width: 1rem;
height: 1rem;
}
.nav-divider {
width: 1px;
height: 1rem;
background: rgba(0, 0, 0, 0.15);
}
:global(.dark) .nav-divider {
background: rgba(255, 255, 255, 0.2);
}
/* Filter pills */
.filter-pills {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-pill {
padding: 0.375rem 0.625rem;
font-size: 0.75rem;
}
.filter-pill.active {
background: color-mix(in srgb, #3b82f6 20%, white 80%);
border-color: #3b82f6;
color: #3b82f6;
}
:global(.dark) .filter-pill.active {
background: color-mix(in srgb, #3b82f6 30%, transparent 70%);
border-color: #3b82f6;
color: #3b82f6;
}
.pill-icon {
width: 0.875rem;
height: 0.875rem;
}
/* Responsive */
@media (max-width: 900px) {
.calendar-header {
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
}
.header-left {
width: 100%;
justify-content: space-between;
justify-content: flex-start;
}
.header-right {
width: 100%;
justify-content: flex-start;
}
.header-title {
font-size: 1rem;
}
}
@media (max-width: 640px) {
.header-title {
font-size: 0.875rem;
}
.filter-pills {
flex-wrap: wrap;
}
}
</style>

View file

@ -4,7 +4,6 @@
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { todosStore, type Task } from '$lib/stores/todos.svelte';
import TodoRow from './TodoRow.svelte';
import TaskBlock from './TaskBlock.svelte';
import { goto } from '$app/navigation';
import {
@ -688,16 +687,6 @@
</div>
{/if}
<!-- Todos section -->
{#if todosStore.serviceAvailable && todosStore.getTodosForDay(viewStore.currentDate).length > 0}
<div class="todos-section">
<div class="time-gutter"></div>
<div class="todos-content">
<TodoRow date={viewStore.currentDate} maxVisible={4} />
</div>
</div>
{/if}
<!-- Time grid -->
<div class="time-grid scrollbar-thin">
<div class="time-column">
@ -857,16 +846,6 @@
cursor: pointer;
}
/* Todos section */
.todos-section {
display: flex;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
}
.todos-content {
flex: 1;
}
/* Block-style all-day events (displayed as full-day blocks in the grid) */
.all-day-block-event {
position: absolute;

View file

@ -4,7 +4,6 @@
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { todosStore, type Task } from '$lib/stores/todos.svelte';
import TodoRow from './TodoRow.svelte';
import TaskBlock from './TaskBlock.svelte';
import { goto } from '$app/navigation';
import {
@ -830,18 +829,6 @@
</div>
{/if}
<!-- Todos row (shown per day, below all-day events) -->
{#if todosStore.serviceAvailable}
<div class="todos-row">
<div class="time-gutter"></div>
{#each days as day}
<div class="todos-cell">
<TodoRow date={day} maxVisible={2} />
</div>
{/each}
</div>
{/if}
<!-- Day headers -->
<div class="day-headers">
<div class="time-gutter"></div>
@ -1043,18 +1030,6 @@
cursor: pointer;
}
/* Todos row */
.todos-row {
display: flex;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
}
.todos-cell {
flex: 1;
border-left: 1px solid hsl(var(--color-border));
min-height: 0;
}
/* Block-style all-day events (displayed as full-day blocks in the grid) */
.all-day-block-event {
position: absolute;

View file

@ -1,10 +1,13 @@
/**
* Settings Store - Manages user preferences for the calendar app
* Uses Svelte 5 runes and localStorage for persistence
* Uses Svelte 5 runes with:
* - localStorage for immediate persistence
* - userSettings store for cloud sync (device-specific)
*/
import { browser } from '$app/environment';
import type { CalendarViewType } from '@calendar/shared';
import { userSettings } from './user-settings.svelte';
// Settings types
export type WeekStartDay = 0 | 1; // 0 = Sunday, 1 = Monday
@ -78,6 +81,34 @@ function saveSettings(settings: CalendarAppSettings) {
// State
let settings = $state<CalendarAppSettings>(loadSettings());
let cloudSyncEnabled = $state(false);
let initialSyncDone = $state(false);
/**
* Sync settings to cloud (device-specific)
*/
async function syncToCloud() {
if (!cloudSyncEnabled || !browser) return;
try {
await userSettings.updateDeviceAppSettings(settings as unknown as Record<string, unknown>);
} catch (e) {
console.error('Failed to sync calendar settings to cloud:', e);
}
}
/**
* Load settings from cloud (device-specific)
*/
function loadFromCloud(): Partial<CalendarAppSettings> | null {
if (!userSettings.loaded) return null;
const cloudSettings = userSettings.currentDeviceAppSettings;
if (cloudSettings && Object.keys(cloudSettings).length > 0) {
return cloudSettings as unknown as Partial<CalendarAppSettings>;
}
return null;
}
export const settingsStore = {
// Getters
@ -120,6 +151,36 @@ export const settingsStore = {
get sidebarCollapsed() {
return settings.sidebarCollapsed;
},
get cloudSyncEnabled() {
return cloudSyncEnabled;
},
/**
* Enable cloud sync and load settings from cloud
*/
enableCloudSync() {
cloudSyncEnabled = true;
// On first sync, prefer cloud settings over local if they exist
if (!initialSyncDone) {
const cloudSettings = loadFromCloud();
if (cloudSettings && Object.keys(cloudSettings).length > 0) {
settings = { ...DEFAULT_SETTINGS, ...settings, ...cloudSettings };
saveSettings(settings);
} else {
// No cloud settings yet, push local settings to cloud
syncToCloud();
}
initialSyncDone = true;
}
},
/**
* Disable cloud sync
*/
disableCloudSync() {
cloudSyncEnabled = false;
},
/**
* Toggle sidebar collapsed state
@ -127,6 +188,7 @@ export const settingsStore = {
toggleSidebar() {
settings = { ...settings, sidebarCollapsed: !settings.sidebarCollapsed };
saveSettings(settings);
syncToCloud();
},
/**
@ -143,6 +205,7 @@ export const settingsStore = {
set<K extends keyof CalendarAppSettings>(key: K, value: CalendarAppSettings[K]) {
settings = { ...settings, [key]: value };
saveSettings(settings);
syncToCloud();
},
/**
@ -151,6 +214,7 @@ export const settingsStore = {
update(updates: Partial<CalendarAppSettings>) {
settings = { ...settings, ...updates };
saveSettings(settings);
syncToCloud();
},
/**
@ -159,6 +223,7 @@ export const settingsStore = {
reset() {
settings = { ...DEFAULT_SETTINGS };
saveSettings(settings);
syncToCloud();
},
/**

View file

@ -5,6 +5,8 @@
import { onMount } from 'svelte';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import ToastContainer from '$lib/components/ToastContainer.svelte';
import { AppLoadingSkeleton } from '$lib/components/skeletons';
@ -24,6 +26,18 @@
loading = false;
});
// Load user settings when authenticated
$effect(() => {
if (authStore.isAuthenticated) {
userSettings.load().then(() => {
// Enable cloud sync for calendar settings after user settings are loaded
settingsStore.enableCloudSync();
});
} else {
settingsStore.disableCloudSync();
}
});
</script>
<ToastContainer />